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