| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301 |
- 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<Record<string, JsonValue>> = 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<string, unknown> | null {
- if (!raw) return null;
- if (typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>;
- 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<string, unknown>)
- : null;
- } catch {
- return null;
- }
- }
- function readStringMap(raw: unknown): Record<string, string> | null {
- const record = parseJsonRecord(raw);
- if (!record) return null;
- const result: Record<string, string> = {};
- 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<string, JsonValue> = { ...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<ProbeAttempt> {
- 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<string, unknown>, apiKey?: string | null): Promise<ProbeAttempt> {
- 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<Omit<EndpointProbeResult, 'protocol' | 'detail'>> & {
- 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<EndpointProbeResult> {
- 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<AppliedProvider | null> {
- try {
- const raw = JSON.parse(await readFile(providerFile(), 'utf8')) as Partial<AppliedProvider>;
- return raw.model ? (raw as AppliedProvider) : null;
- } catch {
- return null;
- }
- }
- async function writeAppliedProvider(value: AppliedProvider | null): Promise<void> {
- 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<AppliedProvider> {
- 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<void> {
- await runtime.applyProvider(null);
- await writeAppliedProvider(null);
- }
|