providerService.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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.builtinProvider ?? null).toBeNull();
  80. expect(spec.httpHeaders).toEqual({ 'X-DashScope-WorkSpace': 'ws-1' });
  81. // config 里只放行白名单键,temperature 必须被丢掉
  82. expect(spec.extra).toEqual({ request_max_retries: 3 });
  83. });
  84. it('OLLAMA 走 Codex 内置 provider,不需要 baseUrl 与 key', () => {
  85. const spec = toProviderSpec({ modelId: 'qwen3:8b', providerType: 'OLLAMA' });
  86. expect(spec).toMatchObject({ id: null, builtinProvider: 'ollama', model: 'qwen3:8b' });
  87. expect(spec.baseUrl).toBeNull();
  88. });
  89. it('远端 Ollama 必须保留并补齐 base_url,不能退回 localhost:11434', () => {
  90. expect(
  91. toProviderSpec({ modelId: 'qwen', providerType: 'OLLAMA', baseUrl: 'http://10.66.66.66:8080' }).baseUrl,
  92. ).toBe('http://10.66.66.66:8080/v1');
  93. expect(
  94. toProviderSpec({ modelId: 'qwen', providerType: 'OLLAMA', baseUrl: 'http://host:11434/v1/' }).baseUrl,
  95. ).toBe('http://host:11434/v1');
  96. });
  97. it('可显式指定内置 provider(lmstudio)', () => {
  98. const spec = toProviderSpec({ modelId: 'local-model', builtinProvider: 'lmstudio' });
  99. expect(spec.builtinProvider).toBe('lmstudio');
  100. });
  101. it('缺 modelId 或非内置 provider 缺 baseUrl 时报错', () => {
  102. expect(() => toProviderSpec({ modelId: ' ' })).toThrow(/modelId/);
  103. expect(() => toProviderSpec({ modelId: 'gpt', providerType: 'OPENAI' })).toThrow(/baseUrl/);
  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).toEqual({
  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('用真实模型名探 /responses,带上 api key', async () => {
  120. responsesStatus = 401;
  121. const result = await probeEndpoint({
  122. baseUrl: `${base()}/`,
  123. modelId: 'qwen3-max',
  124. apiKey: 'sk-test-key',
  125. });
  126. expect(result.protocol).toBe('responses');
  127. expect(lastRequest.url).toBe('/v1/responses');
  128. expect(lastRequest.auth).toBe('Bearer sk-test-key');
  129. expect(JSON.parse(lastRequest.body ?? '{}').model).toBe('qwen3-max');
  130. });
  131. it('401/400/2xx 判为 responses 直通', async () => {
  132. for (const status of [401, 400, 200]) {
  133. responsesStatus = status;
  134. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  135. expect(result).toMatchObject({ protocol: 'responses', responsesStatus: status });
  136. }
  137. });
  138. it('双协议 404 判 unsupported', async () => {
  139. responsesStatus = 404;
  140. chatStatus = 404;
  141. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x', providerType: 'OPENAI' });
  142. expect(result.protocol).toBe('unsupported');
  143. expect(result.detail).toMatch(/无法对接/);
  144. });
  145. it('responses 404 + chat 存在判 chat-only', async () => {
  146. responsesStatus = 404;
  147. chatStatus = 200;
  148. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x', providerType: 'VLLM' });
  149. expect(result).toMatchObject({ protocol: 'chat-only', responsesStatus: 404, chatStatus: 200 });
  150. expect(result.detail).toMatch(/chat\/completions/);
  151. });
  152. it('Ollama ≥ 0.13.3:/responses 404 只是模型未拉取,判 responses', async () => {
  153. responsesStatus = 404;
  154. chatStatus = 404;
  155. ollamaVersion = '0.13.3';
  156. const result = await probeEndpoint({ baseUrl: base(), modelId: 'qwen3:8b', providerType: 'OLLAMA' });
  157. expect(result).toMatchObject({ protocol: 'responses', ollamaVersion: '0.13.3', ollamaNeedsUpgrade: false });
  158. expect(result.detail).toMatch(/ollama pull/);
  159. });
  160. it('Ollama < 0.13.3:判 chat-only 并标记需要升级', async () => {
  161. responsesStatus = 404;
  162. chatStatus = 200;
  163. ollamaVersion = '0.12.9';
  164. const result = await probeEndpoint({ baseUrl: base(), modelId: 'qwen3:8b', providerType: 'OLLAMA' });
  165. expect(result).toMatchObject({ protocol: 'chat-only', ollamaVersion: '0.12.9', ollamaNeedsUpgrade: true });
  166. expect(result.detail).toMatch(/低于 0\.13\.3/);
  167. ollamaVersion = null;
  168. });
  169. it('端点不可达判 unreachable', async () => {
  170. const result = await probeEndpoint({ baseUrl: 'http://127.0.0.1:1/v1', modelId: 'x' });
  171. expect(result.protocol).toBe('unreachable');
  172. expect(result.responsesStatus).toBeNull();
  173. expect(result.detail).toMatch(/不可达/);
  174. });
  175. });