import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { chatBridge } from './chatBridgeService'; import { getCodexDataDir } from './codexHome'; import { DEFAULT_API_KEY_ENV, type CodexProviderSpec, type CodexRuntime, type JsonValue } from './codexRuntime'; import type { AppliedProviderInfo, ApplyProviderInput } from './types'; /** * 把后端「模型管理」里的一条记录翻译成 Codex 的 model provider。 * 全程不做任何 Codex/OpenAI 账号登录:靠 wire_api="responses" + requires_openai_auth=false + env_key。 * apiKey 只存在于内存与子进程环境变量,落盘的 AppliedProvider 不含任何密钥。 */ export const PROVIDER_ID = 'zsjz'; export type AppliedProvider = AppliedProviderInfo; /** 端点协议判定:responses 直通;chat-only 走内置桥接;unsupported 双协议都没有;unreachable 网络不可达 */ export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable'; export interface EndpointProbeResult { protocol: EndpointProtocol; /** /responses 的 HTTP 状态;未探或网络失败为 null */ responsesStatus: number | null; /** /chat/completions 的 HTTP 状态;未探为 null */ chatStatus: number | null; /** /api/version 探到的 Ollama 版本;非 Ollama 或未探为 null */ ollamaVersion: string | null; /** Ollama 低于 0.13.3(没有非状态化 /v1/responses):提示升级,但允许走桥接 */ ollamaNeedsUpgrade: boolean; detail: string; } const EXTRA_ALLOWLIST: readonly string[] = [ 'request_max_retries', 'stream_max_retries', 'stream_idle_timeout_ms', 'query_params', ]; function providerFile(): string { return join(getCodexDataDir(), 'provider.json'); } /** Codex 要求 base_url 是 API 根(形如 https://api.openai.com/v1),它自己再拼 /responses */ export function normalizeBaseUrl(raw: string | null | undefined): string | null { const value = (raw ?? '').trim().replace(/\/+$/u, ''); return value || null; } /** 给页面的非阻断提示;返回 null 表示没问题 */ export function baseUrlHint(baseUrl: string | null): string | null { if (!baseUrl) return '未配置 base_url'; if (!/^https?:\/\//u.test(baseUrl)) return 'base_url 必须以 http:// 或 https:// 开头'; if (!/\/v\d+$/u.test(baseUrl)) { return 'base_url 通常应写到版本段(如 .../v1),否则 Codex 拼出的 /responses 可能 404'; } return null; } function parseJsonRecord(raw: unknown): Record | null { if (!raw) return null; if (typeof raw === 'object' && !Array.isArray(raw)) return raw as Record; if (typeof raw !== 'string') return null; try { const parsed = JSON.parse(raw) as unknown; return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null; } catch { return null; } } function readStringMap(raw: unknown): Record | null { const record = parseJsonRecord(raw); if (!record) return null; const result: Record = {}; for (const [key, value] of Object.entries(record)) { if (typeof value === 'string' && value) result[key] = value; } return Object.keys(result).length ? result : null; } /** Codex 内置 ollama/lmstudio 走 OpenAI 兼容根(.../v1),而库里常只存到 host:port */ function normalizeOssBaseUrl(raw: string | null | undefined): string | null { const base = normalizeBaseUrl(raw); if (!base) return null; return /\/v\d+$/u.test(base) ? base : `${base}/v1`; } /** OLLAMA 用 Codex 内置 provider(Responses API,默认端口 11434),不需要 api key */ function resolveBuiltinProvider(input: ApplyProviderInput): string | null { if (input.builtinProvider) return input.builtinProvider; const type = `${input.providerType ?? ''}`.trim().toUpperCase(); if (type === 'OLLAMA') return 'ollama'; if (type === 'LMSTUDIO') return 'lmstudio'; return null; } export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec { const model = `${input.modelId ?? ''}`.trim(); if (!model) throw new Error('缺少 modelId'); const builtinProvider = resolveBuiltinProvider(input); if (builtinProvider) { return { id: null, builtinProvider, model, name: input.name ?? `${builtinProvider} 本地模型`, // 内置 provider 的默认地址是 localhost,必须把库里的真实地址带上,否则连不到远端 Ollama baseUrl: normalizeOssBaseUrl(input.baseUrl), }; } const baseUrl = normalizeBaseUrl(input.baseUrl); if (!baseUrl) throw new Error('缺少 baseUrl'); const config = parseJsonRecord(input.config); const extra: Record = {}; for (const [key, value] of Object.entries(config ?? {})) { if (EXTRA_ALLOWLIST.includes(key)) extra[key] = value as JsonValue; } return { id: PROVIDER_ID, name: input.name ?? model, baseUrl, model, apiKey: input.apiKey ?? null, envKey: DEFAULT_API_KEY_ENV, httpHeaders: readStringMap(input.headersJson), extra: Object.keys(extra).length ? extra : null, }; } /** config 里被丢弃的非白名单键,用于页面提示 */ export function describeDroppedKeys(input: ApplyProviderInput): string[] { const config = parseJsonRecord(input.config); return Object.keys(config ?? {}).filter((key) => !EXTRA_ALLOWLIST.includes(key)); } /** Ollama 自 0.13.3 起提供非状态化 /v1/responses(流式 / 工具调用 / reasoning summaries) */ const OLLAMA_RESPONSES_MIN_VERSION: readonly number[] = [0, 13, 3]; interface ProbeAttempt { status: number | null; error: string | null; } async function postProbe(url: string, body: Record, apiKey?: string | null): Promise { try { const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}), }, body: JSON.stringify(body), signal: AbortSignal.timeout(8_000), }); return { status: response.status, error: null }; } catch (error) { return { status: null, error: error instanceof Error ? error.message : String(error) }; } } /** Ollama 的 /api/version 挂在 API 根之外(去掉 /v1 尾段);非 Ollama 端点返回 null */ async function probeOllamaVersion(baseUrl: string): Promise { const origin = baseUrl.replace(/\/v\d+$/u, ''); try { const response = await fetch(`${origin}/api/version`, { signal: AbortSignal.timeout(5_000) }); if (response.status !== 200) return null; const parsed = (await response.json()) as { version?: unknown }; return typeof parsed.version === 'string' && parsed.version ? parsed.version : null; } catch { return null; } } function parseVersion(raw: string): number[] | null { const match = raw.trim().match(/^(\d+)\.(\d+)(?:\.(\d+))?/u); if (!match) return null; return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]; } function versionLt(a: number[], b: readonly number[]): boolean { for (let index = 0; index < 3; index += 1) { if (a[index] !== b[index]) return a[index] < b[index]; } return false; } function probeResult(partial: Partial & { protocol: EndpointProtocol; detail: string }): EndpointProbeResult { return { responsesStatus: null, chatStatus: null, ollamaVersion: null, ollamaNeedsUpgrade: false, ...partial, }; } /** * 探测端点协议能力(四态判定)。 * * Codex 0.155+ 只会发 Responses API:responses → 直通;chat-only → 内置桥接转换; * 其余两种拒绝。判定顺序: * 1. POST /responses(用**真实模型名**——Ollama 对未拉取的模型返回 404,不能直接判端点不支持); * 2. 404/405 且是 Ollama → 查 /api/version 消歧:≥0.13.3 说明 404 只是模型没拉(判 responses), * 低于 0.13.3 直接判 chat-only(/chat/completions 必有); * 3. 否则再 POST /chat/completions:路由存在判 chat-only,也不存在判 unsupported。 */ export async function probeEndpoint(params: { baseUrl: string; modelId?: string | null; apiKey?: string | null; providerType?: string | null; builtinProvider?: string | null; }): Promise { const baseUrl = params.baseUrl.replace(/\/+$/u, ''); const model = params.modelId?.trim() || 'probe'; const isOllama = params.builtinProvider === 'ollama' || `${params.providerType ?? ''}`.trim().toUpperCase() === 'OLLAMA'; const responses = await postProbe( `${baseUrl}/responses`, { model, input: 'ping', max_output_tokens: 16, stream: false }, params.apiKey, ); if (responses.status === null) { return probeResult({ protocol: 'unreachable', detail: `端点不可达:${responses.error ?? '网络错误'}` }); } if (responses.status !== 404 && responses.status !== 405) { return probeResult({ protocol: 'responses', responsesStatus: responses.status, detail: `端点已实现 Responses API(HTTP ${responses.status})`, }); } if (isOllama) { const version = await probeOllamaVersion(baseUrl); if (version) { const parsed = parseVersion(version); if (parsed && !versionLt(parsed, OLLAMA_RESPONSES_MIN_VERSION)) { return probeResult({ protocol: 'responses', responsesStatus: responses.status, ollamaVersion: version, detail: `Ollama ${version} 支持 Responses API;/responses 返回 404 通常只是模型「${model}」未拉取,请先 ollama pull ${model}`, }); } return probeResult({ protocol: 'chat-only', responsesStatus: responses.status, ollamaVersion: version, ollamaNeedsUpgrade: true, detail: `Ollama ${version} 低于 0.13.3,只提供 /chat/completions(建议升级 Ollama 以直连)`, }); } } const chat = await postProbe( `${baseUrl}/chat/completions`, { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false }, params.apiKey, ); if (chat.status !== null && chat.status !== 404 && chat.status !== 405) { return probeResult({ protocol: 'chat-only', responsesStatus: responses.status, chatStatus: chat.status, detail: `端点只提供 /chat/completions(/responses 返回 HTTP ${responses.status})`, }); } return probeResult({ protocol: 'unsupported', responsesStatus: responses.status, chatStatus: chat.status, detail: `端点 Responses 与 Chat Completions 均不存在(HTTP ${responses.status}/${chat.status ?? '网络错误'}),无法对接`, }); } export async function readAppliedProvider(): Promise { try { const raw = JSON.parse(await readFile(providerFile(), 'utf8')) as Partial; return raw.model ? (raw as AppliedProvider) : null; } catch { return null; } } async function writeAppliedProvider(value: AppliedProvider | null): Promise { await mkdir(getCodexDataDir(), { recursive: true }); await writeFile(providerFile(), JSON.stringify(value ?? {}, null, 2), 'utf8'); } /** 应用一条模型配置:先探端点,再重启子进程(api_key 走 env,换 key 必须重启) */ export async function applyProvider( runtime: CodexRuntime, input: ApplyProviderInput & { modelRecordId?: string | number | null }, ): Promise { const spec = toProviderSpec(input); /** 桥接时落盘存上游真实地址(展示用);桥接 URL 每次应用临时分配,不落盘 */ const displayBaseUrl = spec.baseUrl ?? null; let bridged = false; // 只要显式配了地址就探(含远端 Ollama):应用成功后才发现连不上,比这里直接报错更难排查。 // 内置 provider 没配地址时不探(默认 localhost,可能压根没起本地服务)。 if (spec.baseUrl) { const probe = await probeEndpoint({ baseUrl: spec.baseUrl, modelId: spec.model, apiKey: spec.apiKey, providerType: input.providerType, builtinProvider: spec.builtinProvider, }); if (probe.protocol !== 'responses' && probe.protocol !== 'chat-only') { throw new Error(probe.detail); } if (probe.protocol === 'chat-only') { // chat-only 端点:启动内置桥接,Codex 改连本地代理的 /v1/responses。 // Ollama 桥接特例:内置 provider 的 buildArgs 只允许覆盖 base_url, // 不降级为自定义 provider 的话 Codex 会绕过桥接直连上游。 const bridge = await chatBridge.start({ upstreamBaseUrl: spec.baseUrl, headers: spec.httpHeaders ?? null }); spec.id = PROVIDER_ID; spec.builtinProvider = null; spec.baseUrl = bridge.url; bridged = true; } } // 直通(或未配地址的内置 provider):确保上一轮的桥接已停 if (!bridged) await chatBridge.stop(); try { await runtime.applyProvider(spec); } catch (error) { // 应用失败回滚桥接,避免留下一个指着旧上游的孤儿代理 if (bridged) await chatBridge.stop(); throw error; } const applied: AppliedProvider = { model: spec.model, modelRecordId: input.modelRecordId === undefined || input.modelRecordId === null ? null : String(input.modelRecordId), baseUrl: displayBaseUrl, providerId: spec.id, builtinProvider: spec.builtinProvider ?? null, name: spec.name ?? null, appliedAt: new Date().toISOString(), ...(bridged ? { bridged: true } : {}), }; await writeAppliedProvider(applied); return applied; } export async function clearProvider(runtime: CodexRuntime): Promise { await chatBridge.stop(); await runtime.applyProvider(null); await writeAppliedProvider(null); }