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(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RefreshParams {
provider: String,
}
impl RefreshParams {
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 super::safe_name(¶ms.provider).is_none() || params.provider.len() > 256 {
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,
providers: Vec<ProviderStatus>,
}
#[derive(Serialize)]
struct ProviderStatus {
provider: String,
catalog_status: &'static str,
authentication: &'static str,
relevant: bool,
recovery: &'static str,
}
pub(super) fn provider_ready(config: &EffectiveConfig) -> bool {
if config.provider_id() == crate::provider_ids::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 refresh_catalog(
&mut self,
params: RefreshParams,
cancellation: &AgentCancellation,
) -> Result<Value, &'static str> {
let provider = ¶ms.provider;
if !matches!(
provider.as_str(),
"openai-codex" | "anthropic" | "claude-subscription"
) && !self.launch.settings.custom_providers.contains_key(provider)
{
return Err("invalid_params");
}
cancellation.check().map_err(|_| "operation_cancelled")?;
if let Some(stored) = self.catalog.as_mut() {
stored.invalidated = true;
}
let config = crate::auth::load_effective_provider_selection_with_settings(
&self.launch.config.paths,
provider,
"catalog-refresh",
&self.launch.settings,
);
let outcome = match config {
Ok(config) if provider_ready(&config) => {
crate::model_catalog::refresh_provider_catalog(
&config,
&self.launch.settings,
cancellation,
)
}
Ok(_) => Err("authentication_unavailable"),
Err(_) => Err("configuration_invalid"),
};
cancellation.check().map_err(|_| "operation_cancelled")?;
let (revision, catalog_error) = match self.scan_catalog(cancellation) {
Ok(scanned) => {
let revision = scanned.revision.clone();
self.catalog = Some(scanned);
(Some(revision), None)
}
Err("operation_cancelled") => return Err("operation_cancelled"),
Err(code) => (None, Some(code)),
};
Ok(
json!({"provider":provider,"outcome":if outcome.is_ok() { "refreshed" } else { "failed" },
"reason":outcome.err(),"catalog_revision":revision,"catalog_error":catalog_error,
"recovery":"reload_catalog","configuration_reload":"restart_child",
"selection_unchanged":true}),
)
}
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::provider_ids::OPENAI_CODEX_PROVIDER.to_owned(),
crate::provider_ids::ANTHROPIC_PROVIDER.to_owned(),
crate::provider_ids::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 provider_statuses = 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");
}
if provider.len() > 256 {
return Err("catalog_limit");
}
if super::safe_name(&provider).is_none() {
if !notices.contains(&"catalog_entries_omitted_unsafe") {
notices.push("catalog_entries_omitted_unsafe");
}
continue;
}
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 relevant = enabled.iter().any(|id| {
crate::model_catalog::ModelId::parse(id).is_ok_and(|id| id.provider() == provider)
}) || self.effective_config().provider_id() == provider;
let ready = relevant
&& crate::auth::load_effective_provider_selection_with_settings(
&self.launch.config.paths,
&provider,
"catalog-readiness",
settings,
)
.as_ref()
.is_ok_and(provider_ready);
digest.update([u8::from(relevant), u8::from(ready)]);
provider_statuses.push(ProviderStatus {
provider: provider.clone(),
catalog_status: cached.status,
authentication: if !relevant {
"not_checked"
} else if ready {
"ready"
} else {
"authentication_unavailable"
},
relevant,
recovery: if !relevant {
"enable_models_and_restart"
} else if !ready {
"configure_authentication"
} else if cached.entries.is_none() {
"refresh_catalog"
} else {
"none"
},
});
let Some(entries) = cached.entries else {
if relevant && !notices.contains(&"provider_catalog_unavailable") {
notices.push("provider_catalog_unavailable");
}
continue;
};
scanned_entries += entries.len();
if scanned_entries > 2048 {
return Err("catalog_limit");
}
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::support::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,
providers: provider_statuses,
})
}
pub(super) fn catalog_page(
&mut self,
params: CatalogParams,
cancellation: &AgentCancellation,
include_status: bool,
) -> 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 = 1024
+ if include_status {
serde_json::to_vec(&stored.providers)
.map_err(|_| "catalog_limit")?
.len()
} else {
0
};
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 mut result = json!({"revision":stored.revision,"choices":&stored.choices[start..end],
"next_cursor":next,"complete":next.is_none(),"notices":stored.notices,"source":"cache_only"});
if include_status {
result["providers"] = json!(stored.providers);
result["configuration_reload"] = json!("restart_child");
}
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(())
}
}