Skip to main content

systemprompt_models/services/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. A route may also name a `fallback_provider`: when
10//! the primary upstream is unreachable or exhausts the transient-failure retry
11//! budget, dispatch re-binds the same request to that provider (optionally
12//! under `fallback_upstream_model`) — [`GatewayRoute::fallback_view`] is the
13//! route as the fallback provider sees it.
14//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18use std::collections::HashMap;
19
20use serde::{Deserialize, Serialize};
21use systemprompt_identifiers::{ProviderId, RouteId};
22
23use super::error::{GatewayProfileError, GatewayResult};
24use super::route_id::{match_pattern, synthesize_route_id};
25use crate::services::ai::ModelPricing;
26use crate::services::providers::{ProviderEntry, ProviderRegistry};
27use crate::wire::canonical::{CanonicalContent, CanonicalRequest, ReasoningEffort, ResponseFormat};
28
29fn default_route_id() -> RouteId {
30    RouteId::new("")
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
34#[serde(deny_unknown_fields)]
35pub struct GatewayRoute {
36    #[serde(default = "default_route_id")]
37    pub id: RouteId,
38    pub model_pattern: String,
39    pub provider: ProviderId,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub upstream_model: Option<String>,
42    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
43    pub extra_headers: HashMap<String, String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub pricing: Option<ModelPricing>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub when: Option<RouteMatch>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub requires: Option<RouteRequirements>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub fallback_provider: Option<ProviderId>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub fallback_upstream_model: Option<String>,
54}
55
56impl GatewayRoute {
57    pub fn matches(&self, model: &str) -> bool {
58        match_pattern(&self.model_pattern, model)
59    }
60
61    pub fn matches_request(&self, request: &CanonicalRequest) -> bool {
62        self.matches(request.model.as_str())
63            && self
64                .when
65                .as_ref()
66                .is_none_or(|w| w.matches_request(request))
67    }
68
69    pub fn effective_upstream_model<'a>(&'a self, requested: &'a str) -> &'a str {
70        self.upstream_model.as_deref().unwrap_or(requested)
71    }
72
73    pub fn ensure_id(&mut self) {
74        if self.id.as_str().trim().is_empty() {
75            self.id = synthesize_route_id(&self.model_pattern, self.provider.as_str());
76        }
77    }
78
79    pub fn resolve<'a>(&self, registry: &'a ProviderRegistry) -> Option<&'a ProviderEntry> {
80        registry.find_provider(self.provider.as_str())
81    }
82
83    #[must_use]
84    pub fn fallback_view(&self) -> Option<Self> {
85        let fallback = self.fallback_provider.clone()?;
86        Some(Self {
87            provider: fallback,
88            upstream_model: self.fallback_upstream_model.clone(),
89            pricing: None,
90            fallback_provider: None,
91            fallback_upstream_model: None,
92            ..self.clone()
93        })
94    }
95}
96
97/// Request-shape predicates a route can require beyond the model glob.
98///
99/// Every field is optional; an absent predicate is a wildcard, so an empty
100/// block matches all requests. The trustworthy discriminators in real agent
101/// loops are `thinking` / `min_reasoning_effort` / `stream` and the model name
102/// itself — the full tool catalogue is typically resent on every step, so
103/// `requires_tools` / `min_tools` are weak signals retained for completeness.
104#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
105#[serde(deny_unknown_fields)]
106pub struct RouteMatch {
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub requires_tools: Option<bool>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub min_tools: Option<usize>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub thinking: Option<bool>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub min_reasoning_effort: Option<ReasoningEffort>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub stream: Option<bool>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub min_input_tokens: Option<u32>,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub response_format: Option<ResponseFormatKind>,
121}
122
123impl RouteMatch {
124    #[must_use]
125    pub fn matches_request(&self, request: &CanonicalRequest) -> bool {
126        self.requires_tools
127            .is_none_or(|want| request.tools.is_empty() != want)
128            && self.min_tools.is_none_or(|n| request.tools.len() >= n)
129            && self
130                .thinking
131                .is_none_or(|want| request.thinking.is_some_and(|t| t.enabled) == want)
132            && self
133                .min_reasoning_effort
134                .is_none_or(|floor| request.reasoning_effort.is_some_and(|e| e >= floor))
135            && self.stream.is_none_or(|want| request.stream == want)
136            && self
137                .min_input_tokens
138                .is_none_or(|n| estimate_input_tokens(request) >= n)
139            && self.response_format.is_none_or(|want| {
140                ResponseFormatKind::from(request.response_format.as_ref()) == want
141            })
142    }
143
144    #[must_use]
145    pub fn matched_predicates(&self) -> Vec<&'static str> {
146        let mut out = Vec::new();
147        if self.requires_tools.is_some() {
148            out.push("requires_tools");
149        }
150        if self.min_tools.is_some() {
151            out.push("min_tools");
152        }
153        if self.thinking.is_some() {
154            out.push("thinking");
155        }
156        if self.min_reasoning_effort.is_some() {
157            out.push("min_reasoning_effort");
158        }
159        if self.stream.is_some() {
160            out.push("stream");
161        }
162        if self.min_input_tokens.is_some() {
163            out.push("min_input_tokens");
164        }
165        if self.response_format.is_some() {
166            out.push("response_format");
167        }
168        out
169    }
170
171    pub const fn validate(&self) -> GatewayResult<()> {
172        if matches!(self.min_tools, Some(0)) {
173            return Err(GatewayProfileError::RouteMatchZeroMinTools);
174        }
175        if let (Some(false), Some(n)) = (self.requires_tools, self.min_tools)
176            && n >= 1
177        {
178            return Err(GatewayProfileError::RouteMatchContradictoryTools);
179        }
180        Ok(())
181    }
182}
183
184/// Governance demands a route places on its *target*, not on the request.
185///
186/// `european: true` restricts the route to providers/models whose effective
187/// [`ModelGovernance`](crate::services::ai::ModelGovernance) declares
188/// `european`, and `no_retain: true` likewise. Checked at boot for every model
189/// the route can reach, and re-checked at dispatch so a selector-refined route
190/// or unlisted model cannot bypass it.
191#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
192#[serde(deny_unknown_fields)]
193pub struct RouteRequirements {
194    #[serde(default)]
195    pub european: bool,
196    #[serde(default)]
197    pub no_retain: bool,
198}
199
200impl RouteRequirements {
201    #[must_use]
202    pub fn unmet(&self, governance: crate::services::ai::ModelGovernance) -> Vec<&'static str> {
203        let mut out = Vec::new();
204        if self.european && !governance.european {
205            out.push("european");
206        }
207        if self.no_retain && !governance.no_retain {
208            out.push("no_retain");
209        }
210        out
211    }
212
213    #[must_use]
214    pub fn declared(&self) -> Vec<&'static str> {
215        let mut out = Vec::new();
216        if self.european {
217            out.push("european");
218        }
219        if self.no_retain {
220            out.push("no_retain");
221        }
222        out
223    }
224}
225
226/// Profile-side, serializable mirror of the wire [`ResponseFormat`], with an
227/// explicit `Text` variant standing in for the wire type's absence (`None`).
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
229#[serde(rename_all = "snake_case")]
230pub enum ResponseFormatKind {
231    Text,
232    JsonObject,
233    JsonSchema,
234}
235
236impl From<Option<&ResponseFormat>> for ResponseFormatKind {
237    fn from(value: Option<&ResponseFormat>) -> Self {
238        match value {
239            None => Self::Text,
240            Some(ResponseFormat::JsonObject) => Self::JsonObject,
241            Some(ResponseFormat::JsonSchema { .. }) => Self::JsonSchema,
242        }
243    }
244}
245
246fn estimate_input_tokens(request: &CanonicalRequest) -> u32 {
247    let mut chars = request
248        .system
249        .iter()
250        .map(|block| block.text.len())
251        .sum::<usize>();
252    for message in &request.messages {
253        for part in &message.content {
254            accumulate_text_len(part, &mut chars);
255        }
256    }
257    u32::try_from(chars / 4 + 1).unwrap_or(u32::MAX)
258}
259
260fn accumulate_text_len(part: &CanonicalContent, acc: &mut usize) {
261    match part {
262        CanonicalContent::Text { text, .. } | CanonicalContent::Thinking { text, .. } => {
263            *acc += text.len();
264        },
265        CanonicalContent::ToolResult { content, .. } => {
266            for inner in content {
267                accumulate_text_len(inner, acc);
268            }
269        },
270        CanonicalContent::ToolUse { .. } | CanonicalContent::Image { .. } => {},
271    }
272}