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);
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)
}