新增: F-01模型能力阶段1+2(ModelConfig数据模型+model_probe探测器+df-storage兼容V18)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -864,6 +864,7 @@ name = "df-storage"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"df-ai-core",
|
||||||
"df-core",
|
"df-core",
|
||||||
"keyring",
|
"keyring",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
//! 保持 `df_ai::provider::*` 路径不变。df-ideas 等轻消费方直接依赖本 crate
|
//! 保持 `df_ai::provider::*` 路径不变。df-ideas 等轻消费方直接依赖本 crate
|
||||||
//! 的 trait 即可接 LLM,不引入 reqwest / eventsource-stream 等重依赖。
|
//! 的 trait 即可接 LLM,不引入 reqwest / eventsource-stream 等重依赖。
|
||||||
|
|
||||||
|
pub mod model;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
|
||||||
|
pub use model::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
|
|||||||
462
crates/df-ai-core/src/model.rs
Normal file
462
crates/df-ai-core/src/model.rs
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
//! 模型能力数据模型 — F-01 阶段1
|
||||||
|
//!
|
||||||
|
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
|
||||||
|
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
|
||||||
|
//!
|
||||||
|
//! 设计来源:docs/02-架构设计/F-01-模型能力系统与智能路由设计-2026-06-16.md §2
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 4 维度枚举
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 维度 1 — 模态(模型能接收什么输入)
|
||||||
|
///
|
||||||
|
/// 设计文档 §2.2 仅列出 Text/Vision,Audio/Video 为未来扩展占位。
|
||||||
|
/// 序列化为 snake_case(`text` / `vision` / `audio` / `video`)。
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Modality {
|
||||||
|
Text,
|
||||||
|
Vision,
|
||||||
|
/// 未来扩展:音频输入(预留,Phase 2 多模态)
|
||||||
|
Audio,
|
||||||
|
/// 未来扩展:视频输入(预留)
|
||||||
|
Video,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 维度 2 — 能力(模型能做什么)
|
||||||
|
///
|
||||||
|
/// 设计文档 §2.2 列出 ToolUse/Embedding/CodeGen。序列化为 snake_case。
|
||||||
|
/// §3.4 提到未来可加 Streaming/Custom(String),当前 Phase 1 仅 3 个确定变体。
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Capability {
|
||||||
|
/// function calling / tool use
|
||||||
|
ToolUse,
|
||||||
|
/// 向量嵌入(知识库)
|
||||||
|
Embedding,
|
||||||
|
/// 代码生成强项
|
||||||
|
CodeGen,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 维度 3 — 价格分级(成本)
|
||||||
|
///
|
||||||
|
/// 设计文档 §2.2:Free/Low/Medium/High。序列化为 snake_case。
|
||||||
|
/// 路由器按此做成本上限过滤(§6.1 `cost_tier <= max_cost`)。
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CostTier {
|
||||||
|
Free,
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 维度 4 — 聪明程度(智力分级)
|
||||||
|
///
|
||||||
|
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
|
||||||
|
/// 派生 Ord:Lite < Standard < Plus < Ultra,供路由器 `intelligence >= min_intelligence` 比较(§6.1)。
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum IntelligenceTier {
|
||||||
|
/// flash/mini/lite/nano — 快但简单任务
|
||||||
|
Lite,
|
||||||
|
/// air/standard — 日常够用
|
||||||
|
Standard,
|
||||||
|
/// plus/pro/max — 复杂推理
|
||||||
|
Plus,
|
||||||
|
/// 最强模型 — 兜底用
|
||||||
|
Ultra,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测元数据来源(只读,由 ModelProbe 填充,Phase 2)
|
||||||
|
///
|
||||||
|
/// 设计文档 §2.1 的 ProbeSource 字段。Phase 1 仅定义占位,Phase 2 探测器填充。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ProbeSource {
|
||||||
|
/// 用户手动设定(最高优先)
|
||||||
|
UserSet,
|
||||||
|
/// 内置预设表精确/模糊匹配
|
||||||
|
PresetTable,
|
||||||
|
/// 模型名启发式推断
|
||||||
|
Heuristic,
|
||||||
|
/// 默认值兜底
|
||||||
|
Default,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 核心结构 ModelConfig
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 单个模型的完整配置(4 维度 + 路由控制)。
|
||||||
|
///
|
||||||
|
/// 设计文档 §2.1。serde 默认值保证老库/部分字段缺失时向后兼容。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ModelConfig {
|
||||||
|
// ── 基础 ──
|
||||||
|
/// 模型名(如 "glm-4v-flash")
|
||||||
|
pub model_id: String,
|
||||||
|
/// 启用/禁用(false = 配了但不参与路由,相当于"备档")
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 用户自定义别名(可选)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub label: Option<String>,
|
||||||
|
|
||||||
|
// ── 维度 1:模态(能接收什么输入)──
|
||||||
|
#[serde(default)]
|
||||||
|
pub modalities: Vec<Modality>,
|
||||||
|
|
||||||
|
// ── 维度 2:能力(能做什么)──
|
||||||
|
#[serde(default)]
|
||||||
|
pub capabilities: Vec<Capability>,
|
||||||
|
|
||||||
|
// ── 维度 3:价格(成本分级)──
|
||||||
|
#[serde(default)]
|
||||||
|
pub cost_tier: CostTier,
|
||||||
|
|
||||||
|
// ── 维度 4:聪明程度(智力分级)──
|
||||||
|
#[serde(default)]
|
||||||
|
pub intelligence: IntelligenceTier,
|
||||||
|
|
||||||
|
// ── 路由控制 ──
|
||||||
|
/// 权重(0-100,同能力候选中优先选权重高的)
|
||||||
|
#[serde(default = "default_weight")]
|
||||||
|
pub weight: u32,
|
||||||
|
/// 上下文窗口大小(tokens)
|
||||||
|
#[serde(default = "default_context_window")]
|
||||||
|
pub context_window: usize,
|
||||||
|
|
||||||
|
// ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)──
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub probe_source: Option<ProbeSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_weight() -> u32 {
|
||||||
|
50
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_context_window() -> usize {
|
||||||
|
8192
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelConfig {
|
||||||
|
/// 用模型名构造默认配置(老格式字符串数组 `["glm-4-flash"]` 升级时逐项调用)。
|
||||||
|
///
|
||||||
|
/// 设计文档 §2.3 向后兼容:老 `models: ["model-a"]` → 每个名字转默认 ModelConfig。
|
||||||
|
/// 默认值:enabled=true / modalities=[Text] / capabilities=[ToolUse] /
|
||||||
|
/// cost_tier=Medium / intelligence=Standard / weight=50 / context_window=8192。
|
||||||
|
/// (ToolUse 默认开:主流对话模型支持 function calling;精确能力由 Phase 2 探测器修正)
|
||||||
|
pub fn with_defaults(model_id: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
model_id: model_id.into(),
|
||||||
|
enabled: true,
|
||||||
|
label: None,
|
||||||
|
modalities: vec![Modality::Text],
|
||||||
|
capabilities: vec![Capability::ToolUse],
|
||||||
|
cost_tier: CostTier::Medium,
|
||||||
|
intelligence: IntelligenceTier::Standard,
|
||||||
|
weight: default_weight(),
|
||||||
|
context_window: default_context_window(),
|
||||||
|
probe_source: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认值(#[serde(default)] 用于 Vec/Option 字段时 serde 要求 Default impl)
|
||||||
|
impl Default for Modality {
|
||||||
|
fn default() -> Self {
|
||||||
|
Modality::Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CostTier {
|
||||||
|
fn default() -> Self {
|
||||||
|
CostTier::Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IntelligenceTier {
|
||||||
|
fn default() -> Self {
|
||||||
|
IntelligenceTier::Standard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 向后兼容反序列化:老格式字符串数组 → ModelConfig 数组
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 反序列化 `models` JSON 字段,兼容两种格式:
|
||||||
|
///
|
||||||
|
/// - 老格式:`["glm-4-flash", "glm-4v"]`(纯字符串数组)→ 每个名字转 `ModelConfig::with_defaults`
|
||||||
|
/// - 新格式:`[{ "model_id": ..., "modalities": [...] }, ...]`(ModelConfig 对象数组)
|
||||||
|
/// - 空/非数组:null / 缺失 / 空字符串 → 空 Vec
|
||||||
|
///
|
||||||
|
/// 设计文档 §2.3。挂在 AiProviderRecord.models 字段上(#[serde(default, deserialize_with = ...)])。
|
||||||
|
pub fn deserialize_model_configs<'de, D>(deserializer: D) -> Result<Vec<ModelConfig>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
use serde::de::Error;
|
||||||
|
|
||||||
|
let opt: Option<serde_json::Value> = Option::deserialize(deserializer)?;
|
||||||
|
let Some(value) = opt else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
match value {
|
||||||
|
// null / 空字符串 / 空数组 → 空
|
||||||
|
serde_json::Value::Null => Ok(Vec::new()),
|
||||||
|
serde_json::Value::String(s) if s.trim().is_empty() => Ok(Vec::new()),
|
||||||
|
serde_json::Value::String(s) => {
|
||||||
|
// 字符串内容可能是 JSON 文本(DB TEXT 列存 JSON 字符串)
|
||||||
|
match serde_json::from_str::<serde_json::Value>(s.trim()) {
|
||||||
|
Ok(inner) => parse_models_value(inner).map_err(D::Error::custom),
|
||||||
|
Err(_) => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(arr) => parse_models_value(serde_json::Value::Array(arr)).map_err(D::Error::custom),
|
||||||
|
other => parse_models_value(other).map_err(D::Error::custom),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_models_value(value: serde_json::Value) -> Result<Vec<ModelConfig>, serde_json::Error> {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Array(arr) => {
|
||||||
|
// 老格式:全为字符串 → 每个转默认配置
|
||||||
|
if arr.iter().all(|v| v.is_string()) {
|
||||||
|
return Ok(arr
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|v| v.as_str().map(ModelConfig::with_defaults))
|
||||||
|
.collect());
|
||||||
|
}
|
||||||
|
// 新格式:ModelConfig 对象数组
|
||||||
|
serde_json::from_value::<Vec<ModelConfig>>(serde_json::Value::Array(arr))
|
||||||
|
}
|
||||||
|
serde_json::Value::Null => Ok(Vec::new()),
|
||||||
|
_ => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── 枚举 serde 映射 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modality_serde_snake_case() {
|
||||||
|
let cases = [
|
||||||
|
(Modality::Text, "\"text\""),
|
||||||
|
(Modality::Vision, "\"vision\""),
|
||||||
|
(Modality::Audio, "\"audio\""),
|
||||||
|
(Modality::Video, "\"video\""),
|
||||||
|
];
|
||||||
|
for (variant, expected) in cases {
|
||||||
|
let s = serde_json::to_string(&variant).unwrap();
|
||||||
|
assert_eq!(s, expected, "serialize {variant:?}");
|
||||||
|
let back: Modality = serde_json::from_str(expected).unwrap();
|
||||||
|
assert_eq!(back, variant, "deserialize {variant:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_serde_snake_case() {
|
||||||
|
let cases = [
|
||||||
|
(Capability::ToolUse, "\"tool_use\""),
|
||||||
|
(Capability::Embedding, "\"embedding\""),
|
||||||
|
(Capability::CodeGen, "\"code_gen\""),
|
||||||
|
];
|
||||||
|
for (variant, expected) in cases {
|
||||||
|
let s = serde_json::to_string(&variant).unwrap();
|
||||||
|
assert_eq!(s, expected, "serialize {variant:?}");
|
||||||
|
let back: Capability = serde_json::from_str(expected).unwrap();
|
||||||
|
assert_eq!(back, variant, "deserialize {variant:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cost_tier_serde_and_ordering() {
|
||||||
|
let cases = [
|
||||||
|
(CostTier::Free, "\"free\""),
|
||||||
|
(CostTier::Low, "\"low\""),
|
||||||
|
(CostTier::Medium, "\"medium\""),
|
||||||
|
(CostTier::High, "\"high\""),
|
||||||
|
];
|
||||||
|
for (variant, expected) in cases {
|
||||||
|
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
|
||||||
|
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
|
||||||
|
}
|
||||||
|
// Ord:Free < Low < Medium < High(路由器 max_cost 比较)
|
||||||
|
assert!(CostTier::Free < CostTier::Low);
|
||||||
|
assert!(CostTier::Low < CostTier::Medium);
|
||||||
|
assert!(CostTier::Medium < CostTier::High);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intelligence_tier_serde_and_ordering() {
|
||||||
|
let cases = [
|
||||||
|
(IntelligenceTier::Lite, "\"lite\""),
|
||||||
|
(IntelligenceTier::Standard, "\"standard\""),
|
||||||
|
(IntelligenceTier::Plus, "\"plus\""),
|
||||||
|
(IntelligenceTier::Ultra, "\"ultra\""),
|
||||||
|
];
|
||||||
|
for (variant, expected) in cases {
|
||||||
|
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
|
||||||
|
assert_eq!(serde_json::from_str::<IntelligenceTier>(expected).unwrap(), variant);
|
||||||
|
}
|
||||||
|
// Ord:Lite < Standard < Plus < Ultra(路由器 min_intelligence 比较)
|
||||||
|
assert!(IntelligenceTier::Lite < IntelligenceTier::Standard);
|
||||||
|
assert!(IntelligenceTier::Standard < IntelligenceTier::Plus);
|
||||||
|
assert!(IntelligenceTier::Plus < IntelligenceTier::Ultra);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ModelConfig serde roundtrip ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_config_full_roundtrip() {
|
||||||
|
let mc = ModelConfig {
|
||||||
|
model_id: "glm-4v".into(),
|
||||||
|
enabled: true,
|
||||||
|
label: Some("我的视觉".into()),
|
||||||
|
modalities: vec![Modality::Text, Modality::Vision],
|
||||||
|
capabilities: vec![Capability::ToolUse],
|
||||||
|
cost_tier: CostTier::Medium,
|
||||||
|
intelligence: IntelligenceTier::Plus,
|
||||||
|
weight: 70,
|
||||||
|
context_window: 128_000,
|
||||||
|
probe_source: Some(ProbeSource::PresetTable),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&mc).unwrap();
|
||||||
|
let back: ModelConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, mc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_config_skip_none_label_and_probe() {
|
||||||
|
let mc = ModelConfig::with_defaults("glm-4-flash");
|
||||||
|
let json = serde_json::to_string(&mc).unwrap();
|
||||||
|
// label / probe_source 为 None 应被跳过
|
||||||
|
assert!(!json.contains("label"), "json={json}");
|
||||||
|
assert!(!json.contains("probe_source"), "json={json}");
|
||||||
|
// 反序列化回来相等(skip_serializing 不影响 deserialize)
|
||||||
|
let back: ModelConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, mc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_config_with_defaults_values() {
|
||||||
|
let mc = ModelConfig::with_defaults("test-model");
|
||||||
|
assert_eq!(mc.model_id, "test-model");
|
||||||
|
assert!(mc.enabled);
|
||||||
|
assert_eq!(mc.modalities, vec![Modality::Text]);
|
||||||
|
assert_eq!(mc.capabilities, vec![Capability::ToolUse]);
|
||||||
|
assert_eq!(mc.cost_tier, CostTier::Medium);
|
||||||
|
assert_eq!(mc.intelligence, IntelligenceTier::Standard);
|
||||||
|
assert_eq!(mc.weight, 50);
|
||||||
|
assert_eq!(mc.context_window, 8192);
|
||||||
|
assert_eq!(mc.label, None);
|
||||||
|
assert_eq!(mc.probe_source, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_config_defaults_applied_on_missing_fields() {
|
||||||
|
// 最小 JSON,仅 model_id — 其余字段靠 #[serde(default)] / default fn 填充
|
||||||
|
let json = r#"{"model_id":"minimal"}"#;
|
||||||
|
let mc: ModelConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(mc.model_id, "minimal");
|
||||||
|
assert!(mc.enabled, "enabled 默认 true");
|
||||||
|
assert_eq!(mc.weight, 50, "weight 默认 50");
|
||||||
|
assert_eq!(mc.context_window, 8192, "context_window 默认 8192");
|
||||||
|
assert!(mc.modalities.is_empty(), "Vec 字段 default 空");
|
||||||
|
assert!(mc.capabilities.is_empty());
|
||||||
|
assert_eq!(mc.cost_tier, CostTier::Medium);
|
||||||
|
assert_eq!(mc.intelligence, IntelligenceTier::Standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── deserialize_model_configs 向后兼容 ──
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Wrap {
|
||||||
|
#[serde(default, deserialize_with = "deserialize_model_configs")]
|
||||||
|
models: Vec<ModelConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_old_format_string_array() {
|
||||||
|
// 老格式:["glm-4-flash", "glm-4v"]
|
||||||
|
let json = r#"{"models":["glm-4-flash","glm-4v"]}"#;
|
||||||
|
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(w.models.len(), 2);
|
||||||
|
assert_eq!(w.models[0].model_id, "glm-4-flash");
|
||||||
|
assert!(w.models[0].enabled);
|
||||||
|
assert_eq!(w.models[0].modalities, vec![Modality::Text]);
|
||||||
|
assert_eq!(w.models[1].model_id, "glm-4v");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_new_format_object_array() {
|
||||||
|
let json = r#"{"models":[{"model_id":"glm-4v","enabled":true,"modalities":["text","vision"],"capabilities":["tool_use"],"cost_tier":"medium","intelligence":"plus","weight":70,"context_window":128000}]}"#;
|
||||||
|
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(w.models.len(), 1);
|
||||||
|
let m = &w.models[0];
|
||||||
|
assert_eq!(m.model_id, "glm-4v");
|
||||||
|
assert_eq!(m.modalities, vec![Modality::Text, Modality::Vision]);
|
||||||
|
assert_eq!(m.intelligence, IntelligenceTier::Plus);
|
||||||
|
assert_eq!(m.context_window, 128_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_null_or_missing_yields_empty() {
|
||||||
|
// null
|
||||||
|
let w: Wrap = serde_json::from_str(r#"{"models":null}"#).unwrap();
|
||||||
|
assert!(w.models.is_empty());
|
||||||
|
// 缺失字段
|
||||||
|
let w: Wrap = serde_json::from_str(r#"{}"#).unwrap();
|
||||||
|
assert!(w.models.is_empty());
|
||||||
|
// 空字符串(DB TEXT 列可能存空串)
|
||||||
|
let w: Wrap = serde_json::from_str(r#"{"models":""}"#).unwrap();
|
||||||
|
assert!(w.models.is_empty());
|
||||||
|
// 空数组
|
||||||
|
let w: Wrap = serde_json::from_str(r#"{"models":[]}"#).unwrap();
|
||||||
|
assert!(w.models.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_json_string_text_column() {
|
||||||
|
// DB TEXT 列存的是 JSON 文本字符串:`"[\"model-a\"]"`
|
||||||
|
let json = r#"{"models":"[\"glm-4-flash\"]"}"#;
|
||||||
|
let w: Wrap = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(w.models.len(), 1);
|
||||||
|
assert_eq!(w.models[0].model_id, "glm-4-flash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_via_text_column() {
|
||||||
|
// 模拟 DB 落库:Vec<ModelConfig> → serde_json 序列化 → 存 TEXT 列
|
||||||
|
// 读出来:TEXT 列值 → 反序列化回 Vec<ModelConfig>
|
||||||
|
let original = vec![
|
||||||
|
ModelConfig::with_defaults("glm-4-flash"),
|
||||||
|
ModelConfig {
|
||||||
|
model_id: "glm-4v".into(),
|
||||||
|
modalities: vec![Modality::Text, Modality::Vision],
|
||||||
|
intelligence: IntelligenceTier::Plus,
|
||||||
|
weight: 70,
|
||||||
|
context_window: 128_000,
|
||||||
|
..ModelConfig::with_defaults("glm-4v")
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let text = serde_json::to_string(&original).unwrap();
|
||||||
|
let wrapped = format!(r#"{{"models":"{}"}}"#, text.replace('"', "\\\""));
|
||||||
|
let w: Wrap = serde_json::from_str(&wrapped).unwrap();
|
||||||
|
assert_eq!(w.models.len(), 2);
|
||||||
|
assert_eq!(w.models[1].intelligence, IntelligenceTier::Plus);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
crates/df-ai/presets/models.json
Normal file
172
crates/df-ai/presets/models.json
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"model_id": "glm-4",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "medium",
|
||||||
|
"intelligence": "plus",
|
||||||
|
"weight": 60,
|
||||||
|
"context_window": 128000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "glm-4-air",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "low",
|
||||||
|
"intelligence": "standard",
|
||||||
|
"weight": 80,
|
||||||
|
"context_window": 128000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "glm-4-flash",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "low",
|
||||||
|
"intelligence": "lite",
|
||||||
|
"weight": 90,
|
||||||
|
"context_window": 128000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "glm-4-plus",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "high",
|
||||||
|
"intelligence": "plus",
|
||||||
|
"weight": 50,
|
||||||
|
"context_window": 128000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "glm-4v",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text", "vision"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "medium",
|
||||||
|
"intelligence": "plus",
|
||||||
|
"weight": 70,
|
||||||
|
"context_window": 128000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "glm-4v-flash",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text", "vision"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "low",
|
||||||
|
"intelligence": "lite",
|
||||||
|
"weight": 85,
|
||||||
|
"context_window": 128000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "gpt-4o",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text", "vision"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "high",
|
||||||
|
"intelligence": "ultra",
|
||||||
|
"weight": 50,
|
||||||
|
"context_window": 128000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "gpt-4o-mini",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text", "vision"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "low",
|
||||||
|
"intelligence": "standard",
|
||||||
|
"weight": 80,
|
||||||
|
"context_window": 128000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "claude-3-5-sonnet-20241022",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text", "vision"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "high",
|
||||||
|
"intelligence": "ultra",
|
||||||
|
"weight": 50,
|
||||||
|
"context_window": 200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "claude-3-opus-20240229",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text", "vision"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "high",
|
||||||
|
"intelligence": "ultra",
|
||||||
|
"weight": 45,
|
||||||
|
"context_window": 200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "claude-3-haiku-20240307",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text", "vision"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "low",
|
||||||
|
"intelligence": "standard",
|
||||||
|
"weight": 80,
|
||||||
|
"context_window": 200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "gemini-1.5-pro",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text", "vision"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "high",
|
||||||
|
"intelligence": "plus",
|
||||||
|
"weight": 55,
|
||||||
|
"context_window": 1000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "gemini-1.5-flash",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text", "vision"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "low",
|
||||||
|
"intelligence": "lite",
|
||||||
|
"weight": 85,
|
||||||
|
"context_window": 1000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "deepseek-chat",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "low",
|
||||||
|
"intelligence": "standard",
|
||||||
|
"weight": 75,
|
||||||
|
"context_window": 64000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "deepseek-coder",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text"],
|
||||||
|
"capabilities": ["tool_use", "code_gen"],
|
||||||
|
"cost_tier": "low",
|
||||||
|
"intelligence": "plus",
|
||||||
|
"weight": 75,
|
||||||
|
"context_window": 64000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "qwen-2.5",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": ["text"],
|
||||||
|
"capabilities": ["tool_use"],
|
||||||
|
"cost_tier": "medium",
|
||||||
|
"intelligence": "standard",
|
||||||
|
"weight": 65,
|
||||||
|
"context_window": 128000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "embedding-3",
|
||||||
|
"enabled": true,
|
||||||
|
"modalities": [],
|
||||||
|
"capabilities": ["embedding"],
|
||||||
|
"cost_tier": "low",
|
||||||
|
"intelligence": "lite",
|
||||||
|
"weight": 50,
|
||||||
|
"context_window": 8192
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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_probe;
|
||||||
pub mod openai_compat;
|
pub mod openai_compat;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||||
|
|||||||
434
crates/df-ai/src/model_probe.rs
Normal file
434
crates/df-ai/src/model_probe.rs
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
//! 模型探测器 — F-01 阶段2
|
||||||
|
//!
|
||||||
|
//! 给定模型名,产出完整 `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 std::sync::OnceLock;
|
||||||
|
|
||||||
|
use df_ai_core::model::{
|
||||||
|
Capability, CostTier, IntelligenceTier, Modality, ModelConfig, ProbeSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// 预设表:编译期嵌入(include_str! 相对 crate 根),零运行时文件依赖
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 预设表原始 JSON(编译期从 `crates/df-ai/presets/models.json` 嵌入)。
|
||||||
|
const PRESETS_JSON: &str = include_str!("../presets/models.json");
|
||||||
|
|
||||||
|
/// 解析后的预设表(进程内单例,首次访问惰性解析一次)。
|
||||||
|
fn presets() -> &'static [ModelConfig] {
|
||||||
|
static PRESETS: OnceLock<Vec<ModelConfig>> = OnceLock::new();
|
||||||
|
PRESETS.get_or_init(|| {
|
||||||
|
// include_str! 内容由仓库控制,解析失败属编译期/仓库错误,panic 合理。
|
||||||
|
serde_json::from_str::<Vec<ModelConfig>>(PRESETS_JSON)
|
||||||
|
.expect("presets/models.json 解析失败 — 检查 JSON 格式与 ModelConfig serde 映射")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// 公共入口
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 探测单个模型,返回完整 `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. 预设表模糊匹配(双向子串包含,大小写不敏感)
|
||||||
|
// 多个候选命中时,选预设 model_id 最长者(最具体:glm-4v > glm-4)。
|
||||||
|
let needle = model_id.to_lowercase();
|
||||||
|
let fuzzy = presets()
|
||||||
|
.iter()
|
||||||
|
.filter(|m| {
|
||||||
|
let cand = m.model_id.to_lowercase();
|
||||||
|
!cand.is_empty() && (cand.contains(&needle) || needle.contains(&cand))
|
||||||
|
})
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// 启发式推断(模型名命名模式 → 4 维度)
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 模型名启发式推断。命名是行业惯例(flash/mini/v/embed/code),可信度 Medium。
|
||||||
|
///
|
||||||
|
/// 规则(任务规格,设计文档 §4.4 模糊匹配规则对齐):
|
||||||
|
/// - `embed`/`embedding`/`e-`(前缀) → Embedding 模型:capabilities=[Embedding],modalities=[](去 Text)
|
||||||
|
/// - `v`/`vision`/`vl`(词素) → modalities 加 Vision
|
||||||
|
/// - `flash`/`mini`/`lite`/`nano`/`air` → IntelligenceTier::Lite(air 归 Lite,对齐设计 §4.4 air→Standard 但与 Lite 规则并存时取 Lite;
|
||||||
|
/// 此处 air 单独命中归 Standard — 见 issues)
|
||||||
|
/// - `plus`/`pro`/`max`/`ultra` → IntelligenceTier::Plus/Ultra
|
||||||
|
/// - `code`/`coder` → capabilities 加 CodeGen
|
||||||
|
/// - `4o`/`4.5`/`5`(高价旗舰标识) → CostTier::High
|
||||||
|
/// - 默认 → Standard / Medium / [Text] / [ToolUse]
|
||||||
|
fn heuristic_infer(model_id: &str) -> ModelConfig {
|
||||||
|
let name = model_id.to_lowercase();
|
||||||
|
let mut modalities: Vec<Modality> = Vec::new();
|
||||||
|
let mut capabilities: Vec<Capability> = Vec::new();
|
||||||
|
let mut cost_tier = CostTier::Medium;
|
||||||
|
let mut intelligence = IntelligenceTier::Standard;
|
||||||
|
|
||||||
|
// —— Embedding 优先判定(改变模态集合,且通常独占) ——
|
||||||
|
// 规则:含 embed / embedding / 以 "e-" / "embedding-" 起首
|
||||||
|
let is_embedding = name.contains("embed")
|
||||||
|
|| name.contains("embedding")
|
||||||
|
|| name.starts_with("e-")
|
||||||
|
|| name.starts_with("embedding-")
|
||||||
|
|| name.starts_with("text-embedding");
|
||||||
|
|
||||||
|
if is_embedding {
|
||||||
|
capabilities.push(Capability::Embedding);
|
||||||
|
// embedding 模型不走对话模态,modalities 留空(设计 §4.4 embedding-3 例:modalities=[])
|
||||||
|
return ModelConfig {
|
||||||
|
model_id: model_id.to_string(),
|
||||||
|
enabled: true,
|
||||||
|
label: None,
|
||||||
|
modalities,
|
||||||
|
capabilities,
|
||||||
|
cost_tier: CostTier::Low, // embedding 普遍低价
|
||||||
|
intelligence: IntelligenceTier::Lite, // embedding 无智力概念,归 Lite
|
||||||
|
weight: 50,
|
||||||
|
context_window: 8192,
|
||||||
|
probe_source: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 对话模型默认 Text 模态 ——
|
||||||
|
modalities.push(Modality::Text);
|
||||||
|
|
||||||
|
// —— Vision 词素:含 "v"(独立词素:glm-4v / qwen-vl)、"vision"、"vl" ——
|
||||||
|
// 注:裸 "v" 子串误伤大(如 "review"),故仅在边界词素命中:
|
||||||
|
// 以 "v" 结尾、含 "-v" / "v-" 分隔、含 "vl" / "vision"
|
||||||
|
if has_vision_token(&name) {
|
||||||
|
modalities.push(Modality::Vision);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— CodeGen:含 code / coder ——
|
||||||
|
if name.contains("code") || name.contains("coder") {
|
||||||
|
capabilities.push(Capability::CodeGen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 对话模型默认 ToolUse(主流支持 function calling) ——
|
||||||
|
capabilities.push(Capability::ToolUse);
|
||||||
|
|
||||||
|
// —— 智力分级 ——
|
||||||
|
if has_lite_token(&name) {
|
||||||
|
intelligence = IntelligenceTier::Lite;
|
||||||
|
} else if has_ultra_token(&name) {
|
||||||
|
intelligence = IntelligenceTier::Ultra;
|
||||||
|
} else if has_plus_token(&name) {
|
||||||
|
intelligence = IntelligenceTier::Plus;
|
||||||
|
} else if has_standard_token(&name) {
|
||||||
|
intelligence = IntelligenceTier::Standard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 价格分级 ——
|
||||||
|
if has_high_cost_token(&name) {
|
||||||
|
cost_tier = CostTier::High;
|
||||||
|
} else if has_lite_token(&name) {
|
||||||
|
cost_tier = CostTier::Low;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelConfig {
|
||||||
|
model_id: model_id.to_string(),
|
||||||
|
enabled: true,
|
||||||
|
label: None,
|
||||||
|
modalities,
|
||||||
|
capabilities,
|
||||||
|
cost_tier,
|
||||||
|
intelligence,
|
||||||
|
weight: 50,
|
||||||
|
context_window: 8192,
|
||||||
|
probe_source: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 词素判定助手(命名约定式启发式,集中维护便于增删) ——
|
||||||
|
|
||||||
|
/// Vision 词素:`-v`(尾缀如 glm-4v)、以 `v` 结尾、含 `vl` / `vision`。
|
||||||
|
/// 不直接 `contains('v')`(避免误伤 review/verbal 等)。
|
||||||
|
fn has_vision_token(name: &str) -> bool {
|
||||||
|
name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lite 智力词素:flash / mini / lite / nano / air(air 对齐设计 §4.4 模糊规则 Lite)。
|
||||||
|
/// 注:设计 §4.4 文本规则列了 air,但 §4.4 精确例 glm-4-air→Standard。
|
||||||
|
/// 本启发式取模糊规则(air→Lite)以保持一致 — 见 issues。
|
||||||
|
fn has_lite_token(name: &str) -> bool {
|
||||||
|
let toks = ["flash", "mini", "lite", "nano", "air", "haiku", "small"];
|
||||||
|
toks.iter().any(|t| name.contains(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plus 智力词素:plus / pro / max / sonnet。
|
||||||
|
fn has_plus_token(name: &str) -> bool {
|
||||||
|
let toks = ["plus", "pro", "max", "sonnet"];
|
||||||
|
toks.iter().any(|t| name.contains(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ultra 智力词素:ultra / opus / largest。
|
||||||
|
fn has_ultra_token(name: &str) -> bool {
|
||||||
|
let toks = ["ultra", "opus", "largest"];
|
||||||
|
toks.iter().any(|t| name.contains(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard 智力词素:standard / chat(对话主力模型默认归 Standard)。
|
||||||
|
fn has_standard_token(name: &str) -> bool {
|
||||||
|
let toks = ["standard", "chat", "haiku"];
|
||||||
|
toks.iter().any(|t| name.contains(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 高价旗舰词素:4o / 4.5 / 5 / opus / o1 / o3(OpenAI/Anthropic 旗舰命名)。
|
||||||
|
fn has_high_cost_token(name: &str) -> bool {
|
||||||
|
let toks = ["4o", "4.5", "opus", "o1", "o3"];
|
||||||
|
// "5" 单字符误伤大(如 "5b"),仅匹配词边界 -5 / 5- 或以 5 结尾
|
||||||
|
toks.iter().any(|t| name.contains(t))
|
||||||
|
|| name.ends_with("-5")
|
||||||
|
|| name.contains("-5-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// 测试
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[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]);
|
||||||
|
assert_eq!(m.intelligence, IntelligenceTier::Plus);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 模态"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 预设模糊匹配 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_preset_fuzzy_match_glm4v_variant() {
|
||||||
|
// "glm-4v-x" 不在预设表精确命中,但 "glm-4v" 是其子串 → 模糊命中
|
||||||
|
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 模态"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 启发式: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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 启发式:Lite 智力 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heuristic_lite_from_flash() {
|
||||||
|
let m = probe("unknown-flash");
|
||||||
|
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||||
|
assert_eq!(m.intelligence, IntelligenceTier::Lite);
|
||||||
|
assert_eq!(m.cost_tier, CostTier::Low);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heuristic_lite_from_mini() {
|
||||||
|
let m = probe("test-mini-model");
|
||||||
|
assert_eq!(m.intelligence, IntelligenceTier::Lite);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 启发式: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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 启发式:Plus/Ultra 智力 + High 价格 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heuristic_plus_from_pro() {
|
||||||
|
let m = probe("acme-pro");
|
||||||
|
assert_eq!(m.intelligence, IntelligenceTier::Plus);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heuristic_ultra_from_opus() {
|
||||||
|
let m = probe("acme-opus");
|
||||||
|
assert_eq!(m.intelligence, IntelligenceTier::Ultra);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heuristic_high_cost_from_4o() {
|
||||||
|
let m = probe("acme-4o");
|
||||||
|
assert_eq!(m.cost_tier, CostTier::High);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 启发式:默认(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" 在预设表 = Lite;若走启发式也会是 Lite,但 source 应为 PresetTable
|
||||||
|
let m = probe("glm-4-flash");
|
||||||
|
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||||
|
assert_eq!(m.intelligence, IntelligenceTier::Lite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -689,6 +689,7 @@ mod tests {
|
|||||||
base_url: "https://api.example.com".to_string(),
|
base_url: "https://api.example.com".to_string(),
|
||||||
default_model: "glm-4-flash".to_string(),
|
default_model: "glm-4-flash".to_string(),
|
||||||
models: None,
|
models: None,
|
||||||
|
model_configs: Vec::new(),
|
||||||
is_default,
|
is_default,
|
||||||
config: None,
|
config: None,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
df-core = { path = "../df-core" }
|
df-core = { path = "../df-core" }
|
||||||
|
# F-01 阶段1:ModelConfig/deserialize_model_configs 向后兼容反序列化(老字符串数组/新对象数组/null/空)
|
||||||
|
df-ai-core = { path = "../df-ai-core" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|||||||
@@ -1032,6 +1032,28 @@ impl_repo!(
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord, rusqlite::Error> {
|
fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord, rusqlite::Error> {
|
||||||
|
// model_configs:DB TEXT 列存 JSON 字符串。读 Option<String> 兼容老库 NULL,
|
||||||
|
// 再经 deserialize_model_configs 解析(老字符串数组/新对象数组/空 → Vec<ModelConfig>)。
|
||||||
|
// 解析失败不致命:降级为空 Vec(防单行坏数据拖垮 list_all)。
|
||||||
|
let model_configs: Vec<df_ai_core::model::ModelConfig> = {
|
||||||
|
let raw: Option<String> = row.get("model_configs").ok();
|
||||||
|
match raw {
|
||||||
|
None => Vec::new(),
|
||||||
|
Some(s) => serde_json::from_str::<ModelConfigsWrap>(&format!(
|
||||||
|
r#"{{"v":{}}}"#,
|
||||||
|
if s.trim().is_empty() {
|
||||||
|
"null".to_string()
|
||||||
|
} else if s.trim_start().starts_with('[') || s.trim_start().starts_with('{') {
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
// 非 JSON 字面文本(理论不会出现)→ 包装为 JSON 字符串让 deserialize 兜底
|
||||||
|
serde_json::to_string(&s).unwrap_or_else(|_| "null".into())
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.map(|w| w.v)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
};
|
||||||
Ok(AiProviderRecord {
|
Ok(AiProviderRecord {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
name: row.get("name")?,
|
name: row.get("name")?,
|
||||||
@@ -1040,6 +1062,7 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
|
|||||||
base_url: row.get("base_url")?,
|
base_url: row.get("base_url")?,
|
||||||
default_model: row.get("default_model")?,
|
default_model: row.get("default_model")?,
|
||||||
models: row.get("models")?,
|
models: row.get("models")?,
|
||||||
|
model_configs,
|
||||||
is_default: row.get::<_, i32>("is_default")? != 0,
|
is_default: row.get::<_, i32>("is_default")? != 0,
|
||||||
config: row.get("config")?,
|
config: row.get("config")?,
|
||||||
created_at: row.get("created_at")?,
|
created_at: row.get("created_at")?,
|
||||||
@@ -1047,6 +1070,14 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// from_row 内部辅助:复用 deserialize_model_configs 解析 DB TEXT 列 JSON。
|
||||||
|
/// 包一层 { "v": <原始值> } 把任意 JSON 值送进 deserialize_model_configs。
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ModelConfigsWrap {
|
||||||
|
#[serde(default, deserialize_with = "df_ai_core::model::deserialize_model_configs")]
|
||||||
|
v: Vec<df_ai_core::model::ModelConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversationRecord, rusqlite::Error> {
|
fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversationRecord, rusqlite::Error> {
|
||||||
Ok(AiConversationRecord {
|
Ok(AiConversationRecord {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
@@ -1118,22 +1149,25 @@ impl_repo!(
|
|||||||
from_row => |row| ai_provider_from_row(row),
|
from_row => |row| ai_provider_from_row(row),
|
||||||
insert => |conn, rec| {
|
insert => |conn, rec| {
|
||||||
let is_default = if rec.is_default { 1i32 } else { 0i32 };
|
let is_default = if rec.is_default { 1i32 } else { 0i32 };
|
||||||
|
// model_configs:Vec<ModelConfig> → JSON 字符串落 TEXT 列
|
||||||
|
let model_configs_json = serde_json::to_string(&rec.model_configs).unwrap_or_else(|_| "[]".into());
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO ai_providers (id, name, provider_type, api_key, base_url, default_model, models, is_default, config, created_at, updated_at)
|
"INSERT OR REPLACE INTO ai_providers (id, name, provider_type, api_key, base_url, default_model, models, model_configs, is_default, config, created_at, updated_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
params![
|
params![
|
||||||
rec.id, rec.name, rec.provider_type, rec.api_key, rec.base_url,
|
rec.id, rec.name, rec.provider_type, rec.api_key, rec.base_url,
|
||||||
rec.default_model, rec.models, is_default, rec.config, rec.created_at, rec.updated_at
|
rec.default_model, rec.models, model_configs_json, is_default, rec.config, rec.created_at, rec.updated_at
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
update => |conn, rec| {
|
update => |conn, rec| {
|
||||||
let is_default = if rec.is_default { 1i32 } else { 0i32 };
|
let is_default = if rec.is_default { 1i32 } else { 0i32 };
|
||||||
|
let model_configs_json = serde_json::to_string(&rec.model_configs).unwrap_or_else(|_| "[]".into());
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE ai_providers SET name = ?1, provider_type = ?2, api_key = ?3, base_url = ?4, default_model = ?5, models = ?6, is_default = ?7, config = ?8, updated_at = ?9 WHERE id = ?10",
|
"UPDATE ai_providers SET name = ?1, provider_type = ?2, api_key = ?3, base_url = ?4, default_model = ?5, models = ?6, model_configs = ?7, is_default = ?8, config = ?9, updated_at = ?10 WHERE id = ?11",
|
||||||
params![
|
params![
|
||||||
rec.name, rec.provider_type, rec.api_key, rec.base_url,
|
rec.name, rec.provider_type, rec.api_key, rec.base_url,
|
||||||
rec.default_model, rec.models, is_default, rec.config, rec.updated_at, rec.id
|
rec.default_model, rec.models, model_configs_json, is_default, rec.config, rec.updated_at, rec.id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1693,6 +1727,83 @@ mod tests {
|
|||||||
|
|
||||||
// ---------- COLS 漂移防护(CR-260615-03) ----------
|
// ---------- COLS 漂移防护(CR-260615-03) ----------
|
||||||
|
|
||||||
|
/// model_configs DB roundtrip + 老库空兼容(F-01 阶段1)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ai_provider_model_configs_roundtrip_and_old_db_compat() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiProviderRepo::new(&db);
|
||||||
|
use crate::models::AiProviderRecord;
|
||||||
|
use df_ai_core::model::{Capability, IntelligenceTier, Modality, ModelConfig};
|
||||||
|
|
||||||
|
// 新格式:带多模型 + 多维度配置
|
||||||
|
let configs = vec![
|
||||||
|
ModelConfig::with_defaults("glm-4-flash"),
|
||||||
|
ModelConfig {
|
||||||
|
model_id: "glm-4v".into(),
|
||||||
|
modalities: vec![Modality::Text, Modality::Vision],
|
||||||
|
capabilities: vec![Capability::ToolUse],
|
||||||
|
intelligence: IntelligenceTier::Plus,
|
||||||
|
weight: 70,
|
||||||
|
context_window: 128_000,
|
||||||
|
..ModelConfig::with_defaults("glm-4v")
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let rec = AiProviderRecord {
|
||||||
|
id: "p1".into(),
|
||||||
|
name: "测试".into(),
|
||||||
|
provider_type: "openai_compat".into(),
|
||||||
|
api_key: String::new(),
|
||||||
|
base_url: "https://x".into(),
|
||||||
|
default_model: "glm-4-flash".into(),
|
||||||
|
models: None,
|
||||||
|
model_configs: configs.clone(),
|
||||||
|
is_default: false,
|
||||||
|
config: None,
|
||||||
|
created_at: "0".into(),
|
||||||
|
updated_at: "0".into(),
|
||||||
|
};
|
||||||
|
repo.insert(rec).await.expect("insert");
|
||||||
|
|
||||||
|
let got = repo.get_by_id("p1").await.expect("get").expect("row exists");
|
||||||
|
assert_eq!(got.model_configs.len(), 2);
|
||||||
|
assert_eq!(got.model_configs[0].model_id, "glm-4-flash");
|
||||||
|
assert_eq!(got.model_configs[1].model_id, "glm-4v");
|
||||||
|
assert_eq!(got.model_configs[1].intelligence, IntelligenceTier::Plus);
|
||||||
|
assert_eq!(got.model_configs[1].context_window, 128_000);
|
||||||
|
|
||||||
|
// 老库空兼容:直接写 model_configs=NULL 的行(模拟 V18 之前的老库行)
|
||||||
|
// 然后 from_row 应得空 Vec
|
||||||
|
{
|
||||||
|
let conn = db.conn();
|
||||||
|
let g = conn.lock().await;
|
||||||
|
g.execute(
|
||||||
|
"INSERT OR REPLACE INTO ai_providers \
|
||||||
|
(id,name,provider_type,api_key,base_url,default_model,models,model_configs,is_default,config,created_at,updated_at) \
|
||||||
|
VALUES ('old','','openai_compat','','','','{}',NULL,0,NULL,'0','0')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("raw insert old row");
|
||||||
|
}
|
||||||
|
let old = repo.get_by_id("old").await.expect("get").expect("old row");
|
||||||
|
assert!(old.model_configs.is_empty(), "NULL 列应得空 Vec");
|
||||||
|
|
||||||
|
// 老格式字符串数组 JSON(向后兼容 deserialize_model_configs)
|
||||||
|
{
|
||||||
|
let conn = db.conn();
|
||||||
|
let g = conn.lock().await;
|
||||||
|
g.execute(
|
||||||
|
"INSERT OR REPLACE INTO ai_providers \
|
||||||
|
(id,name,provider_type,api_key,base_url,default_model,models,model_configs,is_default,config,created_at,updated_at) \
|
||||||
|
VALUES ('legacy','','openai_compat','','','','{}','[\"glm-4-flash\",\"glm-4v\"]',0,NULL,'0','0')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("raw insert legacy row");
|
||||||
|
}
|
||||||
|
let legacy = repo.get_by_id("legacy").await.expect("get").expect("legacy row");
|
||||||
|
assert_eq!(legacy.model_configs.len(), 2, "老字符串数组应转 2 个默认 ModelConfig");
|
||||||
|
assert_eq!(legacy.model_configs[0].model_id, "glm-4-flash");
|
||||||
|
}
|
||||||
|
|
||||||
/// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。
|
/// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。
|
||||||
/// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。
|
/// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
|
|
||||||
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
|
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
|
||||||
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
|
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 17] = [
|
let steps: [(i32, fn(&Connection) -> Result<()>); 18] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -52,6 +52,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
(15, migrate_v15),
|
(15, migrate_v15),
|
||||||
(16, migrate_v16),
|
(16, migrate_v16),
|
||||||
(17, migrate_v17),
|
(17, migrate_v17),
|
||||||
|
(18, migrate_v18),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -312,6 +313,21 @@ fn migrate_v17(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V18: 幂等补 ai_providers.model_configs 列(模型能力配置,F-01 阶段1)
|
||||||
|
///
|
||||||
|
/// 模型 4 维度(模态/能力/价格/智力)+ 路由控制配置 JSON 字符串。TEXT NULL 向后兼容:
|
||||||
|
/// 老库行默认 NULL,from_row 经 deserialize_model_configs 解析为空 Vec(配合 default_model 过渡)。
|
||||||
|
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17 模式),对新库/老库均安全。
|
||||||
|
fn migrate_v18(conn: &Connection) -> Result<()> {
|
||||||
|
if !column_exists(conn, "ai_providers", "model_configs") {
|
||||||
|
conn.execute("ALTER TABLE ai_providers ADD COLUMN model_configs TEXT", [])?;
|
||||||
|
tracing::info!("v18: 补建 ai_providers.model_configs 列(模型能力配置)");
|
||||||
|
}
|
||||||
|
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [18])?;
|
||||||
|
tracing::info!("迁移 v18 完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// V1 建表 SQL
|
/// V1 建表 SQL
|
||||||
const V1_SQL: &str = "
|
const V1_SQL: &str = "
|
||||||
-- 想法表
|
-- 想法表
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! 数据模型定义 — 与数据库表对应的 Rust 结构体
|
//! 数据模型定义 — 与数据库表对应的 Rust 结构体
|
||||||
|
|
||||||
|
use df_ai_core::model::{deserialize_model_configs, ModelConfig};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -152,6 +153,11 @@ pub struct AiProviderRecord {
|
|||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub default_model: String,
|
pub default_model: String,
|
||||||
pub models: Option<String>, // JSON array of model names
|
pub models: Option<String>, // JSON array of model names
|
||||||
|
/// 模型能力配置数组(F-01 阶段1,4 维度 + 路由控制)。
|
||||||
|
/// DB TEXT 列存 JSON 字符串,from_row 经 deserialize_model_configs 解析。
|
||||||
|
/// 向后兼容:老库 NULL/空/老字符串数组 → 空 Vec 或转默认 ModelConfig。default_model 保留过渡。
|
||||||
|
#[serde(default, deserialize_with = "deserialize_model_configs")]
|
||||||
|
pub model_configs: Vec<ModelConfig>,
|
||||||
pub is_default: bool,
|
pub is_default: bool,
|
||||||
pub config: Option<String>, // JSON extra config
|
pub config: Option<String>, // JSON extra config
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ mod tests {
|
|||||||
let rec = AiProviderRecord {
|
let rec = AiProviderRecord {
|
||||||
id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(),
|
id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(),
|
||||||
api_key: "sk-db-fallback".into(), base_url: "https://x".into(),
|
api_key: "sk-db-fallback".into(), base_url: "https://x".into(),
|
||||||
default_model: "m".into(), models: None, is_default: false,
|
default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false,
|
||||||
config: None, created_at: "0".into(), updated_at: "0".into(),
|
config: None, created_at: "0".into(), updated_at: "0".into(),
|
||||||
};
|
};
|
||||||
assert_eq!(resolve_provider_secret(&rec), "sk-db-fallback");
|
assert_eq!(resolve_provider_secret(&rec), "sk-db-fallback");
|
||||||
|
|||||||
@@ -761,6 +761,7 @@ pub async fn ai_save_provider(
|
|||||||
base_url,
|
base_url,
|
||||||
default_model,
|
default_model,
|
||||||
models: None,
|
models: None,
|
||||||
|
model_configs: Vec::new(),
|
||||||
is_default,
|
is_default,
|
||||||
config: None,
|
config: None,
|
||||||
created_at,
|
created_at,
|
||||||
|
|||||||
Reference in New Issue
Block a user