use crate::{
config::{
AuthState, CustomProviderConfig, McPaths, ProviderCredential, read_settings,
set_selected_model,
},
http_body::{
DEFAULT_BOUNDED_BODY_MAX_BYTES, read_bounded_file_to_string, read_bounded_response_text,
},
persistence::atomic_write,
providers::{self, AnthropicProvider, OpenAiCodexProvider, ReqwestHttpTransport},
thinking::ThinkingLevel,
};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::{
fmt,
path::PathBuf,
sync::{Arc, OnceLock},
thread,
time::{Duration as StdDuration, Instant},
};
const MODEL_CATALOG_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(10);
const MODELS_DEV_BODY_MAX_BYTES: u64 = 8 * 1024 * 1024;
const MODEL_CATALOG_REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(30);
const CACHE_SCHEMA_VERSION: u32 = 7;
const ENRICHMENT_VERSION: u32 = 2;
const CATALOG_TTL_HOURS: i64 = 24;
const MAX_CUSTOM_PROVIDER_CATALOG_WORKERS: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelId {
provider: String,
model: String,
}
impl ModelId {
pub fn parse(input: &str) -> Result<Self, ModelIdError> {
let (provider, model) = input.split_once('/').ok_or(ModelIdError::MissingSlash)?;
Self::from_parts(provider, model)
}
pub fn from_parts(provider: &str, model: &str) -> Result<Self, ModelIdError> {
if provider.is_empty() {
return Err(ModelIdError::EmptyProvider);
}
if model.is_empty() {
return Err(ModelIdError::EmptyModel);
}
Ok(Self {
provider: provider.to_string(),
model: model.to_string(),
})
}
pub fn provider(&self) -> &str {
&self.provider
}
pub fn model(&self) -> &str {
&self.model
}
}
impl fmt::Display for ModelId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.provider, self.model)
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ModelIdError {
#[error("model id must use provider/model-name format")]
MissingSlash,
#[error("model id provider is empty")]
EmptyProvider,
#[error("model id model name is empty")]
EmptyModel,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModelCatalogEntry {
pub provider: String,
pub model: String,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_context_window: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_efforts: Option<Vec<ThinkingLevel>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_reasoning: Option<bool>,
#[serde(default, rename = "supports_reasoning_effort", skip_serializing)]
pub legacy_supports_reasoning_effort: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modalities: Option<Vec<String>>,
}
impl ModelCatalogEntry {
pub fn new(provider: impl Into<String>, model: impl Into<String>) -> Self {
let provider = provider.into();
let model = model.into();
let id = ModelId::from_parts(&provider, &model)
.map(|id| id.to_string())
.unwrap_or_else(|_| format!("{provider}/{model}"));
Self {
id,
provider,
model,
display_name: None,
description: None,
context_window: None,
max_context_window: None,
max_output_tokens: None,
reasoning_efforts: None,
supports_reasoning: None,
legacy_supports_reasoning_effort: None,
input_cost: None,
output_cost: None,
modalities: None,
}
}
pub fn new_codex(model: impl Into<String>) -> Self {
Self::new(providers::OPENAI_CODEX_PROVIDER, model)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogForUi {
pub entries: Vec<ModelCatalogEntry>,
pub stale: bool,
pub notice: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
struct CatalogCacheFingerprint {
#[serde(default, skip_serializing_if = "Option::is_none")]
enrichment_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
models_dev_provider: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extra_models: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedCatalog {
schema_version: u32,
provider: String,
#[serde(default)]
cache_fingerprint: CatalogCacheFingerprint,
fetched_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
entries: Vec<ModelCatalogEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CatalogSource {
Live,
LiveNoMatch {
namespace: String,
models: Vec<String>,
},
FreshCache,
LivePartialMatch {
namespace: String,
matched: Vec<String>,
unmatched: Vec<String>,
},
StaleCache {
diagnostic: String,
},
}
impl CatalogSource {
fn notice(&self) -> Option<String> {
match self {
Self::Live => None,
Self::LiveNoMatch { namespace, models } => Some(format!(
"models.dev returned no match for namespace '{namespace}' and model(s): {}",
models.join(", ")
)),
Self::LivePartialMatch {
namespace,
matched,
unmatched,
} => Some(format!(
"models.dev partially matched namespace '{namespace}'; matched: {}, unmatched: {}",
matched.join(", "),
unmatched.join(", ")
)),
Self::FreshCache => None,
Self::StaleCache { diagnostic } => Some(diagnostic.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogLoadResult {
pub entries: Vec<ModelCatalogEntry>,
pub source: CatalogSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CachePreference {
AllowStale,
}
#[derive(Debug, Default)]
struct ModelsDevLookup {
attempted: bool,
value: Option<serde_json::Value>,
}
impl ModelsDevLookup {
#[cfg(test)]
fn get_or_fetch(
&mut self,
fetch: impl FnOnce() -> anyhow::Result<serde_json::Value>,
) -> Option<&serde_json::Value> {
if !self.attempted || self.value.is_none() {
self.attempted = true;
self.value = fetch().ok();
}
self.value.as_ref()
}
}
#[derive(Debug, Default)]
struct SharedModelsDevLookup {
value: OnceLock<Result<serde_json::Value, ModelsDevFetchError>>,
}
impl SharedModelsDevLookup {
fn from_seeded(lookup: &ModelsDevLookup) -> Self {
let shared = Self::default();
if lookup.attempted {
let result = lookup.value.clone().ok_or(ModelsDevFetchError::Transport);
let _ = shared.value.set(result);
}
shared
}
fn get(&self) -> Result<&serde_json::Value, ModelsDevFetchError> {
self.value
.get_or_init(fetch_models_dev_catalog)
.as_ref()
.map_err(Clone::clone)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
enum ModelsDevFetchError {
#[error("models.dev request transport failed")]
Transport,
#[error("models.dev request timed out")]
Timeout,
#[error("models.dev returned HTTP status {0}")]
HttpStatus(u16),
#[error("models.dev response exceeded configured body limit")]
BodyLimit,
#[error("models.dev returned invalid JSON")]
JsonDecode,
}
impl ModelsDevFetchError {
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,
}
}
}
const AUTOMATIC_REFRESH_COOLDOWN: StdDuration = StdDuration::from_secs(30);
const MAX_AUTOMATIC_REFRESH_PROVIDERS: usize = 32;
static AUTOMATIC_REFRESH_FAILURES: OnceLock<std::sync::Mutex<Vec<(String, Instant)>>> =
OnceLock::new();
pub(crate) fn automatic_refresh_allowed(provider: &str) -> bool {
automatic_refresh_allowed_at(provider, Instant::now())
}
fn automatic_refresh_allowed_at(provider: &str, now: Instant) -> bool {
let failures = AUTOMATIC_REFRESH_FAILURES.get_or_init(|| std::sync::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);
}
fn automatic_refresh_failed_at(provider: &str, now: Instant) {
let failures = AUTOMATIC_REFRESH_FAILURES.get_or_init(|| std::sync::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));
}
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)
}
pub(crate) fn resolve_claude_code_model_alias(model: &str) -> String {
match model.strip_prefix("claude-code/").unwrap_or(model) {
"sonnet" => "claude-sonnet-4-6".to_string(),
"opus" => "claude-opus-4-8".to_string(),
"fable" => "claude-fable-5".to_string(),
other => other.to_string(),
}
}
pub(crate) fn claude_code_static_catalog() -> Vec<ModelCatalogEntry> {
[
("sonnet", 64_000),
("opus", 128_000),
("fable", 128_000),
]
.into_iter()
.map(|(model, max_output_tokens)| {
let mut entry = ModelCatalogEntry::new(providers::CLAUDE_CODE_PROVIDER, model);
entry.display_name = Some(format!("Claude Code {model}"));
entry.context_window = Some(1_000_000);
entry.max_output_tokens = Some(max_output_tokens);
let resolved = resolve_claude_code_model_alias(model);
entry.description = Some(format!(
"Claude Code direct Anthropic Messages alias; sends model id {resolved}. OAuth uses Claude Code subscription credentials; API-key fallback bills Anthropic API credits"
));
entry
})
.collect()
}
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;
entries.extend(claude_code_static_catalog());
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(ProviderCredential::OAuth { .. }) => 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::login::refreshed_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"
))
}
}
}
fn fetch_models_dev_catalog() -> Result<serde_json::Value, ModelsDevFetchError> {
fetch_models_dev_catalog_with_timeouts(
"https://models.dev/api.json",
MODEL_CATALOG_CONNECT_TIMEOUT,
MODEL_CATALOG_REQUEST_TIMEOUT,
)
}
fn fetch_models_dev_catalog_with_timeouts(
url: &str,
connect_timeout: StdDuration,
request_timeout: StdDuration,
) -> Result<serde_json::Value, ModelsDevFetchError> {
let response = reqwest::blocking::Client::builder()
.connect_timeout(connect_timeout)
.timeout(request_timeout)
.build()
.map_err(|error| {
if error.is_timeout() {
ModelsDevFetchError::Timeout
} else {
ModelsDevFetchError::Transport
}
})?
.get(url)
.header("accept", "application/json")
.header(
"user-agent",
format!("magi-code/{}", env!("CARGO_PKG_VERSION")),
)
.send()
.map_err(|error| {
if error.is_timeout() {
ModelsDevFetchError::Timeout
} else {
ModelsDevFetchError::Transport
}
})?;
let status = response.status();
if !status.is_success() {
return Err(ModelsDevFetchError::HttpStatus(status.as_u16()));
}
let text =
read_bounded_response_text(response, MODELS_DEV_BODY_MAX_BYTES).map_err(|error| {
if error.to_string().contains("response exceeded") {
ModelsDevFetchError::BodyLimit
} else {
ModelsDevFetchError::Transport
}
})?;
serde_json::from_str::<serde_json::Value>(&text).map_err(|_| ModelsDevFetchError::JsonDecode)
}
fn enrich_with_models_dev(
entries: Vec<ModelCatalogEntry>,
) -> anyhow::Result<Vec<ModelCatalogEntry>> {
let value = fetch_models_dev_catalog()?;
Ok(enrich_with_models_dev_namespace(entries, &value, "openai"))
}
fn enrich_custom_provider_catalog(
entries: Vec<ModelCatalogEntry>,
provider: &str,
custom: &CustomProviderConfig,
) -> anyhow::Result<Vec<ModelCatalogEntry>> {
let value = fetch_models_dev_catalog()?;
Ok(enrich_custom_provider_catalog_with_models_dev_value(
entries, provider, custom, &value,
))
}
fn enrich_custom_provider_catalog_with_models_dev_value(
entries: Vec<ModelCatalogEntry>,
provider: &str,
custom: &CustomProviderConfig,
value: &serde_json::Value,
) -> Vec<ModelCatalogEntry> {
let namespace = effective_custom_provider_models_dev_namespace(provider, custom);
enrich_with_models_dev_namespace(entries, value, namespace)
}
fn effective_custom_provider_models_dev_namespace<'a>(
provider: &'a str,
custom: &'a CustomProviderConfig,
) -> &'a str {
custom.models_dev_provider.as_deref().unwrap_or(provider)
}
fn merge_custom_provider_extra_models(
mut entries: Vec<ModelCatalogEntry>,
provider: &str,
extra_models: &[String],
) -> Result<Vec<ModelCatalogEntry>, String> {
for model in extra_models {
if !entries
.iter()
.any(|entry| entry.provider == provider && entry.model == *model)
{
entries.push(ModelCatalogEntry::new(provider, model));
}
}
Ok(entries)
}
fn enrich_with_models_dev_namespace(
mut entries: Vec<ModelCatalogEntry>,
value: &serde_json::Value,
namespace: &str,
) -> Vec<ModelCatalogEntry> {
for entry in &mut entries {
entry.reasoning_efforts = None;
entry.supports_reasoning = None;
entry.legacy_supports_reasoning_effort = None;
let Some(model) = value
.get(namespace)
.and_then(|provider| provider.get("models"))
.and_then(|models| models.get(&entry.model))
else {
continue;
};
entry.description = entry.description.take().or_else(|| {
model
.get("description")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
});
if let Some(limit) = model.get("limit") {
entry.context_window = entry.context_window.or_else(|| {
limit
.get("context")
.or_else(|| limit.get("input"))
.and_then(serde_json::Value::as_u64)
});
entry.max_output_tokens = entry
.max_output_tokens
.or_else(|| limit.get("output").and_then(serde_json::Value::as_u64));
}
entry.reasoning_efforts = explicit_reasoning_efforts(model);
entry.supports_reasoning = model
.get("reasoning")
.and_then(serde_json::Value::as_bool)
.or_else(|| supports_reasoning(model).then_some(true));
if let Some(cost) = model.get("cost") {
entry.input_cost = entry
.input_cost
.take()
.or_else(|| cost.get("input").map(|value| value.to_string()));
entry.output_cost = entry
.output_cost
.take()
.or_else(|| cost.get("output").map(|value| value.to_string()));
}
entry.modalities = entry.modalities.take().or_else(|| {
model
.get("modalities")
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
});
}
entries
}
fn models_dev_source(
entries: &[ModelCatalogEntry],
lookup: Option<&SharedModelsDevLookup>,
namespace: &str,
) -> CatalogSource {
let Some(lookup) = lookup else {
return CatalogSource::Live;
};
let Ok(value) = lookup.get() else {
return CatalogSource::Live;
};
let models = value
.get(namespace)
.and_then(|provider| provider.get("models"));
let matched = entries
.iter()
.filter(|entry| models.and_then(|models| models.get(&entry.model)).is_some())
.map(|entry| entry.model.clone())
.take(8)
.collect::<Vec<_>>();
let unmatched = entries
.iter()
.filter(|entry| models.and_then(|models| models.get(&entry.model)).is_none())
.map(|entry| entry.model.clone())
.take(8)
.collect::<Vec<_>>();
if matched.is_empty() && !unmatched.is_empty() {
CatalogSource::LiveNoMatch {
namespace: namespace.to_string(),
models: unmatched,
}
} else if !matched.is_empty() && !unmatched.is_empty() {
CatalogSource::LivePartialMatch {
namespace: namespace.to_string(),
matched,
unmatched,
}
} else {
CatalogSource::Live
}
}
fn explicit_reasoning_efforts(model: &serde_json::Value) -> Option<Vec<ThinkingLevel>> {
let efforts = (model.get("reasoning").and_then(serde_json::Value::as_bool) == Some(true))
.then(|| {
model
.pointer("/reasoning_options")
.and_then(serde_json::Value::as_array)
.and_then(|options| {
options.iter().find_map(|option| {
(option.get("type").and_then(serde_json::Value::as_str) == Some("effort"))
.then(|| option.get("values"))
.flatten()
.and_then(serde_json::Value::as_array)
})
})
})
.flatten()
.or_else(|| {
model
.pointer("/reasoning/efforts")
.and_then(serde_json::Value::as_array)
})?;
let levels = efforts
.iter()
.filter_map(serde_json::Value::as_str)
.filter_map(|level| {
(level == "none")
.then_some(ThinkingLevel::Default)
.or_else(|| level.parse().ok())
})
.collect::<Vec<_>>();
let normalized = crate::thinking::normalize_thinking_levels(&levels);
(normalized != crate::thinking::default_thinking_levels()).then_some(normalized)
}
#[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",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CatalogRefreshError {
provider: String,
category: CatalogRefreshFailureCategory,
}
impl CatalogRefreshError {
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 = 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 = crate::providers::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 = 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,
)
})?;
merge_custom_provider_extra_models(entries, provider, &extra_models).map_err(|_| {
CatalogRefreshError::new(provider, CatalogRefreshFailureCategory::ProviderDiscovery)
})
})?;
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 {
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 {
load_anthropic_catalog(paths, &auth_state, CachePreference::AllowStale)
.map(|_| CatalogRefreshOutcome::Updated)
.map_err(|_| {
CatalogRefreshError::new(provider, CatalogRefreshFailureCategory::ProviderDiscovery)
})
} else {
Ok(CatalogRefreshOutcome::Updated)
}
}
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)
}
pub(crate) fn cached_model_thinking_metadata(
paths: &McPaths,
provider: &str,
model: &str,
) -> Option<crate::thinking::CatalogThinkingMetadata> {
if provider == providers::CLAUDE_CODE_PROVIDER {
return claude_code_static_catalog()
.into_iter()
.find(|entry| entry.model == model)
.map(|entry| crate::thinking::CatalogThinkingMetadata {
reasoning_efforts: entry.reasoning_efforts,
supports_reasoning: entry.supports_reasoning,
});
}
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),
})
}
fn supports_reasoning(model: &serde_json::Value) -> bool {
model
.pointer("/reasoning/effort")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
|| model
.get("reasoning")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
pub fn persist_selected_model(paths: &McPaths, provider: &str, model: &str) -> anyhow::Result<()> {
set_selected_model(paths, provider, model)
}
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 = normalize_switch_model_id(ModelId::parse(model_id)?)?;
let settings = read_settings(paths)?;
let catalog = if model_id.provider() == providers::CLAUDE_CODE_PROVIDER {
CatalogLoadResult {
entries: claude_code_static_catalog(),
source: CatalogSource::FreshCache,
}
} else {
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");
}
persist_selected_model(paths, model_id.provider(), model_id.model())?;
let notice = catalog.source.notice();
Ok((
model_id.provider().to_string(),
model_id.model().to_string(),
notice,
))
}
pub fn model_usage() -> &'static str {
"usage: /setmodel <provider>/<model-name> (examples: /setmodel openai-codex/<model-name>, /setmodel anthropic/<model-name>, /setmodel claude-code/sonnet, /setmodel custom-local/<model-name>)"
}
pub fn catalog_cache_path(paths: &McPaths, provider: &str) -> Option<PathBuf> {
if provider
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
{
Some(
paths
.cache
.join("model-catalog")
.join(format!("{provider}.json")),
)
} else {
None
}
}
fn built_in_catalog_cache_fingerprint() -> CatalogCacheFingerprint {
CatalogCacheFingerprint {
enrichment_version: Some(ENRICHMENT_VERSION),
..Default::default()
}
}
fn read_catalog_cache(paths: &McPaths, provider: &str) -> Option<CachedCatalog> {
read_catalog_cache_with_fingerprint(paths, provider, &built_in_catalog_cache_fingerprint())
}
fn read_custom_provider_catalog_cache(
paths: &McPaths,
provider: &str,
custom: &CustomProviderConfig,
) -> Option<CachedCatalog> {
let fingerprint = custom_provider_catalog_cache_fingerprint(provider, custom).ok()?;
read_catalog_cache_with_fingerprint(paths, provider, &fingerprint)
}
fn read_catalog_cache_for_configured_provider(
paths: &McPaths,
provider: &str,
) -> Option<CachedCatalog> {
if provider == providers::OPENAI_CODEX_PROVIDER || provider == providers::ANTHROPIC_PROVIDER {
return read_catalog_cache(paths, provider);
}
let settings = read_settings(paths).ok()?;
let custom = settings.custom_providers.get(provider)?;
read_custom_provider_catalog_cache(paths, provider, custom)
}
fn metadata_entries_for_provider(
paths: &McPaths,
provider: &str,
) -> Option<Vec<ModelCatalogEntry>> {
if provider == providers::CLAUDE_CODE_PROVIDER {
Some(claude_code_static_catalog())
} else {
read_catalog_cache_for_configured_provider(paths, provider).map(|cache| cache.entries)
}
}
fn read_catalog_cache_with_fingerprint(
paths: &McPaths,
provider: &str,
expected_fingerprint: &CatalogCacheFingerprint,
) -> Option<CachedCatalog> {
let path = catalog_cache_path(paths, provider)?;
let text = read_bounded_file_to_string(path, DEFAULT_BOUNDED_BODY_MAX_BYTES)?;
let cache: CachedCatalog = serde_json::from_str(&text).ok()?;
if cache.schema_version != CACHE_SCHEMA_VERSION
|| cache.provider != provider
|| cache.cache_fingerprint != *expected_fingerprint
|| cache.entries.is_empty()
{
return None;
}
if cache.entries.iter().any(|entry| {
entry.provider != provider
|| ModelId::from_parts(provider, &entry.model)
.map(|model_id| entry.id != model_id.to_string())
.unwrap_or(true)
}) {
return None;
}
Some(cache)
}
fn custom_provider_catalog_cache_fingerprint(
provider: &str,
custom: &CustomProviderConfig,
) -> anyhow::Result<CatalogCacheFingerprint> {
Ok(CatalogCacheFingerprint {
enrichment_version: Some(ENRICHMENT_VERSION),
models_dev_provider: Some(
effective_custom_provider_models_dev_namespace(provider, custom).to_string(),
),
extra_models: crate::config::normalized_extra_models(&custom.extra_models)?,
})
}
pub fn write_catalog_cache(
paths: &McPaths,
provider: &str,
entries: &[ModelCatalogEntry],
) -> anyhow::Result<()> {
write_catalog_cache_with_fingerprint(
paths,
provider,
&built_in_catalog_cache_fingerprint(),
entries,
)
}
#[cfg(test)]
pub(crate) fn write_catalog_cache_for_configured_provider(
paths: &McPaths,
provider: &str,
entries: &[ModelCatalogEntry],
) -> anyhow::Result<()> {
if provider == providers::OPENAI_CODEX_PROVIDER {
return write_catalog_cache(paths, provider, entries);
}
let settings = read_settings(paths)?;
let Some(custom) = settings.custom_providers.get(provider) else {
return write_catalog_cache(paths, provider, entries);
};
write_custom_provider_catalog_cache(paths, provider, custom, entries)
}
fn write_custom_provider_catalog_cache(
paths: &McPaths,
provider: &str,
custom: &CustomProviderConfig,
entries: &[ModelCatalogEntry],
) -> anyhow::Result<()> {
let fingerprint = custom_provider_catalog_cache_fingerprint(provider, custom)?;
write_catalog_cache_with_fingerprint(paths, provider, &fingerprint, entries)
}
fn write_catalog_cache_with_fingerprint(
paths: &McPaths,
provider: &str,
cache_fingerprint: &CatalogCacheFingerprint,
entries: &[ModelCatalogEntry],
) -> anyhow::Result<()> {
let path = catalog_cache_path(paths, provider)
.ok_or_else(|| anyhow::anyhow!("invalid model catalog provider cache name"))?;
let now = Utc::now();
let cache = CachedCatalog {
schema_version: CACHE_SCHEMA_VERSION,
provider: provider.to_string(),
cache_fingerprint: cache_fingerprint.clone(),
fetched_at: now,
expires_at: now + Duration::hours(CATALOG_TTL_HOURS),
entries: entries.to_vec(),
};
atomic_write(&path, serde_json::to_string_pretty(&cache)?.as_bytes())
}
#[cfg(test)]
#[path = "model_catalog_tests.rs"]
mod tests;