diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 93f6d85..ca06244 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -61,11 +61,20 @@ pub(crate) struct ManagementRequest { method: String, path: String, query: Option>, + #[serde(default, deserialize_with = "deserialize_management_body")] body: Option, #[serde(rename = "timeoutMs")] timeout_ms: Option, } +fn deserialize_management_body<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + // An explicit v8 null is a JSON value, not an omitted request body. + serde_json::Value::deserialize(deserializer).map(Some) +} + #[tauri::command] pub(crate) async fn management_request( gui_config_state: tauri::State<'_, GuiConfigState>, @@ -96,7 +105,7 @@ pub(crate) async fn management_request( builder = builder.query(&query); } if let Some(body) = request.body { - builder = builder.json(&body); + builder = management_request_body(builder, path, body)?; } let response = builder @@ -106,6 +115,21 @@ pub(crate) async fn management_request( read_management_value(response).await } +fn management_request_body( + builder: reqwest::RequestBuilder, + path: &str, + body: serde_json::Value, +) -> Result { + if path.trim_start_matches('/') == "config.yaml" { + let yaml = body.as_str().ok_or("YAML configuration must be text")?; + Ok(builder + .header("Content-Type", "application/yaml") + .body(yaml.to_owned())) + } else { + Ok(builder.json(&body)) + } +} + #[tauri::command] pub(crate) async fn upload_auth_file( gui_config_state: tauri::State<'_, GuiConfigState>, @@ -507,6 +531,65 @@ mod tests { use super::*; use std::path::PathBuf; + #[test] + fn v8_yaml_body_is_raw_text_and_config_values_remain_json() { + let client = reqwest::Client::new(); + let yaml = "config-version: 8\naccess: {api-keys: [test-client]}\n"; + let request = management_request_body( + client.put("http://127.0.0.1/v8/management/config.yaml"), + "/config.yaml", + serde_json::json!(yaml), + ) + .unwrap() + .build() + .unwrap(); + assert_eq!(request.headers()["Content-Type"], "application/yaml"); + assert_eq!(request.body().unwrap().as_bytes().unwrap(), yaml.as_bytes()); + + for value in [ + serde_json::Value::Null, + serde_json::json!(false), + serde_json::json!(0), + serde_json::json!([]), + serde_json::json!({}), + serde_json::json!("direct"), + ] { + let request = management_request_body( + client.put("http://127.0.0.1/v8/management/config/requests/proxy-url"), + "/config/requests/proxy-url", + value.clone(), + ) + .unwrap() + .build() + .unwrap(); + assert_eq!(request.headers()["Content-Type"], "application/json"); + assert_eq!( + serde_json::from_slice::(request.body().unwrap().as_bytes().unwrap()).unwrap(), + value, + ); + } + assert!(management_request_body( + client.put("http://127.0.0.1/v8/management/config.yaml"), + "/config.yaml", + serde_json::json!({}), + ) + .is_err()); + } + + #[test] + fn v8_requests_distinguish_explicit_null_from_an_absent_body() { + let absent: ManagementRequest = serde_json::from_value(serde_json::json!({ + "method": "GET", "path": "/config", + })) + .unwrap(); + let explicit: ManagementRequest = serde_json::from_value(serde_json::json!({ + "method": "PUT", "path": "/config/requests/payload", "body": null, + })) + .unwrap(); + assert!(absent.body.is_none()); + assert_eq!(explicit.body, Some(serde_json::Value::Null)); + } + #[test] fn plugin_oauth_preserves_literal_provider_ids_without_builtin_aliases() { for provider in [ diff --git a/src/components/AuthFileModelsDialog.tsx b/src/components/AuthFileModelsDialog.tsx index ae349fa..d4c0d4c 100644 --- a/src/components/AuthFileModelsDialog.tsx +++ b/src/components/AuthFileModelsDialog.tsx @@ -25,7 +25,7 @@ export function AuthFileModelsDialog({ name, onClose }: AuthFileModelsDialogProp setLoading(true); setModels([]); setError(''); - void managementApi.get('/auth-files/models', { name }) + void managementApi.get('/credentials/models', { name }) .then((payload) => { if (!cancelled) setModels(oauthModelsFromPayload(payload)); }) .catch((requestError: unknown) => { if (!cancelled) setError(String(requestError)); }) .finally(() => { if (!cancelled) setLoading(false); }); diff --git a/src/components/AuthFileSettingsDialog.tsx b/src/components/AuthFileSettingsDialog.tsx index eab4d39..8213536 100644 --- a/src/components/AuthFileSettingsDialog.tsx +++ b/src/components/AuthFileSettingsDialog.tsx @@ -56,7 +56,7 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: { }).catch((reason: unknown) => { if (active) setError(reason instanceof Error ? reason.message : String(reason)); }).finally(() => { if (active) setLoading(false); }); - void managementApi.get('/auth-files/models', { name }).then((payload) => { + void managementApi.get('/credentials/models', { name }).then((payload) => { if (active) setModels(oauthModelsFromPayload(payload)); }).catch((reason: unknown) => { if (active) setCatalogError(reason instanceof Error ? reason.message : String(reason)); diff --git a/src/mocks/browserMockRuntime.ts b/src/mocks/browserMockRuntime.ts index 7c3626e..50fe0da 100644 --- a/src/mocks/browserMockRuntime.ts +++ b/src/mocks/browserMockRuntime.ts @@ -81,20 +81,6 @@ function groupProviderRecords(section: ProviderSection, records: JsonObject[]): }); } -function flattenProviderGroups(section: ProviderSection, groups: JsonObject[]): JsonObject[] { - return groups.flatMap((group) => { - const keys = asArray(group.keys).map(asObject); - if (section === 'openai-compatibility') { - const record: JsonObject = clone(group); - delete record.keys; - if (keys.length > 0) record['api-key-entries'] = clone(keys); - return [record]; - } - const shared = Object.fromEntries(Object.entries(group).filter(([field]) => field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field))); - return keys.map((key) => clone({ ...shared, ...Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null)) })); - }); -} - const isoHoursAgo = (hours: number) => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); const localHourKey = (date: Date) => [ date.getFullYear(), @@ -738,21 +724,6 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) { return { status: 'ok', 'config-version': 8 }; } } - if (PROVIDER_SECTIONS.some((section) => path === `/${section}`)) { - const section = path.slice(1) as ProviderSection; - if (method === 'GET') return { [section]: flattenProviderGroups(section, state.providerConfig[section]) }; - if (method === 'PUT') { - state.providerConfig[section] = groupProviderRecords(section, asArray(body).map(asObject)); - return { [section]: flattenProviderGroups(section, state.providerConfig[section]) }; - } - if (method === 'PATCH' && section === 'openai-compatibility') { - const patch = asObject(body); - const index = readNumber(patch.index, -1); - const current = flattenProviderGroups(section, state.providerConfig[section])[index]; - if (current) state.providerConfig[section][index] = groupProviderRecords(section, [{ ...current, ...asObject(patch.value) }])[0]; - return { [section]: flattenProviderGroups(section, state.providerConfig[section]) }; - } - } if (method === 'GET' && path === '/credentials') { return { files: clone(state.authFiles), observed_at: new Date().toISOString() }; } @@ -765,24 +736,10 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) { if (method === 'GET' && path === '/config/oauth/excluded-models') { return clone(state.oauthExcludedModels); } - if (path.startsWith('/config/oauth/excluded-models/')) { - const provider = decodeURIComponent(path.slice('/config/oauth/excluded-models/'.length)); - if (method === 'PUT') state.oauthExcludedModels[provider] = asArray(body).map(String); - if (method === 'DELETE') delete state.oauthExcludedModels[provider]; + if (method === 'PUT' && path === '/config/oauth/excluded-models') { + state.oauthExcludedModels = clone(asObject(body)) as Record; return { status: 'ok', 'config-version': 8 }; } - if (method === 'GET' && path === '/oauth-excluded-models') { - return { 'oauth-excluded-models': clone(state.oauthExcludedModels) }; - } - if (method === 'PATCH' && path === '/oauth-excluded-models') { - const patch = asObject(body); - state.oauthExcludedModels[readString(patch.provider)] = asArray(patch.models).map(String); - return { 'oauth-excluded-models': clone(state.oauthExcludedModels) }; - } - if (method === 'DELETE' && path === '/oauth-excluded-models') { - delete state.oauthExcludedModels[readString(query.provider)]; - return null; - } if (method === 'PATCH' && (path === '/credentials/fields' || path === '/credentials/status')) { const patch = asObject(body); const file = findAuthFile(state, readString(patch.name)); diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index d74dbe7..f73da7b 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -172,7 +172,7 @@ export function AuthFileManagementPage() { if (showLoading) setLoading(true); setError(''); try { - const payload = await managementApi.get('/auth-files'); + const payload = await managementApi.get('/credentials'); if (!mountedRef.current || requestId !== fileRequestRef.current) return; const nextFiles = dedupeAuthFiles(responseList(payload, 'files')); setFileSnapshot({ files: nextFiles, receivedAtMs: Date.now(), observedAt: readString(payload, 'observed_at') }); @@ -414,7 +414,7 @@ export function AuthFileManagementPage() { setBusy(true); setError(''); try { - await managementApi.delete('/auth-files', { query: { name } }); + await managementApi.delete('/credentials', { query: { name } }); showNotice({ key: 'authFiles.deleted' }); await loadFiles(); } catch (requestError) { diff --git a/src/pages/EasyModePage.tsx b/src/pages/EasyModePage.tsx index 88e5723..d594cdd 100644 --- a/src/pages/EasyModePage.tsx +++ b/src/pages/EasyModePage.tsx @@ -206,7 +206,7 @@ export function EasyModePage({ const refreshSourceStatus = useCallback(async () => { try { - const authFilesPayload = await managementApi.get("/auth-files"); + const authFilesPayload = await managementApi.get("/credentials"); const files = responseList(authFilesPayload, "files"); setAuthFiles(files); diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx index 031c6a2..578d1e5 100644 --- a/src/pages/ManagementPages.tsx +++ b/src/pages/ManagementPages.tsx @@ -262,7 +262,7 @@ export function OAuthLoginPage() { ); const captureCredentialSnapshot = useCallback(async (provider: OAuthProviderId) => { - const payload = await managementApi.get('/auth-files'); + const payload = await managementApi.get('/credentials'); credentialSnapshots.current[provider] = snapshotAuthFiles(responseList(payload, 'files')); }, []); @@ -271,9 +271,9 @@ export function OAuthLoginPage() { delete credentialSnapshots.current[provider]; if (!before) return; - const payload = await managementApi.get('/auth-files'); + const payload = await managementApi.get('/credentials'); const names = changedOAuthAuthFileNames(before, responseList(payload, 'files'), provider); - await Promise.all(names.map((name) => managementApi.patch('/auth-files/fields', { + await Promise.all(names.map((name) => managementApi.patch('/credentials/fields', { name, priority: 0, }))); @@ -417,7 +417,7 @@ export function OAuthLoginPage() { updateProviderState(provider, { refreshing: true }); try { if (currentState) { - await managementApi.delete('/oauth-session', { query: { state: currentState } }); + await managementApi.delete('/oauth/session', { query: { state: currentState } }); } } catch (error) { if (provider === 'devin') { diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index fdab3d8..5968de2 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -63,7 +63,7 @@ export function QuotaPage() { setLoading(true); setError(''); try { - const payload = await managementApi.get('/auth-files'); + const payload = await managementApi.get('/credentials'); const allFiles = dedupeAuthFiles(responseList(payload, 'files')); const nextFiles = allFiles.filter((file) => !readBoolean(file, 'disabled') && providerForFile(file)); setFiles(nextFiles); diff --git a/src/pages/UsageEventsView.css b/src/pages/UsageEventsView.css index db1892e..2dc5928 100644 --- a/src/pages/UsageEventsView.css +++ b/src/pages/UsageEventsView.css @@ -91,6 +91,7 @@ } .usage-request-log .usage-events-table { + min-width: 100%; font-size: 12px; font-variant-numeric: tabular-nums; } @@ -119,8 +120,8 @@ } .usage-request-log .usage-events-table td { - height: 76px; - padding: 12px 8px; + height: 68px; + padding: 9px 8px; color: var(--usage-log-text); line-height: 1.45; } @@ -161,38 +162,13 @@ } .usage-request-log .usage-event-source { - display: flex; - min-width: 0; - align-items: center; - gap: 6px; -} - -.usage-request-log .usage-event-source > span:last-child { + display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.usage-request-log .usage-event-source-icon { - display: inline-flex; - width: 25px; - height: 25px; - flex: 0 0 25px; - align-items: center; - justify-content: center; - border: 3px solid var(--usage-log-surface); - border-radius: 50%; - background: linear-gradient(145deg, #a5a1ff, #6557f4); - color: #fff; - box-shadow: 0 0 0 1px var(--usage-log-line); -} - -.usage-request-log .usage-event-source-icon svg { - width: 13px; - height: 13px; -} - .usage-request-log .usage-event-metrics { display: flex; align-items: center; diff --git a/src/pages/UsageEventsView.tsx b/src/pages/UsageEventsView.tsx index 7eb0b59..798b63d 100644 --- a/src/pages/UsageEventsView.tsx +++ b/src/pages/UsageEventsView.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; -import { ArrowDown, ArrowUp, Brain, ChevronLeft, ChevronRight, Columns3Cog, Database, DatabaseZap, Download, RotateCcw, Terminal, TriangleAlert, X } from 'lucide-react'; +import { ArrowDown, ArrowUp, Brain, ChevronLeft, ChevronRight, Columns3Cog, Database, DatabaseZap, Download, RotateCcw, TriangleAlert, X } from 'lucide-react'; import { getCurrentLocale, useI18n } from '../i18n'; import type { MessageKey } from '../i18n/resources'; import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics'; @@ -62,16 +62,11 @@ type EventColumnKey = | 'provider' | 'source' | 'key' - | 'input' - | 'output' - | 'reasoning' | 'cache' | 'total' | 'result' | 'latency' - | 'ttft' | 'speed' - | 'cacheRate' | 'effort' | 'request'; @@ -96,17 +91,12 @@ const EVENT_COLUMNS: readonly EventColumnDef[] = [ { key: 'total', labelKey: 'usage.column.tokens', defaultWidth: 120, minWidth: 112, align: 'left' }, { key: 'cache', labelKey: 'usage.column.cache', defaultWidth: 104, minWidth: 88, align: 'left' }, { key: 'provider', labelKey: 'usage.column.provider', defaultWidth: 108, minWidth: 88, align: 'left' }, - { key: 'input', labelKey: 'usage.column.input', defaultWidth: 76, minWidth: 60, align: 'left' }, - { key: 'output', labelKey: 'usage.column.output', defaultWidth: 76, minWidth: 60, align: 'left' }, - { key: 'reasoning', labelKey: 'usage.column.reasoning', defaultWidth: 76, minWidth: 60, align: 'left' }, - { key: 'cacheRate', labelKey: 'usage.column.cacheRate', defaultWidth: 80, minWidth: 68, align: 'left' }, - { key: 'ttft', labelKey: 'usage.column.ttft', defaultWidth: 92, minWidth: 76, align: 'left' }, ] as const; const DEFAULT_EVENT_VISIBLE_COLUMNS: readonly EventColumnKey[] = [ 'time', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'provider', ]; -const LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly EventColumnKey[] = [ +const LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly string[] = [ 'time', 'model', 'input', 'output', 'cache', 'cacheRate', 'total', 'speed', 'ttft', 'latency', 'result', 'provider', 'source', ]; @@ -115,7 +105,6 @@ const LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v1' const PREVIOUS_EVENT_COLUMN_WIDTHS: Record = { time: 105, key: 150, source: 205, model: 150, effort: 100, result: 95, request: 145, latency: 125, speed: 110, total: 145, cache: 135, provider: 135, - input: 90, output: 90, reasoning: 90, cacheRate: 95, ttft: 110, }; const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v3'; const LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v2'; @@ -140,8 +129,8 @@ const getInitialVisibleColumns = (): EventColumnKey[] => { }); if (savedKeys.length > 0) { const wasLegacyDefault = currentRaw === null - && savedKeys.length === LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.length - && LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => savedKeys.includes(key)); + && parsed.length === LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.length + && LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => parsed.includes(key)); return wasLegacyDefault ? [...DEFAULT_EVENT_VISIBLE_COLUMNS] : savedKeys; } } @@ -330,10 +319,7 @@ function UsageEventCell({ case 'source': return ( -
- - {record.source_display || record.source || '—'} -
+ {record.source_display || record.source || '—'} ); case 'key': @@ -343,24 +329,6 @@ function UsageEventCell({ {record.api_key_remark || noRemarkLabel} ); - case 'input': - return ( - - {compactNumber(record.tokens.input_tokens)} - - ); - case 'output': - return ( - - {compactNumber(record.tokens.output_tokens)} - - ); - case 'reasoning': - return ( - - {compactNumber(record.tokens.reasoning_tokens)} - - ); case 'cache': return ( ); - case 'cacheRate': { - const value = formatCacheReadRate({ - inputTokens: record.tokens.input_tokens, - cacheReadTokens: record.tokens.cache_read_tokens, - }); - return {value}; - } case 'total': return ( @@ -403,12 +364,6 @@ function UsageEventCell({ {t('usage.column.ttft')} {record.ttft_ms == null ? '—' : compactDuration(record.ttft_ms)} ); - case 'ttft': - return ( - - {record.ttft_ms == null ? '—' : compactDuration(record.ttft_ms)} - - ); case 'speed': { const value = formatGenerationSpeed({ outputTokens: record.tokens.output_tokens, diff --git a/src/services/authFileSettings.ts b/src/services/authFileSettings.ts index 46b4697..dba304d 100644 --- a/src/services/authFileSettings.ts +++ b/src/services/authFileSettings.ts @@ -134,7 +134,7 @@ type SettingsApi = { }; export const loadAuthFileSettings = async (name: string, api: SettingsApi = managementApi) => - authFileSettingsFromPayload(await api.get('/auth-files/download', { name })); + authFileSettingsFromPayload(await api.get('/credentials/download', { name })); export const saveAuthFileSettings = async ( name: string, original: AuthFileSettingsDraft, draft: AuthFileSettingsDraft, @@ -142,6 +142,6 @@ export const saveAuthFileSettings = async ( ) => { const patch = buildAuthFileSettingsPatch(original, draft); if (!Object.keys(patch).length) return false; - await api.patch('/auth-files/fields', { name, ...patch }); + await api.patch('/credentials/fields', { name, ...patch }); return true; }; diff --git a/src/services/authFiles.ts b/src/services/authFiles.ts index 9e6ae47..9e2542c 100644 --- a/src/services/authFiles.ts +++ b/src/services/authFiles.ts @@ -57,7 +57,7 @@ export const setOAuthCredentialFileDisabled = async ( if (!isOAuthCredentialFile(file)) { throw new Error(translate(getCurrentLocale(), 'authFiles.fileOnly')); } - await api.patch('/auth-files/status', { name: readString(file, 'name'), disabled }); + await api.patch('/credentials/status', { name: readString(file, 'name'), disabled }); }; export const parseAuthFilePriority = (value: unknown): number | undefined => { diff --git a/src/services/homeOverview.ts b/src/services/homeOverview.ts index e3f04f4..0517c91 100644 --- a/src/services/homeOverview.ts +++ b/src/services/homeOverview.ts @@ -130,7 +130,7 @@ export async function loadHomeOverview(coreReady: boolean, nowMs = Date.now()): const [usage, credentials, providerKeys, models] = await Promise.allSettled([ // Usage is stored locally and remains meaningful while the core is stopped. invoke('get_usage_overview', { query }).then(summarizeHomeUsage), - coreReady ? managementApi.get('/auth-files').then(summarizeHomeCredentials) : Promise.resolve(null), + coreReady ? managementApi.get('/credentials').then(summarizeHomeCredentials) : Promise.resolve(null), coreReady ? loadProviderKeys() : Promise.resolve(null), coreReady ? invoke('get_core_models').then(normalizeHomeModels) : Promise.resolve(null), ]); diff --git a/src/services/managementApi.ts b/src/services/managementApi.ts index d1f9f3e..7d5a386 100644 --- a/src/services/managementApi.ts +++ b/src/services/managementApi.ts @@ -1,6 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; import { getCurrentLocale, translate } from '../i18n'; -import { normalizeProviderModels } from './providerModels'; export type ManagementJson = Record | unknown[] | string | number | boolean | null; @@ -10,33 +9,6 @@ type ManagementRequestOptions = { timeoutMs?: number; }; -const PROVIDER_PATHS = { - '/gemini-api-key': { provider: 'gemini', legacy: 'gemini-api-key' }, - '/interactions-api-key': { provider: 'interactions', legacy: 'interactions-api-key' }, - '/vertex-api-key': { provider: 'vertex', legacy: 'vertex-api-key' }, - '/codex-api-key': { provider: 'codex', legacy: 'codex-api-key' }, - '/claude-api-key': { provider: 'claude', legacy: 'claude-api-key' }, - '/xai-api-key': { provider: 'xai', legacy: 'xai-api-key' }, - '/meta-api-key': { provider: 'meta', legacy: 'meta-api-key' }, - '/openai-compatibility': { provider: 'openai-compatibility', legacy: 'openai-compatibility' }, -} as const; - -type ProviderPath = keyof typeof PROVIDER_PATHS; - -const SHARED_PROVIDER_FIELDS = new Set([ - 'priority', - 'prefix', - 'proxy-url', - 'headers', - 'models', - 'excluded-models', - 'disable-cooling', - 'request-retry', - 'request-scoped-errors', -]); - -const providerDefinition = (path: string) => PROVIDER_PATHS[path as ProviderPath]; - // Native v8 groups: callers keep keys and overrides intact through every edit. export const providerGroupsApi = { get: async (section: string): Promise[]> => { @@ -49,7 +21,7 @@ export const providerGroupsApi = { }; async function optionalConfigValue( - path: string, fallback: ManagementJson, options: ManagementRequestOptions, + path: string, fallback: ManagementJson, options: ManagementRequestOptions = {}, ): Promise { try { return await invoke('management_request', { @@ -63,75 +35,6 @@ async function optionalConfigValue( } } -export function flattenV8ProviderGroups(provider: string, payload: unknown): Record[] { - if (!Array.isArray(payload)) return []; - return payload.filter(isRecord).flatMap((group) => { - const keys = Array.isArray(group.keys) ? group.keys.filter(isRecord) : []; - if (provider === 'openai-compatibility') { - const record: Record = { ...group }; - delete record.keys; - delete record['test-model']; - delete record.testModel; - if (keys.length > 0) record['api-key-entries'] = keys.map((key) => ({ ...key })); - return [normalizeProviderModels(record)]; - } - const shared = Object.fromEntries( - Object.entries(group).filter(([key]) => key === 'base-url' || SHARED_PROVIDER_FIELDS.has(key)), - ); - const name = readString(group, 'name'); - const generatedName = name.startsWith(`${provider}-`) && /^[0-9]+$/.test(name.slice(provider.length + 1)); - if (name && !generatedName) shared.name = name; - return keys.map((key) => { - const overrides = Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null)); - return normalizeProviderModels({ ...shared, ...overrides }); - }); - }); -} - -export function groupLegacyProviderRecords(provider: string, payload: unknown): Record[] { - if (!Array.isArray(payload)) return []; - return payload.filter(isRecord).map((input, index) => { - const record = normalizeProviderModels(input); - if (provider === 'openai-compatibility') { - const group: Record = { - ...record, - keys: Array.isArray(record['api-key-entries']) - ? record['api-key-entries'].filter(isRecord).map((key) => ({ ...key })) - : [], - }; - delete group['api-key-entries']; - delete group['test-model']; - delete group.testModel; - return group; - } - const group: Record = { name: readString(record, 'name') || `${provider}-${index + 1}` }; - const key: Record = {}; - Object.entries(record).forEach(([field, value]) => { - if (field === 'name') return; - if (field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field)) group[field] = value; - else key[field] = value; - }); - group.keys = [key]; - return group; - }); -} - -export function legacyManagementConfigView(payload: unknown): unknown { - if (!isRecord(payload)) return payload; - const legacy: Record = { ...payload }; - const upstream = isRecord(payload['api-keys']) ? payload['api-keys'] : {}; - Object.values(PROVIDER_PATHS).forEach(({ provider, legacy: legacyKey }) => { - legacy[legacyKey] = flattenV8ProviderGroups(provider, upstream[provider]); - }); - const access = isRecord(payload.access) ? payload.access : null; - if (access && Array.isArray(access['api-keys'])) legacy['api-keys'] = access['api-keys']; - const oauth = isRecord(payload.oauth) ? payload.oauth : null; - if (oauth && oauth['model-alias'] !== undefined) legacy['oauth-model-alias'] = oauth['model-alias']; - const requests = isRecord(payload.requests) ? payload.requests : null; - if (requests && requests.payload !== undefined) legacy.payload = requests.payload; - return legacy; -} - const normalizeQuery = ( query?: Record, ): Record | undefined => { @@ -152,93 +55,15 @@ async function request( path: string, options: ManagementRequestOptions = {}, ): Promise { - if (path === '/oauth-excluded-models') { - if (method === 'PATCH' && isRecord(options.body)) { - const provider = readString(options.body, 'provider'); - if (!provider || !Array.isArray(options.body.models)) { - throw new Error('Invalid OAuth model exclusion update'); - } - return invoke('management_request', { - request: { - method: 'PUT', - path: `/config/oauth/excluded-models/${encodeURIComponent(provider)}`, - body: options.body.models, - }, - }); - } - if (method === 'DELETE') { - const provider = options.query?.provider; - if (typeof provider !== 'string' || !provider.trim()) { - throw new Error('Invalid OAuth model exclusion delete'); - } - return invoke('management_request', { - request: { - method: 'DELETE', - path: `/config/oauth/excluded-models/${encodeURIComponent(provider.trim())}`, - }, - }); - } - if (method === 'GET') { - const exclusions = await optionalConfigValue('/config/oauth/excluded-models', {}, options); - return { 'oauth-excluded-models': exclusions } as T; - } - } - const definition = providerDefinition(path); - if (method === 'GET' && definition) { - const groups = await optionalConfigValue(`/config/api-keys/${definition.provider}`, [], options); - return { [definition.legacy]: flattenV8ProviderGroups(definition.provider, groups) } as T; - } - if (method === 'PATCH' && path === '/openai-compatibility' && isRecord(options.body)) { - const index = Number(options.body.index); - const value = options.body.value; - if (!Number.isInteger(index) || index < 0 || !isRecord(value)) { - throw new Error('Invalid OpenAI compatibility update'); - } - const groups = await invoke('management_request', { - request: { method: 'GET', path: '/config/api-keys/openai-compatibility' }, - }); - const records = flattenV8ProviderGroups('openai-compatibility', groups); - if (!records[index]) throw new Error('OpenAI compatibility entry no longer exists'); - records[index] = { ...records[index], ...value }; - return invoke('management_request', { - request: { - method: 'PUT', - path: '/config/api-keys/openai-compatibility', - body: groupLegacyProviderRecords('openai-compatibility', records), - }, - }); - } - - let apiPath = path; - let body = options.body; - if (definition) { - apiPath = `/config/api-keys/${definition.provider}`; - if (method === 'PUT' || method === 'PATCH') { - body = groupLegacyProviderRecords(definition.provider, body); - } - } else if (path === '/api-call') { - apiPath = '/requests/api-call'; - } else if (path === '/oauth-session') { - apiPath = '/oauth/session'; - } else if (path.startsWith('/auth-files')) { - apiPath = `/credentials${path.slice('/auth-files'.length)}`; - } else if (path.startsWith('/model-definitions/')) { - apiPath = `/routing${path}`; - } - - const payload = await invoke('management_request', { + return invoke('management_request', { request: { method, - path: apiPath, + path, query: normalizeQuery(options.query), - body, + body: options.body, timeoutMs: options.timeoutMs, }, }); - if (method === 'GET' && path === '/config') { - return legacyManagementConfigView(payload) as T; - } - return payload as T; } export const managementApi = { diff --git a/src/services/modelService.ts b/src/services/modelService.ts index 088203e..8336549 100644 --- a/src/services/modelService.ts +++ b/src/services/modelService.ts @@ -220,7 +220,7 @@ export async function fetchModels( for (let page = 0; page < (['gemini', 'interactions', 'vertex'].includes(provider) ? 20 : 1); page += 1) { const pageUrl = new URL(url); if (pageToken) pageUrl.searchParams.set('pageToken', pageToken); - const response = await managementApi.post>('/api-call', { + const response = await managementApi.post>('/requests/api-call', { authIndex: authIndex?.trim() || undefined, method: 'GET', url: pageUrl.toString(), @@ -257,7 +257,7 @@ export async function fetchModels( if (collected.length) return collected; if (provider === 'openai' && Object.keys(headers).length > 0) { - const response = await managementApi.post>('/api-call', { + const response = await managementApi.post>('/requests/api-call', { method: 'GET', url, }, { timeoutMs }); @@ -271,7 +271,7 @@ export async function fetchModels( lastError = String(error); if (provider === 'openai' && Object.keys(headers).length > 0) { try { - const response = await managementApi.post>('/api-call', { + const response = await managementApi.post>('/requests/api-call', { method: 'GET', url, }, { timeoutMs }); diff --git a/src/services/oauthModelSettings.ts b/src/services/oauthModelSettings.ts index 04935ce..5d3df9d 100644 --- a/src/services/oauthModelSettings.ts +++ b/src/services/oauthModelSettings.ts @@ -23,9 +23,44 @@ export type OAuthModelSettings = { type OAuthModelSettingsApi = { get: (path: string, query?: Record) => Promise; patch: (path: string, body: Record) => Promise; - delete: (path: string, options?: { query?: Record }) => Promise; + put: (path: string, body: Record) => Promise; }; +const exclusionsPath = '/config/oauth/excluded-models'; +const exclusionWrites = new WeakMap>(); + +async function readExclusions(api: OAuthModelSettingsApi): Promise> { + try { + const value = await api.get(exclusionsPath); + if (!isRecord(value)) throw new Error('Invalid OAuth exclusion configuration'); + return value; + } catch (error) { + if ((error instanceof Error ? error.message : error) === 'Management API error (404): not_found') return {}; + throw error; + } +} + +// v8 accepts the provider map directly. Serialize read/modify/write operations +// so simultaneous edits to different providers preserve each other's changes. +export function saveOAuthProviderExclusions( + provider: string, models: string[] | undefined, api: OAuthModelSettingsApi = managementApi, +): Promise { + const key = provider.trim().toLowerCase(); + if (!key) return Promise.reject(new Error('Invalid OAuth provider')); + const pending = (exclusionWrites.get(api) ?? Promise.resolve()).then(async () => { + const current = await readExclusions(api); + const next = Object.fromEntries(Object.entries(current).filter(([name]) => name.trim().toLowerCase() !== key)); + if (models !== undefined) Object.defineProperty(next, key, { value: models, enumerable: true }); + await api.put(exclusionsPath, next); + }); + const settled = pending.catch(() => {}); + exclusionWrites.set(api, settled); + void settled.then(() => { + if (exclusionWrites.get(api) === settled) exclusionWrites.delete(api); + }); + return pending; +} + export const authFileExcludedRulesFromPayload = (payload: unknown): string[] => { let metadata = payload; if (typeof metadata === 'string') { @@ -54,13 +89,13 @@ export const loadOAuthModelSettings = async ( ): Promise => { const [catalog, payload] = await Promise.all([ (target.scope === 'credential' - ? api.get('/auth-files/models', { name: target.name }) - : api.get(`/model-definitions/${encodeURIComponent(target.provider)}`)) + ? api.get('/credentials/models', { name: target.name }) + : api.get(`/routing/model-definitions/${encodeURIComponent(target.provider)}`)) .then((definitions) => ({ models: oauthModelsFromPayload(definitions), error: '' })) .catch((error: unknown) => ({ models: [] as OAuthModelDefinition[], error: String(error) })), target.scope === 'credential' - ? api.get('/auth-files/download', { name: target.name }) - : api.get('/oauth-excluded-models'), + ? api.get('/credentials/download', { name: target.name }) + : readExclusions(api), ]); const excludedRules = target.scope === 'credential' ? authFileExcludedRulesFromPayload(payload) @@ -82,16 +117,13 @@ export const saveOAuthModelSettings = async ( if (excludedModels.length === settings.excludedRules.length && excludedModels.every((rule) => settings.excludedRules.includes(rule))) return; if (settings.target.scope === 'credential') { - await api.patch('/auth-files/fields', { + await api.patch('/credentials/fields', { name: settings.target.name, excluded_models: excludedModels, }); } else if (excludedModels.length > 0) { - await api.patch('/oauth-excluded-models', { - provider: settings.target.provider, - models: excludedModels, - }); + await saveOAuthProviderExclusions(settings.target.provider, excludedModels, api); } else if (settings.excludedRules.length > 0) { - await api.delete('/oauth-excluded-models', { query: { provider: settings.target.provider } }); + await saveOAuthProviderExclusions(settings.target.provider, undefined, api); } }; diff --git a/src/services/oauthModels.ts b/src/services/oauthModels.ts index 7868125..90a814e 100644 --- a/src/services/oauthModels.ts +++ b/src/services/oauthModels.ts @@ -21,15 +21,10 @@ export const oauthModelsFromPayload = (payload: unknown): OAuthModelDefinition[] export const oauthExcludedRulesFromPayload = (payload: unknown, provider: string): string[] => { if (!isRecord(payload)) return []; - const source = isRecord(payload['oauth-excluded-models']) - ? payload['oauth-excluded-models'] - : payload; - const value = source[provider.trim().toLowerCase()]; - if (!Array.isArray(value)) return []; - return value - .map(String) - .map((rule) => rule.trim().toLowerCase()) - .filter((rule, index, rules) => rule && rules.indexOf(rule) === index); + const key = provider.trim().toLowerCase(); + return normalizeOAuthExcludedRules(Object.entries(payload).flatMap(([name, value]) => + name.trim().toLowerCase() === key && Array.isArray(value) ? value.map(String) : [], + )); }; const wildcardPattern = (rule: string) => new RegExp( diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index c9b1745..85bfc51 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -579,7 +579,7 @@ const resolveProjectId = async (file: AuthFile): Promise => { const direct = antigravityProjectFor(file); if (direct) return direct; try { - const payload = parseBody(await managementApi.get('/auth-files/download', { name: fileName(file) })); + const payload = parseBody(await managementApi.get('/credentials/download', { name: fileName(file) })); return isRecord(payload) ? antigravityProjectFor(payload) : ''; } catch { return ''; @@ -615,7 +615,7 @@ const requestQuotaPayload = async ( timeoutMs?: number, responseClock?: { serverTimeOffsetMs?: number }, ) => { - const response = await managementApi.post>('/api-call', { + const response = await managementApi.post>('/requests/api-call', { authIndex, method, url, diff --git a/src/styles.css b/src/styles.css index 9d86ca7..ff09b1c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -9891,9 +9891,7 @@ body.table-col-resizing * { .usage-events-table td.usage-td-token, .usage-events-table td.usage-td-latency, -.usage-events-table td.usage-td-ttft, -.usage-events-table td.usage-td-speed, -.usage-events-table td.usage-td-cache-rate { +.usage-events-table td.usage-td-speed { font-variant-numeric: tabular-nums; } diff --git a/tests/authFileSettings.test.ts b/tests/authFileSettings.test.ts index fc18f83..497d22b 100644 --- a/tests/authFileSettings.test.ts +++ b/tests/authFileSettings.test.ts @@ -15,7 +15,7 @@ describe('credential settings', () => { const writes: unknown[] = []; const api = { async get(path: string, query: Record) { - expect([path, query]).toEqual(['/auth-files/download', { name: 'test.json' }]); + expect([path, query]).toEqual(['/credentials/download', { name: 'test.json' }]); return { ...file }; }, async patch(path: string, body: Record) { @@ -28,7 +28,7 @@ describe('credential settings', () => { file.access_token = 'refreshed'; file.priority = 9; expect(await saveAuthFileSettings('test.json', original, { ...original, note: 'new' }, api)).toBe(true); - expect(writes).toEqual([{ path: '/auth-files/fields', body: { name: 'test.json', note: 'new' } }]); + expect(writes).toEqual([{ path: '/credentials/fields', body: { name: 'test.json', note: 'new' } }]); expect(file).toEqual({ access_token: 'refreshed', priority: 9, note: 'new', auth_index: 'private-index' }); expect(await saveAuthFileSettings('test.json', original, original, api)).toBe(false); expect(writes).toHaveLength(1); diff --git a/tests/authFiles.test.ts b/tests/authFiles.test.ts index d5867ca..0e40b72 100644 --- a/tests/authFiles.test.ts +++ b/tests/authFiles.test.ts @@ -143,8 +143,8 @@ describe('OAuth credential file boundaries', () => { await setOAuthCredentialFileDisabled(oauthFile, true, api); await setOAuthCredentialFileDisabled({ ...oauthFile, disabled: true }, false, api); expect(writes).toEqual([ - { path: '/auth-files/status', body: { name: 'codex-user.json', disabled: true } }, - { path: '/auth-files/status', body: { name: 'codex-user.json', disabled: false } }, + { path: '/credentials/status', body: { name: 'codex-user.json', disabled: true } }, + { path: '/credentials/status', body: { name: 'codex-user.json', disabled: false } }, ]); }); diff --git a/tests/browserMockRuntime.test.ts b/tests/browserMockRuntime.test.ts index fe65c60..3ddc918 100644 --- a/tests/browserMockRuntime.test.ts +++ b/tests/browserMockRuntime.test.ts @@ -71,15 +71,13 @@ describe('browser mock runtime', () => { await runtime.invoke('management_request', { request: { method: 'PUT', - path: '/gemini-api-key', - body: [{ 'api-key': 'AIza-test', models: [{ name: 'gemini-test' }] }], + path: '/config/api-keys/gemini', + body: [{ name: 'test', keys: [{ 'api-key': 'AIza-test' }], models: [{ name: 'gemini-test' }] }], }, }); expect(await runtime.invoke('management_request', { - request: { method: 'GET', path: '/gemini-api-key' }, - })).toEqual({ - 'gemini-api-key': [{ 'api-key': 'AIza-test', models: [{ name: 'gemini-test' }] }], - }); + request: { method: 'GET', path: '/config/api-keys/gemini' }, + })).toEqual([{ name: 'test', keys: [{ 'api-key': 'AIza-test' }], models: [{ name: 'gemini-test' }] }]); }); test('uses normalized ratios for percentage-based usage metrics', async () => { @@ -143,7 +141,7 @@ describe('browser mock native provider groups', () => { }); } - test('derives effective legacy key records without changing native inheritance', async () => { + test('returns native groups without flattening inherited and explicit empty values', async () => { const runtime = createBrowserMockRuntime('running'); const groups = [{ name: 'preserve this group name', @@ -157,16 +155,10 @@ describe('browser mock native provider groups', () => { ], }]; await request(runtime, 'PUT', '/config/api-keys/interactions', groups); - expect(await request(runtime, 'GET', '/interactions-api-key')).toEqual({ - 'interactions-api-key': [ - { 'api-key': 'first', 'base-url': 'https://provider.example', priority: 0, headers: { 'X-Team': 'shared' }, models: [] }, - { 'api-key': 'second', 'base-url': 'https://provider.example', priority: 5, headers: {}, models: [{ name: 'shared-model' }] }, - ], - }); expect(await request(runtime, 'GET', '/config/api-keys/interactions')).toEqual(groups); }); - test('adapts legacy OpenAI mutations while preserving native group metadata and keys', async () => { + test('updates native OpenAI groups while preserving metadata and keys', async () => { const runtime = createBrowserMockRuntime('running'); const group = { name: 'custom group', 'base-url': 'https://provider.example/v1', disabled: false, @@ -174,14 +166,9 @@ describe('browser mock native provider groups', () => { keys: [{ 'api-key': 'first', weight: 0, 'proxy-url': '' }, { 'api-key': 'second', weight: 3 }], }; await request(runtime, 'PUT', '/config/api-keys/openai-compatibility', [group]); - await request(runtime, 'PATCH', '/openai-compatibility', { index: 0, value: { disabled: true } }); + await request(runtime, 'PUT', '/config/api-keys/openai-compatibility', [{ ...group, disabled: true }]); expect(await request(runtime, 'GET', '/config/api-keys/openai-compatibility')).toEqual([{ ...group, disabled: true }]); - const { keys, ...shared } = group; - expect(await request(runtime, 'GET', '/openai-compatibility')).toEqual({ - 'openai-compatibility': [{ ...shared, disabled: true, 'api-key-entries': keys }], - }); - - await request(runtime, 'PUT', '/openai-compatibility', [{ ...shared, 'api-key-entries': [keys[1]] }]); - expect(await request(runtime, 'GET', '/config/api-keys/openai-compatibility')).toEqual([{ ...shared, keys: [keys[1]] }]); + await request(runtime, 'PUT', '/config/api-keys/openai-compatibility', [{ ...group, keys: [group.keys[1]] }]); + expect(await request(runtime, 'GET', '/config/api-keys/openai-compatibility')).toEqual([{ ...group, keys: [group.keys[1]] }]); }); }); diff --git a/tests/coreOperations.v8.integration.test.ts b/tests/coreOperations.v8.integration.test.ts index 523006e..545eb8e 100644 --- a/tests/coreOperations.v8.integration.test.ts +++ b/tests/coreOperations.v8.integration.test.ts @@ -8,7 +8,7 @@ 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 { loadOAuthModelSettings, saveOAuthModelSettings, saveOAuthProviderExclusions } from '../src/services/oauthModelSettings'; import { fetchModels } from '../src/services/modelService'; const executable = process.env.CPA_V8_TEST_CORE; @@ -90,7 +90,7 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}} })], name, { type: 'application/json' })); let file: Record | undefined; for (let attempt = 0; attempt < 50; attempt++) { - file = responseList(await managementApi.get('/auth-files'), 'files').find((item) => item.name === name); + file = responseList(await managementApi.get('/credentials'), 'files').find((item) => item.name === name); if (file) break; await Bun.sleep(100); } @@ -118,15 +118,15 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}} 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>('/auth-files/download', { name }); + const metadata = await managementApi.get>('/credentials/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); + expect(responseList(await managementApi.get('/credentials'), '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); + expect(responseList(await managementApi.get('/credentials'), 'files').find((item) => item.name === name)?.disabled).toBe(false); + await managementApi.delete('/credentials', { query: { name } }); + expect(responseList(await managementApi.get('/credentials'), 'files').some((item) => item.name === name)).toBe(false); }); it('edits provider model exclusions without changing siblings and clears the last rule', async () => { @@ -136,14 +136,14 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}} 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 saveOAuthProviderExclusions('claude', ['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' } }); + expect(await managementApi.get('/config/oauth/excluded-models')).toEqual({ claude: ['keep-*'] }); + await saveOAuthProviderExclusions('claude', undefined); }); it('round-trips template credential aliases and Claude settings without replacing token metadata', async () => { @@ -158,13 +158,13 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}} await saveAuthFileSettings(fileName, original, { ...original, advanced }); const loaded = await loadAuthFileSettings(fileName); expect(loaded.advanced).toEqual(advanced); - const metadata = await managementApi.get>('/auth-files/download', { name: fileName }); + const metadata = await managementApi.get>('/credentials/download', { name: fileName }); expect(metadata.access_token).toBe('isolated-template-token'); expect(metadata.custom_metadata).toEqual({ keep: true }); await saveAuthFileSettings(fileName, loaded, { ...loaded, advanced: {} }); const cleared = await loadAuthFileSettings(fileName); expect(Object.values(cleared.advanced).every(value => value === null)).toBe(true); - } finally { await managementApi.delete('/auth-files', { query: { name: fileName } }); } + } finally { await managementApi.delete('/credentials', { query: { name: fileName } }); } }); it('discovers models and forwards API-call requests to an isolated local upstream', async () => { @@ -175,7 +175,7 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}} 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>('/api-call', { + const response = await managementApi.post>('/requests/api-call', { method: 'GET', url: `http://127.0.0.1:${upstream.port}/usage`, header: { Authorization: 'Bearer local-key' }, }); expect(response.status_code).toBe(200); @@ -189,7 +189,7 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}} 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' } }); + await managementApi.delete('/oauth/session', { query: { state: 'isolated-unknown-state' } }); }); it('accepts new template controls and retains explicit false, zero and empty collections', async () => { diff --git a/tests/devin.test.ts b/tests/devin.test.ts index d029973..7f81b59 100644 --- a/tests/devin.test.ts +++ b/tests/devin.test.ts @@ -67,7 +67,7 @@ describe('Devin live quota', () => { expect(get).not.toHaveBeenCalled(); expect(post).toHaveBeenCalledTimes(1); const [path, body] = post.mock.calls[0]; - expect(path).toBe('/api-call'); + expect(path).toBe('/requests/api-call'); expect(body).toMatchObject({ authIndex: 'devin-index', method: 'POST', url: 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus', header: { 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' } }); expect(JSON.parse((body as { data: string }).data)).toEqual({ metadata: { ideName: 'chisel', ideVersion: '3000.10.21', apiKey: '$TOKEN$', locale: 'en', os: 'darwin', extensionVersion: '3000.10.21', clientName: 'chisel' } }); await loadQuota(file); @@ -95,15 +95,15 @@ it('loads and saves Devin model exclusions under its own provider channel', asyn const api = { get: async (path: string) => { calls.push(path); - return path === '/model-definitions/devin' + return path === '/routing/model-definitions/devin' ? { models: [{ id: 'devin/swe-2', display_name: 'SWE-2' }] } - : { 'oauth-excluded-models': { devin: ['devin/old'] } }; + : { devin: ['devin/old'] }; }, patch: async (path: string, body: Record) => { calls.push([path, body]); }, - delete: async () => {}, + put: async (path: string, body: Record) => { calls.push([path, body]); }, }; const settings = await loadOAuthModelSettings({ scope: 'provider', provider: 'devin', label: 'Devin' }, api); expect(settings.models.map((model) => model.id)).toEqual(['devin/old', 'devin/swe-2']); await saveOAuthModelSettings(settings, ['devin/swe-2'], api); - expect(calls).toContainEqual(['/oauth-excluded-models', { provider: 'devin', models: ['devin/swe-2'] }]); + expect(calls).toContainEqual(['/config/oauth/excluded-models', { devin: ['devin/swe-2'] }]); }); diff --git a/tests/fixtures/legacyProviderRecords.ts b/tests/fixtures/legacyProviderRecords.ts new file mode 100644 index 0000000..083b3e3 --- /dev/null +++ b/tests/fixtures/legacyProviderRecords.ts @@ -0,0 +1,69 @@ +// Legacy fixtures used to verify migration into native v8 provider groups. +import { normalizeProviderModels } from '../../src/services/providerModels'; +import { isRecord, readString } from '../../src/services/managementApi'; + +const SHARED_PROVIDER_FIELDS = new Set([ + 'priority', + 'prefix', + 'proxy-url', + 'headers', + 'models', + 'excluded-models', + 'disable-cooling', + 'request-retry', + 'request-scoped-errors', +]); + +export function flattenV8ProviderGroups(provider: string, payload: unknown): Record[] { + if (!Array.isArray(payload)) return []; + return payload.filter(isRecord).flatMap((group) => { + const keys = Array.isArray(group.keys) ? group.keys.filter(isRecord) : []; + if (provider === 'openai-compatibility') { + const record: Record = { ...group }; + delete record.keys; + delete record['test-model']; + delete record.testModel; + if (keys.length > 0) record['api-key-entries'] = keys.map((key) => ({ ...key })); + return [normalizeProviderModels(record)]; + } + const shared = Object.fromEntries( + Object.entries(group).filter(([key]) => key === 'base-url' || SHARED_PROVIDER_FIELDS.has(key)), + ); + const name = readString(group, 'name'); + const generatedName = name.startsWith(`${provider}-`) && /^[0-9]+$/.test(name.slice(provider.length + 1)); + if (name && !generatedName) shared.name = name; + return keys.map((key) => { + const overrides = Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null)); + return normalizeProviderModels({ ...shared, ...overrides }); + }); + }); +} + +export function groupLegacyProviderRecords(provider: string, payload: unknown): Record[] { + if (!Array.isArray(payload)) return []; + return payload.filter(isRecord).map((input, index) => { + const record = normalizeProviderModels(input); + if (provider === 'openai-compatibility') { + const group: Record = { + ...record, + keys: Array.isArray(record['api-key-entries']) + ? record['api-key-entries'].filter(isRecord).map((key) => ({ ...key })) + : [], + }; + delete group['api-key-entries']; + delete group['test-model']; + delete group.testModel; + return group; + } + const group: Record = { name: readString(record, 'name') || `${provider}-${index + 1}` }; + const key: Record = {}; + Object.entries(record).forEach(([field, value]) => { + if (field === 'name') return; + if (field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field)) group[field] = value; + else key[field] = value; + }); + group.keys = [key]; + return group; + }); +} + diff --git a/tests/managementApi.test.ts b/tests/managementApi.test.ts index db015d4..06785a8 100644 --- a/tests/managementApi.test.ts +++ b/tests/managementApi.test.ts @@ -1,10 +1,9 @@ +import { flattenV8ProviderGroups, groupLegacyProviderRecords } from './fixtures/legacyProviderRecords'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { clearMocks, mockIPC } from '@tauri-apps/api/mocks'; import { apiCallErrorMessage, - flattenV8ProviderGroups, - groupLegacyProviderRecords, - legacyManagementConfigView, + providerGroupsApi, managementApi, } from '../src/services/managementApi'; import { @@ -27,21 +26,50 @@ describe('v8 Management API requests', () => { else Reflect.deleteProperty(globalThis, 'window'); }); + it('returns the native config tree without flattening groups or replacing access keys', async () => { + const config = { + 'config-version': 8, + access: { 'api-keys': ['client-test'] }, + 'api-keys': { claude: [{ name: 'team', models: [{ name: 'upstream', alias: 'public' }], + keys: [{ 'api-key': 'one', models: null }, { 'api-key': 'two', models: [] }] }] }, + oauth: { 'model-alias': { claude: [{ name: 'original', alias: 'desktop' }] } }, + requests: { payload: { override: [] } }, + }; + mockIPC((_command, args) => { + expect(args?.request).toMatchObject({ method: 'GET', path: '/config' }); + return config; + }); + expect(await managementApi.get('/config')).toEqual(config); + }); + + it('preserves native request bodies, query defaults and timeouts', async () => { + const calls: unknown[] = []; + mockIPC((_command, args) => { calls.push(args?.request); return { status: 'ok' }; }); + await managementApi.get('/credentials', { all: false, offset: 0, omitted: undefined }); + await managementApi.put('/config/access/api-keys', []); + await managementApi.put('/config/routing/session-affinity', false); + await managementApi.post('/requests/api-call', { method: 'GET', url: 'https://example.invalid', auth_index: '0' }, { timeoutMs: 12345 }); + await managementApi.delete('/credentials', { body: { names: ['test.json'] } }); + expect(calls).toMatchObject([ + { method: 'GET', path: '/credentials', query: { all: 'false', offset: '0' } }, + { method: 'PUT', path: '/config/access/api-keys', body: [] }, + { method: 'PUT', path: '/config/routing/session-affinity', body: false }, + { method: 'POST', path: '/requests/api-call', body: { method: 'GET', url: 'https://example.invalid', auth_index: '0' }, timeoutMs: 12345 }, + { method: 'DELETE', path: '/credentials', body: { names: ['test.json'] } }, + ]); + }); + it('defaults absent exclusions and provider groups without swallowing other errors', async () => { - for (const [path, key, empty] of [ - ['/oauth-excluded-models', 'oauth-excluded-models', {}], - ['/codex-api-key', 'codex-api-key', []], - ['/openai-compatibility', 'openai-compatibility', []], - ] as const) { + for (const section of ['codex', 'openai-compatibility']) { mockIPC(() => { throw 'Management API error (404): not_found'; }); - expect(await managementApi.get(path)).toEqual({ [key]: empty }); + expect(await providerGroupsApi.get(section)).toEqual([]); for (const message of [ 'Management API error (401): unauthorized', 'Management API error (404): 404 page not found', 'Management API request failed: connection refused', ]) { mockIPC(() => { throw new Error(message); }); - await expect(managementApi.get(path)).rejects.toThrow(message); + await expect(providerGroupsApi.get(section)).rejects.toThrow(message); } } }); @@ -74,17 +102,17 @@ describe('v8 Management API requests', () => { 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[] }>('/codex-api-key'); + await providerGroupsApi.put('codex', groupLegacyProviderRecords('codex', [regular, record])); + const loaded = { 'codex-api-key': flattenV8ProviderGroups('codex', await providerGroupsApi.get('codex')) }; 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 providerGroupsApi.put('codex', groupLegacyProviderRecords('codex', [record, regular])); + expect(flattenV8ProviderGroups('codex', await providerGroupsApi.get('codex'))).toEqual([record, regular]); } - await managementApi.put('/codex-api-key', [regular]); - expect(await managementApi.get('/codex-api-key')).toEqual({ 'codex-api-key': [regular] }); + await providerGroupsApi.put('codex', groupLegacyProviderRecords('codex', [regular])); + expect(flattenV8ProviderGroups('codex', await providerGroupsApi.get('codex'))).toEqual([regular]); } }); }); @@ -163,22 +191,4 @@ describe('v8 Management API compatibility view', () => { 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'] }, - 'api-keys': { - codex: [{ name: 'codex-1', keys: [{ 'api-key': 'upstream-key' }] }], - 'openai-compatibility': [{ name: 'openrouter', keys: [{ 'api-key': 'openrouter-key' }] }], - }, - oauth: { 'model-alias': { codex: [{ name: 'gpt', alias: 'custom' }] } }, - requests: { payload: { override: [] } }, - }) as Record; - expect(view['api-keys']).toEqual(['client-key']); - expect(view['codex-api-key']).toEqual([{ 'api-key': 'upstream-key' }]); - expect(view['openai-compatibility']).toEqual([{ - name: 'openrouter', 'api-key-entries': [{ 'api-key': 'openrouter-key' }], - }]); - expect(view['oauth-model-alias']).toEqual({ codex: [{ name: 'gpt', alias: 'custom' }] }); - expect(view.payload).toEqual({ override: [] }); - }); }); diff --git a/tests/managementApi.v8.integration.test.ts b/tests/managementApi.v8.integration.test.ts index 7a7afd0..2ca383e 100644 --- a/tests/managementApi.v8.integration.test.ts +++ b/tests/managementApi.v8.integration.test.ts @@ -8,7 +8,7 @@ import { applyProviderPreset, buildProviderRecord, buildProviderGroupRecord, createProviderDraft, providerDraftFromRecord, type ProviderSection, } from '../src/pages/ApiAccessPage'; -import { flattenV8ProviderGroups, groupLegacyProviderRecords } from '../src/services/managementApi'; +import { flattenV8ProviderGroups, groupLegacyProviderRecords } from './fixtures/legacyProviderRecords'; const executable = process.env.CPA_V8_TEST_CORE; describe.skipIf(!executable)('real v8 provider configuration', () => { diff --git a/tests/oauthModelSettings.test.ts b/tests/oauthModelSettings.test.ts index d576fbd..18d5b3f 100644 --- a/tests/oauthModelSettings.test.ts +++ b/tests/oauthModelSettings.test.ts @@ -3,6 +3,7 @@ import { authFileExcludedRulesFromPayload, loadOAuthModelSettings, saveOAuthModelSettings, + saveOAuthProviderExclusions, type OAuthModelTarget, } from '../src/services/oauthModelSettings'; import { openOAuthModelNames, setOAuthModelsExcluded } from '../src/services/oauthModels'; @@ -26,11 +27,11 @@ function createApi() { const api = { async get(path: string, query?: Record): Promise { reads.push({ path, query }); - if (path.startsWith('/model-definitions/')) return { models }; - if (path === '/oauth-excluded-models') return { 'oauth-excluded-models': structuredClone(globalRules) }; + if (path.startsWith('/routing/model-definitions/')) return { models }; + if (path === '/config/oauth/excluded-models') return structuredClone(globalRules); const file = query?.name ? files[query.name] : undefined; - if (path === '/auth-files/download' && file) return structuredClone(file); - if (path === '/auth-files/models' && file) { + if (path === '/credentials/download' && file) return structuredClone(file); + if (path === '/credentials/models' && file) { const allowed = openOAuthModelNames(models, [...authFileExcludedRulesFromPayload(file), ...globalRules.codex ?? []]); return { models: models.filter((model) => allowed.has(model.id)) }; } @@ -38,18 +39,17 @@ function createApi() { }, async patch(path: string, body: Record): Promise { writes.push({ path, body }); - if (path === '/auth-files/fields') { + if (path === '/credentials/fields') { const { name, ...fields } = body; Object.assign(files[String(name)], fields); - } else if (path === '/oauth-excluded-models') { - globalRules[String(body.provider)] = body.models as string[]; } else throw new Error('unexpected write'); return { status: 'ok' }; }, - async delete(path: string, options?: { query?: Record }): Promise { - if (path !== '/oauth-excluded-models' || !options?.query?.provider) throw new Error('unexpected delete'); - deletes.push({ path, query: options.query }); - delete globalRules[options.query.provider]; + async put(path: string, body: Record): Promise { + if (path !== '/config/oauth/excluded-models') throw new Error('unexpected put'); + writes.push({ path, body }); + for (const key of Object.keys(globalRules)) delete globalRules[key]; + Object.assign(globalRules, structuredClone(body)); return { status: 'ok' }; }, }; @@ -57,6 +57,36 @@ function createApi() { } describe('OAuth model settings scopes', () => { + it('serializes simultaneous provider edits and preserves unrelated values verbatim', async () => { + const { api, globalRules } = createApi(); + globalRules[' CODEX '] = ['old']; + globalRules['Future-Plugin'] = ['CaseSensitive-*']; + await Promise.all([ + saveOAuthProviderExclusions(' Codex ', ['next'], api), + saveOAuthProviderExclusions('meta', [], api), + saveOAuthProviderExclusions('claude', undefined, api), + ]); + expect(globalRules).toEqual({ codex: ['next'], meta: [], 'Future-Plugin': ['CaseSensitive-*'] }); + }); + + it('only defaults missing config nodes and releases the write queue after errors', async () => { + const { api, writes } = createApi(); + const get = api.get; + for (const error of ['Management API error (401): unauthorized', 'Management API error (404): 404 page not found']) { + api.get = async () => { throw new Error(error); }; + await expect(saveOAuthProviderExclusions('codex', ['next'], api)).rejects.toThrow(error); + } + api.get = async () => []; + await expect(saveOAuthProviderExclusions('codex', ['next'], api)).rejects.toThrow('Invalid OAuth exclusion configuration'); + expect(writes).toEqual([]); + api.get = async () => { throw 'Management API error (404): not_found'; }; + await saveOAuthProviderExclusions('codex', ['next'], api); + expect(writes).toEqual([{ path: '/config/oauth/excluded-models', body: { codex: ['next'] } }]); + api.get = get; + await saveOAuthProviderExclusions('claude', ['next'], api); + expect(writes.at(-1)?.body).toEqual({ codex: ['next'], claude: ['next'] }); + }); + it('saves A exclusions without changing B, global rules, or refreshed account fields', async () => { const { api, files, globalRules, writes, reads } = createApi(); const settings = await loadOAuthModelSettings(account('a.json'), api); @@ -66,9 +96,9 @@ describe('OAuth model settings scopes', () => { expect(files['a.json']).toMatchObject({ excluded_models: [modelId], access_token: 'refreshed-during-edit', priority: 9 }); expect((await loadOAuthModelSettings(account('b.json'), api)).excludedRules).toEqual([]); expect(globalRules).toEqual({ codex: [], claude: ['claude-old-*'] }); - expect(writes).toEqual([{ path: '/auth-files/fields', body: { name: 'a.json', excluded_models: [modelId] } }]); - expect(reads).toContainEqual({ path: '/auth-files/models', query: { name: 'a.json' } }); - expect(reads.some(({ path }) => path === '/oauth-excluded-models' || path.startsWith('/model-definitions/'))).toBe(false); + expect(writes).toEqual([{ path: '/credentials/fields', body: { name: 'a.json', excluded_models: [modelId] } }]); + expect(reads).toContainEqual({ path: '/credentials/models', query: { name: 'a.json' } }); + expect(reads.some(({ path }) => path === '/config/oauth/excluded-models' || path.startsWith('/routing/model-definitions/'))).toBe(false); }); it('loads exact account exclusions as checked candidates without copying inherited bans', async () => { @@ -82,7 +112,7 @@ describe('OAuth model settings scopes', () => { expect(files['a.json'].excluded_models).toEqual(['gpt-5.4', modelId]); expect(globalRules.codex).toEqual(['gpt-image-*']); globalRules.codex = []; - expect((await api.get('/auth-files/models', { name: 'a.json' }) as { models: typeof models }).models) + expect((await api.get('/credentials/models', { name: 'a.json' }) as { models: typeof models }).models) .toEqual([{ id: 'gpt-image-2' }]); }); @@ -93,7 +123,7 @@ describe('OAuth model settings scopes', () => { await saveOAuthModelSettings(settings, ['gpt-image-*'], api); globalRules.codex = []; expect(files['a.json'].excluded_models).toEqual(['gpt-image-*']); - expect((await api.get('/auth-files/models', { name: 'a.json' }) as { models: typeof models }).models) + expect((await api.get('/credentials/models', { name: 'a.json' }) as { models: typeof models }).models) .not.toContainEqual({ id: 'gpt-image-2' }); }); @@ -112,7 +142,7 @@ describe('OAuth model settings scopes', () => { const { api, files } = createApi(); const originalGet = api.get; api.get = async (path, query) => { - if (path === '/auth-files/models') throw new Error('catalog unavailable'); + if (path === '/credentials/models') throw new Error('catalog unavailable'); return originalGet(path, query); }; const settings = await loadOAuthModelSettings(account('a.json'), api); @@ -125,14 +155,15 @@ describe('OAuth model settings scopes', () => { const { api, files, globalRules, writes, deletes, reads } = createApi(); files['a.json'].excluded_models = [modelId]; await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), ['gpt-image-*'], api); - expect(writes).toEqual([{ path: '/oauth-excluded-models', body: { provider: 'codex', models: ['gpt-image-*'] } }]); + expect(writes).toEqual([{ path: '/config/oauth/excluded-models', body: { codex: ['gpt-image-*'], claude: ['claude-old-*'] } }]); await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), [], api); - expect(deletes).toEqual([{ path: '/oauth-excluded-models', query: { provider: 'codex' } }]); + expect(deletes).toEqual([]); + expect(writes.at(-1)).toEqual({ path: '/config/oauth/excluded-models', body: { claude: ['claude-old-*'] } }); expect(globalRules).toEqual({ claude: ['claude-old-*'] }); expect(files['a.json'].excluded_models).toEqual([modelId]); - expect(reads.some(({ path }) => path.startsWith('/auth-files/'))).toBe(false); + expect(reads.some(({ path }) => path.startsWith('/credentials/'))).toBe(false); await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), [], api); - expect(deletes).toHaveLength(1); + expect(writes).toHaveLength(2); }); it('uses only the OAuth exclusion endpoint even when excluding every model for a provider', async () => { @@ -140,17 +171,19 @@ describe('OAuth model settings scopes', () => { const beforeFiles = structuredClone(files); await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), ['*'], api); expect(reads).toEqual([ - { path: '/model-definitions/codex', query: undefined }, - { path: '/oauth-excluded-models', query: undefined }, + { path: '/routing/model-definitions/codex', query: undefined }, + { path: '/config/oauth/excluded-models', query: undefined }, + { path: '/config/oauth/excluded-models', query: undefined }, ]); expect(writes).toEqual([ - { path: '/oauth-excluded-models', body: { provider: 'codex', models: ['*'] } }, + { path: '/config/oauth/excluded-models', body: { codex: ['*'], claude: ['claude-old-*'] } }, ]); expect(globalRules).toEqual({ codex: ['*'], claude: ['claude-old-*'] }); expect(files).toEqual(beforeFiles); await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), [], api); - expect(deletes).toEqual([{ path: '/oauth-excluded-models', query: { provider: 'codex' } }]); - expect(writes).toHaveLength(1); + expect(deletes).toEqual([]); + expect(writes.at(-1)).toEqual({ path: '/config/oauth/excluded-models', body: { claude: ['claude-old-*'] } }); + expect(writes).toHaveLength(2); expect(files).toEqual(beforeFiles); }); diff --git a/tests/oauthModels.test.ts b/tests/oauthModels.test.ts index 4483995..9683687 100644 --- a/tests/oauthModels.test.ts +++ b/tests/oauthModels.test.ts @@ -22,7 +22,7 @@ describe('OAuth model exclusion rules', () => { { id: 'GPT-5.4' }, ] })).toEqual([{ id: 'gpt-5.4', displayName: 'GPT 5.4' }]); expect(oauthExcludedRulesFromPayload({ - 'oauth-excluded-models': { codex: [' GPT-IMAGE-* ', 'gpt-image-*'] }, + codex: [' GPT-IMAGE-* ', 'gpt-image-*'], }, 'codex')).toEqual(['gpt-image-*']); expect(normalizeOAuthExcludedRules([' GPT-* ', '', 'gpt-*', 'future-model'])) .toEqual(['gpt-*', 'future-model']); diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index 60127df..794b462 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -23,7 +23,7 @@ import { stripResponseFields, } from '../src/pages/ApiAccessPage'; import { modelsFromRecord } from '../src/services/modelService'; -import { flattenV8ProviderGroups } from '../src/services/managementApi'; +import { flattenV8ProviderGroups } from './fixtures/legacyProviderRecords'; describe('shared-credential provider entries (#276)', () => { const first = { diff --git a/tests/quotaRequests.test.ts b/tests/quotaRequests.test.ts index 4c5c9c9..1626423 100644 --- a/tests/quotaRequests.test.ts +++ b/tests/quotaRequests.test.ts @@ -19,7 +19,7 @@ beforeEach(() => { calls = []; handler = () => { throw new Error('Unexpected API request'); }; post = spyOn(managementApi, 'post').mockImplementation(async (path, body) => { - expect(path).toBe('/api-call'); + expect(path).toBe('/requests/api-call'); const request = body as unknown as Request; calls.push(request); return await handler(request) as never; @@ -110,7 +110,7 @@ describe('quota API compatibility', () => { groups: [{ buckets: [{ remainingFraction: 1 }] }], }); expect((await loadQuota({ name: 'anti-download.json', provider: 'antigravity', auth_index: 'a' })).status).toBe('success'); - expect(get).toHaveBeenCalledWith('/auth-files/download', { name: 'anti-download.json' }); + expect(get).toHaveBeenCalledWith('/credentials/download', { name: 'anti-download.json' }); expect(calls.find((request) => request.url.endsWith(':retrieveUserQuotaSummary'))?.data).toBe('{"project":"downloaded-project"}'); }); diff --git a/tests/usage-scroll-ui.cjs b/tests/usage-scroll-ui.cjs index 60c1f53..fa63eb4 100644 --- a/tests/usage-scroll-ui.cjs +++ b/tests/usage-scroll-ui.cjs @@ -159,6 +159,11 @@ function parseCsv(text) { } await page.setViewportSize({ width: 1100, height: 700 }); + // Existing installations may have explicitly enabled every old metric column. + await page.evaluate(() => localStorage.setItem('cpa-gui.usage-events-visible-cols.v3', JSON.stringify([ + 'time', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'provider', + 'input', 'output', 'reasoning', 'cacheRate', 'ttft', + ]))); await page.goto(`${base}/tests/fixtures/usage-layout.html?tab=events&locale=en`, { waitUntil: 'domcontentloaded' }); await page.locator('.usage-table-top-scrollbar:not(.is-hidden)').waitFor(); await page.locator('.usage-page-size-select').selectOption('200'); @@ -166,6 +171,8 @@ function parseCsv(text) { assert.equal(await page.getByRole('heading', { name: 'Request Event Log' }).count(), 0, 'The redundant request log heading is removed'); const firstRow = page.locator('.usage-events-table tbody tr').first(); + assert.equal(await page.locator('.usage-events-table th').count(), 12, 'Saved settings cannot restore removed duplicate columns'); + assert.equal(await firstRow.locator('.usage-td-source svg, .usage-td-source img').count(), 0, 'Source is plain text without a logo'); assert.equal(await firstRow.locator('.usage-td-total').getAttribute('title'), '1,200 tokens', 'The displayed total uses the recorded total instead of adding cache and reasoning again'); assert.equal(await firstRow.locator('.tone-input').getAttribute('aria-label'), 'Input: 1000'); assert.equal(await firstRow.locator('.tone-output').getAttribute('aria-label'), 'Output: 200'); @@ -284,6 +291,7 @@ function parseCsv(text) { await page.locator('.usage-col-settings-btn').click(); const checkboxes = page.locator('.usage-column-option input'); + assert.equal(await checkboxes.count(), 12, 'Column settings contain only the remaining columns'); for (let index = 2; index < await checkboxes.count(); index += 1) await checkboxes.nth(index).uncheck(); await page.locator('.usage-column-dialog-actions .primary-button').click(); await page.waitForFunction(() => document.querySelector('.usage-table-top-scrollbar').classList.contains('is-hidden'));