pub mod classify;
pub mod client;
pub mod merge;
pub mod source;
pub mod vertex;
use std::collections::HashSet;
use std::time::Duration;
use systemprompt_models::services::{DiscoveryReport, ProviderRegistry, VertexRateCard};
use systemprompt_security::credential::ProviderCredential;
use classify::Classification;
use source::{CatalogListing, CatalogSource};
use vertex::VertexCatalog;
pub type SecretLookup<'a> = &'a (dyn Fn(&str) -> Option<String> + Sync);
#[must_use]
pub fn default_sources(card: VertexRateCard) -> Vec<Box<dyn CatalogSource>> {
vec![Box::new(VertexCatalog::new(card))]
}
struct Plan<'a> {
index: usize,
source: &'a dyn CatalogSource,
credential: ProviderCredential,
secret_name: String,
}
pub async fn discover(
providers: &mut ProviderRegistry,
secret: SecretLookup<'_>,
timeout: Duration,
) -> DiscoveryReport {
let mut report = DiscoveryReport {
ran_at: chrono::Utc::now().to_rfc3339(),
..DiscoveryReport::default()
};
let card = match VertexRateCard::embedded() {
Ok(card) => card,
Err(e) => {
tracing::warn!("catalog discovery skipped: {e}");
report.failed_publishers.push(format!("rate card: {e}"));
return report;
},
};
let sources = default_sources(card.clone());
let catalog = Catalog {
sources: &sources,
card: &card,
};
discover_with(providers, secret, timeout, catalog, &mut report).await;
report
}
#[derive(Clone, Copy)]
pub struct Catalog<'a> {
pub sources: &'a [Box<dyn CatalogSource>],
pub card: &'a VertexRateCard,
}
impl std::fmt::Debug for Catalog<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Catalog")
.field("sources", &self.sources.len())
.field("card_entries", &self.card.entries.len())
.finish()
}
}
pub async fn discover_with(
providers: &mut ProviderRegistry,
secret: SecretLookup<'_>,
timeout: Duration,
catalog: Catalog<'_>,
report: &mut DiscoveryReport,
) {
let plans = plan(providers, secret, catalog.sources, report);
if plans.is_empty() {
return;
}
let http = reqwest::Client::new();
for plan in plans {
let Some(provider) = providers.providers.get(plan.index) else {
continue;
};
let name = provider.name.as_str().to_owned();
let auth = match plan.credential.bearer(&plan.secret_name).await {
Ok(auth) => auth,
Err(e) => {
push_failure(
report,
format!("{name}: could not mint an access token: {e}"),
);
continue;
},
};
let scope = plan.credential.scope();
let listing = plan.source.list(&http, &auth, provider, &scope);
let listing = match tokio::time::timeout(timeout, listing).await {
Ok(Ok(listing)) => listing,
Ok(Err(e)) => {
push_failure(report, format!("{name}: {e}"));
continue;
},
Err(_) => {
push_failure(
report,
format!("{name}: discovery timed out after {}s", timeout.as_secs()),
);
continue;
},
};
absorb(providers, plan.index, catalog.card, listing, report);
}
}
fn push_failure(report: &mut DiscoveryReport, note: String) {
tracing::warn!("catalog discovery: {note}");
report.failed_publishers.push(note);
}
fn plan<'a>(
providers: &ProviderRegistry,
secret: SecretLookup<'_>,
sources: &'a [Box<dyn CatalogSource>],
report: &mut DiscoveryReport,
) -> Vec<Plan<'a>> {
let mut plans = Vec::new();
for (index, entry) in providers.providers.iter().enumerate() {
if !sources.iter().any(|s| s.matches_provider(entry)) {
continue;
}
let secret_name = entry.api_key_secret.as_str().to_owned();
let Some(value) = secret(&secret_name) else {
continue;
};
let credential = match ProviderCredential::parse(&value) {
Ok(credential) => credential,
Err(e) => {
report
.failed_publishers
.push(format!("{}: {e}", entry.name.as_str()));
continue;
},
};
if let Some(source) = sources.iter().find(|s| s.applies(entry, &credential)) {
plans.push(Plan {
index,
source: source.as_ref(),
credential,
secret_name,
});
}
}
plans
}
fn absorb(
providers: &mut ProviderRegistry,
index: usize,
card: &VertexRateCard,
listing: CatalogListing,
report: &mut DiscoveryReport,
) {
for failure in listing.failures {
push_failure(report, failure);
}
let Some(provider) = providers.providers.get_mut(index) else {
return;
};
let name = provider.name.as_str().to_owned();
let name = name.as_str();
let mut seen: HashSet<String> = HashSet::new();
let today = chrono::Utc::now().date_naive();
for model in &listing.models {
let (classification, entry) = classify::classify_discovered(model, card, name);
match classification {
Classification::NotServerless => {},
Classification::Unpriced => {
merge::record_unpriced(model.upstream.clone(), report);
},
Classification::PreviewWithheld => {
if let Some(entry) = entry {
seen.insert(entry.upstream.clone());
}
},
Classification::Publish => {
if let Some(entry) = entry {
seen.insert(entry.upstream.clone());
merge::publish(provider, entry, today, report);
}
},
}
}
merge::record_unseen(card, name, &seen, report);
}