use reqwest::{Client, StatusCode};
use serde_json::Value;
use std::collections::HashSet;
use std::time::Duration;
use crate::error::AppError;
use super::ProviderService;
const KNOWN_COMPAT_SUFFIXES: &[&str] = &[
"/api/claudecode",
"/api/anthropic",
"/apps/anthropic",
"/api/coding",
"/claudecode",
"/anthropic",
"/step_plan",
"/coding",
"/claude",
];
impl ProviderService {
pub async fn fetch_provider_models(
base_url: &str,
api_key: Option<&str>,
) -> Result<Vec<String>, AppError> {
let base_url = base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(AppError::localized(
"fetch.invalid_url",
"URL 不能为空",
"URL cannot be empty",
));
}
let candidate_urls = build_provider_model_candidate_urls(base_url);
let client = Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| AppError::Message(e.to_string()))?;
let mut last_err_zh = None;
let mut last_err_en = None;
for url in candidate_urls {
let mut req = client.get(&url);
if let Some(key) = api_key {
let key = key.trim();
req = req
.header("Authorization", format!("Bearer {}", key))
.header("x-api-key", key);
}
match req.send().await {
Ok(resp) => {
if resp.status().is_success() {
if let Ok(json) = resp.json::<Value>().await {
let mut models = Vec::new();
if let Some(data) = json.get("data").and_then(|d| d.as_array()) {
for item in data {
if let Some(id) = item.get("id").and_then(|i| i.as_str()) {
models.push(id.to_string());
}
}
}
if models.is_empty() {
if let Some(data) = json.get("models").and_then(|d| d.as_array()) {
for item in data {
if let Some(name) =
item.get("name").and_then(|i| i.as_str())
{
let id = name.strip_prefix("models/").unwrap_or(name);
models.push(id.to_string());
}
}
}
}
if models.is_empty() {
if let Some(arr) = json.as_array() {
for item in arr {
if let Some(id) = item.get("id").and_then(|i| i.as_str()) {
models.push(id.to_string());
}
}
}
}
if !models.is_empty() {
let mut seen = HashSet::new();
models.retain(|model| seen.insert(model.clone()));
return Ok(models);
} else {
last_err_zh =
Some(format!("未能在响应中找到模型列表 (URL: {})", url));
last_err_en =
Some(format!("No model list found in response (URL: {})", url));
}
} else {
last_err_zh = Some(format!("无法解析 JSON 响应 (URL: {})", url));
last_err_en =
Some(format!("Failed to parse JSON response (URL: {})", url));
}
} else {
let status = resp.status();
let err = format!("HTTP {} (URL: {})", status, url);
last_err_zh = Some(err.clone());
last_err_en = Some(err);
if status != StatusCode::NOT_FOUND
&& status != StatusCode::METHOD_NOT_ALLOWED
{
break;
}
}
}
Err(e) => {
let err = e.to_string();
last_err_zh = Some(err.clone());
last_err_en = Some(err);
}
}
}
let err_zh = last_err_zh.unwrap_or_else(|| "未知错误".to_string());
let err_en = last_err_en.unwrap_or_else(|| "Unknown error".to_string());
Err(AppError::localized(
"fetch.failed",
format!("拉取失败: {}", err_zh),
format!("Fetch failed: {}", err_en),
))
}
}
fn build_provider_model_candidate_urls(base_url: &str) -> Vec<String> {
let base = base_url.trim().trim_end_matches('/');
if base.is_empty() {
return Vec::new();
}
if base.ends_with("/models") {
return vec![base.to_string()];
}
let append_models = format!("{base}/models");
let append_versioned_models = if base.ends_with("/v1") || base.ends_with("/v1beta") {
None
} else {
Some(format!("{base}/v1/models"))
};
let mut urls = Vec::new();
if let Some(stripped) = strip_compat_suffix(base) {
if let Some(versioned) = append_versioned_models {
urls.push(versioned);
} else {
urls.push(append_models.clone());
}
let root = stripped.trim_end_matches('/');
if !root.is_empty() && root.contains("://") {
urls.push(format!("{root}/v1/models"));
urls.push(format!("{root}/models"));
}
} else {
urls.push(append_models);
if let Some(versioned) = append_versioned_models {
urls.push(versioned);
}
}
let mut seen = HashSet::new();
urls.retain(|url| seen.insert(url.clone()));
urls
}
fn strip_compat_suffix(base: &str) -> Option<&str> {
let lower = base.to_ascii_lowercase();
KNOWN_COMPAT_SUFFIXES.iter().find_map(|suffix| {
lower
.ends_with(suffix)
.then(|| &base[..base.len() - suffix.len()])
})
}
#[cfg(test)]
mod tests {
use super::build_provider_model_candidate_urls;
#[test]
fn model_candidates_strip_deepseek_anthropic_suffix() {
let urls = build_provider_model_candidate_urls("https://api.deepseek.com/anthropic");
assert_eq!(
urls,
vec![
"https://api.deepseek.com/anthropic/v1/models".to_string(),
"https://api.deepseek.com/v1/models".to_string(),
"https://api.deepseek.com/models".to_string(),
]
);
}
}