Skip to main content

systemprompt_models/profile/gateway/
config.rs

1//! Gateway configuration: on-disk spec and resolved runtime form.
2//!
3//! [`GatewayConfigSpec`] is the serde shape accepted under `gateway:` in a
4//! profile; [`GatewayConfig`] is its runtime projection. Routes carry no
5//! embedded provider catalog — every route resolves its provider against
6//! `profile.providers` ([`ProviderRegistry`]) at use time.
7
8use std::borrow::Cow;
9use std::collections::HashMap;
10
11use serde::{Deserialize, Serialize};
12use systemprompt_identifiers::{ProviderId, RouteId};
13
14use super::super::providers::ProviderRegistry;
15use super::error::{GatewayProfileError, GatewayResult};
16use super::override_rule::SystemPromptRule;
17use super::route::GatewayRoute;
18use crate::wire::canonical::CanonicalRequest;
19
20pub(crate) const DEFAULT_ROUTE_PATTERN: &str = "*";
21
22#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
23#[serde(deny_unknown_fields)]
24pub struct GatewayConfigSpec {
25    #[serde(default)]
26    pub enabled: bool,
27    #[serde(default)]
28    pub routes: Vec<GatewayRoute>,
29    /// Authorizes the synthetic catch-all route, but a model is only
30    /// *dispatched* to it when [`Self::allow_unlisted_models`] is also set; see
31    /// [`GatewayConfig::is_model_exposed`].
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub default_provider: Option<ProviderId>,
34    /// Closed allowlist when `false` (the default): a model matching no route
35    /// and absent from the registry is denied (`403`) rather than silently
36    /// billed against `default_provider`. Set `true` only to let the default
37    /// provider absorb arbitrary model strings.
38    #[serde(default)]
39    pub allow_unlisted_models: bool,
40    #[serde(default = "default_auth_scheme")]
41    pub auth_scheme: String,
42    #[serde(default = "default_inference_path_prefix")]
43    pub inference_path_prefix: String,
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub system_prompt_overrides: Vec<SystemPromptRule>,
46}
47
48impl Default for GatewayConfigSpec {
49    fn default() -> Self {
50        Self {
51            enabled: false,
52            routes: Vec::new(),
53            default_provider: None,
54            allow_unlisted_models: false,
55            auth_scheme: default_auth_scheme(),
56            inference_path_prefix: default_inference_path_prefix(),
57            system_prompt_overrides: Vec::new(),
58        }
59    }
60}
61
62fn default_auth_scheme() -> String {
63    "bearer".to_owned()
64}
65
66fn default_inference_path_prefix() -> String {
67    "/v1".to_owned()
68}
69
70impl GatewayConfigSpec {
71    #[must_use]
72    pub fn resolve(self) -> GatewayConfig {
73        let Self {
74            enabled,
75            routes,
76            default_provider,
77            allow_unlisted_models,
78            auth_scheme,
79            inference_path_prefix,
80            system_prompt_overrides,
81        } = self;
82
83        GatewayConfig {
84            enabled,
85            routes,
86            default_provider,
87            allow_unlisted_models,
88            auth_scheme,
89            inference_path_prefix,
90            system_prompt_overrides,
91        }
92    }
93}
94
95/// Runtime gateway configuration: the post-resolution shape every non-loader
96/// caller sees.
97///
98/// Not `Deserialize`: the only legal construction paths are
99/// [`GatewayConfigSpec::resolve`] for the production loader and direct
100/// struct-literal construction in tests.
101#[derive(Debug, Clone)]
102pub struct GatewayConfig {
103    pub enabled: bool,
104    pub routes: Vec<GatewayRoute>,
105    pub default_provider: Option<ProviderId>,
106    pub allow_unlisted_models: bool,
107    pub auth_scheme: String,
108    pub inference_path_prefix: String,
109    pub system_prompt_overrides: Vec<SystemPromptRule>,
110}
111
112impl Default for GatewayConfig {
113    fn default() -> Self {
114        Self {
115            enabled: false,
116            routes: Vec::new(),
117            default_provider: None,
118            allow_unlisted_models: false,
119            auth_scheme: default_auth_scheme(),
120            inference_path_prefix: default_inference_path_prefix(),
121            system_prompt_overrides: Vec::new(),
122        }
123    }
124}
125
126impl GatewayConfig {
127    pub fn find_route(&self, model: &str) -> Option<&GatewayRoute> {
128        self.routes.iter().find(|route| route.matches(model))
129    }
130
131    pub fn candidate_routes<'a>(
132        &'a self,
133        registry: &ProviderRegistry,
134    ) -> impl Iterator<Item = Cow<'a, GatewayRoute>> {
135        self.routes
136            .iter()
137            .map(Cow::Borrowed)
138            .chain(self.synthesize_default_route(registry).map(Cow::Owned))
139    }
140
141    /// Selects the first candidate route whose model glob **and** request-shape
142    /// predicates match. A route without a `when` block matches on model name
143    /// alone, so omitting predicates preserves the prior model-only behaviour.
144    #[must_use]
145    pub fn resolve_route<'a>(
146        &'a self,
147        registry: &ProviderRegistry,
148        request: &CanonicalRequest,
149    ) -> Option<Cow<'a, GatewayRoute>> {
150        self.candidate_routes(registry)
151            .find(|route| route.matches_request(request))
152    }
153
154    #[must_use]
155    pub fn dispatchable_route_ids(&self, registry: &ProviderRegistry) -> Vec<RouteId> {
156        let mut ids: Vec<RouteId> = Vec::new();
157        let mut seen: std::collections::HashSet<RouteId> = std::collections::HashSet::new();
158        for route in self.candidate_routes(registry) {
159            let mut route = route.into_owned();
160            route.ensure_id();
161            if seen.insert(route.id.clone()) {
162                ids.push(route.id);
163            }
164        }
165        ids
166    }
167
168    /// `upstream_model` is left `None` so the requested model passes through
169    /// unchanged; per-model rewrites live in the registry, applied downstream.
170    fn synthesize_default_route(&self, registry: &ProviderRegistry) -> Option<GatewayRoute> {
171        let provider = self.default_provider.as_ref()?;
172        registry.find_provider(provider.as_str())?;
173        let mut route = GatewayRoute {
174            id: RouteId::new(""),
175            model_pattern: DEFAULT_ROUTE_PATTERN.to_owned(),
176            provider: provider.clone(),
177            upstream_model: None,
178            extra_headers: HashMap::new(),
179            pricing: None,
180            when: None,
181        };
182        route.ensure_id();
183        Some(route)
184    }
185
186    /// Closed-allowlist posture: a model matching no explicit route and not a
187    /// registered provider model is dispatchable only when `default_provider`
188    /// is set **and** [`Self::allow_unlisted_models`] opts in. Otherwise it
189    /// is denied before dispatch rather than silently billed.
190    #[must_use]
191    pub fn is_model_exposed(&self, registry: &ProviderRegistry, model: &str) -> bool {
192        if self.find_route(model).is_some() || registry.contains_model(model) {
193            return true;
194        }
195        if self.default_provider.is_some() && self.allow_unlisted_models {
196            tracing::warn!(
197                model,
198                "gateway forwarding an unlisted model to default_provider \
199                 (allow_unlisted_models=true): open allowlist posture"
200            );
201            return true;
202        }
203        false
204    }
205
206    pub fn validate(&self, registry: &ProviderRegistry) -> GatewayResult<()> {
207        let mut route_ids: std::collections::HashSet<&str> =
208            std::collections::HashSet::with_capacity(self.routes.len());
209        for route in &self.routes {
210            if !route_ids.insert(route.id.as_str()) {
211                return Err(GatewayProfileError::DuplicateRouteId {
212                    id: route.id.as_str().to_owned(),
213                });
214            }
215        }
216        if let Some(provider) = self.default_provider.as_ref()
217            && registry.find_provider(provider.as_str()).is_none()
218        {
219            return Err(GatewayProfileError::DefaultProviderNotInRegistry {
220                provider: provider.as_str().to_owned(),
221            });
222        }
223        for route in &self.routes {
224            if registry.find_provider(route.provider.as_str()).is_none() {
225                return Err(GatewayProfileError::RouteProviderNotInRegistry {
226                    route: route.model_pattern.clone(),
227                    provider: route.provider.as_str().to_owned(),
228                });
229            }
230            if let Some(when) = route.when.as_ref() {
231                when.validate()?;
232            }
233        }
234        for rule in &self.system_prompt_overrides {
235            rule.validate()?;
236            if let Some(provider) = rule.provider.as_ref()
237                && registry.find_provider(provider.as_str()).is_none()
238            {
239                return Err(GatewayProfileError::OverrideProviderNotInRegistry {
240                    provider: provider.as_str().to_owned(),
241                });
242            }
243        }
244        Ok(())
245    }
246
247    #[must_use]
248    pub fn to_spec(&self) -> GatewayConfigSpec {
249        GatewayConfigSpec {
250            enabled: self.enabled,
251            routes: self.routes.clone(),
252            default_provider: self.default_provider.clone(),
253            allow_unlisted_models: self.allow_unlisted_models,
254            auth_scheme: self.auth_scheme.clone(),
255            inference_path_prefix: self.inference_path_prefix.clone(),
256            system_prompt_overrides: self.system_prompt_overrides.clone(),
257        }
258    }
259}