新增: F-01模型能力阶段3厂商模型列表拉取(model_fetch+协议分派+噪音过滤)

This commit is contained in:
2026-06-16 23:47:56 +08:00
parent 5c2122ac3b
commit 107b8486ae
2 changed files with 546 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ pub mod ai_tools;
pub mod anthropic_compat; pub mod anthropic_compat;
pub mod context; pub mod context;
pub mod coordinator; pub mod coordinator;
pub mod model_fetch;
pub mod model_probe; pub mod model_probe;
pub mod openai_compat; pub mod openai_compat;
pub mod provider; pub mod provider;

View File

@@ -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<Vec<String>> {
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<Vec<ModelConfig>> {
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<Vec<String>> {
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<Vec<String>> {
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> {
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<String>) -> Vec<String> {
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<ModelEntry>,
#[serde(default, alias = "models")]
data_alt: Vec<ModelEntry>,
}
#[derive(Debug, Deserialize)]
struct ModelEntry {
#[serde(default, alias = "name")]
id: Option<String>,
}
impl ModelsList {
/// 合并 `data` 与 `models`(alias)字段,提取所有模型 id/name,丢弃 None。
fn into_ids(self) -> Vec<String> {
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}");
}
}