import { createServer, type Server } from 'node:http'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import type { CodexProviderSpec, CodexRuntime } from './codexRuntime'; import { applyProvider, baseUrlHint, describeDroppedKeys, normalizeBaseUrl, probeEndpoint, toProviderSpec, } from './providerService'; import type { ApplyProviderInput } from './types'; /** applyProvider 会往 getCodexDataDir() 落 provider.json;挪到临时目录,绝不碰客户端真实数据目录 */ vi.mock('./codexHome', async (importOriginal) => { const actual = await importOriginal(); // 目录只能算一次:providerFile() 每次都会调 getCodexDataDir(),给出不同路径就写不进同一处 const dir = mkdtempSync(join(tmpdir(), 'zsjz-provider-test-')); return { ...actual, getCodexDataDir: () => dir }; }); let server: Server; let port = 0; let responsesStatus = 404; let chatStatus = 404; let lastRequest: { url: string | null; auth: string | null; body: string | null } = { url: null, auth: null, body: null }; beforeAll(async () => { server = createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => chunks.push(chunk)); req.on('end', () => { lastRequest = { url: req.url ?? null, auth: req.headers.authorization ?? null, body: Buffer.concat(chunks).toString('utf8') || null, }; res.setHeader('content-type', 'application/json'); const status = req.url?.endsWith('/responses') ? responsesStatus : req.url?.endsWith('/chat/completions') ? chatStatus : 404; res.writeHead(status).end(JSON.stringify({ status })); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); port = typeof address === 'object' && address ? address.port : 0; }); afterAll(async () => { await new Promise((resolve) => server.close(() => resolve())); }); const openaiLike: ApplyProviderInput = { modelId: 'qwen3-max', name: '通义千问', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/', apiKey: 'sk-test-key', headersJson: JSON.stringify({ 'X-DashScope-WorkSpace': 'ws-1' }), config: JSON.stringify({ request_max_retries: 3, temperature: 0.2 }), }; describe('normalizeBaseUrl / baseUrlHint', () => { it('去掉尾部斜杠,空值返回 null', () => { expect(normalizeBaseUrl('https://x.com/v1///')).toBe('https://x.com/v1'); expect(normalizeBaseUrl(' ')).toBeNull(); expect(normalizeBaseUrl(null)).toBeNull(); }); it('缺版本段时给非阻断提示', () => { expect(baseUrlHint('https://x.com')).toMatch(/v1/); expect(baseUrlHint('https://x.com/v1')).toBeNull(); expect(baseUrlHint('ftp://x.com/v1')).toMatch(/http/); expect(baseUrlHint(null)).toMatch(/未配置/); }); }); describe('toProviderSpec', () => { it('OpenAI 兼容端点:写 model_providers.zsjz,key 只留给 env', () => { const spec = toProviderSpec(openaiLike); expect(spec).toMatchObject({ id: 'zsjz', model: 'qwen3-max', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', apiKey: 'sk-test-key', envKey: 'ZSJZ_CODEX_API_KEY', }); expect(spec.httpHeaders).toEqual({ 'X-DashScope-WorkSpace': 'ws-1' }); // config 里只放行白名单键,temperature 必须被丢掉 expect(spec.extra).toMatchObject({ request_max_retries: 3 }); expect(spec.extra).not.toHaveProperty('temperature'); }); it('地址原样使用,尾部斜杠规整;没配 key 就不写 env_key', () => { const spec = toProviderSpec({ modelId: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1/' }); expect(spec).toMatchObject({ id: 'zsjz', model: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1' }); expect(spec.apiKey ?? null).toBeNull(); }); it('空闲窗口必须显著大于 Codex 默认的 300 秒,且不写等于默认值的无效项', () => { const spec = toProviderSpec({ modelId: 'm', baseUrl: 'http://10.66.66.66:8080/v1' }); expect(spec.extra).toMatchObject({ request_max_retries: 0, stream_max_retries: 0, // Codex 默认 300_000(model-provider-info/src/lib.rs:29);写 300_000 等于没写 stream_idle_timeout_ms: 1_800_000, }); expect(spec.extra).toBeTypeOf('object'); // supports_websockets 的 serde 默认就是 false,用 -c 声明的 provider 根本不走 WS, // 写进默认值只会让下一位误以为这里防住了什么(15 秒那条说法来自 websocket 握手超时) expect(spec.extra).not.toHaveProperty('supports_websockets'); }); it('模型管理 config 里显式写的值优先于默认', () => { const spec = toProviderSpec({ modelId: 'm', baseUrl: 'http://x/v1', config: { stream_idle_timeout_ms: 5_000, stream_max_retries: 3 }, }); expect(spec.extra).toMatchObject({ stream_idle_timeout_ms: 5_000, stream_max_retries: 3, request_max_retries: 0, }); }); it('缺 modelId 或缺 baseUrl 直接报错,不做任何地址兜底', () => { expect(() => toProviderSpec({ modelId: ' ' })).toThrow(/modelId/); expect(() => toProviderSpec({ modelId: 'gpt' })).toThrow(/base_url/); expect(() => toProviderSpec({ modelId: 'gpt', baseUrl: ' ' })).toThrow(/base_url/); }); it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => { expect(toProviderSpec({ ...openaiLike, headersJson: { A: '1' } }).httpHeaders).toEqual({ A: '1' }); expect(toProviderSpec({ ...openaiLike, headersJson: '{bad json' }).httpHeaders).toBeNull(); expect(toProviderSpec({ ...openaiLike, config: { stream_max_retries: 5 } }).extra).toMatchObject({ stream_max_retries: 5, }); }); it('describeDroppedKeys 报告被丢弃的非白名单键', () => { expect(describeDroppedKeys(openaiLike)).toEqual(['temperature']); expect(describeDroppedKeys({ modelId: 'x' })).toEqual([]); }); }); describe('probeEndpoint', () => { const base = () => `http://127.0.0.1:${port}/v1`; it('只探 Responses 路由存在性,请求体不带 input(不能触发推理)', async () => { responsesStatus = 400; const result = await probeEndpoint({ baseUrl: `${base()}/`, modelId: 'qwen3-max', apiKey: 'sk-test-key', }); expect(result.protocol).toBe('responses'); expect(lastRequest.url).toBe('/v1/responses'); expect(lastRequest.auth).toBe('Bearer sk-test-key'); const body = JSON.parse(lastRequest.body ?? '{}') as Record; expect(body.model).toBe('qwen3-max'); // 一旦带上 input,单槽本地服务就会真的开始生成,实测一次要 14 秒 expect(body.input).toBeUndefined(); }); it('2xx 与 4xx 只要有路由就判 responses 可用', async () => { for (const status of [200, 400, 401]) { responsesStatus = status; const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' }); expect(result).toMatchObject({ protocol: 'responses', responsesStatus: status }); } }); it('没有 Responses 但有 Chat → chat-only,并说明本客户端不做桥接', async () => { responsesStatus = 404; chatStatus = 400; const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' }); expect(result.protocol).toBe('chat-only'); expect(result.detail).toMatch(/没有 \/responses/); expect(result.detail).toMatch(/不做桥接/); }); it('两条路由都没有 → unsupported', async () => { responsesStatus = 404; chatStatus = 404; const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' }); expect(result.protocol).toBe('unsupported'); }); it('端点不可达时报错带上是哪个地址', async () => { const result = await probeEndpoint({ baseUrl: 'http://127.0.0.1:1/v1', modelId: 'x' }); expect(result.protocol).toBe('unreachable'); expect(result.responsesStatus).toBeNull(); expect(result.detail).toMatch(/不可达/); expect(result.detail).toContain('http://127.0.0.1:1/v1/responses'); }); }); /** * 客户端真正走的是 applyProvider:探端点 → 把 spec 交给运行时重启子进程。 * 这里用假运行时把「交给 Codex 的最终参数」钉死 —— 真机 smoke 用的是手搓 spec,覆盖不到这一段。 */ describe('applyProvider 交给运行时的 spec', () => { class StubRuntime { spec: CodexProviderSpec | null | undefined; calls = 0; async applyProvider(spec: CodexProviderSpec | null): Promise { this.calls += 1; this.spec = spec; } } it('Codex 直连上游真实地址,并带上有效的重试/空闲窗口参数', async () => { responsesStatus = 400; const stub = new StubRuntime(); const upstream = `http://127.0.0.1:${port}/v1`; const applied = await applyProvider(stub as unknown as CodexRuntime, { modelId: 'qwen', name: '自部署 Qwen', baseUrl: upstream, modelRecordId: 7, }); const spec = stub.spec; expect(spec).not.toBeNull(); expect(spec?.id).toBe('zsjz'); expect(spec?.model).toBe('qwen'); // 不做任何翻译:Codex 连的就是模型记录里那个地址 expect(spec?.baseUrl).toBe(upstream); expect(spec?.extra).toMatchObject({ request_max_retries: 0, stream_max_retries: 0, stream_idle_timeout_ms: 1_800_000, }); expect(applied).toMatchObject({ model: 'qwen', modelRecordId: '7', baseUrl: upstream }); expect(applied).not.toHaveProperty('bridged'); }); it('端点只会 Chat 时直接报错,且不碰运行时', async () => { responsesStatus = 404; chatStatus = 400; const stub = new StubRuntime(); await expect( applyProvider(stub as unknown as CodexRuntime, { modelId: 'qwen', baseUrl: `http://127.0.0.1:${port}/v1` }), ).rejects.toThrow(/没有 \/responses/); expect(stub.calls).toBe(0); expect(stub.spec).toBeUndefined(); }); });