use super::{
cache,
models_dev::{self, SharedModelsDevLookup},
types::{CachePreference, ModelCatalogEntry},
};
use crate::{
config::{AuthState, McPaths, read_settings},
providers::{self, OpenAiCompatibleProvider, ReqwestHttpTransport},
};
use std::{
fmt,
sync::{Mutex, OnceLock},
time::{Duration as StdDuration, Instant},
};
pub(super) const AUTOMATIC_REFRESH_COOLDOWN: StdDuration = StdDuration::from_secs(30);
const MAX_AUTOMATIC_REFRESH_PROVIDERS: usize = 32;
static AUTOMATIC_REFRESH_FAILURES: OnceLock<Mutex<Vec<(String, Instant)>>> = OnceLock::new();
pub(crate) fn automatic_refresh_allowed(provider: &str) -> bool {
automatic_refresh_allowed_at(provider, Instant::now())
}
pub(super) fn automatic_refresh_allowed_at(provider: &str, now: Instant) -> bool {
let failures = AUTOMATIC_REFRESH_FAILURES.get_or_init(|| Mutex::new(Vec::new()));
let Ok(failures) = failures.lock() else {
return false;
};
failures
.iter()
.find(|(name, _)| name == provider)
.is_none_or(|(_, failed_at)| now.duration_since(*failed_at) >= AUTOMATIC_REFRESH_COOLDOWN)
}
pub(crate) fn automatic_refresh_failed(provider: &str) {
automatic_refresh_failed_at(provider, Instant::now());
}
pub(crate) fn automatic_refresh_suppressed(provider: &str) {
automatic_refresh_failed(provider);
}
pub(super) fn automatic_refresh_failed_at(provider: &str, now: Instant) {
let failures = AUTOMATIC_REFRESH_FAILURES.get_or_init(|| Mutex::new(Vec::new()));
let Ok(mut failures) = failures.lock() else {
return;
};
if let Some((_, failed_at)) = failures.iter_mut().find(|(name, _)| name == provider) {
*failed_at = now;
return;
}
if failures.len() >= MAX_AUTOMATIC_REFRESH_PROVIDERS {
failures.remove(0);
}
failures.push((provider.to_string(), now));
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CatalogRefreshFailureCategory {
Settings,
Auth,
ModelsDevTransport,
ModelsDevTimeout,
ModelsDevHttpStatus,
ModelsDevBodyLimit,
ModelsDevJsonDecode,
ProviderDiscovery,
Cache,
}
impl CatalogRefreshFailureCategory {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Settings => "settings",
Self::Auth => "auth",
Self::ModelsDevTransport => "models.dev transport",
Self::ModelsDevTimeout => "models.dev timeout",
Self::ModelsDevHttpStatus => "models.dev HTTP status",
Self::ModelsDevBodyLimit => "models.dev body limit",
Self::ModelsDevJsonDecode => "models.dev JSON decode",
Self::ProviderDiscovery => "provider discovery",
Self::Cache => "catalog cache",
}
}
}
impl models_dev::ModelsDevFetchError {
pub(super) fn category(self) -> CatalogRefreshFailureCategory {
match self {
Self::Transport => CatalogRefreshFailureCategory::ModelsDevTransport,
Self::Timeout => CatalogRefreshFailureCategory::ModelsDevTimeout,
Self::HttpStatus(_) => CatalogRefreshFailureCategory::ModelsDevHttpStatus,
Self::BodyLimit => CatalogRefreshFailureCategory::ModelsDevBodyLimit,
Self::JsonDecode => CatalogRefreshFailureCategory::ModelsDevJsonDecode,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CatalogRefreshError {
provider: String,
category: CatalogRefreshFailureCategory,
}
impl CatalogRefreshError {
pub(super) fn new(provider: &str, category: CatalogRefreshFailureCategory) -> Self {
Self {
provider: provider.to_string(),
category,
}
}
pub(crate) fn category(&self) -> CatalogRefreshFailureCategory {
self.category
}
}
impl fmt::Display for CatalogRefreshError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} refresh failed for provider '{}'",
self.category.as_str(),
self.provider
)
}
}
fn catalog_refresh_failure_category(error: &str) -> CatalogRefreshFailureCategory {
if error.contains("timed out") {
CatalogRefreshFailureCategory::ModelsDevTimeout
} else if error.contains("HTTP status") {
CatalogRefreshFailureCategory::ModelsDevHttpStatus
} else if error.contains("body limit") {
CatalogRefreshFailureCategory::ModelsDevBodyLimit
} else if error.contains("invalid JSON") {
CatalogRefreshFailureCategory::ModelsDevJsonDecode
} else if error.contains("models.dev") {
CatalogRefreshFailureCategory::ModelsDevTransport
} else {
CatalogRefreshFailureCategory::ProviderDiscovery
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CatalogRefreshOutcome {
Updated,
NoMatch,
}
pub(crate) fn refresh_catalog_for_provider(
paths: &McPaths,
provider: &str,
selected_model: &str,
) -> Result<CatalogRefreshOutcome, CatalogRefreshError> {
let settings = read_settings(paths)
.map_err(|_| CatalogRefreshError::new(provider, CatalogRefreshFailureCategory::Settings))?;
let auth = crate::config::read_auth(paths)
.map_err(|_| CatalogRefreshError::new(provider, CatalogRefreshFailureCategory::Auth))?;
if let Some(custom) = settings.custom_providers.get(provider) {
let credential = crate::config::resolve_provider_credential(
provider,
&auth,
None,
&settings.custom_providers,
)
.map_err(|_| CatalogRefreshError::new(provider, CatalogRefreshFailureCategory::Auth))?;
let api_key = super::custom_provider_catalog_api_key(provider, credential.as_ref())
.map_err(|_| CatalogRefreshError::new(provider, CatalogRefreshFailureCategory::Auth))?;
let models_dev = SharedModelsDevLookup::default();
let value = models_dev
.get()
.map_err(|error| CatalogRefreshError::new(provider, error.category()))?;
let entries = OpenAiCompatibleProvider::custom(
provider.to_string(),
providers::DEFAULT_CODEX_MODEL,
api_key,
custom.base_url.clone(),
custom.use_responses_endpoint,
ReqwestHttpTransport,
)
.discover_model_catalog()
.map_err(|_| {
CatalogRefreshError::new(provider, CatalogRefreshFailureCategory::ProviderDiscovery)
})
.and_then(|entries| {
let entries = models_dev::enrich_custom_provider_catalog_with_models_dev_value(
entries, provider, custom, value,
);
let extra_models = crate::config::normalized_extra_models(&custom.extra_models)
.map_err(|_| {
CatalogRefreshError::new(
provider,
CatalogRefreshFailureCategory::ProviderDiscovery,
)
})?;
models_dev::merge_custom_provider_extra_models(entries, provider, &extra_models)
.map_err(|_| {
CatalogRefreshError::new(
provider,
CatalogRefreshFailureCategory::ProviderDiscovery,
)
})
})?;
cache::write_custom_provider_catalog_cache(paths, provider, custom, &entries).map_err(
|_| CatalogRefreshError::new(provider, CatalogRefreshFailureCategory::Cache),
)?;
let outcome =
catalog_refresh_outcome_for_selected_model(&entries, provider, selected_model);
return Ok(outcome);
}
let credential = crate::config::resolve_provider_credential(
provider,
&auth,
None,
&settings.custom_providers,
)
.map_err(|_| CatalogRefreshError::new(provider, CatalogRefreshFailureCategory::Auth))?;
let auth_state = AuthState::for_provider_with_custom(
provider,
credential.as_ref(),
&settings.custom_providers,
);
if provider == providers::OPENAI_CODEX_PROVIDER {
super::load_openai_codex_catalog_with_models_dev(
paths,
&auth_state,
CachePreference::AllowStale,
Some(&SharedModelsDevLookup::default()),
)
.map(|result| {
catalog_refresh_outcome_for_selected_model(&result.entries, provider, selected_model)
})
.map_err(|error| {
CatalogRefreshError::new(provider, catalog_refresh_failure_category(&error))
})
} else if provider == providers::ANTHROPIC_PROVIDER {
super::load_anthropic_catalog(paths, &auth_state, CachePreference::AllowStale)
.map(|_| CatalogRefreshOutcome::Updated)
.map_err(|_| {
CatalogRefreshError::new(provider, CatalogRefreshFailureCategory::ProviderDiscovery)
})
} else {
Ok(CatalogRefreshOutcome::Updated)
}
}
pub(super) fn catalog_refresh_outcome_for_selected_model(
entries: &[ModelCatalogEntry],
provider: &str,
selected_model: &str,
) -> CatalogRefreshOutcome {
entries
.iter()
.find(|entry| entry.provider == provider && entry.model == selected_model)
.filter(|entry| entry.supports_reasoning.is_some() || entry.reasoning_efforts.is_some())
.map(|_| CatalogRefreshOutcome::Updated)
.unwrap_or(CatalogRefreshOutcome::NoMatch)
}