Skip to main content

cc_switch_lib/services/provider/
models.rs

1use reqwest::{Client, StatusCode};
2use serde_json::Value;
3use std::collections::HashSet;
4use std::time::Duration;
5
6use crate::error::AppError;
7
8use super::ProviderService;
9
10const KNOWN_COMPAT_SUFFIXES: &[&str] = &[
11    "/api/claudecode",
12    "/api/anthropic",
13    "/apps/anthropic",
14    "/api/coding",
15    "/claudecode",
16    "/anthropic",
17    "/step_plan",
18    "/coding",
19    "/claude",
20];
21
22impl ProviderService {
23    /// 尝试从远端拉取模型列表
24    pub async fn fetch_provider_models(
25        base_url: &str,
26        api_key: Option<&str>,
27    ) -> Result<Vec<String>, AppError> {
28        let base_url = base_url.trim().trim_end_matches('/');
29        if base_url.is_empty() {
30            return Err(AppError::localized(
31                "fetch.invalid_url",
32                "URL 不能为空",
33                "URL cannot be empty",
34            ));
35        }
36
37        let candidate_urls = build_provider_model_candidate_urls(base_url);
38
39        let client = Client::builder()
40            .timeout(Duration::from_secs(5))
41            .build()
42            .map_err(|e| AppError::Message(e.to_string()))?;
43
44        let mut last_err_zh = None;
45        let mut last_err_en = None;
46
47        for url in candidate_urls {
48            let mut req = client.get(&url);
49            if let Some(key) = api_key {
50                let key = key.trim();
51                // 同时添加 OpenAI 的 Bearer 和 Anthropic 的 x-api-key 格式,代理服务通常会接受其中之一
52                req = req
53                    .header("Authorization", format!("Bearer {}", key))
54                    .header("x-api-key", key);
55            }
56
57            match req.send().await {
58                Ok(resp) => {
59                    if resp.status().is_success() {
60                        if let Ok(json) = resp.json::<Value>().await {
61                            let mut models = Vec::new();
62
63                            // 测试格式 1: OpenAI 兼容格式 {"data": [{"id": "gpt-4o"}]}
64                            if let Some(data) = json.get("data").and_then(|d| d.as_array()) {
65                                for item in data {
66                                    if let Some(id) = item.get("id").and_then(|i| i.as_str()) {
67                                        models.push(id.to_string());
68                                    }
69                                }
70                            }
71
72                            // 测试格式 2: Gemini 格式 {"models": [{"name": "models/gemini-pro"}]}
73                            if models.is_empty() {
74                                if let Some(data) = json.get("models").and_then(|d| d.as_array()) {
75                                    for item in data {
76                                        if let Some(name) =
77                                            item.get("name").and_then(|i| i.as_str())
78                                        {
79                                            let id = name.strip_prefix("models/").unwrap_or(name);
80                                            models.push(id.to_string());
81                                        }
82                                    }
83                                }
84                            }
85
86                            // 测试格式 3: 直接的数组格式 [{"id": "llama-3"}]
87                            if models.is_empty() {
88                                if let Some(arr) = json.as_array() {
89                                    for item in arr {
90                                        if let Some(id) = item.get("id").and_then(|i| i.as_str()) {
91                                            models.push(id.to_string());
92                                        }
93                                    }
94                                }
95                            }
96
97                            if !models.is_empty() {
98                                // 保序去重,避免非相邻重复项残留。
99                                let mut seen = HashSet::new();
100                                models.retain(|model| seen.insert(model.clone()));
101                                return Ok(models);
102                            } else {
103                                last_err_zh =
104                                    Some(format!("未能在响应中找到模型列表 (URL: {})", url));
105                                last_err_en =
106                                    Some(format!("No model list found in response (URL: {})", url));
107                            }
108                        } else {
109                            last_err_zh = Some(format!("无法解析 JSON 响应 (URL: {})", url));
110                            last_err_en =
111                                Some(format!("Failed to parse JSON response (URL: {})", url));
112                        }
113                    } else {
114                        let status = resp.status();
115                        let err = format!("HTTP {} (URL: {})", status, url);
116                        last_err_zh = Some(err.clone());
117                        last_err_en = Some(err);
118                        if status != StatusCode::NOT_FOUND
119                            && status != StatusCode::METHOD_NOT_ALLOWED
120                        {
121                            break;
122                        }
123                    }
124                }
125                Err(e) => {
126                    let err = e.to_string();
127                    last_err_zh = Some(err.clone());
128                    last_err_en = Some(err);
129                }
130            }
131        }
132
133        let err_zh = last_err_zh.unwrap_or_else(|| "未知错误".to_string());
134        let err_en = last_err_en.unwrap_or_else(|| "Unknown error".to_string());
135        Err(AppError::localized(
136            "fetch.failed",
137            format!("拉取失败: {}", err_zh),
138            format!("Fetch failed: {}", err_en),
139        ))
140    }
141}
142
143fn build_provider_model_candidate_urls(base_url: &str) -> Vec<String> {
144    let base = base_url.trim().trim_end_matches('/');
145    if base.is_empty() {
146        return Vec::new();
147    }
148    if base.ends_with("/models") {
149        return vec![base.to_string()];
150    }
151
152    let append_models = format!("{base}/models");
153    let append_versioned_models = if base.ends_with("/v1") || base.ends_with("/v1beta") {
154        None
155    } else {
156        Some(format!("{base}/v1/models"))
157    };
158
159    let mut urls = Vec::new();
160    if let Some(stripped) = strip_compat_suffix(base) {
161        if let Some(versioned) = append_versioned_models {
162            urls.push(versioned);
163        } else {
164            urls.push(append_models.clone());
165        }
166        let root = stripped.trim_end_matches('/');
167        if !root.is_empty() && root.contains("://") {
168            urls.push(format!("{root}/v1/models"));
169            urls.push(format!("{root}/models"));
170        }
171    } else {
172        urls.push(append_models);
173        if let Some(versioned) = append_versioned_models {
174            urls.push(versioned);
175        }
176    }
177
178    let mut seen = HashSet::new();
179    urls.retain(|url| seen.insert(url.clone()));
180    urls
181}
182
183fn strip_compat_suffix(base: &str) -> Option<&str> {
184    let lower = base.to_ascii_lowercase();
185    KNOWN_COMPAT_SUFFIXES.iter().find_map(|suffix| {
186        lower
187            .ends_with(suffix)
188            .then(|| &base[..base.len() - suffix.len()])
189    })
190}
191
192#[cfg(test)]
193mod tests {
194    use super::build_provider_model_candidate_urls;
195
196    #[test]
197    fn model_candidates_strip_deepseek_anthropic_suffix() {
198        let urls = build_provider_model_candidate_urls("https://api.deepseek.com/anthropic");
199
200        assert_eq!(
201            urls,
202            vec![
203                "https://api.deepseek.com/anthropic/v1/models".to_string(),
204                "https://api.deepseek.com/v1/models".to_string(),
205                "https://api.deepseek.com/models".to_string(),
206            ]
207        );
208    }
209}