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//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::borrow::Cow;
12use std::collections::HashMap;
13
14use serde::{Deserialize, Serialize};
15use systemprompt_identifiers::{ProviderId, RouteId};
16
17use super::super::providers::ProviderRegistry;
18use super::error::{GatewayProfileError, GatewayResult};
19use super::override_rule::SystemPromptRule;
20use super::route::GatewayRoute;
21use crate::wire::canonical::CanonicalRequest;
22
23pub(crate) const DEFAULT_ROUTE_PATTERN: &str = "*";
24
25#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
26#[serde(deny_unknown_fields)]
27pub struct GatewayConfigSpec {
28    #[serde(default)]
29    pub enabled: bool,
30    #[serde(default)]
31    pub routes: Vec<GatewayRoute>,
32    /// Authorizes the synthetic catch-all route, but a model is only
33    /// *dispatched* to it when [`Self::allow_unlisted_models`] is also set; see
34    /// [`GatewayConfig::is_model_exposed`].
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub default_provider: Option<ProviderId>,
37    /// Closed allowlist when `false` (the default): a model matching no route
38    /// and absent from the registry is denied (`403`) rather than silently
39    /// billed against `default_provider`. Set `true` only to let the default
40    /// provider absorb arbitrary model strings.
41    #[serde(default)]
42    pub allow_unlisted_models: bool,
43    #[serde(default = "default_auth_scheme")]
44    pub auth_scheme: String,
45    #[serde(default = "default_inference_path_prefix")]
46    pub inference_path_prefix: String,
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub system_prompt_overrides: Vec<SystemPromptRule>,
49}
50
51impl Default for GatewayConfigSpec {
52    fn default() -> Self {
53        Self {
54            enabled: false,
55            routes: Vec::new(),
56            default_provider: None,
57            allow_unlisted_models: false,
58            auth_scheme: default_auth_scheme(),
59            inference_path_prefix: default_inference_path_prefix(),
60            system_prompt_overrides: Vec::new(),
61        }
62    }
63}
64
65fn default_auth_scheme() -> String {
66    "bearer".to_owned()
67}
68
69fn default_inference_path_prefix() -> String {
70    "/v1".to_owned()
71}
72
73impl GatewayConfigSpec {
74    #[must_use]
75    pub fn resolve(self) -> GatewayConfig {
76        let Self {
77            enabled,
78            routes,
79            default_provider,
80            allow_unlisted_models,
81            auth_scheme,
82            inference_path_prefix,
83            system_prompt_overrides,
84        } = self;
85
86        GatewayConfig {
87            enabled,
88            routes,
89            default_provider,
90            allow_unlisted_models,
91            auth_scheme,
92            inference_path_prefix,
93            system_prompt_overrides,
94        }
95    }
96}
97
98/// Runtime gateway configuration: the post-resolution shape every non-loader
99/// caller sees.
100///
101/// Not `Deserialize`: the only legal construction paths are
102/// [`GatewayConfigSpec::resolve`] for the production loader and direct
103/// struct-literal construction in tests.
104#[derive(Debug, Clone)]
105pub struct GatewayConfig {
106    pub enabled: bool,
107    pub routes: Vec<GatewayRoute>,
108    pub default_provider: Option<ProviderId>,
109    pub allow_unlisted_models: bool,
110    pub auth_scheme: String,
111    pub inference_path_prefix: String,
112    pub system_prompt_overrides: Vec<SystemPromptRule>,
113}
114
115impl Default for GatewayConfig {
116    fn default() -> Self {
117        Self {
118            enabled: false,
119            routes: Vec::new(),
120            default_provider: None,
121            allow_unlisted_models: false,
122            auth_scheme: default_auth_scheme(),
123            inference_path_prefix: default_inference_path_prefix(),
124            system_prompt_overrides: Vec::new(),
125        }
126    }
127}
128
129impl GatewayConfig {
130    pub fn find_route(&self, model: &str) -> Option<&GatewayRoute> {
131        self.routes.iter().find(|route| route.matches(model))
132    }
133
134    pub fn candidate_routes<'a>(
135        &'a self,
136        registry: &ProviderRegistry,
137    ) -> impl Iterator<Item = Cow<'a, GatewayRoute>> {
138        self.routes
139            .iter()
140            .map(Cow::Borrowed)
141            .chain(self.synthesize_default_route(registry).map(Cow::Owned))
142    }
143
144    /// Selects the first candidate route whose model glob **and** request-shape
145    /// predicates match. A route without a `when` block matches on model name
146    /// alone, so omitting predicates preserves the prior model-only behaviour.
147    #[must_use]
148    pub fn resolve_route<'a>(
149        &'a self,
150        registry: &ProviderRegistry,
151        request: &CanonicalRequest,
152    ) -> Option<Cow<'a, GatewayRoute>> {
153        self.candidate_routes(registry)
154            .find(|route| route.matches_request(request))
155    }
156
157    #[must_use]
158    pub fn dispatchable_route_ids(&self, registry: &ProviderRegistry) -> Vec<RouteId> {
159        let mut ids: Vec<RouteId> = Vec::new();
160        let mut seen: std::collections::HashSet<RouteId> = std::collections::HashSet::new();
161        for route in self.candidate_routes(registry) {
162            let mut route = route.into_owned();
163            route.ensure_id();
164            if seen.insert(route.id.clone()) {
165                ids.push(route.id);
166            }
167        }
168        ids
169    }
170
171    fn synthesize_default_route(&self, registry: &ProviderRegistry) -> Option<GatewayRoute> {
172        let provider = self.default_provider.as_ref()?;
173        registry.find_provider(provider.as_str())?;
174        let mut route = GatewayRoute {
175            id: RouteId::new(""),
176            model_pattern: DEFAULT_ROUTE_PATTERN.to_owned(),
177            provider: provider.clone(),
178            upstream_model: None,
179            extra_headers: HashMap::new(),
180            pricing: None,
181            when: None,
182        };
183        route.ensure_id();
184        Some(route)
185    }
186
187    /// Closed-allowlist posture: a model matching no explicit route and not a
188    /// registered provider model is dispatchable only when `default_provider`
189    /// is set **and** [`Self::allow_unlisted_models`] opts in. Otherwise it
190    /// is denied before dispatch rather than silently billed.
191    #[must_use]
192    pub fn is_model_exposed(&self, registry: &ProviderRegistry, model: &str) -> bool {
193        if self.find_route(model).is_some() || registry.contains_model(model) {
194            return true;
195        }
196        if self.default_provider.is_some() && self.allow_unlisted_models {
197            tracing::warn!(
198                model,
199                "gateway forwarding an unlisted model to default_provider \
200                 (allow_unlisted_models=true): open allowlist posture"
201            );
202            return true;
203        }
204        false
205    }
206
207    pub fn validate(&self, registry: &ProviderRegistry) -> GatewayResult<()> {
208        let mut route_ids: std::collections::HashSet<&str> =
209            std::collections::HashSet::with_capacity(self.routes.len());
210        for route in &self.routes {
211            if !route_ids.insert(route.id.as_str()) {
212                return Err(GatewayProfileError::DuplicateRouteId {
213                    id: route.id.as_str().to_owned(),
214                });
215            }
216        }
217        if let Some(provider) = self.default_provider.as_ref()
218            && registry.find_provider(provider.as_str()).is_none()
219        {
220            return Err(GatewayProfileError::DefaultProviderNotInRegistry {
221                provider: provider.as_str().to_owned(),
222            });
223        }
224        for route in &self.routes {
225            if registry.find_provider(route.provider.as_str()).is_none() {
226                return Err(GatewayProfileError::RouteProviderNotInRegistry {
227                    route: route.model_pattern.clone(),
228                    provider: route.provider.as_str().to_owned(),
229                });
230            }
231            if let Some(when) = route.when.as_ref() {
232                when.validate()?;
233            }
234            self.validate_route_pricing(registry, route)?;
235        }
236        for rule in &self.system_prompt_overrides {
237            rule.validate()?;
238            if let Some(provider) = rule.provider.as_ref()
239                && registry.find_provider(provider.as_str()).is_none()
240            {
241                return Err(GatewayProfileError::OverrideProviderNotInRegistry {
242                    provider: provider.as_str().to_owned(),
243                });
244            }
245        }
246        Ok(())
247    }
248
249    /// Uncosted AI is a configuration bug, not a runtime warning: a route that
250    /// dispatches real inference must resolve to real rates, or the request is
251    /// billed at zero and the gap is invisible until someone reads the ledger.
252    ///
253    /// A route-level `pricing:` override covers everything the route dispatches
254    /// (it is what `pricing::resolve` prefers), so it short-circuits the check.
255    /// Otherwise every registry model the pattern can reach must carry usable
256    /// rates — and the pattern must reach at least one, which is what catches a
257    /// glob route pointed at a catalog that has fallen behind the models
258    /// actually in use.
259    fn validate_route_pricing(
260        &self,
261        registry: &ProviderRegistry,
262        route: &GatewayRoute,
263    ) -> GatewayResult<()> {
264        if !self.enabled {
265            return Ok(());
266        }
267        let route_id = route.id.as_str().to_owned();
268        if let Some(pricing) = route.pricing {
269            return if pricing.is_billable() {
270                Ok(())
271            } else {
272                Err(GatewayProfileError::RouteModelUnpriced {
273                    route: route_id,
274                    model: route.model_pattern.clone(),
275                })
276            };
277        }
278        let Some(entry) = route.resolve(registry) else {
279            return Ok(());
280        };
281        let mut reached = 0usize;
282        for model in entry.models.iter().filter(|m| route.matches(m.id.as_str())) {
283            reached += 1;
284            if !model.pricing.is_billable() {
285                return Err(GatewayProfileError::RouteModelUnpriced {
286                    route: route_id,
287                    model: model.id.as_str().to_owned(),
288                });
289            }
290        }
291        if reached == 0 {
292            return Err(GatewayProfileError::RouteReachesNoPricedModel {
293                route: route_id,
294                pattern: route.model_pattern.clone(),
295                provider: route.provider.as_str().to_owned(),
296            });
297        }
298        Ok(())
299    }
300
301    #[must_use]
302    pub fn to_spec(&self) -> GatewayConfigSpec {
303        GatewayConfigSpec {
304            enabled: self.enabled,
305            routes: self.routes.clone(),
306            default_provider: self.default_provider.clone(),
307            allow_unlisted_models: self.allow_unlisted_models,
308            auth_scheme: self.auth_scheme.clone(),
309            inference_path_prefix: self.inference_path_prefix.clone(),
310            system_prompt_overrides: self.system_prompt_overrides.clone(),
311        }
312    }
313}