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