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:
1 parent
36b98d2d0a
commit
25ed568b27
5 files changed
+161
-4
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() !== '*')
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user