Skip to main content

systemprompt_models/profile/gateway/
catalog.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4use systemprompt_identifiers::{ModelId, ProviderId, SecretName};
5
6use super::error::{GatewayProfileError, GatewayResult};
7use crate::services::ai::ModelPricing;
8
9/// Reject gateway upstream endpoints that point at the local host or private
10/// network ranges; an operator-configured endpoint pointing at
11/// `169.254.169.254` or an internal service would otherwise turn the inference
12/// proxy into an SSRF primitive. Delegates to the shared outbound-URL guard so
13/// gateway, webhook, and authz destinations enforce one policy.
14pub(super) fn validate_endpoint(label: &str, endpoint: &str) -> GatewayResult<()> {
15    // SYSTEMPROMPT_TRUSTED_HTTP_HOSTS is the sealed-network opt-in for
16    // known-internal hostnames like the air-gap mock; empty when unset, so
17    // production deployments keep the strict loopback-only http rule.
18    let trusted = crate::net::trusted_http_hosts_from_env();
19    crate::net::validate_outbound_url_with_trust(endpoint, &trusted)
20        .map(|_| ())
21        .map_err(|e| GatewayProfileError::BlockedEndpoint {
22            label: label.to_owned(),
23            endpoint: endpoint.to_owned(),
24            reason: e.to_string(),
25        })
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
29#[serde(deny_unknown_fields)]
30pub struct GatewayCatalog {
31    #[serde(default)]
32    pub providers: Vec<GatewayProvider>,
33    #[serde(default)]
34    pub models: Vec<GatewayModel>,
35}
36
37impl GatewayCatalog {
38    pub fn validate(&self) -> GatewayResult<()> {
39        for model in &self.models {
40            if model.id.as_str().is_empty() {
41                return Err(GatewayProfileError::ModelEmptyId);
42            }
43            if !self.providers.iter().any(|p| p.name == model.provider) {
44                return Err(GatewayProfileError::UnknownProvider {
45                    model: model.id.as_str().to_owned(),
46                    provider: model.provider.as_str().to_owned(),
47                });
48            }
49        }
50        for provider in &self.providers {
51            if provider.name.as_str().is_empty() {
52                return Err(GatewayProfileError::ProviderEmptyName);
53            }
54            if provider.endpoint.is_empty() {
55                return Err(GatewayProfileError::ProviderEmptyEndpoint {
56                    name: provider.name.as_str().to_owned(),
57                });
58            }
59            validate_endpoint(
60                &format!("provider '{}'", provider.name.as_str()),
61                &provider.endpoint,
62            )?;
63        }
64        Ok(())
65    }
66
67    pub fn find_provider(&self, name: &str) -> Option<&GatewayProvider> {
68        self.providers.iter().find(|p| p.name.as_str() == name)
69    }
70
71    #[must_use]
72    pub fn contains_model(&self, requested: &str) -> bool {
73        self.models.iter().any(|m| {
74            m.id.as_str() == requested || m.aliases.iter().any(|a| a.as_str() == requested)
75        })
76    }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
80#[serde(deny_unknown_fields)]
81pub struct GatewayProvider {
82    pub name: ProviderId,
83    pub endpoint: String,
84    pub api_key_secret: SecretName,
85    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
86    pub extra_headers: HashMap<String, String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
90#[serde(deny_unknown_fields)]
91pub struct GatewayModel {
92    pub id: ModelId,
93    pub provider: ProviderId,
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub aliases: Vec<ModelId>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub display_name: Option<String>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub upstream_model: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub pricing: Option<ModelPricing>,
102}