重构: 拆model_fetch模型获取(strategy核心库)
- 新建 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/*)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<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::*;
|
||||
|
||||
// ── 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]
|
||||
|
||||
400
crates/df-ai/src/model_fetch_helpers.rs
Normal file
400
crates/df-ai/src/model_fetch_helpers.rs
Normal file
@@ -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<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)]
|
||||
pub(crate) 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。
|
||||
pub(crate) 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::*;
|
||||
|
||||
// ── 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"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user