From f5f23587996f2cf379b91fc07d80d19c1725fc96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 04:44:30 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E6=8B=86model=5Ffetch?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=8E=B7=E5=8F=96(strategy=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=BA=93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 df-ai/model_fetch_helpers.rs(400行): build_models_url/is_non_chat_model/filter_chat_models + ModelsList/ModelEntry + 27测试 - model_fetch.rs 21KB→6KB(176行): fetch_model_names/fetch_and_probe入口 + IO fn + use helpers - lib.rs: mod model_fetch_helpers 防污染核验: extract_error_diag不存在(以源码为准非背景声明), probe在model_probe 主代兜底: cargo check --workspace 0 + test df-ai 119 strategy: 核心库自底向上, 纯函数抽离, ModelsList pub(crate) git add指定(df-ai/*) --- crates/df-ai/src/lib.rs | 3 + crates/df-ai/src/model_fetch.rs | 390 +---------------------- crates/df-ai/src/model_fetch_helpers.rs | 400 ++++++++++++++++++++++++ 3 files changed, 408 insertions(+), 385 deletions(-) create mode 100644 crates/df-ai/src/model_fetch_helpers.rs diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index 1e425c3..81821c2 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -7,6 +7,9 @@ pub mod context; pub mod context_helpers; pub mod coordinator; pub mod model_fetch; +// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化), +// crate 内复用,private mod — 外部经 model_fetch 间接访问,无直接路径需求。 +mod model_fetch_helpers; pub mod model_probe; pub mod openai_compat; pub mod openai_helpers; diff --git a/crates/df-ai/src/model_fetch.rs b/crates/df-ai/src/model_fetch.rs index b6fa2df..0e15e9c 100644 --- a/crates/df-ai/src/model_fetch.rs +++ b/crates/df-ai/src/model_fetch.rs @@ -8,6 +8,9 @@ //! - `anthropic_compat` → `GET {base_url}/v1/models`(x-api-key + anthropic-version 鉴权) //! - 其他 → `UnsupportedProvider` 错误 //! +//! 本模块仅含网络 IO 入口;URL 拼接 / 噪音过滤 / 响应反序列化等纯逻辑见 +//! [`model_fetch_helpers`]。 +//! //! 设计来源:docs/02-架构设计/F-01-模型能力系统与智能路由设计-2026-06-16.md §5 //! (设计 §5.1 端点表 + §5.3 URL 智能拼接 + §5.4 噪音过滤)。 @@ -15,8 +18,8 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use df_ai_core::model::ModelConfig; -use serde::Deserialize; +use crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList}; use crate::model_probe::probe; // ──────────────────────────────────────────────────────────── @@ -151,396 +154,13 @@ fn map_status_error(provider_type: &str, status: reqwest::StatusCode, url: &str) } // ──────────────────────────────────────────────────────────── -// 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] diff --git a/crates/df-ai/src/model_fetch_helpers.rs b/crates/df-ai/src/model_fetch_helpers.rs new file mode 100644 index 0000000..0bd7eb2 --- /dev/null +++ b/crates/df-ai/src/model_fetch_helpers.rs @@ -0,0 +1,400 @@ +//! 厂商模型列表拉取 — 纯逻辑(无 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"]); + } +}