- Add plugin store, version selection, configuration, and OAuth login - Support Muse (Meta) OAuth device authorization - Redesign home dashboard and add batch model health checks - Support per-credential provider editing, removal, and reordering - Improve usage charts and track actual response models - Preserve installed plugins and runtime data during portable rebuilds - Update translations, documentation, and related tests
177 lines
10 KiB
TypeScript
177 lines
10 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import {
|
|
buildProviderKeyRecord, providerDraftFromRecord, type ProviderDraft, type ProviderSection,
|
|
} from '../src/pages/ApiAccessPage';
|
|
import { providerEntries, updateProviderEntry } from '../src/services/providerEntries';
|
|
import { cleanProviderGroup } from '../src/services/providerGroups';
|
|
|
|
type Value = Record<string, unknown>;
|
|
const keys = (group: Value) => group.keys as Value[];
|
|
const fixture = (): Value[] => [{
|
|
name: 'shared', 'base-url': 'https://gateway.test/v1', priority: 3,
|
|
headers: { 'X-Shared': 'group', 'auth-index': 'literal header' }, 'excluded-models': ['old-*'],
|
|
models: [
|
|
{ name: 'same-upstream', alias: 'regular', vendor: { index: 1 }, thinking: { levels: ['low'], 'zero-allowed': false } },
|
|
{ name: 'same-upstream', alias: 'fast', vendor: { index: 2 }, thinking: { levels: ['high'] } },
|
|
{ name: 'same-upstream', alias: 'careful', vendor: { index: 3 }, 'max-context-length': 128000 },
|
|
],
|
|
'request-retry': 2, 'request-scoped-errors': [{ status: 429, action: 'continue', match: ['quota'] }],
|
|
keys: [
|
|
{ 'api-key': 'key-a', priority: null, models: null, 'request-retry': null, 'disable-cooling': null,
|
|
weight: null, 'auth-index': 'runtime-a', custom: { list: [1, 2], 'test-model': 'extension' } },
|
|
{ 'api-key': 'key-b', headers: {}, 'excluded-models': [], weight: 0, websockets: false },
|
|
],
|
|
}];
|
|
|
|
const edit = (groups: Value[], change: (draft: ProviderDraft) => void, section: ProviderSection = 'codex-api-key', index = 0) => {
|
|
const entry = providerEntries(section, groups)[index];
|
|
const draft = providerDraftFromRecord(section, entry.record);
|
|
change(draft);
|
|
const after = buildProviderKeyRecord(section, draft, entry.record);
|
|
return { after, draft, result: updateProviderEntry(groups, entry.source, entry.record, after) };
|
|
};
|
|
|
|
describe('single-provider form edits over native groups', () => {
|
|
for (const section of ['codex-api-key', 'claude-api-key', 'gemini-api-key', 'interactions-api-key', 'vertex-api-key', 'xai-api-key', 'meta-api-key'] as const) {
|
|
test(`${section} untouched form preserves inheritance, aliases, unknown configuration and sibling keys`, () => {
|
|
const original = fixture();
|
|
const { draft, result } = edit(original, () => {}, section);
|
|
expect(draft.models.map((model) => model.alias)).toEqual(['regular', 'fast', 'careful']);
|
|
expect(result).toEqual(original.map(cleanProviderGroup));
|
|
expect(keys(result[0])[0]).toMatchObject({ priority: null, models: null, 'request-retry': null, 'disable-cooling': null, weight: null });
|
|
expect(keys(result[0])[0]).not.toHaveProperty('headers');
|
|
expect(keys(result[0])[0]).not.toHaveProperty('websockets');
|
|
expect(keys(result[0])[0].custom).toEqual({ list: [1, 2], 'test-model': 'extension' });
|
|
});
|
|
}
|
|
|
|
test('untouched absent, null and explicit empty form values retain their stored representations', () => {
|
|
for (const fields of [
|
|
{},
|
|
{ models: null, headers: null, priority: null, 'proxy-url': null, 'excluded-models': null, 'disable-cooling': null, websockets: null },
|
|
{ models: [], headers: {}, priority: 0, 'proxy-url': '', 'excluded-models': [], 'disable-cooling': false, websockets: false },
|
|
]) {
|
|
const original = [{ name: 'untouched', keys: [{ 'api-key': 'key', ...fields }] }];
|
|
expect(edit(original, () => {}).result).toEqual(original);
|
|
}
|
|
});
|
|
|
|
test('changing only a local remark does not modify provider configuration', () => {
|
|
const original = fixture();
|
|
const { result } = edit(original, (draft) => { draft.remark = 'My display note'; });
|
|
expect(result).toEqual(original.map(cleanProviderGroup));
|
|
expect(keys(result[0])[0]).not.toHaveProperty('remark');
|
|
});
|
|
|
|
test('preserves an existing key-owned name extension on unrelated edits', () => {
|
|
const original = fixture();
|
|
keys(original[0])[0].name = 'retained key metadata';
|
|
expect(edit(original, (draft) => { draft.remark = 'Updated note'; }).result).toEqual(original.map(cleanProviderGroup));
|
|
});
|
|
|
|
test('removes exactly one repeated-source alias and keeps other model metadata', () => {
|
|
const original = fixture();
|
|
const { result } = edit(original, (draft) => { draft.models = draft.models.filter((_, index) => index !== 1); });
|
|
const existing = original[0].models as Value[];
|
|
expect(keys(result[0])[0].models).toEqual([existing[0], existing[2]]);
|
|
expect(result[0].models).toEqual(existing);
|
|
expect(keys(result[0])[1]).toEqual(keys(original[0])[1]);
|
|
expect(providerEntries('codex-api-key', result)[1].record.models).toEqual(existing);
|
|
});
|
|
|
|
test('renames one alias without duplicating or merging its repeated-source siblings', () => {
|
|
const original = fixture();
|
|
const { result } = edit(original, (draft) => { draft.models[1].alias = 'fast-renamed'; });
|
|
const existing = original[0].models as Value[];
|
|
expect(keys(result[0])[0].models).toEqual([existing[0], { ...existing[1], alias: 'fast-renamed' }, existing[2]]);
|
|
});
|
|
|
|
test('clearing every selected model writes an explicit empty list for that credential', () => {
|
|
const original = fixture();
|
|
const { result } = edit(original, (draft) => { draft.models = []; });
|
|
expect(keys(result[0])[0].models).toEqual([]);
|
|
expect(result[0].models).toEqual(original[0].models);
|
|
});
|
|
|
|
test('clearing shared headers and exclusions writes empty overrides while preserving siblings', () => {
|
|
const original = fixture();
|
|
const { result } = edit(original, (draft) => { draft.headersText = ''; draft.excludedModelsText = ''; });
|
|
expect(keys(result[0])[0]).toMatchObject({ headers: {}, 'excluded-models': [] });
|
|
expect(result[0].headers).toEqual(original[0].headers);
|
|
expect(result[0]['excluded-models']).toEqual(original[0]['excluded-models']);
|
|
expect(keys(result[0])[1]).toEqual(keys(original[0])[1]);
|
|
});
|
|
|
|
test('editing header values does not strip fields named like response metadata', () => {
|
|
const original = fixture();
|
|
const { result } = edit(original, (draft) => { draft.headersText = 'auth-index: literal header\ntest-model: allowed header\nX-New: added'; });
|
|
expect(keys(result[0])[0].headers).toEqual({ 'auth-index': 'literal header', 'test-model': 'allowed header', 'X-New': 'added' });
|
|
});
|
|
|
|
test('changing a shared endpoint splits only the edited credential and retains inherited policies', () => {
|
|
const original = fixture();
|
|
const { result } = edit(original, (draft) => { draft.baseUrl = 'https://new.test/v1'; });
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]['base-url']).toBe(original[0]['base-url']);
|
|
expect(keys(result[0])).toEqual([keys(original[0])[1]]);
|
|
expect(result[1]['base-url']).toBe('https://new.test/v1');
|
|
expect(result[1].models).toEqual(original[0].models);
|
|
expect(result[1].headers).toEqual(original[0].headers);
|
|
expect(keys(result[1])).toEqual([cleanProviderGroup(keys(original[0])[0])]);
|
|
});
|
|
|
|
test('weight changes affect only the selected key and clearing it restores the default', () => {
|
|
const original = fixture();
|
|
const changed = edit(original, (draft) => { draft.weight = '5'; }).result;
|
|
expect(keys(changed[0])[0].weight).toBe(5);
|
|
expect(keys(changed[0])[1].weight).toBe(0);
|
|
const cleared = edit(changed, (draft) => { draft.weight = ''; }).result;
|
|
expect(keys(cleared[0])[0]).not.toHaveProperty('weight');
|
|
for (const weight of ['1.5', 'nope', '1000001']) {
|
|
expect(() => edit(original, (draft) => { draft.weight = weight; })).toThrow();
|
|
}
|
|
});
|
|
|
|
test('template edits retain untouched policies and apply explicit false and zero', () => {
|
|
const original = fixture();
|
|
keys(original[0])[0]['disable-codex-cloaking'] = true;
|
|
keys(original[0])[0]['alpha-search'] = null;
|
|
const { result } = edit(original, (draft) => {
|
|
draft.templateFields = { ...draft.templateFields, 'request-retry': 0, 'disable-codex-cloaking': false, 'alpha-search': true };
|
|
});
|
|
expect(keys(result[0])[0]).toMatchObject({ 'request-retry': 0, 'disable-codex-cloaking': false, 'alpha-search': true });
|
|
expect(keys(result[0])[0]).not.toHaveProperty('request-scoped-errors');
|
|
expect(result[0]['request-scoped-errors']).toEqual(original[0]['request-scoped-errors']);
|
|
expect(keys(result[0])[1]).toEqual(keys(original[0])[1]);
|
|
});
|
|
|
|
test('edited template rules retain unedited extension fields and clearing inherited rules is explicit', () => {
|
|
const original = fixture();
|
|
const rules = [{ status: 503, action: 'continue-and-cooldown', match: ['overloaded'], custom: { retained: true } }];
|
|
const changed = edit(original, (draft) => { draft.templateFields = { ...draft.templateFields, 'request-scoped-errors': rules }; }).result;
|
|
expect(keys(changed[0])[0]['request-scoped-errors']).toEqual(rules);
|
|
const cleared = edit(original, (draft) => { delete draft.templateFields!['request-scoped-errors']; }).result;
|
|
expect(keys(cleared[0])[0]['request-scoped-errors']).toEqual([]);
|
|
});
|
|
|
|
test('untouched Claude cloak metadata survives and an edited cache choice preserves unrelated fields', () => {
|
|
const original = fixture();
|
|
keys(original[0])[0].cloak = { mode: 'always', 'strict-mode': true, 'cache-user-id': true, 'sensitive-words': ['private'], custom: { retained: true } };
|
|
expect(edit(original, () => {}, 'claude-api-key').result).toEqual(original.map(cleanProviderGroup));
|
|
const changed = edit(original, (draft) => { draft.cloakCacheUserId = false; }, 'claude-api-key').result;
|
|
expect(keys(changed[0])[0].cloak).toEqual({ ...(keys(original[0])[0].cloak as Value), 'cache-user-id': false });
|
|
});
|
|
|
|
test('editing one Claude cloak option keeps explicit false and absent sibling options distinct', () => {
|
|
for (const cloak of [
|
|
{ 'strict-mode': false, 'cache-user-id': false, custom: { retained: true } },
|
|
{ 'cache-user-id': false, custom: { retained: true } },
|
|
]) {
|
|
const original = fixture();
|
|
keys(original[0])[0].cloak = cloak;
|
|
const changed = edit(original, (draft) => { draft.cloakCacheUserId = true; }, 'claude-api-key').result;
|
|
expect(keys(changed[0])[0].cloak).toEqual({ ...cloak, 'cache-user-id': true });
|
|
}
|
|
});
|
|
});
|