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 to this one endpoint, and if so what does
13//! it say it serves? Discovery runs per `(provider, endpoint)`: one vendor
14//! account can expose a listing on one wire and none on another. The next
15//! source — an Azure deployment listing, a self-hosted `/v1/models` — is a new
16//! implementation and no change to the caller.
17//!
18//! Copyright (c) systemprompt.io — Business Source License 1.1.
19//! See <https://systemprompt.io> for licensing details.
20
21use async_trait::async_trait;
22use systemprompt_manifest::services::catalog::EndpointRef;
23use systemprompt_security::credential::{AuthHeader, CredentialScope, ProviderCredential};
24use systemprompt_wire::WireProtocol;
25use thiserror::Error;
26
27/// One catalog endpoint a source may list: its address, wire and URL.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DiscoveryEndpoint {
30    pub endpoint: EndpointRef,
31    pub wire: WireProtocol,
32    pub url: String,
33}
34
35/// How far along an upstream considers a model to be.
36///
37/// Only two states matter to the pricing decision: a model an upstream calls
38/// generally available, and everything else, which is served only where the
39/// rate card explicitly opts in.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum LaunchStage {
42    GenerallyAvailable,
43    Preview,
44}
45
46impl LaunchStage {
47    #[must_use]
48    pub const fn is_generally_available(self) -> bool {
49        matches!(self, Self::GenerallyAvailable)
50    }
51}
52
53/// One model an upstream says it serves, reduced to what the decision needs.
54///
55/// `upstream` is how the rate card names it (e.g. `google/gemini-2.5-pro`)
56/// and `serverless` is whether it can be called without the operator
57/// deploying anything first.
58#[derive(Debug, Clone)]
59pub struct DiscoveredModel {
60    pub upstream: String,
61    pub launch_stage: LaunchStage,
62    pub serverless: bool,
63}
64
65/// What one source returned for one provider.
66///
67/// Failures travel beside the models rather than instead of them: a publisher
68/// we are not entitled to answers 403, and that must not cost us the
69/// publishers we are entitled to. Each string is already in the shape
70/// [`DiscoveryReport::failed_publishers`](systemprompt_manifest::services::DiscoveryReport)
71/// takes.
72#[derive(Debug, Default)]
73pub struct CatalogListing {
74    pub models: Vec<DiscoveredModel>,
75    pub failures: Vec<String>,
76}
77
78/// A listing that could not be attempted at all.
79#[derive(Debug, Error)]
80pub enum DiscoveryError {
81    #[error("endpoint '{endpoint}' is not a Vertex host")]
82    NotVertexHost { endpoint: String },
83
84    #[error("Model Garden requires a bearer token")]
85    BearerRequired,
86}
87
88/// An upstream that can be asked which models it serves.
89///
90/// `name` labels the source in report lines and logs. `matches_endpoint` is
91/// the cheap, credential-free question — could this source ever list this
92/// endpoint? — kept separate from `applies` (the same question with the
93/// secret parsed) so the caller knows whether a provider is worth parsing a
94/// secret for before it parses one, and so a malformed secret on an unrelated
95/// provider is not reported as a discovery failure. `list` asks the upstream
96/// what it serves.
97///
98/// `#[async_trait]` because sources are held as `dyn CatalogSource`.
99#[async_trait]
100pub trait CatalogSource: Send + Sync {
101    fn name(&self) -> &'static str;
102
103    fn matches_endpoint(&self, endpoint: &DiscoveryEndpoint) -> bool;
104
105    fn applies(&self, endpoint: &DiscoveryEndpoint, credential: &ProviderCredential) -> bool;
106
107    async fn list(
108        &self,
109        http: &reqwest::Client,
110        auth: &AuthHeader,
111        endpoint: &DiscoveryEndpoint,
112        scope: &CredentialScope,
113    ) -> Result<CatalogListing, DiscoveryError>;
114}