providerService.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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 responsesStatus = 404;
  14. let chatStatus = 404;
  15. let ollamaVersion: string | null = null;
  16. let lastRequest: { url: string | null; auth: string | null; body: string | null } = { url: null, auth: null, body: null };
  17. beforeAll(async () => {
  18. server = createServer((req, res) => {
  19. const chunks: Buffer[] = [];
  20. req.on('data', (chunk: Buffer) => chunks.push(chunk));
  21. req.on('end', () => {
  22. lastRequest = {
  23. url: req.url ?? null,
  24. auth: req.headers.authorization ?? null,
  25. body: Buffer.concat(chunks).toString('utf8') || null,
  26. };
  27. res.setHeader('content-type', 'application/json');
  28. if (req.url === '/api/version') {
  29. if (ollamaVersion === null) {
  30. res.writeHead(404).end('{}');
  31. } else {
  32. res.writeHead(200).end(JSON.stringify({ version: ollamaVersion }));
  33. }
  34. return;
  35. }
  36. const status = req.url?.endsWith('/chat/completions') ? chatStatus : responsesStatus;
  37. res.writeHead(status).end(JSON.stringify({ status }));
  38. });
  39. });
  40. await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  41. const address = server.address();
  42. port = typeof address === 'object' && address ? address.port : 0;
  43. });
  44. afterAll(async () => {
  45. await new Promise<void>((resolve) => server.close(() => resolve()));
  46. });
  47. const openaiLike: ApplyProviderInput = {
  48. modelId: 'qwen3-max',
  49. name: '通义千问',
  50. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
  51. apiKey: 'sk-test-key',
  52. providerType: 'DASHSCOPE',
  53. headersJson: JSON.stringify({ 'X-DashScope-WorkSpace': 'ws-1' }),
  54. config: JSON.stringify({ request_max_retries: 3, temperature: 0.2 }),
  55. };
  56. describe('normalizeBaseUrl / baseUrlHint', () => {
  57. it('去掉尾部斜杠,空值返回 null', () => {
  58. expect(normalizeBaseUrl('https://x.com/v1///')).toBe('https://x.com/v1');
  59. expect(normalizeBaseUrl(' ')).toBeNull();
  60. expect(normalizeBaseUrl(null)).toBeNull();
  61. });
  62. it('缺版本段时给非阻断提示', () => {
  63. expect(baseUrlHint('https://x.com')).toMatch(/v1/);
  64. expect(baseUrlHint('https://x.com/v1')).toBeNull();
  65. expect(baseUrlHint('ftp://x.com/v1')).toMatch(/http/);
  66. expect(baseUrlHint(null)).toMatch(/未配置/);
  67. });
  68. });
  69. describe('toProviderSpec', () => {
  70. it('OpenAI 兼容端点:写 model_providers.zsjz,key 只留给 env', () => {
  71. const spec = toProviderSpec(openaiLike);
  72. expect(spec).toMatchObject({
  73. id: 'zsjz',
  74. model: 'qwen3-max',
  75. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  76. apiKey: 'sk-test-key',
  77. envKey: 'ZSJZ_CODEX_API_KEY',
  78. });
  79. expect(spec.httpHeaders).toEqual({ 'X-DashScope-WorkSpace': 'ws-1' });
  80. // config 里只放行白名单键,temperature 必须被丢掉
  81. expect(spec.extra).toEqual({ request_max_retries: 3 });
  82. });
  83. it('OLLAMA 同样走自定义 provider:内置 id 是 Codex 保留字,地址原样使用', () => {
  84. const spec = toProviderSpec({
  85. modelId: 'qwen',
  86. providerType: 'OLLAMA',
  87. baseUrl: 'http://10.66.66.66:8080/v1/',
  88. });
  89. expect(spec).toMatchObject({ id: 'zsjz', model: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1' });
  90. // 本地服务没有 key,就不该写 env_key
  91. expect(spec.apiKey ?? null).toBeNull();
  92. });
  93. it('缺 modelId 或缺 baseUrl 直接报错,不退回 localhost 内置 provider', () => {
  94. expect(() => toProviderSpec({ modelId: ' ' })).toThrow(/modelId/);
  95. expect(() => toProviderSpec({ modelId: 'gpt', providerType: 'OPENAI' })).toThrow(/base_url/);
  96. expect(() => toProviderSpec({ modelId: 'qwen3:8b', providerType: 'OLLAMA' })).toThrow(/base_url/);
  97. });
  98. it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => {
  99. expect(toProviderSpec({ ...openaiLike, headersJson: { A: '1' } }).httpHeaders).toEqual({ A: '1' });
  100. expect(toProviderSpec({ ...openaiLike, headersJson: '{bad json' }).httpHeaders).toBeNull();
  101. expect(toProviderSpec({ ...openaiLike, config: { stream_max_retries: 5 } }).extra).toEqual({
  102. stream_max_retries: 5,
  103. });
  104. });
  105. it('describeDroppedKeys 报告被丢弃的非白名单键', () => {
  106. expect(describeDroppedKeys(openaiLike)).toEqual(['temperature']);
  107. expect(describeDroppedKeys({ modelId: 'x' })).toEqual([]);
  108. });
  109. });
  110. describe('probeEndpoint', () => {
  111. const base = () => `http://127.0.0.1:${port}/v1`;
  112. it('用真实模型名探 /responses,带上 api key', async () => {
  113. responsesStatus = 401;
  114. const result = await probeEndpoint({
  115. baseUrl: `${base()}/`,
  116. modelId: 'qwen3-max',
  117. apiKey: 'sk-test-key',
  118. });
  119. expect(result.protocol).toBe('responses');
  120. expect(lastRequest.url).toBe('/v1/responses');
  121. expect(lastRequest.auth).toBe('Bearer sk-test-key');
  122. expect(JSON.parse(lastRequest.body ?? '{}').model).toBe('qwen3-max');
  123. });
  124. it('401/400/2xx 判为 responses 直通', async () => {
  125. for (const status of [401, 400, 200]) {
  126. responsesStatus = status;
  127. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  128. expect(result).toMatchObject({ protocol: 'responses', responsesStatus: status });
  129. }
  130. });
  131. it('双协议 404 判 unsupported', async () => {
  132. responsesStatus = 404;
  133. chatStatus = 404;
  134. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x', providerType: 'OPENAI' });
  135. expect(result.protocol).toBe('unsupported');
  136. expect(result.detail).toMatch(/无法对接/);
  137. });
  138. it('responses 404 + chat 存在判 chat-only', async () => {
  139. responsesStatus = 404;
  140. chatStatus = 200;
  141. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x', providerType: 'VLLM' });
  142. expect(result).toMatchObject({ protocol: 'chat-only', responsesStatus: 404, chatStatus: 200 });
  143. expect(result.detail).toMatch(/chat\/completions/);
  144. });
  145. it('Ollama ≥ 0.13.3:/responses 404 只是模型未拉取,判 responses', async () => {
  146. responsesStatus = 404;
  147. chatStatus = 404;
  148. ollamaVersion = '0.13.3';
  149. const result = await probeEndpoint({ baseUrl: base(), modelId: 'qwen3:8b', providerType: 'OLLAMA' });
  150. expect(result).toMatchObject({ protocol: 'responses', ollamaVersion: '0.13.3', ollamaNeedsUpgrade: false });
  151. expect(result.detail).toMatch(/ollama pull/);
  152. });
  153. it('Ollama < 0.13.3:判 chat-only 并标记需要升级', async () => {
  154. responsesStatus = 404;
  155. chatStatus = 200;
  156. ollamaVersion = '0.12.9';
  157. const result = await probeEndpoint({ baseUrl: base(), modelId: 'qwen3:8b', providerType: 'OLLAMA' });
  158. expect(result).toMatchObject({ protocol: 'chat-only', ollamaVersion: '0.12.9', ollamaNeedsUpgrade: true });
  159. expect(result.detail).toMatch(/低于 0\.13\.3/);
  160. ollamaVersion = null;
  161. });
  162. it('端点不可达判 unreachable', async () => {
  163. const result = await probeEndpoint({ baseUrl: 'http://127.0.0.1:1/v1', modelId: 'x' });
  164. expect(result.protocol).toBe('unreachable');
  165. expect(result.responsesStatus).toBeNull();
  166. expect(result.detail).toMatch(/不可达/);
  167. });
  168. });