cli: improve doctor auth diagnostics

This commit is contained in:
William Valentin
2026-02-15 10:43:21 -08:00
parent 7627e6e630
commit 60e30a8138
2 changed files with 265 additions and 19 deletions
+76
View File
@@ -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;
}
}
});
});
+189 -19
View File
@@ -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<string, string> = {
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<string, unknown>): 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<string, unknown> => {
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<string, unknown> : {};
} 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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>): { 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<string, string> = {
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<string, unknown> }> = [
{ tier: 'default', cfg: models.default as unknown as Record<string, unknown> },
{ tier: 'fast', cfg: models.fast as unknown as Record<string, unknown> | undefined },
{ tier: 'complex', cfg: models.complex as unknown as Record<string, unknown> | undefined },
{ tier: 'local', cfg: models.local as unknown as Record<string, unknown> | 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) => {