cc_switch_lib/services/provider/
models.rs1use 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 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 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 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 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 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 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}