magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use super::types::{CatalogSource, ModelCatalogEntry};
use crate::{
    config::CustomProviderConfig, http_body::read_bounded_response_text, thinking::ThinkingLevel,
};
use std::{sync::OnceLock, time::Duration};

const MODEL_CATALOG_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
// models.dev currently exceeds shared 2 MiB cap; 8 MiB adds bounded headroom without widening unrelated callers.
pub(super) const MODELS_DEV_BODY_MAX_BYTES: u64 = 8 * 1024 * 1024;
const MODEL_CATALOG_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);

#[derive(Debug, Default)]
pub(super) struct ModelsDevLookup {
    pub(super) attempted: bool,
    pub(super) value: Option<serde_json::Value>,
}

impl ModelsDevLookup {
    #[cfg(test)]
    pub(super) 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)]
pub(super) struct SharedModelsDevLookup {
    value: OnceLock<Result<serde_json::Value, ModelsDevFetchError>>,
}

impl SharedModelsDevLookup {
    pub(super) 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
    }

    pub(super) 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)]
pub(super) 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,
}

pub(super) 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,
    )
}

pub(super) fn fetch_models_dev_catalog_with_timeouts(
    url: &str,
    connect_timeout: Duration,
    request_timeout: Duration,
) -> 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)
}

pub(super) 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"))
}

pub(super) 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,
    ))
}

pub(super) 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)
}

pub(super) 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)
}

pub(super) 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)
}

pub(super) fn enrich_with_models_dev_namespace(
    mut entries: Vec<ModelCatalogEntry>,
    value: &serde_json::Value,
    namespace: &str,
) -> Vec<ModelCatalogEntry> {
    for entry in &mut entries {
        let preserve_provider_reasoning = entry.provider == crate::providers::OPENAI_CODEX_PROVIDER;
        let provider_reasoning_efforts = preserve_provider_reasoning
            .then(|| entry.reasoning_efforts.clone())
            .flatten();
        let provider_supports_reasoning = preserve_provider_reasoning
            .then_some(entry.supports_reasoning)
            .flatten()
            .or_else(|| {
                preserve_provider_reasoning
                    .then_some(entry.legacy_supports_reasoning_effort)
                    .flatten()
            });
        if !preserve_provider_reasoning {
            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 =
            provider_reasoning_efforts.or_else(|| explicit_reasoning_efforts(model));
        entry.supports_reasoning = provider_supports_reasoning
            .or_else(|| 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
}

pub(super) 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
    }
}

pub(super) 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)
}

pub(super) 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)
}