Enhance support for the v8 kernel
This commit is contained in:
1 parent
9991aea818
commit
59b05e9281
25 files changed
+1463
-185
No files matched your search
@@ -74,6 +74,8 @@ const path = require('node:path');
|
||||
await form.locator('.provider-advanced-settings summary').click();
|
||||
const headers = form.getByLabel(/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');
|
||||
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');
|
||||
@@ -97,13 +99,30 @@ const path = require('node:path');
|
||||
const record = await page.evaluate(() => window.providerFixture.records[0]);
|
||||
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(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();
|
||||
for (const name of ['Disable Cooldown', 'Cache User ID']) {
|
||||
const control = form.getByRole('combobox', { name, exact: true });
|
||||
assert.equal(await control.inputValue(), 'false');
|
||||
await control.selectOption('');
|
||||
}
|
||||
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
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);
|
||||
|
||||
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 assertViewportOverlay('.config-dialog-backdrop');
|
||||
await assertDialogControls(add);
|
||||
await add.locator('.provider-advanced-settings summary').click();
|
||||
assert.equal(await add.getByLabel('Test Model', { exact: true }).count(), 0);
|
||||
await add.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await add.waitFor({ state: 'detached' });
|
||||
assert.deepEqual(errors, []);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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 { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { managementApi, responseList } from '../src/services/managementApi';
|
||||
import { loadAuthFileSettings, saveAuthFileSettings } from '../src/services/authFileSettings';
|
||||
import { setOAuthCredentialFileDisabled } from '../src/services/authFiles';
|
||||
import { loadOAuthModelSettings, saveOAuthModelSettings } from '../src/services/oauthModelSettings';
|
||||
import { fetchModels } from '../src/services/modelService';
|
||||
|
||||
const executable = process.env.CPA_V8_TEST_CORE;
|
||||
describe.skipIf(!executable)('real v8 management operations', () => {
|
||||
let child: ChildProcess;
|
||||
let work = '';
|
||||
let origin = '';
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
const headers = { Authorization: 'Bearer isolated-test-secret', 'Content-Type': 'application/json' };
|
||||
const name = 'contract-codex.json';
|
||||
const call = async (method: string, path: string, query?: Record<string, string>, body?: unknown) => {
|
||||
const url = new URL(origin + path);
|
||||
Object.entries(query ?? {}).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||
const response = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const text = await response.text();
|
||||
let result: any;
|
||||
try { result = JSON.parse(text); } catch { result = text; }
|
||||
if (!response.ok) throw new Error(`Management API error (${response.status}): ${result.error ?? text}${result.message ? `: ${result.message}` : ''}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
work = await mkdtemp(join(tmpdir(), 'easycpa-v8-operations-'));
|
||||
const listener = createServer();
|
||||
await new Promise<void>((done) => listener.listen(0, '127.0.0.1', done));
|
||||
const port = (listener.address() as { port: number }).port;
|
||||
await new Promise<void>((done) => listener.close(() => done()));
|
||||
const config = join(work, 'config.yaml');
|
||||
await writeFile(config, `config-version: 8
|
||||
server: {host: 127.0.0.1, port: ${port}}
|
||||
management: {secret-key: isolated-test-secret, disable-control-panel: true, disable-auto-update-panel: true}
|
||||
access: {api-keys: [isolated-client-key]}
|
||||
oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
|
||||
`);
|
||||
child = spawn(resolve(executable!), ['-config', config, '-local-model'], {
|
||||
cwd: work, windowsHide: true, stdio: 'ignore',
|
||||
});
|
||||
let startupError: Error | undefined;
|
||||
child.on('error', (error) => { startupError = error; });
|
||||
origin = `http://127.0.0.1:${port}/v8/management`;
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (startupError) throw startupError;
|
||||
if (child.exitCode !== null) throw new Error(`Kernel exited: ${child.exitCode}`);
|
||||
try { await call('GET', '/config'); ready = true; break; } catch {}
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
if (!ready) throw new Error('Kernel startup timed out');
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
|
||||
mockIPC(async (command, args) => {
|
||||
if (command === 'upload_auth_file') {
|
||||
return call('POST', '/credentials', { name: args!.name as string }, JSON.parse(new TextDecoder().decode(new Uint8Array(args!.data as number[]))));
|
||||
}
|
||||
expect(command).toBe('management_request');
|
||||
const request = args!.request as { method: string; path: string; query?: Record<string, string>; body?: unknown };
|
||||
return call(request.method, request.path, request.query, request.body);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
afterAll(async () => {
|
||||
clearMocks();
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (child?.pid && child.exitCode === null) {
|
||||
const exited = new Promise<void>((done) => child.once('exit', () => done()));
|
||||
child.kill();
|
||||
await exited;
|
||||
}
|
||||
if (work && resolve(work).startsWith(resolve(tmpdir()) + sep + 'easycpa-v8-operations-')) {
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uploads, edits every credential setting, clears overrides, toggles and deletes', async () => {
|
||||
await managementApi.uploadAuthFile(new File([JSON.stringify({
|
||||
type: 'codex', access_token: 'isolated-access-token', expired: '2099-01-01T00:00:00Z',
|
||||
email: 'test@example.invalid', custom_metadata: { preserve: true },
|
||||
})], name, { type: 'application/json' }));
|
||||
let file: Record<string, unknown> | undefined;
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
file = responseList(await managementApi.get('/auth-files'), 'files').find((item) => item.name === name);
|
||||
if (file) break;
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
expect(file).toBeDefined();
|
||||
const original = await loadAuthFileSettings(name);
|
||||
const edited = {
|
||||
...original, prefix: 'team', proxy_url: 'direct', priority: '-2', weight: '3',
|
||||
disable_cooling: 'false' as const, websockets: 'false' as const,
|
||||
excluded_models: 'old-*\nretired', headers: '{"X-Test":"one","X-Remove":"two"}', note: 'test note',
|
||||
};
|
||||
await saveAuthFileSettings(name, original, edited);
|
||||
const reloaded = await loadAuthFileSettings(name);
|
||||
expect(reloaded).toMatchObject({ ...edited, headers: reloaded.headers });
|
||||
expect(JSON.parse(reloaded.headers)).toEqual(JSON.parse(edited.headers));
|
||||
const models = await loadOAuthModelSettings({ scope: 'credential', provider: 'codex', label: 'test', name });
|
||||
expect(models.catalogError).toBe('');
|
||||
expect(models.excludedRules).toEqual(['old-*', 'retired']);
|
||||
await saveOAuthModelSettings(models, ['next-*']);
|
||||
const beforeClear = await loadAuthFileSettings(name);
|
||||
await saveAuthFileSettings(name, beforeClear, {
|
||||
...beforeClear, prefix: '', proxy_url: '', priority: '', weight: '',
|
||||
disable_cooling: '', websockets: '', excluded_models: '', headers: '{"X-Test":"updated"}', note: '',
|
||||
});
|
||||
const cleared = await loadAuthFileSettings(name);
|
||||
expect(cleared).toMatchObject({ prefix: '', proxy_url: '', priority: '0', weight: '',
|
||||
disable_cooling: '', websockets: '', excluded_models: '', note: '' });
|
||||
expect(JSON.parse(cleared.headers)).toEqual({ 'X-Test': 'updated' });
|
||||
const metadata = await managementApi.get<Record<string, unknown>>('/auth-files/download', { name });
|
||||
expect(metadata.custom_metadata).toEqual({ preserve: true });
|
||||
expect(metadata.access_token).toBe('isolated-access-token');
|
||||
await setOAuthCredentialFileDisabled(file!, true);
|
||||
expect(responseList(await managementApi.get('/auth-files'), 'files').find((item) => item.name === name)?.disabled).toBe(true);
|
||||
await setOAuthCredentialFileDisabled(file!, false);
|
||||
expect(responseList(await managementApi.get('/auth-files'), 'files').find((item) => item.name === name)?.disabled).toBe(false);
|
||||
await managementApi.delete('/auth-files', { query: { name } });
|
||||
expect(responseList(await managementApi.get('/auth-files'), 'files').some((item) => item.name === name)).toBe(false);
|
||||
});
|
||||
|
||||
it('edits provider model exclusions without changing siblings and clears the last rule', async () => {
|
||||
await call('PUT', '/config/oauth/excluded-models', undefined, null);
|
||||
const target = { scope: 'provider' as const, provider: 'codex', label: 'Codex' };
|
||||
const original = await loadOAuthModelSettings(target);
|
||||
expect(original.catalogError).toBe('');
|
||||
expect(original.models.length).toBeGreaterThan(0);
|
||||
expect(original.excludedRules).toEqual([]);
|
||||
await managementApi.patch('/oauth-excluded-models', { provider: 'claude', models: ['keep-*'] });
|
||||
await saveOAuthModelSettings(original, ['hide-*']);
|
||||
const edited = await loadOAuthModelSettings(target);
|
||||
expect(edited.excludedRules).toEqual(['hide-*']);
|
||||
await saveOAuthModelSettings(edited, []);
|
||||
expect((await loadOAuthModelSettings(target)).excludedRules).toEqual([]);
|
||||
expect(await managementApi.get('/oauth-excluded-models')).toEqual({ 'oauth-excluded-models': { claude: ['keep-*'] } });
|
||||
await managementApi.delete('/oauth-excluded-models', { query: { provider: 'claude' } });
|
||||
});
|
||||
|
||||
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 });
|
||||
return Response.json({ data: [{ id: 'local-model' }] });
|
||||
} });
|
||||
try {
|
||||
const models = await fetchModels('openai', `http://127.0.0.1:${upstream.port}/v1`, 'local-key');
|
||||
expect(models.map((model) => model.name)).toEqual(['local-model']);
|
||||
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
|
||||
method: 'GET', url: `http://127.0.0.1:${upstream.port}/usage`, header: { Authorization: 'Bearer local-key' },
|
||||
});
|
||||
expect(response.status_code).toBe(200);
|
||||
expect(JSON.parse(response.body as string)).toEqual({ data: [{ id: 'local-model' }] });
|
||||
} finally { await upstream.stop(true); }
|
||||
});
|
||||
|
||||
it('uses the v8 usage queue and OAuth validation/session endpoints', async () => {
|
||||
expect(await call('GET', '/observability/usage/queue', { count: '10' })).toEqual([]);
|
||||
const status = await call('GET', '/oauth/status', { state: 'isolated-unknown-state' });
|
||||
expect(typeof status.status).toBe('string');
|
||||
await expect(call('GET', '/oauth/auth-url', { provider: 'unsupported-test-provider' })).rejects.toThrow('(404): provider_not_found');
|
||||
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' } });
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,15 @@ import {
|
||||
legacyManagementConfigView,
|
||||
managementApi,
|
||||
} from '../src/services/managementApi';
|
||||
import {
|
||||
apiAccessRemarkLocatorFromRecord,
|
||||
buildProviderRecord,
|
||||
createProviderDraft,
|
||||
providerCategoryMatchesRecord,
|
||||
providerRecordWithDisabledState,
|
||||
} from '../src/pages/ApiAccessPage';
|
||||
|
||||
describe('optional v8 config reads', () => {
|
||||
describe('v8 Management API requests', () => {
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
beforeEach(() => {
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
@@ -38,6 +45,48 @@ describe('optional v8 config reads', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('saves and reloads DeepSeek through the v8 Codex group without credential names', async () => {
|
||||
for (const baseUrl of ['https://api.deepseek.com', 'https://gateway.example.test/v1']) {
|
||||
let persisted: unknown = [];
|
||||
mockIPC((command, args) => {
|
||||
expect(command).toBe('management_request');
|
||||
const request = args?.request as { method: string; path: string; body: unknown };
|
||||
expect(request.path).toBe('/config/api-keys/codex');
|
||||
if (request.method === 'GET') return persisted;
|
||||
expect(request.method).toBe('PUT');
|
||||
const groups = request.body as Record<string, unknown>[];
|
||||
for (const group of groups) {
|
||||
for (const key of group.keys as Record<string, unknown>[]) {
|
||||
if ('name' in key) throw new Error('invalid_config: field name not found in type config.CodexKey');
|
||||
}
|
||||
}
|
||||
persisted = request.body;
|
||||
return { status: 'ok' };
|
||||
});
|
||||
const original = buildProviderRecord('codex-api-key', {
|
||||
...createProviderDraft('deepseek'), apiKey: 'deepseek-test-key', baseUrl,
|
||||
models: [{ name: 'deepseek-chat', alias: 'my-deepseek' }],
|
||||
priority: '5', proxyUrl: 'direct', headersText: 'X-Team: test',
|
||||
});
|
||||
const regular = buildProviderRecord('codex-api-key', {
|
||||
...createProviderDraft('codex-api-key'), apiKey: 'codex-test-key',
|
||||
baseUrl: 'https://codex.example.test/v1', models: [{ name: 'gpt-test' }],
|
||||
});
|
||||
for (const record of [original, providerRecordWithDisabledState('codex-api-key', original, true)]) {
|
||||
await managementApi.put('/codex-api-key', [regular, record]);
|
||||
const loaded = await managementApi.get<{ 'codex-api-key': Record<string, unknown>[] }>('/codex-api-key');
|
||||
expect(loaded['codex-api-key']).toEqual([regular, record]);
|
||||
expect(providerCategoryMatchesRecord('deepseek', loaded['codex-api-key'][1])).toBe(true);
|
||||
expect(apiAccessRemarkLocatorFromRecord('codex-api-key', loaded['codex-api-key'][1]))
|
||||
.toEqual(apiAccessRemarkLocatorFromRecord('codex-api-key', original));
|
||||
await managementApi.put('/codex-api-key', [record, regular]);
|
||||
expect((await managementApi.get('/codex-api-key'))).toEqual({ 'codex-api-key': [record, regular] });
|
||||
}
|
||||
await managementApi.put('/codex-api-key', [regular]);
|
||||
expect(await managementApi.get('/codex-api-key')).toEqual({ 'codex-api-key': [regular] });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiCallErrorMessage', () => {
|
||||
@@ -72,10 +121,12 @@ describe('v8 Management API compatibility view', () => {
|
||||
}];
|
||||
expect(flattenV8ProviderGroups('codex', groups)).toEqual([
|
||||
{
|
||||
name: 'production',
|
||||
'base-url': 'https://example.test/v1', priority: 10,
|
||||
models: [{ name: 'model-a' }], 'api-key': 'first', 'proxy-url': 'direct',
|
||||
},
|
||||
{
|
||||
name: 'production',
|
||||
'base-url': 'https://example.test/v1', priority: 0,
|
||||
models: [{ name: 'model-a' }], 'api-key': 'second',
|
||||
},
|
||||
@@ -90,13 +141,28 @@ describe('v8 Management API compatibility view', () => {
|
||||
models: [{ name: 'claude-test' }],
|
||||
cloak: { mode: 'always' },
|
||||
}])).toEqual([{
|
||||
name: 'claude-1',
|
||||
name: 'delegated-key',
|
||||
'base-url': 'https://claude.test',
|
||||
models: [{ name: 'claude-test' }],
|
||||
keys: [{ name: 'delegated-key', 'api-key': 'secret', cloak: { mode: 'always' } }],
|
||||
keys: [{ 'api-key': 'secret', cloak: { mode: 'always' } }],
|
||||
}]);
|
||||
});
|
||||
|
||||
it('repairs legacy credential names while preserving policies and unnamed record identities', () => {
|
||||
const records = flattenV8ProviderGroups('codex', [{
|
||||
name: 'codex-1', 'base-url': 'https://gateway.example.test/v1', priority: 5,
|
||||
keys: [
|
||||
{ name: 'DeepSeek', 'api-key': 'first', weight: 2 },
|
||||
{ 'api-key': 'second', priority: 0 },
|
||||
],
|
||||
}]);
|
||||
const groups = groupLegacyProviderRecords('codex', records);
|
||||
expect(groups[0]).toMatchObject({ name: 'DeepSeek', keys: [{ 'api-key': 'first', weight: 2 }] });
|
||||
expect(groups[1]).toMatchObject({ name: 'codex-2', keys: [{ 'api-key': 'second' }] });
|
||||
expect(flattenV8ProviderGroups('codex', groups)).toEqual(records);
|
||||
expect(records[1]).not.toHaveProperty('name');
|
||||
});
|
||||
|
||||
it('projects the v8 config tree into the legacy semantic view used by the UI', () => {
|
||||
const view = legacyManagementConfigView({
|
||||
access: { 'api-keys': ['client-key'] },
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import {
|
||||
applyProviderPreset, buildProviderRecord, createProviderDraft,
|
||||
type ProviderSection,
|
||||
} from '../src/pages/ApiAccessPage';
|
||||
import { flattenV8ProviderGroups, groupLegacyProviderRecords } from '../src/services/managementApi';
|
||||
|
||||
const executable = process.env.CPA_V8_TEST_CORE;
|
||||
describe.skipIf(!executable)('real v8 provider configuration', () => {
|
||||
let child: ChildProcess;
|
||||
let work = '';
|
||||
let origin = '';
|
||||
const headers = { Authorization: 'Bearer isolated-test-secret', 'Content-Type': 'application/json' };
|
||||
beforeAll(async () => {
|
||||
work = await mkdtemp(join(tmpdir(), 'easycpa-v8-fields-'));
|
||||
const listener = createServer();
|
||||
await new Promise<void>((done) => listener.listen(0, '127.0.0.1', done));
|
||||
const port = (listener.address() as { port: number }).port;
|
||||
await new Promise<void>((done) => listener.close(() => done()));
|
||||
const config = join(work, 'config.yaml');
|
||||
await writeFile(config, `config-version: 8
|
||||
server: {host: 127.0.0.1, port: ${port}}
|
||||
management: {secret-key: isolated-test-secret, disable-control-panel: true, disable-auto-update-panel: true}
|
||||
access: {api-keys: [isolated-client-key]}
|
||||
oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
|
||||
`);
|
||||
child = spawn(resolve(executable!), ['--config', config, '--local-model'], {
|
||||
cwd: work, windowsHide: true, stdio: 'ignore',
|
||||
});
|
||||
let startupError: Error | undefined;
|
||||
child.on('error', (error) => { startupError = error; });
|
||||
origin = `http://127.0.0.1:${port}/v8/management`;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (startupError) throw startupError;
|
||||
if (child.exitCode !== null) throw new Error(`Kernel exited: ${child.exitCode}`);
|
||||
try { if ((await fetch(`${origin}/config`, { headers })).ok) return; } catch {}
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
throw new Error('Kernel startup timed out');
|
||||
}, 15000);
|
||||
afterAll(async () => {
|
||||
if (child?.pid && child.exitCode === null) {
|
||||
const exited = new Promise<void>((done) => child.once('exit', () => done()));
|
||||
child.kill();
|
||||
await exited;
|
||||
}
|
||||
if (work && resolve(work).startsWith(resolve(tmpdir()) + sep + 'easycpa-v8-fields-')) {
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const save = async (provider: string, records: Record<string, unknown>[]) => {
|
||||
const url = `${origin}/config/api-keys/${provider}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT', headers, body: JSON.stringify(groupLegacyProviderRecords(provider, records)),
|
||||
});
|
||||
const result = await response.text();
|
||||
expect({ status: response.status, result: response.ok ? 'ok' : result }).toEqual({ status: 200, result: 'ok' });
|
||||
const loaded = await fetch(url, { headers });
|
||||
expect(loaded.status).toBe(200);
|
||||
return flattenV8ProviderGroups(provider, await loaded.json());
|
||||
};
|
||||
|
||||
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', '');
|
||||
it(`${category}: accepts advanced fields and discovered reasoning metadata`, async () => {
|
||||
const draft = {
|
||||
...createProviderDraft(category), name: category === 'deepseek' ? 'DeepSeek' : 'integration',
|
||||
apiKey: 'test-key', baseUrl: 'https://gateway.example.test/v1', priority: '-2',
|
||||
proxyUrl: 'direct', prefix: 'team', headersText: 'X-Team: test', excludedModelsText: 'old-*',
|
||||
disableCooling: true, websockets: true,
|
||||
cloakMode: 'always', cloakStrictMode: true, cloakSensitiveWordsText: 'internal', cloakCacheUserId: true,
|
||||
models: [{ name: 'upstream', alias: 'public', thinking: {
|
||||
min: 0, max: 8192, zero_allowed: true, dynamic_allowed: true, levels: ['low', 'high'],
|
||||
} }],
|
||||
};
|
||||
const [loaded] = await save(provider, [buildProviderRecord(section, draft)]);
|
||||
expect(loaded).toMatchObject({ priority: -2, prefix: 'team', headers: { 'X-Team': 'test' }, 'disable-cooling': true });
|
||||
expect(loaded.models).toMatchObject([{ name: 'upstream', alias: 'public', thinking: {
|
||||
min: 0, max: 8192, 'zero-allowed': true, 'dynamic-allowed': true, levels: ['low', 'high'],
|
||||
} }]);
|
||||
if (section === 'openai-compatibility') expect(loaded['api-key-entries']).toMatchObject([{ 'proxy-url': 'direct' }]);
|
||||
else expect(loaded).toMatchObject({ 'proxy-url': 'direct', 'excluded-models': ['old-*'] });
|
||||
if (section === 'codex-api-key') expect(loaded.websockets).toBe(true);
|
||||
if (section === 'claude-api-key') expect(loaded.cloak).toMatchObject({
|
||||
mode: 'always', 'strict-mode': true, 'sensitive-words': ['internal'], 'cache-user-id': true,
|
||||
});
|
||||
|
||||
const editedDraft = { ...draft, priority: '0', prefix: '', headersText: '', proxyUrl: '',
|
||||
excludedModelsText: '', disableCooling: false, websockets: false,
|
||||
cloakMode: 'never', cloakStrictMode: false, cloakSensitiveWordsText: '', cloakCacheUserId: false,
|
||||
models: [{ name: 'upstream', alias: 'renamed' }],
|
||||
};
|
||||
const [edited] = await save(provider, [buildProviderRecord(section, editedDraft, loaded)]);
|
||||
expect(edited.priority ?? 0).toBe(0);
|
||||
for (const field of ['prefix', 'headers', 'proxy-url', 'excluded-models']) expect(edited).not.toHaveProperty(field);
|
||||
expect(edited['disable-cooling']).toBe(false);
|
||||
expect(edited.models).toMatchObject([{ name: 'upstream', alias: 'renamed' }]);
|
||||
if (section === 'codex-api-key') expect(edited.websockets ?? false).toBe(false);
|
||||
if (section === 'openai-compatibility') {
|
||||
expect((edited['api-key-entries'] as Record<string, unknown>[])[0]).not.toHaveProperty('proxy-url');
|
||||
}
|
||||
if (section === 'claude-api-key') {
|
||||
expect(edited.cloak).toMatchObject({ mode: 'never', 'cache-user-id': false });
|
||||
expect((edited.cloak as Record<string, unknown>)['strict-mode'] ?? false).toBe(false);
|
||||
expect(edited.cloak).not.toHaveProperty('sensitive-words');
|
||||
}
|
||||
const [inherited] = await save(provider, [buildProviderRecord(section, {
|
||||
...editedDraft, disableCooling: null, cloakMode: '', cloakCacheUserId: null,
|
||||
}, edited)]);
|
||||
expect(inherited).not.toHaveProperty('disable-cooling');
|
||||
if (section === 'claude-api-key') expect(inherited).not.toHaveProperty('cloak');
|
||||
});
|
||||
}
|
||||
|
||||
for (const provider of ['interactions', 'vertex', 'xai', 'meta']) {
|
||||
it(`${provider}: round-trips the additional provider families supported by the adapter`, async () => {
|
||||
const [loaded] = await save(provider, [{
|
||||
'api-key': 'test-key', 'base-url': 'https://gateway.example.test/v1', weight: 2,
|
||||
'disable-cooling': false, 'request-retry': 0,
|
||||
models: [{ name: 'model', thinking: { zero_allowed: false, dynamic_allowed: true } }],
|
||||
}]);
|
||||
expect(loaded).toMatchObject({ weight: 2, 'disable-cooling': false, 'request-retry': 0 });
|
||||
expect(loaded.models).toMatchObject([{ thinking: { 'zero-allowed': false, 'dynamic-allowed': true } }]);
|
||||
});
|
||||
}
|
||||
|
||||
it('removes obsolete OpenAI test-model metadata when saving old records', async () => {
|
||||
const [loaded] = await save('openai-compatibility', [{
|
||||
name: 'compat', 'base-url': 'https://gateway.example.test/v1',
|
||||
'api-key-entries': [{ 'api-key': 'test-key' }], models: [{ name: 'model' }], 'test-model': 'model',
|
||||
}]);
|
||||
expect(loaded).not.toHaveProperty('test-model');
|
||||
});
|
||||
|
||||
it('persists explicit false for cooldown and Claude user ID caching', async () => {
|
||||
const draft = { ...createProviderDraft('claude-api-key'), apiKey: 'test-key',
|
||||
baseUrl: 'https://gateway.example.test', disableCooling: false,
|
||||
cloakMode: 'always', cloakCacheUserId: false,
|
||||
};
|
||||
const [loaded] = await save('claude', [buildProviderRecord('claude-api-key', draft)]);
|
||||
expect(loaded['disable-cooling']).toBe(false);
|
||||
expect(loaded.cloak).toMatchObject({ 'cache-user-id': false });
|
||||
});
|
||||
|
||||
it('clears reasoning levels without restoring the previous values or dropping model options', async () => {
|
||||
const current = { name: 'compat', 'base-url': 'https://gateway.example.test/v1',
|
||||
'api-key-entries': [{ 'api-key': 'test-key' }], models: [
|
||||
{ name: 'model', alias: 'first', 'max-context-length': 65536, thinking: { levels: ['low', 'high'] } },
|
||||
{ name: 'model', alias: 'second', thinking: { min: 128, max: 8192, levels: ['high'] } },
|
||||
],
|
||||
};
|
||||
const draft = applyProviderPreset('openai-compatibility', {
|
||||
...createProviderDraft('openai-compatibility'), name: 'compat', apiKey: 'test-key',
|
||||
baseUrl: current['base-url'], thinkingLevels: [], thinkingLevelsEdited: true,
|
||||
models: [{ name: 'model', alias: 'first', thinking: { levels: ['low', 'high'] } }],
|
||||
});
|
||||
const [loaded] = await save('openai-compatibility', [buildProviderRecord('openai-compatibility', draft, current)]);
|
||||
expect(loaded.models).toEqual([
|
||||
{ name: 'model', alias: 'first', 'max-context-length': 65536 },
|
||||
{ name: 'model', alias: 'second', thinking: { min: 128, max: 8192 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
stripResponseFields,
|
||||
} from '../src/pages/ApiAccessPage';
|
||||
import { modelsFromRecord } from '../src/services/modelService';
|
||||
import { flattenV8ProviderGroups } from '../src/services/managementApi';
|
||||
|
||||
describe('shared-credential provider entries (#276)', () => {
|
||||
const first = {
|
||||
@@ -68,6 +69,39 @@ describe('shared-credential provider entries (#276)', () => {
|
||||
expect(hasDuplicateProviderRecord('codex-api-key', [first], [second])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects duplicate Codex and Claude credentials regardless of v8 group labels', () => {
|
||||
for (const section of ['codex-api-key', 'claude-api-key'] as const) {
|
||||
const records = flattenV8ProviderGroups(section.replace('-api-key', ''), [{
|
||||
name: 'production', 'base-url': first['base-url'], priority: 10,
|
||||
models: first.models, keys: [{ 'api-key': 'shared-key' }],
|
||||
}]);
|
||||
const candidate = buildProviderRecord(section, {
|
||||
...createProviderDraft(section), apiKey: 'shared-key',
|
||||
baseUrl: first['base-url'], priority: '10', models: first.models,
|
||||
});
|
||||
expect(hasDuplicateProviderRecord(section, records, [candidate])).toBe(true);
|
||||
expect(hasDuplicateProviderRecord(section, records, [{ ...candidate, name: 'another-group' }])).toBe(true);
|
||||
expect(hasDuplicateProviderRecord(section, records, [candidate], 0)).toBe(false);
|
||||
expect(hasDuplicateProviderRecord(section, records, [{ ...candidate, priority: 1 }])).toBe(false);
|
||||
expect(hasDuplicateProviderRecord(section, records, [{ ...candidate, models: second.models }])).toBe(false);
|
||||
expect(records[0].name).toBe('production');
|
||||
expect(candidate).not.toHaveProperty('name');
|
||||
}
|
||||
expect(hasDuplicateProviderRecord('openai-compatibility', [{ ...first, name: 'production' }], [
|
||||
{ ...first, name: 'another-group' },
|
||||
])).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps group labels in edit and remark identities when checking for duplicates', () => {
|
||||
const production = { ...first, name: 'production' };
|
||||
const staging = { ...first, name: 'staging' };
|
||||
expect(hasDuplicateProviderRecord('claude-api-key', [production], [staging])).toBe(true);
|
||||
expect(resolveProviderRecordIndex([staging, production], row(production, 0))).toBe(1);
|
||||
expect(apiAccessRemarkLocatorFromRecord('claude-api-key', production).providerName).toBe('production');
|
||||
expect(providerRemarkIdentity('claude-api-key', apiAccessRemarkLocatorFromRecord('claude-api-key', production)))
|
||||
.not.toBe(providerRemarkIdentity('claude-api-key', apiAccessRemarkLocatorFromRecord('claude-api-key', staging)));
|
||||
});
|
||||
|
||||
it('locates the second entry for edit/toggle/delete, even after an external reorder', () => {
|
||||
const selected = row({ ...second, 'auth-index': 'runtime-second' }, 1);
|
||||
expect(resolveProviderRecordIndex([first, second], selected)).toBe(1);
|
||||
@@ -325,9 +359,10 @@ describe('API 接入配置合并', () => {
|
||||
name: 'custom-openai',
|
||||
baseUrl: 'https://api.example.com',
|
||||
thinkingLevels: ['fast', 'ultra'],
|
||||
thinkingLevelsEdited: true,
|
||||
models: [
|
||||
{ name: 'reasoning-a' },
|
||||
{ name: 'reasoning-b', thinking: { effort: 'high' } },
|
||||
{ name: 'reasoning-b', thinking: { min: 128, max: 8192 } },
|
||||
],
|
||||
});
|
||||
const result = buildProviderRecord('openai-compatibility', draft);
|
||||
@@ -336,7 +371,7 @@ describe('API 接入配置合并', () => {
|
||||
{ name: 'reasoning-a', thinking: { levels: ['fast', 'ultra'] } },
|
||||
{
|
||||
name: 'reasoning-b',
|
||||
thinking: { effort: 'high', levels: ['fast', 'ultra'] },
|
||||
thinking: { min: 128, max: 8192, levels: ['fast', 'ultra'] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { applyProviderPreset, buildProviderRecord, createProviderDraft, stripResponseFields } from '../src/pages/ApiAccessPage';
|
||||
import { modelsFromDiscoveredPayload, modelsFromRecord } from '../src/services/modelService';
|
||||
|
||||
describe('provider field editing semantics', () => {
|
||||
it('distinguishes explicit false, inherited defaults and untouched optional settings', () => {
|
||||
for (const section of ['codex-api-key', 'claude-api-key', 'gemini-api-key', 'openai-compatibility'] as const) {
|
||||
const draft = { ...createProviderDraft(section), name: 'provider', apiKey: 'key', baseUrl: 'https://example.test/v1' };
|
||||
const current = { 'disable-cooling': false, 'request-retry': 0, weight: 0 };
|
||||
for (const value of [true, false, null, undefined]) {
|
||||
const result = buildProviderRecord(section, { ...draft, disableCooling: value }, current);
|
||||
if (value === null) expect(result).not.toHaveProperty('disable-cooling');
|
||||
else expect(result['disable-cooling']).toBe(value ?? false);
|
||||
expect(result['request-retry']).toBe(0);
|
||||
expect(result.weight).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves other cloak fields during a partial update and can restore inherited caching', () => {
|
||||
const draft = { name: '', remark: '', apiKey: 'key', baseUrl: '', priority: '', models: [] };
|
||||
const current = { cloak: { mode: 'always', 'strict-mode': true, 'cache-user-id': true, 'sensitive-words': ['private'] } };
|
||||
for (const value of [false, true, null]) {
|
||||
const result = buildProviderRecord('claude-api-key', { ...draft, cloakCacheUserId: value }, current);
|
||||
expect(result.cloak).toMatchObject({ mode: 'always', 'strict-mode': true, 'sensitive-words': ['private'] });
|
||||
if (value === null) expect(result.cloak).not.toHaveProperty('cache-user-id');
|
||||
else expect(result.cloak).toMatchObject({ 'cache-user-id': value });
|
||||
}
|
||||
expect(current.cloak['cache-user-id']).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves each model reasoning policy intact when only another field was edited', () => {
|
||||
const current = { name: 'compat', models: [
|
||||
{ name: 'first', thinking: { levels: ['low', 'high'] } },
|
||||
{ name: 'second', thinking: { min: 128, max: 8192, levels: ['medium'] } },
|
||||
] };
|
||||
const draft = applyProviderPreset('openai-compatibility', {
|
||||
...createProviderDraft('openai-compatibility'), name: 'compat', apiKey: 'key',
|
||||
models: modelsFromRecord(current.models), thinkingLevels: ['low', 'high', 'medium'],
|
||||
thinkingLevelsEdited: false, priority: '2',
|
||||
});
|
||||
expect(buildProviderRecord('openai-compatibility', draft, current).models).toEqual(current.models);
|
||||
});
|
||||
|
||||
it('normalizes discovery JSON names and ignores non-config upstream reasoning metadata', () => {
|
||||
const models = modelsFromDiscoveredPayload({ data: [{ id: 'model', thinking: {
|
||||
min: 128, max: 8192, zero_allowed: true, 'zero-allowed': false, dynamic_allowed: true,
|
||||
levels: ['low', 'high'], effort: 'high', vendor_metadata: { arbitrary: true },
|
||||
} }, { id: 'bad-metadata', thinking: { min: 'oops', max: 1.5, levels: [1], zero_allowed: 'yes' } }] });
|
||||
expect(models[0].thinking).toEqual({ min: 128, max: 8192, 'zero-allowed': false, 'dynamic-allowed': true, levels: ['low', 'high'] });
|
||||
expect(models[1].thinking).toBeUndefined();
|
||||
expect(stripResponseFields({ 'test-model': 'old', models: [{ name: 'model', thinking: { zero_allowed: true }, custom: true }] }))
|
||||
.toEqual({ models: [{ name: 'model', thinking: { 'zero-allowed': true }, custom: true }] });
|
||||
});
|
||||
|
||||
it('accepts negative priorities and rejects fractional, nonnumeric and unsafe values', () => {
|
||||
const draft = { ...createProviderDraft('codex-api-key'), apiKey: 'key' };
|
||||
expect(buildProviderRecord('codex-api-key', { ...draft, priority: '-2' }).priority).toBe(-2);
|
||||
for (const priority of ['1.5', 'abc', 'Infinity', '9007199254740992']) {
|
||||
expect(() => buildProviderRecord('codex-api-key', { ...draft, priority })).toThrow('Priority must be a safe integer');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user