fix(aliases): preserve payload rules for surviving channels

Scope payload cleanup to the selected OAuth protocol and retain rules still used by surviving OAuth aliases, API mappings, or real models. Preserve protocol-free shared rules while any matching mapping survives, and limit creation cleanup to its source protocol.

Validation: cargo test --locked tests::alias_delete (6 passed), including Codex/Claude collisions, wildcard selectors, same-protocol OAuth channels, and API mappings.
This commit is contained in:
lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent e84e576d1d
commit a57c81e4e1
2 files changed
+170 -11

No files matched your search

+108 -11
View File
@@ -1,4 +1,5 @@
use super::*;
use std::collections::BTreeSet;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct OAuthAliasChannel {
@@ -1630,8 +1631,9 @@ pub(crate) fn add_model_alias_to_yaml(
)?,
}
remove_thinking_payload_model(root, alias)?;
remove_speed_payload_model(root, alias)?;
let scope = AliasPayloadScope::for_protocol(&source.source.protocol);
remove_thinking_payload_model(root, alias, &scope)?;
remove_speed_payload_model(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)?;
@@ -1729,7 +1731,11 @@ pub(crate) fn add_speed_alias_to_yaml(
)?,
}
remove_speed_payload_model(root, alias)?;
remove_speed_payload_model(
root,
alias,
&AliasPayloadScope::for_protocol(&source.source.protocol),
)?;
let mut params_mapping = serde_norway::Mapping::new();
params_mapping.insert(
yaml_key("service_tier"),
@@ -1914,8 +1920,9 @@ pub(crate) fn remove_thinking_alias_from_yaml_for_channel(
if !removed {
return Err(format!("别名模型 {alias} 不存在,请刷新后重试"));
}
remove_thinking_payload_model(root, alias)?;
remove_speed_payload_model(root, alias)?;
let scope = AliasPayloadScope::after_removal(root, alias, oauth_channel);
remove_thinking_payload_model(root, alias, &scope)?;
remove_speed_payload_model(root, alias, &scope)?;
render_updated_core_yaml(&mut document, updated)
}
@@ -1943,7 +1950,8 @@ pub(crate) fn remove_speed_alias_from_yaml_for_channel(
if !removed {
return Err(format!("别名模型 {alias} 不存在,请刷新后重试"));
}
remove_speed_payload_model(root, alias)?;
let scope = AliasPayloadScope::after_removal(root, alias, oauth_channel);
remove_speed_payload_model(root, alias, &scope)?;
render_updated_core_yaml(&mut document, updated)
}
@@ -2095,9 +2103,96 @@ pub(crate) fn remove_config_speed_alias(
Ok(removed)
}
pub(crate) fn remove_thinking_payload_model(
struct AliasPayloadScope {
protocol: Option<String>,
preserved_protocols: BTreeSet<String>,
}
impl AliasPayloadScope {
fn for_protocol(protocol: &str) -> Self {
Self {
protocol: Some(protocol.to_string()),
preserved_protocols: BTreeSet::new(),
}
}
fn after_removal(root: &serde_norway::Mapping, alias: &str, channel: Option<&str>) -> Self {
let mut preserved_protocols = BTreeSet::new();
if let Some(channels) =
yaml_mapping_value(root, "oauth-model-alias").and_then(serde_norway::Value::as_mapping)
{
for (channel, entries) in channels {
let matches = entries.as_sequence().is_some_and(|entries| {
entries.iter().any(|entry| {
entry
.as_mapping()
.and_then(|entry| yaml_mapping_value(entry, "alias"))
.and_then(serde_norway::Value::as_str)
.is_some_and(|name| name.trim().eq_ignore_ascii_case(alias))
})
});
if matches {
preserved_protocols.insert(
oauth_alias_channel_details(channel.as_str().unwrap_or_default()).2,
);
}
}
}
for (section, protocol) in [
("codex-api-key", "codex"),
("openai-compatibility", "openai"),
("claude-api-key", "claude"),
("gemini-api-key", "gemini"),
] {
let matches = yaml_mapping_value(root, section)
.and_then(serde_norway::Value::as_sequence)
.into_iter()
.flatten()
.filter_map(serde_norway::Value::as_mapping)
.filter_map(|provider| yaml_mapping_value(provider, "models"))
.filter_map(serde_norway::Value::as_sequence)
.flatten()
.filter_map(configured_model_identity)
.any(|(_, name, _)| name.eq_ignore_ascii_case(alias));
if matches {
preserved_protocols.insert(protocol.to_string());
}
}
Self {
protocol: channel.map(|channel| oauth_alias_channel_details(channel).2),
preserved_protocols,
}
}
fn matches(&self, model: &serde_norway::Value, alias: &str) -> bool {
if !thinking_payload_model_name_matches(model, alias) {
return false;
}
let protocol = model
.as_mapping()
.and_then(|model| yaml_mapping_value(model, "protocol"))
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|protocol| !protocol.is_empty());
match protocol {
Some(protocol) => {
self.protocol
.as_ref()
.is_none_or(|target| target.eq_ignore_ascii_case(protocol))
&& !self
.preserved_protocols
.iter()
.any(|existing| existing.eq_ignore_ascii_case(protocol))
}
None => self.preserved_protocols.is_empty(),
}
}
}
fn remove_thinking_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") {
@@ -2134,7 +2229,7 @@ pub(crate) fn remove_thinking_payload_model(
.ok_or_else(|| "payload.override.models 必须是数组".to_string())?;
let before = models.len();
models
.retain(|model| !thinking_payload_model_name_matches(model, alias));
.retain(|model| !scope.matches(model, alias));
removed_from_rule = models.len() != before;
models_empty = models.is_empty();
}
@@ -2157,9 +2252,10 @@ pub(crate) fn remove_thinking_payload_model(
Ok(())
}
pub(crate) fn remove_speed_payload_model(
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") {
@@ -2185,8 +2281,9 @@ pub(crate) fn remove_speed_payload_model(
.ok_or_else(|| "payload.override.models 必须是数组".to_string())?;
let before = models.len();
models.retain(|model| {
!thinking_payload_model_matches(model, alias, "codex")
&& !thinking_payload_model_matches(model, alias, "openai")
!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();
+62
View File
@@ -63,3 +63,65 @@ fn plain_alias_deletion_preserves_real_models_with_the_same_name() {
);
assert!(remove_thinking_alias_from_yaml(&deleted, "plain").is_err());
}
#[test]
fn channel_deletion_preserves_other_protocols_and_shared_models() {
let original = "oauth-model-alias:\n codex: [{name: model-a, alias: shared}]\n claude: [{name: model-b, alias: shared}]\npayload:\n override:\n - models: [{name: shared, protocol: codex}]\n params: {reasoning.effort: high, service_tier: priority}\n - models: [{name: shared, protocol: claude}, {name: other, protocol: codex}]\n params: {output_config.effort: high}\n";
for speed in [false, true] {
let deleted = if speed {
remove_speed_alias_from_yaml_for_channel(original, "shared", Some("codex"))
} else {
remove_thinking_alias_from_yaml_for_channel(original, "shared", Some("codex"))
}
.unwrap();
let entries = thinking_aliases_from_yaml(&deleted).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].oauth_channel.as_deref(), Some("claude"));
assert_eq!(entries[0].effort.as_deref(), Some("high"));
assert_eq!(
json(&deleted)["payload"]["override"],
serde_json::json!([json(original)["payload"]["override"][1]])
);
}
}
#[test]
fn channel_deletion_preserves_wildcard_rules_for_surviving_aliases() {
for protocol in ["", ", protocol: ''"] {
let original = format!("oauth-model-alias:\n codex: [{{name: model-a, alias: shared}}]\n claude: [{{name: model-b, alias: shared}}]\npayload:\n override:\n - models: [{{name: shared{protocol}}}]\n params: {{reasoning.effort: high, output_config.effort: high, service_tier: priority}}\n");
for speed in [false, true] {
let deleted = if speed {
remove_speed_alias_from_yaml_for_channel(&original, "shared", Some("codex"))
} else {
remove_thinking_alias_from_yaml_for_channel(&original, "shared", Some("codex"))
}
.unwrap();
assert_eq!(json(&deleted)["payload"], json(&original)["payload"]);
let deleted =
remove_thinking_alias_from_yaml_for_channel(&deleted, "shared", Some("claude"))
.unwrap();
assert!(json(&deleted).get("payload").is_none());
}
}
}
#[test]
fn channel_deletion_preserves_rules_used_by_same_protocol_mappings() {
for surviving in [
" xai: [{name: model-b, alias: shared}]\n",
"codex-api-key:\n - models: [{name: model-b, alias: shared}]\n",
"codex-api-key:\n - models: [{name: shared}]\n",
] {
let original = format!("oauth-model-alias:\n codex: [{{name: model-a, alias: shared}}]\n{surviving}payload:\n override:\n - models: [{{name: shared, protocol: codex}}]\n params: {{reasoning.effort: high, service_tier: priority}}\n");
for speed in [false, true] {
let deleted = if speed {
remove_speed_alias_from_yaml_for_channel(&original, "shared", Some("codex"))
} else {
remove_thinking_alias_from_yaml_for_channel(&original, "shared", Some("codex"))
}
.unwrap();
assert_eq!(json(&deleted)["payload"], json(&original)["payload"]);
assert!(json(&deleted)["oauth-model-alias"].get("codex").is_none());
}
}
}