use crate::chat::config::ProviderKind;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ModelsResponse {
data: Vec<ModelEntry>,
}
#[derive(Debug, Deserialize)]
struct ModelEntry {
id: String,
}
pub async fn list_models(
provider: ProviderKind,
api_key: &str,
base_url: &str,
) -> Result<Vec<String>, String> {
let key = api_key.trim();
if key.is_empty() {
return Err("missing API key".into());
}
let base = match (base_url.trim(), provider) {
("", ProviderKind::Anthropic) => "https://api.anthropic.com",
("", ProviderKind::OpenAI) => "https://api.openai.com",
(b, _) => b,
};
let url = format!("{}/v1/models", base.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.build()
.map_err(|e| e.to_string())?;
let mut req = client.get(&url);
match provider {
ProviderKind::Anthropic => {
req = req
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01");
}
ProviderKind::OpenAI => {
req = req.header("authorization", format!("Bearer {key}"));
}
}
let resp = req.send().await.map_err(|e| e.to_string())?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("HTTP {status}: {}", body.trim()));
}
let parsed: ModelsResponse = resp.json().await.map_err(|e| e.to_string())?;
let mut ids: Vec<String> = parsed
.data
.into_iter()
.map(|m| m.id)
.filter(|id| is_chat_model(provider, id))
.collect();
ids.sort();
ids.reverse();
ids.dedup();
Ok(ids)
}
fn is_chat_model(provider: ProviderKind, id: &str) -> bool {
let id = match provider {
ProviderKind::Anthropic => return true,
ProviderKind::OpenAI => id.to_ascii_lowercase(),
};
const REJECT_SUBSTRINGS: &[&str] = &[
"embedding",
"embed", "rerank",
"moderation",
"whisper",
"transcribe",
"tts", "speech",
"audio",
"dall-e",
"image", "vision-preview", "guard", "code-search",
];
const REJECT_PREFIXES: &[&str] = &[
"text-", "babbage", "curie", "ada-", "davinci", ];
if REJECT_PREFIXES.iter().any(|p| id.starts_with(p)) {
return false;
}
if REJECT_SUBSTRINGS.iter().any(|s| id.contains(s)) {
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anthropic_passes_everything_through() {
assert!(is_chat_model(ProviderKind::Anthropic, "claude-sonnet-4-6"));
assert!(is_chat_model(ProviderKind::Anthropic, "claude-3-5-haiku"));
}
#[test]
fn openai_compatible_keeps_third_party_chat_ids() {
assert!(is_chat_model(ProviderKind::OpenAI, "gpt-4o"));
assert!(is_chat_model(ProviderKind::OpenAI, "o1-mini"));
assert!(is_chat_model(ProviderKind::OpenAI, "o3-mini"));
assert!(is_chat_model(ProviderKind::OpenAI, "grok-4"));
assert!(is_chat_model(ProviderKind::OpenAI, "deepseek-chat"));
assert!(is_chat_model(ProviderKind::OpenAI, "moonshot-v1-8k"));
assert!(is_chat_model(ProviderKind::OpenAI, "glm-4-plus"));
assert!(is_chat_model(ProviderKind::OpenAI, "qwen-plus"));
assert!(is_chat_model(
ProviderKind::OpenAI,
"llama-3.1-70b-instruct"
));
assert!(is_chat_model(ProviderKind::OpenAI, "mistral-large-latest"));
}
#[test]
fn openai_filter_drops_non_chat() {
assert!(!is_chat_model(
ProviderKind::OpenAI,
"text-embedding-3-small"
));
assert!(!is_chat_model(
ProviderKind::OpenAI,
"text-moderation-latest"
));
assert!(!is_chat_model(ProviderKind::OpenAI, "whisper-1"));
assert!(!is_chat_model(ProviderKind::OpenAI, "tts-1-hd"));
assert!(!is_chat_model(ProviderKind::OpenAI, "dall-e-3"));
assert!(!is_chat_model(ProviderKind::OpenAI, "gpt-image-1"));
assert!(!is_chat_model(ProviderKind::OpenAI, "babbage-002"));
assert!(!is_chat_model(ProviderKind::OpenAI, "davinci-002"));
assert!(!is_chat_model(ProviderKind::OpenAI, "voyage-rerank-2")); assert!(!is_chat_model(ProviderKind::OpenAI, "llama-guard-3-8b")); }
}