//! 厂商模型列表拉取 — 纯逻辑(无 IO)。 //! //! 从 `model_fetch.rs` 抽离的纯函数 / 类型:URL 拼接、噪音过滤、响应反序列化。 //! 这些逻辑无网络依赖,可独立单测;主文件 `model_fetch.rs` 保留有 IO 的拉取入口。 //! //! 设计来源:docs/02-架构设计/F-01-模型能力系统与智能路由设计-2026-06-16.md §5.3 / §5.4。 use serde::Deserialize; // ──────────────────────────────────────────────────────────── // 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)] pub(crate) 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。 pub(crate) 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"]); } }