fix(aliases): clear raw options when deleting and recreating aliases

Use one cleanup path for override and override-raw across model and Fast alias creation and deletion. Remove supported effort and service-tier keys while preserving unrelated parameters, selector conditions, other protocols, and grouped models.

Validation: cargo test --locked alias_ (72 passed), including raw High/Fast deletion followed by Low/non-Fast recreation, orphaned raw rules, conditional grouped rules, and all supported effort keys.
This commit is contained in:
lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent a57c81e4e1
commit ade2d50a69
3 files changed
+163 -111

No files matched your search

+1 -1
View File
@@ -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",
+67 -110
View File
@@ -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(())
+95
View File
@@ -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}");
}
}
}