systemprompt_models/profile/gateway/
route.rs1use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14use systemprompt_identifiers::{ProviderId, RouteId};
15
16use super::super::providers::{ProviderEntry, ProviderRegistry};
17use super::error::{GatewayProfileError, GatewayResult};
18use crate::gateway_hash::fnv1a_segments;
19use crate::services::ai::ModelPricing;
20use crate::wire::canonical::{CanonicalContent, CanonicalRequest, ReasoningEffort, ResponseFormat};
21
22fn default_route_id() -> RouteId {
23 RouteId::new("")
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
27#[serde(deny_unknown_fields)]
28pub struct GatewayRoute {
29 #[serde(default = "default_route_id")]
30 pub id: RouteId,
31 pub model_pattern: String,
32 pub provider: ProviderId,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub upstream_model: Option<String>,
35 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
36 pub extra_headers: HashMap<String, String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub pricing: Option<ModelPricing>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub when: Option<RouteMatch>,
44}
45
46impl GatewayRoute {
47 pub fn matches(&self, model: &str) -> bool {
48 match_pattern(&self.model_pattern, model)
49 }
50
51 pub fn matches_request(&self, request: &CanonicalRequest) -> bool {
55 self.matches(&request.model)
56 && self
57 .when
58 .as_ref()
59 .is_none_or(|w| w.matches_request(request))
60 }
61
62 pub fn effective_upstream_model<'a>(&'a self, requested: &'a str) -> &'a str {
67 self.upstream_model.as_deref().unwrap_or(requested)
68 }
69
70 pub fn ensure_id(&mut self) {
71 if self.id.as_str().trim().is_empty() {
72 self.id = synthesize_route_id(&self.model_pattern, self.provider.as_str());
73 }
74 }
75
76 pub fn resolve<'a>(&self, registry: &'a ProviderRegistry) -> Option<&'a ProviderEntry> {
77 registry.find_provider(self.provider.as_str())
78 }
79}
80
81#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct RouteMatch {
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub requires_tools: Option<bool>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub min_tools: Option<usize>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub thinking: Option<bool>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub min_reasoning_effort: Option<ReasoningEffort>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub stream: Option<bool>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub min_input_tokens: Option<u32>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub response_format: Option<ResponseFormatKind>,
105}
106
107impl RouteMatch {
108 #[must_use]
111 pub fn matches_request(&self, request: &CanonicalRequest) -> bool {
112 self.requires_tools
113 .is_none_or(|want| request.tools.is_empty() != want)
114 && self.min_tools.is_none_or(|n| request.tools.len() >= n)
115 && self
116 .thinking
117 .is_none_or(|want| request.thinking.is_some_and(|t| t.enabled) == want)
118 && self
119 .min_reasoning_effort
120 .is_none_or(|floor| request.reasoning_effort.is_some_and(|e| e >= floor))
121 && self.stream.is_none_or(|want| request.stream == want)
122 && self
123 .min_input_tokens
124 .is_none_or(|n| estimate_input_tokens(request) >= n)
125 && self.response_format.is_none_or(|want| {
126 ResponseFormatKind::from(request.response_format.as_ref()) == want
127 })
128 }
129
130 #[must_use]
133 pub fn matched_predicates(&self) -> Vec<&'static str> {
134 let mut out = Vec::new();
135 if self.requires_tools.is_some() {
136 out.push("requires_tools");
137 }
138 if self.min_tools.is_some() {
139 out.push("min_tools");
140 }
141 if self.thinking.is_some() {
142 out.push("thinking");
143 }
144 if self.min_reasoning_effort.is_some() {
145 out.push("min_reasoning_effort");
146 }
147 if self.stream.is_some() {
148 out.push("stream");
149 }
150 if self.min_input_tokens.is_some() {
151 out.push("min_input_tokens");
152 }
153 if self.response_format.is_some() {
154 out.push("response_format");
155 }
156 out
157 }
158
159 pub const fn validate(&self) -> GatewayResult<()> {
163 if matches!(self.min_tools, Some(0)) {
164 return Err(GatewayProfileError::RouteMatchZeroMinTools);
165 }
166 if let (Some(false), Some(n)) = (self.requires_tools, self.min_tools)
167 && n >= 1
168 {
169 return Err(GatewayProfileError::RouteMatchContradictoryTools);
170 }
171 Ok(())
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
178#[serde(rename_all = "snake_case")]
179pub enum ResponseFormatKind {
180 Text,
181 JsonObject,
182 JsonSchema,
183}
184
185impl From<Option<&ResponseFormat>> for ResponseFormatKind {
186 fn from(value: Option<&ResponseFormat>) -> Self {
187 match value {
188 None => Self::Text,
189 Some(ResponseFormat::JsonObject) => Self::JsonObject,
190 Some(ResponseFormat::JsonSchema { .. }) => Self::JsonSchema,
191 }
192 }
193}
194
195fn estimate_input_tokens(request: &CanonicalRequest) -> u32 {
200 let mut chars = request.system.as_deref().map_or(0, str::len);
201 for message in &request.messages {
202 for part in &message.content {
203 accumulate_text_len(part, &mut chars);
204 }
205 }
206 u32::try_from(chars / 4 + 1).unwrap_or(u32::MAX)
207}
208
209fn accumulate_text_len(part: &CanonicalContent, acc: &mut usize) {
210 match part {
211 CanonicalContent::Text(t) => *acc += t.len(),
212 CanonicalContent::Thinking { text, .. } => *acc += text.len(),
213 CanonicalContent::ToolResult { content, .. } => {
214 for inner in content {
215 accumulate_text_len(inner, acc);
216 }
217 },
218 CanonicalContent::ToolUse { .. } | CanonicalContent::Image(_) => {},
219 }
220}
221
222#[must_use]
223pub fn slugify_pattern(pattern: &str) -> String {
224 let mut out = String::with_capacity(pattern.len());
225 let mut last_dash = false;
226 for ch in pattern.chars() {
227 if ch == '*' {
228 out.push_str("star");
229 last_dash = false;
230 } else if ch.is_ascii_alphanumeric() {
231 for lc in ch.to_lowercase() {
232 out.push(lc);
233 }
234 last_dash = false;
235 } else if !last_dash && !out.is_empty() {
236 out.push('-');
237 last_dash = true;
238 }
239 }
240 while out.ends_with('-') {
241 out.pop();
242 }
243 while out.starts_with('-') {
244 out.remove(0);
245 }
246 if out.is_empty() {
247 out.push_str("route");
248 }
249 out
250}
251
252#[must_use]
253pub fn synthesize_route_id(model_pattern: &str, provider: &str) -> RouteId {
254 let h = fnv1a_segments(&[
255 ("model_pattern", model_pattern.as_bytes()),
256 ("provider", provider.as_bytes()),
257 ]);
258 let hash6: String = format!("{h:016x}").chars().take(6).collect();
259 RouteId::new(format!("{}-{}", slugify_pattern(model_pattern), hash6))
260}
261
262pub(crate) fn match_pattern(pattern: &str, model: &str) -> bool {
263 if pattern == "*" {
264 return true;
265 }
266 if let Some(prefix) = pattern.strip_suffix('*') {
267 return model.starts_with(prefix);
268 }
269 if let Some(suffix) = pattern.strip_prefix('*') {
270 return model.ends_with(suffix);
271 }
272 pattern == model
273}