Skip to main content

systemprompt_models/profile/gateway/
route.rs

1//! Gateway routing patterns and stable route-id synthesis.
2//!
3//! A [`GatewayRoute`] maps an external `model_pattern` (exact, prefix `foo*`,
4//! suffix `*foo`, or catch-all `*`) onto a provider in the registry. When a
5//! route omits an explicit id, [`synthesize_route_id`] derives a stable one
6//! from `(model_pattern, provider)` so `access_control_rules` can address the
7//! route by a name that survives reordering. A model's connectivity is never
8//! embedded here — [`GatewayRoute::resolve`] looks the provider up in the
9//! registry at use time.
10
11use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14use systemprompt_identifiers::{ProviderId, RouteId};
15
16use super::super::providers::{ProviderEntry, ProviderRegistry};
17use super::error::{GatewayProfileError, GatewayResult};
18use crate::gateway_hash::fnv1a_segments;
19use crate::services::ai::ModelPricing;
20use crate::wire::canonical::{CanonicalContent, CanonicalRequest, ReasoningEffort, ResponseFormat};
21
22fn default_route_id() -> RouteId {
23    RouteId::new("")
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
27#[serde(deny_unknown_fields)]
28pub struct GatewayRoute {
29    #[serde(default = "default_route_id")]
30    pub id: RouteId,
31    pub model_pattern: String,
32    pub provider: ProviderId,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub upstream_model: Option<String>,
35    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
36    pub extra_headers: HashMap<String, String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub pricing: Option<ModelPricing>,
39    /// Optional request-shape predicates. Absent (`None`) preserves the
40    /// model-only matching behaviour; present narrows the route to requests
41    /// whose attributes satisfy every set predicate.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub when: Option<RouteMatch>,
44}
45
46impl GatewayRoute {
47    pub fn matches(&self, model: &str) -> bool {
48        match_pattern(&self.model_pattern, model)
49    }
50
51    /// Model-glob match plus, when a [`RouteMatch`] is present, every set
52    /// request-shape predicate. A route without a `when` block matches purely
53    /// on model name, exactly as [`Self::matches`].
54    pub fn matches_request(&self, request: &CanonicalRequest) -> bool {
55        self.matches(&request.model)
56            && self
57                .when
58                .as_ref()
59                .is_none_or(|w| w.matches_request(request))
60    }
61
62    pub fn effective_upstream_model<'a>(&'a self, requested: &'a str) -> &'a str {
63        self.upstream_model.as_deref().unwrap_or(requested)
64    }
65
66    pub fn ensure_id(&mut self) {
67        if self.id.as_str().trim().is_empty() {
68            self.id = synthesize_route_id(&self.model_pattern, self.provider.as_str());
69        }
70    }
71
72    pub fn resolve<'a>(&self, registry: &'a ProviderRegistry) -> Option<&'a ProviderEntry> {
73        registry.find_provider(self.provider.as_str())
74    }
75}
76
77/// Request-shape predicates a route can require beyond the model glob.
78///
79/// Every field is optional; an absent predicate is a wildcard, so an empty
80/// block matches all requests. The trustworthy discriminators in real agent
81/// loops are `thinking` / `min_reasoning_effort` / `stream` and the model name
82/// itself — the full tool catalogue is typically resent on every step, so
83/// `requires_tools` / `min_tools` are weak signals retained for completeness.
84#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
85#[serde(deny_unknown_fields)]
86pub struct RouteMatch {
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub requires_tools: Option<bool>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub min_tools: Option<usize>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub thinking: Option<bool>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub min_reasoning_effort: Option<ReasoningEffort>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub stream: Option<bool>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub min_input_tokens: Option<u32>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub response_format: Option<ResponseFormatKind>,
101}
102
103impl RouteMatch {
104    /// True when every set predicate holds for `request`; absent predicates are
105    /// ignored.
106    #[must_use]
107    pub fn matches_request(&self, request: &CanonicalRequest) -> bool {
108        self.requires_tools
109            .is_none_or(|want| request.tools.is_empty() != want)
110            && self.min_tools.is_none_or(|n| request.tools.len() >= n)
111            && self
112                .thinking
113                .is_none_or(|want| request.thinking.is_some_and(|t| t.enabled) == want)
114            && self
115                .min_reasoning_effort
116                .is_none_or(|floor| request.reasoning_effort.is_some_and(|e| e >= floor))
117            && self.stream.is_none_or(|want| request.stream == want)
118            && self
119                .min_input_tokens
120                .is_none_or(|n| estimate_input_tokens(request) >= n)
121            && self.response_format.is_none_or(|want| {
122                ResponseFormatKind::from(request.response_format.as_ref()) == want
123            })
124    }
125
126    /// Names of the predicates that are set, in declaration order — the basis
127    /// for the route-match audit descriptor.
128    #[must_use]
129    pub fn matched_predicates(&self) -> Vec<&'static str> {
130        let mut out = Vec::new();
131        if self.requires_tools.is_some() {
132            out.push("requires_tools");
133        }
134        if self.min_tools.is_some() {
135            out.push("min_tools");
136        }
137        if self.thinking.is_some() {
138            out.push("thinking");
139        }
140        if self.min_reasoning_effort.is_some() {
141            out.push("min_reasoning_effort");
142        }
143        if self.stream.is_some() {
144            out.push("stream");
145        }
146        if self.min_input_tokens.is_some() {
147            out.push("min_input_tokens");
148        }
149        if self.response_format.is_some() {
150            out.push("response_format");
151        }
152        out
153    }
154
155    /// Rejects predicate combinations that can never match: a zero `min_tools`
156    /// (vacuous), and `requires_tools: false` paired with a positive
157    /// `min_tools` (contradiction).
158    pub const fn validate(&self) -> GatewayResult<()> {
159        if matches!(self.min_tools, Some(0)) {
160            return Err(GatewayProfileError::RouteMatchZeroMinTools);
161        }
162        if let (Some(false), Some(n)) = (self.requires_tools, self.min_tools)
163            && n >= 1
164        {
165            return Err(GatewayProfileError::RouteMatchContradictoryTools);
166        }
167        Ok(())
168    }
169}
170
171/// Profile-side, serializable mirror of the wire [`ResponseFormat`], with an
172/// explicit `Text` variant standing in for the wire type's absence (`None`).
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
174#[serde(rename_all = "snake_case")]
175pub enum ResponseFormatKind {
176    Text,
177    JsonObject,
178    JsonSchema,
179}
180
181impl From<Option<&ResponseFormat>> for ResponseFormatKind {
182    fn from(value: Option<&ResponseFormat>) -> Self {
183        match value {
184            None => Self::Text,
185            Some(ResponseFormat::JsonObject) => Self::JsonObject,
186            Some(ResponseFormat::JsonSchema { .. }) => Self::JsonSchema,
187        }
188    }
189}
190
191/// Deliberately approximate input-token estimate (`chars / 4 + 1`) over the
192/// system prompt and message text — cheap and dependency-free. Precise
193/// tokenisation, when needed, belongs in a [`super::super`]-side
194/// `RouteSelector`.
195fn estimate_input_tokens(request: &CanonicalRequest) -> u32 {
196    let mut chars = request.system.as_deref().map_or(0, str::len);
197    for message in &request.messages {
198        for part in &message.content {
199            accumulate_text_len(part, &mut chars);
200        }
201    }
202    u32::try_from(chars / 4 + 1).unwrap_or(u32::MAX)
203}
204
205fn accumulate_text_len(part: &CanonicalContent, acc: &mut usize) {
206    match part {
207        CanonicalContent::Text(t) => *acc += t.len(),
208        CanonicalContent::Thinking { text, .. } => *acc += text.len(),
209        CanonicalContent::ToolResult { content, .. } => {
210            for inner in content {
211                accumulate_text_len(inner, acc);
212            }
213        },
214        CanonicalContent::ToolUse { .. } | CanonicalContent::Image(_) => {},
215    }
216}
217
218#[must_use]
219pub fn slugify_pattern(pattern: &str) -> String {
220    let mut out = String::with_capacity(pattern.len());
221    let mut last_dash = false;
222    for ch in pattern.chars() {
223        if ch == '*' {
224            out.push_str("star");
225            last_dash = false;
226        } else if ch.is_ascii_alphanumeric() {
227            for lc in ch.to_lowercase() {
228                out.push(lc);
229            }
230            last_dash = false;
231        } else if !last_dash && !out.is_empty() {
232            out.push('-');
233            last_dash = true;
234        }
235    }
236    while out.ends_with('-') {
237        out.pop();
238    }
239    while out.starts_with('-') {
240        out.remove(0);
241    }
242    if out.is_empty() {
243        out.push_str("route");
244    }
245    out
246}
247
248#[must_use]
249pub fn synthesize_route_id(model_pattern: &str, provider: &str) -> RouteId {
250    let h = fnv1a_segments(&[
251        ("model_pattern", model_pattern.as_bytes()),
252        ("provider", provider.as_bytes()),
253    ]);
254    let hash6: String = format!("{h:016x}").chars().take(6).collect();
255    RouteId::new(format!("{}-{}", slugify_pattern(model_pattern), hash6))
256}
257
258pub(crate) fn match_pattern(pattern: &str, model: &str) -> bool {
259    if pattern == "*" {
260        return true;
261    }
262    if let Some(prefix) = pattern.strip_suffix('*') {
263        return model.starts_with(prefix);
264    }
265    if let Some(suffix) = pattern.strip_prefix('*') {
266        return model.ends_with(suffix);
267    }
268    pattern == model
269}