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