cli: improve doctor auth diagnostics
This commit is contained in:
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user