feat(api-access): support native CPA v8 groups and fix alias source validation

This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 6dc8650e68
commit 84fe2be8ac
22 files changed
+1136 -95

No files matched your search

+11 -7
View File
@@ -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
View File
@@ -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}`);
+1
View File
@@ -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>
+33
View File
@@ -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>);
+26 -1
View File
@@ -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', '');
+3 -2
View File
@@ -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');
+102
View File
@@ -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; });
+99
View File
@@ -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)]);
});
});