providerService.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { createServer, type Server } from 'node:http';
  2. import { mkdtempSync } 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 { chatBridge } from './chatBridgeService';
  7. import type { CodexProviderSpec, CodexRuntime } from './codexRuntime';
  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 chatStatus = 404;
  27. let lastRequest: { url: string | null; auth: string | null; body: string | null } = { url: null, auth: null, body: null };
  28. beforeAll(async () => {
  29. server = createServer((req, res) => {
  30. const chunks: Buffer[] = [];
  31. req.on('data', (chunk: Buffer) => chunks.push(chunk));
  32. req.on('end', () => {
  33. lastRequest = {
  34. url: req.url ?? null,
  35. auth: req.headers.authorization ?? null,
  36. body: Buffer.concat(chunks).toString('utf8') || null,
  37. };
  38. res.setHeader('content-type', 'application/json');
  39. const status = req.url?.endsWith('/chat/completions') ? chatStatus : 404;
  40. res.writeHead(status).end(JSON.stringify({ status }));
  41. });
  42. });
  43. await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  44. const address = server.address();
  45. port = typeof address === 'object' && address ? address.port : 0;
  46. });
  47. afterAll(async () => {
  48. await new Promise<void>((resolve) => server.close(() => resolve()));
  49. });
  50. const openaiLike: ApplyProviderInput = {
  51. modelId: 'qwen3-max',
  52. name: '通义千问',
  53. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
  54. apiKey: 'sk-test-key',
  55. headersJson: JSON.stringify({ 'X-DashScope-WorkSpace': 'ws-1' }),
  56. config: JSON.stringify({ request_max_retries: 3, temperature: 0.2 }),
  57. };
  58. describe('normalizeBaseUrl / baseUrlHint', () => {
  59. it('去掉尾部斜杠,空值返回 null', () => {
  60. expect(normalizeBaseUrl('https://x.com/v1///')).toBe('https://x.com/v1');
  61. expect(normalizeBaseUrl(' ')).toBeNull();
  62. expect(normalizeBaseUrl(null)).toBeNull();
  63. });
  64. it('缺版本段时给非阻断提示', () => {
  65. expect(baseUrlHint('https://x.com')).toMatch(/v1/);
  66. expect(baseUrlHint('https://x.com/v1')).toBeNull();
  67. expect(baseUrlHint('ftp://x.com/v1')).toMatch(/http/);
  68. expect(baseUrlHint(null)).toMatch(/未配置/);
  69. });
  70. });
  71. describe('toProviderSpec', () => {
  72. it('OpenAI 兼容端点:写 model_providers.zsjz,key 只留给 env', () => {
  73. const spec = toProviderSpec(openaiLike);
  74. expect(spec).toMatchObject({
  75. id: 'zsjz',
  76. model: 'qwen3-max',
  77. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  78. apiKey: 'sk-test-key',
  79. envKey: 'ZSJZ_CODEX_API_KEY',
  80. });
  81. expect(spec.httpHeaders).toEqual({ 'X-DashScope-WorkSpace': 'ws-1' });
  82. // config 里只放行白名单键,temperature 必须被丢掉
  83. expect(spec.extra).toMatchObject({ request_max_retries: 3 });
  84. expect(spec.extra).not.toHaveProperty('temperature');
  85. });
  86. it('地址原样使用,尾部斜杠规整;没配 key 就不写 env_key', () => {
  87. const spec = toProviderSpec({ modelId: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1/' });
  88. expect(spec).toMatchObject({ id: 'zsjz', model: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1' });
  89. expect(spec.apiKey ?? null).toBeNull();
  90. });
  91. it('自部署端点默认关掉重试放大、放宽空闲超时、不走 websocket', () => {
  92. const spec = toProviderSpec({ modelId: 'm', baseUrl: 'http://10.66.66.66:8080/v1' });
  93. // 单槽推理上一次 prefill 要几分钟,Codex 默认 5 次重试 = 同样几分钟的活儿重复五遍
  94. expect(spec.extra).toMatchObject({
  95. request_max_retries: 0,
  96. stream_max_retries: 0,
  97. stream_idle_timeout_ms: 300_000,
  98. supports_websockets: false,
  99. });
  100. });
  101. it('模型管理 config 里显式写的值优先于默认', () => {
  102. const spec = toProviderSpec({
  103. modelId: 'm',
  104. baseUrl: 'http://x/v1',
  105. config: { stream_idle_timeout_ms: 5_000, stream_max_retries: 3 },
  106. });
  107. expect(spec.extra).toMatchObject({
  108. stream_idle_timeout_ms: 5_000,
  109. stream_max_retries: 3,
  110. request_max_retries: 0,
  111. });
  112. });
  113. it('缺 modelId 或缺 baseUrl 直接报错,不做任何地址兜底', () => {
  114. expect(() => toProviderSpec({ modelId: ' ' })).toThrow(/modelId/);
  115. expect(() => toProviderSpec({ modelId: 'gpt' })).toThrow(/base_url/);
  116. expect(() => toProviderSpec({ modelId: 'gpt', baseUrl: ' ' })).toThrow(/base_url/);
  117. });
  118. it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => {
  119. expect(toProviderSpec({ ...openaiLike, headersJson: { A: '1' } }).httpHeaders).toEqual({ A: '1' });
  120. expect(toProviderSpec({ ...openaiLike, headersJson: '{bad json' }).httpHeaders).toBeNull();
  121. expect(toProviderSpec({ ...openaiLike, config: { stream_max_retries: 5 } }).extra).toMatchObject({
  122. stream_max_retries: 5,
  123. });
  124. });
  125. it('describeDroppedKeys 报告被丢弃的非白名单键', () => {
  126. expect(describeDroppedKeys(openaiLike)).toEqual(['temperature']);
  127. expect(describeDroppedKeys({ modelId: 'x' })).toEqual([]);
  128. });
  129. });
  130. describe('probeEndpoint', () => {
  131. const base = () => `http://127.0.0.1:${port}/v1`;
  132. it('只探 Chat 路由存在性,请求体不带 messages(不能触发推理)', async () => {
  133. chatStatus = 400;
  134. const result = await probeEndpoint({
  135. baseUrl: `${base()}/`,
  136. modelId: 'qwen3-max',
  137. apiKey: 'sk-test-key',
  138. });
  139. expect(result.protocol).toBe('chat');
  140. expect(lastRequest.url).toBe('/v1/chat/completions');
  141. expect(lastRequest.auth).toBe('Bearer sk-test-key');
  142. const body = JSON.parse(lastRequest.body ?? '{}') as Record<string, unknown>;
  143. expect(body.model).toBe('qwen3-max');
  144. // 一旦带上 messages,单槽本地服务就会真的开始生成,实测一次要 14 秒
  145. expect(body.messages).toBeUndefined();
  146. });
  147. it('2xx 与 4xx 只要有路由就判 chat 可用', async () => {
  148. for (const status of [200, 400, 401]) {
  149. chatStatus = status;
  150. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  151. expect(result).toMatchObject({ protocol: 'chat', chatStatus: status });
  152. }
  153. });
  154. it('没有 Chat 路由判 unsupported', async () => {
  155. chatStatus = 404;
  156. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  157. expect(result.protocol).toBe('unsupported');
  158. expect(result.detail).toMatch(/没有 \/chat\/completions/);
  159. });
  160. it('端点不可达时报错带上是哪个地址', async () => {
  161. const result = await probeEndpoint({ baseUrl: 'http://127.0.0.1:1/v1', modelId: 'x' });
  162. expect(result.protocol).toBe('unreachable');
  163. expect(result.chatStatus).toBeNull();
  164. expect(result.detail).toMatch(/不可达/);
  165. expect(result.detail).toContain('http://127.0.0.1:1/v1/chat/completions');
  166. });
  167. });
  168. /**
  169. * 客户端真正走的是 applyProvider:探测 → 起桥接 → 把 spec 交给运行时重启子进程。
  170. * 这里用假运行时把「交给 Codex 的最终参数」钉死 —— 真机 smoke 用的是手搓 spec,覆盖不到这一段。
  171. */
  172. describe('applyProvider 交给运行时的 spec', () => {
  173. class StubRuntime {
  174. spec: CodexProviderSpec | null | undefined;
  175. calls = 0;
  176. async applyProvider(spec: CodexProviderSpec | null): Promise<void> {
  177. this.calls += 1;
  178. this.spec = spec;
  179. }
  180. }
  181. it('一律自定义 provider + 桥接地址,并带上单槽端点该有的重试/超时开关', async () => {
  182. chatStatus = 200;
  183. const stub = new StubRuntime();
  184. const upstream = `http://127.0.0.1:${port}/v1`;
  185. const applied = await applyProvider(stub as unknown as CodexRuntime, {
  186. modelId: 'qwen',
  187. name: '自部署 Qwen',
  188. baseUrl: upstream,
  189. modelRecordId: 7,
  190. });
  191. try {
  192. const spec = stub.spec;
  193. expect(spec).not.toBeNull();
  194. expect(spec?.id).toBe('zsjz');
  195. expect(spec?.model).toBe('qwen');
  196. // Codex 连的是本地桥接,不是上游
  197. expect(spec?.baseUrl).toBe(chatBridge.info?.url);
  198. expect(spec?.baseUrl).not.toBe(upstream);
  199. expect(spec?.extra).toMatchObject({
  200. request_max_retries: 0,
  201. stream_max_retries: 0,
  202. stream_idle_timeout_ms: 300_000,
  203. supports_websockets: false,
  204. });
  205. // 落盘的是上游真实地址,便于页面展示
  206. expect(applied).toMatchObject({ model: 'qwen', modelRecordId: '7', baseUrl: upstream, bridged: true });
  207. } finally {
  208. await chatBridge.stop();
  209. }
  210. });
  211. it('上游没有 Chat 路由时直接报错,且不碰运行时', async () => {
  212. chatStatus = 404;
  213. const stub = new StubRuntime();
  214. await expect(
  215. applyProvider(stub as unknown as CodexRuntime, { modelId: 'qwen', baseUrl: `http://127.0.0.1:${port}/v1` }),
  216. ).rejects.toThrow(/没有 \/chat\/completions/);
  217. expect(stub.calls).toBe(0);
  218. expect(stub.spec).toBeUndefined();
  219. });
  220. });