feat(provider): zero-config autodetection primitive (#784)

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 <openclaude@gitlawb.com>
This commit is contained in:
Kevin Codex
2026-04-21 23:37:04 +08:00
committed by GitHub
parent 268c0398e4
commit a5bfcbbadf
2 changed files with 582 additions and 0 deletions

View File

@@ -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<string, string | undefined>) {
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<Response>((_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()
})
})

View File

@@ -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<string, string | undefined>
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<boolean> {
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<DetectedProvider | null> {
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<DetectedProvider | null> {
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,
})
}