Skip to main content

systemprompt_models/services/providers/
mod.rs

1//! Provider registry: the single source of upstream connectivity.
2//!
3//! [`ProviderRegistry`] is the `providers:` list of the services tree
4//! (`services/ai/providers.yaml` by convention, merged across includes). It is
5//! implementation configuration shipped with the deployment, not a
6//! per-environment profile section: the same catalog boots every environment
7//! and only the credentials it names differ. Each [`ProviderEntry`] declares
8//! one upstream exactly once — its
9//! [`WireProtocol`], endpoint, credential ([`SecretName`]), extra headers, and
10//! the model catalog it serves. The two policy layers reference entries by
11//! [`ProviderId`] and never re-declare connectivity: the gateway policy
12//! (`services.gateway`) routes external model names to a provider, and the AI
13//! policy (`services/ai/config.yaml`) selects an agent default and per-provider
14//! overrides.
15//!
16//! Validation here is the authority for connectivity: unique provider names,
17//! SSRF-guarded endpoints, and globally-unique model ids/aliases. The gateway
18//! and AI layers validate only their references *into* this registry.
19//!
20//! Copyright (c) systemprompt.io — Business Source License 1.1.
21//! See <https://systemprompt.io> for licensing details.
22
23mod discovery_report;
24mod error;
25mod protocol;
26mod rate_card;
27mod surface;
28
29use std::collections::{HashMap, HashSet};
30
31use serde::{Deserialize, Serialize};
32use systemprompt_identifiers::{ModelId, ProviderId, SecretName};
33
34use crate::services::ai::{ModelCapabilities, ModelGovernance, ModelLimits, ModelPricing};
35
36pub use discovery_report::DiscoveryReport;
37pub use error::{ProviderRegistryError, ProviderRegistryResult};
38pub use protocol::WireProtocol;
39pub use rate_card::{
40    DocumentedLaunchStage, RETIREMENT_NOTICE_DAYS, VertexRateCard, VertexRateCardEntry,
41};
42pub use surface::ApiSurface;
43
44const DEFAULT_CATALOG_YAML: &str = include_str!("default_catalog.yaml");
45
46#[derive(Deserialize)]
47struct DefaultCatalogFile {
48    providers: Vec<ProviderEntry>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
52#[serde(deny_unknown_fields)]
53pub struct ProviderModel {
54    pub id: ModelId,
55
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub aliases: Vec<ModelId>,
58
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub upstream_model: Option<String>,
61
62    #[serde(default)]
63    pub pricing: ModelPricing,
64
65    #[serde(default)]
66    pub capabilities: ModelCapabilities,
67
68    #[serde(default)]
69    pub limits: ModelLimits,
70
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub governance: Option<ModelGovernance>,
73}
74
75impl ProviderModel {
76    #[must_use]
77    pub fn matches(&self, requested: &str) -> bool {
78        self.id.as_str() == requested || self.aliases.iter().any(|a| a.as_str() == requested)
79    }
80
81    #[must_use]
82    pub fn effective_upstream_model<'a>(&'a self, requested: &'a str) -> &'a str {
83        self.upstream_model.as_deref().unwrap_or(requested)
84    }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
88#[serde(deny_unknown_fields)]
89pub struct ProviderEntry {
90    pub name: ProviderId,
91
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub display_name: Option<String>,
94
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub description: Option<String>,
97
98    pub wire: WireProtocol,
99
100    pub surface: ApiSurface,
101
102    pub endpoint: String,
103
104    pub api_key_secret: SecretName,
105
106    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
107    pub extra_headers: HashMap<String, String>,
108
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub models: Vec<ProviderModel>,
111
112    #[serde(default)]
113    pub governance: ModelGovernance,
114}
115
116impl ProviderEntry {
117    #[must_use]
118    pub fn find_model(&self, requested: &str) -> Option<&ProviderModel> {
119        self.models.iter().find(|m| m.matches(requested))
120    }
121
122    #[must_use]
123    pub fn upstream_model_for<'a>(
124        &'a self,
125        route_override: Option<&'a str>,
126        requested: &'a str,
127    ) -> &'a str {
128        let name = route_override.unwrap_or(requested);
129        self.find_model(name)
130            .map_or(name, |model| model.effective_upstream_model(name))
131    }
132
133    #[must_use]
134    pub fn effective_governance(&self, requested: &str) -> ModelGovernance {
135        self.find_model(requested)
136            .and_then(|m| m.governance)
137            .unwrap_or(self.governance)
138    }
139}
140
141#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
142#[serde(transparent)]
143pub struct ProviderRegistry {
144    pub providers: Vec<ProviderEntry>,
145}
146
147impl ProviderRegistry {
148    pub fn default_seed() -> ProviderRegistryResult<Self> {
149        let file: DefaultCatalogFile = serde_yaml::from_str(DEFAULT_CATALOG_YAML)
150            .map_err(|e| ProviderRegistryError::InvalidDefaultCatalog(e.to_string()))?;
151        Ok(Self {
152            providers: file.providers,
153        })
154    }
155
156    #[must_use]
157    pub fn find_provider(&self, name: &str) -> Option<&ProviderEntry> {
158        self.providers.iter().find(|p| p.name.as_str() == name)
159    }
160
161    #[must_use]
162    pub fn contains_model(&self, requested: &str) -> bool {
163        self.providers
164            .iter()
165            .any(|p| p.find_model(requested).is_some())
166    }
167
168    pub fn advertised_providers(&self) -> impl Iterator<Item = &ProviderEntry> {
169        self.providers
170            .iter()
171            .filter(|entry| entry.surface.is_advertised())
172    }
173
174    #[must_use]
175    pub fn advertised_model_ids(&self, surfaces: &[ApiSurface]) -> Vec<String> {
176        self.advertised_providers()
177            .filter(|entry| surfaces.is_empty() || surfaces.contains(&entry.surface))
178            .flat_map(|entry| {
179                entry.models.iter().flat_map(|m| {
180                    std::iter::once(m.id.as_str().to_owned())
181                        .chain(m.aliases.iter().map(|a| a.as_str().to_owned()))
182                })
183            })
184            .collect()
185    }
186
187    pub fn validate(&self) -> ProviderRegistryResult<()> {
188        let trusted = crate::net::trusted_http_hosts_from_env();
189        let mut seen_providers: HashSet<&str> = HashSet::with_capacity(self.providers.len());
190        let mut seen_models: HashSet<&str> = HashSet::new();
191
192        for provider in &self.providers {
193            if !seen_providers.insert(provider.name.as_str()) {
194                return Err(ProviderRegistryError::DuplicateProvider {
195                    name: provider.name.as_str().to_owned(),
196                });
197            }
198            if provider.endpoint.is_empty() {
199                return Err(ProviderRegistryError::EmptyEndpoint {
200                    name: provider.name.as_str().to_owned(),
201                });
202            }
203            crate::net::validate_outbound_url_with_trust(&provider.endpoint, &trusted).map_err(
204                |e| ProviderRegistryError::BlockedEndpoint {
205                    provider: provider.name.as_str().to_owned(),
206                    endpoint: provider.endpoint.clone(),
207                    reason: e.to_string(),
208                },
209            )?;
210            if names_a_project_literally(&provider.endpoint) {
211                return Err(ProviderRegistryError::LiteralProjectInEndpoint {
212                    provider: provider.name.as_str().to_owned(),
213                    endpoint: provider.endpoint.clone(),
214                });
215            }
216
217            for model in &provider.models {
218                if model.id.as_str().is_empty() {
219                    return Err(ProviderRegistryError::EmptyModelId {
220                        id: provider.name.as_str().to_owned(),
221                    });
222                }
223                if !seen_models.insert(model.id.as_str()) {
224                    return Err(ProviderRegistryError::DuplicateModel {
225                        id: model.id.as_str().to_owned(),
226                    });
227                }
228                for alias in &model.aliases {
229                    if !seen_models.insert(alias.as_str()) {
230                        return Err(ProviderRegistryError::DuplicateModel {
231                            id: alias.as_str().to_owned(),
232                        });
233                    }
234                }
235            }
236        }
237        Ok(())
238    }
239}
240
241pub const PROJECT_PLACEHOLDER: &str = "{project}";
242
243// Why: the placeholder names live here, beside the registry that validates
244// endpoints, so that the credential layer that fills them and the validator
245// that polices them can never disagree about their spelling. `{region}` has
246// no filler today — no shipped credential type carries a region — and an
247// endpoint using it is refused until one does, which is the intended shape:
248// a coordinate is served by the credential or not at all.
249pub const REGION_PLACEHOLDER: &str = "{region}";
250
251// Why: a Google Cloud project id is a tenant identifier, and Vertex reports it
252// verbatim in every IAM error it returns, which the gateway relays to the
253// caller. A catalog that names one literally therefore ships that id to every
254// installation of the image and every client that trips a 403. The id lives in
255// exactly one place, the service-account key, and the endpoint says
256// `{project}` instead.
257#[must_use]
258pub fn names_a_project_literally(endpoint: &str) -> bool {
259    let Ok(url) = url::Url::parse(endpoint) else {
260        return false;
261    };
262    let on_vertex = url.host_str().is_some_and(|host| {
263        host.eq_ignore_ascii_case("aiplatform.googleapis.com")
264            || host
265                .to_ascii_lowercase()
266                .ends_with("-aiplatform.googleapis.com")
267    });
268    if !on_vertex {
269        return false;
270    }
271    let mut segments = url.path_segments().into_iter().flatten();
272    while let Some(segment) = segments.next() {
273        if segment == "projects" {
274            return segments
275                .next()
276                .is_some_and(|id| id != "%7Bproject%7D" && id != PROJECT_PLACEHOLDER);
277        }
278    }
279    false
280}