providerService.test.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  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).toEqual({ request_max_retries: 3 });
  71. });
  72. it('地址原样使用,尾部斜杠规整;没配 key 就不写 env_key', () => {
  73. const spec = toProviderSpec({ modelId: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1/' });
  74. expect(spec).toMatchObject({ id: 'zsjz', model: 'qwen', baseUrl: 'http://10.66.66.66:8080/v1' });
  75. expect(spec.apiKey ?? null).toBeNull();
  76. });
  77. it('缺 modelId 或缺 baseUrl 直接报错,不做任何地址兜底', () => {
  78. expect(() => toProviderSpec({ modelId: ' ' })).toThrow(/modelId/);
  79. expect(() => toProviderSpec({ modelId: 'gpt' })).toThrow(/base_url/);
  80. expect(() => toProviderSpec({ modelId: 'gpt', baseUrl: ' ' })).toThrow(/base_url/);
  81. });
  82. it('headersJson 与 config 支持对象或 JSON 字符串,非法 JSON 忽略', () => {
  83. expect(toProviderSpec({ ...openaiLike, headersJson: { A: '1' } }).httpHeaders).toEqual({ A: '1' });
  84. expect(toProviderSpec({ ...openaiLike, headersJson: '{bad json' }).httpHeaders).toBeNull();
  85. expect(toProviderSpec({ ...openaiLike, config: { stream_max_retries: 5 } }).extra).toEqual({
  86. stream_max_retries: 5,
  87. });
  88. });
  89. it('describeDroppedKeys 报告被丢弃的非白名单键', () => {
  90. expect(describeDroppedKeys(openaiLike)).toEqual(['temperature']);
  91. expect(describeDroppedKeys({ modelId: 'x' })).toEqual([]);
  92. });
  93. });
  94. describe('probeEndpoint', () => {
  95. const base = () => `http://127.0.0.1:${port}/v1`;
  96. it('只探 Chat 路由存在性,请求体不带 messages(不能触发推理)', async () => {
  97. chatStatus = 400;
  98. const result = await probeEndpoint({
  99. baseUrl: `${base()}/`,
  100. modelId: 'qwen3-max',
  101. apiKey: 'sk-test-key',
  102. });
  103. expect(result.protocol).toBe('chat');
  104. expect(lastRequest.url).toBe('/v1/chat/completions');
  105. expect(lastRequest.auth).toBe('Bearer sk-test-key');
  106. const body = JSON.parse(lastRequest.body ?? '{}') as Record<string, unknown>;
  107. expect(body.model).toBe('qwen3-max');
  108. // 一旦带上 messages,单槽本地服务就会真的开始生成,实测一次要 14 秒
  109. expect(body.messages).toBeUndefined();
  110. });
  111. it('2xx 与 4xx 只要有路由就判 chat 可用', async () => {
  112. for (const status of [200, 400, 401]) {
  113. chatStatus = status;
  114. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  115. expect(result).toMatchObject({ protocol: 'chat', chatStatus: status });
  116. }
  117. });
  118. it('没有 Chat 路由判 unsupported', async () => {
  119. chatStatus = 404;
  120. const result = await probeEndpoint({ baseUrl: base(), modelId: 'x' });
  121. expect(result.protocol).toBe('unsupported');
  122. expect(result.detail).toMatch(/没有 \/chat\/completions/);
  123. });
  124. it('端点不可达时报错带上是哪个地址', async () => {
  125. const result = await probeEndpoint({ baseUrl: 'http://127.0.0.1:1/v1', modelId: 'x' });
  126. expect(result.protocol).toBe('unreachable');
  127. expect(result.chatStatus).toBeNull();
  128. expect(result.detail).toMatch(/不可达/);
  129. expect(result.detail).toContain('http://127.0.0.1:1/v1/chat/completions');
  130. });
  131. });