systemprompt_models/services/gateway/
route.rs1use 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#[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#[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#[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}