From 60e30a8138fb4348656fbaaf269855a3bfee0cbe Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 15 Feb 2026 10:43:21 -0800 Subject: [PATCH] cli: improve doctor auth diagnostics --- src/cli/doctor.test.ts | 76 +++++++++++++++ src/cli/doctor.ts | 208 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 265 insertions(+), 19 deletions(-) diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts index 0dd0cbf..0b4879d 100644 --- a/src/cli/doctor.test.ts +++ b/src/cli/doctor.test.ts @@ -292,4 +292,80 @@ skills: expect(skillsCheck?.status).toBe('pass'); expect(skillsCheck?.detail).toContain('1 skill(s), 1 available, 0 unavailable'); }); + + it('reports FAIL when OpenAI auth_mode=api_key has no available key sources', async () => { + const originalHome = process.env.HOME; + const originalKey = process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_KEY; + + const homeDir = join(tmpdir(), `flynn-test-doctor-home-${Date.now()}`); + process.env.HOME = homeDir; + + try { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'openai-missing.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: openai + model: gpt-4o + auth_mode: api_key +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + const modelCheck = results.find(r => r.label.includes('Model connectivity')) as CheckResult | undefined; + expect(modelCheck?.status).toBe('fail'); + expect(modelCheck?.detail).toContain('OPENAI_API_KEY'); + expect(modelCheck?.detail).toContain('flynn openai-key'); + } finally { + process.env.HOME = originalHome; + if (originalKey) { + process.env.OPENAI_API_KEY = originalKey; + } else { + delete process.env.OPENAI_API_KEY; + } + } + }); + + it('reports FAIL when Anthropic auth_mode=oauth has no available auth token sources', async () => { + const originalHome = process.env.HOME; + const originalToken = process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_AUTH_TOKEN; + + const homeDir = join(tmpdir(), `flynn-test-doctor-home-${Date.now()}-2`); + process.env.HOME = homeDir; + + try { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'anthropic-missing-token.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet + auth_mode: oauth +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + const modelCheck = results.find(r => r.label.includes('Model connectivity')) as CheckResult | undefined; + expect(modelCheck?.status).toBe('fail'); + expect(modelCheck?.detail).toContain('ANTHROPIC_AUTH_TOKEN'); + expect(modelCheck?.detail).toContain('flynn anthropic-auth --token'); + } finally { + process.env.HOME = originalHome; + if (originalToken) { + process.env.ANTHROPIC_AUTH_TOKEN = originalToken; + } else { + delete process.env.ANTHROPIC_AUTH_TOKEN; + } + } + }); }); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 769ec14..da65f09 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -135,30 +135,200 @@ const checkModelConnectivity: Check = async (ctx) => { return { status: 'fail', label: 'Model connectivity', detail: 'no default model configured' }; } - // Check if API key is present for providers that need one - const needsKey = ['anthropic', 'openai', 'gemini', 'openrouter']; - const openaiUsingOAuth = model.provider === 'openai' && Boolean((model as unknown as { use_oauth?: boolean }).use_oauth); - if (needsKey.includes(model.provider) && !openaiUsingOAuth && !model.api_key && !model.auth_token) { - const envVarMap: Record = { - anthropic: 'ANTHROPIC_API_KEY', - openai: 'OPENAI_API_KEY', - openrouter: 'OPENROUTER_API_KEY', - }; - const envVar = envVarMap[model.provider]; - const hasEnv = envVar && process.env[envVar]; - if (!hasEnv) { - return { status: 'warn', label: 'Model connectivity', detail: `${model.provider}/${model.model} — no API key or auth token found` }; + type AuthMode = 'auto' | 'api_key' | 'oauth'; + + const getEffectiveAuthMode = (cfg: Record): AuthMode => { + const mode = cfg.auth_mode; + if (mode === 'auto' || mode === 'api_key' || mode === 'oauth') { + return mode; } + const useOauth = Boolean(cfg.use_oauth); + if (useOauth) { + return 'oauth'; + } + return 'auto'; + }; + + const loadAuthStore = (): Record => { + const home = process.env.HOME ?? homedir(); + const authFile = resolve(home, '.config/flynn/auth.json'); + try { + const raw = readFileSync(authFile, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + return (parsed && typeof parsed === 'object') ? parsed as Record : {}; + } catch { + return {}; + } + }; + + const store = loadAuthStore(); + + const storeOpenAIOAuthPresent = (): boolean => { + const openai = store.openai as unknown; + if (!openai || typeof openai !== 'object') { + return false; + } + const o = openai as Record; + // Legacy: auth.json.openai = { access_token, refresh_token, ... } + if (typeof o.access_token === 'string' && typeof o.refresh_token === 'string') { + return true; + } + const oauth = o.oauth; + return Boolean( + oauth + && typeof oauth === 'object' + && typeof (oauth as any).access_token === 'string' + && typeof (oauth as any).refresh_token === 'string', + ); + }; + + const storeOpenAIApiKeyPresent = (): boolean => { + const openai = store.openai as unknown; + if (!openai || typeof openai !== 'object') { + return false; + } + const o = openai as Record; + const apiKey = o.api_key; + return Boolean( + apiKey + && typeof apiKey === 'object' + && typeof (apiKey as any).api_key === 'string' + && (apiKey as any).api_key.length > 0, + ); + }; + + const storeAnthropicApiKeyPresent = (): boolean => { + const anthropic = store.anthropic as any; + return Boolean(anthropic && typeof anthropic.api_key === 'string' && anthropic.api_key.length > 0); + }; + + const storeAnthropicAuthTokenPresent = (): boolean => { + const anthropic = store.anthropic as any; + return Boolean(anthropic && typeof anthropic.auth_token === 'string' && anthropic.auth_token.length > 0); + }; + + const formatSources = (sources: { config: boolean; env: boolean; store: boolean }): string => { + const parts: string[] = []; + if (sources.config) {parts.push('config');} + if (sources.env) {parts.push('env');} + if (sources.store) {parts.push('store');} + return parts.length > 0 ? parts.join('+') : 'missing'; + }; + + const checkTierAuth = (tier: string, cfg: Record): { status: 'pass' | 'warn' | 'fail'; detail?: string } => { + const provider = String(cfg.provider ?? ''); + const modelName = String(cfg.model ?? ''); + const authMode = getEffectiveAuthMode(cfg); + + if (provider === 'openai') { + if (authMode === 'oauth') { + const ok = storeOpenAIOAuthPresent(); + if (!ok) { + const status = tier === 'default' ? 'fail' : 'warn'; + return { status, detail: `${tier}: openai/${modelName} (auth_mode=oauth, oauth=missing — run flynn openai-auth)` }; + } + return { status: 'pass', detail: `${tier}: openai/${modelName} (auth_mode=oauth, oauth=store)` }; + } + + const sources = { + config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0, + env: typeof process.env.OPENAI_API_KEY === 'string' && process.env.OPENAI_API_KEY.length > 0, + store: storeOpenAIApiKeyPresent(), + }; + + const ok = sources.config || sources.env || sources.store; + if (!ok) { + const status = (authMode === 'api_key' && tier === 'default') ? 'fail' : 'warn'; + const hint = authMode === 'api_key' + ? 'Set OPENAI_API_KEY or run flynn openai-key' + : 'Set OPENAI_API_KEY (or run flynn openai-key) or run flynn openai-auth'; + return { status, detail: `${tier}: openai/${modelName} (auth_mode=${authMode}, api_key=${formatSources(sources)} — ${hint})` }; + } + return { status: 'pass', detail: `${tier}: openai/${modelName} (auth_mode=${authMode}, api_key=${formatSources(sources)})` }; + } + + if (provider === 'anthropic') { + if (authMode === 'oauth') { + const sources = { + config: typeof cfg.auth_token === 'string' && (cfg.auth_token as string).length > 0, + env: typeof process.env.ANTHROPIC_AUTH_TOKEN === 'string' && process.env.ANTHROPIC_AUTH_TOKEN.length > 0, + store: storeAnthropicAuthTokenPresent(), + }; + const ok = sources.config || sources.env || sources.store; + if (!ok) { + const status = tier === 'default' ? 'fail' : 'warn'; + return { status, detail: `${tier}: anthropic/${modelName} (auth_mode=oauth, auth_token=${formatSources(sources)} — set ANTHROPIC_AUTH_TOKEN or run flynn anthropic-auth --token)` }; + } + return { status: 'pass', detail: `${tier}: anthropic/${modelName} (auth_mode=oauth, auth_token=${formatSources(sources)})` }; + } + + const sources = { + config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0, + env: typeof process.env.ANTHROPIC_API_KEY === 'string' && process.env.ANTHROPIC_API_KEY.length > 0, + store: storeAnthropicApiKeyPresent(), + }; + + const ok = sources.config || sources.env || sources.store; + if (!ok) { + const status = (authMode === 'api_key' && tier === 'default') ? 'fail' : 'warn'; + const hint = authMode === 'api_key' + ? 'Set ANTHROPIC_API_KEY or run flynn anthropic-auth' + : 'Set ANTHROPIC_API_KEY (or run flynn anthropic-auth) or set ANTHROPIC_AUTH_TOKEN (or run flynn anthropic-auth --token)'; + return { status, detail: `${tier}: anthropic/${modelName} (auth_mode=${authMode}, api_key=${formatSources(sources)} — ${hint})` }; + } + return { status: 'pass', detail: `${tier}: anthropic/${modelName} (auth_mode=${authMode}, api_key=${formatSources(sources)})` }; + } + + // Providers with API-key style auth (no auth store integration yet) + const needsKey = ['gemini', 'openrouter', 'xai', 'github']; + if (needsKey.includes(provider)) { + const envVarMap: Record = { + gemini: 'GEMINI_API_KEY', + openrouter: 'OPENROUTER_API_KEY', + xai: 'XAI_API_KEY', + github: 'GITHUB_TOKEN', + }; + const envVar = envVarMap[provider]; + const sources = { + config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0, + env: Boolean(envVar && process.env[envVar] && process.env[envVar]!.length > 0), + store: false, + }; + const ok = sources.config || sources.env; + if (!ok) { + const status = tier === 'default' ? 'warn' : 'warn'; + const hint = envVar ? `set ${envVar} or provide api_key in config` : 'provide api_key in config'; + return { status, detail: `${tier}: ${provider}/${modelName} (api_key=${formatSources(sources)} — ${hint})` }; + } + return { status: 'pass', detail: `${tier}: ${provider}/${modelName} (api_key=${formatSources(sources)})` }; + } + + return { status: 'pass', detail: `${tier}: ${provider}/${modelName}` }; + }; + + const tierEntries: Array<{ tier: string; cfg?: Record }> = [ + { tier: 'default', cfg: models.default as unknown as Record }, + { tier: 'fast', cfg: models.fast as unknown as Record | undefined }, + { tier: 'complex', cfg: models.complex as unknown as Record | undefined }, + { tier: 'local', cfg: models.local as unknown as Record | undefined }, + ]; + + const details: string[] = []; + let hasFail = false; + let hasWarn = false; + for (const { tier, cfg } of tierEntries) { + if (!cfg) {continue;} + const r = checkTierAuth(tier, cfg); + if (r.detail) {details.push(r.detail);} + if (r.status === 'fail') {hasFail = true;} + if (r.status === 'warn') {hasWarn = true;} } // Build a summary of the model stack - const parts = [`default: ${model.provider}/${model.model}`]; - if (models.fast) {parts.push(`fast: ${models.fast.provider}/${models.fast.model}`);} - if (models.complex) {parts.push(`complex: ${models.complex.provider}/${models.complex.model}`);} - if (models.local) {parts.push(`local: ${models.local.provider}/${models.local.model}`);} - parts.push(`fallback: [${models.fallback_chain.join(', ')}]`); + details.push(`fallback: [${models.fallback_chain.join(', ')}]`); - return { status: 'pass', label: 'Model connectivity', detail: parts.join(', ') }; + const status: CheckResult['status'] = hasFail ? 'fail' : hasWarn ? 'warn' : 'pass'; + return { status, label: 'Model connectivity', detail: details.join(', ') }; }; const checkTelegram: Check = async (ctx) => {