//! 厂商模型列表拉取 — F-01 阶段3 //! //! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。 //! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。 //! //! 协议分派: //! - `openai_compat` → `GET {base_url}/v1/models`(Bearer 鉴权) //! - `anthropic_compat` → `GET {base_url}/v1/models`(x-api-key + anthropic-version 鉴权) //! - 其他 → `UnsupportedProvider` 错误 //! //! 设计来源:docs/02-架构设计/F-01-模型能力系统与智能路由设计-2026-06-16.md §5 //! (设计 §5.1 端点表 + §5.3 URL 智能拼接 + §5.4 噪音过滤)。 use std::time::Duration; use anyhow::{anyhow, Result}; use df_ai_core::model::ModelConfig; use serde::Deserialize; use crate::model_probe::probe; // ──────────────────────────────────────────────────────────── // 公共入口 // ──────────────────────────────────────────────────────────── /// HTTP 请求超时(秒)。模型列表接口响应小、无需长超时。 const FETCH_TIMEOUT_SECS: u64 = 10; /// 拉取厂商模型列表,返回已过滤噪音的模型名 Vec。 /// /// 协议分派(见模块文档)。错误信息含 `provider_type` + HTTP 状态/原因,便于前端友好提示。 /// /// 不真实调测:本函数本身是网络 IO,单测只覆盖 URL 拼接 / 噪音过滤等纯逻辑; /// 集成验证留给阶段 5 的 IPC `ai_fetch_models`(用户在 Settings 点「测试连接」触发)。 pub async fn fetch_model_names( provider_type: &str, base_url: &str, api_key: &str, ) -> Result> { match provider_type { "openai_compat" => fetch_openai_compat(base_url, api_key).await, "anthropic_compat" => fetch_anthropic_compat(base_url, api_key).await, other => Err(anyhow!( "拉取模型列表失败:不支持的 provider_type={other}(仅支持 openai_compat / anthropic_compat)" )), } } /// 拉取模型列表 + 对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。 /// /// 探测为纯 CPU(无网络),不会因单模型探测失败中断整体 — 探测对任意输入都有兜底(见 model_probe)。 pub async fn fetch_and_probe( provider_type: &str, base_url: &str, api_key: &str, ) -> Result> { let names = fetch_model_names(provider_type, base_url, api_key).await?; Ok(names.into_iter().map(|n| probe(&n)).collect()) } // ──────────────────────────────────────────────────────────── // 协议分派实现 // ──────────────────────────────────────────────────────────── /// OpenAI 兼容(GLM / DeepSeek / Kimi / 通义 / 中转站):`GET /v1/models`,Bearer 鉴权。 async fn fetch_openai_compat(base_url: &str, api_key: &str) -> Result> { let url = build_models_url(base_url); let client = build_client()?; let resp = client .get(&url) .bearer_auth(api_key) .send() .await .map_err(|e| map_network_error("openai_compat", &url, e))?; let status = resp.status(); if !status.is_success() { return Err(map_status_error("openai_compat", status, &url)); } // OpenAI 响应:`{data:[{id, owned_by, ...}]}`。中转站通常同构。 let body: ModelsList = resp .json() .await .map_err(|e| anyhow!("openai_compat 响应解析失败({url}):{e}"))?; Ok(filter_chat_models(body.into_ids())) } /// Anthropic 兼容(Claude 官方 / GLM 订阅端点):`GET /v1/models`,x-api-key + anthropic-version 鉴权。 async fn fetch_anthropic_compat(base_url: &str, api_key: &str) -> Result> { let url = build_models_url(base_url); let client = build_client()?; let resp = client .get(&url) .header("x-api-key", api_key) .header("anthropic-version", "2023-06-01") .send() .await .map_err(|e| map_network_error("anthropic_compat", &url, e))?; let status = resp.status(); if !status.is_success() { return Err(map_status_error("anthropic_compat", status, &url)); } // Anthropic 响应:`{data:[{id, display_name, type, ...}]}`(has_more 分页字段忽略)。 // 兼容兜底:`{models:[{name, ...}]}`(Ollama 风格,理论 anthropic_compat 不会命中, // 但中转站行为不可控,用 `#[serde(alias)]` 零成本兜底 — 见 issues)。 let body: ModelsList = resp .json() .await .map_err(|e| anyhow!("anthropic_compat 响应解析失败({url}):{e}"))?; Ok(filter_chat_models(body.into_ids())) } // ──────────────────────────────────────────────────────────── // HTTP 客户端 + 错误映射 // ──────────────────────────────────────────────────────────── fn build_client() -> Result { reqwest::Client::builder() .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)) .build() .map_err(|e| anyhow!("构建 HTTP 客户端失败:{e}")) } /// 网络错误(连接拒绝 / DNS / 超时)→ 友好提示,标注 provider_type + url 便于排查。 fn map_network_error(provider_type: &str, url: &str, e: reqwest::Error) -> anyhow::Error { if e.is_timeout() { anyhow!("{provider_type} 拉取模型列表超时({url},>{FETCH_TIMEOUT_SECS}s)— 检查 base_url 可达性") } else { anyhow!("{provider_type} 拉取模型列表网络错误({url}):{e}") } } /// HTTP 状态错误 → 按 401/403/404/5xx 分类友好提示(对齐设计 §5.5 失败降级)。 fn map_status_error(provider_type: &str, status: reqwest::StatusCode, url: &str) -> anyhow::Error { if status.as_u16() == 401 || status.as_u16() == 403 { anyhow!("{provider_type} 鉴权失败({status}, {url})— 检查 api_key 是否正确 / 是否有该接口权限") } else if status.as_u16() == 404 { anyhow!("{provider_type} 端点不存在(404, {url})— 该厂商可能不支持模型列表拉取,请手动输入模型名") } else if status.is_server_error() { anyhow!("{provider_type} 厂商服务异常({status}, {url})— 稍后重试或手动输入模型名") } else { anyhow!("{provider_type} 拉取模型列表失败(HTTP {status}, {url})") } } // ──────────────────────────────────────────────────────────── // URL 智能拼接 // ──────────────────────────────────────────────────────────── /// 拼接模型列表端点 URL。 /// /// 规则(设计 §5.3,base_url 先去尾 `/`): /// - 以 `/chat/completions` 结尾 → 替换为 `/models`(用户把对话端点当 base_url 填了) /// - 以 `/v1` / `/v4` 结尾 → 直接补 `/models`(避免 `/v1/v1/models`) /// - 以 `/models` 结尾 → 原样用(用户已填完整端点) /// - 以 `/api` 结尾 → 补 `/models`(Ollama 风格 base,anthropic_compat 兜底用) /// - 其余 → 补 `/v1/models`(最常见:用户填 `https://open.bigmodel.cn/api/paas/v4`) pub fn build_models_url(base_url: &str) -> String { let url = base_url.trim_end_matches('/'); if url.ends_with("/chat/completions") { url.replace("/chat/completions", "/models") } else if url.ends_with("/v1") || url.ends_with("/v4") { format!("{url}/models") } else if url.ends_with("/models") { url.to_string() } else if url.ends_with("/api") { format!("{url}/models") } else { format!("{url}/v1/models") } } // ──────────────────────────────────────────────────────────── // 噪音过滤(剔除非 chat 模型) // ──────────────────────────────────────────────────────────── /// 判断是否为非 chat 模型(应从列表中剔除)。 /// /// 规则(设计 §5.4 + 合理扩展,见 issues): /// - 图片生成:dall-e / midjourney / stable-diffusion / imagen /// - 语音:tts / whisper / audio / speech / voice(语音合成/识别) /// - 实时:realtime(OpenAI Realtime API 语音对话,非 chat completions) /// - 转写:transcribe / transcription /// - 审查:moderation(内容审查模型) /// - 搜索:davinci-search / search(旧 GPT 搜索变种) /// /// embedding 设计 §5.4 注释「保留(知识库需要)」— 此处遵循设计保留 embedding, /// 不在 `is_non_chat_model` 剔除(知识库 embedding 路由用得着)。 pub fn is_non_chat_model(id: &str) -> bool { let id = id.to_lowercase(); // 图片生成 id.contains("dall-e") || id.contains("midjourney") || id.contains("stable-diffusion") || id.contains("imagen") // 语音(tts 合成 / whisper 识别 / 通用 audio / speech / voice) || id.contains("tts") || id.contains("whisper") || id.contains("audio") || id.contains("speech") || id.contains("voice") // 实时语音对话(OpenAI Realtime API,走独立协议非 chat completions) || id.contains("realtime") // 转写 || id.contains("transcribe") || id.contains("transcription") // 内容审查 || id.contains("moderation") // 旧 GPT 搜索变种(davinci-search 覆盖旧 GPT 搜索;裸 -search 误伤合法 search-augmented chat // 模型如 gpt-4o-search-preview/qwen-search/glm-4-search,已移除) || id.contains("davinci-search") } /// 过滤噪音:剔除非 chat 模型 + 去重(中转站可能返回重复) + 去空名。 pub fn filter_chat_models(ids: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); let mut out = Vec::with_capacity(ids.len()); for id in ids { let trimmed = id.trim(); if trimmed.is_empty() || is_non_chat_model(trimmed) { continue; } if seen.insert(trimmed.to_string()) { out.push(trimmed.to_string()); } } out } // ──────────────────────────────────────────────────────────── // 响应反序列化 // ──────────────────────────────────────────────────────────── /// 厂商 `/v1/models` 响应统一反序列化。 /// /// 兼容三种结构(用 `#[serde(alias)]`): /// - OpenAI / Anthropic:`{data:[{id, ...}]}` /// - Ollama:`{models:[{name, ...}]}`(anthropic_compat 走中转站时兜底) /// - 单字段别名:`id` / `name` 都认 #[derive(Debug, Deserialize)] struct ModelsList { #[serde(default)] data: Vec, #[serde(default, alias = "models")] data_alt: Vec, } #[derive(Debug, Deserialize)] struct ModelEntry { #[serde(default, alias = "name")] id: Option, } impl ModelsList { /// 合并 `data` 与 `models`(alias)字段,提取所有模型 id/name,丢弃 None。 fn into_ids(self) -> Vec { self.data .into_iter() .chain(self.data_alt) .filter_map(|e| e.id) .collect() } } // ──────────────────────────────────────────────────────────── // 测试(纯逻辑/字符串处理,不真实调 API) // ──────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; // ── is_non_chat_model:search-augmented chat 模型不应误剔(裸 -search 已移除) ── #[test] fn is_non_chat_model_keeps_search_augmented_chat() { // search-augmented chat 模型(对话端点可调)不应被 -search 子串误剔 assert!(!is_non_chat_model("gpt-4o-search-preview")); assert!(!is_non_chat_model("qwen-search")); assert!(!is_non_chat_model("glm-4-search-preview")); // 旧 GPT 搜索变种仍剔(davinci-search 覆盖) assert!(is_non_chat_model("text-davinci-search-001")); } // ── build_models_url:尾 / 处理 ── #[test] fn build_url_strips_trailing_slash() { // 用户填 `https://api.x.com/v1/`(尾 /)→ 去 / 后命中 /v1 分支 → 补 /models assert_eq!( build_models_url("https://api.x.com/v1/"), "https://api.x.com/v1/models" ); // 多个尾 / assert_eq!( build_models_url("https://api.x.com/v4//"), "https://api.x.com/v4/models" ); } #[test] fn build_url_v1_suffix_appends_models() { assert_eq!( build_models_url("https://api.openai.com/v1"), "https://api.openai.com/v1/models" ); // 不应产生 /v1/v1/models assert!(!build_models_url("https://api.openai.com/v1").contains("/v1/v1")); } #[test] fn build_url_v4_suffix_appends_models() { // GLM:`https://open.bigmodel.cn/api/paas/v4` assert_eq!( build_models_url("https://open.bigmodel.cn/api/paas/v4"), "https://open.bigmodel.cn/api/paas/v4/models" ); } #[test] fn build_url_bare_base_appends_v1_models() { // 用户只填根:`https://api.deepseek.com` → 补 /v1/models assert_eq!( build_models_url("https://api.deepseek.com"), "https://api.deepseek.com/v1/models" ); } #[test] fn build_url_chat_completions_replaced() { // 用户误把对话端点当 base_url assert_eq!( build_models_url("https://api.x.com/v1/chat/completions"), "https://api.x.com/v1/models" ); } #[test] fn build_url_already_models_kept() { // 用户已填完整端点 → 原样用 assert_eq!( build_models_url("https://api.x.com/v1/models"), "https://api.x.com/v1/models" ); } #[test] fn build_url_api_suffix_ollama_style() { // Ollama 风格 base(anthropic_compat 兜底路径) assert_eq!( build_models_url("http://localhost:11434/api"), "http://localhost:11434/api/models" ); } // ── is_non_chat_model:噪音过滤规则 ── #[test] fn non_chat_image_generation_filtered() { assert!(is_non_chat_model("dall-e-3")); assert!(is_non_chat_model("dall-e-2")); assert!(is_non_chat_model("midjourney-v6")); assert!(is_non_chat_model("stable-diffusion-xl")); } #[test] fn non_chat_speech_filtered() { assert!(is_non_chat_model("tts-1")); assert!(is_non_chat_model("tts-1-hd")); assert!(is_non_chat_model("whisper-1")); assert!(is_non_chat_model("audio-transcribe")); assert!(is_non_chat_model("speech-synth")); } #[test] fn non_chat_realtime_filtered() { assert!(is_non_chat_model("gpt-4o-realtime")); assert!(is_non_chat_model("gpt-4o-mini-realtime-preview")); } #[test] fn non_chat_moderation_filtered() { assert!(is_non_chat_model("text-moderation-stable")); assert!(is_non_chat_model("omni-moderation-latest")); } #[test] fn non_chat_search_variants_filtered() { assert!(is_non_chat_model("text-davinci-search-001")); } #[test] fn chat_models_kept() { // 主流 chat 模型不应被误剔 for ok in [ "glm-4", "glm-4-flash", "glm-4v", "glm-4-air", "glm-4-plus", "deepseek-chat", "deepseek-coder", "gpt-4o", "gpt-4o-mini", "claude-3-5-sonnet-20241022", "claude-3-haiku-20240307", "qwen-plus", "kimi-k2", ] { assert!(!is_non_chat_model(ok), "{ok} 不应被当噪音剔除"); } } #[test] fn embedding_kept_per_design() { // 设计 §5.4 明确「embedding 保留(知识库需要)」 for emb in ["embedding-3", "text-embedding-3-large", "bge-m3"] { assert!(!is_non_chat_model(emb), "{emb} 按 §5.4 应保留"); } } // ── filter_chat_models:过滤 + 去重 + 去空 ── #[test] fn filter_removes_noise_and_keeps_chat() { let raw = vec![ "glm-4-flash".to_string(), "dall-e-3".to_string(), // 噪音 "tts-1".to_string(), // 噪音 "whisper-1".to_string(), // 噪音 "text-moderation-stable".to_string(), // 噪音 "glm-4v".to_string(), "gpt-4o".to_string(), ]; let got = filter_chat_models(raw); assert_eq!(got, vec!["glm-4-flash", "glm-4v", "gpt-4o"]); } #[test] fn filter_dedupes() { // 中转站可能返回重复模型名 let raw = vec![ "glm-4".to_string(), "glm-4".to_string(), "glm-4-flash".to_string(), ]; let got = filter_chat_models(raw); assert_eq!(got, vec!["glm-4", "glm-4-flash"]); } #[test] fn filter_drops_empty_and_whitespace_only() { let raw = vec![ "".to_string(), " ".to_string(), "glm-4".to_string(), " glm-4v ".to_string(), // 带空格 → trim 后保留 ]; let got = filter_chat_models(raw); assert_eq!(got, vec!["glm-4", "glm-4v"]); } #[test] fn filter_empty_input() { assert!(filter_chat_models(vec![]).is_empty()); } // ── ModelsList 反序列化:兼容 OpenAI / Anthropic / Ollama 三种结构 ── #[test] fn parse_openai_format() { // OpenAI:`{data:[{id, ...}]}` let json = r#"{ "data": [ {"id": "gpt-4o", "owned_by": "openai"}, {"id": "gpt-4o-mini", "owned_by": "openai"} ] }"#; let m: ModelsList = serde_json::from_str(json).unwrap(); let ids = m.into_ids(); assert_eq!(ids, vec!["gpt-4o", "gpt-4o-mini"]); } #[test] fn parse_anthropic_format() { // Anthropic:`{data:[{id, display_name, type}], has_more:false}` let json = r#"{ "data": [ {"id": "claude-3-5-sonnet-20241022", "display_name": "Claude 3.5 Sonnet", "type": "model"}, {"id": "claude-3-haiku-20240307", "display_name": "Claude 3 Haiku", "type": "model"} ], "has_more": false, "first_id": "claude-3-5-sonnet-20241022", "last_id": "claude-3-haiku-20240307" }"#; let m: ModelsList = serde_json::from_str(json).unwrap(); let ids = m.into_ids(); assert_eq!(ids, vec!["claude-3-5-sonnet-20241022", "claude-3-haiku-20240307"]); } #[test] fn parse_ollama_format_via_alias() { // Ollama:`{models:[{name, size}]}` — 走 anthropic_compat 中转站兜底 let json = r#"{ "models": [ {"name": "llama3:8b", "size": 4661214673}, {"name": "qwen2:7b", "size": 4436122400} ] }"#; let m: ModelsList = serde_json::from_str(json).unwrap(); let ids = m.into_ids(); assert_eq!(ids, vec!["llama3:8b", "qwen2:7b"]); } #[test] fn parse_missing_data_field_yields_empty() { // 厂商返回 `{}` 或缺 data 字段 → 空列表,不 panic let json = r#"{}"#; let m: ModelsList = serde_json::from_str(json).unwrap(); assert!(m.into_ids().is_empty()); } #[test] fn parse_entry_missing_id_skipped() { // data 数组项缺 id → 该项跳过 let json = r#"{ "data": [ {"id": "glm-4"}, {"owned_by": "x"}, {"id": "glm-4v"} ] }"#; let m: ModelsList = serde_json::from_str(json).unwrap(); let ids = m.into_ids(); assert_eq!(ids, vec!["glm-4", "glm-4v"]); } // ── fetch_model_names 协议分派:不支持类型直接报错(不触网) ── #[tokio::test] async fn fetch_unsupported_provider_errors_without_network() { // ollama / glm 等非 openai_compat / anthropic_compat 类型 → 直接错误,不发请求 let err = fetch_model_names("ollama", "http://localhost:11434", "k") .await .unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("ollama"), "err={msg}"); assert!(msg.contains("provider_type"), "err={msg}"); } }