diff --git a/src-tauri/src/codex_catalog/customizations.rs b/src-tauri/src/codex_catalog/customizations.rs index 37a51f8..5d2ee87 100644 --- a/src-tauri/src/codex_catalog/customizations.rs +++ b/src-tauri/src/codex_catalog/customizations.rs @@ -370,7 +370,17 @@ fn save_for_state( state: &mut CatalogState, ) -> Result { 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 = 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(); diff --git a/src-tauri/src/core_config/extended.rs b/src-tauri/src/core_config/extended.rs index 724dc49..fd4f97b 100644 --- a/src-tauri/src/core_config/extended.rs +++ b/src-tauri/src/core_config/extended.rs @@ -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"), diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index dc7336c..4c79b59 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -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; diff --git a/src-tauri/src/provider_health.rs b/src-tauri/src/provider_health.rs index e15ce38..2a4c226 100644 --- a/src-tauri/src/provider_health.rs +++ b/src-tauri/src/provider_health.rs @@ -26,6 +26,8 @@ pub(crate) struct ProviderHealthProbeRequest { protocol: String, #[serde(default)] source_provider: Option, + #[serde(default)] + proxy_url: Option, timeout_ms: Option, #[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, diff --git a/src-tauri/src/provider_health/tests.rs b/src-tauri/src/provider_health/tests.rs index a179321..199b3b8 100644 --- a/src-tauri/src/provider_health/tests.rs +++ b/src-tauri/src/provider_health/tests.rs @@ -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, diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index b29144e..8159208 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -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"); diff --git a/src/components/AuthFileSettingsDialog.tsx b/src/components/AuthFileSettingsDialog.tsx index 8213536..717063a 100644 --- a/src/components/AuthFileSettingsDialog.tsx +++ b/src/components/AuthFileSettingsDialog.tsx @@ -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; diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index aef86e2..52c844f 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1011,6 +1011,12 @@ export const buildProviderKeyRecord = ( return next; }; +export const providerDraftConnection = ( + draft: Pick, key?: ProviderKeyDraft, +) => effectiveProviderKey({ + headers: parseProviderHeaders(draft.headersText ?? ''), 'proxy-url': draft.proxyUrl, +}, key ? serializeProviderKey(key) : {}); + export const buildProviderGroupRecord = ( section: ProviderSection, draft: ProviderDraft, current?: Record, ): Record => { @@ -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( diff --git a/src/services/authFileSettings.ts b/src/services/authFileSettings.ts index dba304d..78819e4 100644 --- a/src/services/authFileSettings.ts +++ b/src/services/authFileSettings.ts @@ -20,6 +20,7 @@ export type AuthFileSettingsDraft = { headers: string; note: string; advanced: Record; + normalizeCloakMetadata?: string[]; }; const fail = (key: 'metadata' | 'headers' | 'weight' | 'priority') => { @@ -52,6 +53,27 @@ const headersFromText = (text: string): Record => { 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; }; diff --git a/src/services/modelService.ts b/src/services/modelService.ts index 8336549..a0d4b43 100644 --- a/src/services/modelService.ts +++ b/src/services/modelService.ts @@ -184,6 +184,7 @@ export async function fetchModels( authIndex?: string, customHeaders: Record = {}, timeoutMs?: number, + proxyUrl?: string, ): Promise { 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>('/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>('/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>('/requests/api-call', { + proxy_url: proxyUrl?.trim() || undefined, method: 'GET', url, }, { timeoutMs }); diff --git a/src/services/providerHealthCheck.ts b/src/services/providerHealthCheck.ts index 921b644..c4fc2db 100644 --- a/src/services/providerHealthCheck.ts +++ b/src/services/providerHealthCheck.ts @@ -35,6 +35,7 @@ export type ProviderHealthCheckOptions = { authIndex?: string; customHeaders?: Record; 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 = {}, timeoutMs = PROVIDER_HEALTH_TIMEOUT_MS, + proxyUrl?: string, ): Promise { 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, diff --git a/tests/authFileSettings.test.ts b/tests/authFileSettings.test.ts index 497d22b..fcd6073 100644 --- a/tests/authFileSettings.test.ts +++ b/tests/authFileSettings.test.ts @@ -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; + const writes: Record[] = []; + const api = { get: async () => metadata, patch: async (_path: string, body: Record) => { + 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, + }); + }); }); diff --git a/tests/coreOperations.v8.integration.test.ts b/tests/coreOperations.v8.integration.test.ts index 545eb8e..81e1e42 100644 --- a/tests/coreOperations.v8.integration.test.ts +++ b/tests/coreOperations.v8.integration.test.ts @@ -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>('/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>('/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: {} }); diff --git a/tests/providerHealthCheck.test.ts b/tests/providerHealthCheck.test.ts index 160b38e..f8e7f07 100644 --- a/tests/providerHealthCheck.test.ts +++ b/tests/providerHealthCheck.test.ts @@ -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'); diff --git a/tests/providerProxyRequests.test.ts b/tests/providerProxyRequests.test.ts new file mode 100644 index 0000000..26cce56 --- /dev/null +++ b/tests/providerProxyRequests.test.ts @@ -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[] = []; + mockIPC((_command, args) => { + const request = args?.request as Record; + 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[] = []; + mockIPC((command, args) => { + const request = args?.request as Record; + 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; + 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'); + }); +});