Skip to main content

systemprompt_loader/vertex_discovery/
source.rs

1//! The seam that makes catalog discovery a provider capability.
2//!
3//! Discovery began as one Vertex-shaped function. The parts that are actually
4//! Vertex-shaped are narrow — which host counts, which credential can list it,
5//! and the wire format of the listing call — and everything else (deciding
6//! what is priced, what wins against an explicit declaration, what goes in the
7//! report) is policy this deployment applies to any upstream that can be
8//! asked what it serves.
9//!
10//! So a [`CatalogSource`] answers only the narrow questions, in a vocabulary
11//! that carries no Google in it: given a provider and the credential its
12//! secret parsed into, do I apply, and if so what does this upstream say it
13//! serves? The next source — an Azure deployment listing, a self-hosted
14//! `/v1/models` — is a new implementation and no change to the caller.
15//!
16//! Copyright (c) systemprompt.io — Business Source License 1.1.
17//! See <https://systemprompt.io> for licensing details.
18
19use async_trait::async_trait;
20use systemprompt_models::services::ProviderEntry;
21use systemprompt_security::credential::{AuthHeader, CredentialScope, ProviderCredential};
22use thiserror::Error;
23
24/// How far along an upstream considers a model to be.
25///
26/// Only two states matter to the pricing decision: a model an upstream calls
27/// generally available, and everything else, which is served only where the
28/// rate card explicitly opts in.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum LaunchStage {
31    GenerallyAvailable,
32    Preview,
33}
34
35impl LaunchStage {
36    #[must_use]
37    pub const fn is_generally_available(self) -> bool {
38        matches!(self, Self::GenerallyAvailable)
39    }
40}
41
42/// One model an upstream says it serves, reduced to what the decision needs.
43///
44/// `upstream` is how the rate card names it (e.g. `google/gemini-2.5-pro`)
45/// and `serverless` is whether it can be called without the operator
46/// deploying anything first.
47#[derive(Debug, Clone)]
48pub struct DiscoveredModel {
49    pub upstream: String,
50    pub launch_stage: LaunchStage,
51    pub serverless: bool,
52}
53
54/// What one source returned for one provider.
55///
56/// Failures travel beside the models rather than instead of them: a publisher
57/// we are not entitled to answers 403, and that must not cost us the
58/// publishers we are entitled to. Each string is already in the shape
59/// [`DiscoveryReport::failed_publishers`](systemprompt_models::services::DiscoveryReport)
60/// takes.
61#[derive(Debug, Default)]
62pub struct CatalogListing {
63    pub models: Vec<DiscoveredModel>,
64    pub failures: Vec<String>,
65}
66
67/// A listing that could not be attempted at all.
68#[derive(Debug, Error)]
69pub enum DiscoveryError {
70    #[error("{0}")]
71    Unusable(String),
72}
73
74/// An upstream that can be asked which models it serves.
75///
76/// `name` labels the source in report lines and logs. `matches_provider` is
77/// the cheap, credential-free question — could this source ever list this
78/// provider? — kept separate from `applies` (the same question with the
79/// secret parsed) so the caller knows whether a provider is worth parsing a
80/// secret for before it parses one, and so a malformed secret on an unrelated
81/// provider is not reported as a discovery failure. `list` asks the upstream
82/// what it serves.
83///
84/// `#[async_trait]` because sources are held as `dyn CatalogSource`.
85#[async_trait]
86pub trait CatalogSource: Send + Sync {
87    fn name(&self) -> &'static str;
88
89    fn matches_provider(&self, provider: &ProviderEntry) -> bool;
90
91    fn applies(&self, provider: &ProviderEntry, credential: &ProviderCredential) -> bool;
92
93    async fn list(
94        &self,
95        http: &reqwest::Client,
96        auth: &AuthHeader,
97        provider: &ProviderEntry,
98        scope: &CredentialScope,
99    ) -> Result<CatalogListing, DiscoveryError>;
100}