//! Model discovery and fetching for multiple LLM providers. /// Fetch models from the Anthropic API. /// /// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error. pub(crate) async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String)> { let static_defaults = vec![ ( "claude-opus-4-6".into(), "Claude Opus 4.6 (latest flagship)".into(), ), ("claude-sonnet-4-6".into(), "Claude Sonnet 4.6".into()), ("claude-opus-4-5".into(), "Claude Opus 4.5".into()), ("claude-sonnet-4-5".into(), "Claude Sonnet 4.5".into()), ("claude-haiku-4-5".into(), "Claude Haiku 4.5 (fast)".into()), ]; let api_key = cached_key .map(String::from) .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()) .filter(|k| !k.is_empty() && k != crate::config::OAUTH_PLACEHOLDER); // Fall back to OAuth token if no API key let oauth_token = if api_key.is_none() { crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN") .ok() .flatten() .filter(|t| !t.is_empty()) } else { None }; let (key_or_token, is_oauth) = match (api_key, oauth_token) { (Some(k), _) => (k, false), (None, Some(t)) => (t, true), (None, None) => return static_defaults, }; let client = reqwest::Client::new(); let mut request = client .get("https://api.anthropic.com/v1/models") .header("anthropic-version", "2023-06-01") .timeout(std::time::Duration::from_secs(5)); if is_oauth { request = request .bearer_auth(&key_or_token) .header("anthropic-beta", "oauth-2025-04-20"); } else { request = request.header("x-api-key", &key_or_token); } let resp = match request.send().await { Ok(r) if r.status().is_success() => r, _ => return static_defaults, }; #[derive(serde::Deserialize)] struct ModelEntry { id: String, } #[derive(serde::Deserialize)] struct ModelsResponse { data: Vec, } match resp.json::().await { Ok(body) => { let mut models: Vec<(String, String)> = body .data .into_iter() .filter(|m| !m.id.contains("embedding") && !m.id.contains("audio")) .map(|m| { let label = m.id.clone(); (m.id, label) }) .collect(); if models.is_empty() { return static_defaults; } models.sort_by(|a, b| a.0.cmp(&b.0)); models } Err(_) => static_defaults, } } /// Fetch models from the OpenAI API. /// /// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error. pub(crate) async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)> { let static_defaults = vec![ ( "gpt-5.3-codex".into(), "GPT-5.3 Codex (latest flagship)".into(), ), ("gpt-5.2-codex".into(), "GPT-5.2 Codex".into()), ("gpt-5.2".into(), "GPT-5.2".into()), ( "gpt-5.1-codex-mini".into(), "GPT-5.1 Codex Mini (fast)".into(), ), ("gpt-5".into(), "GPT-5".into()), ("gpt-5-mini".into(), "GPT-5 Mini".into()), ("gpt-4.1".into(), "GPT-4.1".into()), ("gpt-4.1-mini".into(), "GPT-4.1 Mini".into()), ("o4-mini".into(), "o4-mini (fast reasoning)".into()), ("o3".into(), "o3 (reasoning)".into()), ]; let api_key = cached_key .map(String::from) .or_else(|| std::env::var("OPENAI_API_KEY").ok()) .filter(|k| !k.is_empty()); let api_key = match api_key { Some(k) => k, None => return static_defaults, }; let client = reqwest::Client::new(); let resp = match client .get("https://api.openai.com/v1/models") .bearer_auth(&api_key) .timeout(std::time::Duration::from_secs(5)) .send() .await { Ok(r) if r.status().is_success() => r, _ => return static_defaults, }; #[derive(serde::Deserialize)] struct ModelEntry { id: String, } #[derive(serde::Deserialize)] struct ModelsResponse { data: Vec, } match resp.json::().await { Ok(body) => { let mut models: Vec<(String, String)> = body .data .into_iter() .filter(|m| is_openai_chat_model(&m.id)) .map(|m| { let label = m.id.clone(); (m.id, label) }) .collect(); if models.is_empty() { return static_defaults; } sort_openai_models(&mut models); models } Err(_) => static_defaults, } } pub(crate) fn is_openai_chat_model(model_id: &str) -> bool { let id = model_id.to_ascii_lowercase(); let is_chat_family = id.starts_with("gpt-") || id.starts_with("chatgpt-") || id.starts_with("o1") || id.starts_with("o3") || id.starts_with("o4") || id.starts_with("o5"); let is_non_chat_variant = id.contains("realtime") || id.contains("audio") || id.contains("transcribe") || id.contains("tts") || id.contains("embedding") || id.contains("moderation") || id.contains("image"); is_chat_family && !is_non_chat_variant } pub(crate) fn openai_model_priority(model_id: &str) -> usize { let id = model_id.to_ascii_lowercase(); const EXACT_PRIORITY: &[&str] = &[ "gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2", "gpt-5.1-codex-mini", "gpt-5", "gpt-5-mini", "gpt-5-nano", "o4-mini", "o3", "o1", "gpt-4.1", "gpt-4.1-mini", "gpt-4o", "gpt-4o-mini", ]; if let Some(pos) = EXACT_PRIORITY.iter().position(|m| id == *m) { return pos; } const PREFIX_PRIORITY: &[&str] = &[ "gpt-5.", "gpt-5-", "o3-", "o4-", "o1-", "gpt-4.1-", "gpt-4o-", "gpt-3.5-", "chatgpt-", ]; if let Some(pos) = PREFIX_PRIORITY .iter() .position(|prefix| id.starts_with(prefix)) { return EXACT_PRIORITY.len() + pos; } EXACT_PRIORITY.len() + PREFIX_PRIORITY.len() + 1 } pub(crate) fn sort_openai_models(models: &mut [(String, String)]) { models.sort_by(|a, b| { openai_model_priority(&a.0) .cmp(&openai_model_priority(&b.0)) .then_with(|| a.0.cmp(&b.0)) }); } /// Fetch installed models from a local Ollama instance. /// /// Returns `(model_name, display_label)` pairs. Falls back to static defaults on error. pub(crate) async fn fetch_ollama_models(base_url: &str) -> Vec<(String, String)> { let static_defaults = vec![ ("llama3".into(), "llama3".into()), ("mistral".into(), "mistral".into()), ("codellama".into(), "codellama".into()), ]; let url = format!("{}/api/tags", base_url.trim_end_matches('/')); let client = reqwest::Client::new(); let resp = match client .get(&url) .timeout(std::time::Duration::from_secs(5)) .send() .await { Ok(r) if r.status().is_success() => r, Ok(_) => return static_defaults, Err(_) => { tracing::warn!( "Could not connect to Ollama at {base_url}. Is it running? Using static defaults." ); return static_defaults; } }; #[derive(serde::Deserialize)] struct ModelEntry { name: String, } #[derive(serde::Deserialize)] struct TagsResponse { models: Vec, } match resp.json::().await { Ok(body) => { let models: Vec<(String, String)> = body .models .into_iter() .map(|m| { let label = m.name.clone(); (m.name, label) }) .collect(); if models.is_empty() { return static_defaults; } models } Err(_) => static_defaults, } } /// Fetch models from a generic OpenAI-compatible /v1/models endpoint. /// /// Used for registry providers like Groq, NVIDIA NIM, etc. pub(crate) async fn fetch_openai_compatible_models( base_url: &str, cached_key: Option<&str>, ) -> Vec<(String, String)> { if base_url.is_empty() { return vec![]; } let url = format!("{}/models", base_url.trim_end_matches('/')); let client = reqwest::Client::new(); let mut req = client.get(&url).timeout(std::time::Duration::from_secs(5)); if let Some(key) = cached_key { req = req.bearer_auth(key); } let resp = match req.send().await { Ok(r) if r.status().is_success() => r, _ => return vec![], }; #[derive(serde::Deserialize)] struct Model { id: String, } #[derive(serde::Deserialize)] struct ModelsResponse { data: Vec, } match resp.json::().await { Ok(body) => body .data .into_iter() .map(|m| { let label = m.id.clone(); (m.id, label) }) .collect(), Err(_) => vec![], } } /// Build the `LlmConfig` used by `fetch_nearai_models` to list available models. /// /// Uses [`NearAiConfig::for_model_discovery()`] to construct a minimal NEAR AI /// config, then wraps it in an `LlmConfig` with session config for auth. pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig { let auth_base_url = crate::config::helpers::env_or_override("NEARAI_AUTH_URL") .unwrap_or_else(|| "https://private.near.ai".to_string()); crate::config::LlmConfig { backend: "nearai".to_string(), session: crate::llm::session::SessionConfig { auth_base_url, session_path: crate::config::llm::default_session_path(), }, nearai: crate::config::NearAiConfig::for_model_discovery(), provider: None, bedrock: None, request_timeout_secs: 120, cheap_model: None, smart_routing_cascade: false, openai_codex: None, } }