Files
DevFlow/crates/df-ai/src/model_fetch.rs
绝尘 998a2f243d 文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并:
- 意图识别层论证(8维度+10业界佐证)
- 多主题上下文管理愿景+并存论证+补充论证(多轮agentic)
- 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化)
- 前端架构技术债清单归档
2026-06-19 15:04:04 +08:00

177 lines
8.0 KiB
Rust

//! 厂商模型列表拉取 — 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` 错误
//!
//! 本模块仅含网络 IO 入口;URL 拼接 / 噪音过滤 / 响应反序列化等纯逻辑见
//! [`model_fetch_helpers`]。
//!
//! 设计来源: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 crate::model_fetch_helpers::{build_models_url, filter_chat_models, ModelsList};
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})")
}
}
// ────────────────────────────────────────────────────────────
// 测试(协议分派:不支持类型直接报错,不触网)
// ────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── 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}");
}
}