From 107b8486aeb25981c303a0167065b79809f611d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Tue, 16 Jun 2026 23:47:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20F-01=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E9=98=B6=E6=AE=B53=E5=8E=82=E5=95=86?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E6=8B=89=E5=8F=96(model=5F?= =?UTF-8?q?fetch+=E5=8D=8F=E8=AE=AE=E5=88=86=E6=B4=BE+=E5=99=AA=E9=9F=B3?= =?UTF-8?q?=E8=BF=87=E6=BB=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/df-ai/src/lib.rs | 1 + crates/df-ai/src/model_fetch.rs | 545 ++++++++++++++++++++++++++++++++ 2 files changed, 546 insertions(+) create mode 100644 crates/df-ai/src/model_fetch.rs diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index 631fc6a..b593351 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -4,6 +4,7 @@ pub mod ai_tools; pub mod anthropic_compat; pub mod context; pub mod coordinator; +pub mod model_fetch; pub mod model_probe; pub mod openai_compat; pub mod provider; diff --git a/crates/df-ai/src/model_fetch.rs b/crates/df-ai/src/model_fetch.rs new file mode 100644 index 0000000..3627bed --- /dev/null +++ b/crates/df-ai/src/model_fetch.rs @@ -0,0 +1,545 @@ +//! 厂商模型列表拉取 — 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 搜索变种 + || id.contains("davinci-search") + || id.contains("-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::*; + + // ── 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}"); + } +}