- temperature 按模型适配,仅收 1 的模型不再 400 - 流式 error 帧不再被 choices 必填整帧丢弃 - 只读缓存写后失效 - git diff 中文截断撞 UTF-8 边界 panic - 非视觉模型纯图片直接拒绝 - 全局快捷键补输入焦点守卫
419 lines
16 KiB
Rust
419 lines
16 KiB
Rust
//! 模型探测器。
|
|
//!
|
|
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
|
|
//!
|
|
//! 多源探测,高优先源命中即返(短路):
|
|
//! 1. 内置预设表精确匹配(name 完全相等) → `ProbeSource::PresetTable`
|
|
//! 2. 内置预设表前缀匹配(前缀 + 分隔符边界) → `ProbeSource::PresetTable`
|
|
//! 3. 模型名启发式推断(命名模式) → `ProbeSource::Heuristic`
|
|
//! 4. 默认值兜底(`ModelConfig::with_defaults`) → `ProbeSource::Default`
|
|
//!
|
|
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §4
|
|
//! (设计文档 §4.1 写的是"启发式先行 + 预设表合并";本实施按阶段 2 任务规格
|
|
//! 收敛为"预设优先 > 启发式"链 — 预设表精确数据可信度高于命名猜测,优先短路)。
|
|
|
|
use df_ai_core::model::{ModelConfig, ProbeSource};
|
|
|
|
// 纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定)抽离至此 —
|
|
// private mod,crate 内复用,外部经 model_probe::probe 间接访问(对齐 model_fetch_helpers)。
|
|
use crate::model_probe_helpers::{heuristic_infer, presets};
|
|
|
|
// ────────────────────────────────────────────────────────────
|
|
// 公共入口
|
|
// ────────────────────────────────────────────────────────────
|
|
|
|
/// 探测单个模型,返回完整 `ModelConfig`(已填充 `probe_source`)。
|
|
///
|
|
/// 多源探测顺序(高优先源命中即返):
|
|
/// 1. 预设表精确匹配(`model_id` 完全相等,大小写敏感)
|
|
/// 2. 预设表前缀匹配(`model_id` 是入参前缀且后缀以分隔符开始,大小写不敏感)
|
|
/// 3. 启发式推断(模型名命名模式)
|
|
/// 4. 默认值兜底
|
|
///
|
|
/// 返回的 `ModelConfig.probe_source` 标注实际命中来源。
|
|
pub fn probe(model_id: &str) -> ModelConfig {
|
|
// 1. 预设表精确匹配
|
|
if let Some(mut hit) = presets().iter().find(|m| m.model_id == model_id).cloned() {
|
|
hit.probe_source = Some(ProbeSource::PresetTable);
|
|
return hit;
|
|
}
|
|
|
|
// 2. 预设表前缀匹配(入参名以预设名开头 + 后缀以分隔符开始,大小写不敏感)
|
|
// 收紧自原「双向子串包含」:子串匹配会把 glm-4.6v 误配给 glm-4v(继承其 vision),
|
|
// gpt-4o-mini 误配给 gpt-4o 等「张冠李戴」。前缀匹配保留合理继承
|
|
// (glm-4v-flash → glm-4v),同时要求后缀以分隔符(-/./_)或结尾开始,
|
|
// 防止 glm-4v2/glm-4vx 命中 glm-4v。
|
|
let needle = model_id.to_lowercase();
|
|
let fuzzy = presets()
|
|
.iter()
|
|
.filter(|m| {
|
|
let cand = m.model_id.to_lowercase();
|
|
if cand.is_empty() || cand.len() > needle.len() {
|
|
return false;
|
|
}
|
|
if !needle.starts_with(&cand) {
|
|
return false;
|
|
}
|
|
// 前缀后须为分隔符或字符串结束,避免部分单词命中(glm-4v2 不算 glm-4v)
|
|
match needle[cand.len()..].chars().next() {
|
|
None => true, // 完全相等(精确匹配已覆盖,这里兜底)
|
|
Some(c) => matches!(c, '-' | '.' | '_'),
|
|
}
|
|
})
|
|
.max_by_key(|m| m.model_id.len());
|
|
|
|
if let Some(mut hit) = fuzzy.cloned() {
|
|
// 模糊命中:保留预设维度,但用入参的实际模型名覆盖 model_id(避免回写错名)
|
|
hit.model_id = model_id.to_string();
|
|
hit.probe_source = Some(ProbeSource::PresetTable);
|
|
return hit;
|
|
}
|
|
|
|
// 3. 启发式推断
|
|
let mut heuristic = heuristic_infer(model_id);
|
|
heuristic.probe_source = Some(ProbeSource::Heuristic);
|
|
heuristic
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────
|
|
// 测试
|
|
// ────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ProbeSource};
|
|
|
|
// ── 预设表加载 ──
|
|
|
|
#[test]
|
|
fn presets_loaded_and_nonempty() {
|
|
let p = presets();
|
|
assert!(!p.is_empty(), "预设表应非空");
|
|
// 确保每个预设都有 model_id
|
|
assert!(p.iter().all(|m| !m.model_id.is_empty()));
|
|
}
|
|
|
|
#[test]
|
|
fn presets_contain_expected_models() {
|
|
let p = presets();
|
|
let ids: Vec<&str> = p.iter().map(|m| m.model_id.as_str()).collect();
|
|
assert!(ids.contains(&"glm-4"), "glm-4 应在预设表: {ids:?}");
|
|
assert!(ids.contains(&"glm-4-flash"));
|
|
assert!(ids.contains(&"glm-4v"));
|
|
assert!(ids.contains(&"gpt-4o"));
|
|
assert!(ids.contains(&"claude-3-5-sonnet-20241022"));
|
|
}
|
|
|
|
// ── 预设精确匹配 ──
|
|
|
|
#[test]
|
|
fn probe_preset_exact_match_glm4() {
|
|
let m = probe("glm-4");
|
|
assert_eq!(m.model_id, "glm-4");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert_eq!(m.modalities, vec![Modality::Text]);
|
|
// 预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值
|
|
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
|
assert_eq!(m.cost_tier, CostTier::Medium);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_preset_exact_match_glm4v_has_vision() {
|
|
let m = probe("glm-4v");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert!(m.modalities.contains(&Modality::Vision));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_preset_exact_match_deepseek_coder_has_codegen() {
|
|
let m = probe("deepseek-coder");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert!(m.capabilities.contains(&Capability::CodeGen));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_preset_exact_match_embedding_no_text_modality() {
|
|
let m = probe("embedding-3");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert!(m.capabilities.contains(&Capability::Embedding));
|
|
assert!(
|
|
!m.modalities.contains(&Modality::Text),
|
|
"embedding 模型不应有 Text 模态"
|
|
);
|
|
}
|
|
|
|
// ── 预设表:已知多模态模型(命名无 vision 词素,靠预设显式标 vision) ──
|
|
// SenseNova flash-lite 原生多模态(商汤文档:支持图像输入理解),
|
|
// 但模型名不含 vision/vl/-v 词素,启发式 has_vision_token 无法识别 →
|
|
// 必须靠预设表显式标 vision,否则 modalities 漏 vision 致路由误判。
|
|
#[test]
|
|
fn probe_preset_sensenova_flash_lite_has_vision() {
|
|
let m = probe("sensenova-6.7-flash-lite");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert!(
|
|
m.modalities.contains(&Modality::Vision),
|
|
"sensenova-6.7-flash-lite 应有 Vision(原生多模态): {:?}",
|
|
m.modalities
|
|
);
|
|
assert!(m.modalities.contains(&Modality::Text));
|
|
// context_window 应为预设的 256K
|
|
assert_eq!(m.context_window, 262144);
|
|
// P1 温度:商汤 sensenova 仅接受 temperature=1.0,透传其它值(标题 0.3/压缩 0.2)
|
|
// → 上游 HTTP 400(实测 9 次)。预设表须标 temperature_supported=false,
|
|
// 协议转换层据此省略该字段。
|
|
assert!(
|
|
!m.temperature_supported,
|
|
"sensenova 应标 temperature_supported=false(仅接受 1.0)"
|
|
);
|
|
}
|
|
|
|
/// 温度支持判定:仅 sensenova 系列为 false,其余模型默认 true(零回归)。
|
|
#[test]
|
|
fn probe_temperature_supported_defaults_true_except_sensenova() {
|
|
assert!(!probe("sensenova-6.7-flash-lite").temperature_supported);
|
|
// 启发式路径(非预设表):sensenova 前缀仍判 false
|
|
assert!(!probe("sensenova-unknown-model").temperature_supported);
|
|
// 常规模型保持 true(原样透传)
|
|
assert!(probe("deepseek-v4-flash").temperature_supported);
|
|
assert!(probe("glm-4v").temperature_supported);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_preset_deepseek_v4_flash_text_only() {
|
|
// SenseNova 上的 deepseek-v4-flash:纯文本对话 + 1M 上下文,无 vision
|
|
let m = probe("deepseek-v4-flash");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert_eq!(m.modalities, vec![Modality::Text]);
|
|
assert_eq!(m.context_window, 1_048_576);
|
|
}
|
|
|
|
// ── 预设前缀匹配 ──
|
|
|
|
#[test]
|
|
fn probe_preset_fuzzy_match_glm4v_variant() {
|
|
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其前缀(后缀 "-x" 以分隔符开始) → 前缀命中
|
|
let m = probe("glm-4v-x");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert_eq!(m.model_id, "glm-4v-x", "前缀命中后 model_id 应用入参名");
|
|
assert!(
|
|
m.modalities.contains(&Modality::Vision),
|
|
"应继承 glm-4v 的 vision 模态"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_preset_prefix_match_glm4v_flash() {
|
|
// 合理继承:glm-4v-flash 以 glm-4v 为前缀 + 分隔符 -,命中并继承 vision
|
|
let m = probe("glm-4v-flash");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert!(m.modalities.contains(&Modality::Vision));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_preset_prefix_match_gpt4o_mini() {
|
|
// 合理继承:gpt-4o-mini 以 gpt-4o 为前缀 + 分隔符 -
|
|
let m = probe("gpt-4o-mini");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert_eq!(m.model_id, "gpt-4o-mini");
|
|
}
|
|
|
|
#[test]
|
|
fn probe_preset_prefix_match_case_insensitive() {
|
|
// 前缀匹配大小写不敏感(精确匹配大小写敏感,前缀兜底)
|
|
let m = probe("GLM-4V-FLASH");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert!(m.modalities.contains(&Modality::Vision));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_preset_prefix_reject_non_prefix_variant() {
|
|
// 张冠李戴防线:glm-4.6v 不以 glm-4v 为前缀(字符顺序不同),不继承 glm-4v 的 vision。
|
|
// 但它以 glm-4 为前缀(+ 分隔符 .)→ 命中 glm-4 预设(纯文本),合理。
|
|
let m = probe("glm-4.6v");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
assert_eq!(
|
|
m.model_id, "glm-4.6v",
|
|
"前缀命中后 model_id 应用入参名"
|
|
);
|
|
assert!(
|
|
!m.modalities.contains(&Modality::Vision),
|
|
"glm-4.6v 不应继承 glm-4v 的 vision(只继承 glm-4 纯文本): {:?}",
|
|
m.modalities
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_preset_prefix_reject_no_separator() {
|
|
// 分隔符边界:glm-4v2 前缀后是数字(非 -/./_),不算 glm-4v 的合法变体
|
|
let m = probe("glm-4v2");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
|
}
|
|
|
|
// ── 启发式:Vision ──
|
|
|
|
#[test]
|
|
fn heuristic_vision_from_v_suffix() {
|
|
// glm-4v 不走启发式(精确命中预设);用未知名验证启发式
|
|
let m = probe("custom-model-v");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
|
assert!(
|
|
m.modalities.contains(&Modality::Vision),
|
|
"v 后缀应推断 Vision"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn heuristic_vision_from_vl_token() {
|
|
let m = probe("qwen-vl-unknown");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
|
assert!(m.modalities.contains(&Modality::Vision));
|
|
}
|
|
|
|
#[test]
|
|
fn heuristic_vision_from_vision_word() {
|
|
let m = probe("some-vision-7b");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
|
assert!(m.modalities.contains(&Modality::Vision));
|
|
}
|
|
|
|
// ── 启发式:档位中性(flash/mini 不再猜 Lite) ──
|
|
|
|
#[test]
|
|
fn heuristic_flash_keeps_neutral_tier() {
|
|
let m = probe("unknown-flash");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
|
// cost/intel 一律中性,不靠名字猜
|
|
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
|
assert_eq!(m.cost_tier, CostTier::Medium);
|
|
}
|
|
|
|
#[test]
|
|
fn heuristic_mini_keeps_neutral_tier() {
|
|
let m = probe("test-mini-model");
|
|
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
|
assert_eq!(m.cost_tier, CostTier::Medium);
|
|
}
|
|
|
|
// ── 启发式:CodeGen ──
|
|
|
|
#[test]
|
|
fn heuristic_codegen_from_code() {
|
|
let m = probe("acme-code-7b");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
|
assert!(m.capabilities.contains(&Capability::CodeGen));
|
|
// 对话模型仍应保留 ToolUse
|
|
assert!(m.capabilities.contains(&Capability::ToolUse));
|
|
}
|
|
|
|
#[test]
|
|
fn heuristic_codegen_from_coder() {
|
|
let m = probe("acme-coder");
|
|
assert!(m.capabilities.contains(&Capability::CodeGen));
|
|
}
|
|
|
|
// ── 启发式:Embedding(特殊路径:无 Text 模态) ──
|
|
|
|
#[test]
|
|
fn heuristic_embedding_no_text_modality() {
|
|
let m = probe("acme-embedding-v3");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
|
assert!(m.capabilities.contains(&Capability::Embedding));
|
|
assert!(!m.modalities.contains(&Modality::Text));
|
|
assert!(!m.capabilities.contains(&Capability::ToolUse));
|
|
}
|
|
|
|
#[test]
|
|
fn heuristic_embedding_from_text_embedding_prefix() {
|
|
let m = probe("text-embedding-3-large");
|
|
assert!(m.capabilities.contains(&Capability::Embedding));
|
|
assert!(m.modalities.is_empty());
|
|
}
|
|
|
|
// ── 启发式:档位中性(pro/opus/4o 不再猜 Plus/Ultra/High) ──
|
|
|
|
#[test]
|
|
fn heuristic_pro_keeps_neutral_tier() {
|
|
let m = probe("acme-pro");
|
|
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
|
assert_eq!(m.cost_tier, CostTier::Medium);
|
|
}
|
|
|
|
#[test]
|
|
fn heuristic_opus_keeps_neutral_tier() {
|
|
let m = probe("acme-opus");
|
|
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
|
}
|
|
|
|
#[test]
|
|
fn heuristic_4o_keeps_neutral_cost() {
|
|
let m = probe("acme-4o");
|
|
assert_eq!(m.cost_tier, CostTier::Medium);
|
|
}
|
|
|
|
// ── 启发式:版本号后缀不误判 Vision ──
|
|
|
|
#[test]
|
|
fn heuristic_version_suffix_no_vision() {
|
|
// "-v" 后接数字属版本号(deepseek-v3 为纯文本模型),不得误判 Vision
|
|
for name in ["deepseek-v3", "acme-chat-v2", "acme-v12"] {
|
|
let m = probe(name);
|
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
|
assert!(
|
|
!m.modalities.contains(&Modality::Vision),
|
|
"{name} 版本号后缀不应误判 Vision: {:?}",
|
|
m.modalities
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn heuristic_v_morpheme_tail_still_vision() {
|
|
// v 词素尾仍判视觉:独立 v 段(qwen-v-plus 形态)与段尾 v(glm-4v 形态,假名避开预设表)
|
|
for name in ["acme-chat-v", "acme-4v-plus"] {
|
|
let m = probe(name);
|
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
|
assert!(
|
|
m.modalities.contains(&Modality::Vision),
|
|
"{name} 段尾 v 词素应判 Vision: {:?}",
|
|
m.modalities
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
|
|
|
|
#[test]
|
|
fn heuristic_default_for_unknown_name() {
|
|
let m = probe("acme-unknown-model");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
|
assert_eq!(m.modalities, vec![Modality::Text]);
|
|
assert_eq!(m.capabilities, vec![Capability::ToolUse]);
|
|
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
|
assert_eq!(m.cost_tier, CostTier::Medium);
|
|
}
|
|
|
|
// ── 优先级链:预设精确 > 启发式 ──
|
|
|
|
#[test]
|
|
fn probe_preset_beats_heuristic() {
|
|
// "glm-4-flash" 精确命中预设表;预设/启发式档位都中性,
|
|
// 此处仅校验 source 标注为 PresetTable
|
|
let m = probe("glm-4-flash");
|
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_priority_chain_returns_config() {
|
|
// 任何名字都应返回完整 ModelConfig,不 panic
|
|
for name in ["glm-4", "unknown-xyz", "qwen-vl", "acme-embedding", "gpt-5"] {
|
|
let m = probe(name);
|
|
assert_eq!(m.model_id, name);
|
|
assert!(
|
|
m.probe_source.is_some(),
|
|
"probe_source 应被填充: {name}"
|
|
);
|
|
}
|
|
}
|
|
}
|