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` **by design**: the `*` catch-all route
169    /// forwards the requested model name to `default_provider` verbatim.
170    /// Per-model rewrites live in the registry, applied downstream. Which
171    /// names may reach this route at all is governed by the closed allowlist
172    /// in [`Self::is_model_exposed`]; a profile that opts into
173    /// `allow_unlisted_models` accepts that unlisted names are not validated
174    /// at the gateway — the upstream provider's error is the rejection signal.
175    /// Shipped profiles keep the opt-in off.
176    fn synthesize_default_route(&self, registry: &ProviderRegistry) -> Option<GatewayRoute> {
177        let provider = self.default_provider.as_ref()?;
178        registry.find_provider(provider.as_str())?;
179        let mut route = GatewayRoute {
180            id: RouteId::new(""),
181            model_pattern: DEFAULT_ROUTE_PATTERN.to_owned(),
182            provider: provider.clone(),
183            upstream_model: None,
184            extra_headers: HashMap::new(),
185            pricing: None,
186            when: None,
187        };
188        route.ensure_id();
189        Some(route)
190    }
191
192    /// Closed-allowlist posture: a model matching no explicit route and not a
193    /// registered provider model is dispatchable only when `default_provider`
194    /// is set **and** [`Self::allow_unlisted_models`] opts in. Otherwise it
195    /// is denied before dispatch rather than silently billed.
196    #[must_use]
197    pub fn is_model_exposed(&self, registry: &ProviderRegistry, model: &str) -> bool {
198        if self.find_route(model).is_some() || registry.contains_model(model) {
199            return true;
200        }
201        if self.default_provider.is_some() && self.allow_unlisted_models {
202            tracing::warn!(
203                model,
204                "gateway forwarding an unlisted model to default_provider \
205                 (allow_unlisted_models=true): open allowlist posture"
206            );
207            return true;
208        }
209        false
210    }
211
212    pub fn validate(&self, registry: &ProviderRegistry) -> GatewayResult<()> {
213        let mut route_ids: std::collections::HashSet<&str> =
214            std::collections::HashSet::with_capacity(self.routes.len());
215        for route in &self.routes {
216            if !route_ids.insert(route.id.as_str()) {
217                return Err(GatewayProfileError::DuplicateRouteId {
218                    id: route.id.as_str().to_owned(),
219                });
220            }
221        }
222        if let Some(provider) = self.default_provider.as_ref()
223            && registry.find_provider(provider.as_str()).is_none()
224        {
225            return Err(GatewayProfileError::DefaultProviderNotInRegistry {
226                provider: provider.as_str().to_owned(),
227            });
228        }
229        for route in &self.routes {
230            if registry.find_provider(route.provider.as_str()).is_none() {
231                return Err(GatewayProfileError::RouteProviderNotInRegistry {
232                    route: route.model_pattern.clone(),
233                    provider: route.provider.as_str().to_owned(),
234                });
235            }
236            if let Some(when) = route.when.as_ref() {
237                when.validate()?;
238            }
239        }
240        for rule in &self.system_prompt_overrides {
241            rule.validate()?;
242            if let Some(provider) = rule.provider.as_ref()
243                && registry.find_provider(provider.as_str()).is_none()
244            {
245                return Err(GatewayProfileError::OverrideProviderNotInRegistry {
246                    provider: provider.as_str().to_owned(),
247                });
248            }
249        }
250        Ok(())
251    }
252
253    #[must_use]
254    pub fn to_spec(&self) -> GatewayConfigSpec {
255        GatewayConfigSpec {
256            enabled: self.enabled,
257            routes: self.routes.clone(),
258            default_provider: self.default_provider.clone(),
259            allow_unlisted_models: self.allow_unlisted_models,
260            auth_scheme: self.auth_scheme.clone(),
261            inference_path_prefix: self.inference_path_prefix.clone(),
262            system_prompt_overrides: self.system_prompt_overrides.clone(),
263        }
264    }
265}