239 lines
7.5 KiB
TypeScript
239 lines
7.5 KiB
TypeScript
import { describe, expect, it } from 'bun:test';
|
|
import {
|
|
buildProviderHealthProbe,
|
|
mergeProviderHealthModels,
|
|
primaryProviderHealthCredential,
|
|
runProviderModelHealthChecks,
|
|
} from '../src/services/providerHealthCheck';
|
|
import { modelEndpointCandidates } from '../src/services/modelService';
|
|
|
|
describe('API 接入健康检测', () => {
|
|
it('取消后不派发剩余模型,也不更新已关闭的界面', async () => {
|
|
const controller = new AbortController();
|
|
const requested: string[] = [];
|
|
const delivered: string[] = [];
|
|
let release!: () => void;
|
|
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
const checking = runProviderModelHealthChecks(
|
|
Array.from({ length: 100 }, (_, index) => ({ name: String(index) })),
|
|
async (model) => {
|
|
requested.push(model.name);
|
|
await gate;
|
|
return { model: model.name, status: 'healthy', success: true };
|
|
},
|
|
(result) => delivered.push(result.model),
|
|
4,
|
|
controller.signal,
|
|
);
|
|
expect(requested).toHaveLength(4);
|
|
controller.abort();
|
|
release();
|
|
expect(await checking).toEqual([]);
|
|
expect(requested).toHaveLength(4);
|
|
expect(delivered).toEqual([]);
|
|
});
|
|
|
|
it('已取消的批次不会发起请求', async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
let requested = false;
|
|
const results = await runProviderModelHealthChecks([{ name: 'unused' }], async (model) => {
|
|
requested = true;
|
|
return { model: model.name, status: 'healthy', success: true };
|
|
}, undefined, 4, controller.signal);
|
|
expect(results).toEqual([]);
|
|
expect(requested).toBe(false);
|
|
});
|
|
|
|
it('健康检测只保留已勾选模型,并使用发现结果补充模型信息', () => {
|
|
const models = mergeProviderHealthModels(
|
|
[
|
|
{ name: 'model-b' },
|
|
{ name: 'model-a', contextWindow: 200_000 },
|
|
{ name: 'model-c' },
|
|
],
|
|
[{ name: 'MODEL-A', alias: 'model-a-alias' }, { name: 'model-c' }],
|
|
);
|
|
|
|
expect(models).toEqual([
|
|
{ name: 'MODEL-A', alias: 'model-a-alias', contextWindow: 200_000 },
|
|
{ name: 'model-c' },
|
|
]);
|
|
});
|
|
|
|
it('没有勾选模型时不允许发现列表自动加入健康检测', () => {
|
|
expect(mergeProviderHealthModels(
|
|
[{ name: 'model-a' }, { name: 'model-b' }],
|
|
[],
|
|
)).toEqual([]);
|
|
});
|
|
|
|
it('逐模型检测只使用当前接入的首个有效密钥', () => {
|
|
expect(primaryProviderHealthCredential(['', ' first-key ', 'second-key'])).toBe('first-key');
|
|
expect(primaryProviderHealthCredential([])).toBe('');
|
|
});
|
|
|
|
it('为 OpenAI 兼容接入构造最小 chat completions 请求并保留自定义头', () => {
|
|
const probe = buildProviderHealthProbe(
|
|
'openai',
|
|
'https://openrouter.example/api/v1',
|
|
'gpt-test',
|
|
'secret-key',
|
|
'',
|
|
{ 'X-Team': 'production' },
|
|
);
|
|
|
|
expect(probe.url).toBe('https://openrouter.example/api/v1/chat/completions');
|
|
expect(probe.header).toMatchObject({
|
|
Authorization: 'Bearer secret-key',
|
|
'Content-Type': 'application/json',
|
|
'X-Team': 'production',
|
|
});
|
|
expect(JSON.parse(probe.data)).toEqual({
|
|
model: 'gpt-test',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
stream: true,
|
|
});
|
|
expect(probe.protocol).toBe('openai-chat');
|
|
});
|
|
|
|
it('为非 v1 的 OpenAI 兼容版本前缀保留原始版本号', () => {
|
|
const probe = buildProviderHealthProbe(
|
|
'openai',
|
|
'https://open.bigmodel.cn/api/coding/paas/v4',
|
|
'glm-test',
|
|
'secret-key',
|
|
);
|
|
|
|
expect(probe.url).toBe(
|
|
'https://open.bigmodel.cn/api/coding/paas/v4/chat/completions',
|
|
);
|
|
expect(modelEndpointCandidates(
|
|
'openai',
|
|
'https://open.bigmodel.cn/api/coding/paas/v4',
|
|
)).toEqual([
|
|
'https://open.bigmodel.cn/api/coding/paas/v4/models',
|
|
]);
|
|
});
|
|
|
|
it('把不含版本号的中转地址视为完整 API 路径前缀', () => {
|
|
const baseUrl = 'https://relay.example/custom/openai';
|
|
const probe = buildProviderHealthProbe(
|
|
'openai',
|
|
baseUrl,
|
|
'mapped-model',
|
|
'secret-key',
|
|
);
|
|
|
|
expect(probe.url).toBe(
|
|
'https://relay.example/custom/openai/chat/completions',
|
|
);
|
|
expect(modelEndpointCandidates('openai', baseUrl)).toEqual([
|
|
'https://relay.example/custom/openai/models',
|
|
]);
|
|
expect(modelEndpointCandidates(
|
|
'openai',
|
|
'https://relay.example/custom/openai/v1/models',
|
|
)).toEqual([
|
|
'https://relay.example/custom/openai/v1/models',
|
|
]);
|
|
expect(modelEndpointCandidates('codex', 'https://codex.example')).toEqual([
|
|
'https://codex.example/v1/models',
|
|
]);
|
|
});
|
|
|
|
it('不会覆盖供应商已有的自定义认证头', () => {
|
|
const probe = buildProviderHealthProbe(
|
|
'claude',
|
|
'',
|
|
'claude-test',
|
|
'new-key',
|
|
'',
|
|
{ 'X-Api-Key': 'custom-key', 'Anthropic-Version': 'custom-version' },
|
|
);
|
|
|
|
expect(probe.url).toBe('https://api.anthropic.com/v1/messages');
|
|
expect(probe.header['X-Api-Key']).toBe('custom-key');
|
|
expect(probe.header['Anthropic-Version']).toBe('custom-version');
|
|
expect(probe.header['x-api-key']).toBeUndefined();
|
|
expect(probe.header['anthropic-version']).toBeUndefined();
|
|
});
|
|
|
|
it('使用运行时凭据占位符构造 Codex responses 请求', () => {
|
|
const probe = buildProviderHealthProbe(
|
|
'codex',
|
|
'https://codex.example/v1/responses',
|
|
'gpt-5-codex',
|
|
'',
|
|
'runtime-auth-index',
|
|
);
|
|
|
|
expect(probe.url).toBe('https://codex.example/v1/responses');
|
|
expect(probe.header.Authorization).toBe('Bearer $TOKEN$');
|
|
expect(JSON.parse(probe.data)).toEqual({
|
|
model: 'gpt-5-codex',
|
|
input: 'hi',
|
|
stream: true,
|
|
});
|
|
expect(probe.protocol).toBe('openai-responses');
|
|
});
|
|
|
|
it('为 DeepSeek 使用 Codex Responses 协议并保留自定义路径前缀', () => {
|
|
const probe = buildProviderHealthProbe(
|
|
'deepseek',
|
|
'https://api.deepseek.com',
|
|
'deepseek-chat',
|
|
'deepseek-key',
|
|
);
|
|
|
|
expect(probe.url).toBe('https://api.deepseek.com/responses');
|
|
expect(probe.protocol).toBe('openai-responses');
|
|
expect(JSON.parse(probe.data)).toEqual({
|
|
model: 'deepseek-chat',
|
|
input: 'hi',
|
|
stream: true,
|
|
});
|
|
expect(modelEndpointCandidates('deepseek', 'https://api.deepseek.com')).toEqual([
|
|
'https://api.deepseek.com/models',
|
|
]);
|
|
});
|
|
|
|
it('为 Gemini 使用默认地址并移除 models/ 前缀', () => {
|
|
const probe = buildProviderHealthProbe(
|
|
'gemini',
|
|
'',
|
|
'models/gemini-2.5-flash',
|
|
'gemini-key',
|
|
);
|
|
|
|
expect(probe.url).toBe(
|
|
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?alt=sse',
|
|
);
|
|
expect(probe.header['x-goog-api-key']).toBe('gemini-key');
|
|
expect(probe.protocol).toBe('gemini');
|
|
expect(probe.model).toBe('gemini-2.5-flash');
|
|
expect(probe.source).toBe('gemini-key');
|
|
});
|
|
|
|
it('检测全部时每个模型只请求一次并保持结果顺序', async () => {
|
|
const requested: string[] = [];
|
|
const results = await runProviderModelHealthChecks([
|
|
{ name: 'model-a' },
|
|
{ name: 'model-b' },
|
|
{ name: 'model-c' },
|
|
], async (model) => {
|
|
requested.push(model.name);
|
|
return {
|
|
model: model.name,
|
|
status: 'healthy',
|
|
success: true,
|
|
firstTokenLatencyMs: 120,
|
|
};
|
|
}, undefined, 2);
|
|
|
|
expect(requested.sort()).toEqual(['model-a', 'model-b', 'model-c']);
|
|
expect(results.map((result) => result.model)).toEqual(['model-a', 'model-b', 'model-c']);
|
|
});
|
|
|
|
});
|