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:
299
src/utils/providerAutoDetect.test.ts
Normal file
299
src/utils/providerAutoDetect.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
283
src/utils/providerAutoDetect.ts
Normal file
283
src/utils/providerAutoDetect.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user