From c8a45df6006a7666bcb7e2ff14f15187e0140a41 Mon Sep 17 00:00:00 2001 From: J3romee Date: Thu, 4 Jun 2026 15:58:21 -0400 Subject: [PATCH 01/10] Added empty capability object in lacuna example config --- examples/lacuna/lacuna.config.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/lacuna/lacuna.config.json b/examples/lacuna/lacuna.config.json index 1bfd53b..7abde9d 100644 --- a/examples/lacuna/lacuna.config.json +++ b/examples/lacuna/lacuna.config.json @@ -12,6 +12,10 @@ "baseurl": "https://api.anthropic.com", "authorization": "x-api-key", "apikey": "${ANTHROPIC_API_KEY}", + "capability": { + "models": {}, + "user_agents": [] + }, "compatibility": { "anthropic_messages": true } @@ -21,6 +25,10 @@ "baseurl": "https://bedrock-runtime.us-east-1.amazonaws.com", "authorization": "bearer", "apikey": "${BEDROCK_API_KEY}", + "capability": { + "models": {}, + "user_agents": [] + }, "compatibility": { "bedrock_model_invoke": true }, From 0916836c8d1c69085ed6a5aa75b0d308a316d916 Mon Sep 17 00:00:00 2001 From: J3romee Date: Thu, 4 Jun 2026 15:58:22 -0400 Subject: [PATCH 02/10] Added model_rules serialization utils --- src/serde_utils.rs | 141 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/src/serde_utils.rs b/src/serde_utils.rs index 20eba8d..20728a4 100644 --- a/src/serde_utils.rs +++ b/src/serde_utils.rs @@ -17,3 +17,144 @@ pub fn deserialize_patterns<'de, D: serde::Deserializer<'de>>( .map(|s| glob::Pattern::new(&s).map_err(serde::de::Error::custom)) .collect() } + +/// Exists only so the map value can be (de)serialized with `deny_unknown_fields` +/// (rejecting typos such as `{ "rewite": "x" }`) +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ModelRuleRepr { + #[serde(default, skip_serializing_if = "Option::is_none")] + rewrite: Option, +} + +/// Serialize `Vec` as an ordered object: `pattern => { "rewrite"?: target }`. +/// Preserves declaration order and omits the `rewrite` key when `None`. +pub fn serialize_model_rules( + rules: &[crate::config::ModelRule], + serializer: S, +) -> Result { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(rules.len()))?; + for rule in rules { + map.serialize_entry( + rule.pattern.as_str(), + &ModelRuleRepr { + rewrite: rule.rewrite.clone(), + }, + )?; + } + map.end() +} + +/// Deserialize the ordered object form (`pattern => { "rewrite"?: target }`) +/// into `Vec` to preserve JSON key order. +pub fn deserialize_model_rules<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct ModelRulesVisitor; + + impl<'de> serde::de::Visitor<'de> for ModelRulesVisitor { + type Value = Vec; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a map of model pattern to rule settings") + } + + fn visit_map>( + self, + mut map: M, + ) -> Result { + let mut rules = Vec::new(); + while let Some((key, value)) = map.next_entry::()? { + let pattern = glob::Pattern::new(&key).map_err(serde::de::Error::custom)?; + rules.push(crate::config::ModelRule { + pattern, + rewrite: value.rewrite, + }); + } + Ok(rules) + } + } + + deserializer.deserialize_map(ModelRulesVisitor) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ModelRule; + + fn parse(json: &str) -> Result, serde_json::Error> { + let mut de = serde_json::Deserializer::from_str(json); + deserialize_model_rules(&mut de) + } + + fn dump(rules: &[ModelRule]) -> String { + let mut buf = Vec::new(); + let mut ser = serde_json::Serializer::new(&mut buf); + serialize_model_rules(rules, &mut ser).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn preserves_declaration_order_when_deserializing_model_rules_() { + let rules = parse(r#"{ "c-*": {}, "a-*": {}, "b-*": {} }"#).unwrap(); + assert_eq!( + rules, + vec![ + ModelRule { + pattern: glob::Pattern::new("c-*").unwrap(), + rewrite: None, + }, + ModelRule { + pattern: glob::Pattern::new("a-*").unwrap(), + rewrite: None, + }, + ModelRule { + pattern: glob::Pattern::new("b-*").unwrap(), + rewrite: None, + } + ] + ); + + let patterns: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect(); + assert_eq!(patterns, vec!["c-*", "a-*", "b-*"]); + } + + #[test] + fn deserialization_rejected_when_model_rule_pattern_is_invalid() { + let err = parse(r#"{ "[invalid": {} }"#).unwrap_err().to_string(); + assert!( + err.contains("Pattern syntax error"), + "expected pattern syntax error, got: {err}" + ); + } + + #[test] + fn deserialization_rejected_when_model_rule_has_unknown_field() { + let err = parse(r#"{ "a-*": { "something": "x" } }"#) + .unwrap_err() + .to_string(); + assert!( + err.contains("unknown field"), + "expected unknown field error, got: {err}" + ); + } + + #[test] + fn serialization_deserialization_round_trip() { + let rules = vec![ + ModelRule { + pattern: glob::Pattern::new("a-*").unwrap(), + rewrite: Some("X".to_string()), + }, + ModelRule { + pattern: glob::Pattern::new("b-*").unwrap(), + rewrite: None, + }, + ]; + assert_eq!(dump(&rules), r#"{"a-*":{"rewrite":"X"},"b-*":{}}"#); + // And it round-trips back to the same rules. + assert_eq!(parse(&dump(&rules)).unwrap(), rules); + } +} From 0cff55bdfb98534f5a098117627083ae43027db3 Mon Sep 17 00:00:00 2001 From: J3romee Date: Thu, 4 Jun 2026 15:58:22 -0400 Subject: [PATCH 03/10] Parsing the new config structure --- src/config.rs | 118 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 83 insertions(+), 35 deletions(-) diff --git a/src/config.rs b/src/config.rs index 42380ec..638db60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,15 +29,22 @@ pub struct Lacuna { pub user_agents: Vec, } +#[derive(Debug, Clone, PartialEq)] +pub struct ModelRule { + pub pattern: glob::Pattern, + pub rewrite: Option, +} + #[derive(Debug, Deserialize, Serialize, PartialEq, Default)] #[serde(deny_unknown_fields)] pub struct Capability { #[serde( + rename = "models", default, - serialize_with = "crate::serde_utils::serialize_patterns", - deserialize_with = "crate::serde_utils::deserialize_patterns" + serialize_with = "crate::serde_utils::serialize_model_rules", + deserialize_with = "crate::serde_utils::deserialize_model_rules" )] - pub models: Vec, + pub model_rules: Vec, #[serde( default, @@ -131,7 +138,10 @@ mod tests { "name": "OpenAI", "baseurl": "https://api.openai.com/v1", "capability": { - "models": ["gpt-4o", "gpt-4o-mini"], + "models": { + "gpt-4o": {}, + "gpt-4o-mini": {} + }, "user_agents": ["claude-code"] }, "apikey": "sk-test", @@ -144,7 +154,11 @@ mod tests { "anthropic": { "name": "Anthropic", "baseurl": "https://api.anthropic.com", - "capability": { "models": ["claude-sonnet-4-20250514"] }, + "capability": { + "models": { + "claude-sonnet-4-20250514": {} + } + }, "apikey": "sk-ant-test", "authorization": "x-api-key", "compatibility": { @@ -155,7 +169,11 @@ mod tests { "gemini": { "name": "Gemini", "baseurl": "https://generativelanguage.googleapis.com", - "capability": { "models": ["gemini-2.0-flash"] }, + "capability": { + "models": { + "gemini-2.0-flash": {} + } + }, "authorization": "x-goog-api-key", "headers": { "x-some-header": "foo" @@ -170,10 +188,16 @@ mod tests { assert_eq!(openai.name, "OpenAI"); assert_eq!(openai.baseurl, "https://api.openai.com/v1"); assert_eq!( - openai.capability.models, + openai.capability.model_rules, vec![ - glob::Pattern::new("gpt-4o").unwrap(), - glob::Pattern::new("gpt-4o-mini").unwrap() + ModelRule { + pattern: glob::Pattern::new("gpt-4o").unwrap(), + rewrite: None, + }, + ModelRule { + pattern: glob::Pattern::new("gpt-4o-mini").unwrap(), + rewrite: None, + }, ] ); assert_eq!( @@ -208,7 +232,11 @@ mod tests { "minimal": { "name": "Minimal", "baseurl": "https://example.com", - "capability": { "models": ["model-1"] } + "capability": { + "models": { + "model-1": {} + } + } } } }"#; @@ -275,7 +303,11 @@ mod tests { "baseurl": "https://api.openai.com/", "apikey": "YOUR_OPENAI_KEY", "capability": { - "models": ["gpt-5", "gpt-5-mini", "gpt-4.1"], + "models": { + "gpt-5": {}, + "gpt-5-mini": {}, + "gpt-4.1": {} + }, "user_agents": ["claude-code"] }, "name": "OpenAI", @@ -290,12 +322,12 @@ mod tests { "apikey": "bedrock-api-key-xxx", "authorization": "bearer", "capability": { - "models": [ - "us.anthropic.claude-haiku-4-5-20251001-v1:0", - "us.anthropic.claude-sonnet-4-5-20250929-v1:0", - "us.anthropic.claude-opus-4-5-20251101-v1:0", - "us.anthropic.claude-opus-4-6-v1" - ] + "models": { + "us.anthropic.claude-haiku-4-5-20251001-v1:0": {}, + "us.anthropic.claude-sonnet-4-5-20250929-v1:0": {}, + "us.anthropic.claude-opus-4-5*": { "rewrite": "arn:aws:bedrock:us-east-1:409905535292:application-inference-profile/11cprf2uimr9" }, + "us.anthropic.claude-opus-4-6-v1": {} + } }, "compatibility": { "bedrock_model_invoke": true @@ -305,7 +337,13 @@ mod tests { "baseurl": "https://api.anthropic.com", "apikey": "YOUR_ANTHROPIC_KEY", "authorization": "x-api-key", - "capability": { "models": ["claude-sonnet-4-5", "claude-haiku-4-5", "claude-opus-4-5"] }, + "capability": { + "models": { + "claude-sonnet-4-5": {}, + "claude-haiku-4-5": {}, + "claude-opus-4-5": {} + } + }, "compatibility": { "openai_chat": false, "anthropic_messages": true @@ -315,7 +353,12 @@ mod tests { "baseurl": "https://generativelanguage.googleapis.com", "apikey": "YOUR_GEMINI_KEY", "authorization": "x-goog-api-key", - "capability": { "models": ["gemini-2.5-flash", "gemini-2.5-pro"] }, + "capability": { + "models": { + "gemini-2.5-flash": {}, + "gemini-2.5-pro": {} + } + }, "name": "Google Gemini", "compatibility": { "openai_chat": false, @@ -327,16 +370,16 @@ mod tests { "authorization": "bearer", "apikey": "keyfile::ba3..3kb.data...67", "capability": { - "models": [ - "gemini-2.0-flash-exp", - "gemini-2.5-flash", - "gemini-2.5-flash-image", - "gemini-2.5-pro", - "claude-opus-4-5@20251101", - "claude-haiku-4-5@20251001", - "claude-sonnet-4-5@20250929", - "claude-opus-4-6" - ] + "models": { + "gemini-2.0-flash-exp": {}, + "gemini-2.5-flash": {}, + "gemini-2.5-flash-image": {}, + "gemini-2.5-pro": {}, + "claude-opus-4-5@20251101": {}, + "claude-haiku-4-5@20251001": {}, + "claude-sonnet-4-5@20250929": {}, + "claude-opus-4-6": {} + } }, "compatibility": { // Gemini model support @@ -349,17 +392,22 @@ mod tests { "baseurl": "https://openrouter.ai/api/", "apikey": "YOUR_OPENROUTER_KEY", "capability": { - "models": [ - "qwen/qwen3-235b-a22b-2507", - "google/gemini-2.5-pro-preview", - "x-ai/grok-code-fast-1" - ] + "models": { + "qwen/qwen3-235b-a22b-2507": {}, + "google/gemini-2.5-pro-preview": {}, + "x-ai/grok-code-fast-1": {} + } } }, "private": { "baseurl": "YOUR_PRIVATE_LLM_URL", "tailnet": true, - "capability": { "models": ["qwen3-coder-30b", "llama-3.1-70b"] } + "capability": { + "models": { + "qwen3-coder-30b": {}, + "llama-3.1-70b": {} + } + } } } } From 077e65da2ed5da712d19fc91d52dcaa6f2b956d4 Mon Sep 17 00:00:00 2001 From: J3romee Date: Thu, 4 Jun 2026 15:58:22 -0400 Subject: [PATCH 04/10] Using model_rules in provider --- src/provider/mod.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 9e82d09..ffbdde9 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -60,7 +60,7 @@ pub struct Provider { #[allow(dead_code)] pub name: String, pub baseurl: reqwest::Url, - pub models: Vec, + pub model_rules: Vec, pub user_agents: Vec, pub authorizer: Authorization, client: reqwest::Client, @@ -74,10 +74,16 @@ impl Provider { pub fn from_config(key: &str, config: &config::Provider) -> Result { let baseurl = reqwest::Url::parse(&config.baseurl)?; let authenticator = build_authenticator(&config.authorization, &config.apikey); + let model_patterns: Vec = config + .capability + .model_rules + .iter() + .map(|r| r.pattern.clone()) + .collect(); let authorizer = Authorization { rules: vec![Rule { providers: vec![], - models: config.capability.models.clone(), + models: model_patterns, user_agents: config.capability.user_agents.clone(), }], }; @@ -85,7 +91,7 @@ impl Provider { key: key.to_owned(), name: config.name.clone(), baseurl, - models: config.capability.models.clone(), + model_rules: config.capability.model_rules.clone(), user_agents: config.capability.user_agents.clone(), authorizer, client: reqwest::Client::new(), @@ -160,7 +166,10 @@ mod tests { description: String::new(), baseurl: baseurl.to_owned(), capability: config::Capability { - models: vec![glob::Pattern::new("model-1").unwrap()], + model_rules: vec![config::ModelRule { + pattern: glob::Pattern::new("model-1").unwrap(), + rewrite: None, + }], user_agents: vec![], }, apikey: apikey.to_owned(), From 6acb23347ccab7ed050ffda244f62222f7c77f9d Mon Sep 17 00:00:00 2001 From: J3romee Date: Thu, 4 Jun 2026 15:58:22 -0400 Subject: [PATCH 05/10] Updated test utils --- src/test_utils.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/test_utils.rs b/src/test_utils.rs index a863fa8..59738df 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -46,6 +46,13 @@ pub fn make_provider_with_models( compat: provider::compatibility::Compatibility, models: Vec, ) -> provider::Provider { + let model_rules = models + .into_iter() + .map(|pattern| config::ModelRule { + pattern, + rewrite: None, + }) + .collect(); provider::Provider::from_config( key, &config::Provider { @@ -53,7 +60,7 @@ pub fn make_provider_with_models( description: String::new(), baseurl: baseurl.to_owned(), capability: config::Capability { - models, + model_rules, user_agents: vec![], }, apikey: String::new(), From 776e155dbb0684b82fd2ea0dbbc22313529938c2 Mon Sep 17 00:00:00 2001 From: J3romee Date: Thu, 4 Jun 2026 15:58:22 -0400 Subject: [PATCH 06/10] Renamed helper fct to 'make_provider_with_model_rules' --- src/http_handlers/proxy.rs | 16 +++++++++++----- src/test_utils.rs | 13 +++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/http_handlers/proxy.rs b/src/http_handlers/proxy.rs index c852f87..a0984f9 100644 --- a/src/http_handlers/proxy.rs +++ b/src/http_handlers/proxy.rs @@ -211,7 +211,7 @@ mod tests { use crate::provider::ProviderManager; use crate::provider::compatibility::Compatibility; - use crate::test_utils::{make_provider, make_provider_with_models, spawn_echo_server}; + use crate::test_utils::{make_provider, make_provider_with_model_rules, spawn_echo_server}; #[tokio::test] async fn unmatched_path_returns_404() { @@ -412,18 +412,24 @@ mod tests { compat.bedrock_model_invoke = true; let mut manager = ProviderManager::new(); - manager.add(make_provider_with_models( + manager.add(make_provider_with_model_rules( "provider-a", &format!("http://{addr}"), compat.clone(), - vec![glob::Pattern::new("*").unwrap()], + vec![crate::config::ModelRule { + pattern: glob::Pattern::new("*").unwrap(), + rewrite: None, + }], )); - manager.add(make_provider_with_models( + manager.add(make_provider_with_model_rules( "provider-b", &format!("http://{addr}"), compat, - vec![glob::Pattern::new("gpt-4o").unwrap()], + vec![crate::config::ModelRule { + pattern: glob::Pattern::new("gpt-4o").unwrap(), + rewrite: None, + }], )); let app = crate::app::AppBuilder::new().manager(manager).build(); diff --git a/src/test_utils.rs b/src/test_utils.rs index 59738df..d29bb91 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -37,22 +37,15 @@ pub fn make_provider( baseurl: &str, compat: provider::compatibility::Compatibility, ) -> provider::Provider { - make_provider_with_models(key, baseurl, compat, vec![]) + make_provider_with_model_rules(key, baseurl, compat, vec![]) } -pub fn make_provider_with_models( +pub fn make_provider_with_model_rules( key: &str, baseurl: &str, compat: provider::compatibility::Compatibility, - models: Vec, + model_rules: Vec, ) -> provider::Provider { - let model_rules = models - .into_iter() - .map(|pattern| config::ModelRule { - pattern, - rewrite: None, - }) - .collect(); provider::Provider::from_config( key, &config::Provider { From 6b5f379f03ccde54450ce786a39a528c73cdfeb6 Mon Sep 17 00:00:00 2001 From: J3romee Date: Thu, 4 Jun 2026 15:58:22 -0400 Subject: [PATCH 07/10] Updated readme --- README.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e3b44e9..17182b9 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,9 @@ The provided configuration file may include environment variable substitution us "authorization": "x-api-key", "apikey": "${ANTHROPIC_API_KEY}", "capability": { - "models": ["claude-*"], + "models": { + "claude-*": {} + }, "user_agents": ["claude-code"] }, "compatibility": { @@ -120,9 +122,20 @@ When `capability` is set on a provider, Lacuna will use it to filter the request "anthropic": { "baseurl": "https://api.anthropic.com", "capability": { - "models": ["claude-*"], + "models": { + "claude-*": {} + }, "user_agents": ["claude-code"] } + }, + "bedrock": { + "baseurl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "capability": { + "models": { + "us.anthropic.claude-opus-4-5*": { "rewrite": "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abcd1234567" }, + "us.anthropic.claude-haiku-4-5*": {} + } + } } } } @@ -132,7 +145,13 @@ In the above example: - The `anthropic` provider will only allow requests with a User-Agent that match Lacuna's built-in `claude-code` pattern. - The `anthropic` provider may only be used with models that match the `claude-*` glob pattern. +`capability.models` maps each model glob pattern to a settings object with an optional `rewrite`: +- `{}` ⇒ the model is allowed and forwarded as-is. +- `{ "rewrite": "" }` ⇒ the model is allowed and the upstream is called with `` instead. In the example, requests for `us.anthropic.claude-opus-4-5*` are sent to Bedrock as the configured application-inference-profile ARN. + Notes: +- Ordering matters: the **first matching pattern wins** for rewrite resolution. Authorization is order-independent (any match allows). +- An empty or omitted `models` allows all models (no rewrite). - Built-in User-Agent patterns can be found in [src/user_agent.rs](src/user_agent.rs). ## 📦 Dev Dependencies From 97027baa360091375a581b7425c3307dfca57e89 Mon Sep 17 00:00:00 2001 From: J3romee Date: Thu, 4 Jun 2026 17:20:49 -0400 Subject: [PATCH 08/10] Moved model_rules parsing out of serde_utils to config --- src/config.rs | 144 ++++++++++++++++++++++++++++++++++++++++++++- src/serde_utils.rs | 141 -------------------------------------------- 2 files changed, 142 insertions(+), 143 deletions(-) diff --git a/src/config.rs b/src/config.rs index 638db60..d81a092 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,14 +35,75 @@ pub struct ModelRule { pub rewrite: Option, } +/// Exists only so the map value can be (de)serialized with `deny_unknown_fields` +/// (rejecting typos such as `{ "rewite": "x" }`) +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ModelRuleRepr { + #[serde(default, skip_serializing_if = "Option::is_none")] + rewrite: Option, +} + +/// Serialize `Vec` as an ordered object: `pattern => { "rewrite"?: target }`. +/// Preserves declaration order and omits the `rewrite` key when `None`. +pub(crate) fn serialize_model_rules( + rules: &[ModelRule], + serializer: S, +) -> Result { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(rules.len()))?; + for rule in rules { + map.serialize_entry( + rule.pattern.as_str(), + &ModelRuleRepr { + rewrite: rule.rewrite.clone(), + }, + )?; + } + map.end() +} + +/// Deserialize the ordered object form (`pattern => { "rewrite"?: target }`) +/// into `Vec` to preserve JSON key order. +pub(crate) fn deserialize_model_rules<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct ModelRulesVisitor; + + impl<'de> serde::de::Visitor<'de> for ModelRulesVisitor { + type Value = Vec; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a map of model pattern to rule settings") + } + + fn visit_map>( + self, + mut map: M, + ) -> Result { + let mut rules = Vec::new(); + while let Some((key, value)) = map.next_entry::()? { + let pattern = glob::Pattern::new(&key).map_err(serde::de::Error::custom)?; + rules.push(ModelRule { + pattern, + rewrite: value.rewrite, + }); + } + Ok(rules) + } + } + + deserializer.deserialize_map(ModelRulesVisitor) +} + #[derive(Debug, Deserialize, Serialize, PartialEq, Default)] #[serde(deny_unknown_fields)] pub struct Capability { #[serde( rename = "models", default, - serialize_with = "crate::serde_utils::serialize_model_rules", - deserialize_with = "crate::serde_utils::deserialize_model_rules" + serialize_with = "serialize_model_rules", + deserialize_with = "deserialize_model_rules" )] pub model_rules: Vec, @@ -130,6 +191,85 @@ impl Config { mod tests { use super::*; + fn parse_model_rules(json: &str) -> Result, serde_json::Error> { + let mut de = serde_json::Deserializer::from_str(json); + deserialize_model_rules(&mut de) + } + + fn dump_model_rules(rules: &[ModelRule]) -> String { + let mut buf = Vec::new(); + let mut ser = serde_json::Serializer::new(&mut buf); + serialize_model_rules(rules, &mut ser).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn preserves_declaration_order_when_deserializing_model_rules() { + let rules = parse_model_rules(r#"{ "c-*": {}, "a-*": {}, "b-*": {} }"#).unwrap(); + assert_eq!( + rules, + vec![ + ModelRule { + pattern: glob::Pattern::new("c-*").unwrap(), + rewrite: None, + }, + ModelRule { + pattern: glob::Pattern::new("a-*").unwrap(), + rewrite: None, + }, + ModelRule { + pattern: glob::Pattern::new("b-*").unwrap(), + rewrite: None, + } + ] + ); + + let patterns: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect(); + assert_eq!(patterns, vec!["c-*", "a-*", "b-*"]); + } + + #[test] + fn deserialization_rejected_when_model_rule_pattern_is_invalid() { + let err = parse_model_rules(r#"{ "[invalid": {} }"#) + .unwrap_err() + .to_string(); + assert!( + err.contains("Pattern syntax error"), + "expected pattern syntax error, got: {err}" + ); + } + + #[test] + fn deserialization_rejected_when_model_rule_has_unknown_field() { + let err = parse_model_rules(r#"{ "a-*": { "something": "x" } }"#) + .unwrap_err() + .to_string(); + assert!( + err.contains("unknown field"), + "expected unknown field error, got: {err}" + ); + } + + #[test] + fn serialization_deserialization_round_trip() { + let rules = vec![ + ModelRule { + pattern: glob::Pattern::new("a-*").unwrap(), + rewrite: Some("X".to_string()), + }, + ModelRule { + pattern: glob::Pattern::new("b-*").unwrap(), + rewrite: None, + }, + ]; + assert_eq!( + dump_model_rules(&rules), + r#"{"a-*":{"rewrite":"X"},"b-*":{}}"# + ); + // And it round-trips back to the same rules. + assert_eq!(parse_model_rules(&dump_model_rules(&rules)).unwrap(), rules); + } + #[test] fn deserialize_json_config() { let json = r#"{ diff --git a/src/serde_utils.rs b/src/serde_utils.rs index 20728a4..20eba8d 100644 --- a/src/serde_utils.rs +++ b/src/serde_utils.rs @@ -17,144 +17,3 @@ pub fn deserialize_patterns<'de, D: serde::Deserializer<'de>>( .map(|s| glob::Pattern::new(&s).map_err(serde::de::Error::custom)) .collect() } - -/// Exists only so the map value can be (de)serialized with `deny_unknown_fields` -/// (rejecting typos such as `{ "rewite": "x" }`) -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ModelRuleRepr { - #[serde(default, skip_serializing_if = "Option::is_none")] - rewrite: Option, -} - -/// Serialize `Vec` as an ordered object: `pattern => { "rewrite"?: target }`. -/// Preserves declaration order and omits the `rewrite` key when `None`. -pub fn serialize_model_rules( - rules: &[crate::config::ModelRule], - serializer: S, -) -> Result { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(Some(rules.len()))?; - for rule in rules { - map.serialize_entry( - rule.pattern.as_str(), - &ModelRuleRepr { - rewrite: rule.rewrite.clone(), - }, - )?; - } - map.end() -} - -/// Deserialize the ordered object form (`pattern => { "rewrite"?: target }`) -/// into `Vec` to preserve JSON key order. -pub fn deserialize_model_rules<'de, D: serde::Deserializer<'de>>( - deserializer: D, -) -> Result, D::Error> { - struct ModelRulesVisitor; - - impl<'de> serde::de::Visitor<'de> for ModelRulesVisitor { - type Value = Vec; - - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("a map of model pattern to rule settings") - } - - fn visit_map>( - self, - mut map: M, - ) -> Result { - let mut rules = Vec::new(); - while let Some((key, value)) = map.next_entry::()? { - let pattern = glob::Pattern::new(&key).map_err(serde::de::Error::custom)?; - rules.push(crate::config::ModelRule { - pattern, - rewrite: value.rewrite, - }); - } - Ok(rules) - } - } - - deserializer.deserialize_map(ModelRulesVisitor) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::ModelRule; - - fn parse(json: &str) -> Result, serde_json::Error> { - let mut de = serde_json::Deserializer::from_str(json); - deserialize_model_rules(&mut de) - } - - fn dump(rules: &[ModelRule]) -> String { - let mut buf = Vec::new(); - let mut ser = serde_json::Serializer::new(&mut buf); - serialize_model_rules(rules, &mut ser).unwrap(); - String::from_utf8(buf).unwrap() - } - - #[test] - fn preserves_declaration_order_when_deserializing_model_rules_() { - let rules = parse(r#"{ "c-*": {}, "a-*": {}, "b-*": {} }"#).unwrap(); - assert_eq!( - rules, - vec![ - ModelRule { - pattern: glob::Pattern::new("c-*").unwrap(), - rewrite: None, - }, - ModelRule { - pattern: glob::Pattern::new("a-*").unwrap(), - rewrite: None, - }, - ModelRule { - pattern: glob::Pattern::new("b-*").unwrap(), - rewrite: None, - } - ] - ); - - let patterns: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect(); - assert_eq!(patterns, vec!["c-*", "a-*", "b-*"]); - } - - #[test] - fn deserialization_rejected_when_model_rule_pattern_is_invalid() { - let err = parse(r#"{ "[invalid": {} }"#).unwrap_err().to_string(); - assert!( - err.contains("Pattern syntax error"), - "expected pattern syntax error, got: {err}" - ); - } - - #[test] - fn deserialization_rejected_when_model_rule_has_unknown_field() { - let err = parse(r#"{ "a-*": { "something": "x" } }"#) - .unwrap_err() - .to_string(); - assert!( - err.contains("unknown field"), - "expected unknown field error, got: {err}" - ); - } - - #[test] - fn serialization_deserialization_round_trip() { - let rules = vec![ - ModelRule { - pattern: glob::Pattern::new("a-*").unwrap(), - rewrite: Some("X".to_string()), - }, - ModelRule { - pattern: glob::Pattern::new("b-*").unwrap(), - rewrite: None, - }, - ]; - assert_eq!(dump(&rules), r#"{"a-*":{"rewrite":"X"},"b-*":{}}"#); - // And it round-trips back to the same rules. - assert_eq!(parse(&dump(&rules)).unwrap(), rules); - } -} From 77c375fa68d582a0d9ded6eb7ee7fe33cb2842b6 Mon Sep 17 00:00:00 2001 From: J3romee Date: Mon, 8 Jun 2026 16:51:24 -0400 Subject: [PATCH 09/10] Update README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 17182b9..e802eb1 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,6 @@ In the above example: - `{ "rewrite": "" }` ⇒ the model is allowed and the upstream is called with `` instead. In the example, requests for `us.anthropic.claude-opus-4-5*` are sent to Bedrock as the configured application-inference-profile ARN. Notes: -- Ordering matters: the **first matching pattern wins** for rewrite resolution. Authorization is order-independent (any match allows). - An empty or omitted `models` allows all models (no rewrite). - Built-in User-Agent patterns can be found in [src/user_agent.rs](src/user_agent.rs). From b52f36f2f005fdbd47bf8741e65f240112222343 Mon Sep 17 00:00:00 2001 From: J3romee Date: Mon, 8 Jun 2026 17:17:44 -0400 Subject: [PATCH 10/10] Sort rules to be independant of the json declaration order --- src/config.rs | 75 +++++++++++++++++++-------------------------------- 1 file changed, 27 insertions(+), 48 deletions(-) diff --git a/src/config.rs b/src/config.rs index d81a092..606820a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -45,7 +45,6 @@ struct ModelRuleRepr { } /// Serialize `Vec` as an ordered object: `pattern => { "rewrite"?: target }`. -/// Preserves declaration order and omits the `rewrite` key when `None`. pub(crate) fn serialize_model_rules( rules: &[ModelRule], serializer: S, @@ -63,37 +62,21 @@ pub(crate) fn serialize_model_rules( map.end() } -/// Deserialize the ordered object form (`pattern => { "rewrite"?: target }`) -/// into `Vec` to preserve JSON key order. +/// Deserialize the map (`pattern => { "rewrite"?: target }`) into rules sorted +/// by pattern, so iteration order is deterministic regardless of the JSON key order. pub(crate) fn deserialize_model_rules<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { - struct ModelRulesVisitor; - - impl<'de> serde::de::Visitor<'de> for ModelRulesVisitor { - type Value = Vec; - - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("a map of model pattern to rule settings") - } - - fn visit_map>( - self, - mut map: M, - ) -> Result { - let mut rules = Vec::new(); - while let Some((key, value)) = map.next_entry::()? { - let pattern = glob::Pattern::new(&key).map_err(serde::de::Error::custom)?; - rules.push(ModelRule { - pattern, - rewrite: value.rewrite, - }); - } - Ok(rules) - } - } - - deserializer.deserialize_map(ModelRulesVisitor) + let map = std::collections::BTreeMap::::deserialize(deserializer)?; + map.into_iter() + .map(|(key, value)| { + let pattern = glob::Pattern::new(&key).map_err(serde::de::Error::custom)?; + Ok(ModelRule { + pattern, + rewrite: value.rewrite, + }) + }) + .collect() } #[derive(Debug, Deserialize, Serialize, PartialEq, Default)] @@ -204,28 +187,24 @@ mod tests { } #[test] - fn preserves_declaration_order_when_deserializing_model_rules() { - let rules = parse_model_rules(r#"{ "c-*": {}, "a-*": {}, "b-*": {} }"#).unwrap(); + fn deserializes_model_rules_to_deterministic_sorted_order_regardless_of_input_order() { + let a = parse_model_rules(r#"{ "c-*": {}, "a-*": {}, "b-*": {} }"#).unwrap(); + let b = parse_model_rules(r#"{ "b-*": {}, "c-*": {}, "a-*": {} }"#).unwrap(); + assert_eq!(a, b); + let patterns: Vec<&str> = a.iter().map(|r| r.pattern.as_str()).collect(); + assert_eq!(patterns, vec!["a-*", "b-*", "c-*"]); + } + + #[test] + fn duplicate_model_rule_patterns_are_deduped_last_wins() { + let rules = parse_model_rules(r#"{ "a-*": { "rewrite": "X" }, "a-*": {} }"#).unwrap(); assert_eq!( rules, - vec![ - ModelRule { - pattern: glob::Pattern::new("c-*").unwrap(), - rewrite: None, - }, - ModelRule { - pattern: glob::Pattern::new("a-*").unwrap(), - rewrite: None, - }, - ModelRule { - pattern: glob::Pattern::new("b-*").unwrap(), - rewrite: None, - } - ] + vec![ModelRule { + pattern: glob::Pattern::new("a-*").unwrap(), + rewrite: None, + }] ); - - let patterns: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect(); - assert_eq!(patterns, vec!["c-*", "a-*", "b-*"]); } #[test]