cc-switch-tui 0.1.3

All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode, OpenClaw & Hermes
Documentation
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();
                // 同时添加 OpenAI 的 Bearer 和 Anthropic 的 x-api-key 格式,代理服务通常会接受其中之一
                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();

                            // 测试格式 1: OpenAI 兼容格式 {"data": [{"id": "gpt-4o"}]}
                            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());
                                    }
                                }
                            }

                            // 测试格式 2: Gemini 格式 {"models": [{"name": "models/gemini-pro"}]}
                            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());
                                        }
                                    }
                                }
                            }

                            // 测试格式 3: 直接的数组格式 [{"id": "llama-3"}]
                            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(),
            ]
        );
    }
}