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:
1 parent
a57c81e4e1
commit
ade2d50a69
3 files changed
+163
-111
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user