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
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 4d1afc82ef
commit c89e2fb020
46 files changed
+3839 -235

No files matched your search

+15 -1
View File
@@ -6,7 +6,7 @@ describe('credential settings', () => {
const draft = authFileSettingsFromPayload(JSON.stringify({ prefix: 'team', 'proxy-url': 'socks5://localhost:1080', priority: -4, weight: 6,
'disable-cooling': false, websocket: true, 'excluded-models': [' GPT-* ', 'gpt-*'], headers: { 'X-Team': 'test' }, note: 'note', access_token: 'secret' }));
expect(draft).toEqual({ prefix: 'team', proxy_url: 'socks5://localhost:1080', priority: '-4', weight: '6', disable_cooling: 'false',
websockets: 'true', excluded_models: 'gpt-*', headers: '{\n "X-Team": "test"\n}', note: 'note' });
websockets: 'true', excluded_models: 'gpt-*', headers: '{\n "X-Team": "test"\n}', note: 'note', advanced: {} });
expect(JSON.stringify(draft)).not.toContain('secret');
});
@@ -85,4 +85,18 @@ describe('credential settings', () => {
get: async () => ({}), patch: async () => { throw new Error('save failed'); },
})).rejects.toThrow('save failed');
});
it('edits template credential fields without resending tokens or unmodified metadata', () => {
const original = authFileSettingsFromPayload({ access_token: 'never-copy', cloak_mode: 'auto', timezone: 'Asia/Tokyo',
cloak_strict_mode: true, model_aliases: [{ name: 'upstream', alias: 'public', future_metadata: true }] });
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, cloak_mode: 'always', cloak_strict_mode: false } }))
.toEqual({ cloak_mode: 'always', cloak_strict_mode: false });
const next = { ...original.advanced }; delete next.timezone;
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: next })).toEqual({ timezone: null });
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, model_aliases: [] } })).toEqual({ model_aliases: [] });
expect(JSON.stringify(original)).not.toContain('never-copy');
for (const advanced of [{ timezone: 'Not/AZone' }, { cloak_mode: 'invalid' }, { model_aliases: [{ name: '', alias: 'x' }] }]) {
expect(() => buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, ...advanced } })).toThrow();
}
});
});
+89
View File
@@ -96,3 +96,92 @@ describe('browser mock runtime', () => {
expect(await runtime.invoke('plugin:app|version')).toBe('0.2.97-mock');
});
});
describe('browser mock native provider groups', () => {
const providers = ['gemini', 'interactions', 'vertex', 'codex', 'claude', 'xai', 'meta', 'openai-compatibility'] as const;
const request = (runtime: ReturnType<typeof createBrowserMockRuntime>, method: string, path: string, body?: unknown) =>
runtime.invoke('management_request', { request: { method, path, body } });
for (const provider of providers) {
test(`preserves native ${provider} groups, overrides, and extensions across saves`, async () => {
const runtime = createBrowserMockRuntime('running');
const path = `/config/api-keys/${provider}`;
const groups = [{
name: `${provider} production`,
'base-url': 'https://provider.example/v1',
headers: { 'X-Team': 'shared' },
models: [{ name: 'model', alias: 'primary', 'future-model-option': false }],
'request-retry': 0,
'future-group-option': { enabled: false, values: [] },
keys: [
{ 'api-key': 'first', weight: 2, headers: null, models: [], 'future-key-option': 0 },
{ 'api-key': 'second', 'proxy-url': '', headers: {}, models: null },
],
}, { name: `${provider} empty standby`, keys: [], 'future-group-option': null }];
expect(await request(runtime, 'GET', path)).toBeArray();
expect(await request(runtime, 'PUT', path, groups)).toEqual({ status: 'ok', 'config-version': 8 });
expect(await request(runtime, 'GET', path)).toEqual(groups);
expect(await request(runtime, 'GET', '/config')).toMatchObject({
'config-version': 8, 'api-keys': { [provider]: groups },
});
// Reading the legacy view must not flatten the authoritative state.
const legacyPath = provider === 'openai-compatibility' ? path.slice('/config/api-keys'.length) : `/${provider}-api-key`;
await request(runtime, 'GET', legacyPath);
expect(await request(runtime, 'GET', path)).toEqual(groups);
// Both writes and reads isolate stored values from caller mutations.
const expected = structuredClone(groups);
groups[0].keys[0]['api-key'] = 'mutated request';
const readBack = await request(runtime, 'GET', path) as typeof groups;
readBack[0].keys[0]['api-key'] = 'mutated response';
expect(await request(runtime, 'GET', path)).toEqual(expected);
await request(runtime, 'PUT', path, []);
expect(await request(runtime, 'GET', path)).toEqual([]);
});
}
test('derives effective legacy key records without changing native inheritance', async () => {
const runtime = createBrowserMockRuntime('running');
const groups = [{
name: 'preserve this group name',
'base-url': 'https://provider.example',
priority: 5,
headers: { 'X-Team': 'shared' },
models: [{ name: 'shared-model' }],
keys: [
{ 'api-key': 'first', priority: 0, headers: null, models: [] },
{ 'api-key': 'second', headers: {}, models: null },
],
}];
await request(runtime, 'PUT', '/config/api-keys/interactions', groups);
expect(await request(runtime, 'GET', '/interactions-api-key')).toEqual({
'interactions-api-key': [
{ 'api-key': 'first', 'base-url': 'https://provider.example', priority: 0, headers: { 'X-Team': 'shared' }, models: [] },
{ 'api-key': 'second', 'base-url': 'https://provider.example', priority: 5, headers: {}, models: [{ name: 'shared-model' }] },
],
});
expect(await request(runtime, 'GET', '/config/api-keys/interactions')).toEqual(groups);
});
test('adapts legacy OpenAI mutations while preserving native group metadata and keys', async () => {
const runtime = createBrowserMockRuntime('running');
const group = {
name: 'custom group', 'base-url': 'https://provider.example/v1', disabled: false,
'future-option': { enabled: false },
keys: [{ 'api-key': 'first', weight: 0, 'proxy-url': '' }, { 'api-key': 'second', weight: 3 }],
};
await request(runtime, 'PUT', '/config/api-keys/openai-compatibility', [group]);
await request(runtime, 'PATCH', '/openai-compatibility', { index: 0, value: { disabled: true } });
expect(await request(runtime, 'GET', '/config/api-keys/openai-compatibility')).toEqual([{ ...group, disabled: true }]);
const { keys, ...shared } = group;
expect(await request(runtime, 'GET', '/openai-compatibility')).toEqual({
'openai-compatibility': [{ ...shared, disabled: true, 'api-key-entries': keys }],
});
await request(runtime, 'PUT', '/openai-compatibility', [{ ...shared, 'api-key-entries': [keys[1]] }]);
expect(await request(runtime, 'GET', '/config/api-keys/openai-compatibility')).toEqual([{ ...shared, keys: [keys[1]] }]);
});
});
+58 -1
View File
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve, sep } from 'node:path';
@@ -146,6 +146,27 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
await managementApi.delete('/oauth-excluded-models', { query: { provider: 'claude' } });
});
it('round-trips template credential aliases and Claude settings without replacing token metadata', async () => {
const fileName = 'template-claude.json';
await managementApi.uploadAuthFile(new File([JSON.stringify({ type: 'claude', access_token: 'isolated-template-token',
expired: '2099-01-01T00:00:00Z', custom_metadata: { keep: true } })], fileName, { type: 'application/json' }));
try {
const original = await loadAuthFileSettings(fileName);
const advanced = { cloak_mode: 'always', cloak_strict_mode: false, cloak_cache_user_id: true, cloak_sensitive_words: ['Word'],
fingerprint_profile: 'claude-code-cli', timezone: 'Asia/Shanghai',
model_aliases: [{ name: 'claude-template', alias: 'public-template', fork: false, 'display-name': 'Template model', 'force-mapping': true }] };
await saveAuthFileSettings(fileName, original, { ...original, advanced });
const loaded = await loadAuthFileSettings(fileName);
expect(loaded.advanced).toEqual(advanced);
const metadata = await managementApi.get<Record<string, unknown>>('/auth-files/download', { name: fileName });
expect(metadata.access_token).toBe('isolated-template-token');
expect(metadata.custom_metadata).toEqual({ keep: true });
await saveAuthFileSettings(fileName, loaded, { ...loaded, advanced: {} });
const cleared = await loadAuthFileSettings(fileName);
expect(Object.values(cleared.advanced).every(value => value === null)).toBe(true);
} finally { await managementApi.delete('/auth-files', { query: { name: fileName } }); }
});
it('discovers models and forwards API-call requests to an isolated local upstream', async () => {
const upstream = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
if (request.headers.get('Authorization') !== 'Bearer local-key') return new Response('wrong key', { status: 401 });
@@ -170,4 +191,40 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
await expect(call('POST', '/oauth/callback', undefined, { provider: 'codex', redirect_url: 'http://localhost/callback' })).rejects.toThrow('(400)');
await managementApi.delete('/oauth-session', { query: { state: 'isolated-unknown-state' } });
});
it('accepts new template controls and retains explicit false, zero and empty collections', async () => {
const values: Array<[string, unknown]> = [
['routing/strategy', 'weighted-round-robin'], ['routing/session-affinity-subagents', false],
['routing/cooldown/transient-error-cooldown-seconds', -1], ['routing/retry/max-retry-interval', -1],
['requests/streaming/keepalive-seconds', 0], ['server/trusted-proxies', []],
['client/codex/enable-apply-patch', false], ['client/codex/optimize-multi-agent-v2', true],
['multimedia/disable-image-generation', 'passthrough'],
['oauth/settings', { codex: [{ name: 'gpt-template', 'max-context-length': 524288 }] }],
['oauth/model-alias', { meta: [{ name: 'muse-template', alias: 'public-template', fork: false, 'display-name': 'Template', 'force-mapping': true }] }],
['oauth/request-scoped-errors', { claude: [{ status: 400, match: ['context'], 'match-regexr': ['^limit'], action: 'stop' }] }],
['requests/payload/default-raw', [{ models: [{ name: 'gpt-*', protocol: 'codex', 'from-protocol': 'responses', headers: { 'X-Tier': 'test-*' },
match: [{ 'metadata.client': 'codex' }], 'not-match': [{ 'metadata.mode': 'dev' }], exist: ['input'], 'not-exist': ['metadata.disable'] }],
params: { response_format: '{"type":"json_object"}', temperature: 0.5, enabled: false, schema: { type: 'object' } } }]],
['plugins/configs', { 'template-test': { enabled: false, mode: 'safe', nested: { list: [1, false, 'value'] } } }],
];
for (const [path, value] of values) {
await call('PUT', `/config/${path}`, undefined, value);
const actual = await call('GET', `/config/${path}`);
// The core's typed alias JSON omits fork:false (its default); the
// effective value is still false. Other explicit defaults stay intact.
if (path === 'oauth/model-alias') actual.meta[0].fork ??= false;
expect(actual).toEqual(value);
}
});
it('keeps TURN secrets in YAML although the JSON config response redacts them', async () => {
const relay = { enabled: false, 'max-sessions': 1, 'udp-port-min': 49152, 'udp-port-max': 49155,
'ice-servers': [{ urls: ['turn:example.invalid:3478'], username: 'isolated', credential: 'isolated-turn-secret' }] };
await call('PUT', '/config/oauth/providers/codex/live-media-relay', undefined, relay);
const json = await call('GET', '/config/oauth/providers/codex/live-media-relay');
expect(JSON.stringify(json)).not.toContain('isolated-turn-secret');
expect(await readFile(join(work, 'config.yaml'), 'utf8')).toContain('isolated-turn-secret');
await call('PUT', '/config/oauth/providers/codex/live-media-relay/max-sessions', undefined, 2);
expect(await readFile(join(work, 'config.yaml'), 'utf8')).toContain('isolated-turn-secret');
});
});
@@ -66,6 +66,51 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
return flattenV8ProviderGroups(provider, await loaded.json());
};
for (const provider of ['gemini', 'interactions', 'vertex', 'codex', 'claude', 'xai', 'meta', 'openai-compatibility']) {
it(`${provider}: saves all template provider, key and model options through native v8 groups`, async () => {
const section = (provider === 'openai-compatibility' ? provider : `${provider}-api-key`) as ProviderSection;
const model: Record<string, unknown> = { name: 'isolated-model', alias: 'isolated-public' };
{
model.thinking = { levels: ['low', 'high'], min: 0, max: 32768, 'zero-allowed': false, 'dynamic-allowed': true };
model['display-name'] = 'Isolated model';
if (provider !== 'vertex') { model['max-context-length'] = 1048576; model['is-compat'] = false; }
}
model['force-mapping'] = true;
if (['codex', 'xai', 'meta'].includes(provider)) model['support-configuration-update'] = false;
if (provider === 'openai-compatibility') Object.assign(model, { image: false, 'input-modalities': ['text', 'image'], 'output-modalities': ['text'], 'use-max-completion-tokens': true });
const special = provider === 'codex' ? { 'disable-codex-cloaking': false, 'alpha-search': false, websockets: false }
: provider === 'claude' ? { 'rebuild-mid-system-message': false, 'fingerprint-profile': 'claude-code-cli', 'experimental-cch-signing': false,
cloak: { mode: 'auto', 'strict-mode': false, 'sensitive-words': [], 'cache-user-id': false } }
: provider === 'xai' ? { websockets: true } : {};
const group = { name: 'isolated-template', 'base-url': 'https://gateway.example.test/v1', priority: 0, prefix: '', 'proxy-url': 'direct',
headers: { 'X-Session': '$X-Client-Session' }, 'request-retry': 0, 'disable-cooling': false,
'request-scoped-errors': [{ status: 400, match: ['context'], 'match-regexr': ['^context'], action: 'stop' }],
...(provider === 'openai-compatibility' ? { 'support-prompt-cache-key': false } : {}),
models: [model], 'excluded-models': [],
keys: [{ 'api-key': 'isolated-template-key', weight: 0, priority: null, models: null, headers: {}, 'request-scoped-errors': [], ...special }],
};
if (provider === 'vertex') {
delete (group as Record<string, unknown>)['request-scoped-errors'];
delete (group.keys[0] as Record<string, unknown>)['request-scoped-errors'];
}
if (provider === 'openai-compatibility') {
delete (group as Record<string, unknown>)['proxy-url'];
delete (group as Record<string, unknown>)['excluded-models'];
group.keys = [{ 'api-key': 'isolated-template-key', weight: 0, 'proxy-url': 'direct' }] as never;
}
const draft = providerDraftFromRecord(section, group);
const saved = buildProviderGroupRecord(section, draft, group);
const url = `${origin}/config/api-keys/${provider}`;
const response = await fetch(url, { method: 'PUT', headers, body: JSON.stringify([saved]) });
const result = await response.text();
expect({ status: response.status, result: response.ok ? 'ok' : result }).toEqual({ status: 200, result: 'ok' });
const loaded = await (await fetch(url, { headers })).json();
expect(loaded[0].keys[0]['api-key']).toBe('isolated-template-key');
expect(loaded[0]['request-retry']).toBe(0);
expect(loaded[0].models[0].alias).toBe('isolated-public');
});
}
it('native group edits preserve multiple keys, shared models and explicit overrides in v8', async () => {
const url = `${origin}/config/api-keys/codex`;
const groups = [{ name: 'native-multi-key', 'base-url': 'https://gateway.example.test/v1',
+5 -1
View File
@@ -277,13 +277,17 @@ it('keeps remark identities separate for records that share an API key', () => {
});
describe('API 接入配置合并', () => {
it('固定使用 Codex、OpenAI、DeepSeek、Claude、Gemini 顺序且不包含 Vertex', () => {
it('保留常用分类顺序并覆盖 v8 模板全部原生提供商', () => {
expect(providerSectionOrder).toEqual([
'codex-api-key',
'openai-compatibility',
'deepseek',
'claude-api-key',
'gemini-api-key',
'interactions-api-key',
'vertex-api-key',
'xai-api-key',
'meta-api-key',
]);
});
+128
View File
@@ -0,0 +1,128 @@
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']);
}
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'bun:test';
import { defaultStructuredValue, validateStructuredValue, validateStructuredValueText, errorRulesShape, type ConfigShape } from '../src/services/structuredConfig';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { StructuredConfigEditor } from '../src/components/StructuredConfigEditor';
import { I18nProvider } from '../src/i18n';
import { credentialAdvancedShape } from '../src/services/credentialAdvancedSettings';
import { payloadShape } from '../src/services/payloadTemplateFields';
import { oauthTemplateGroups } from '../src/services/oauthTemplateFields';
import { pluginStoreAuthShape } from '../src/services/extensionTemplateFields';
describe('structured configuration forms', () => {
it('allows decimals for arbitrary JSON values while schema numbers retain integer controls', () => {
const render = (shape: ConfigShape) => renderToStaticMarkup(createElement(I18nProvider, null,
createElement(StructuredConfigEditor, { shape, value: 0.5, onChange: () => {}, id: 'value' })));
const arbitrary = render({ type: 'any', label: 'Temperature' });
expect(arbitrary).toContain('step="any"');
expect(arbitrary).toContain('aria-label="Temperature type"');
expect(arbitrary).toContain('value="number" selected="">Number');
expect(render({ type: 'number' })).toContain('step="1"');
expect(validateStructuredValue({ type: 'number' }, 0.5)).toContain('integer');
expect(validateStructuredValue({ type: 'any' }, 0.5)).toBeNull();
});
it('localizes nested and custom validation without changing the default English API', () => {
const integer: ConfigShape = { type: 'object', fields: { count: { type: 'number', min: 0 } } };
expect(validateStructuredValue(integer, { count: 1.5 })).toBe('.count: expected an integer ≥ 0');
expect(validateStructuredValueText(integer, { count: 1.5 })).toEqual({
en: '.count: expected an integer ≥ 0', zh: '.count: 请输入整数 ≥ 0', ja: '.count: 整数を入力してください ≥ 0',
});
expect(validateStructuredValueText(credentialAdvancedShape, { timezone: 'Not/AZone' })).toEqual({
en: 'Enter an IANA timezone such as Asia/Shanghai', zh: '请输入 IANA 时区,例如 Asia/Shanghai', ja: 'Asia/Shanghai などの IANA タイムゾーンを入力してください',
});
expect(validateStructuredValueText(credentialAdvancedShape, { timezone: 'Asia/Tokyo' })).toBeNull();
});
it('does not turn optional settings into explicit defaults when a row is added', () => {
expect(defaultStructuredValue(credentialAdvancedShape)).toEqual({});
expect(defaultStructuredValue(errorRulesShape.item!)).toEqual({ status: 400, action: 'stop' });
});
it('keeps explicit false, zero and empty collections distinct from missing values', () => {
const shape: ConfigShape = { type: 'object', fields: { enabled: { type: 'boolean', optional: true }, cap: { type: 'number', optional: true, min: 0 },
list: { type: 'array', optional: true, item: { type: 'string' } } } };
expect(validateStructuredValue(shape, { enabled: false, cap: 0, list: [], future_metadata: true })).toBeNull();
expect(validateStructuredValue(shape, {})).toBeNull();
expect(validateStructuredValue(shape, { cap: -1 })).toContain('integer');
expect(validateStructuredValue(shape, { cap: 1.5 })).toContain('integer');
});
it('validates nested rule actions and model identifiers while preserving unknown metadata', () => {
expect(validateStructuredValue(errorRulesShape, [{ status: 429, action: 'continue-and-cooldown', match: [] }])).toBeNull();
expect(validateStructuredValue(errorRulesShape, [{ status: 99, action: 'stop' }])).not.toBeNull();
expect(validateStructuredValue(errorRulesShape, [{ status: 400, action: 'retry-forever' }])).not.toBeNull();
expect(validateStructuredValue(credentialAdvancedShape, { model_aliases: [{ name: 'upstream', alias: 'public', fork: false, future_metadata: { retain: true } }] })).toBeNull();
expect(validateStructuredValue(credentialAdvancedShape, { model_aliases: [{ name: 'upstream', alias: '' }] })).not.toBeNull();
});
it('accepts typed raw JSON values and validates only strings as raw JSON syntax', () => {
const shape = payloadShape('override-raw');
expect(validateStructuredValue(shape, [{ models: [{ name: 'gpt-*' }], params: { temperature: 0.5, enabled: false,
schema: { type: 'object' }, list: [1, 'value'], literal: '"string"', number: '0.5' } }])).toBeNull();
expect(validateStructuredValue(shape, [{ models: [{ name: 'gpt-*' }], params: { invalid: 'unquoted-string' } }])).toContain('valid JSON');
});
it('accepts native plugin authentication defaults and all supported scopes', () => {
for (const rule of [
{ match: 'https://plugins.example.com/' },
{ match: 'http://plugins.example.com/', type: 'none', 'allow-insecure': true },
{ match: 'https://plugins.example.com/', type: 'bearer', 'token-env': 'PLUGIN_TOKEN' },
{ match: 'https://plugins.example.com/', type: 'github-token', 'token-env': 'GITHUB_TOKEN', 'apply-to': [] },
{ match: 'https://plugins.example.com/', type: 'header', 'header-name': 'X-Token', 'header-value-env': 'PLUGIN_TOKEN', 'apply-to': ['metadata', 'artifact', 'registry'] },
]) expect(validateStructuredValue(pluginStoreAuthShape, [rule])).toBeNull();
expect(validateStructuredValue(pluginStoreAuthShape, [{ match: 'https://plugins.example.com/', type: 'bearer' }])).not.toBeNull();
expect(validateStructuredValue(pluginStoreAuthShape, [{ match: 'https://plugins.example.com/', 'apply-to': ['unknown'] }])).not.toBeNull();
});
it('edits OAuth model capabilities without forcing an optional context override', () => {
const field = oauthTemplateGroups.find(group => group.id === 'oauth-models')!.fields.find(field => field.path.at(-1) === 'settings')!;
for (const setting of [
{ name: 'gpt-5.4' },
{ name: 'gpt-5.4', alias: 'public-model' },
{ name: 'gpt-5.4', 'max-context-length': 0 },
{ name: 'gpt-5.4', alias: 'public-model', 'max-context-length': 200000, future: true },
]) expect(field.validate!({ codex: [setting] })).toBeNull();
expect(field.validate!({ codex: [{ name: 'gpt-5.4', 'max-context-length': -1 }] })).not.toBeNull();
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, test } from 'bun:test';
import {
applyTemplateChanges, draftTemplateField, readTemplatePath, sameTemplateValue,
templateFieldSaveValue, templateFieldValidation, type TemplateConfigField,
} from '../src/services/templateConfig';
import { createBrowserMockRuntime } from '../src/mocks/browserMockRuntime';
import { generalTemplateGroups, networkTemplateGroups, requestTemplateGroups, routingTemplateGroups } from '../src/services/generalTemplateFields';
import { templateMessages } from '../src/i18n/templateConfig';
import { oauthTemplateGroups } from '../src/services/oauthTemplateFields';
const field: TemplateConfigField = { path: ['oauth', 'providers', 'codex', 'response-steering'], type: 'boolean', label: 'Steering', defaultValue: false };
const change = (path: string[], value: unknown, expected: unknown = null, expectedExists = false, remove = false) => ({ path, value, expected, expectedExists, remove });
describe('template configuration drafts', () => {
test('all general settings labels and help messages include Japanese', () => {
const groups = [...generalTemplateGroups, ...networkTemplateGroups, ...requestTemplateGroups, ...routingTemplateGroups];
const messages = [...Object.values(templateMessages), ...groups.flatMap((group) => [group.title, group.description, ...group.fields.flatMap((item) => [item.label, item.description, ...item.options?.map((option) => option.label) ?? []])])].filter((value) => value !== undefined);
for (const message of messages) {
expect(typeof message).toBe('object');
expect((message as { ja: string }).ja.length).toBeGreaterThan(0);
}
});
test('preserves absent versus explicit false, zero and empty collections', () => {
for (const value of [false, 0, '', [], {}]) {
const draft = draftTemplateField(field, {}, undefined, value);
expect(draft).toMatchObject({ value, remove: false, expectedExists: false });
const saved = applyTemplateChanges({}, [{ ...draft!, path: [...field.path] }]);
expect(readTemplatePath(saved, field.path)).toEqual({ exists: true, value });
}
});
test('retains original expected values across external refresh and subsequent edits', () => {
const initial = applyTemplateChanges({}, [change([...field.path], false)]);
const draft = draftTemplateField(field, initial, undefined, true);
const refreshed = applyTemplateChanges(initial, [change([...field.path], true)]);
expect(draftTemplateField(field, refreshed, draft, true)).toEqual(draft);
expect(draftTemplateField(field, refreshed, draft, false)).toBeUndefined();
});
test('reset removes only selected leaf and preserves unknown neighbors', () => {
const source = { requests: { payload: { rules: [1] }, enabled: false }, unknown: { nested: ['preserve'] } };
const result = applyTemplateChanges(source, [change(['requests', 'enabled'], null, false, true, true)]);
expect(result).toEqual({ requests: { payload: { rules: [1] } }, unknown: { nested: ['preserve'] } });
expect(source.requests.enabled).toBe(false);
expect(readTemplatePath(result, ['requests', 'enabled']).exists).toBe(false);
});
test('uses structural equality without depending on map order', () => {
expect(sameTemplateValue({ a: false, b: [0] }, { b: [0], a: false })).toBe(true);
expect(sameTemplateValue({}, { a: null })).toBe(false);
expect(sameTemplateValue([], {})).toBe(false);
});
test('normalizes newline lists at save even without a blur event', () => {
const listField = { ...field, type: 'string-list' as const };
expect(templateFieldSaveValue(listField, ['', ' ', 'one ', ' two'])).toEqual(['one', 'two']);
expect(templateFieldSaveValue(listField, [''])).toEqual([]);
});
test('mixed selectors retain booleans and signed numeric settings accept negative limits', () => {
const select = { ...field, type: 'select' as const, options: [{ value: false, label: 'Enable' }, { value: true, label: 'Disable' }, { value: 'chat', label: 'Chat' }] };
expect(templateFieldValidation(select, false)).toBeNull();
expect(templateFieldValidation(select, 'false')).not.toBeNull();
const number = { ...field, type: 'number' as const, min: -1 };
expect(templateFieldValidation(number, -1)).toBeNull();
expect(templateFieldValidation(number, -2)).not.toBeNull();
expect(templateFieldValidation(number, '')).not.toBeNull();
});
test('accepts native bootstrap seconds and an explicitly empty fallback image model', () => {
const timeout = oauthTemplateGroups.find(group => group.id === 'oauth-codex')!.fields.find(field => field.path.at(-1) === 'stream-bootstrap-timeout')!;
for (const value of ['', ' ', '15', '0', '20s', '500ms', 'off', 'unlimited']) expect(templateFieldValidation(timeout, value)).toBeNull();
expect(templateFieldValidation(timeout, 'invalid')).not.toBeNull();
const imageModel = requestTemplateGroups.find(group => group.id === 'multimedia')!.fields.find(field => field.path.at(-1) === 'gpt-image-2-base-model')!;
expect(templateFieldValidation(imageModel, '')).toBeNull();
expect(templateFieldValidation(imageModel, 'gpt-5.4-mini')).toBeNull();
expect(templateFieldValidation(imageModel, 'invalid')).not.toBeNull();
});
test('rejects unsafe path writes', () => {
expect(() => applyTemplateChanges({}, [change(['__proto__', 'polluted'], true)])).toThrow('Invalid configuration path');
expect(() => applyTemplateChanges({}, [change([], true)])).toThrow('Invalid configuration path');
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
});
describe('extended browser mock persistence', () => {
test('hot-reloadable discovery, diagnostics and plugins do not ask for a restart', async () => {
const runtime = createBrowserMockRuntime('running');
const changes = [change(['server', 'discovery', 'enabled'], true), change(['observability', 'pprof', 'enable'], true), change(['plugins', 'enabled'], true), change(['plugins', 'dir'], 'custom-plugins')];
expect(await runtime.invoke('save_extended_core_config', { changes })).toMatchObject({ restartRequired: false });
});
test('credential directory changes and restoring defaults require a restart', async () => {
const runtime = createBrowserMockRuntime('running');
const path = ['oauth', 'auth-dir'];
expect(await runtime.invoke('save_extended_core_config', { changes: [change(path, '~/custom-credentials')] })).toMatchObject({ restartRequired: true });
expect(readTemplatePath(await runtime.invoke('get_extended_core_config'), path).value).toBe('~/custom-credentials');
expect(await runtime.invoke('save_extended_core_config', { changes: [change(path, null, '~/custom-credentials', true, true)] })).toMatchObject({ restartRequired: true });
expect(readTemplatePath(await runtime.invoke('get_extended_core_config'), path).exists).toBe(false);
});
test('saves offline, supports explicit overrides, removal and preserves unrelated values', async () => {
const events: string[] = [];
const runtime = createBrowserMockRuntime('stopped', (event) => events.push(event));
const changes = [change(['plugins', 'enabled'], false), change(['plugins', 'configs'], {}), change(['server', 'trusted-proxies'], [])];
const result = await runtime.invoke('save_extended_core_config', { changes }) as { config: Record<string, unknown>; restartRequired: boolean };
expect(readTemplatePath(result.config, ['plugins', 'enabled'])).toEqual({ exists: true, value: false });
expect(readTemplatePath(result.config, ['plugins', 'configs']).value).toEqual({});
expect(result.restartRequired).toBe(true);
expect(events).toContain('config-files-changed');
await runtime.invoke('save_extended_core_config', { changes: [change(['plugins', 'enabled'], null, false, true, true)] });
const current = await runtime.invoke('get_extended_core_config');
expect(readTemplatePath(current, ['plugins', 'enabled']).exists).toBe(false);
expect(readTemplatePath(current, ['server', 'host']).value).toBe('127.0.0.1');
});
test('conflicts are atomic and absent differs from explicit null', async () => {
const runtime = createBrowserMockRuntime('running');
const path = ['oauth', 'auth-auto-refresh-workers'];
await runtime.invoke('save_extended_core_config', { changes: [change(path, 2)] });
await expect(runtime.invoke('save_extended_core_config', { changes: [change(['plugins', 'enabled'], true), change(path, 5)] })).rejects.toThrow('changed externally');
const current = await runtime.invoke('get_extended_core_config');
expect(readTemplatePath(current, ['plugins', 'enabled']).exists).toBe(false);
expect(readTemplatePath(current, path).value).toBe(2);
await runtime.invoke('save_extended_core_config', { changes: [change(['plugins', 'configs'], null)] });
await expect(runtime.invoke('save_extended_core_config', { changes: [change(['plugins', 'configs'], {})] })).rejects.toThrow('changed externally');
});
test('existing and extended controls stay synchronized', async () => {
const runtime = createBrowserMockRuntime('running');
await runtime.invoke('save_retry_settings', { settings: { maxRetryInterval: -1 } });
expect(readTemplatePath(await runtime.invoke('get_extended_core_config'), ['routing', 'retry', 'max-retry-interval']).value).toBe(-1);
await runtime.invoke('save_extended_core_config', { changes: [change(['observability', 'logs', 'request-log'], true), change(['plugins', 'enabled'], true)] });
expect(await runtime.invoke('get_core_config_settings')).toMatchObject({ requestLog: true, pluginsEnabled: true });
await runtime.invoke('save_extended_core_config', { changes: [change(['observability', 'logs', 'request-log'], null, true, true, true)] });
expect(await runtime.invoke('get_core_config_settings')).toMatchObject({ requestLog: false, pluginsEnabled: true });
});
});