providerService.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import { createServer, type Server } from 'node:http';
  2. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  3. import {
  4. baseUrlHint,
  5. describeDroppedKeys,
  6. normalizeBaseUrl,
  7. probeEndpoint,
  8. toProviderSpec,
  9. } from './providerService';
  10. import type { ApplyProviderInput } from './types';
  11. let server: Server;
  12. let port = 0;
  13. let chatStatus = 404;
  14. let lastRequest: { url: string | null; auth: string | null; body: string | null } = { url: null, auth: null, body: null };
  15. beforeAll(async () => {
  16. server = createServer((req, res) => {
  17. const chunks: Buffer[] = [];
  18. req.on('data', (chunk: Buffer) => chunks.push(chunk));
  19. req.on('end', () => {
  20. lastRequest = {
  21. url: req.url ?? null,
  22. auth: req.headers.authorization ?? null,
  23. body: Buffer.concat(chunks).toString('utf8') || null,
  24. };
  25. res.setHeader('content-type', 'application/json');
  26. const status = req.url?.endsWith('/chat/completions') ? chatStatus : 404;
  27. res.writeHead(status).end(JSON.stringify({ status }));
  28. });
  29. });
  30. await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  31. const address = server.address();
  32. port = typeof address === 'object' && address ? address.port : 0;
  33. });
  34. afterAll(async () => {
  35. await new Promise<void>((resolve) => server.close(() => resolve()));
  36. });
  37. const openaiLike: ApplyProviderInput = {
  38. modelId: 'qwen3-max',
  39. name: '通义千问',
  40. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
  41. apiKey: 'sk-test-key',
  42. headersJson: JSON.stringify({ 'X-DashScope-WorkSpace': 'ws-1' }),
  43. config: JSON.stringify({ request_max_retries: 3, temperature: 0.2 }),
  44. };
  45. describe('normalizeBaseUrl / baseUrlHint', () => {
  46. it('去掉尾部斜杠,空值返回 null', () => {
  47. expect(normalizeBaseUrl('https://x.com/v1///')).toBe('https://x.com/v1');
  48. expect(normalizeBaseUrl(' ')).toBeNull();
  49. expect(normalizeBaseUrl(null)).toBeNull();
  50. });
  51. it('缺版本段时给非阻断提示', () => {
  52. expect(baseUrlHint('https://x.com')).toMatch(/v1/);
  53. expect(baseUrlHint('https://x.com/v1')).toBeNull();
  54. expect(baseUrlHint('ftp://x.com/v1')).toMatch(/http/);
  55. expect(baseUrlHint(null)).toMatch(/未配置/);
  56. });
  57. });
  58. describe('toProviderSpec', () => {
  59. it('OpenAI 兼容端点:写 model_providers.zsjz,key 只留给 env', () => {
  60. const spec = toProviderSpec(openaiLike);
  61. expect(spec).toMatchObject({
  62. id: 'zsjz',
  63. model: 'qwen3-max',
  64. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  65. apiKey: 'sk-test-key',
  66. envKey: 'ZSJZ_CODEX_API_KEY',
  67. });
  68. expect(spec.httpHeaders).toEqual({ 'X-DashScope-WorkSpace': 'ws-1' });
  69. // config 里只放行白名单键,temperature 必须被丢掉
  70. expect(spec.extra).toMatchObject({ request_max_retries: 3 });
  71. expect(spec.extra).not.toHaveProperty('temperature');
  72. });
  73. it('地址原样使用,尾部斜杠规整;没配 key 就不写 env_key', () => {
  74. const spec = toProviderSpec({ modelId: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1/' });
  75. expect(spec).toMatchObject({ id: 'zsjz', model: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1' });
  76. expect(spec.apiKey ?? null).toBeNull();
  77. });
  78. it('自部署端点默认关掉重试放大、放宽空闲超时、不走 websocket', () => {
  79. const spec = toProviderSpec({ modelId: 'm', baseUrl: 'http://10.66.66.66:8080/v1' });
  80. // 单槽推理上一次 prefill 要几分钟,Codex 默认 5 次重试 = 同样几分钟的活儿重复五遍
  81. expect(spec.extra).toMatchObject({
  82. request_max_retries: 0,
  83. stream_max_retries: 0,
  84. stream_idle_timeout_ms: 300_000,
  85. supports_websockets: false,
  86. });
  87. });
  88. it('模型管理 config 里显式写的值优先于默认', () => {
  89. const spec = toProviderSpec({
  90. modelId: 'm',
  91. baseUrl: 'http://x/v1',
  92. config: { stream_idle_timeout_ms: 5_000, stream_max_retries: 3 },
  93. });
  94. expect(spec.extra).toMatchObject({
  95. stream_idle_timeout_ms: 5_000,
  96. stream_max_retries: 3,
  97. request_max_retries: 0,
  98. });
  99. });
  100. it('缺 modelId 或缺 baseUrl 直接报错,不做任何地址兜底', () => {
  101. expect(() => toProviderSpec({ modelId: ' ' })).toThrow(/modelId/);
  102. expect(() => toProviderSpec({ modelId: 'gpt' })).toThrow(/base_url/);
  103. expect(() => toProviderSpec({ modelId: 'gpt', baseUrl: ' ' })).toThrow(/base_url/);
  104. });
  105. it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => {
  106. expect(toProviderSpec({ ...openaiLike, headersJson: { A: '1' } }).httpHeaders).toEqual({ A: '1' });
  107. expect(toProviderSpec({ ...openaiLike, headersJson: '{bad json' }).httpHeaders).toBeNull();
  108. expect(toProviderSpec({ ...openaiLike, config: { stream_max_retries: 5 } }).extra).toMatchObject({
  109. stream_max_retries: 5,
  110. });
  111. });
  112. it('describeDroppedKeys 报告被丢弃的非白名单键', () => {
  113. expect(describeDroppedKeys(openaiLike)).toEqual(['temperature']);
  114. expect(describeDroppedKeys({ modelId: 'x' })).toEqual([]);
  115. });
  116. });
  117. describe('probeEndpoint', () => {
  118. const base = () => `http://127.0.0.1:${port}/v1`;
  119. it('只探 Chat 路由存在性,请求体不带 messages(不能触发推理)', async () => {
  120. chatStatus = 400;
  121. const result = await probeEndpoint({
  122. baseUrl: `${base()}/`,
  123. modelId: 'qwen3-max',
  124. apiKey: 'sk-test-key',
  125. });
  126. expect(result.protocol).toBe('chat');
  127. expect(lastRequest.url).toBe('/v1/chat/completions');
  128. expect(lastRequest.auth).toBe('Bearer sk-test-key');
  129. const body = JSON.parse(lastRequest.body ?? '{}') as Record<string, unknown>;
  130. expect(body.model).toBe('qwen3-max');
  131. // 一旦带上 messages,单槽本地服务就会真的开始生成,实测一次要 14 秒
  132. expect(body.messages).toBeUndefined();
  133. });
  134. it('2xx 与 4xx 只要有路由就判 chat 可用', async () => {
  135. for (const status of [200, 400, 401]) {
  136. chatStatus = status;
  137. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  138. expect(result).toMatchObject({ protocol: 'chat', chatStatus: status });
  139. }
  140. });
  141. it('没有 Chat 路由判 unsupported', async () => {
  142. chatStatus = 404;
  143. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  144. expect(result.protocol).toBe('unsupported');
  145. expect(result.detail).toMatch(/没有 \/chat\/completions/);
  146. });
  147. it('端点不可达时报错带上是哪个地址', async () => {
  148. const result = await probeEndpoint({ baseUrl: 'http://127.0.0.1:1/v1', modelId: 'x' });
  149. expect(result.protocol).toBe('unreachable');
  150. expect(result.chatStatus).toBeNull();
  151. expect(result.detail).toMatch(/不可达/);
  152. expect(result.detail).toContain('http://127.0.0.1:1/v1/chat/completions');
  153. });
  154. });