diff --git a/src-tauri/src/core_config/alias_edit.rs b/src-tauri/src/core_config/alias_edit.rs index 28b2b58..4bfe66b 100644 --- a/src-tauri/src/core_config/alias_edit.rs +++ b/src-tauri/src/core_config/alias_edit.rs @@ -388,7 +388,7 @@ pub(crate) fn edit_model_alias_in_yaml( render_updated_core_yaml(&mut document, updated) } -const ALIAS_EFFORT_KEYS: &[&str] = &[ +pub(crate) const ALIAS_EFFORT_KEYS: &[&str] = &[ "reasoning.effort", "reasoning_effort", "output_config.effort", diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index e251e50..0335d75 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -1632,8 +1632,7 @@ pub(crate) fn add_model_alias_to_yaml( } let scope = AliasPayloadScope::for_protocol(&source.source.protocol); - remove_thinking_payload_model(root, alias, &scope)?; - remove_speed_payload_model(root, alias, &scope)?; + remove_alias_payload_options(root, alias, &scope)?; if !effort.is_empty() { let mut params_mapping = serde_norway::Mapping::new(); insert_thinking_effort_params(&mut params_mapping, &source.source, effort)?; @@ -1731,7 +1730,7 @@ pub(crate) fn add_speed_alias_to_yaml( )?, } - remove_speed_payload_model( + remove_alias_payload_options( root, alias, &AliasPayloadScope::for_protocol(&source.source.protocol), @@ -1921,8 +1920,7 @@ pub(crate) fn remove_thinking_alias_from_yaml_for_channel( return Err(format!("别名模型 {alias} 不存在,请刷新后重试")); } let scope = AliasPayloadScope::after_removal(root, alias, oauth_channel); - remove_thinking_payload_model(root, alias, &scope)?; - remove_speed_payload_model(root, alias, &scope)?; + remove_alias_payload_options(root, alias, &scope)?; render_updated_core_yaml(&mut document, updated) } @@ -1951,7 +1949,7 @@ pub(crate) fn remove_speed_alias_from_yaml_for_channel( return Err(format!("别名模型 {alias} 不存在,请刷新后重试")); } let scope = AliasPayloadScope::after_removal(root, alias, oauth_channel); - remove_speed_payload_model(root, alias, &scope)?; + remove_alias_payload_options(root, alias, &scope)?; render_updated_core_yaml(&mut document, updated) } @@ -2189,119 +2187,78 @@ impl AliasPayloadScope { } } -fn remove_thinking_payload_model( +fn remove_alias_payload_options( root: &mut serde_norway::Mapping, alias: &str, scope: &AliasPayloadScope, ) -> Result<(), String> { - let mut remove_payload_section = false; - if let Some(payload) = yaml_mapping_value_mut(root, "payload") { - let payload = payload - .as_mapping_mut() - .ok_or_else(|| "payload 必须是 YAML 映射".to_string())?; - if let Some(override_rules) = yaml_mapping_value_mut(payload, "override") { - let override_rules = override_rules - .as_sequence_mut() - .ok_or_else(|| "payload.override 必须是数组".to_string())?; - let mut next_rules = Vec::with_capacity(override_rules.len()); - for mut rule in std::mem::take(override_rules) { - let mut removed_from_rule = false; - let mut models_empty = false; - if let Some(rule_mapping) = rule.as_mapping_mut() { - let has_effort = yaml_mapping_value(rule_mapping, "params") - .and_then(serde_norway::Value::as_mapping) - .is_some_and(|params| { - [ - "reasoning.effort", - "reasoning_effort", - "output_config.effort", - "generationConfig.thinkingConfig.thinkingLevel", - "thinking.effort", - ] - .into_iter() - .any(|key| yaml_mapping_value(params, key).is_some()) - || yaml_mapping_value(params, "thinking.type").is_some() - }); - if has_effort { - if let Some(models) = yaml_mapping_value_mut(rule_mapping, "models") { - let models = models - .as_sequence_mut() - .ok_or_else(|| "payload.override.models 必须是数组".to_string())?; - let before = models.len(); - models - .retain(|model| !scope.matches(model, alias)); - removed_from_rule = models.len() != before; - models_empty = models.is_empty(); - } - } - } - if !(removed_from_rule && models_empty) { - next_rules.push(rule); - } + let Some(payload) = yaml_mapping_value_mut(root, "payload") else { + return Ok(()); + }; + let payload = payload.as_mapping_mut().ok_or("payload 必须是 YAML 映射")?; + for section in ["override", "override-raw"] { + let Some(rules) = yaml_mapping_value_mut(payload, section) else { + continue; + }; + let rules = rules + .as_sequence_mut() + .ok_or_else(|| format!("payload.{section} 必须是数组"))?; + let mut next = Vec::with_capacity(rules.len()); + for rule in rules.iter() { + let Some(mapping) = rule.as_mapping() else { + next.push(rule.clone()); + continue; + }; + let Some(params) = + yaml_mapping_value(mapping, "params").and_then(serde_norway::Value::as_mapping) + else { + next.push(rule.clone()); + continue; + }; + let mut retained_params = params.clone(); + for key in ALIAS_EFFORT_KEYS.iter().copied().chain(["service_tier"]) { + retained_params.remove(yaml_key(key)); } - *override_rules = next_rules; - if override_rules.is_empty() { - payload.remove(yaml_key("override")); + if retained_params == *params { + next.push(rule.clone()); + continue; + } + let Some(models) = yaml_mapping_value(mapping, "models") else { + next.push(rule.clone()); + continue; + }; + let models = models + .as_sequence() + .ok_or_else(|| format!("payload.{section}.models 必须是数组"))?; + let (target, others): (Vec<_>, Vec<_>) = models + .iter() + .cloned() + .partition(|model| scope.matches(model, alias)); + if target.is_empty() { + next.push(rule.clone()); + continue; + } + if !others.is_empty() { + let mut shared = mapping.clone(); + shared.insert(yaml_key("models"), serde_norway::Value::Sequence(others)); + next.push(serde_norway::Value::Mapping(shared)); + } + if !retained_params.is_empty() { + let mut retained = mapping.clone(); + retained.insert(yaml_key("models"), serde_norway::Value::Sequence(target)); + retained.insert( + yaml_key("params"), + serde_norway::Value::Mapping(retained_params), + ); + next.push(serde_norway::Value::Mapping(retained)); } } - remove_payload_section = payload.is_empty(); - } - if remove_payload_section { - root.remove(yaml_key("payload")); - } - Ok(()) -} - -fn remove_speed_payload_model( - root: &mut serde_norway::Mapping, - alias: &str, - scope: &AliasPayloadScope, -) -> Result<(), String> { - let mut remove_payload_section = false; - if let Some(payload) = yaml_mapping_value_mut(root, "payload") { - let payload = payload - .as_mapping_mut() - .ok_or_else(|| "payload 必须是 YAML 映射".to_string())?; - if let Some(override_rules) = yaml_mapping_value_mut(payload, "override") { - let override_rules = override_rules - .as_sequence_mut() - .ok_or_else(|| "payload.override 必须是数组".to_string())?; - let mut next_rules = Vec::with_capacity(override_rules.len()); - for mut rule in std::mem::take(override_rules) { - let mut removed_from_rule = false; - let mut models_empty = false; - if let Some(rule_mapping) = rule.as_mapping_mut() { - let has_speed = yaml_mapping_value(rule_mapping, "params") - .and_then(serde_norway::Value::as_mapping) - .is_some_and(|params| yaml_mapping_value(params, "service_tier").is_some()); - if has_speed { - if let Some(models) = yaml_mapping_value_mut(rule_mapping, "models") { - let models = models - .as_sequence_mut() - .ok_or_else(|| "payload.override.models 必须是数组".to_string())?; - let before = models.len(); - models.retain(|model| { - !scope.matches(model, alias) - || (!thinking_payload_model_matches(model, alias, "codex") - && !thinking_payload_model_matches(model, alias, "openai")) - }); - removed_from_rule = models.len() != before; - models_empty = models.is_empty(); - } - } - } - if !(removed_from_rule && models_empty) { - next_rules.push(rule); - } - } - *override_rules = next_rules; - if override_rules.is_empty() { - payload.remove(yaml_key("override")); - } + *rules = next; + if rules.is_empty() { + payload.remove(yaml_key(section)); } - remove_payload_section = payload.is_empty(); } - if remove_payload_section { + if payload.is_empty() { root.remove(yaml_key("payload")); } Ok(()) diff --git a/src-tauri/src/tests/alias_delete.rs b/src-tauri/src/tests/alias_delete.rs index cc115ec..f4acf66 100644 --- a/src-tauri/src/tests/alias_delete.rs +++ b/src-tauri/src/tests/alias_delete.rs @@ -125,3 +125,98 @@ fn channel_deletion_preserves_rules_used_by_same_protocol_mappings() { } } } + +#[test] +fn raw_alias_options_do_not_survive_deletion_and_recreation() { + let original = "oauth-model-alias:\n codex: [{name: model-a, alias: reused, fork: true}]\npayload:\n override-raw:\n - models: [{name: reused, protocol: codex}]\n params: {reasoning.effort: '\"high\"', service_tier: '\"priority\"'}\n"; + let source = test_codex_oauth_thinking_source("model-a"); + for speed in [false, true] { + let deleted = if speed { + remove_speed_alias_from_yaml(original, "reused") + } else { + remove_thinking_alias_from_yaml(original, "reused") + } + .unwrap(); + assert!(json(&deleted).get("payload").is_none()); + let recreated = add_model_alias_to_yaml(&deleted, &source, "reused", "low", false).unwrap(); + let context = model_alias_edit_context(&recreated, "reused", &[]).unwrap(); + assert_eq!(context.effort.as_deref(), Some("low")); + assert!(!context.fast); + } +} + +#[test] +fn creation_cleans_orphaned_raw_options_for_model_and_speed_aliases() { + let original = "payload:\n override-raw:\n - models: [{name: reused, protocol: codex}]\n params: {reasoning.effort: '\"high\"', service_tier: '\"priority\"', temperature: '0.2'}\n - models: [{name: reused, protocol: claude}]\n params: {output_config.effort: '\"high\"'}\n"; + let source = test_codex_oauth_thinking_source("model-a"); + for speed in [false, true] { + let created = if speed { + add_speed_alias_to_yaml(original, &source, "reused") + } else { + add_model_alias_to_yaml(original, &source, "reused", "low", false) + } + .unwrap(); + let context = model_alias_edit_context(&created, "reused", &[]).unwrap(); + assert_eq!( + context.effort.as_deref(), + if speed { None } else { Some("low") } + ); + assert_eq!(context.fast, speed); + let rules = json(&created)["payload"]["override-raw"].clone(); + assert_eq!( + rules[0]["params"], + serde_json::json!({"temperature": "0.2"}) + ); + assert_eq!(rules[1], json(original)["payload"]["override-raw"][1]); + } +} + +#[test] +fn option_cleanup_preserves_shared_models_conditions_and_unrelated_parameters() { + for (section, effort, tier, temperature) in [ + ("override", "high", "priority", "0.2"), + ("override-raw", "'\"high\"'", "'\"priority\"'", "'0.2'"), + ] { + let original = format!("oauth-model-alias:\n codex: [{{name: model-a, alias: shared}}]\n claude: [{{name: model-b, alias: shared}}]\npayload:\n {section}:\n - models:\n - name: shared\n protocol: codex\n headers: {{X-Client: premium}}\n from-protocol: responses\n - name: other\n protocol: codex\n - name: shared\n protocol: claude\n params: {{reasoning.effort: {effort}, service_tier: {tier}, temperature: {temperature}}}\n match: [{{metadata.client: codex}}]\n default:\n - models: [{{name: shared}}]\n params: {{max_tokens: 4096}}\n default-raw:\n - models: [{{name: shared}}]\n params: {{top_p: '0.9'}}\n filter:\n - models: [{{name: shared}}]\n params: [metadata.internal]\n"); + let before = json(&original); + let deleted = + remove_thinking_alias_from_yaml_for_channel(&original, "shared", Some("codex")) + .unwrap(); + let after = json(&deleted); + let original_rule = &before["payload"][section][0]; + let mut shared = original_rule.clone(); + shared["models"].as_array_mut().unwrap().remove(0); + let mut retained = original_rule.clone(); + retained["models"].as_array_mut().unwrap().truncate(1); + retained["params"] + .as_object_mut() + .unwrap() + .remove("reasoning.effort"); + retained["params"] + .as_object_mut() + .unwrap() + .remove("service_tier"); + assert_eq!( + after["payload"][section], + serde_json::json!([shared, retained]) + ); + for unchanged in ["default", "default-raw", "filter"] { + assert_eq!(after["payload"][unchanged], before["payload"][unchanged]); + } + assert_eq!( + after["oauth-model-alias"]["claude"], + before["oauth-model-alias"]["claude"] + ); + } +} + +#[test] +fn raw_cleanup_removes_every_supported_effort_key_with_wildcard_protocols() { + for key in ALIAS_EFFORT_KEYS { + for protocol in ["", ", protocol: ''"] { + let original = format!("oauth-model-alias:\n codex: [{{name: model-a, alias: reused}}]\npayload:\n override-raw:\n - models: [{{name: reused{protocol}}}]\n params: {{{key}: '\"high\"', service_tier: '\"priority\"'}}\n"); + let deleted = remove_thinking_alias_from_yaml(&original, "reused").unwrap(); + assert!(json(&deleted).get("payload").is_none(), "{key}"); + } + } +}