magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
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)
}