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    /// Where the desktop bridge's self-updater is served from. Absent means the
50    /// update endpoints report "not configured" and bridges simply never see an
51    /// update — never an error the user has to act on.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub bridge_releases: Option<BridgeReleasesSpec>,
54}
55
56/// Release feed for the desktop bridge self-updater.
57///
58/// The bridge cannot reach these assets itself — the repository is private —
59/// so the gateway resolves and proxies them. Keeping the resolution here is
60/// also what makes staged rollouts a config change rather than a client
61/// release.
62#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct BridgeReleasesSpec {
65    /// Source repository as `owner/name`.
66    pub repo: String,
67    /// Environment variable holding the GitHub token used to read releases and
68    /// download assets. Named rather than inlined so the token never lands in
69    /// a config file or a profile dump.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub token_env: Option<String>,
72    /// Release tags to consider. Bridge releases are tagged separately from the
73    /// server's, so an unfiltered "latest release" would pick the wrong one.
74    #[serde(default = "default_tag_prefix")]
75    pub tag_prefix: String,
76    /// Pins every bridge to one version instead of tracking the newest release.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub pinned_version: Option<String>,
79    /// Platform slug (`macos`, `windows`, `linux-x86_64`, `linux-aarch64`) to
80    /// release asset filename. A platform absent here has no published build.
81    #[serde(default)]
82    pub assets: std::collections::BTreeMap<String, String>,
83}
84
85fn default_tag_prefix() -> String {
86    "bridge-v".to_owned()
87}
88
89impl Default for GatewayConfigSpec {
90    fn default() -> Self {
91        Self {
92            enabled: false,
93            routes: Vec::new(),
94            default_provider: None,
95            allow_unlisted_models: false,
96            auth_scheme: default_auth_scheme(),
97            inference_path_prefix: default_inference_path_prefix(),
98            system_prompt_overrides: Vec::new(),
99            bridge_releases: None,
100        }
101    }
102}
103
104fn default_auth_scheme() -> String {
105    "bearer".to_owned()
106}
107
108fn default_inference_path_prefix() -> String {
109    "/v1".to_owned()
110}
111
112impl GatewayConfigSpec {
113    #[must_use]
114    pub fn resolve(self) -> GatewayConfig {
115        let Self {
116            enabled,
117            routes,
118            default_provider,
119            allow_unlisted_models,
120            auth_scheme,
121            inference_path_prefix,
122            system_prompt_overrides,
123            bridge_releases,
124        } = self;
125
126        GatewayConfig {
127            enabled,
128            routes,
129            default_provider,
130            allow_unlisted_models,
131            auth_scheme,
132            inference_path_prefix,
133            system_prompt_overrides,
134            bridge_releases,
135        }
136    }
137}
138
139/// Runtime gateway configuration: the post-resolution shape every non-loader
140/// caller sees.
141///
142/// Not `Deserialize`: the only legal construction paths are
143/// [`GatewayConfigSpec::resolve`] for the production loader and direct
144/// struct-literal construction in tests.
145#[derive(Debug, Clone)]
146pub struct GatewayConfig {
147    pub enabled: bool,
148    pub routes: Vec<GatewayRoute>,
149    pub default_provider: Option<ProviderId>,
150    pub allow_unlisted_models: bool,
151    pub auth_scheme: String,
152    pub inference_path_prefix: String,
153    pub system_prompt_overrides: Vec<SystemPromptRule>,
154    pub bridge_releases: Option<BridgeReleasesSpec>,
155}
156
157impl Default for GatewayConfig {
158    fn default() -> Self {
159        Self {
160            enabled: false,
161            routes: Vec::new(),
162            default_provider: None,
163            allow_unlisted_models: false,
164            auth_scheme: default_auth_scheme(),
165            inference_path_prefix: default_inference_path_prefix(),
166            system_prompt_overrides: Vec::new(),
167            bridge_releases: None,
168        }
169    }
170}
171
172impl GatewayConfig {
173    pub fn find_route(&self, model: &str) -> Option<&GatewayRoute> {
174        self.routes.iter().find(|route| route.matches(model))
175    }
176
177    pub fn candidate_routes<'a>(
178        &'a self,
179        registry: &ProviderRegistry,
180    ) -> impl Iterator<Item = Cow<'a, GatewayRoute>> {
181        self.routes
182            .iter()
183            .map(Cow::Borrowed)
184            .chain(self.synthesize_default_route(registry).map(Cow::Owned))
185    }
186
187    /// Selects the first candidate route whose model glob **and** request-shape
188    /// predicates match. A route without a `when` block matches on model name
189    /// alone, so omitting predicates preserves the prior model-only behaviour.
190    #[must_use]
191    pub fn resolve_route<'a>(
192        &'a self,
193        registry: &ProviderRegistry,
194        request: &CanonicalRequest,
195    ) -> Option<Cow<'a, GatewayRoute>> {
196        self.candidate_routes(registry)
197            .find(|route| route.matches_request(request))
198    }
199
200    #[must_use]
201    pub fn dispatchable_route_ids(&self, registry: &ProviderRegistry) -> Vec<RouteId> {
202        let mut ids: Vec<RouteId> = Vec::new();
203        let mut seen: std::collections::HashSet<RouteId> = std::collections::HashSet::new();
204        for route in self.candidate_routes(registry) {
205            let mut route = route.into_owned();
206            route.ensure_id();
207            if seen.insert(route.id.clone()) {
208                ids.push(route.id);
209            }
210        }
211        ids
212    }
213
214    fn synthesize_default_route(&self, registry: &ProviderRegistry) -> Option<GatewayRoute> {
215        let provider = self.default_provider.as_ref()?;
216        registry.find_provider(provider.as_str())?;
217        let mut route = GatewayRoute {
218            id: RouteId::new(""),
219            model_pattern: DEFAULT_ROUTE_PATTERN.to_owned(),
220            provider: provider.clone(),
221            upstream_model: None,
222            extra_headers: HashMap::new(),
223            pricing: None,
224            when: None,
225        };
226        route.ensure_id();
227        Some(route)
228    }
229
230    /// Closed-allowlist posture: a model matching no explicit route and not a
231    /// registered provider model is dispatchable only when `default_provider`
232    /// is set **and** [`Self::allow_unlisted_models`] opts in. Otherwise it
233    /// is denied before dispatch rather than silently billed.
234    #[must_use]
235    pub fn is_model_exposed(&self, registry: &ProviderRegistry, model: &str) -> bool {
236        if self.find_route(model).is_some() || registry.contains_model(model) {
237            return true;
238        }
239        if self.default_provider.is_some() && self.allow_unlisted_models {
240            tracing::warn!(
241                model,
242                "gateway forwarding an unlisted model to default_provider \
243                 (allow_unlisted_models=true): open allowlist posture"
244            );
245            return true;
246        }
247        false
248    }
249
250    pub fn validate(&self, registry: &ProviderRegistry) -> GatewayResult<()> {
251        let mut route_ids: std::collections::HashSet<&str> =
252            std::collections::HashSet::with_capacity(self.routes.len());
253        for route in &self.routes {
254            if !route_ids.insert(route.id.as_str()) {
255                return Err(GatewayProfileError::DuplicateRouteId {
256                    id: route.id.as_str().to_owned(),
257                });
258            }
259        }
260        if let Some(provider) = self.default_provider.as_ref()
261            && registry.find_provider(provider.as_str()).is_none()
262        {
263            return Err(GatewayProfileError::DefaultProviderNotInRegistry {
264                provider: provider.as_str().to_owned(),
265            });
266        }
267        for route in &self.routes {
268            if registry.find_provider(route.provider.as_str()).is_none() {
269                return Err(GatewayProfileError::RouteProviderNotInRegistry {
270                    route: route.model_pattern.clone(),
271                    provider: route.provider.as_str().to_owned(),
272                });
273            }
274            if let Some(when) = route.when.as_ref() {
275                when.validate()?;
276            }
277            self.validate_route_pricing(registry, route)?;
278        }
279        for rule in &self.system_prompt_overrides {
280            rule.validate()?;
281            if let Some(provider) = rule.provider.as_ref()
282                && registry.find_provider(provider.as_str()).is_none()
283            {
284                return Err(GatewayProfileError::OverrideProviderNotInRegistry {
285                    provider: provider.as_str().to_owned(),
286                });
287            }
288        }
289        Ok(())
290    }
291
292    /// Uncosted AI is a configuration bug, not a runtime warning: a route that
293    /// dispatches real inference must resolve to real rates, or the request is
294    /// billed at zero and the gap is invisible until someone reads the ledger.
295    ///
296    /// A route-level `pricing:` override covers everything the route dispatches
297    /// (it is what `pricing::resolve` prefers), so it short-circuits the check.
298    /// A route with an `upstream_model` rewrite dispatches every request to
299    /// that one model regardless of the pattern, so that model's rates are the
300    /// ones that must be usable. Otherwise every registry model the pattern can
301    /// reach must carry usable rates — and the pattern must reach at least one,
302    /// which is what catches a glob route pointed at a catalog that has fallen
303    /// behind the models actually in use.
304    fn validate_route_pricing(
305        &self,
306        registry: &ProviderRegistry,
307        route: &GatewayRoute,
308    ) -> GatewayResult<()> {
309        if !self.enabled {
310            return Ok(());
311        }
312        let route_id = route.id.as_str().to_owned();
313        if let Some(pricing) = route.pricing {
314            return if pricing.is_billable() {
315                Ok(())
316            } else {
317                Err(GatewayProfileError::RouteModelUnpriced {
318                    route: route_id,
319                    model: route.model_pattern.clone(),
320                })
321            };
322        }
323        let Some(entry) = route.resolve(registry) else {
324            return Ok(());
325        };
326        if let Some(upstream) = route.upstream_model.as_deref() {
327            return match entry.find_model(upstream) {
328                Some(model) if model.pricing.is_billable() => Ok(()),
329                Some(model) => Err(GatewayProfileError::RouteModelUnpriced {
330                    route: route_id,
331                    model: model.id.as_str().to_owned(),
332                }),
333                None => Err(GatewayProfileError::RouteReachesNoPricedModel {
334                    route: route_id,
335                    pattern: route.model_pattern.clone(),
336                    provider: route.provider.as_str().to_owned(),
337                }),
338            };
339        }
340        let mut reached = 0usize;
341        for model in entry.models.iter().filter(|m| route.matches(m.id.as_str())) {
342            reached += 1;
343            if !model.pricing.is_billable() {
344                return Err(GatewayProfileError::RouteModelUnpriced {
345                    route: route_id,
346                    model: model.id.as_str().to_owned(),
347                });
348            }
349        }
350        if reached == 0 {
351            return Err(GatewayProfileError::RouteReachesNoPricedModel {
352                route: route_id,
353                pattern: route.model_pattern.clone(),
354                provider: route.provider.as_str().to_owned(),
355            });
356        }
357        Ok(())
358    }
359
360    #[must_use]
361    pub fn to_spec(&self) -> GatewayConfigSpec {
362        GatewayConfigSpec {
363            enabled: self.enabled,
364            routes: self.routes.clone(),
365            default_provider: self.default_provider.clone(),
366            allow_unlisted_models: self.allow_unlisted_models,
367            auth_scheme: self.auth_scheme.clone(),
368            inference_path_prefix: self.inference_path_prefix.clone(),
369            system_prompt_overrides: self.system_prompt_overrides.clone(),
370            bridge_releases: self.bridge_releases.clone(),
371        }
372    }
373}