fix(v8): preserve migrated config and correct provider requests

- Preserve provider credentials, aliases, headers, and network settings during migration
- Normalize Claude cloak metadata and repair previously saved invalid types
- Use streaming Gemini health probes and honor group and key proxy settings
- Retain Codex customizations for temporarily unavailable models
- Add regression tests for migration, metadata, proxy routing, and catalog persistence
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 2fd0f73f4c
commit 06fd58d3d8
15 files changed
+433 -35

No files matched your search

+57 -1
View File
@@ -370,7 +370,17 @@ fn save_for_state(
state: &mut CatalogState,
) -> Result<CatalogEditorSnapshot, String> {
let snapshot = snapshot_for_state(runtime_models, state)?;
let customizations = customizations_from_request(&snapshot, request)?;
let mut customizations = customizations_from_request(&snapshot, request)?;
// The public catalog can temporarily omit disabled or unavailable models.
// Editing visible models must not erase the saved settings for those IDs.
let visible: HashSet<String> = snapshot.models.iter()
.map(|model| normalize_id(&model.slug))
.collect();
for (slug, configuration) in &state.customizations {
if !visible.contains(slug) {
customizations.insert(slug.clone(), configuration.clone());
}
}
let saved = SavedCustomizations {
version: 1,
models: customizations,
@@ -423,6 +433,52 @@ mod tests {
))
}
#[test]
fn saving_visible_models_preserves_unavailable_models_and_allows_visible_reset() {
let mut state = CatalogState {
sources: parse_sources(MODEL_CATALOG_JSON).unwrap(),
json: MODEL_CATALOG_JSON.to_string(),
customizations: Default::default(),
};
let hidden = serde_json::json!({
"base_instructions": "Saved prompt for temporarily unavailable A",
"context_window": 131_072,
"max_context_window": 262_144,
}).as_object().unwrap().clone();
state.customizations.insert("model-a".to_string(), hidden.clone());
let runtime = vec![runtime_model("model-b")];
let snapshot = snapshot_for_state(&runtime, &state).unwrap();
let mut configuration = snapshot.models[0].configuration.clone();
configuration.insert("display_name".to_string(), Value::String("Edited B".to_string()));
let path = temporary_path();
let saved = save_for_state(&path, &runtime, CatalogEditorRequest {
revision: snapshot.revision,
models: vec![CatalogEditorModelRequest { slug: "model-b".to_string(), configuration }],
}, &mut state).unwrap();
let persisted = decode_customizations(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(persisted.get("model-a"), Some(&hidden));
assert_eq!(persisted["model-b"]["display_name"], "Edited B");
save_for_state(&path, &runtime, CatalogEditorRequest {
revision: saved.revision,
models: vec![CatalogEditorModelRequest {
slug: "model-b".to_string(), configuration: saved.models[0].defaults.clone(),
}],
}, &mut state).unwrap();
assert!(!state.customizations.contains_key("model-b"));
assert_eq!(state.customizations.get("model-a"), Some(&hidden));
let empty = snapshot_for_state(&[], &state).unwrap();
save_for_state(&path, &[], CatalogEditorRequest {
revision: empty.revision, models: vec![],
}, &mut state).unwrap();
assert_eq!(decode_customizations(&std::fs::read(&path).unwrap()).unwrap().get("model-a"), Some(&hidden));
let restored = snapshot_for_state(&[runtime_model("model-a")], &state).unwrap();
assert_eq!(restored.models[0].configuration["base_instructions"], hidden["base_instructions"]);
assert_eq!(restored.models[0].configuration["context_window"], 131_072);
std::fs::remove_file(path).unwrap();
}
#[test]
fn customizations_persist_apply_and_restore_to_the_current_template() {
let sources = parse_sources(MODEL_CATALOG_JSON).unwrap();
+2
View File
@@ -65,6 +65,8 @@ const PATHS: &[(&str, &str)] = &[
("oauth/providers/xai", "xai"),
("server/host", "host"),
("server/port", "port"),
("server/trusted-proxies", "trusted-proxies"),
("server/discovery", "discovery"),
("server/tls", "tls"),
("server/commercial-mode", "commercial-mode"),
("management", "remote-management"),
+49 -19
View File
@@ -1276,14 +1276,12 @@ fn lift_legacy_core_config_fields(
template: &serde_norway::Mapping,
) -> Result<(), String> {
let copy = |document: &mut serde_norway::Value, legacy_path: &[&str], v8_path: &[&str]| -> Result<(), String> {
let Some(value) = nested_yaml_value(legacy, legacy_path).cloned() else {
// Read the remaining source: narrower moves may already have consumed
// children which now belong elsewhere in the v8 layout.
let Some(value) = document.as_mapping().and_then(|root| nested_yaml_value(root, legacy_path)).cloned() else {
return Ok(());
};
// A partially migrated file can contain both spellings. Prefer the
// canonical v8 value in that case; otherwise move the legacy value.
if nested_yaml_value(legacy, v8_path).is_none() {
set_core_yaml_path_value(document, v8_path, value)?;
}
lift_legacy_config_value(document, legacy, v8_path, value)?;
if nested_yaml_value(template, legacy_path).is_none() {
remove_core_yaml_path_value(document, legacy_path);
}
@@ -1292,6 +1290,7 @@ fn lift_legacy_core_config_fields(
for (legacy_path, v8_path) in [
(&["host"][..], &["server", "host"][..]),
(&["port"], &["server", "port"]),
(&["tls"], &["server", "tls"]),
(&["commercial-mode"], &["server", "commercial-mode"]),
(&["auth-dir"], &["oauth", "auth-dir"]),
(&["debug"], &["observability", "logs", "debug"]),
@@ -1313,14 +1312,6 @@ fn lift_legacy_core_config_fields(
] {
copy(document, legacy_path, v8_path)?;
}
if let Some(tls) = yaml_mapping_value(legacy, "tls").filter(|value| value.is_mapping()).cloned() {
if nested_yaml_value(legacy, &["server", "tls"]).is_none() {
set_core_yaml_path_value(document, &["server", "tls"], tls)?;
}
if yaml_mapping_value(template, "tls").is_none() {
remove_core_yaml_path_value(document, &["tls"]);
}
}
if let Some(keys) = yaml_mapping_value(legacy, "api-keys").filter(|value| value.is_sequence()).cloned() {
if nested_yaml_value(legacy, &["access", "api-keys"]).is_none() {
set_core_yaml_path_value(document, &["access", "api-keys"], keys)?;
@@ -1336,15 +1327,11 @@ fn lift_legacy_core_config_fields(
remove_core_yaml_path_value(document, &["api-keys"]);
}
}
let has_canonical_upstream_mapping = yaml_mapping_value(legacy, "api-keys")
.is_some_and(serde_norway::Value::is_mapping);
for (legacy_name, provider) in V8_PROVIDER_FAMILIES {
let Some(records) = yaml_mapping_value(legacy, legacy_name) else {
continue;
};
if !has_canonical_upstream_mapping
&& nested_yaml_value(legacy, &["api-keys", provider]).is_none()
{
if nested_yaml_value(legacy, &["api-keys", provider]).is_none() {
let grouped = group_legacy_provider_records(provider, records)?;
set_core_yaml_path_value(document, &["api-keys", provider], grouped)?;
}
@@ -1358,6 +1345,49 @@ fn lift_legacy_core_config_fields(
Ok(())
}
// Structs have independent leaf settings. User-owned maps (aliases, payload
// params, headers, etc.) instead retain the canonical value as a whole.
fn lift_legacy_config_value(
document: &mut serde_norway::Value,
original: &serde_norway::Mapping,
path: &[&str],
value: serde_norway::Value,
) -> Result<(), String> {
// Explicit canonical null/scalar parents must not be recreated by a
// narrower legacy spelling such as claude-header-defaults.
if (1..path.len()).any(|length| {
nested_yaml_value(original, &path[..length]).is_some_and(|value| !value.is_mapping())
}) {
return Ok(());
}
let is_struct = matches!(path,
["server", "tls"] | ["server", "discovery"]
| ["server", "discovery", "interfaces"] | ["management"]
| ["credentials", "concurrency"] | ["credentials", "in-flight"]
| ["requests", "streaming"] | ["observability", "pprof"]
| ["oauth", "providers", "codex"] | ["oauth", "providers", "claude"]
| ["oauth", "providers", "antigravity"] | ["oauth", "providers", "devin"]
| ["oauth", "providers", "xai"]
| ["oauth", "providers", "codex", "live-media-relay"]
| ["oauth", "providers", "codex", "header-defaults"]
| ["oauth", "providers", "claude", "header-defaults"]
| ["oauth", "providers", "claude", "claude-code"]
| ["oauth", "providers", "antigravity", "connection-pool"]
);
let canonical = nested_yaml_value(original, path);
if is_struct && value.is_mapping() && canonical.is_none_or(serde_norway::Value::is_mapping) {
for (key, child) in value.as_mapping().unwrap() {
let key = key.as_str().ok_or("Configuration field must be a string")?;
let mut child_path = path.to_vec();
child_path.push(key);
lift_legacy_config_value(document, original, &child_path, child.clone())?;
}
} else if canonical.is_none() {
set_core_yaml_path_value(document, path, value)?;
}
Ok(())
}
fn remove_core_yaml_path_value(document: &mut serde_norway::Value, path: &[&str]) -> bool {
let Some((key, parents)) = path.split_last() else {
return false;
+12 -5
View File
@@ -26,6 +26,8 @@ pub(crate) struct ProviderHealthProbeRequest {
protocol: String,
#[serde(default)]
source_provider: Option<String>,
#[serde(default)]
proxy_url: Option<String>,
timeout_ms: Option<u64>,
#[serde(default)]
model: String,
@@ -424,6 +426,7 @@ async fn probe_core_model(
}).to_string(),
protocol: "openai-chat".to_string(),
source_provider: None,
proxy_url: None,
timeout_ms,
model,
source: String::new(),
@@ -447,11 +450,7 @@ pub(crate) async fn provider_health_probe(
let url = reqwest::Url::parse(request.url.trim())
.map_err(|error| format!("Invalid health check URL: {error}"))?;
let config = gui_config_state.snapshot()?;
let proxy_url = url
.host_str()
.filter(|host| !is_loopback_host(host))
.map(|_| config.proxy_url.as_str())
.unwrap_or_default();
let proxy_url = provider_health_proxy_url(&url, request.proxy_url.as_deref(), &config.proxy_url);
let client = build_http_client_with_proxy(
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
@@ -462,6 +461,14 @@ pub(crate) async fn provider_health_probe(
execute_health_probe(Some(&app), client, request).await
}
fn provider_health_proxy_url<'a>(url: &reqwest::Url, requested: Option<&'a str>, global: &'a str) -> &'a str {
if url.host_str().is_some_and(is_loopback_host) {
return "";
}
let proxy = requested.map(str::trim).filter(|value| !value.is_empty()).unwrap_or(global.trim());
if proxy.eq_ignore_ascii_case("direct") { "" } else { proxy }
}
async fn execute_health_probe(
usage_app: Option<&tauri::AppHandle>,
client: reqwest::Client,
+28
View File
@@ -2,6 +2,34 @@ use super::*;
use crate::GuiApiKeyEntry;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn provider_proxy_override_preserves_direct_and_global_inheritance() {
let upstream = reqwest::Url::parse("https://provider.invalid/v1/responses").unwrap();
let global = "http://127.0.0.1:18080";
assert_eq!(provider_health_proxy_url(&upstream, None, global), global);
assert_eq!(provider_health_proxy_url(&upstream, Some(" "), global), global);
assert_eq!(provider_health_proxy_url(&upstream, Some(" direct "), global), "");
assert_eq!(provider_health_proxy_url(&upstream, Some("socks5://127.0.0.1:19080"), global), "socks5://127.0.0.1:19080");
let local = reqwest::Url::parse("http://127.0.0.1:8317/v1/responses").unwrap();
assert_eq!(provider_health_proxy_url(&local, None, global), "");
}
#[tokio::test]
async fn provider_health_uses_request_proxy_instead_of_global_proxy() {
let (port, server) = mock_core_response("200 OK", "text/event-stream", "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"OK\"}]}}]}\n\n", "").await;
let request: ProviderHealthProbeRequest = serde_json::from_value(serde_json::json!({
"url": "http://provider.invalid/v1beta/models/test:streamGenerateContent?alt=sse",
"header": {}, "data": "{}", "protocol": "gemini",
"proxyUrl": format!("http://127.0.0.1:{port}"),
})).unwrap();
let url = reqwest::Url::parse(&request.url).unwrap();
let selected = provider_health_proxy_url(&url, request.proxy_url.as_deref(), "http://127.0.0.1:1");
let client = build_http_client_with_proxy(reqwest::Client::builder().timeout(Duration::from_secs(3)), selected, "proxy test").unwrap();
assert!(execute_health_probe(None, client, request).await.unwrap().first_token_latency_ms.is_some());
let wire_request = server.await.unwrap();
assert!(wire_request.starts_with("POST http://provider.invalid/v1beta/models/test:streamGenerateContent?alt=sse HTTP/1.1"));
}
async fn mock_core_response(
status: &str,
content_type: &str,
+79
View File
@@ -697,6 +697,85 @@ fn v7_config_startup_with_v8_template_writes_v8_version() {
}
}
#[test]
fn v8_migration_preserves_each_legacy_provider_family_independently() {
let template = "config-version: 8\napi-keys: {codex: [], claude: []}\n";
for marker in ["", "config-version: 7\n"] {
let old = format!("{marker}api-keys: {{codex: []}}\ncodex-api-key: [{{api-key: ignored}}]\nclaude-api-key: [{{api-key: fixture, models: [{{name: upstream, alias: claude-alias}}]}}]\n");
let migrated = migrate_legacy_core_config_to_v8(template, &old).unwrap();
let value: serde_norway::Value = serde_norway::from_str(&migrated).unwrap();
assert_eq!(value["api-keys"]["codex"].as_sequence().unwrap().len(), 0);
assert_eq!(value["api-keys"]["claude"][0]["keys"][0]["api-key"], "fixture");
assert_eq!(value["api-keys"]["claude"][0]["models"][0]["alias"], "claude-alias");
assert!(value.get("claude-api-key").is_none());
assert!(value.get("codex-api-key").is_none());
}
}
#[test]
fn v8_migration_preserves_narrow_fields_and_canonical_struct_leaves() {
let template = "config-version: 8\nmanagement: {allow-remote: false}\noauth: {providers: {claude: {model-level-cooling: false}, codex: {response-steering: false}}}\n";
let legacy = r#"
remote-management: {allow-remote: true, secret-key: old-secret}
management: {secret-key: canonical-secret}
claude-header-defaults: {user-agent: custom-agent, timezone: custom-zone}
claude-code: {disable-cloaking-model-list: true}
disable-claude-cloak-mode: true
claude: {model-level-cooling: true}
codex-header-defaults: {user-agent: codex-agent}
codex: {response-steering: true, optimize-multi-agent-v2: true}
oauth:
providers:
claude:
model-level-cooling: false
header-defaults: {timezone: canonical-zone}
codex: {response-steering: false}
"#;
let migrated = migrate_legacy_core_config_to_v8(template, legacy).unwrap();
let value: serde_norway::Value = serde_norway::from_str(&migrated).unwrap();
let claude = &value["oauth"]["providers"]["claude"];
assert_eq!(claude["header-defaults"]["user-agent"], "custom-agent");
assert_eq!(claude["header-defaults"]["timezone"], "canonical-zone");
assert_eq!(claude["model-level-cooling"].as_bool(), Some(false));
assert_eq!(claude["claude-code"]["disable-cloaking-model-list"].as_bool(), Some(true));
assert_eq!(claude["disable-claude-cloak-mode"].as_bool(), Some(true));
assert_eq!(value["management"]["allow-remote"].as_bool(), Some(true));
assert_eq!(value["management"]["secret-key"], "canonical-secret");
assert_eq!(value["oauth"]["providers"]["codex"]["header-defaults"]["user-agent"], "codex-agent");
assert_eq!(value["oauth"]["providers"]["codex"]["response-steering"].as_bool(), Some(false));
assert!(value["oauth"]["providers"]["codex"].get("optimize-multi-agent-v2").is_none());
assert_eq!(value["client"]["codex"]["optimize-multi-agent-v2"].as_bool(), Some(true));
}
#[test]
fn v8_migration_preserves_network_settings_over_template_defaults() {
let template = "config-version: 8\nserver: {trusted-proxies: [], discovery: {enabled: false}, tls: {enable: false, cert: '', key: ''}}\n";
let legacy = "trusted-proxies: [127.0.0.1]\ndiscovery: {enabled: true, service-name: office}\ntls: {enable: true, cert: old-cert, key: old-key}\nserver: {tls: {cert: canonical-cert}}\n";
let migrated = migrate_legacy_core_config_to_v8(template, legacy).unwrap();
let value: serde_norway::Value = serde_norway::from_str(&migrated).unwrap();
assert_eq!(value["server"]["trusted-proxies"][0], "127.0.0.1");
assert_eq!(value["server"]["discovery"]["enabled"].as_bool(), Some(true));
assert_eq!(value["server"]["discovery"]["service-name"], "office");
assert_eq!(value["server"]["tls"]["enable"].as_bool(), Some(true));
assert_eq!(value["server"]["tls"]["cert"], "canonical-cert");
assert_eq!(value["server"]["tls"]["key"], "old-key");
assert!(value.get("trusted-proxies").is_none());
assert!(value.get("discovery").is_none());
}
#[test]
fn v8_migration_preserves_explicit_canonical_clears_and_existing_v8_files() {
let template = "config-version: 8\nserver: {trusted-proxies: [192.0.2.1]}\n";
let old = "trusted-proxies: [127.0.0.1]\nserver: {trusted-proxies: []}\nclaude-header-defaults: {user-agent: legacy}\nclaude: {model-level-cooling: true}\noauth: {providers: {claude: null}, model-alias: {}}\noauth-model-alias: {claude: [{name: original, alias: old-alias}]}\n";
let migrated = migrate_legacy_core_config_to_v8(template, old).unwrap();
let value: serde_norway::Value = serde_norway::from_str(&migrated).unwrap();
assert!(value["server"]["trusted-proxies"].as_sequence().unwrap().is_empty());
assert!(value["oauth"]["providers"]["claude"].is_null());
assert!(value["oauth"]["model-alias"].as_mapping().unwrap().is_empty());
let already_v8 = format!("config-version: 8\n{old}");
assert_eq!(migrate_legacy_core_config_to_v8(template, &already_v8).unwrap(), already_v8);
}
#[test]
fn replacing_a_core_rejects_invalid_config_without_overwriting_files() {
let root = agent_test_home("core-config-migrate-invalid");
+1 -1
View File
@@ -30,7 +30,7 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
const [error, setError] = useState('');
const [discard, setDiscard] = useState(false);
const [attempt, setAttempt] = useState(0);
const dirty = draft !== null && JSON.stringify(draft) !== JSON.stringify(original);
const dirty = draft !== null && (Boolean(draft.normalizeCloakMetadata?.length) || JSON.stringify(draft) !== JSON.stringify(original));
useEffect(() => {
const previousFocus = document.activeElement;
+13 -1
View File
@@ -1011,6 +1011,12 @@ export const buildProviderKeyRecord = (
return next;
};
export const providerDraftConnection = (
draft: Pick<ProviderDraft, 'headersText' | 'proxyUrl'>, key?: ProviderKeyDraft,
) => effectiveProviderKey({
headers: parseProviderHeaders(draft.headersText ?? ''), 'proxy-url': draft.proxyUrl,
}, key ? serializeProviderKey(key) : {});
export const buildProviderGroupRecord = (
section: ProviderSection, draft: ProviderDraft, current?: Record<string, unknown>,
): Record<string, unknown> => {
@@ -1437,6 +1443,8 @@ export function ApiAccessPage() {
preparedDraft.groupKeys?.[0]
? providerHeadersFromRecord(effectiveProviderKey({ headers: providerHeaders }, serializeProviderKey(preparedDraft.groupKeys[0])))
: providerHeaders,
undefined,
readString(providerDraftConnection(draftToSave, preparedDraft.groupKeys?.[0]), 'proxy-url'),
);
if (fetchedModels.length === 0) {
throw new Error(t('apiAccess.error.noModels'));
@@ -1765,6 +1773,7 @@ function ProviderHealthDialog({ row, onClose, keySelector }: ProviderHealthDialo
apiKeys: row.apiKeys,
authIndex: row.authIndex,
customHeaders: providerHeadersFromRecord(row.record),
proxyUrl: readString(row.record, 'proxy-url'),
timeoutMs: PROVIDER_HEALTH_TIMEOUT_MS,
}), [row]);
@@ -1779,6 +1788,7 @@ function ProviderHealthDialog({ row, onClose, keySelector }: ProviderHealthDialo
healthOptions.authIndex,
healthOptions.customHeaders,
healthOptions.timeoutMs,
healthOptions.proxyUrl,
).then((discovered) => {
if (!disposed) setModels(mergeProviderHealthModels(discovered, row.models));
}).catch((requestError) => {
@@ -2118,7 +2128,7 @@ export function ApiProviderDialog({
try {
const provider: ModelProvider = activeCategory === 'deepseek' ? 'deepseek' : providerModelType(definition.section);
const selectedKey = draft.groupKeys?.find((key) => key.id === discoveryKeyId) ?? draft.groupKeys?.[0];
const connection = selectedKey ? effectiveProviderKey({ headers: parseProviderHeaders(draft.headersText ?? '') }, serializeProviderKey(selectedKey)) : null;
const connection = selectedKey ? providerDraftConnection(draft, selectedKey) : null;
const modelApiKey = connection ? readString(connection, 'api-key') : draft.apiKey.split(/\r?\n/).map((value) => value.trim()).find(Boolean) ?? '';
const fetchedModels = await fetchModels(
provider,
@@ -2126,6 +2136,8 @@ export function ApiProviderDialog({
modelApiKey,
connection ? readString(selectedKey!.value, 'auth-index', 'authIndex') || undefined : editingRow?.authIndex,
connection ? providerHeadersFromRecord(connection) : parseProviderHeaders(draft.headersText ?? ''),
undefined,
connection ? readString(connection, 'proxy-url') : draft.proxyUrl,
);
if (requestId !== discoveryRequestRef.current) return;
const models = applyProviderPreset(
+32 -3
View File
@@ -20,6 +20,7 @@ export type AuthFileSettingsDraft = {
headers: string;
note: string;
advanced: Record<string, unknown>;
normalizeCloakMetadata?: string[];
};
const fail = (key: 'metadata' | 'headers' | 'weight' | 'priority') => {
@@ -52,6 +53,27 @@ const headersFromText = (text: string): Record<string, string> => {
return result;
};
// OAuth cloak metadata is consumed as strings by the core, unlike the typed
// cloak block in API-key configuration. Keep typed values only in the form.
const advancedDraftValue = (key: string, value: unknown): unknown => {
if (value == null) return value;
if (key === 'cloak_strict_mode' || key === 'cloak_cache_user_id') {
if (typeof value === 'string') return value.trim().toLowerCase() === 'true';
}
if (key === 'cloak_sensitive_words' && typeof value === 'string') {
return value.split(',').map(word => word.trim()).filter(Boolean);
}
// Accept typed values persisted by older versions of this editor as well.
return value;
};
const advancedMetadataValue = (key: string, value: unknown): unknown => {
if (value == null) return null;
if (key === 'cloak_strict_mode' || key === 'cloak_cache_user_id') return String(value);
if (key === 'cloak_sensitive_words') return (value as string[]).map(word => word.trim()).filter(Boolean).join(',');
return value;
};
export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsDraft => {
let metadata = payload;
if (typeof metadata === 'string') {
@@ -63,7 +85,14 @@ export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsD
const text = (value: unknown) => typeof value === 'string' || typeof value === 'number' ? String(value) : '';
const headers = metadata.headers == null ? {} : metadata.headers;
if (!isRecord(headers) || Object.values(headers).some((value) => typeof value !== 'string')) return fail('headers');
const normalizeCloakMetadata = ['cloak_strict_mode', 'cloak_cache_user_id', 'cloak_sensitive_words'].filter(key => {
const value = read(key, key.replace(/_/g, '-'));
return key === 'cloak_sensitive_words'
? Array.isArray(value) && value.every(word => typeof word === 'string')
: typeof value === 'boolean';
});
return {
...(normalizeCloakMetadata.length ? { normalizeCloakMetadata } : {}),
prefix: text(metadata.prefix),
proxy_url: text(read('proxy_url', 'proxy-url')),
priority: text(metadata.priority),
@@ -75,7 +104,7 @@ export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsD
note: text(metadata.note),
advanced: Object.fromEntries(Object.keys(credentialAdvancedShape.fields ?? {}).flatMap(key => {
const value = read(key, key.replace(/_/g, '-'));
return value === undefined ? [] : [[key, value]];
return value === undefined ? [] : [[key, advancedDraftValue(key, value)]];
})),
};
};
@@ -120,10 +149,10 @@ export const buildAuthFileSettingsPatch = (
}
for (const [key, shape] of Object.entries(credentialAdvancedShape.fields ?? {})) {
const next = draft.advanced[key];
if (sameTemplateValue(next, original.advanced[key])) continue;
if (sameTemplateValue(next, original.advanced[key]) && !original.normalizeCloakMetadata?.includes(key)) continue;
const error = validateStructuredValueText(shape, next, key);
if (error) throw new Error(templateText(error, getCurrentLocale()));
patch[key] = next ?? null;
patch[key] = advancedMetadataValue(key, next);
}
return patch;
};
+4
View File
@@ -184,6 +184,7 @@ export async function fetchModels(
authIndex?: string,
customHeaders: Record<string, string> = {},
timeoutMs?: number,
proxyUrl?: string,
): Promise<ModelOption[]> {
const normalized = baseUrl.trim() ? normalizeBaseUrl(baseUrl) : '';
const candidates = modelEndpointCandidates(provider, normalized);
@@ -222,6 +223,7 @@ export async function fetchModels(
if (pageToken) pageUrl.searchParams.set('pageToken', pageToken);
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
authIndex: authIndex?.trim() || undefined,
proxy_url: proxyUrl?.trim() || undefined,
method: 'GET',
url: pageUrl.toString(),
header: Object.keys(headers).length ? headers : undefined,
@@ -258,6 +260,7 @@ export async function fetchModels(
if (provider === 'openai' && Object.keys(headers).length > 0) {
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
proxy_url: proxyUrl?.trim() || undefined,
method: 'GET',
url,
}, { timeoutMs });
@@ -272,6 +275,7 @@ export async function fetchModels(
if (provider === 'openai' && Object.keys(headers).length > 0) {
try {
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
proxy_url: proxyUrl?.trim() || undefined,
method: 'GET',
url,
}, { timeoutMs });
+5 -1
View File
@@ -35,6 +35,7 @@ export type ProviderHealthCheckOptions = {
authIndex?: string;
customHeaders?: Record<string, string>;
timeoutMs?: number;
proxyUrl?: string;
};
const defaultBaseUrl = defaultProviderBaseUrl;
@@ -125,7 +126,7 @@ export function buildProviderHealthProbe(
return {
...metadata,
url: provider === 'vertex' ? `${root}/v1/publishers/google/models/${encodeURIComponent(normalizedModel)}:streamGenerateContent?alt=sse`
: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:generateContent?alt=sse`,
: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:streamGenerateContent?alt=sse`,
header: headers,
protocol: 'gemini',
data: JSON.stringify({
@@ -203,6 +204,7 @@ export async function checkProviderHealthProbe(
authIndex = '',
customHeaders: Record<string, string> = {},
timeoutMs = PROVIDER_HEALTH_TIMEOUT_MS,
proxyUrl?: string,
): Promise<ProviderHealthProbeResult> {
try {
const probe = buildProviderHealthProbe(
@@ -233,6 +235,7 @@ export async function checkProviderHealthProbe(
model: probe.model,
source: probe.source,
authIndex: probe.authIndex,
proxyUrl: proxyUrl?.trim() || undefined,
},
});
const firstTokenLatencyMs = Number.isFinite(response.firstTokenLatencyMs)
@@ -268,6 +271,7 @@ export async function checkProviderModelHealth(
options.authIndex,
options.customHeaders,
options.timeoutMs,
options.proxyUrl,
);
return {
...result,
+57 -2
View File
@@ -88,9 +88,9 @@ describe('credential settings', () => {
it('edits template credential fields without resending tokens or unmodified metadata', () => {
const original = authFileSettingsFromPayload({ access_token: 'never-copy', cloak_mode: 'auto', timezone: 'Asia/Tokyo',
cloak_strict_mode: true, model_aliases: [{ name: 'upstream', alias: 'public', future_metadata: true }] });
cloak_strict_mode: 'true', model_aliases: [{ name: 'upstream', alias: 'public', future_metadata: true }] });
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, cloak_mode: 'always', cloak_strict_mode: false } }))
.toEqual({ cloak_mode: 'always', cloak_strict_mode: false });
.toEqual({ cloak_mode: 'always', cloak_strict_mode: 'false' });
const next = { ...original.advanced }; delete next.timezone;
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: next })).toEqual({ timezone: null });
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, model_aliases: [] } })).toEqual({ model_aliases: [] });
@@ -99,4 +99,59 @@ describe('credential settings', () => {
expect(() => buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, ...advanced } })).toThrow();
}
});
it('converts native string cloak metadata to typed controls and writes executor-compatible strings', () => {
const original = authFileSettingsFromPayload({ cloak_strict_mode: 'true', cloak_cache_user_id: 'false',
cloak_sensitive_words: ' first,second , ', custom_metadata: { keep: true } });
expect(original.advanced).toEqual({ cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: ['first', 'second'] });
expect(buildAuthFileSettingsPatch(original, original)).toEqual({});
const patch = buildAuthFileSettingsPatch(original, { ...original, advanced: {
cloak_strict_mode: false, cloak_cache_user_id: true, cloak_sensitive_words: ['changed', ' second '],
} });
expect(patch).toEqual({ cloak_strict_mode: 'false', cloak_cache_user_id: 'true', cloak_sensitive_words: 'changed,second' });
expect(authFileSettingsFromPayload(patch).advanced).toEqual({ cloak_strict_mode: false, cloak_cache_user_id: true,
cloak_sensitive_words: ['changed', 'second'] });
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { cloak_sensitive_words: [] } }))
.toEqual({ cloak_strict_mode: null, cloak_cache_user_id: null, cloak_sensitive_words: '' });
});
it('can edit older incorrectly typed cloak metadata without resending unrelated fields', () => {
const original = authFileSettingsFromPayload({ cloak_strict_mode: false, cloak_cache_user_id: true,
cloak_sensitive_words: ['old'], fingerprint_profile: 'claude-code-cli', model_aliases: [{ name: 'upstream', alias: 'public', extension: true }] });
expect(original.advanced.cloak_strict_mode).toBe(false);
expect(original.advanced.cloak_sensitive_words).toEqual(['old']);
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced,
cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: ['new'],
} })).toEqual({ cloak_strict_mode: 'true', cloak_cache_user_id: 'false', cloak_sensitive_words: 'new' });
expect(buildAuthFileSettingsPatch(original, { ...original, note: 'only note' })).toEqual({ note: 'only note',
cloak_strict_mode: 'false', cloak_cache_user_id: 'true', cloak_sensitive_words: 'old' });
const canonicalNull = authFileSettingsFromPayload({ cloak_strict_mode: null, 'cloak-strict-mode': 'true', cloak_sensitive_words: null });
expect(canonicalNull.advanced).toEqual({ cloak_strict_mode: null, cloak_sensitive_words: null });
expect(canonicalNull.normalizeCloakMetadata).toBeUndefined();
});
it('repairs previously typed cloak fields on an unchanged explicit save and stops after reload', async () => {
const metadata = { cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: [] as string[],
access_token: 'retain', extension: { untouched: true } } as Record<string, unknown>;
const writes: Record<string, unknown>[] = [];
const api = { get: async () => metadata, patch: async (_path: string, body: Record<string, unknown>) => {
const { name, ...patch } = body;
writes.push(patch);
Object.assign(metadata, patch);
} };
const original = await loadAuthFileSettings('old.json', api);
expect(original.normalizeCloakMetadata).toEqual(['cloak_strict_mode', 'cloak_cache_user_id', 'cloak_sensitive_words']);
expect(await saveAuthFileSettings('old.json', original, original, api)).toBe(true);
expect(writes).toEqual([{ cloak_strict_mode: 'true', cloak_cache_user_id: 'false', cloak_sensitive_words: '' }]);
expect(metadata.access_token).toBe('retain');
expect(metadata.extension).toEqual({ untouched: true });
const reloaded = await loadAuthFileSettings('old.json', api);
expect(reloaded.normalizeCloakMetadata).toBeUndefined();
expect(reloaded.advanced).toEqual(original.advanced);
expect(await saveAuthFileSettings('old.json', reloaded, reloaded, api)).toBe(false);
expect(writes).toHaveLength(1);
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: {} })).toEqual({
cloak_strict_mode: null, cloak_cache_user_id: null, cloak_sensitive_words: null,
});
});
});
+13 -1
View File
@@ -149,9 +149,18 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
it('round-trips template credential aliases and Claude settings without replacing token metadata', async () => {
const fileName = 'template-claude.json';
await managementApi.uploadAuthFile(new File([JSON.stringify({ type: 'claude', access_token: 'isolated-template-token',
expired: '2099-01-01T00:00:00Z', custom_metadata: { keep: true } })], fileName, { type: 'application/json' }));
expired: '2099-01-01T00:00:00Z', custom_metadata: { keep: true },
cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: ['Legacy'] })], fileName, { type: 'application/json' }));
try {
const legacy = await loadAuthFileSettings(fileName);
expect(await saveAuthFileSettings(fileName, legacy, legacy)).toBe(true);
const repaired = await managementApi.get<Record<string, unknown>>('/credentials/download', { name: fileName });
expect(repaired.cloak_strict_mode).toBe('true');
expect(repaired.cloak_cache_user_id).toBe('false');
expect(repaired.cloak_sensitive_words).toBe('Legacy');
const original = await loadAuthFileSettings(fileName);
expect(original.normalizeCloakMetadata).toBeUndefined();
expect(await saveAuthFileSettings(fileName, original, original)).toBe(false);
const advanced = { cloak_mode: 'always', cloak_strict_mode: false, cloak_cache_user_id: true, cloak_sensitive_words: ['Word'],
fingerprint_profile: 'claude-code-cli', timezone: 'Asia/Shanghai',
model_aliases: [{ name: 'claude-template', alias: 'public-template', fork: false, 'display-name': 'Template model', 'force-mapping': true }] };
@@ -159,6 +168,9 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
const loaded = await loadAuthFileSettings(fileName);
expect(loaded.advanced).toEqual(advanced);
const metadata = await managementApi.get<Record<string, unknown>>('/credentials/download', { name: fileName });
expect(metadata.cloak_strict_mode).toBe('false');
expect(metadata.cloak_cache_user_id).toBe('true');
expect(metadata.cloak_sensitive_words).toBe('Word');
expect(metadata.access_token).toBe('isolated-template-token');
expect(metadata.custom_metadata).toEqual({ keep: true });
await saveAuthFileSettings(fileName, loaded, { ...loaded, advanced: {} });
+1 -1
View File
@@ -207,7 +207,7 @@ describe('API 接入健康检测', () => {
);
expect(probe.url).toBe(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?alt=sse',
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse',
);
expect(probe.header['x-goog-api-key']).toBe('gemini-key');
expect(probe.protocol).toBe('gemini');
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
import { fetchModels } from '../src/services/modelService';
import { checkProviderModelHealth } from '../src/services/providerHealthCheck';
import { providerDraftConnection } from '../src/pages/ApiAccessPage';
import { providerKeyDraft } from '../src/services/providerGroups';
describe('provider request proxy routing', () => {
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
});
afterEach(() => {
clearMocks();
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
});
it('uses the edited shared proxy unless a key supplies a non-null override', () => {
const draft = { proxyUrl: 'socks5://127.0.0.1:19080', headersText: 'X-Team: new' };
for (const override of [undefined, null, '', 'direct', 'http://127.0.0.1:18080']) {
const key = providerKeyDraft({ 'api-key': 'test-key', ...(override === undefined ? {} : { 'proxy-url': override }) });
const effective = providerDraftConnection(draft, key);
expect(effective['proxy-url']).toBe(override == null ? draft.proxyUrl : override);
expect(effective.headers).toEqual({ 'X-Team': 'new' });
}
});
for (const firstResult of ['http-error', 'transport-error'] as const) {
it(`retains direct proxy selection on unauthenticated discovery fallback (${firstResult})`, async () => {
const requests: Record<string, any>[] = [];
mockIPC((_command, args) => {
const request = args?.request as Record<string, any>;
requests.push(request);
if (requests.length === 1) {
if (firstResult === 'transport-error') throw new Error('mock transport failure');
return { status_code: 401, body: 'unauthorized' };
}
return { status_code: 200, body: JSON.stringify({ data: [{ id: 'test-model' }] }) };
});
await fetchModels('openai', 'https://provider.invalid/v1', 'test-key', undefined, {}, undefined, ' direct ');
expect(requests).toHaveLength(2);
expect(requests.every(request => request.body.proxy_url === 'direct')).toBe(true);
expect(requests[1].body.header).toBeUndefined();
});
}
it('sends proxy overrides on every Gemini model page and uses SSE for health checks', async () => {
const requests: Record<string, any>[] = [];
mockIPC((command, args) => {
const request = args?.request as Record<string, any>;
requests.push(request);
if (command === 'provider_health_probe') return { firstTokenLatencyMs: 12, responseLatencyMs: 12 };
return { status_code: 200, body: { models: [{ name: `models/model-${requests.length}` }], ...(requests.length === 1 ? { nextPageToken: 'next' } : {}) } };
});
const proxyUrl = 'socks5://127.0.0.1:19080';
await fetchModels('gemini', 'https://generativelanguage.googleapis.com', 'test-key', undefined, {}, undefined, proxyUrl);
expect(requests).toHaveLength(2);
expect(requests.every(request => request.body.proxy_url === proxyUrl)).toBe(true);
const result = await checkProviderModelHealth({ provider: 'gemini', baseUrl: '', apiKeys: ['test-key'], proxyUrl }, 'model-1');
expect(result.success).toBe(true);
expect(requests[2].proxyUrl).toBe(proxyUrl);
expect(requests[2].url).toEndWith(':streamGenerateContent?alt=sse');
});
it('leaves proxy inheritance unset rather than converting it to direct', async () => {
mockIPC((command, args) => {
const request = args?.request as Record<string, any>;
if (command === 'provider_health_probe') {
expect(request.proxyUrl).toBeUndefined();
return { firstTokenLatencyMs: 12, responseLatencyMs: 12 };
}
expect(request.body.proxy_url).toBeUndefined();
return { status_code: 200, body: { data: [{ id: 'model' }] } };
});
await fetchModels('openai', 'https://provider.invalid/v1', 'test-key');
await checkProviderModelHealth({ provider: 'openai', baseUrl: 'https://provider.invalid/v1', apiKeys: ['test-key'], proxyUrl: '' }, 'model');
});
});