Files
EasyCLIProxyAPI/tests/providerTemplate.test.ts
T
lzt404 c89e2fb020 feat(config): support CLIProxyAPI v8.0.10 template settings
- Add structured editors for request, OAuth, plugin, and concurrency settings
- Expand advanced configuration for all eight provider families and credentials
- Preserve YAML comments, secrets, unknown fields, and explicit overrides
- Add validation, conflict detection, default restoration, and restart notices
- Improve responsive layouts and localize configuration controls
- Fix provider health checks and extend mocks and integration tests
2026-10-04 17:31:52 +08:00

129 lines
8.5 KiB
TypeScript

import { describe, expect, it } from 'bun:test';
import { buildProviderGroupRecord, providerDraftFromRecord, type ProviderSection } from '../src/pages/ApiAccessPage';
import { appendNativeProviderGroup, validateProviderTemplateRecord, validateProviderGroupKeys, providerKeyDraft } from '../src/services/providerGroups';
import { modelEndpointCandidates, modelsFromRecord } from '../src/services/modelService';
import { buildProviderHealthProbe } from '../src/services/providerHealthCheck';
const sections: ProviderSection[] = ['gemini-api-key', 'interactions-api-key', 'vertex-api-key', 'codex-api-key', 'claude-api-key', 'xai-api-key', 'meta-api-key', 'openai-compatibility'];
describe('v8 provider template fields', () => {
for (const section of sections) it(`${section} preserves unknown data, explicit defaults and inheritance`, () => {
const group = {
name: 'production', priority: 0, headers: {}, 'request-retry': -1, 'request-scoped-errors': [],
extension: { future: false }, models: null,
keys: [{ 'api-key': 'first', weight: -2, 'request-scoped-errors': null, models: [], headers: null, 'disable-cooling': false },
{ 'api-key': 'second', weight: 0, 'proxy-url': '', extension: { opaque: 0 } }],
};
const draft = providerDraftFromRecord(section, group);
draft.name = 'renamed';
expect(buildProviderGroupRecord(section, draft, group)).toEqual({ ...group, name: 'renamed' });
});
it('edits group error rules separately from key overrides without merging lists', () => {
const group = { name: 'gateway', 'request-retry': 5,
'request-scoped-errors': [{ status: 400, match: ['old'], action: 'continue', opaque: { keep: true } }],
keys: [{ 'api-key': 'inherited', 'request-scoped-errors': null }, { 'api-key': 'cleared', 'request-scoped-errors': [] }] };
const draft = providerDraftFromRecord('codex-api-key', group);
draft.templateFields!['request-retry'] = 0;
(draft.templateFields!['request-scoped-errors'] as any[])[0].match = [];
const saved = buildProviderGroupRecord('codex-api-key', draft, group);
expect(saved['request-retry']).toBe(0);
expect(saved['request-scoped-errors']).toEqual([{ ...group['request-scoped-errors'][0], match: [] }]);
expect(saved.keys).toEqual(group.keys);
delete draft.templateFields!['request-retry'];
expect(buildProviderGroupRecord('codex-api-key', draft, group)).not.toHaveProperty('request-retry');
});
it('supports explicit model capabilities and removal without dropping future metadata or pooled aliases', () => {
const models = [
{ name: 'model-a', alias: 'pooled', 'display-name': 'A', 'max-context-length': 32768, 'is-compat': true,
thinking: { levels: ['low', 'high'], 'zero-allowed': false, vendor: 1 }, custom: { preserve: [] } },
{ name: 'model-b', alias: 'pooled', image: false, 'input-modalities': ['text', 'image'], 'output-modalities': ['text'] },
];
const group = { name: 'gateway', models, keys: [{ 'api-key': 'key' }] };
const draft = providerDraftFromRecord('openai-compatibility', group);
const first = draft.models[0];
first.config = { ...first.config, 'is-compat': false, 'max-context-length': 0, 'display-name': '' };
delete first.config.thinking;
delete first.thinking;
const second = draft.models[1];
second.config = { ...second.config, 'input-modalities': [], 'output-modalities': [], 'use-max-completion-tokens': false };
const saved = buildProviderGroupRecord('openai-compatibility', draft, group);
expect(saved.models).toEqual([
{ name: 'model-a', alias: 'pooled', 'display-name': '', 'max-context-length': 0, 'is-compat': false, custom: { preserve: [] } },
{ ...models[1], 'input-modalities': [], 'output-modalities': [], 'use-max-completion-tokens': false },
]);
});
it('retains per-key provider options when rotating the credential', () => {
for (const options of [
{ 'disable-codex-cloaking': false, 'alpha-search': true, websockets: false },
{ 'rebuild-mid-system-message': false, 'fingerprint-profile': '', 'experimental-cch-signing': false,
cloak: { mode: 'never', 'strict-mode': false, 'sensitive-words': [], 'cache-user-id': false, custom: {} } },
]) {
const group = { name: 'gateway', keys: [{ 'api-key': 'old', ...options }] };
const draft = providerDraftFromRecord('claude-api-key', group);
draft.groupKeys![0].value['api-key'] = 'new';
expect(buildProviderGroupRecord('claude-api-key', draft, group).keys).toEqual([{ 'api-key': 'new', ...options }]);
}
});
it('rejects invalid retry/error/model values and accepts negative weights that disable weighted routing', () => {
for (const value of [
{ 'request-retry': 1.5 }, { 'request-scoped-errors': [{ status: 600, action: 'stop' }] },
{ 'request-scoped-errors': [{ status: 400, action: 'retry' }] },
{ models: [{ name: 'm', 'max-context-length': -1 }] },
{ models: [{ name: 'm', thinking: { min: 500, max: 200 } }] },
{ keys: [{ models: [{ name: 'm', thinking: { levels: [2] } }] }] },
]) expect(validateProviderTemplateRecord(value)).not.toBeNull();
expect(validateProviderTemplateRecord({ 'request-retry': -1, keys: [{ weight: -1 }] })).toBeNull();
expect(validateProviderGroupKeys([providerKeyDraft({ 'api-key': 'k', weight: -1 })])).toBeNull();
});
it('EasyMode appends one native group without flattening or losing existing overrides', () => {
const existing = [{ name: 'codex', opaque: { keep: null }, models: [{ name: 'm', thinking: { levels: [] } }], keys: [
{ 'api-key': 'a', weight: 5, headers: null, models: null },
{ 'api-key': 'b', weight: 0, headers: {}, models: [], 'request-retry': 0 },
] }, { name: 'codex (2)', keys: [] }];
const snapshot = structuredClone(existing);
const next = appendNativeProviderGroup(existing, { name: 'codex', keys: [{ 'api-key': 'c' }] });
expect(next.slice(0, 2)).toEqual(snapshot);
expect(next[2]).toEqual({ name: 'codex (3)', keys: [{ 'api-key': 'c' }] });
expect(existing).toEqual(snapshot);
});
it('retains full model metadata only when requested for the configuration editor', () => {
const record = { name: 'm', 'support-configuration-update': false, thinking: { zero_allowed: false, levels: [] }, unknown: [] };
expect(modelsFromRecord([record], true)[0].config).toEqual({ ...record, thinking: { 'zero-allowed': false, levels: [] } });
expect(modelsFromRecord([record])[0]).not.toHaveProperty('config');
});
});
describe('new native provider connections', () => {
it('uses native model discovery defaults', () => {
expect(modelEndpointCandidates('interactions', '')).toEqual(['https://generativelanguage.googleapis.com/v1beta/models']);
expect(modelEndpointCandidates('vertex', '')[0]).toBe('https://aiplatform.googleapis.com/v1/publishers/google/models');
expect(modelEndpointCandidates('xai', '')).toEqual(['https://api.x.ai/v1/models']);
expect(modelEndpointCandidates('meta', '')).toEqual(['https://api.meta.ai/v1/models']);
});
it('probes Interactions and Vertex with Google keys and their native endpoints', () => {
const interactions = buildProviderHealthProbe('interactions', '', 'models/gemini-3-pro-preview', 'google-key');
expect(interactions.url).toBe('https://generativelanguage.googleapis.com/v1beta/interactions');
expect(interactions.protocol).toBe('interactions');
expect(interactions.header['x-goog-api-key']).toBe('google-key');
expect(JSON.parse(interactions.data)).toEqual({ model: 'gemini-3-pro-preview', input: 'hi', stream: true });
const vertex = buildProviderHealthProbe('vertex', '', 'gemini-3-pro-preview', 'vertex-key');
expect(vertex.url).toBe('https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-pro-preview:streamGenerateContent?alt=sse');
expect(vertex.header['x-goog-api-key']).toBe('vertex-key');
});
it('uses native Responses defaults for xAI and Meta', () => {
for (const [provider, host] of [['xai', 'api.x.ai'], ['meta', 'api.meta.ai']] as const) {
const probe = buildProviderHealthProbe(provider, '', 'native-model', 'key');
expect(probe.url).toBe(`https://${host}/v1/responses`);
expect(probe.protocol).toBe('openai-responses');
expect(probe.header.Authorization).toBe('Bearer key');
expect(buildProviderHealthProbe(provider, 'https://gateway.test/api', 'native-model', 'key').url).toBe('https://gateway.test/api/responses');
expect(modelEndpointCandidates(provider, 'https://gateway.test/api')).toEqual(['https://gateway.test/api/models']);
}
});
});