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.
This commit is contained in:
lzt404 committed 2026-10-06 15:36:29 +08:00
1 parent 36b98d2d0a
commit 25ed568b27
5 files changed
+161 -4

No files matched your search

+2 -1
View File
@@ -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(
+121
View File
@@ -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<String, String> {
let original = serde_norway::from_str::<serde_norway::Value>(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::<yaml_edit::YamlFile>()
.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::<serde_norway::Value>(&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<String>,
removed: &mut Vec<Vec<String>>,
) {
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()?;
+20
View File
@@ -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::<serde_norway::Value>(&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";
+3 -2
View File
@@ -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() !== '*')
+15 -1
View File
@@ -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',