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 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#[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#[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#[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}