use super::{selection::EffortOptions, startup::RuntimeContext};
use crate::{cancellation::AgentCancellation, config::EffectiveConfig};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::time::{Duration, Instant};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct CatalogParams {
revision: Option<String>,
cursor: Option<String>,
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize {
100
}
impl CatalogParams {
pub(super) fn parse(params: &serde_json::value::RawValue) -> Result<Self, &'static str> {
let params: Self = serde_json::from_str(params.get()).map_err(|_| "invalid_params")?;
if params.limit == 0
|| params.limit > 100
|| params
.revision
.as_deref()
.is_some_and(|r| !super::valid_id(r))
|| params
.cursor
.as_deref()
.is_some_and(|c| !super::valid_id(c))
|| params.revision.is_some() != params.cursor.is_some()
{
return Err("invalid_params");
}
Ok(params)
}
}
#[derive(Serialize)]
pub(super) struct Choice {
pub(super) selection_id: String,
pub(super) provider: String,
pub(super) model: String,
provider_name: Option<String>,
model_name: Option<String>,
pub(super) availability: &'static str,
#[serde(flatten)]
pub(super) effort: EffortOptions,
}
pub(super) struct Snapshot {
pub(super) revision: String,
pub(super) fingerprint: [u8; 32],
pub(super) choices: Vec<Choice>,
notices: Vec<&'static str>,
invalidated: bool,
}
pub(super) fn provider_ready(config: &EffectiveConfig) -> bool {
if config.provider_id() == crate::providers::OPENAI_CODEX_PROVIDER {
crate::auth::read_auth_store(&config.paths)
.ok()
.is_some_and(|store| {
crate::auth::classify_provider_auth_record(
config.provider_id(),
store.auth().providers.get(config.provider_id()),
chrono::Utc::now().timestamp(),
)
.is_ready()
})
} else {
config.auth_state().is_ready()
}
}
fn display_name(name: &str) -> Option<String> {
if name.len() > 256
|| name
.split_whitespace()
.any(crate::config::looks_like_secret_value)
{
return None;
}
let sanitized = crate::output::sanitize_display_text(name);
(!sanitized.trim().is_empty() && sanitized.len() <= 256).then_some(sanitized)
}
impl RuntimeContext {
pub(super) fn scan_catalog(
&self,
cancellation: &AgentCancellation,
) -> Result<Snapshot, &'static str> {
let deadline = Instant::now() + Duration::from_secs(2);
let settings = &self.launch.settings;
let mut providers = vec![
crate::providers::OPENAI_CODEX_PROVIDER.to_owned(),
crate::providers::ANTHROPIC_PROVIDER.to_owned(),
crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER.to_owned(),
];
providers.extend(settings.custom_providers.keys().cloned());
providers.sort();
providers.dedup();
if providers.len() > 32 {
return Err("catalog_limit");
}
let enabled = crate::config::enabled_model_ids_from_settings(settings);
let mut remaining = 8 * 1024 * 1024;
let mut scanned_entries = 0;
let mut choices = Vec::new();
let mut notices = Vec::new();
let mut digest = Sha256::new();
for provider in providers {
cancellation.check().map_err(|_| "operation_cancelled")?;
if Instant::now() >= deadline {
return Err("catalog_limit");
}
digest.update(provider.len().to_le_bytes());
digest.update(&provider);
let cached = crate::model_catalog::read_selection_catalog(
&self.launch.config.paths,
&provider,
settings,
remaining.min(1024 * 1024),
)?;
remaining -= cached.bytes_read;
digest.update(cached.fingerprint);
let Some(entries) = cached.entries else {
if !notices.contains(&"provider_catalog_unavailable") {
notices.push("provider_catalog_unavailable");
}
continue;
};
scanned_entries += entries.len();
if scanned_entries > 2048 {
return Err("catalog_limit");
}
let config = crate::config::load_effective_provider_selection_with_settings(
&self.launch.config.paths,
&provider,
"catalog-readiness",
settings,
);
let ready = config.as_ref().is_ok_and(provider_ready);
digest.update([u8::from(ready)]);
for entry in entries {
cancellation.check().map_err(|_| "operation_cancelled")?;
if !enabled.contains(&entry.id)
|| crate::config::reject_retired_provider_selection(
&entry.provider,
&entry.model,
)
.is_err()
{
continue;
}
if entry.provider.len() > 256 || entry.model.len() > 256 {
return Err("catalog_limit");
}
if super::safe_name(&entry.provider).is_none()
|| super::safe_name(&entry.model).is_none()
{
if !notices.contains(&"catalog_entries_omitted_unsafe") {
notices.push("catalog_entries_omitted_unsafe");
}
continue;
}
let scope = crate::thinking::capability_scope_for_provider(
&settings.custom_providers,
&entry.provider,
);
let effort =
EffortOptions::for_model(&entry.provider, &entry.model, Some(&entry), scope);
let selection_id = crate::hex::lower_hex(Sha256::digest(
serde_json::to_vec(&(&entry.provider, &entry.model))
.map_err(|_| "catalog_unavailable")?,
));
let provider_name = settings
.custom_providers
.get(&entry.provider)
.and_then(|p| display_name(&p.label));
choices.push(Choice {
selection_id,
provider: entry.provider,
model: entry.model,
provider_name,
model_name: entry.display_name.as_deref().and_then(display_name),
availability: if ready {
"available"
} else {
"authentication_unavailable"
},
effort,
});
}
}
choices.sort_by(|a, b| (&a.provider, &a.model).cmp(&(&b.provider, &b.model)));
if choices
.windows(2)
.any(|pair| pair[0].selection_id == pair[1].selection_id)
{
return Err("catalog_unavailable");
}
if serde_json::to_vec(&choices)
.map_err(|_| "catalog_limit")?
.len()
> 1024 * 1024
{
return Err("catalog_limit");
}
cancellation.check().map_err(|_| "operation_cancelled")?;
if Instant::now() >= deadline {
return Err("catalog_limit");
}
Ok(Snapshot {
revision: uuid::Uuid::new_v4().to_string(),
fingerprint: digest.finalize().into(),
choices,
notices,
invalidated: false,
})
}
pub(super) fn catalog_page(
&mut self,
params: CatalogParams,
cancellation: &AgentCancellation,
) -> Result<Value, &'static str> {
let scan = self.scan_catalog(cancellation);
if params.cursor.is_some() {
let stored = self.catalog.as_mut().ok_or("stale_catalog")?;
let scanned = match scan {
Ok(scanned) => scanned,
Err("operation_cancelled") => return Err("operation_cancelled"),
Err(_) => {
stored.invalidated = true;
return Err("stale_catalog");
}
};
if stored.invalidated
|| Some(&stored.revision) != params.revision.as_ref()
|| stored.fingerprint != scanned.fingerprint
{
stored.invalidated = true;
return Err("stale_catalog");
}
} else {
self.catalog = None;
self.catalog = Some(scan?);
}
let stored = self.catalog.as_ref().ok_or("stale_catalog")?;
let start = match params.cursor {
None => 0,
Some(cursor) => cursor
.strip_prefix(&format!("{}.", stored.revision))
.and_then(|v| v.parse::<usize>().ok())
.ok_or("stale_catalog")?,
};
if start > stored.choices.len() {
return Err("stale_catalog");
}
let mut end = start;
let mut bytes = 512;
for entry in stored.choices[start..].iter().take(params.limit) {
let cost = serde_json::to_vec(entry)
.map_err(|_| "catalog_limit")?
.len()
+ 1;
if bytes + cost > 32_768 {
break;
}
bytes += cost;
end += 1;
}
if end == start && start < stored.choices.len() {
return Err("catalog_limit");
}
let next = (end < stored.choices.len()).then(|| format!("{}.{}", stored.revision, end));
let result = json!({"revision":stored.revision,"choices":&stored.choices[start..end],
"next_cursor":next,"complete":next.is_none(),"notices":stored.notices,"source":"cache_only"});
if serde_json::to_vec(&result)
.map_err(|_| "catalog_limit")?
.len()
> 32_768
{
return Err("catalog_limit");
}
self.sync_selection_effort();
Ok(result)
}
pub(super) fn validate_catalog_revision(
&mut self,
revision: &str,
cancellation: &AgentCancellation,
) -> Result<(), &'static str> {
let stored = self.catalog.as_ref().ok_or("stale_catalog")?;
if stored.invalidated || stored.revision != revision {
return Err("stale_catalog");
}
let scanned = match self.scan_catalog(cancellation) {
Ok(scanned) => scanned,
Err("operation_cancelled") => return Err("operation_cancelled"),
Err(_) => {
if let Some(stored) = self.catalog.as_mut() {
stored.invalidated = true;
}
return Err("stale_catalog");
}
};
if self
.catalog
.as_ref()
.is_none_or(|stored| stored.fingerprint != scanned.fingerprint)
{
if let Some(stored) = self.catalog.as_mut() {
stored.invalidated = true;
}
return Err("stale_catalog");
}
Ok(())
}
}