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',