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}
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 {
52 self.matches(&request.model)
53 && self
54 .when
55 .as_ref()
56 .is_none_or(|w| w.matches_request(request))
57 }
58
59 pub fn effective_upstream_model<'a>(&'a self, requested: &'a str) -> &'a str {
60 self.upstream_model.as_deref().unwrap_or(requested)
61 }
62
63 pub fn ensure_id(&mut self) {
64 if self.id.as_str().trim().is_empty() {
65 self.id = synthesize_route_id(&self.model_pattern, self.provider.as_str());
66 }
67 }
68
69 pub fn resolve<'a>(&self, registry: &'a ProviderRegistry) -> Option<&'a ProviderEntry> {
70 registry.find_provider(self.provider.as_str())
71 }
72}
73
74#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)]
82#[serde(deny_unknown_fields)]
83pub struct RouteMatch {
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub requires_tools: Option<bool>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub min_tools: Option<usize>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub thinking: Option<bool>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub min_reasoning_effort: Option<ReasoningEffort>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub stream: Option<bool>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub min_input_tokens: Option<u32>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub response_format: Option<ResponseFormatKind>,
98}
99
100impl RouteMatch {
101 #[must_use]
102 pub fn matches_request(&self, request: &CanonicalRequest) -> bool {
103 self.requires_tools
104 .is_none_or(|want| request.tools.is_empty() != want)
105 && self.min_tools.is_none_or(|n| request.tools.len() >= n)
106 && self
107 .thinking
108 .is_none_or(|want| request.thinking.is_some_and(|t| t.enabled) == want)
109 && self
110 .min_reasoning_effort
111 .is_none_or(|floor| request.reasoning_effort.is_some_and(|e| e >= floor))
112 && self.stream.is_none_or(|want| request.stream == want)
113 && self
114 .min_input_tokens
115 .is_none_or(|n| estimate_input_tokens(request) >= n)
116 && self.response_format.is_none_or(|want| {
117 ResponseFormatKind::from(request.response_format.as_ref()) == want
118 })
119 }
120
121 #[must_use]
122 pub fn matched_predicates(&self) -> Vec<&'static str> {
123 let mut out = Vec::new();
124 if self.requires_tools.is_some() {
125 out.push("requires_tools");
126 }
127 if self.min_tools.is_some() {
128 out.push("min_tools");
129 }
130 if self.thinking.is_some() {
131 out.push("thinking");
132 }
133 if self.min_reasoning_effort.is_some() {
134 out.push("min_reasoning_effort");
135 }
136 if self.stream.is_some() {
137 out.push("stream");
138 }
139 if self.min_input_tokens.is_some() {
140 out.push("min_input_tokens");
141 }
142 if self.response_format.is_some() {
143 out.push("response_format");
144 }
145 out
146 }
147
148 pub const fn validate(&self) -> GatewayResult<()> {
149 if matches!(self.min_tools, Some(0)) {
150 return Err(GatewayProfileError::RouteMatchZeroMinTools);
151 }
152 if let (Some(false), Some(n)) = (self.requires_tools, self.min_tools)
153 && n >= 1
154 {
155 return Err(GatewayProfileError::RouteMatchContradictoryTools);
156 }
157 Ok(())
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
164#[serde(rename_all = "snake_case")]
165pub enum ResponseFormatKind {
166 Text,
167 JsonObject,
168 JsonSchema,
169}
170
171impl From<Option<&ResponseFormat>> for ResponseFormatKind {
172 fn from(value: Option<&ResponseFormat>) -> Self {
173 match value {
174 None => Self::Text,
175 Some(ResponseFormat::JsonObject) => Self::JsonObject,
176 Some(ResponseFormat::JsonSchema { .. }) => Self::JsonSchema,
177 }
178 }
179}
180
181fn estimate_input_tokens(request: &CanonicalRequest) -> u32 {
182 let mut chars = request.system.as_deref().map_or(0, str::len);
183 for message in &request.messages {
184 for part in &message.content {
185 accumulate_text_len(part, &mut chars);
186 }
187 }
188 u32::try_from(chars / 4 + 1).unwrap_or(u32::MAX)
189}
190
191fn accumulate_text_len(part: &CanonicalContent, acc: &mut usize) {
192 match part {
193 CanonicalContent::Text(t) => *acc += t.len(),
194 CanonicalContent::Thinking { text, .. } => *acc += text.len(),
195 CanonicalContent::ToolResult { content, .. } => {
196 for inner in content {
197 accumulate_text_len(inner, acc);
198 }
199 },
200 CanonicalContent::ToolUse { .. } | CanonicalContent::Image(_) => {},
201 }
202}
203
204#[must_use]
205pub fn slugify_pattern(pattern: &str) -> String {
206 let mut out = String::with_capacity(pattern.len());
207 let mut last_dash = false;
208 for ch in pattern.chars() {
209 if ch == '*' {
210 out.push_str("star");
211 last_dash = false;
212 } else if ch.is_ascii_alphanumeric() {
213 for lc in ch.to_lowercase() {
214 out.push(lc);
215 }
216 last_dash = false;
217 } else if !last_dash && !out.is_empty() {
218 out.push('-');
219 last_dash = true;
220 }
221 }
222 while out.ends_with('-') {
223 out.pop();
224 }
225 while out.starts_with('-') {
226 out.remove(0);
227 }
228 if out.is_empty() {
229 out.push_str("route");
230 }
231 out
232}
233
234#[must_use]
235pub fn synthesize_route_id(model_pattern: &str, provider: &str) -> RouteId {
236 let h = fnv1a_segments(&[
237 ("model_pattern", model_pattern.as_bytes()),
238 ("provider", provider.as_bytes()),
239 ]);
240 let hash6: String = format!("{h:016x}").chars().take(6).collect();
241 RouteId::new(format!("{}-{}", slugify_pattern(model_pattern), hash6))
242}
243
244pub(crate) fn match_pattern(pattern: &str, model: &str) -> bool {
245 if pattern == "*" {
246 return true;
247 }
248 if let Some(prefix) = pattern.strip_suffix('*') {
249 return model.starts_with(prefix);
250 }
251 if let Some(suffix) = pattern.strip_prefix('*') {
252 return model.ends_with(suffix);
253 }
254 pattern == model
255}