providerService.test.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  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. probeResponsesEndpoint,
  8. toProviderSpec,
  9. } from './providerService';
  10. import type { ApplyProviderInput } from './types';
  11. let server: Server;
  12. let port = 0;
  13. let nextStatus = 404;
  14. let lastRequest: { url: string | null; auth: string | null } = { url: null, auth: null };
  15. beforeAll(async () => {
  16. server = createServer((req, res) => {
  17. lastRequest = { url: req.url ?? null, auth: req.headers.authorization ?? null };
  18. req.resume();
  19. res.writeHead(nextStatus, { 'content-type': 'application/json' });
  20. res.end(JSON.stringify({ status: nextStatus }));
  21. });
  22. await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  23. const address = server.address();
  24. port = typeof address === 'object' && address ? address.port : 0;
  25. });
  26. afterAll(async () => {
  27. await new Promise<void>((resolve) => server.close(() => resolve()));
  28. });
  29. const openaiLike: ApplyProviderInput = {
  30. modelId: 'qwen3-max',
  31. name: '通义千问',
  32. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
  33. apiKey: 'sk-test-key',
  34. providerType: 'DASHSCOPE',
  35. headersJson: JSON.stringify({ 'X-DashScope-WorkSpace': 'ws-1' }),
  36. config: JSON.stringify({ request_max_retries: 3, temperature: 0.2 }),
  37. };
  38. describe('normalizeBaseUrl / baseUrlHint', () => {
  39. it('去掉尾部斜杠,空值返回 null', () => {
  40. expect(normalizeBaseUrl('https://x.com/v1///')).toBe('https://x.com/v1');
  41. expect(normalizeBaseUrl(' ')).toBeNull();
  42. expect(normalizeBaseUrl(null)).toBeNull();
  43. });
  44. it('缺版本段时给非阻断提示', () => {
  45. expect(baseUrlHint('https://x.com')).toMatch(/v1/);
  46. expect(baseUrlHint('https://x.com/v1')).toBeNull();
  47. expect(baseUrlHint('ftp://x.com/v1')).toMatch(/http/);
  48. expect(baseUrlHint(null)).toMatch(/未配置/);
  49. });
  50. });
  51. describe('toProviderSpec', () => {
  52. it('OpenAI 兼容端点:写 model_providers.zsjz,key 只留给 env', () => {
  53. const spec = toProviderSpec(openaiLike);
  54. expect(spec).toMatchObject({
  55. id: 'zsjz',
  56. model: 'qwen3-max',
  57. baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  58. apiKey: 'sk-test-key',
  59. envKey: 'ZSJZ_CODEX_API_KEY',
  60. });
  61. expect(spec.builtinProvider ?? null).toBeNull();
  62. expect(spec.httpHeaders).toEqual({ 'X-DashScope-WorkSpace': 'ws-1' });
  63. // config 里只放行白名单键,temperature 必须被丢掉
  64. expect(spec.extra).toEqual({ request_max_retries: 3 });
  65. });
  66. it('OLLAMA 走 Codex 内置 provider,不需要 baseUrl 与 key', () => {
  67. const spec = toProviderSpec({ modelId: 'qwen3:8b', providerType: 'OLLAMA' });
  68. expect(spec).toMatchObject({ id: null, builtinProvider: 'ollama', model: 'qwen3:8b' });
  69. expect(spec.baseUrl).toBeNull();
  70. });
  71. it('远端 Ollama 必须保留并补齐 base_url,不能退回 localhost:11434', () => {
  72. expect(
  73. toProviderSpec({ modelId: 'qwen', providerType: 'OLLAMA', baseUrl: 'http://10.66.66.66:8080' }).baseUrl,
  74. ).toBe('http://10.66.66.66:8080/v1');
  75. expect(
  76. toProviderSpec({ modelId: 'qwen', providerType: 'OLLAMA', baseUrl: 'http://host:11434/v1/' }).baseUrl,
  77. ).toBe('http://host:11434/v1');
  78. });
  79. it('可显式指定内置 provider(lmstudio)', () => {
  80. const spec = toProviderSpec({ modelId: 'local-model', builtinProvider: 'lmstudio' });
  81. expect(spec.builtinProvider).toBe('lmstudio');
  82. });
  83. it('缺 modelId 或非内置 provider 缺 baseUrl 时报错', () => {
  84. expect(() => toProviderSpec({ modelId: ' ' })).toThrow(/modelId/);
  85. expect(() => toProviderSpec({ modelId: 'gpt', providerType: 'OPENAI' })).toThrow(/baseUrl/);
  86. });
  87. it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => {
  88. expect(toProviderSpec({ ...openaiLike, headersJson: { A: '1' } }).httpHeaders).toEqual({ A: '1' });
  89. expect(toProviderSpec({ ...openaiLike, headersJson: '{bad json' }).httpHeaders).toBeNull();
  90. expect(toProviderSpec({ ...openaiLike, config: { stream_max_retries: 5 } }).extra).toEqual({
  91. stream_max_retries: 5,
  92. });
  93. });
  94. it('describeDroppedKeys 报告被丢弃的非白名单键', () => {
  95. expect(describeDroppedKeys(openaiLike)).toEqual(['temperature']);
  96. expect(describeDroppedKeys({ modelId: 'x' })).toEqual([]);
  97. });
  98. });
  99. describe('probeResponsesEndpoint', () => {
  100. it('打到 base_url 下的 /responses', async () => {
  101. nextStatus = 401;
  102. await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1/`, 'sk-test-key');
  103. expect(lastRequest.url).toBe('/v1/responses');
  104. expect(lastRequest.auth).toBe('Bearer sk-test-key');
  105. });
  106. it('404/405 判为未实现 Responses API', async () => {
  107. nextStatus = 404;
  108. const notFound = await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1`);
  109. expect(notFound).toMatchObject({ compatible: false, status: 404 });
  110. expect(notFound.detail).toMatch(/未实现 Responses API/);
  111. nextStatus = 405;
  112. expect((await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1`)).compatible).toBe(false);
  113. });
  114. it('401/400/2xx 判为路由存在(兼容)', async () => {
  115. for (const status of [401, 400, 200]) {
  116. nextStatus = status;
  117. const result = await probeResponsesEndpoint(`http://127.0.0.1:${port}/v1`);
  118. expect(result).toMatchObject({ compatible: true, status });
  119. }
  120. });
  121. it('端点不可达时 status 为 null', async () => {
  122. const result = await probeResponsesEndpoint('http://127.0.0.1:1/v1');
  123. expect(result.compatible).toBe(false);
  124. expect(result.status).toBeNull();
  125. expect(result.detail).toMatch(/不可达/);
  126. });
  127. });