From a5bfcbbadf8e9a1fd42f3e103d295524b8da64b0 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Tue, 21 Apr 2026 23:37:04 +0800 Subject: [PATCH] feat(provider): zero-config autodetection primitive (#784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-run users with a credential already exported (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) currently still have to navigate the provider picker or set CLAUDE_CODE_USE_* flags manually. Selecting the right provider from ambient state should be automatic. New module src/utils/providerAutoDetect.ts: - detectProviderFromEnv() — synchronous env scan in a deterministic priority order (anthropic → codex → github → openai → gemini → mistral → minimax). Also detects Codex via ~/.codex/auth.json presence. - detectLocalService() — parallel probes for Ollama (:11434) and LM Studio (:1234), with honoring of OLLAMA_BASE_URL / LM_STUDIO_BASE_URL overrides. Short 1.2s default timeout so first-run latency stays low when no local service is running. - detectBestProvider() — orchestrator. Env scan short-circuits the probe; only hits the network when env has nothing. All detection paths are side-effect-free: returns a DetectedProvider descriptor describing what was found and why. Callers decide whether to apply it (gated on hasExplicitProviderSelection() / profile file existence) and how to hydrate the launch env. Codex auth-file check is injectable (hasCodexAuth option) so tests are hermetic from the dev machine's ~/.codex/auth.json state. Co-authored-by: OpenClaude --- src/utils/providerAutoDetect.test.ts | 299 +++++++++++++++++++++++++++ src/utils/providerAutoDetect.ts | 283 +++++++++++++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 src/utils/providerAutoDetect.test.ts create mode 100644 src/utils/providerAutoDetect.ts diff --git a/src/utils/providerAutoDetect.test.ts b/src/utils/providerAutoDetect.test.ts new file mode 100644 index 00000000..4fd365b9 --- /dev/null +++ b/src/utils/providerAutoDetect.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, test } from 'bun:test' + +import { + detectBestProvider, + detectLocalService, + detectProviderFromEnv, +} from './providerAutoDetect.ts' + +// Hermetic env scan: always report "no Codex auth on disk" so tests don't +// depend on the dev machine's ~/.codex/auth.json state. +function scan(env: Record) { + return detectProviderFromEnv({ env, hasCodexAuth: () => false }) +} + +describe('detectProviderFromEnv — priority order', () => { + test('ANTHROPIC_API_KEY wins over all others', () => { + expect( + scan({ + ANTHROPIC_API_KEY: 'sk-ant-x', + OPENAI_API_KEY: 'sk-x', + GEMINI_API_KEY: 'gem-x', + }), + ).toEqual({ kind: 'anthropic', source: 'ANTHROPIC_API_KEY set' }) + }) + + test('CODEX_API_KEY beats OpenAI/Gemini/etc', () => { + expect( + scan({ + CODEX_API_KEY: 'codex-x', + OPENAI_API_KEY: 'sk-x', + }), + ).toEqual({ kind: 'codex', source: 'CODEX_API_KEY set' }) + }) + + test('CHATGPT_ACCOUNT_ID alone is enough for Codex', () => { + expect( + scan({ + CHATGPT_ACCOUNT_ID: 'acct-123', + }), + ).toEqual({ kind: 'codex', source: 'CHATGPT_ACCOUNT_ID set' }) + }) + + test('Codex auth file on disk is detected without any env', () => { + expect( + detectProviderFromEnv({ env: {}, hasCodexAuth: () => true }), + ).toEqual({ kind: 'codex', source: '~/.codex/auth.json present' }) + }) + + test('GITHUB_TOKEN wins over OpenAI', () => { + expect( + scan({ + GITHUB_TOKEN: 'ghp-x', + OPENAI_API_KEY: 'sk-x', + }), + ).toEqual({ kind: 'github', source: 'GITHUB_TOKEN set (GitHub Copilot)' }) + }) + + test('GH_TOKEN is equivalent to GITHUB_TOKEN', () => { + expect( + scan({ + GH_TOKEN: 'ghp-x', + }), + ).toEqual({ kind: 'github', source: 'GH_TOKEN set (GitHub Copilot)' }) + }) + + test('OPENAI_API_KEYS (plural) detected', () => { + expect( + scan({ + OPENAI_API_KEYS: 'sk-a,sk-b', + }), + ).toEqual({ kind: 'openai', source: 'OPENAI_API_KEYS set' }) + }) + + test('OPENAI_API_KEY reports baseUrl when set', () => { + expect( + scan({ + OPENAI_API_KEY: 'sk-x', + OPENAI_BASE_URL: 'https://openrouter.ai/api/v1', + }), + ).toEqual({ + kind: 'openai', + source: 'OPENAI_API_KEY set', + baseUrl: 'https://openrouter.ai/api/v1', + }) + }) + + test('GEMINI_API_KEY detected', () => { + expect(scan({ GEMINI_API_KEY: 'gem-x' })).toEqual({ + kind: 'gemini', + source: 'GEMINI_API_KEY set', + }) + }) + + test('GOOGLE_API_KEY also detects Gemini', () => { + expect(scan({ GOOGLE_API_KEY: 'gk-x' })).toEqual({ + kind: 'gemini', + source: 'GOOGLE_API_KEY set', + }) + }) + + test('MISTRAL_API_KEY detected', () => { + expect(scan({ MISTRAL_API_KEY: 'mis-x' })).toEqual({ + kind: 'mistral', + source: 'MISTRAL_API_KEY set', + }) + }) + + test('MINIMAX_API_KEY detected', () => { + expect(scan({ MINIMAX_API_KEY: 'mm-x' })).toEqual({ + kind: 'minimax', + source: 'MINIMAX_API_KEY set', + }) + }) + + test('empty-string values are ignored', () => { + expect( + scan({ + ANTHROPIC_API_KEY: '', + OPENAI_API_KEY: ' ', + GEMINI_API_KEY: 'gem-x', + }), + ).toEqual({ kind: 'gemini', source: 'GEMINI_API_KEY set' }) + }) + + test('no credentials → null', () => { + expect(scan({})).toBeNull() + }) +}) + +describe('detectLocalService', () => { + test('returns Ollama when its /api/tags responds ok', async () => { + const fetchImpl = (async (input: URL | RequestInfo) => { + const url = typeof input === 'string' ? input : (input as URL).toString() + if (url.includes(':11434')) { + return new Response('{"models":[]}', { status: 200 }) + } + return new Response('', { status: 404 }) + }) as typeof fetch + + const result = await detectLocalService({ + env: {}, + fetchImpl, + timeoutMs: 200, + }) + expect(result?.kind).toBe('ollama') + expect(result?.baseUrl).toBe('http://localhost:11434') + }) + + test('Ollama wins over LM Studio even when both are reachable', async () => { + const fetchImpl = (async () => new Response('{}', { status: 200 })) as typeof fetch + const result = await detectLocalService({ + env: {}, + fetchImpl, + timeoutMs: 200, + }) + expect(result?.kind).toBe('ollama') + }) + + test('falls back to LM Studio when Ollama is unreachable', async () => { + const fetchImpl = (async (input: URL | RequestInfo) => { + const url = typeof input === 'string' ? input : (input as URL).toString() + if (url.includes(':1234')) { + return new Response('{"data":[]}', { status: 200 }) + } + return new Response('', { status: 404 }) + }) as typeof fetch + + const result = await detectLocalService({ + env: {}, + fetchImpl, + timeoutMs: 200, + }) + expect(result?.kind).toBe('lm-studio') + expect(result?.baseUrl).toBe('http://localhost:1234') + }) + + test('returns null when no local services respond', async () => { + const fetchImpl = (async () => + new Response('', { status: 500 })) as typeof fetch + const result = await detectLocalService({ + env: {}, + fetchImpl, + timeoutMs: 200, + }) + expect(result).toBeNull() + }) + + test('honors OLLAMA_BASE_URL override', async () => { + const probedUrls: string[] = [] + const fetchImpl = (async (input: URL | RequestInfo) => { + const url = typeof input === 'string' ? input : (input as URL).toString() + probedUrls.push(url) + return new Response('{"models":[]}', { status: 200 }) + }) as typeof fetch + + const result = await detectLocalService({ + env: { OLLAMA_BASE_URL: 'http://10.0.0.5:11434' }, + fetchImpl, + timeoutMs: 200, + }) + expect(result?.baseUrl).toBe('http://10.0.0.5:11434') + expect(probedUrls).toContain('http://10.0.0.5:11434/api/tags') + }) + + test('probe timeout does not throw — returns null', async () => { + const fetchImpl = (async (_input: URL | RequestInfo, init?: RequestInit) => { + // Respect the caller's abort signal so the race with timeoutMs is fair. + return new Promise((_resolve, reject) => { + const onAbort = () => reject(new Error('aborted')) + init?.signal?.addEventListener('abort', onAbort) + setTimeout(() => { + init?.signal?.removeEventListener('abort', onAbort) + _resolve(new Response('ok')) + }, 500) + }) + }) as typeof fetch + + const result = await detectLocalService({ + env: {}, + fetchImpl, + timeoutMs: 50, + }) + expect(result).toBeNull() + }) + + test('network errors do not throw', async () => { + const fetchImpl = (async () => { + throw new Error('ECONNREFUSED') + }) as typeof fetch + + const result = await detectLocalService({ + env: {}, + fetchImpl, + timeoutMs: 200, + }) + expect(result).toBeNull() + }) +}) + +describe('detectBestProvider — orchestrator', () => { + test('env match short-circuits the local probe', async () => { + let probeCalled = false + const fetchImpl = (async () => { + probeCalled = true + return new Response('{}', { status: 200 }) + }) as typeof fetch + + const result = await detectBestProvider({ + env: { ANTHROPIC_API_KEY: 'sk-ant' }, + fetchImpl, + timeoutMs: 200, + hasCodexAuth: () => false, + }) + expect(result?.kind).toBe('anthropic') + expect(probeCalled).toBe(false) + }) + + test('env miss falls through to local-service probe', async () => { + const fetchImpl = (async () => new Response('{}', { status: 200 })) as typeof fetch + const result = await detectBestProvider({ + env: {}, + fetchImpl, + timeoutMs: 200, + hasCodexAuth: () => false, + }) + expect(result?.kind).toBe('ollama') + }) + + test('skipLocal prevents network probes', async () => { + let probeCalled = false + const fetchImpl = (async () => { + probeCalled = true + return new Response('{}', { status: 200 }) + }) as typeof fetch + + const result = await detectBestProvider({ + env: {}, + fetchImpl, + skipLocal: true, + hasCodexAuth: () => false, + }) + expect(result).toBeNull() + expect(probeCalled).toBe(false) + }) + + test('completely empty environment returns null', async () => { + const fetchImpl = (async () => { + throw new Error('nothing reachable') + }) as typeof fetch + + const result = await detectBestProvider({ + env: {}, + fetchImpl, + timeoutMs: 100, + hasCodexAuth: () => false, + }) + expect(result).toBeNull() + }) +}) diff --git a/src/utils/providerAutoDetect.ts b/src/utils/providerAutoDetect.ts new file mode 100644 index 00000000..8c4fb536 --- /dev/null +++ b/src/utils/providerAutoDetect.ts @@ -0,0 +1,283 @@ +/** + * Zero-config provider autodetection. + * + * Scans the environment (API keys, OAuth tokens, stored credentials) and local + * network (Ollama, LM Studio) to pick the best provider for first-run users + * who have not explicitly configured one. Returns a structured detection + * result that callers can consume to build a launch-ready profile env, or + * null when nothing is detected — in which case the existing onboarding / + * picker flow should take over. + * + * Detection priority (first match wins): + * 1. ANTHROPIC_API_KEY → first-party Claude (most capable default) + * 2. Codex: CODEX_API_KEY, CHATGPT_ACCOUNT_ID, or valid ~/.codex/auth.json + * 3. GitHub Copilot: GITHUB_TOKEN or GH_TOKEN + * 4. OPENAI_API_KEY / OPENAI_API_KEYS + * 5. GEMINI_API_KEY or GOOGLE_API_KEY + * 6. MISTRAL_API_KEY + * 7. MINIMAX_API_KEY + * 8. Local Ollama reachable (default localhost:11434) + * 9. Local LM Studio reachable (default localhost:1234) + * + * Local-service probes are parallelized and cheap (short timeout, no + * request body). Env scans are synchronous and run first so we don't make + * network calls when a credential is already present. + * + * This module intentionally does NOT decide whether to apply the detection; + * callers should gate on hasExplicitProviderSelection() (providerProfile.ts) + * and the presence of a persisted profile file. + */ + +import { existsSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' + +export type DetectedProviderKind = + | 'anthropic' + | 'codex' + | 'github' + | 'openai' + | 'gemini' + | 'mistral' + | 'minimax' + | 'ollama' + | 'lm-studio' + +export type DetectedProvider = { + kind: DetectedProviderKind + /** One-line human-readable reason, e.g. "ANTHROPIC_API_KEY set". */ + source: string + /** Present when the detection already resolved a usable base URL. */ + baseUrl?: string + /** Present when detection also narrowed down a specific model. */ + model?: string +} + +type EnvLike = NodeJS.ProcessEnv | Record + +function envHasNonEmpty(env: EnvLike, key: string): boolean { + const value = env[key] + return typeof value === 'string' && value.trim().length > 0 +} + +function firstSet(env: EnvLike, keys: readonly string[]): string | undefined { + for (const key of keys) { + if (envHasNonEmpty(env, key)) return key + } + return undefined +} + +function defaultHasCodexAuthFile(): boolean { + const paths = [ + process.env.CODEX_AUTH_PATH, + join(homedir(), '.codex', 'auth.json'), + ] + return paths.some(p => p && existsSync(p)) +} + +export type DetectProviderFromEnvOptions = { + env?: EnvLike + /** + * Override Codex auth-file detection. Primarily for tests — the default + * implementation checks ~/.codex/auth.json and CODEX_AUTH_PATH on disk. + */ + hasCodexAuth?: () => boolean +} + +/** + * Synchronous env-only scan. Returns the highest-priority env-provided + * provider, or null if nothing is present. Intentionally does not touch + * the network — fast path for the common case where a user has exported + * one of the standard API-key env vars. + */ +function isOptionsObject( + value: EnvLike | DetectProviderFromEnvOptions | undefined, +): value is DetectProviderFromEnvOptions { + if (!value || typeof value !== 'object') return false + if ('hasCodexAuth' in value && typeof value.hasCodexAuth === 'function') { + return true + } + if ('env' in value && typeof (value as { env?: unknown }).env === 'object') { + return true + } + return false +} + +export function detectProviderFromEnv( + envOrOptions: EnvLike | DetectProviderFromEnvOptions = process.env, +): DetectedProvider | null { + const options: DetectProviderFromEnvOptions = isOptionsObject(envOrOptions) + ? envOrOptions + : { env: envOrOptions as EnvLike } + const env = options.env ?? process.env + const hasCodexAuth = options.hasCodexAuth ?? defaultHasCodexAuthFile + if (envHasNonEmpty(env, 'ANTHROPIC_API_KEY')) { + return { kind: 'anthropic', source: 'ANTHROPIC_API_KEY set' } + } + + if ( + envHasNonEmpty(env, 'CODEX_API_KEY') || + envHasNonEmpty(env, 'CHATGPT_ACCOUNT_ID') || + envHasNonEmpty(env, 'CODEX_ACCOUNT_ID') || + hasCodexAuth() + ) { + const sourceEnv = + firstSet(env, ['CODEX_API_KEY', 'CHATGPT_ACCOUNT_ID', 'CODEX_ACCOUNT_ID']) + return { + kind: 'codex', + source: sourceEnv ? `${sourceEnv} set` : '~/.codex/auth.json present', + } + } + + const githubKey = firstSet(env, ['GITHUB_TOKEN', 'GH_TOKEN']) + if (githubKey) { + return { + kind: 'github', + source: `${githubKey} set (GitHub Copilot)`, + } + } + + const openaiKey = firstSet(env, ['OPENAI_API_KEYS', 'OPENAI_API_KEY']) + if (openaiKey) { + return { + kind: 'openai', + source: `${openaiKey} set`, + baseUrl: env.OPENAI_BASE_URL ?? env.OPENAI_API_BASE, + } + } + + const geminiKey = firstSet(env, ['GEMINI_API_KEY', 'GOOGLE_API_KEY']) + if (geminiKey) { + return { kind: 'gemini', source: `${geminiKey} set` } + } + + if (envHasNonEmpty(env, 'MISTRAL_API_KEY')) { + return { kind: 'mistral', source: 'MISTRAL_API_KEY set' } + } + + if (envHasNonEmpty(env, 'MINIMAX_API_KEY')) { + return { kind: 'minimax', source: 'MINIMAX_API_KEY set' } + } + + return null +} + +type LocalProbe = { + kind: DetectedProviderKind + url: string + timeoutMs: number + source: string + baseUrl: string +} + +const DEFAULT_LOCAL_PROBE_TIMEOUT_MS = 1200 + +async function probeReachable( + url: string, + timeoutMs: number, + fetchImpl: typeof fetch, +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetchImpl(url, { + method: 'GET', + signal: controller.signal, + }) + return response.ok + } catch { + return false + } finally { + clearTimeout(timer) + } +} + +/** + * Returns the highest-priority local service reachable from the host. + * Runs probes in parallel and picks by priority rather than first-response, + * so slow-but-preferred services still win over fast-but-lower-priority ones. + */ +export async function detectLocalService(options?: { + env?: EnvLike + fetchImpl?: typeof fetch + timeoutMs?: number +}): Promise { + const env = options?.env ?? process.env + const fetchImpl = options?.fetchImpl ?? globalThis.fetch + const timeoutMs = options?.timeoutMs ?? DEFAULT_LOCAL_PROBE_TIMEOUT_MS + + const ollamaBase = (env.OLLAMA_BASE_URL ?? 'http://localhost:11434').replace( + /\/+$/, + '', + ) + const lmStudioBase = (env.LM_STUDIO_BASE_URL ?? 'http://localhost:1234').replace( + /\/+$/, + '', + ) + + const probes: LocalProbe[] = [ + { + kind: 'ollama', + url: `${ollamaBase}/api/tags`, + timeoutMs, + source: `Ollama reachable at ${ollamaBase}`, + baseUrl: ollamaBase, + }, + { + kind: 'lm-studio', + url: `${lmStudioBase}/v1/models`, + timeoutMs, + source: `LM Studio reachable at ${lmStudioBase}`, + baseUrl: lmStudioBase, + }, + ] + + const results = await Promise.all( + probes.map(async probe => ({ + probe, + reachable: await probeReachable(probe.url, probe.timeoutMs, fetchImpl), + })), + ) + + for (const { probe, reachable } of results) { + if (reachable) { + return { + kind: probe.kind, + source: probe.source, + baseUrl: probe.baseUrl, + } + } + } + + return null +} + +/** + * Orchestrator: env scan first (sync, free), then local-service probes + * (async, ~1-2s worst case) only if nothing was found in env. + */ +export async function detectBestProvider(options?: { + env?: EnvLike + fetchImpl?: typeof fetch + timeoutMs?: number + /** Skip local-service probes — useful for tests or offline smoke checks. */ + skipLocal?: boolean + /** Override for Codex auth-file detection. See detectProviderFromEnv. */ + hasCodexAuth?: () => boolean +}): Promise { + const env = options?.env ?? process.env + + const fromEnv = detectProviderFromEnv({ + env, + hasCodexAuth: options?.hasCodexAuth, + }) + if (fromEnv) return fromEnv + + if (options?.skipLocal) return null + + return detectLocalService({ + env, + fetchImpl: options?.fetchImpl, + timeoutMs: options?.timeoutMs, + }) +}