import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { getCodexDataDir } from './codexHome'; import { DEFAULT_API_KEY_ENV, PROVIDER_EXTRA_ALLOWLIST, 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; /** * 端点协议判定。Codex 0.155.1 起 `wire_api="chat"` 被删掉(实测 0.146/0.151/0.155 三个版本 * 都在加载 config.toml 时就报 "wire_api = \"chat\" is no longer supported"),客户端也不做 * Responses↔Chat 翻译 —— 只会 Chat 的端点一律拒绝应用,把原因说清楚。 */ export type EndpointProtocol = 'responses' | 'chat-only' | 'unsupported' | 'unreachable'; export interface EndpointProbeResult { protocol: EndpointProtocol; /** /responses 的 HTTP 状态;未探或网络失败为 null */ responsesStatus: number | null; /** /chat/completions 的 HTTP 状态;只在需要区分 chat-only 时才探,未探为 null */ chatStatus: number | null; detail: string; } const EXTRA_ALLOWLIST: readonly string[] = PROVIDER_EXTRA_ALLOWLIST; /** * 自部署端点多是单槽推理:实测一轮 prompt 4.6 万 token、prefill 只有 ~29 token/s, * 首字之前可以静默十几分钟。Codex 在流建立后每次轮询都套一个 * `timeout(stream_idle_timeout_ms, stream.next())`(rust-v0.155.1 * codex-api/src/sse/responses.rs:580-606),默认 300_000(model-provider-info/src/lib.rs:29), * 到期给出可重试的 CodexErr::Stream(protocol/src/error.rs:372-413),按 stream_max_retries * (默认 5)重发整轮 —— 把几分钟的活儿重复五遍,还挤掉前缀缓存。 * * 所以这里:空闲窗口放宽到远大于最差 prefill;两个重试都关到 0。 * 注意不要再写 `supports_websockets=false`:那是 serde 默认值,用 -c 声明的 provider 从来不走 * WebSocket,那个 15 秒是 websocket_connect_timeout_ms,与此无关(此前一条错误注释把六次修改带偏)。 * 模型管理 config 里显式写的值优先。 */ const PROVIDER_DEFAULTS: Readonly> = Object.freeze({ request_max_retries: 0, stream_max_retries: 0, stream_idle_timeout_ms: 1_800_000, }); function providerFile(): string { return join(getCodexDataDir(), 'provider.json'); } /** Codex 要求 base_url 是 API 根(形如 https://api.openai.com/v1),它自己再拼路由 */ 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),否则拼出的 /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; } /** * 后端「模型管理」的一条记录一律翻译成自定义 provider:Codex 0.155.1 把 ollama / lmstudio * 等内置 id 列为保留字,覆盖 model_providers.<内置 id> 会让 app-server 启动即退出。 * 地址缺失就是配置缺失,直接报错让页面提示,不做 localhost 兜底。 */ export function toProviderSpec(input: ApplyProviderInput): CodexProviderSpec { const model = `${input.modelId ?? ''}`.trim(); if (!model) throw new Error('缺少 modelId'); const baseUrl = normalizeBaseUrl(input.baseUrl); if (!baseUrl) { throw new Error(`模型「${input.name || model}」没有配置 base_url,请到「模型管理」补全(形如 https://host:port/v1)`); } const config = parseJsonRecord(input.config); const extra: Record = { ...PROVIDER_DEFAULTS }; 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)); } /** * 探测只问「路由在不在」,绝不让端点真去生成:本地服务多是单槽推理, * 一次生成能占住整个 HTTP 服务几十秒(实测 max_tokens:1 也要 14 秒,期间连 /models 都不应答)。 * 15 秒是留给排队的余量。 */ const PROBE_TIMEOUT_MS = 15_000; interface ProbeAttempt { status: number | null; error: string | null; ms: number; timedOut: boolean; } function describeFetchError(error: unknown): { message: string; timedOut: boolean } { const err = error instanceof Error ? error : new Error(String(error)); return { message: err.message, timedOut: err.name === 'TimeoutError' || err.name === 'AbortError' || /timeout/iu.test(err.message), }; } async function probeFetch(url: string, init: RequestInit, timeoutMs: number): Promise { const startedAt = Date.now(); try { const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); return { status: response.status, error: null, ms: Date.now() - startedAt, timedOut: false }; } catch (error) { const { message, timedOut } = describeFetchError(error); return { status: null, error: message, ms: Date.now() - startedAt, timedOut }; } } async function postProbe(url: string, body: Record, apiKey?: string | null): Promise { return probeFetch( url, { method: 'POST', headers: { 'content-type': 'application/json', ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}), }, body: JSON.stringify(body), }, PROBE_TIMEOUT_MS, ); } /** * 不可达的提示必须带上是哪个地址、等了多久——只说「端点不可达」时, * 用户既不知道配错在哪,也无从判断是没起服务还是被防火墙慢慢吞掉。 */ function unreachableDetail(url: string, attempt: ProbeAttempt): string { if (attempt.timedOut) { return `端点不可达:${url} 在 ${(attempt.ms / 1000).toFixed(1)} 秒内没有响应,请确认服务已启动、地址与端口正确`; } return `端点不可达:${url} —— ${attempt.error ?? '网络错误'}`; } function probeResult( partial: Partial> & { protocol: EndpointProtocol; detail: string; }, ): EndpointProbeResult { return { responsesStatus: null, chatStatus: null, ...partial }; } /** * 判定端点会不会说 Responses。只问「路由在不在」,绝不让端点真去生成:本地服务多是单槽推理, * 一次生成能占住整个 HTTP 服务几十秒(实测 max_tokens:1 也要 14 秒,期间连 /models 都不应答)。 * 请求体故意不带 input,端点会在校验阶段就回 400(实测 15–27ms)。 * 代价是模型名写错要到真正提问时才暴露。 */ export async function probeEndpoint(params: { baseUrl: string; modelId?: string | null; apiKey?: string | null; }): Promise { const baseUrl = params.baseUrl.replace(/\/+$/u, ''); const responsesUrl = `${baseUrl}/responses`; const chatUrl = `${baseUrl}/chat/completions`; const model = params.modelId?.trim() || 'probe'; const responses = await postProbe(responsesUrl, { model }, params.apiKey); if (responses.status === null) { return probeResult({ protocol: 'unreachable', detail: unreachableDetail(responsesUrl, responses) }); } if (responses.status !== 404 && responses.status !== 405) { return probeResult({ protocol: 'responses', responsesStatus: responses.status, detail: `端点支持 Responses 协议(HTTP ${responses.status}),Codex 直连 ${responsesUrl}`, }); } // /responses 不在:区分「只会 Chat」和「两条路由都没有」,前者的话要说得能让人去修端点 const chat = await postProbe(chatUrl, { model }, params.apiKey); if (chat.status !== null && chat.status !== 404 && chat.status !== 405) { return probeResult({ protocol: 'chat-only', responsesStatus: responses.status, chatStatus: chat.status, detail: `端点没有 /responses(HTTP ${responses.status}),只有 /chat/completions。` + 'Codex 0.155.1 已删除 wire_api="chat"(实测 0.146/0.151/0.155 一致),本客户端不做桥接。' + `请把端点换成会回答 POST ${responsesUrl} 的服务(自证:curl -X POST ${responsesUrl} -d '{"model":"${model}"}')`, }); } return probeResult({ protocol: 'unsupported', responsesStatus: responses.status, chatStatus: chat.status, detail: `端点没有 /responses 路由(HTTP ${responses.status}),Codex 只能按 Responses 协议对接 ${responsesUrl}`, }); } 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); // 一定要先探端点:应用成功后才发现连不上,比这里直接报错更难排查 const probe = await probeEndpoint({ baseUrl: spec.baseUrl, modelId: spec.model, apiKey: spec.apiKey, }); if (probe.protocol !== 'responses') throw new Error(probe.detail); await runtime.applyProvider(spec); const applied: AppliedProvider = { model: spec.model, modelRecordId: input.modelRecordId === undefined || input.modelRecordId === null ? null : String(input.modelRecordId), baseUrl: spec.baseUrl, providerId: spec.id, name: spec.name ?? null, appliedAt: new Date().toISOString(), }; await writeAppliedProvider(applied); return applied; } export async function clearProvider(runtime: CodexRuntime): Promise { await runtime.applyProvider(null); await writeAppliedProvider(null); }