systemprompt-loader 0.79.0

File and module discovery infrastructure for systemprompt.io AI governance — manifests, schemas, and extension loading. Separates I/O from shared models in the MCP governance pipeline.
Documentation
//! The seam that makes catalog discovery a provider capability.
//!
//! Discovery began as one Vertex-shaped function. The parts that are actually
//! Vertex-shaped are narrow — which host counts, which credential can list it,
//! and the wire format of the listing call — and everything else (deciding
//! what is priced, what wins against an explicit declaration, what goes in the
//! report) is policy this deployment applies to any upstream that can be
//! asked what it serves.
//!
//! So a [`CatalogSource`] answers only the narrow questions, in a vocabulary
//! that carries no Google in it: given a provider and the credential its
//! secret parsed into, do I apply to this one endpoint, and if so what does
//! it say it serves? Discovery runs per `(provider, endpoint)`: one vendor
//! account can expose a listing on one wire and none on another. The next
//! source — an Azure deployment listing, a self-hosted `/v1/models` — is a new
//! implementation and no change to the caller.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use async_trait::async_trait;
use systemprompt_manifest::services::catalog::EndpointRef;
use systemprompt_security::credential::{AuthHeader, CredentialScope, ProviderCredential};
use systemprompt_wire::WireProtocol;
use thiserror::Error;

/// One catalog endpoint a source may list: its address, wire and URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveryEndpoint {
    pub endpoint: EndpointRef,
    pub wire: WireProtocol,
    pub url: String,
}

/// How far along an upstream considers a model to be.
///
/// Only two states matter to the pricing decision: a model an upstream calls
/// generally available, and everything else, which is served only where the
/// rate card explicitly opts in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchStage {
    GenerallyAvailable,
    Preview,
}

impl LaunchStage {
    #[must_use]
    pub const fn is_generally_available(self) -> bool {
        matches!(self, Self::GenerallyAvailable)
    }
}

/// One model an upstream says it serves, reduced to what the decision needs.
///
/// `upstream` is how the rate card names it (e.g. `google/gemini-2.5-pro`)
/// and `serverless` is whether it can be called without the operator
/// deploying anything first.
#[derive(Debug, Clone)]
pub struct DiscoveredModel {
    pub upstream: String,
    pub launch_stage: LaunchStage,
    pub serverless: bool,
}

/// What one source returned for one provider.
///
/// Failures travel beside the models rather than instead of them: a publisher
/// we are not entitled to answers 403, and that must not cost us the
/// publishers we are entitled to. Each string is already in the shape
/// [`DiscoveryReport::failed_publishers`](systemprompt_manifest::services::DiscoveryReport)
/// takes.
#[derive(Debug, Default)]
pub struct CatalogListing {
    pub models: Vec<DiscoveredModel>,
    pub failures: Vec<String>,
}

/// A listing that could not be attempted at all.
#[derive(Debug, Error)]
pub enum DiscoveryError {
    #[error("endpoint '{endpoint}' is not a Vertex host")]
    NotVertexHost { endpoint: String },

    #[error("Model Garden requires a bearer token")]
    BearerRequired,
}

/// An upstream that can be asked which models it serves.
///
/// `name` labels the source in report lines and logs. `matches_endpoint` is
/// the cheap, credential-free question — could this source ever list this
/// endpoint? — kept separate from `applies` (the same question with the
/// secret parsed) so the caller knows whether a provider is worth parsing a
/// secret for before it parses one, and so a malformed secret on an unrelated
/// provider is not reported as a discovery failure. `list` asks the upstream
/// what it serves.
///
/// `#[async_trait]` because sources are held as `dyn CatalogSource`.
#[async_trait]
pub trait CatalogSource: Send + Sync {
    fn name(&self) -> &'static str;

    fn matches_endpoint(&self, endpoint: &DiscoveryEndpoint) -> bool;

    fn applies(&self, endpoint: &DiscoveryEndpoint, credential: &ProviderCredential) -> bool;

    async fn list(
        &self,
        http: &reqwest::Client,
        auth: &AuthHeader,
        endpoint: &DiscoveryEndpoint,
        scope: &CredentialScope,
    ) -> Result<CatalogListing, DiscoveryError>;
}