mod cache;
mod models_dev;
mod refresh;
pub(crate) mod types;
use crate::{
config::{
AuthState, CustomProviderConfig, McPaths, ProviderCredential, read_settings,
set_selected_model,
},
providers::{self, AnthropicProvider, OpenAiCodexProvider, ReqwestHttpTransport},
};
use chrono::Utc;
use std::{sync::Arc, thread};
#[cfg(test)]
pub(crate) use cache::catalog_cache_path;
pub(crate) use cache::write_catalog_cache;
use cache::{
metadata_entries_for_provider, read_catalog_cache, read_catalog_cache_for_configured_provider,
read_custom_provider_catalog_cache, write_custom_provider_catalog_cache,
};
use models_dev::{
ModelsDevLookup, SharedModelsDevLookup, effective_custom_provider_models_dev_namespace,
enrich_custom_provider_catalog, enrich_custom_provider_catalog_with_models_dev_value,
enrich_with_models_dev, enrich_with_models_dev_namespace, merge_custom_provider_extra_models,
models_dev_source,
};
#[cfg(test)]
pub(crate) use refresh::CatalogRefreshFailureCategory;
pub(crate) use refresh::{
CatalogRefreshOutcome, automatic_refresh_allowed, automatic_refresh_failed,
automatic_refresh_suppressed, refresh_catalog_for_provider,
};
#[cfg_attr(
not(test),
expect(
unused_imports,
reason = "Retained as part of the model catalog type API."
)
)]
pub use types::ModelCatalogServiceTier;
pub use types::{
CachePreference, CatalogForUi, CatalogLoadResult, CatalogSource, ModelCatalogEntry, ModelId,
ModelIdError,
};
const MAX_CUSTOM_PROVIDER_CATALOG_WORKERS: usize = 4;
#[cfg(test)]
pub(crate) use cache::write_catalog_cache_for_configured_provider;
#[cfg(test)]
#[path = "tests/mod.rs"]
mod tests;
pub fn validate_openai_codex_active(auth_state: &AuthState) -> Result<(), String> {
let provider = auth_state.provider();
if provider != providers::OPENAI_CODEX_PROVIDER {
return Err(format!(
"/setmodel requires active provider '{}'; current provider is '{provider}'",
providers::OPENAI_CODEX_PROVIDER
));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AggregatedCatalogForUi {
pub entries: Vec<ModelCatalogEntry>,
pub notices: Vec<String>,
pub stale: bool,
}
pub fn load_aggregated_catalog(
paths: &McPaths,
preference: CachePreference,
) -> AggregatedCatalogForUi {
let mut models_dev = ModelsDevLookup::default();
load_aggregated_catalog_with_models_dev(paths, preference, &mut models_dev)
}
fn load_aggregated_catalog_with_models_dev(
paths: &McPaths,
preference: CachePreference,
models_dev: &mut ModelsDevLookup,
) -> AggregatedCatalogForUi {
let mut entries = Vec::new();
let mut notices = Vec::new();
let mut stale = false;
let settings = match read_settings(paths) {
Ok(settings) => settings,
Err(error) => {
notices.push(format!(
"settings.json could not be read; custom providers are unavailable until settings are fixed: {error}"
));
Default::default()
}
};
let auth = match crate::config::read_auth(paths) {
Ok(auth) => auth,
Err(error) => {
notices.push(format!(
"auth.json could not be read; openai-codex model discovery is unavailable until auth is fixed: {error}"
));
Default::default()
}
};
let credential = crate::config::resolve_provider_credential(
providers::OPENAI_CODEX_PROVIDER,
&auth,
None,
&settings.custom_providers,
)
.ok()
.flatten();
let openai_state =
AuthState::for_provider(providers::OPENAI_CODEX_PROVIDER, credential.as_ref());
let anthropic_credential = crate::config::resolve_provider_credential(
providers::ANTHROPIC_PROVIDER,
&auth,
None,
&settings.custom_providers,
)
.ok()
.flatten();
let anthropic_state =
AuthState::for_provider(providers::ANTHROPIC_PROVIDER, anthropic_credential.as_ref());
let shared_models_dev = Arc::new(SharedModelsDevLookup::from_seeded(models_dev));
let mut custom_jobs = Vec::new();
for (provider, custom) in settings.custom_providers {
if let Err(error) = crate::config::normalize_custom_provider_base_url(&custom.base_url) {
notices.push(format!(
"custom provider '{provider}' has invalid base_url and was skipped: {error}"
));
continue;
}
let credential = match &custom.api_key_env_var {
Some(env_var) => match std::env::var(env_var).ok().filter(|v| !v.is_empty()) {
Some(key) => Some(ProviderCredential::ApiKey { key }),
None => {
notices.push(format!(
"custom provider '{provider}' configured but not ready; set {env_var}"
));
None
}
},
None => Some(ProviderCredential::NoAuth),
};
let Some(credential) = credential else {
continue;
};
custom_jobs.push((provider, custom, credential));
}
let provider_results = thread::scope(|scope| {
let mut base_handles = Vec::new();
{
let paths = paths.clone();
let shared_models_dev = Arc::clone(&shared_models_dev);
base_handles.push(scope.spawn(move || {
load_openai_codex_catalog_with_models_dev(
&paths,
&openai_state,
preference,
Some(&*shared_models_dev),
)
}));
}
{
let paths = paths.clone();
base_handles.push(
scope.spawn(move || load_anthropic_catalog(&paths, &anthropic_state, preference)),
);
}
let mut custom_results = Vec::new();
for chunk in custom_jobs.chunks(MAX_CUSTOM_PROVIDER_CATALOG_WORKERS) {
let handles = chunk
.iter()
.map(|(provider, custom, credential)| {
let paths = paths.clone();
let shared_models_dev = Arc::clone(&shared_models_dev);
scope.spawn(move || {
load_custom_provider_catalog_with_models_dev(
&paths,
provider,
custom,
Some(credential),
preference,
Some(&*shared_models_dev),
)
})
})
.collect::<Vec<_>>();
custom_results.extend(handles.into_iter().map(|handle| match handle.join() {
Ok(result) => result,
Err(_) => Err("model catalog worker panicked".to_string()),
}));
}
let mut results = base_handles
.into_iter()
.map(|handle| match handle.join() {
Ok(result) => result,
Err(_) => Err("model catalog worker panicked".to_string()),
})
.collect::<Vec<_>>();
results.extend(custom_results);
results
});
for result in provider_results {
match result {
Ok(result) => {
entries.extend(result.entries);
match result.source {
CatalogSource::StaleCache { diagnostic } => {
stale = true;
notices.push(diagnostic);
}
source => {
if let Some(notice) = source.notice() {
notices.push(notice);
}
}
}
}
Err(error) => notices.push(error),
}
}
AggregatedCatalogForUi {
entries,
notices,
stale,
}
}
fn custom_provider_catalog_api_key(
provider: &str,
credential: Option<&ProviderCredential>,
) -> Result<Option<String>, String> {
match credential {
Some(ProviderCredential::ApiKey { key }) => Ok(Some(key.clone())),
Some(ProviderCredential::NoAuth) => Ok(None),
Some(_) => Err(format!(
"provider '{provider}' requires custom-provider auth mode, not OAuth token"
)),
None => Err(format!(
"custom provider '{provider}' is configured but not ready"
)),
}
}
fn load_custom_provider_catalog(
paths: &McPaths,
provider: &str,
custom: &CustomProviderConfig,
credential: Option<&ProviderCredential>,
preference: CachePreference,
) -> Result<CatalogLoadResult, String> {
load_custom_provider_catalog_with_models_dev(
paths, provider, custom, credential, preference, None,
)
}
fn load_custom_provider_catalog_with_models_dev(
paths: &McPaths,
provider: &str,
custom: &CustomProviderConfig,
credential: Option<&ProviderCredential>,
preference: CachePreference,
models_dev: Option<&SharedModelsDevLookup>,
) -> Result<CatalogLoadResult, String> {
crate::config::normalize_custom_provider_base_url(&custom.base_url)
.map_err(|error| format!("custom provider '{provider}' has invalid base_url: {error}"))?;
let extra_models =
crate::config::normalized_extra_models(&custom.extra_models).map_err(|error| {
format!("custom provider '{provider}' has invalid extra_models: {error}")
})?;
let api_key = custom_provider_catalog_api_key(provider, credential)?;
if let Some(cached) = read_custom_provider_catalog_cache(paths, provider, custom)
&& cached.expires_at > Utc::now()
{
return Ok(CatalogLoadResult {
entries: cached.entries,
source: CatalogSource::FreshCache,
});
}
let live = crate::providers::OpenAiCompatibleProvider::custom(
provider.to_string(),
providers::DEFAULT_CODEX_MODEL,
api_key,
custom.base_url.clone(),
custom.use_responses_endpoint,
crate::providers::ReqwestHttpTransport,
)
.discover_model_catalog()
.and_then(|items| match models_dev {
Some(lookup) => lookup
.get()
.map(|value| {
enrich_custom_provider_catalog_with_models_dev_value(items, provider, custom, value)
})
.map_err(|error| anyhow::anyhow!("models.dev enrichment fetch failed: {error}")),
None => enrich_custom_provider_catalog(items, provider, custom),
})
.and_then(|items| {
merge_custom_provider_extra_models(items, provider, &extra_models)
.map_err(anyhow::Error::msg)
})
.and_then(|items| {
write_custom_provider_catalog_cache(paths, provider, custom, &items)?;
Ok(items)
});
match live {
Ok(entries) => {
let namespace = effective_custom_provider_models_dev_namespace(provider, custom);
Ok(CatalogLoadResult {
source: models_dev_source(&entries, models_dev, namespace),
entries,
})
}
Err(error) => {
if preference == CachePreference::AllowStale
&& let Some(cached) = read_custom_provider_catalog_cache(paths, provider, custom)
{
return Ok(CatalogLoadResult {
entries: cached.entries,
source: CatalogSource::StaleCache {
diagnostic: format!(
"using stale {provider} model catalog; live refresh failed: {error}"
),
},
});
}
Err(format!("{provider} model discovery unavailable: {error}"))
}
}
}
pub fn load_openai_codex_catalog(
paths: &McPaths,
auth_state: &AuthState,
preference: CachePreference,
) -> Result<CatalogLoadResult, String> {
load_openai_codex_catalog_with_models_dev(paths, auth_state, preference, None)
}
fn load_openai_codex_catalog_with_models_dev(
paths: &McPaths,
auth_state: &AuthState,
preference: CachePreference,
models_dev: Option<&SharedModelsDevLookup>,
) -> Result<CatalogLoadResult, String> {
validate_openai_codex_active(auth_state)?;
if let Some(cached) = read_catalog_cache(paths, providers::OPENAI_CODEX_PROVIDER)
&& cached.expires_at > Utc::now()
{
return Ok(CatalogLoadResult {
entries: cached.entries,
source: CatalogSource::FreshCache,
});
}
let live_result = crate::config::refreshed_codex_auth_state(paths, auth_state)
.map_err(|error| format!("credential refresh failed: {error}"))
.and_then(|refreshed_auth_state| {
fetch_live_openai_codex_catalog_with_models_dev(&refreshed_auth_state, models_dev)
})
.and_then(|entries| {
write_catalog_cache(paths, providers::OPENAI_CODEX_PROVIDER, &entries).map_err(
|error| format!("openai-codex model catalog cache write failed: {error}"),
)?;
Ok(entries)
});
match live_result {
Ok(entries) => Ok(CatalogLoadResult {
source: models_dev_source(&entries, models_dev, "openai"),
entries,
}),
Err(error) => {
if preference == CachePreference::AllowStale
&& let Some(cached) = read_catalog_cache(paths, providers::OPENAI_CODEX_PROVIDER)
&& !cached.entries.is_empty()
{
return Ok(CatalogLoadResult {
entries: cached.entries,
source: CatalogSource::StaleCache {
diagnostic: format!(
"using stale openai-codex model catalog; live refresh failed: {error}"
),
},
});
}
Err(format!(
"openai-codex model discovery unavailable: {error}; run /login openai-codex and retry /setmodel when network access is available"
))
}
}
}
fn fetch_live_openai_codex_catalog_with_models_dev(
auth_state: &AuthState,
models_dev: Option<&SharedModelsDevLookup>,
) -> Result<Vec<ModelCatalogEntry>, String> {
let AuthState::Ready {
provider,
credential,
} = auth_state
else {
return Err("provider auth is not configured".to_string());
};
if provider != providers::OPENAI_CODEX_PROVIDER {
return Err(format!(
"provider '{provider}' does not support dynamic model discovery"
));
}
let ProviderCredential::OAuth { access, account_id } = credential else {
return Err(
"openai-codex model discovery requires provider-keyed OAuth auth, not an API key"
.to_string(),
);
};
let provider = OpenAiCodexProvider::new(
providers::DEFAULT_CODEX_MODEL,
access.clone(),
account_id.clone(),
ReqwestHttpTransport,
);
provider
.discover_model_catalog()
.map_err(|error| error.to_string())
.and_then(|entries| match models_dev {
Some(lookup) => lookup
.get()
.map(|value| enrich_with_models_dev_namespace(entries, value, "openai"))
.map_err(|error| format!("models.dev enrichment fetch failed: {error}")),
None => enrich_with_models_dev(entries)
.map_err(|error| format!("models.dev enrichment fetch failed: {error}")),
})
}
fn fetch_live_anthropic_catalog(auth_state: &AuthState) -> Result<Vec<ModelCatalogEntry>, String> {
let AuthState::Ready {
provider,
credential,
} = auth_state
else {
return Err("provider auth is not configured".to_string());
};
if provider != providers::ANTHROPIC_PROVIDER {
return Err(format!(
"provider '{provider}' does not support Anthropic model discovery"
));
}
let ProviderCredential::ApiKey { key } = credential else {
return Err(
"anthropic model discovery requires API-key auth, not OAuth or no-auth".to_string(),
);
};
AnthropicProvider::new(
providers::DEFAULT_ANTHROPIC_MODEL,
key.clone(),
ReqwestHttpTransport,
)
.discover_model_catalog()
.map_err(|error| error.to_string())
}
pub fn load_anthropic_catalog(
paths: &McPaths,
auth_state: &AuthState,
preference: CachePreference,
) -> Result<CatalogLoadResult, String> {
if let Some(cached) = read_catalog_cache(paths, providers::ANTHROPIC_PROVIDER)
&& cached.expires_at > Utc::now()
{
return Ok(CatalogLoadResult {
entries: cached.entries,
source: CatalogSource::FreshCache,
});
}
let live_result = fetch_live_anthropic_catalog(auth_state).and_then(|entries| {
write_catalog_cache(paths, providers::ANTHROPIC_PROVIDER, &entries)
.map_err(|error| format!("anthropic model catalog cache write failed: {error}"))?;
Ok(entries)
});
match live_result {
Ok(entries) => Ok(CatalogLoadResult {
entries,
source: CatalogSource::Live,
}),
Err(error) => {
if preference == CachePreference::AllowStale
&& let Some(cached) = read_catalog_cache(paths, providers::ANTHROPIC_PROVIDER)
&& !cached.entries.is_empty()
{
return Ok(CatalogLoadResult {
entries: cached.entries,
source: CatalogSource::StaleCache {
diagnostic: format!(
"using stale anthropic model catalog; live refresh failed: {error}"
),
},
});
}
Err(format!(
"anthropic model discovery unavailable: {error}; set ANTHROPIC_API_KEY and retry /setmodel when network access is available"
))
}
}
}
pub(crate) fn cached_catalog_for_service(paths: &McPaths, provider: &str) -> Option<CatalogForUi> {
let cache = read_catalog_cache_for_configured_provider(paths, provider)?;
Some(CatalogForUi {
entries: cache.entries,
stale: cache.expires_at <= Utc::now(),
notice: None,
})
}
pub(crate) fn cached_model_thinking_metadata(
paths: &McPaths,
provider: &str,
model: &str,
) -> Option<crate::thinking::CatalogThinkingMetadata> {
let cache = read_catalog_cache_for_configured_provider(paths, provider)?;
if cache.expires_at <= Utc::now() {
return None;
}
cache
.entries
.iter()
.find(|entry| entry.provider == provider && entry.model == model)
.map(|entry| crate::thinking::CatalogThinkingMetadata {
reasoning_efforts: entry.reasoning_efforts.clone(),
supports_reasoning: entry
.supports_reasoning
.or(entry.legacy_supports_reasoning_effort),
})
}
pub fn persist_selected_model(paths: &McPaths, provider: &str, model: &str) -> anyhow::Result<()> {
set_selected_model(paths, provider, model)
}
pub(crate) fn resolve_codex_fast_service_tier(
provider: &str,
model: &str,
entry: &ModelCatalogEntry,
) -> Option<String> {
if provider != providers::OPENAI_CODEX_PROVIDER
|| entry.provider != providers::OPENAI_CODEX_PROVIDER
{
return None;
}
if let Some(service_tiers) = &entry.service_tiers {
return service_tiers.iter().find_map(|tier| {
(tier.name.trim().eq_ignore_ascii_case("fast") && !tier.id.trim().is_empty())
.then(|| tier.id.clone())
});
}
codex_model_supports_fast(model).then(|| "fast".to_string())
}
pub(crate) fn resolve_codex_fast_service_tier_from_entries(
provider: &str,
model: &str,
entries: Option<&[ModelCatalogEntry]>,
) -> Option<String> {
if provider != providers::OPENAI_CODEX_PROVIDER {
return None;
}
let Some(entries) = entries else {
return codex_model_supports_fast(model).then(|| "fast".to_string());
};
let normalized = providers::normalize_codex_model(model);
let Some(entry) = entries.iter().find(|entry| {
entry.provider == providers::OPENAI_CODEX_PROVIDER
&& (entry.model == model
|| entry.model == normalized
|| providers::normalize_codex_model(&entry.model) == normalized)
}) else {
return codex_model_supports_fast(model).then(|| "fast".to_string());
};
resolve_codex_fast_service_tier(provider, model, entry)
}
pub(crate) fn resolve_codex_fast_service_tier_from_catalog_entries(
provider: &str,
model: &str,
entries: &[ModelCatalogEntry],
) -> Option<String> {
resolve_codex_fast_service_tier_from_entries(provider, model, Some(entries))
}
pub(crate) fn cached_codex_fast_service_tier(paths: &McPaths, model: &str) -> Option<String> {
let cached = metadata_entries_for_provider(paths, providers::OPENAI_CODEX_PROVIDER);
resolve_codex_fast_service_tier_from_entries(
providers::OPENAI_CODEX_PROVIDER,
model,
cached.as_deref(),
)
}
fn codex_model_supports_fast(model: &str) -> bool {
let model = providers::normalize_codex_model(model);
let Some((major, minor_and_variant)) = model
.strip_prefix("gpt-")
.and_then(|model| model.split_once('.'))
else {
return false;
};
let Some(major) = (!major.is_empty())
.then(|| major.parse::<u64>().ok())
.flatten()
else {
return false;
};
let minor_end = minor_and_variant
.find(|character: char| !character.is_ascii_digit())
.unwrap_or(minor_and_variant.len());
let minor_text = &minor_and_variant[..minor_end];
let Some(minor) = (!minor_text.is_empty())
.then(|| minor_text.parse::<u64>().ok())
.flatten()
else {
return false;
};
let variant = &minor_and_variant[minor_end..];
if !variant.is_empty() && !matches!(variant.as_bytes().first(), Some(b'.' | b'-' | b'_')) {
return false;
}
major > 5 || (major == 5 && minor >= 4)
}
pub fn cached_model_context_window(paths: &McPaths, provider: &str, model: &str) -> Option<usize> {
let cached = metadata_entries_for_provider(paths, provider)?;
let context_window = cached
.iter()
.find(|entry| entry.provider == provider && entry.model == model)
.and_then(|entry| entry.context_window)?;
usize::try_from(context_window).ok()
}
pub fn cached_model_max_output_tokens(paths: &McPaths, provider: &str, model: &str) -> Option<u64> {
let cached = metadata_entries_for_provider(paths, provider)?;
cached
.iter()
.find(|entry| entry.provider == provider && entry.model == model)
.and_then(|entry| entry.max_output_tokens)
}
fn normalize_switch_model_id(model_id: ModelId) -> Result<ModelId, ModelIdError> {
if model_id.provider() == providers::OPENAI_CODEX_PROVIDER {
let normalized = providers::normalize_codex_model(model_id.model());
if normalized != model_id.model() {
return ModelId::from_parts(model_id.provider(), normalized);
}
}
Ok(model_id)
}
pub fn switch_model(
paths: &McPaths,
auth_state: &AuthState,
model_id: &str,
) -> anyhow::Result<(String, String, Option<String>)> {
let (model_id, catalog) = switch_model_internal(paths, auth_state, model_id)?;
let notice = catalog.source.notice();
persist_selected_model(paths, model_id.provider(), model_id.model())?;
Ok((
model_id.provider().to_string(),
model_id.model().to_string(),
notice,
))
}
pub(crate) fn resolve_model_with_fast_service_tier(
paths: &McPaths,
auth_state: &AuthState,
model_id: &str,
) -> anyhow::Result<(String, String, Option<String>, Option<String>)> {
let (model_id, catalog) = switch_model_internal(paths, auth_state, model_id)?;
let service_tier = resolve_codex_fast_service_tier_from_catalog_entries(
model_id.provider(),
model_id.model(),
&catalog.entries,
);
Ok((
model_id.provider().to_string(),
model_id.model().to_string(),
catalog.source.notice(),
service_tier,
))
}
fn switch_model_internal(
paths: &McPaths,
_auth_state: &AuthState,
model_id: &str,
) -> anyhow::Result<(ModelId, CatalogLoadResult)> {
let model_id = normalize_switch_model_id(ModelId::parse(model_id)?)?;
crate::config::reject_retired_provider_selection(model_id.provider(), model_id.model())?;
let normalized_model_id = model_id.to_string();
let settings = read_settings(paths)?;
if crate::config::disabled_model_ids_from_settings(&settings).contains(&normalized_model_id) {
anyhow::bail!("cannot select disabled model: {normalized_model_id}");
}
let catalog = {
let auth = crate::config::read_auth(paths)?;
let target_credential = crate::config::resolve_provider_credential(
model_id.provider(),
&auth,
None,
&settings.custom_providers,
)?;
if let Some(custom) = settings.custom_providers.get(model_id.provider())
&& target_credential.is_none()
{
let Some(env_var) = &custom.api_key_env_var else {
anyhow::bail!(
"custom provider '{}' is configured for no-auth but is not ready",
model_id.provider()
);
};
anyhow::bail!(
"custom provider '{}' is configured but not ready; set {env_var} before selecting models",
model_id.provider()
);
}
let target_auth_state = AuthState::for_provider_with_custom(
model_id.provider(),
target_credential.as_ref(),
&settings.custom_providers,
);
if model_id.provider() == providers::OPENAI_CODEX_PROVIDER {
load_openai_codex_catalog(paths, &target_auth_state, CachePreference::AllowStale)
.map_err(|e| anyhow::anyhow!(e))?
} else if model_id.provider() == providers::ANTHROPIC_PROVIDER {
load_anthropic_catalog(paths, &target_auth_state, CachePreference::AllowStale)
.map_err(|e| anyhow::anyhow!(e))?
} else if let Some(custom) = settings.custom_providers.get(model_id.provider()) {
load_custom_provider_catalog(
paths,
model_id.provider(),
custom,
target_credential.as_ref(),
CachePreference::AllowStale,
)
.map_err(|e| anyhow::anyhow!(e))?
} else {
anyhow::bail!(
"unsupported provider '{}'; configure it with /login custom-provider before selecting models",
model_id.provider()
);
}
};
if !catalog
.entries
.iter()
.any(|entry| entry.provider == model_id.provider() && entry.model == model_id.model())
{
anyhow::bail!("model '{model_id}' is not available in the discovered provider catalog");
}
Ok((model_id, catalog))
}
pub fn model_usage() -> &'static str {
"usage: /setmodel <provider>/<model-name> (examples: /setmodel openai-codex/<model-name>, /setmodel anthropic/<model-name>, /setmodel custom-local/<model-name>)"
}