fix(api): align management requests and configuration with v8
- Use native v8 routes for credentials, models, OAuth, and API calls - Remove implicit legacy endpoint and response transformations - Serialize OAuth exclusion updates while preserving other providers - Send YAML as raw text and preserve explicit null request bodies - Update mocks and regression tests, including migrated Claude aliases
This commit is contained in:
1 parent
245319be2d
commit
0e7437493d
32 files changed
+383
-455
No files matched your search
@@ -61,11 +61,20 @@ pub(crate) struct ManagementRequest {
|
||||
method: String,
|
||||
path: String,
|
||||
query: Option<HashMap<String, String>>,
|
||||
#[serde(default, deserialize_with = "deserialize_management_body")]
|
||||
body: Option<serde_json::Value>,
|
||||
#[serde(rename = "timeoutMs")]
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
fn deserialize_management_body<'de, D>(deserializer: D) -> Result<Option<serde_json::Value>, 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<reqwest::RequestBuilder, String> {
|
||||
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::<serde_json::Value>(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 [
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<EventColumnKey, number> = {
|
||||
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 (
|
||||
<td className="usage-td-source align-left" title={record.source_display || record.source || undefined}>
|
||||
<div className="usage-event-source">
|
||||
<span className="usage-event-source-icon" aria-hidden="true"><Terminal size={13} /></span>
|
||||
<span>{record.source_display || record.source || '—'}</span>
|
||||
</div>
|
||||
<span className="usage-event-source">{record.source_display || record.source || '—'}</span>
|
||||
</td>
|
||||
);
|
||||
case 'key':
|
||||
@@ -343,24 +329,6 @@ function UsageEventCell({
|
||||
<small title={record.api_key_remark}>{record.api_key_remark || noRemarkLabel}</small>
|
||||
</td>
|
||||
);
|
||||
case 'input':
|
||||
return (
|
||||
<td className="usage-td-token align-left" title={`${record.tokens.input_tokens.toLocaleString()} tokens`}>
|
||||
{compactNumber(record.tokens.input_tokens)}
|
||||
</td>
|
||||
);
|
||||
case 'output':
|
||||
return (
|
||||
<td className="usage-td-token align-left" title={`${record.tokens.output_tokens.toLocaleString()} tokens`}>
|
||||
{compactNumber(record.tokens.output_tokens)}
|
||||
</td>
|
||||
);
|
||||
case 'reasoning':
|
||||
return (
|
||||
<td className="usage-td-token align-left" title={`${record.tokens.reasoning_tokens.toLocaleString()} tokens`}>
|
||||
{compactNumber(record.tokens.reasoning_tokens)}
|
||||
</td>
|
||||
);
|
||||
case 'cache':
|
||||
return (
|
||||
<td
|
||||
@@ -376,13 +344,6 @@ function UsageEventCell({
|
||||
<div className="usage-event-metrics"><span className="tone-cache-write" title={`${t('usage.token.cacheCreation')}: ${record.tokens.cache_creation_tokens.toLocaleString()}`} aria-label={`${t('usage.token.cacheCreation')}: ${record.tokens.cache_creation_tokens}`}><DatabaseZap size={11} aria-hidden="true" />{compactNumber(record.tokens.cache_creation_tokens)}</span></div>
|
||||
</td>
|
||||
);
|
||||
case 'cacheRate': {
|
||||
const value = formatCacheReadRate({
|
||||
inputTokens: record.tokens.input_tokens,
|
||||
cacheReadTokens: record.tokens.cache_read_tokens,
|
||||
});
|
||||
return <td className="usage-td-cache-rate align-left" title={value === '—' ? undefined : value}>{value}</td>;
|
||||
}
|
||||
case 'total':
|
||||
return (
|
||||
<td className="usage-td-token usage-td-total align-left" title={`${record.tokens.total_tokens.toLocaleString()} tokens`}>
|
||||
@@ -403,12 +364,6 @@ function UsageEventCell({
|
||||
<small title={record.ttft_ms == null ? undefined : `${record.ttft_ms} ms`}>{t('usage.column.ttft')} {record.ttft_ms == null ? '—' : compactDuration(record.ttft_ms)}</small>
|
||||
</td>
|
||||
);
|
||||
case 'ttft':
|
||||
return (
|
||||
<td className="usage-td-ttft align-left" title={record.ttft_ms == null ? undefined : `${record.ttft_ms} ms`}>
|
||||
{record.ttft_ms == null ? '—' : compactDuration(record.ttft_ms)}
|
||||
</td>
|
||||
);
|
||||
case 'speed': {
|
||||
const value = formatGenerationSpeed({
|
||||
outputTokens: record.tokens.output_tokens,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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<unknown>('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<unknown>('get_core_models').then(normalizeHomeModels) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentLocale, translate } from '../i18n';
|
||||
import { normalizeProviderModels } from './providerModels';
|
||||
|
||||
export type ManagementJson = Record<string, unknown> | 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<Record<string, unknown>[]> => {
|
||||
@@ -49,7 +21,7 @@ export const providerGroupsApi = {
|
||||
};
|
||||
|
||||
async function optionalConfigValue(
|
||||
path: string, fallback: ManagementJson, options: ManagementRequestOptions,
|
||||
path: string, fallback: ManagementJson, options: ManagementRequestOptions = {},
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await invoke<unknown>('management_request', {
|
||||
@@ -63,75 +35,6 @@ async function optionalConfigValue(
|
||||
}
|
||||
}
|
||||
|
||||
export function flattenV8ProviderGroups(provider: string, payload: unknown): Record<string, unknown>[] {
|
||||
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<string, unknown> = { ...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<string, unknown>[] {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
return payload.filter(isRecord).map((input, index) => {
|
||||
const record = normalizeProviderModels(input);
|
||||
if (provider === 'openai-compatibility') {
|
||||
const group: Record<string, unknown> = {
|
||||
...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<string, unknown> = { name: readString(record, 'name') || `${provider}-${index + 1}` };
|
||||
const key: Record<string, unknown> = {};
|
||||
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<string, unknown> = { ...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<string, string | number | boolean | undefined>,
|
||||
): Record<string, string> | undefined => {
|
||||
@@ -152,93 +55,15 @@ async function request<T = ManagementJson>(
|
||||
path: string,
|
||||
options: ManagementRequestOptions = {},
|
||||
): Promise<T> {
|
||||
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<T>('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<T>('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<unknown>('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<T>('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<unknown>('management_request', {
|
||||
return invoke<T>('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 = {
|
||||
|
||||
@@ -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<Record<string, unknown>>('/api-call', {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/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<Record<string, unknown>>('/api-call', {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/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<Record<string, unknown>>('/api-call', {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
|
||||
method: 'GET',
|
||||
url,
|
||||
}, { timeoutMs });
|
||||
|
||||
@@ -23,9 +23,44 @@ export type OAuthModelSettings = {
|
||||
type OAuthModelSettingsApi = {
|
||||
get: (path: string, query?: Record<string, string>) => Promise<unknown>;
|
||||
patch: (path: string, body: Record<string, unknown>) => Promise<unknown>;
|
||||
delete: (path: string, options?: { query?: Record<string, string> }) => Promise<unknown>;
|
||||
put: (path: string, body: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const exclusionsPath = '/config/oauth/excluded-models';
|
||||
const exclusionWrites = new WeakMap<OAuthModelSettingsApi, Promise<void>>();
|
||||
|
||||
async function readExclusions(api: OAuthModelSettingsApi): Promise<Record<string, unknown>> {
|
||||
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<void> {
|
||||
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<OAuthModelSettings> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -579,7 +579,7 @@ const resolveProjectId = async (file: AuthFile): Promise<string> => {
|
||||
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<Record<string, unknown>>('/api-call', {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
|
||||
authIndex,
|
||||
method,
|
||||
url,
|
||||
|
||||
+1
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('credential settings', () => {
|
||||
const writes: unknown[] = [];
|
||||
const api = {
|
||||
async get(path: string, query: Record<string, string>) {
|
||||
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<string, unknown>) {
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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]] }]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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<Record<string, unknown>>('/auth-files/download', { name });
|
||||
const metadata = await managementApi.get<Record<string, unknown>>('/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<Record<string, unknown>>('/auth-files/download', { name: fileName });
|
||||
const metadata = await managementApi.get<Record<string, unknown>>('/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<Record<string, unknown>>('/api-call', {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/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 () => {
|
||||
|
||||
+5
-5
@@ -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<string, unknown>) => { calls.push([path, body]); },
|
||||
delete: async () => {},
|
||||
put: async (path: string, body: Record<string, unknown>) => { 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'] }]);
|
||||
});
|
||||
+69
@@ -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<string, unknown>[] {
|
||||
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<string, unknown> = { ...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<string, unknown>[] {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
return payload.filter(isRecord).map((input, index) => {
|
||||
const record = normalizeProviderModels(input);
|
||||
if (provider === 'openai-compatibility') {
|
||||
const group: Record<string, unknown> = {
|
||||
...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<string, unknown> = { name: readString(record, 'name') || `${provider}-${index + 1}` };
|
||||
const key: Record<string, unknown> = {};
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
+44
-34
@@ -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<string, unknown>[] }>('/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<string, unknown>;
|
||||
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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, string>): Promise<unknown> {
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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<string, string> }): Promise<unknown> {
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"}');
|
||||
});
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in new issue
Block a user