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