systemprompt_models/profile/gateway/
config.rs1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub default_provider: Option<ProviderId>,
37 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub bridge_releases: Option<BridgeReleasesSpec>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct BridgeReleasesSpec {
65 pub repo: String,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub token_env: Option<String>,
72 #[serde(default = "default_tag_prefix")]
75 pub tag_prefix: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub pinned_version: Option<String>,
79 #[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#[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 #[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 #[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 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}