Skip to main content

systemprompt_models/profile/gateway/
route.rs

1//! Gateway routing patterns and stable route-id synthesis.
2//!
3//! A [`GatewayRoute`] maps an external `model_pattern` (exact, prefix `foo*`,
4//! suffix `*foo`, or catch-all `*`) onto a provider in the registry. When a
5//! route omits an explicit id, [`synthesize_route_id`] derives a stable one
6//! from `(model_pattern, provider)` so `access_control_rules` can address the
7//! route by a name that survives reordering. A model's connectivity is never
8//! embedded here — [`GatewayRoute::resolve`] looks the provider up in the
9//! registry at use time.
10
11use std::collections::HashMap;
12use std::collections::hash_map::DefaultHasher;
13use std::hash::{Hash, Hasher};
14
15use serde::{Deserialize, Serialize};
16use systemprompt_identifiers::{ProviderId, RouteId};
17
18use super::super::providers::{ProviderEntry, ProviderRegistry};
19use crate::services::ai::ModelPricing;
20
21fn default_route_id() -> RouteId {
22    RouteId::new("")
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
26#[serde(deny_unknown_fields)]
27pub struct GatewayRoute {
28    #[serde(default = "default_route_id")]
29    pub id: RouteId,
30    pub model_pattern: String,
31    pub provider: ProviderId,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub upstream_model: Option<String>,
34    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
35    pub extra_headers: HashMap<String, String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub pricing: Option<ModelPricing>,
38}
39
40impl GatewayRoute {
41    pub fn matches(&self, model: &str) -> bool {
42        match_pattern(&self.model_pattern, model)
43    }
44
45    pub fn effective_upstream_model<'a>(&'a self, requested: &'a str) -> &'a str {
46        self.upstream_model.as_deref().unwrap_or(requested)
47    }
48
49    pub fn ensure_id(&mut self) {
50        if self.id.as_str().trim().is_empty() {
51            self.id = synthesize_route_id(&self.model_pattern, self.provider.as_str());
52        }
53    }
54
55    pub fn resolve<'a>(&self, registry: &'a ProviderRegistry) -> Option<&'a ProviderEntry> {
56        registry.find_provider(self.provider.as_str())
57    }
58}
59
60/// Slugify a model pattern for use in a stable route id: `*` becomes `star`,
61/// non-alphanumeric runs collapse to a single `-`, leading/trailing `-` are
62/// trimmed, and an empty result becomes `route`.
63#[must_use]
64pub fn slugify_pattern(pattern: &str) -> String {
65    let mut out = String::with_capacity(pattern.len());
66    let mut last_dash = false;
67    for ch in pattern.chars() {
68        if ch == '*' {
69            out.push_str("star");
70            last_dash = false;
71        } else if ch.is_ascii_alphanumeric() {
72            for lc in ch.to_lowercase() {
73                out.push(lc);
74            }
75            last_dash = false;
76        } else if !last_dash && !out.is_empty() {
77            out.push('-');
78            last_dash = true;
79        }
80    }
81    while out.ends_with('-') {
82        out.pop();
83    }
84    while out.starts_with('-') {
85        out.remove(0);
86    }
87    if out.is_empty() {
88        out.push_str("route");
89    }
90    out
91}
92
93// Format: <slug>-<6 hex chars> where the hex digest is the first 6 chars of
94// DefaultHasher over (model_pattern, provider). The collision check in
95// GatewayConfig::validate() guards against the vanishingly unlikely case of
96// two operator-authored patterns colliding on the 6-hex tail.
97#[must_use]
98pub fn synthesize_route_id(model_pattern: &str, provider: &str) -> RouteId {
99    let mut hasher = DefaultHasher::new();
100    model_pattern.hash(&mut hasher);
101    provider.hash(&mut hasher);
102    let h = hasher.finish();
103    let hash6: String = format!("{h:016x}").chars().take(6).collect();
104    RouteId::new(format!("{}-{}", slugify_pattern(model_pattern), hash6))
105}
106
107fn match_pattern(pattern: &str, model: &str) -> bool {
108    if pattern == "*" {
109        return true;
110    }
111    if let Some(prefix) = pattern.strip_suffix('*') {
112        return model.starts_with(prefix);
113    }
114    if let Some(suffix) = pattern.strip_prefix('*') {
115        return model.ends_with(suffix);
116    }
117    pattern == model
118}