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    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub requires: Option<RouteRequirements>,
46}
47
48impl GatewayRoute {
49    pub fn matches(&self, model: &str) -> bool {
50        match_pattern(&self.model_pattern, model)
51    }
52
53    pub fn matches_request(&self, request: &CanonicalRequest) -> bool {
54        self.matches(&request.model)
55            && self
56                .when
57                .as_ref()
58                .is_none_or(|w| w.matches_request(request))
59    }
60
61    pub fn effective_upstream_model<'a>(&'a self, requested: &'a str) -> &'a str {
62        self.upstream_model.as_deref().unwrap_or(requested)
63    }
64
65    pub fn ensure_id(&mut self) {
66        if self.id.as_str().trim().is_empty() {
67            self.id = synthesize_route_id(&self.model_pattern, self.provider.as_str());
68        }
69    }
70
71    pub fn resolve<'a>(&self, registry: &'a ProviderRegistry) -> Option<&'a ProviderEntry> {
72        registry.find_provider(self.provider.as_str())
73    }
74}
75
76/// Request-shape predicates a route can require beyond the model glob.
77///
78/// Every field is optional; an absent predicate is a wildcard, so an empty
79/// block matches all requests. The trustworthy discriminators in real agent
80/// loops are `thinking` / `min_reasoning_effort` / `stream` and the model name
81/// itself — the full tool catalogue is typically resent on every step, so
82/// `requires_tools` / `min_tools` are weak signals retained for completeness.
83#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
84#[serde(deny_unknown_fields)]
85pub struct RouteMatch {
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub requires_tools: Option<bool>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub min_tools: Option<usize>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub thinking: Option<bool>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub min_reasoning_effort: Option<ReasoningEffort>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub stream: Option<bool>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub min_input_tokens: Option<u32>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub response_format: Option<ResponseFormatKind>,
100}
101
102impl RouteMatch {
103    #[must_use]
104    pub fn matches_request(&self, request: &CanonicalRequest) -> bool {
105        self.requires_tools
106            .is_none_or(|want| request.tools.is_empty() != want)
107            && self.min_tools.is_none_or(|n| request.tools.len() >= n)
108            && self
109                .thinking
110                .is_none_or(|want| request.thinking.is_some_and(|t| t.enabled) == want)
111            && self
112                .min_reasoning_effort
113                .is_none_or(|floor| request.reasoning_effort.is_some_and(|e| e >= floor))
114            && self.stream.is_none_or(|want| request.stream == want)
115            && self
116                .min_input_tokens
117                .is_none_or(|n| estimate_input_tokens(request) >= n)
118            && self.response_format.is_none_or(|want| {
119                ResponseFormatKind::from(request.response_format.as_ref()) == want
120            })
121    }
122
123    #[must_use]
124    pub fn matched_predicates(&self) -> Vec<&'static str> {
125        let mut out = Vec::new();
126        if self.requires_tools.is_some() {
127            out.push("requires_tools");
128        }
129        if self.min_tools.is_some() {
130            out.push("min_tools");
131        }
132        if self.thinking.is_some() {
133            out.push("thinking");
134        }
135        if self.min_reasoning_effort.is_some() {
136            out.push("min_reasoning_effort");
137        }
138        if self.stream.is_some() {
139            out.push("stream");
140        }
141        if self.min_input_tokens.is_some() {
142            out.push("min_input_tokens");
143        }
144        if self.response_format.is_some() {
145            out.push("response_format");
146        }
147        out
148    }
149
150    pub const fn validate(&self) -> GatewayResult<()> {
151        if matches!(self.min_tools, Some(0)) {
152            return Err(GatewayProfileError::RouteMatchZeroMinTools);
153        }
154        if let (Some(false), Some(n)) = (self.requires_tools, self.min_tools)
155            && n >= 1
156        {
157            return Err(GatewayProfileError::RouteMatchContradictoryTools);
158        }
159        Ok(())
160    }
161}
162
163/// Governance demands a route places on its *target*, not on the request.
164///
165/// `european: true` restricts the route to providers/models whose effective
166/// [`ModelGovernance`](crate::services::ai::ModelGovernance) declares
167/// `european`, and `no_retain: true` likewise. Checked at boot for every model
168/// the route can reach, and re-checked at dispatch so a selector-refined route
169/// or unlisted model cannot bypass it.
170#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
171#[serde(deny_unknown_fields)]
172pub struct RouteRequirements {
173    #[serde(default)]
174    pub european: bool,
175    #[serde(default)]
176    pub no_retain: bool,
177}
178
179impl RouteRequirements {
180    #[must_use]
181    pub fn unmet(&self, governance: crate::services::ai::ModelGovernance) -> Vec<&'static str> {
182        let mut out = Vec::new();
183        if self.european && !governance.european {
184            out.push("european");
185        }
186        if self.no_retain && !governance.no_retain {
187            out.push("no_retain");
188        }
189        out
190    }
191
192    #[must_use]
193    pub fn declared(&self) -> Vec<&'static str> {
194        let mut out = Vec::new();
195        if self.european {
196            out.push("european");
197        }
198        if self.no_retain {
199            out.push("no_retain");
200        }
201        out
202    }
203}
204
205/// Profile-side, serializable mirror of the wire [`ResponseFormat`], with an
206/// explicit `Text` variant standing in for the wire type's absence (`None`).
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
208#[serde(rename_all = "snake_case")]
209pub enum ResponseFormatKind {
210    Text,
211    JsonObject,
212    JsonSchema,
213}
214
215impl From<Option<&ResponseFormat>> for ResponseFormatKind {
216    fn from(value: Option<&ResponseFormat>) -> Self {
217        match value {
218            None => Self::Text,
219            Some(ResponseFormat::JsonObject) => Self::JsonObject,
220            Some(ResponseFormat::JsonSchema { .. }) => Self::JsonSchema,
221        }
222    }
223}
224
225fn estimate_input_tokens(request: &CanonicalRequest) -> u32 {
226    let mut chars = request.system.as_deref().map_or(0, str::len);
227    for message in &request.messages {
228        for part in &message.content {
229            accumulate_text_len(part, &mut chars);
230        }
231    }
232    u32::try_from(chars / 4 + 1).unwrap_or(u32::MAX)
233}
234
235fn accumulate_text_len(part: &CanonicalContent, acc: &mut usize) {
236    match part {
237        CanonicalContent::Text(t) => *acc += t.len(),
238        CanonicalContent::Thinking { text, .. } => *acc += text.len(),
239        CanonicalContent::ToolResult { content, .. } => {
240            for inner in content {
241                accumulate_text_len(inner, acc);
242            }
243        },
244        CanonicalContent::ToolUse { .. } | CanonicalContent::Image(_) => {},
245    }
246}
247
248#[must_use]
249pub fn slugify_pattern(pattern: &str) -> String {
250    let mut out = String::with_capacity(pattern.len());
251    let mut last_dash = false;
252    for ch in pattern.chars() {
253        if ch == '*' {
254            out.push_str("star");
255            last_dash = false;
256        } else if ch.is_ascii_alphanumeric() {
257            for lc in ch.to_lowercase() {
258                out.push(lc);
259            }
260            last_dash = false;
261        } else if !last_dash && !out.is_empty() {
262            out.push('-');
263            last_dash = true;
264        }
265    }
266    while out.ends_with('-') {
267        out.pop();
268    }
269    while out.starts_with('-') {
270        out.remove(0);
271    }
272    if out.is_empty() {
273        out.push_str("route");
274    }
275    out
276}
277
278#[must_use]
279pub fn synthesize_route_id(model_pattern: &str, provider: &str) -> RouteId {
280    let h = fnv1a_segments(&[
281        ("model_pattern", model_pattern.as_bytes()),
282        ("provider", provider.as_bytes()),
283    ]);
284    let hash6: String = format!("{h:016x}").chars().take(6).collect();
285    RouteId::new(format!("{}-{}", slugify_pattern(model_pattern), hash6))
286}
287
288pub(crate) fn match_pattern(pattern: &str, model: &str) -> bool {
289    if pattern == "*" {
290        return true;
291    }
292    if let Some(prefix) = pattern.strip_suffix('*') {
293        return model.starts_with(prefix);
294    }
295    if let Some(suffix) = pattern.strip_prefix('*') {
296        return model.ends_with(suffix);
297    }
298    pattern == model
299}