From 25ed568b276b914a72b0e3643bae43205418d83d Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 6 Oct 2026 15:32:00 +0800 Subject: [PATCH] fix(providers): stop writing disabled onto native API keys Codex, Claude, and Gemini keys reject the disabled field, so toggling them made the whole config fail to load. Keep that field for OpenAI-compatible providers, disable native keys with excluded-models, and repair existing configs before the core starts. --- src-tauri/src/core_config/settings.rs | 3 +- src-tauri/src/core_config/yaml.rs | 121 ++++++++++++++++++++++++++ src-tauri/src/tests/core_config.rs | 20 +++++ src/pages/ApiAccessPage.tsx | 5 +- tests/providerConfig.test.ts | 16 +++- 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 6535a49..1644caf 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -313,7 +313,8 @@ pub(crate) fn merge_core_config_yaml( // software-side opt-out should disable statistics when starting the kernel. let mut startup_config = config.clone(); startup_config.usage_statistics_enabled = config.usage_statistics_disabled != Some(true); - apply_gui_managed_settings(&base, &startup_config) + let managed = apply_gui_managed_settings(&base, &startup_config)?; + sanitize_unsupported_provider_disabled_yaml(&managed) } pub(crate) fn merge_core_config_fields( diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 4c79b59..e0f78fa 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -36,6 +36,127 @@ pub(crate) fn merge_core_config_for_start( Ok(config_path) } +/// Codex, Claude, Gemini and the other native key types have no disabled field. +/// OpenAI-compatible providers do. Drop the illegal field and keep the key +/// disabled through excluded-models: ["*"], which is how the enable switch works. +pub(crate) fn sanitize_unsupported_provider_disabled_yaml(content: &str) -> Result { + let original = serde_norway::from_str::(content) + .map_err(|error| format!("Failed to parse kernel configuration: {error}"))?; + let mut updated = original.clone(); + if !sanitize_unsupported_provider_disabled(&mut updated) { + return Ok(content.to_string()); + } + let file = content + .parse::() + .map_err(|error| format!("Failed to parse editable kernel configuration: {error}"))?; + let root = file + .document() + .and_then(|document| document.as_mapping()) + .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; + let mut removed = Vec::new(); + collect_removed_yaml_paths(&original, &updated, &mut Vec::new(), &mut removed); + for path in removed { + remove_yaml_edit_path(&root, &path); + } + let prepared = file.to_string(); + let before = serde_norway::from_str::(&prepared) + .map_err(|error| format!("Failed to parse repaired kernel configuration: {error}"))?; + render_yaml_value_changes(&prepared, &before, &updated) +} + +fn sanitize_unsupported_provider_disabled(document: &mut serde_norway::Value) -> bool { + let Some(keys) = document + .as_mapping_mut() + .and_then(|root| root.get_mut(yaml_key("api-keys"))) + .and_then(serde_norway::Value::as_mapping_mut) + else { + return false; + }; + let mut changed = false; + for (name, groups) in keys.iter_mut() { + if name.as_str() == Some("openai-compatibility") { + continue; + } + let Some(groups) = groups.as_sequence_mut() else { + continue; + }; + for group in groups { + changed |= strip_disabled_field(group); + let Some(entries) = group + .as_mapping_mut() + .and_then(|group| group.get_mut(yaml_key("keys"))) + .and_then(serde_norway::Value::as_sequence_mut) + else { + continue; + }; + for entry in entries { + changed |= strip_disabled_field(entry); + } + } + } + changed +} + +fn strip_disabled_field(value: &mut serde_norway::Value) -> bool { + let Some(mapping) = value.as_mapping_mut() else { + return false; + }; + let Some(disabled) = mapping.remove(yaml_key("disabled")) else { + return false; + }; + if disabled.as_bool() == Some(true) { + let excluded = mapping + .entry(yaml_key("excluded-models")) + .or_insert_with(|| serde_norway::Value::Sequence(Vec::new())); + match excluded { + serde_norway::Value::Sequence(models) => { + let present = models.iter().any(|model| model.as_str().is_some_and(|model| model.trim() == "*")); + if !present { + models.push(serde_norway::Value::String("*".to_string())); + } + } + _ => { + *excluded = serde_norway::Value::Sequence(vec![serde_norway::Value::String("*".to_string())]); + } + } + } + true +} + +fn collect_removed_yaml_paths( + before: &serde_norway::Value, + after: &serde_norway::Value, + path: &mut Vec, + removed: &mut Vec>, +) { + let (Some(before), Some(after)) = (before.as_mapping(), after.as_mapping()) else { + return; + }; + for (key, value) in before { + let Some(key) = key.as_str() else { continue }; + path.push(key.to_string()); + if let Some(next) = after.get(yaml_key(key)) { + collect_removed_yaml_paths(value, next, path, removed); + } else { + removed.push(path.clone()); + } + path.pop(); + } +} + +fn remove_yaml_edit_path(mapping: &yaml_edit::Mapping, path: &[String]) { + let Some((key, rest)) = path.split_first() else { + return; + }; + if rest.is_empty() { + mapping.remove(key.as_str()); + } else if let Some(node) = mapping.get(key.as_str()) { + if let Some(child) = node.as_mapping() { + remove_yaml_edit_path(&child, rest); + } + } +} + pub(crate) fn patch_core_network_settings(config: &GuiConfigFile) -> Result<(), String> { let _config_guard = lock_core_config_file()?; let install_dir = core_install_dir()?; diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index b24684b..b5cb357 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -2193,6 +2193,26 @@ fn startup_merge_can_shrink_template_api_key_sequence() { } + +#[test] +fn startup_merge_repairs_disabled_fields_rejected_by_native_provider_keys() { + let template = "config-version: 8\nhost: \"\"\nport: 8317\napi-keys:\n codex: []\n claude: []\n openai-compatibility: []\n"; + let current = "config-version: 8\nhost: 127.0.0.1\nport: 8317\napi-keys:\n codex:\n - name: codex-1\n excluded-models:\n - preview-*\n keys:\n - api-key: live\n - api-key: paused\n disabled: true\n excluded-models:\n - preview-*\n claude:\n - name: claude-1\n disabled: true\n keys:\n - api-key: claude-key\n openai-compatibility:\n - name: relay\n base-url: https://relay.example/v1\n disabled: true\n keys:\n - api-key: relay-key\n"; + let config = GuiConfigFile::default(); + let merged = merge_core_config_yaml(template, Some(current), &config).unwrap(); + let document = serde_norway::from_str::(&merged) + .unwrap_or_else(|error| panic!("invalid YAML: {error}\n{merged}")); + + assert!(serde_norway::to_string(&document["api-keys"]["codex"]).unwrap().contains("disabled") == false); + assert_eq!(document["api-keys"]["codex"][0]["keys"][1]["excluded-models"][0], "preview-*"); + assert_eq!(document["api-keys"]["codex"][0]["keys"][1]["excluded-models"][1], "*"); + assert!(document["api-keys"]["claude"][0].get("disabled").is_none()); + assert_eq!(document["api-keys"]["claude"][0]["excluded-models"][0], "*"); + assert_eq!(document["api-keys"]["claude"][0]["keys"][0]["api-key"], "claude-key"); + assert_eq!(document["api-keys"]["openai-compatibility"][0]["disabled"], true); + assert!(!merged.contains("disabled: true\n excluded-models")); +} + #[test] fn v8_signed_retry_weighted_routing_and_empty_host_roundtrip() { let content = "config-version: 8\nserver: {host: '', port: 8317}\nrouting: {strategy: weighted-round-robin, retry: {max-retry-interval: -1}}\nmanagement: {secret-key: ''}\n"; diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 647dc88..5563b07 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1184,12 +1184,13 @@ export const providerRecordWithDisabledState = ( disabled: boolean, ) => { const nextRecord = stripResponseFields(record); + // Only OpenAI-compatible providers have a native disabled field. Codex, Claude, + // Gemini, and the other key types reject it during YAML unmarshal. if (definitionFor(section).openAi) { nextRecord.disabled = disabled; return nextRecord; } - if (disabled) nextRecord.disabled = true; - else delete nextRecord.disabled; + delete nextRecord.disabled; const excludedModels = Array.isArray(nextRecord['excluded-models']) ? nextRecord['excluded-models'].map(String).filter((model) => model.trim() !== '*') diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index 5783f95..95604fd 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -590,11 +590,25 @@ describe('API 接入配置合并', () => { const enabled = providerRecordWithDisabledState('codex-api-key', disabled, false); expect(disabled['excluded-models']).toEqual(['preview-*', '*']); - expect(disabled.disabled).toBe(true); + expect(disabled.disabled).toBeUndefined(); expect(enabled['excluded-models']).toEqual(['preview-*']); expect(enabled.disabled).toBeUndefined(); }); + it('does not persist disabled on Codex keys because the core rejects that field', () => { + const disabled = providerRecordWithDisabledState('codex-api-key', { + 'api-key': 'codex-key', + disabled: true, + 'excluded-models': ['preview-*'], + }, true); + const enabled = providerRecordWithDisabledState('codex-api-key', disabled, false); + + expect(disabled).not.toHaveProperty('disabled'); + expect(disabled['excluded-models']).toEqual(['preview-*', '*']); + expect(enabled).not.toHaveProperty('disabled'); + expect(enabled['excluded-models']).toEqual(['preview-*']); + }); + it('列表按原生 disabled 标记显示每把密钥的启用状态', () => { const rows = providerRowsFromGroups('claude-api-key', [{ name: 'claude-1',