providerService.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import { createServer, type Server } from 'node:http';
  2. import { mkdtempSync, readFileSync } from 'node:fs';
  3. import { tmpdir } from 'node:os';
  4. import { join } from 'node:path';
  5. import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
  6. import type { CodexProviderSpec, CodexRuntime } from './codexRuntime';
  7. import { chatRouter } from './chatRouter/routerService';
  8. import {
  9. applyProvider,
  10. baseUrlHint,
  11. describeDroppedKeys,
  12. normalizeBaseUrl,
  13. probeEndpoint,
  14. toProviderSpec,
  15. } from './providerService';
  16. import type { ApplyProviderInput } from './types';
  17. /** applyProvider 会往 getCodexDataDir() 落 provider.json;挪到临时目录,绝不碰客户端真实数据目录 */
  18. vi.mock('./codexHome', async (importOriginal) => {
  19. const actual = await importOriginal<typeof import('./codexHome')>();
  20. // 目录只能算一次:providerFile() 每次都会调 getCodexDataDir(),给出不同路径就写不进同一处
  21. const dir = mkdtempSync(join(tmpdir(), 'zsjz-provider-test-'));
  22. return { ...actual, getCodexDataDir: () => dir };
  23. });
  24. let server: Server;
  25. let port = 0;
  26. let responsesStatus = 404;
  27. let chatStatus = 404;
  28. let lastRequest: { url: string | null; auth: string | null; body: string | null } = { url: null, auth: null, body: null };
  29. beforeAll(async () => {
  30. server = createServer((req, res) => {
  31. const chunks: Buffer[] = [];
  32. req.on('data', (chunk: Buffer) => chunks.push(chunk));
  33. req.on('end', () => {
  34. lastRequest = {
  35. url: req.url ?? null,
  36. auth: req.headers.authorization ?? null,
  37. body: Buffer.concat(chunks).toString('utf8') || null,
  38. };
  39. res.setHeader('content-type', 'application/json');
  40. const status = req.url?.endsWith('/responses')
  41. ? responsesStatus
  42. : req.url?.endsWith('/chat/completions')
  43. ? chatStatus
  44. : 404;
  45. res.writeHead(status).end(JSON.stringify({ status }));
  46. });
  47. });
  48. await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  49. const address = server.address();
  50. port = typeof address === 'object' && address ? address.port : 0;
  51. });
  52. afterAll(async () => {
  53. await new Promise<void>((resolve) => server.close(() => resolve()));
  54. });
  55. const openaiLike: ApplyProviderInput = {
  56. modelId: 'qwen3-max',
  57. name: '通义千问',
  58. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
  59. apiKey: 'sk-test-key',
  60. headersJson: JSON.stringify({ 'X-DashScope-WorkSpace': 'ws-1' }),
  61. config: JSON.stringify({ request_max_retries: 3, temperature: 0.2 }),
  62. };
  63. describe('normalizeBaseUrl / baseUrlHint', () => {
  64. it('去掉尾部斜杠,空值返回 null', () => {
  65. expect(normalizeBaseUrl('https://x.com/v1///')).toBe('https://x.com/v1');
  66. expect(normalizeBaseUrl(' ')).toBeNull();
  67. expect(normalizeBaseUrl(null)).toBeNull();
  68. });
  69. it('缺版本段时给非阻断提示', () => {
  70. expect(baseUrlHint('https://x.com')).toMatch(/v1/);
  71. expect(baseUrlHint('https://x.com/v1')).toBeNull();
  72. expect(baseUrlHint('ftp://x.com/v1')).toMatch(/http/);
  73. expect(baseUrlHint(null)).toMatch(/未配置/);
  74. });
  75. });
  76. describe('toProviderSpec', () => {
  77. it('OpenAI 兼容端点:写 model_providers.zsjz,key 只留给 env', () => {
  78. const spec = toProviderSpec(openaiLike);
  79. expect(spec).toMatchObject({
  80. id: 'zsjz',
  81. model: 'qwen3-max',
  82. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  83. apiKey: 'sk-test-key',
  84. envKey: 'ZSJZ_CODEX_API_KEY',
  85. });
  86. expect(spec.httpHeaders).toEqual({ 'X-DashScope-WorkSpace': 'ws-1' });
  87. // config 里只放行白名单键,temperature 必须被丢掉
  88. expect(spec.extra).toMatchObject({ request_max_retries: 3 });
  89. expect(spec.extra).not.toHaveProperty('temperature');
  90. });
  91. it('地址原样使用,尾部斜杠规整;没配 key 就不写 env_key', () => {
  92. const spec = toProviderSpec({ modelId: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1/' });
  93. expect(spec).toMatchObject({ id: 'zsjz', model: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1' });
  94. expect(spec.apiKey ?? null).toBeNull();
  95. });
  96. it('空闲窗口必须显著大于 Codex 默认的 300 秒,且不写等于默认值的无效项', () => {
  97. const spec = toProviderSpec({ modelId: 'm', baseUrl: 'http://10.66.66.66:8080/v1' });
  98. expect(spec.extra).toMatchObject({
  99. request_max_retries: 0,
  100. stream_max_retries: 0,
  101. // Codex 默认 300_000(model-provider-info/src/lib.rs:29);写 300_000 等于没写
  102. stream_idle_timeout_ms: 1_800_000,
  103. });
  104. expect(spec.extra).toBeTypeOf('object');
  105. // supports_websockets 的 serde 默认就是 false,用 -c 声明的 provider 根本不走 WS,
  106. // 写进默认值只会让下一位误以为这里防住了什么(15 秒那条说法来自 websocket 握手超时)
  107. expect(spec.extra).not.toHaveProperty('supports_websockets');
  108. });
  109. it('模型管理 config 里显式写的值优先于默认', () => {
  110. const spec = toProviderSpec({
  111. modelId: 'm',
  112. baseUrl: 'http://x/v1',
  113. config: { stream_idle_timeout_ms: 5_000, stream_max_retries: 3 },
  114. });
  115. expect(spec.extra).toMatchObject({
  116. stream_idle_timeout_ms: 5_000,
  117. stream_max_retries: 3,
  118. request_max_retries: 0,
  119. });
  120. });
  121. it('缺 modelId 或缺 baseUrl 直接报错,不做任何地址兜底', () => {
  122. expect(() => toProviderSpec({ modelId: ' ' })).toThrow(/modelId/);
  123. expect(() => toProviderSpec({ modelId: 'gpt' })).toThrow(/base_url/);
  124. expect(() => toProviderSpec({ modelId: 'gpt', baseUrl: ' ' })).toThrow(/base_url/);
  125. });
  126. it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => {
  127. expect(toProviderSpec({ ...openaiLike, headersJson: { A: '1' } }).httpHeaders).toEqual({ A: '1' });
  128. expect(toProviderSpec({ ...openaiLike, headersJson: '{bad json' }).httpHeaders).toBeNull();
  129. expect(toProviderSpec({ ...openaiLike, config: { stream_max_retries: 5 } }).extra).toMatchObject({
  130. stream_max_retries: 5,
  131. });
  132. });
  133. it('describeDroppedKeys 报告被丢弃的非白名单键', () => {
  134. expect(describeDroppedKeys(openaiLike)).toEqual(['temperature']);
  135. expect(describeDroppedKeys({ modelId: 'x' })).toEqual([]);
  136. });
  137. });
  138. describe('probeEndpoint', () => {
  139. const base = () => `http://127.0.0.1:${port}/v1`;
  140. it('只探 Responses 路由存在性,请求体不带 input(不能触发推理)', async () => {
  141. responsesStatus = 400;
  142. const result = await probeEndpoint({
  143. baseUrl: `${base()}/`,
  144. modelId: 'qwen3-max',
  145. apiKey: 'sk-test-key',
  146. });
  147. expect(result.protocol).toBe('responses');
  148. expect(lastRequest.url).toBe('/v1/responses');
  149. expect(lastRequest.auth).toBe('Bearer sk-test-key');
  150. const body = JSON.parse(lastRequest.body ?? '{}') as Record<string, unknown>;
  151. expect(body.model).toBe('qwen3-max');
  152. // 一旦带上 input,单槽本地服务就会真的开始生成,实测一次要 14 秒
  153. expect(body.input).toBeUndefined();
  154. });
  155. it('2xx 与 4xx 只要有路由就判 responses 可用', async () => {
  156. for (const status of [200, 400, 401]) {
  157. responsesStatus = status;
  158. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  159. expect(result).toMatchObject({ protocol: 'responses', responsesStatus: status });
  160. }
  161. });
  162. it('没有 Responses 但有 Chat → chat-only,说明将由内置路由层桥接对接', async () => {
  163. responsesStatus = 404;
  164. chatStatus = 400;
  165. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  166. expect(result.protocol).toBe('chat-only');
  167. expect(result.detail).toMatch(/没有 \/responses/);
  168. expect(result.detail).toMatch(/路由层/);
  169. });
  170. it('两条路由都没有 → unsupported', async () => {
  171. responsesStatus = 404;
  172. chatStatus = 404;
  173. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  174. expect(result.protocol).toBe('unsupported');
  175. });
  176. it('端点不可达时报错带上是哪个地址', async () => {
  177. const result = await probeEndpoint({ baseUrl: 'http://127.0.0.1:1/v1', modelId: 'x' });
  178. expect(result.protocol).toBe('unreachable');
  179. expect(result.responsesStatus).toBeNull();
  180. expect(result.detail).toMatch(/不可达/);
  181. expect(result.detail).toContain('http://127.0.0.1:1/v1/responses');
  182. });
  183. });
  184. /**
  185. * 客户端真正走的是 applyProvider:探端点 → 把 spec 交给运行时重启子进程。
  186. * 这里用假运行时把「交给 Codex 的最终参数」钉死 —— 真机 smoke 用的是手搓 spec,覆盖不到这一段。
  187. */
  188. describe('applyProvider 交给运行时的 spec', () => {
  189. class StubRuntime {
  190. spec: CodexProviderSpec | null | undefined;
  191. calls = 0;
  192. async applyProvider(spec: CodexProviderSpec | null): Promise<void> {
  193. this.calls += 1;
  194. this.spec = spec;
  195. }
  196. }
  197. it('Codex 直连上游真实地址,并带上有效的重试/空闲窗口参数', async () => {
  198. responsesStatus = 400;
  199. const stub = new StubRuntime();
  200. const upstream = `http://127.0.0.1:${port}/v1`;
  201. const applied = await applyProvider(stub as unknown as CodexRuntime, {
  202. modelId: 'qwen',
  203. name: '自部署 Qwen',
  204. baseUrl: upstream,
  205. modelRecordId: 7,
  206. });
  207. const spec = stub.spec;
  208. expect(spec).not.toBeNull();
  209. expect(spec?.id).toBe('zsjz');
  210. expect(spec?.model).toBe('qwen');
  211. // 不做任何翻译:Codex 连的就是模型记录里那个地址
  212. expect(spec?.baseUrl).toBe(upstream);
  213. expect(spec?.extra).toMatchObject({
  214. request_max_retries: 0,
  215. stream_max_retries: 0,
  216. stream_idle_timeout_ms: 1_800_000,
  217. });
  218. expect(applied).toMatchObject({ model: 'qwen', modelRecordId: '7', baseUrl: upstream, bridged: false });
  219. });
  220. it('落盘 model-catalog.json 并把路径挂到 spec,目录条目带 slug 与 instructions_template', async () => {
  221. responsesStatus = 400;
  222. const stub = new StubRuntime();
  223. await applyProvider(stub as unknown as CodexRuntime, {
  224. modelId: 'qwen',
  225. name: '自部署 Qwen',
  226. baseUrl: `http://127.0.0.1:${port}/v1`,
  227. });
  228. const spec = stub.spec;
  229. expect(spec?.modelCatalogPath).toBeTruthy();
  230. const catalog = JSON.parse(readFileSync(spec!.modelCatalogPath!, 'utf8')) as {
  231. models: Array<Record<string, unknown>>;
  232. };
  233. expect(catalog.models).toHaveLength(1);
  234. const entry = catalog.models[0] as Record<string, unknown> & {
  235. slug: string;
  236. display_name: string;
  237. model_messages: { instructions_template: string };
  238. };
  239. // slug 必须与应用的 model 一致,codex 靠它命中元数据(used_fallback 归零、警告消失)
  240. expect(entry.slug).toBe('qwen');
  241. expect(entry.display_name).toBe('自部署 Qwen');
  242. // 0.155.1 校验:目录模型缺 instructions_template 会在启动时报错
  243. expect(entry.model_messages.instructions_template.length).toBeGreaterThan(1000);
  244. });
  245. it('端点只会 Chat 时起路由层桥接:spec 指向本地路由,落盘保留上游真实地址', async () => {
  246. responsesStatus = 404;
  247. chatStatus = 400;
  248. const stub = new StubRuntime();
  249. const upstream = `http://127.0.0.1:${port}/v1`;
  250. const applied = await applyProvider(stub as unknown as CodexRuntime, {
  251. modelId: 'qwen',
  252. baseUrl: upstream,
  253. modelRecordId: 9,
  254. });
  255. const spec = stub.spec;
  256. expect(spec).not.toBeNull();
  257. // codex 的 base_url 是本地路由地址(127.0.0.1 随机端口 + /v1),绝不是上游地址
  258. expect(spec?.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1$/u);
  259. expect(spec?.baseUrl).not.toBe(upstream);
  260. // 落盘快照保存真实上游地址与 bridged 标记,供页面与 modelApplied 比对
  261. expect(applied.bridged).toBe(true);
  262. expect(applied.baseUrl).toBe(upstream);
  263. // 目录文件的 slug 仍是上游模型 id,桥接不改变模型名
  264. expect(stub.spec?.modelCatalogPath).toBeTruthy();
  265. });
  266. it('路由层桥接应用失败时回滚停掉路由', async () => {
  267. responsesStatus = 404;
  268. chatStatus = 400;
  269. class FailingRuntime {
  270. calls = 0;
  271. async applyProvider(): Promise<void> {
  272. this.calls += 1;
  273. throw new Error('runtime boom');
  274. }
  275. }
  276. await expect(
  277. applyProvider(new FailingRuntime() as unknown as CodexRuntime, {
  278. modelId: 'qwen',
  279. baseUrl: `http://127.0.0.1:${port}/v1`,
  280. }),
  281. ).rejects.toThrow('runtime boom');
  282. expect(chatRouter.running).toBe(false);
  283. });
  284. });