| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- 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<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;
- }
- /** 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<string, JsonValue> = {};
- 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<string, unknown>, apiKey?: string | null): Promise<ProbeAttempt> {
- 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<string | null> {
- 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<EndpointProbeResult> & { 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<EndpointProbeResult> {
- 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<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);
- /** 桥接时落盘存上游真实地址(展示用);桥接 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<void> {
- await chatBridge.stop();
- await runtime.applyProvider(null);
- await writeAppliedProvider(null);
- }
|