Skip to main content

systemprompt_models/profile/providers/
mod.rs

1//! Provider registry: the single source of upstream connectivity.
2//!
3//! [`ProviderRegistry`] is the per-environment `profile.providers` section.
4//! Each [`ProviderEntry`] declares one upstream exactly once — its
5//! [`WireProtocol`], endpoint, credential ([`SecretName`]), extra headers, and
6//! the model catalog it serves. The two policy layers reference entries by
7//! [`ProviderId`] and never re-declare connectivity: the gateway policy
8//! (`profile.gateway`) routes external model names to a provider, and the AI
9//! policy (`services/ai/config.yaml`) selects an agent default and per-provider
10//! overrides.
11//!
12//! Validation here is the authority for connectivity: unique provider names,
13//! SSRF-guarded endpoints, and globally-unique model ids/aliases. The gateway
14//! and AI layers validate only their references *into* this registry.
15//!
16//! Copyright (c) systemprompt.io — Business Source License 1.1.
17//! See <https://systemprompt.io> for licensing details.
18
19mod error;
20mod protocol;
21mod surface;
22
23use std::collections::{HashMap, HashSet};
24
25use serde::{Deserialize, Serialize};
26use systemprompt_identifiers::{ModelId, ProviderId, SecretName};
27
28use crate::services::ai::{ModelCapabilities, ModelGovernance, ModelLimits, ModelPricing};
29
30pub use error::{ProviderRegistryError, ProviderRegistryResult};
31pub use protocol::WireProtocol;
32pub use surface::ApiSurface;
33
34const DEFAULT_CATALOG_YAML: &str = include_str!("default_catalog.yaml");
35
36#[derive(Deserialize)]
37struct DefaultCatalogFile {
38    providers: Vec<ProviderEntry>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct ProviderModel {
44    pub id: ModelId,
45
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub aliases: Vec<ModelId>,
48
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub upstream_model: Option<String>,
51
52    #[serde(default)]
53    pub pricing: ModelPricing,
54
55    #[serde(default)]
56    pub capabilities: ModelCapabilities,
57
58    #[serde(default)]
59    pub limits: ModelLimits,
60
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub governance: Option<ModelGovernance>,
63}
64
65impl ProviderModel {
66    #[must_use]
67    pub fn matches(&self, requested: &str) -> bool {
68        self.id.as_str() == requested || self.aliases.iter().any(|a| a.as_str() == requested)
69    }
70
71    #[must_use]
72    pub fn effective_upstream_model<'a>(&'a self, requested: &'a str) -> &'a str {
73        self.upstream_model.as_deref().unwrap_or(requested)
74    }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
78#[serde(deny_unknown_fields)]
79pub struct ProviderEntry {
80    pub name: ProviderId,
81
82    pub wire: WireProtocol,
83
84    pub surface: ApiSurface,
85
86    pub endpoint: String,
87
88    pub api_key_secret: SecretName,
89
90    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91    pub extra_headers: HashMap<String, String>,
92
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub models: Vec<ProviderModel>,
95
96    #[serde(default)]
97    pub governance: ModelGovernance,
98}
99
100impl ProviderEntry {
101    #[must_use]
102    pub fn find_model(&self, requested: &str) -> Option<&ProviderModel> {
103        self.models.iter().find(|m| m.matches(requested))
104    }
105
106    #[must_use]
107    pub fn effective_governance(&self, requested: &str) -> ModelGovernance {
108        self.find_model(requested)
109            .and_then(|m| m.governance)
110            .unwrap_or(self.governance)
111    }
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
115#[serde(transparent)]
116pub struct ProviderRegistry {
117    pub providers: Vec<ProviderEntry>,
118}
119
120impl ProviderRegistry {
121    pub fn default_seed() -> ProviderRegistryResult<Self> {
122        let file: DefaultCatalogFile = serde_yaml::from_str(DEFAULT_CATALOG_YAML)
123            .map_err(|e| ProviderRegistryError::InvalidDefaultCatalog(e.to_string()))?;
124        Ok(Self {
125            providers: file.providers,
126        })
127    }
128
129    #[must_use]
130    pub fn find_provider(&self, name: &str) -> Option<&ProviderEntry> {
131        self.providers.iter().find(|p| p.name.as_str() == name)
132    }
133
134    #[must_use]
135    pub fn contains_model(&self, requested: &str) -> bool {
136        self.providers
137            .iter()
138            .any(|p| p.find_model(requested).is_some())
139    }
140
141    pub fn advertised_providers(&self) -> impl Iterator<Item = &ProviderEntry> {
142        self.providers
143            .iter()
144            .filter(|entry| entry.surface.is_advertised())
145    }
146
147    #[must_use]
148    pub fn advertised_model_ids(&self, surfaces: &[ApiSurface]) -> Vec<String> {
149        self.advertised_providers()
150            .filter(|entry| surfaces.is_empty() || surfaces.contains(&entry.surface))
151            .flat_map(|entry| {
152                entry.models.iter().flat_map(|m| {
153                    std::iter::once(m.id.as_str().to_owned())
154                        .chain(m.aliases.iter().map(|a| a.as_str().to_owned()))
155                })
156            })
157            .collect()
158    }
159
160    pub fn validate(&self) -> ProviderRegistryResult<()> {
161        let trusted = crate::net::trusted_http_hosts_from_env();
162        let mut seen_providers: HashSet<&str> = HashSet::with_capacity(self.providers.len());
163        let mut seen_models: HashSet<&str> = HashSet::new();
164
165        for provider in &self.providers {
166            if !seen_providers.insert(provider.name.as_str()) {
167                return Err(ProviderRegistryError::DuplicateProvider {
168                    name: provider.name.as_str().to_owned(),
169                });
170            }
171            if provider.endpoint.is_empty() {
172                return Err(ProviderRegistryError::EmptyEndpoint {
173                    name: provider.name.as_str().to_owned(),
174                });
175            }
176            crate::net::validate_outbound_url_with_trust(&provider.endpoint, &trusted).map_err(
177                |e| ProviderRegistryError::BlockedEndpoint {
178                    provider: provider.name.as_str().to_owned(),
179                    endpoint: provider.endpoint.clone(),
180                    reason: e.to_string(),
181                },
182            )?;
183
184            for model in &provider.models {
185                if model.id.as_str().is_empty() {
186                    return Err(ProviderRegistryError::EmptyModelId {
187                        id: provider.name.as_str().to_owned(),
188                    });
189                }
190                if !seen_models.insert(model.id.as_str()) {
191                    return Err(ProviderRegistryError::DuplicateModel {
192                        id: model.id.as_str().to_owned(),
193                    });
194                }
195                for alias in &model.aliases {
196                    if !seen_models.insert(alias.as_str()) {
197                        return Err(ProviderRegistryError::DuplicateModel {
198                            id: alias.as_str().to_owned(),
199                        });
200                    }
201                }
202            }
203        }
204        Ok(())
205    }
206}