feat(api-access): support native CPA v8 groups and fix alias source validation
This commit is contained in:
1 parent
6dc8650e68
commit
84fe2be8ac
22 files changed
+1136
-95
No files matched your search
@@ -33,7 +33,7 @@ const path = require('node:path');
|
||||
});
|
||||
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await page.locator('.real-provider-row').getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
const form = page.getByRole('dialog', { name: 'Edit API Connection', exact: true });
|
||||
const form = page.getByRole('dialog', { name: 'Edit Group', exact: true });
|
||||
assert.equal(await form.locator('.model-config-entry').count(), 30);
|
||||
|
||||
// Older WebKit makes an inline-size query container a fixed-position containing block.
|
||||
@@ -72,10 +72,13 @@ const path = require('node:path');
|
||||
await assertDialogControls(form);
|
||||
await form.getByLabel('Remark', { exact: true }).fill('Small-window edit');
|
||||
await form.locator('.provider-advanced-settings summary').click();
|
||||
const headers = form.getByLabel(/Custom Headers/);
|
||||
const headers = form.getByRole('textbox', { name: /Custom Headers/ });
|
||||
await headers.fill('X-Layout-Test: reachable');
|
||||
await form.getByRole('combobox', { name: 'Disable Cooldown', exact: true }).selectOption('false');
|
||||
await form.getByRole('combobox', { name: 'Cache User ID', exact: true }).selectOption('false');
|
||||
const keySettings = form.locator('.provider-group-key').first();
|
||||
await keySettings.locator('summary').click();
|
||||
await keySettings.getByRole('combobox', { name: 'Cache User ID', exact: true }).selectOption('false');
|
||||
await keySettings.locator('summary').click();
|
||||
await assertDialogControls(form);
|
||||
const scroll = await form.evaluate((element) => ({ top: element.scrollTop, max: element.scrollHeight - element.clientHeight }));
|
||||
assert.ok(scroll.top > 0 && scroll.max > 0, 'Advanced settings must be reachable by scrolling inside the dialog');
|
||||
@@ -100,11 +103,12 @@ const path = require('node:path');
|
||||
assert.equal(record.headers['X-Layout-Test'], 'reachable');
|
||||
assert.equal(record.models.length, 30);
|
||||
assert.equal(record['disable-cooling'], false);
|
||||
assert.equal(record.cloak['cache-user-id'], false);
|
||||
assert.equal(record.keys[0].cloak['cache-user-id'], false);
|
||||
assert.equal(await page.evaluate(() => document.body.style.overflow), '');
|
||||
|
||||
await page.locator('.real-provider-row').getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
await form.locator('.provider-advanced-settings summary').click();
|
||||
await form.locator('.provider-group-key').first().locator('summary').click();
|
||||
for (const name of ['Disable Cooldown', 'Cache User ID']) {
|
||||
const control = form.getByRole('combobox', { name, exact: true });
|
||||
assert.equal(await control.inputValue(), 'false');
|
||||
@@ -114,11 +118,11 @@ const path = require('node:path');
|
||||
await form.waitFor({ state: 'detached' });
|
||||
const inherited = await page.evaluate(() => window.providerFixture.records[0]);
|
||||
assert.equal(inherited['disable-cooling'], undefined);
|
||||
assert.equal(inherited.cloak?.['cache-user-id'], undefined);
|
||||
assert.equal(inherited.keys[0].cloak?.['cache-user-id'], undefined);
|
||||
|
||||
await page.locator('.provider-category-panel button').filter({ hasText: 'OpenAI' }).click();
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
const add = page.getByRole('dialog', { name: 'Add API Connection', exact: true });
|
||||
await page.getByRole('button', { name: 'Add Group', exact: true }).click();
|
||||
const add = page.getByRole('dialog', { name: 'Add Group', exact: true });
|
||||
await assertViewportOverlay('.config-dialog-backdrop');
|
||||
await assertDialogControls(add);
|
||||
await add.locator('.provider-advanced-settings summary').click();
|
||||
|
||||
+3
-4
@@ -3,7 +3,6 @@ import { createRoot } from 'react-dom/client';
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { ApiAccessPage, providerRemarkIdentity, type ApiAccessRemarkLocator, type ProviderSection } from '../../src/pages/ApiAccessPage';
|
||||
import { flattenV8ProviderGroups, groupLegacyProviderRecords } from '../../src/services/managementApi';
|
||||
import '../../src/styles/index.css';
|
||||
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', 'en');
|
||||
@@ -12,7 +11,7 @@ const fixture = window as typeof window & {
|
||||
};
|
||||
fixture.providerFixture = {
|
||||
records: [{
|
||||
'api-key': 'shared-test-key', 'base-url': 'https://claude.example.test', priority: 10,
|
||||
name: 'First route', keys: [{ 'api-key': 'shared-test-key' }], 'base-url': 'https://claude.example.test', priority: 10,
|
||||
models: [{ name: 'upstream-a', alias: 'claude-a' }],
|
||||
}],
|
||||
writes: [],
|
||||
@@ -40,13 +39,13 @@ mockIPC((cmd, rawArgs) => {
|
||||
return { status_code: 200, body: { data: [{ id: 'discovered-model' }] } };
|
||||
}
|
||||
if (request.method === 'GET') {
|
||||
const groups = groupLegacyProviderRecords('claude', fixture.providerFixture.records);
|
||||
const groups = structuredClone(fixture.providerFixture.records);
|
||||
if (section === 'config') return { 'api-keys': { claude: groups } };
|
||||
return section === 'config/api-keys/claude' ? groups : [];
|
||||
}
|
||||
fixture.providerFixture.writes.push({ method: request.method, body: structuredClone(request.body) });
|
||||
if (request.method !== 'PUT' || section !== 'config/api-keys/claude') throw new Error('Unexpected provider mutation');
|
||||
fixture.providerFixture.records = flattenV8ProviderGroups('claude', structuredClone(request.body));
|
||||
fixture.providerFixture.records = structuredClone(request.body);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
throw new Error(`Unhandled fixture command: ${cmd}`);
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<html><head><meta name="viewport" content="width=device-width, initial-scale=1" /></head><body><div id="root"></div><script type="module" src="/tests/fixtures/provider-groups.tsx"></script></body></html>
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { ApiAccessPage } from '../../src/pages/ApiAccessPage';
|
||||
import '../../src/styles/index.css';
|
||||
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', new URLSearchParams(location.search).get('locale') ?? 'en');
|
||||
const fixture = window as typeof window & { groupFixture: { groups: Record<string, unknown>[], writes: unknown[], probes: unknown[] } };
|
||||
fixture.groupFixture = {
|
||||
groups: [{ name: 'Primary gateway', 'base-url': 'https://gateway.example.test/v1', priority: 2,
|
||||
headers: { 'X-Group': 'shared' }, models: [{ name: 'gpt-original' }, { name: 'gpt-original', alias: 'gpt-fast' }],
|
||||
keys: [{ 'api-key': 'key-inherited', weight: 2, priority: null },
|
||||
{ 'api-key': 'key-overridden', weight: 5, priority: 0, 'proxy-url': 'direct', headers: { 'X-Key': 'second' }, models: [{ name: 'private-model' }], 'excluded-models': [], 'disable-cooling': false }],
|
||||
}, { name: 'Backup gateway', 'base-url': 'https://backup.example.test/v1', models: [{ name: 'backup-model' }], keys: [{ 'api-key': 'backup-key' }] }],
|
||||
writes: [], probes: [],
|
||||
};
|
||||
mockIPC((cmd, args: any) => {
|
||||
if (cmd === 'set_app_locale' || cmd === 'save_api_access_remark') return null;
|
||||
if (cmd === 'resolve_api_access_remarks') return args.queries.map(() => '');
|
||||
if (cmd !== 'management_request') throw new Error(`Unexpected ${cmd}`);
|
||||
const request = args.request;
|
||||
if (request.path === '/requests/api-call') {
|
||||
fixture.groupFixture.probes.push(request.body);
|
||||
return { status_code: 200, body: { data: [{ id: 'gpt-original' }, { id: 'new-discovered' }] } };
|
||||
}
|
||||
if (request.method === 'GET') return request.path === '/config/api-keys/codex' ? structuredClone(fixture.groupFixture.groups) : [];
|
||||
if (request.path !== '/config/api-keys/codex' || request.method !== 'PUT') throw new Error('Unexpected mutation');
|
||||
fixture.groupFixture.writes.push(structuredClone(request.body));
|
||||
fixture.groupFixture.groups = structuredClone(request.body);
|
||||
return { status: 'ok' };
|
||||
});
|
||||
createRoot(document.getElementById('root')!).render(<I18nProvider><div className="app-shell"><aside className="sidebar" /><div className="workspace"><main className="content"><ApiAccessPage /></main></div></div></I18nProvider>);
|
||||
@@ -5,7 +5,7 @@ import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import {
|
||||
applyProviderPreset, buildProviderRecord, createProviderDraft,
|
||||
applyProviderPreset, buildProviderRecord, buildProviderGroupRecord, createProviderDraft, providerDraftFromRecord,
|
||||
type ProviderSection,
|
||||
} from '../src/pages/ApiAccessPage';
|
||||
import { flattenV8ProviderGroups, groupLegacyProviderRecords } from '../src/services/managementApi';
|
||||
@@ -66,6 +66,31 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
|
||||
return flattenV8ProviderGroups(provider, await loaded.json());
|
||||
};
|
||||
|
||||
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',
|
||||
models: [{ name: 'gpt-test' }, { name: 'gpt-test', alias: 'gpt-test-fast' }],
|
||||
headers: { 'X-Group': 'shared' }, 'excluded-models': ['old-*'],
|
||||
keys: [{ 'api-key': 'native-first', weight: 2 }, { 'api-key': 'native-second', priority: 0,
|
||||
weight: 5, models: null, headers: {}, 'excluded-models': [], 'disable-cooling': false, 'request-retry': 0 }],
|
||||
}];
|
||||
const write = async (value: unknown) => {
|
||||
const response = await fetch(url, { method: 'PUT', headers, body: JSON.stringify(value) });
|
||||
const body = await response.text();
|
||||
expect({ status: response.status, result: response.ok ? 'ok' : body }).toEqual({ status: 200, result: 'ok' });
|
||||
return (await (await fetch(url, { headers })).json()) as Record<string, unknown>[];
|
||||
};
|
||||
const loaded = await write(groups);
|
||||
const draft = providerDraftFromRecord('codex-api-key', loaded[0]);
|
||||
draft.name = 'renamed-native'; draft.priority = '6';
|
||||
const saved = await write([buildProviderGroupRecord('codex-api-key', draft, loaded[0])]);
|
||||
expect(saved.length).toBe(1);
|
||||
expect(saved[0].name).toBe('renamed-native');
|
||||
expect(saved[0].priority).toBe(6);
|
||||
expect(saved[0].keys).toEqual(loaded[0].keys);
|
||||
expect(saved[0].models).toEqual(loaded[0].models);
|
||||
});
|
||||
|
||||
for (const category of ['codex-api-key', 'deepseek', 'claude-api-key', 'gemini-api-key', 'openai-compatibility'] as const) {
|
||||
const section: ProviderSection = category === 'deepseek' ? 'codex-api-key' : category;
|
||||
const provider = section.replace('-api-key', '');
|
||||
|
||||
@@ -18,8 +18,9 @@ const base = 'http://127.0.0.1:1421';
|
||||
const form = page.locator('.api-provider-dialog');
|
||||
const waitForSave = () => form.waitFor({ state: 'detached' });
|
||||
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await form.getByLabel('API Keys (one per line)', { exact: true }).fill('shared-test-key');
|
||||
await page.getByRole('button', { name: 'Add Group', exact: true }).click();
|
||||
await form.getByLabel('Group Name', { exact: true }).fill('Second route');
|
||||
await form.getByLabel('Key 1', { exact: true }).fill('shared-test-key');
|
||||
await form.getByLabel('Base URL', { exact: true }).fill('https://claude.example.test');
|
||||
await form.getByLabel('Priority', { exact: true }).fill('1');
|
||||
await form.getByLabel('Remark', { exact: true }).fill('Second route');
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
|
||||
(async () => {
|
||||
const { createServer } = await import('vite');
|
||||
const react = (await import('@vitejs/plugin-react')).default;
|
||||
const server = await createServer({ configFile: false, root: path.resolve(__dirname, '..'), plugins: [react()],
|
||||
optimizeDeps: { entries: ['tests/fixtures/provider-groups.html'] }, logLevel: 'error',
|
||||
server: { host: '127.0.0.1', port: 0, watch: null } });
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(String(error)));
|
||||
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
|
||||
await page.goto(`${base}/tests/fixtures/provider-groups.html`);
|
||||
const rows = page.locator('.real-provider-row');
|
||||
const primary = () => rows.filter({ hasText: 'Primary gateway' });
|
||||
const groups = () => page.evaluate(() => window.groupFixture.groups);
|
||||
const dialog = page.getByRole('dialog', { name: 'Edit Group', exact: true });
|
||||
await primary().waitFor();
|
||||
assert.equal(await rows.count(), 2, 'One row per group, not per key');
|
||||
const initial = await groups();
|
||||
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
assert.equal(await dialog.locator('.provider-group-key').count(), 2);
|
||||
await dialog.getByLabel('Priority', { exact: true }).last().fill('7');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
let saved = await groups();
|
||||
assert.deepEqual(saved, [{ ...initial[0], priority: 7 }, initial[1]]);
|
||||
|
||||
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
const second = dialog.locator('.provider-group-key').nth(1);
|
||||
await second.locator('summary').click();
|
||||
await second.getByLabel('Override: Priority', { exact: true }).uncheck();
|
||||
await second.getByLabel('Key 2', { exact: true }).fill('rotated-second-key');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
saved = await groups();
|
||||
assert.equal(saved[0].keys[1]['api-key'], 'rotated-second-key');
|
||||
assert.ok(!('priority' in saved[0].keys[1]), 'Unchecking restores inheritance');
|
||||
assert.deepEqual(saved[0].keys[1].models, initial[0].keys[1].models);
|
||||
assert.deepEqual(saved[0].keys[0], initial[0].keys[0]);
|
||||
|
||||
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
await dialog.getByLabel('Key for Model Discovery').selectOption({ index: 1 });
|
||||
await dialog.getByRole('button', { name: /Fetch Models|Get Models|Select Models/ }).click();
|
||||
await page.waitForFunction(() => window.groupFixture.probes.length > 0);
|
||||
const probe = await page.evaluate(() => window.groupFixture.probes.at(-1));
|
||||
assert.equal(probe.header.Authorization, 'Bearer rotated-second-key');
|
||||
assert.equal(probe.header['X-Key'], 'second');
|
||||
assert.ok(!('X-Group' in probe.header));
|
||||
await page.locator('.model-discovery-dialog').getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Add Group', exact: true }).click();
|
||||
const add = page.getByRole('dialog', { name: 'Add Group', exact: true });
|
||||
await add.getByLabel('Group Name', { exact: true }).fill('New multi-key group');
|
||||
await add.getByLabel('Base URL', { exact: true }).fill('https://new.example.test/v1');
|
||||
await add.getByLabel('Key 1', { exact: true }).fill('new-key-a');
|
||||
await add.getByRole('button', { name: 'Add Key', exact: true }).click();
|
||||
await add.getByLabel('Key 2', { exact: true }).fill('new-key-b');
|
||||
await add.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await add.waitFor({ state: 'detached' });
|
||||
saved = await groups();
|
||||
assert.equal(saved.length, 3);
|
||||
assert.deepEqual(saved[2].keys, [{ 'api-key': 'new-key-a' }, { 'api-key': 'new-key-b' }]);
|
||||
|
||||
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
await page.evaluate(() => { window.groupFixture.groups[0].keys[1].weight = 9; });
|
||||
const writes = await page.evaluate(() => window.groupFixture.writes.length);
|
||||
await dialog.getByLabel('Priority', { exact: true }).last().fill('9');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await dialog.locator('.action-feedback-message').waitFor();
|
||||
assert.equal(await page.evaluate(() => window.groupFixture.writes.length), writes, 'Stale save must not write');
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
await fs.mkdir(path.resolve('bin-work/provider-groups-qa'), { recursive: true });
|
||||
for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 }]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(`${base}/tests/fixtures/provider-groups.html?locale=zh-CN`);
|
||||
await rows.first().getByRole('button', { name: '编辑', exact: true }).click();
|
||||
const form = page.locator('.api-provider-dialog');
|
||||
await page.screenshot({ path: path.resolve(`bin-work/provider-groups-qa/edit-${viewport.width}.png`) });
|
||||
const dimensions = await form.evaluate(el => ({width: el.clientWidth, scroll: el.scrollWidth, offenders: [...el.querySelectorAll('*')].filter(child=>child.getBoundingClientRect().right > el.getBoundingClientRect().right).map(child=>({tag:child.tagName,cls:child.className,width:child.getBoundingClientRect().width})).slice(0,12)}));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify({viewport,dimensions}));
|
||||
await page.screenshot({ path: path.resolve(`bin-work/provider-groups-qa/edit-${viewport.width}.png`) });
|
||||
await form.locator('.provider-group-key').nth(1).locator('summary').click();
|
||||
await form.locator('.provider-group-key').nth(1).scrollIntoViewIfNeeded();
|
||||
assert.ok(await form.evaluate(el => el.scrollWidth <= el.clientWidth + 1), 'Expanded overrides fit');
|
||||
await page.screenshot({ path: path.resolve(`bin-work/provider-groups-qa/overrides-${viewport.width}.png`) });
|
||||
await form.getByRole('button', { name: '取消', exact: true }).click();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: native group editing, key rotation and inheritance, discovery credentials, multi-key creation, stale-save protection and responsive layout.');
|
||||
} finally { await browser?.close(); await server.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildProviderGroupRecord, providerDraftFromRecord, reorderProviderRecords, resolveProviderRecordIndex } from '../src/pages/ApiAccessPage';
|
||||
import { cleanProviderGroup, effectiveProviderKey, providerGroupStatus, providerKeyDraft, serializeProviderKey } from '../src/services/providerGroups';
|
||||
|
||||
const group = () => ({
|
||||
name: 'shared-gateway', 'base-url': 'https://gateway.test/v1',
|
||||
priority: 3, 'proxy-url': 'http://proxy.test:80', 'excluded-models': ['old-*'],
|
||||
headers: { 'X-Group': 'shared' }, 'request-retry': 0,
|
||||
models: [{ name: 'upstream' }, { name: 'upstream', alias: 'upstream-fast', thinking: { levels: ['high'] } }],
|
||||
keys: [
|
||||
{ 'api-key': 'key-a', weight: 2, 'auth-index': 'runtime-a' },
|
||||
{ 'api-key': 'key-b', weight: 5, priority: null, headers: {}, models: null,
|
||||
'excluded-models': [], 'disable-cooling': false, websockets: false, 'request-scoped-errors': [{ status: 429, action: 'continue' }] },
|
||||
{ 'api-key': 'key-c', priority: 0, models: [{ name: 'private', alias: 'private-alias' }], 'proxy-url': 'direct' },
|
||||
],
|
||||
});
|
||||
|
||||
describe('native v8 provider groups', () => {
|
||||
for (const section of ['codex-api-key', 'claude-api-key', 'gemini-api-key', 'openai-compatibility'] as const) {
|
||||
test(`${section} keeps one group, key order and overrides on a shared edit`, () => {
|
||||
const original = group();
|
||||
const draft = providerDraftFromRecord(section, original);
|
||||
draft.name = 'renamed'; draft.priority = '4';
|
||||
const saved = buildProviderGroupRecord(section, draft, original);
|
||||
expect(saved).toEqual({ ...cleanProviderGroup(original), name: 'renamed', priority: 4 });
|
||||
expect(original.keys[0]['auth-index']).toBe('runtime-a');
|
||||
expect(saved).not.toHaveProperty('api-key');
|
||||
expect(saved).not.toHaveProperty('api-key-entries');
|
||||
});
|
||||
}
|
||||
test('preserves absent, null and empty shared fields on an unrelated edit', () => {
|
||||
for (const fields of [{}, { models: null, headers: null, 'proxy-url': null }, { models: [], headers: {}, 'proxy-url': '' }]) {
|
||||
const original = { name: 'empty', keys: [], ...fields };
|
||||
const draft = providerDraftFromRecord('codex-api-key', original);
|
||||
expect(buildProviderGroupRecord('codex-api-key', draft, original)).toEqual(original);
|
||||
}
|
||||
});
|
||||
test('shared model edits keep existing aliases and independent key models', () => {
|
||||
const original = group();
|
||||
const draft = providerDraftFromRecord('codex-api-key', original);
|
||||
draft.models = [...draft.models, { name: 'new-model' }];
|
||||
const saved = buildProviderGroupRecord('codex-api-key', draft, original);
|
||||
expect(saved.models).toEqual(expect.arrayContaining([...original.models, { name: 'new-model' }]));
|
||||
expect((saved.models as unknown[]).length).toBe(3);
|
||||
expect(saved.keys).toEqual(cleanProviderGroup(original).keys);
|
||||
});
|
||||
test('key rotation and removal do not transfer settings to another key', () => {
|
||||
const original = group();
|
||||
const draft = providerDraftFromRecord('codex-api-key', original);
|
||||
const second = draft.groupKeys![1];
|
||||
second.value['api-key'] = 'rotated';
|
||||
draft.groupKeys = [second, providerKeyDraft({ 'api-key': 'new' })];
|
||||
const saved = buildProviderGroupRecord('codex-api-key', draft, original);
|
||||
expect(saved.keys).toEqual([{ ...original.keys[1], 'api-key': 'rotated' }, { 'api-key': 'new' }]);
|
||||
});
|
||||
test('all aliases of one upstream can be edited and removed individually', () => {
|
||||
const original = group();
|
||||
const draft = providerDraftFromRecord('codex-api-key', original);
|
||||
expect(draft.models.map((model) => model.alias ?? '')).toEqual(['', 'upstream-fast']);
|
||||
draft.models[1].alias = 'upstream-renamed';
|
||||
const renamed = buildProviderGroupRecord('codex-api-key', draft, original);
|
||||
expect(renamed.models).toEqual([original.models[0], { ...original.models[1], alias: 'upstream-renamed' }]);
|
||||
draft.models = [draft.models[1]];
|
||||
expect(buildProviderGroupRecord('codex-api-key', draft, original).models)
|
||||
.toEqual([{ ...original.models[1], alias: 'upstream-renamed' }]);
|
||||
});
|
||||
test('stale checks distinguish inherited null from explicit empty overrides', () => {
|
||||
const original = group();
|
||||
const changed = structuredClone(original);
|
||||
changed.keys[1].models = [] as never;
|
||||
expect(resolveProviderRecordIndex([changed], { section: 'codex-api-key', name: original.name,
|
||||
apiKey: 'key-a', baseUrl: original['base-url'], index: 0, record: original })).toBe(-1);
|
||||
});
|
||||
test('inheritance respects null, explicit false, zero and entire map/list overrides', () => {
|
||||
const original = group();
|
||||
const effective = effectiveProviderKey(original, original.keys[1]);
|
||||
expect(effective).toMatchObject({ priority: 3, models: original.models, headers: {}, 'excluded-models': [], 'disable-cooling': false });
|
||||
expect(effectiveProviderKey(original, original.keys[2]).priority).toBe(0);
|
||||
expect(providerGroupStatus({ ...original, 'excluded-models': ['*'] })).toBe('partial');
|
||||
expect(providerGroupStatus({ ...original, disabled: true })).toBe('disabled');
|
||||
});
|
||||
test('serializes explicit blank overrides and rejects malformed headers', () => {
|
||||
const draft = providerKeyDraft({ 'api-key': 'key', headers: null, 'excluded-models': ['old'] });
|
||||
draft.text = { headers: '', 'excluded-models': '' };
|
||||
expect(serializeProviderKey(draft)).toEqual({ 'api-key': 'key', headers: {}, 'excluded-models': [] });
|
||||
draft.text.headers = 'bad header';
|
||||
expect(() => serializeProviderKey(draft)).toThrow();
|
||||
});
|
||||
test('external changes to a sibling key reject stale group saves', () => {
|
||||
const original = group();
|
||||
const identity = { section: 'codex-api-key' as const, name: original.name, apiKey: 'key-a', baseUrl: original['base-url'], index: 0, record: original };
|
||||
const changed = group(); changed.keys[1].weight = 10;
|
||||
expect(resolveProviderRecordIndex([changed], identity)).toBe(-1);
|
||||
const other = { ...group(), name: 'other' };
|
||||
const second = { ...identity, index: 1, name: 'other', record: other };
|
||||
expect(reorderProviderRecords([original, other], [identity, second], identity, second))
|
||||
.toEqual([cleanProviderGroup(other), cleanProviderGroup(original)]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user