优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)

This commit is contained in:
2026-06-18 22:57:19 +08:00
parent 0ca5d9805f
commit a2871a66e0
87 changed files with 5720 additions and 3012 deletions

View File

@@ -4,8 +4,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "medium",
"intelligence": "plus",
"weight": 60,
"context_window": 128000
},
@@ -14,8 +12,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 80,
"context_window": 128000
},
@@ -24,8 +20,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 90,
"context_window": 128000
},
@@ -34,8 +28,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "plus",
"weight": 50,
"context_window": 128000
},
@@ -44,8 +36,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "medium",
"intelligence": "plus",
"weight": 70,
"context_window": 128000
},
@@ -54,8 +44,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 85,
"context_window": 128000
},
@@ -64,8 +52,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "ultra",
"weight": 50,
"context_window": 128000
},
@@ -74,8 +60,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 80,
"context_window": 128000
},
@@ -84,8 +68,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "ultra",
"weight": 50,
"context_window": 200000
},
@@ -94,8 +76,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "ultra",
"weight": 45,
"context_window": 200000
},
@@ -104,8 +84,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 80,
"context_window": 200000
},
@@ -114,8 +92,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "plus",
"weight": 55,
"context_window": 1000000
},
@@ -124,8 +100,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 85,
"context_window": 1000000
},
@@ -134,8 +108,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 75,
"context_window": 64000
},
@@ -144,8 +116,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use", "code_gen"],
"cost_tier": "low",
"intelligence": "plus",
"weight": 75,
"context_window": 64000
},
@@ -154,8 +124,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "medium",
"intelligence": "standard",
"weight": 65,
"context_window": 128000
},
@@ -164,8 +132,6 @@
"enabled": true,
"modalities": [],
"capabilities": ["embedding"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 50,
"context_window": 8192
}

View File

@@ -177,3 +177,84 @@ pub fn object_schema(properties: Vec<(&str, &str, bool)>) -> Value {
"required": required,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// 构造一个空参恒返 Ok 的 handler(注册测试用·不实际执行)
fn dummy_handler() -> AiToolHandler {
Box::new(|_args: Value| {
Box::pin(async { Ok(serde_json::json!({ "ok": true })) })
as Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>>
})
}
#[test]
fn risk_level_serde_lowercase() {
// serde rename_all="lowercase": Low→"low" / Medium→"medium" / High→"high"
assert_eq!(serde_json::to_string(&RiskLevel::Low).unwrap(), "\"low\"");
assert_eq!(serde_json::to_string(&RiskLevel::Medium).unwrap(), "\"medium\"");
assert_eq!(serde_json::to_string(&RiskLevel::High).unwrap(), "\"high\"");
assert_eq!(
serde_json::from_str::<RiskLevel>("\"high\"").unwrap(),
RiskLevel::High
);
}
#[test]
fn register_and_get_tool() {
let mut reg = AiToolRegistry::new();
assert!(reg.is_empty());
reg.register(
"list_tasks",
"列出任务",
object_schema(vec![]),
RiskLevel::Low,
dummy_handler(),
);
assert!(!reg.is_empty());
assert_eq!(reg.len(), 1);
assert_eq!(reg.get("list_tasks").unwrap().risk_level, RiskLevel::Low);
assert!(reg.get("nonexistent").is_none());
}
#[test]
fn register_same_name_overwrites() {
// 同名二次注册覆盖(HashMap insert 语义)·非新增·后注册的 risk_level 胜出
let mut reg = AiToolRegistry::new();
reg.register("t", "v1", object_schema(vec![]), RiskLevel::Low, dummy_handler());
reg.register("t", "v2", object_schema(vec![]), RiskLevel::High, dummy_handler());
assert_eq!(reg.len(), 1);
assert_eq!(reg.get("t").unwrap().risk_level, RiskLevel::High);
}
#[test]
fn tool_definitions_and_names() {
let mut reg = AiToolRegistry::new();
reg.register("a", "desc a", object_schema(vec![]), RiskLevel::Low, dummy_handler());
reg.register("b", "desc b", object_schema(vec![]), RiskLevel::Medium, dummy_handler());
assert_eq!(reg.tool_definitions().len(), 2);
let names = reg.tool_names();
assert_eq!(names.len(), 2);
assert!(names.contains(&"a".to_string()));
assert!(names.contains(&"b".to_string()));
}
#[test]
fn default_is_empty() {
let reg = AiToolRegistry::default();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
#[test]
fn object_schema_collects_required() {
let schema = object_schema(vec![("name", "string", true), ("age", "integer", false)]);
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["name"]["type"], "string");
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "name");
}
}

View File

@@ -58,10 +58,12 @@ struct AnthropicToolDef {
/// Anthropic 同步响应
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
// SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
#[allow(dead_code)]
id: String,
model: String,
content: Vec<AnthropicContentBlock>,
// SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)]
stop_reason: Option<String>,
usage: AnthropicUsage,
@@ -246,13 +248,8 @@ impl AnthropicCompatProvider {
/// - `api_key`: API 密钥Anthropic 用 x-api-key 头,非 Bearer
/// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
let client = Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
});
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client与 OpenAI 共用 DRY
let client = crate::build_provider_client();
Self {
client,
api_key: api_key.into(),
@@ -354,8 +351,9 @@ impl AnthropicCompatProvider {
"text": text,
}),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
let mt = media_type.clone().unwrap_or_else(|| "image/png".into());
let data = base64.clone().unwrap_or_else(|| {
// p 已被 match 取得所有权,直接 move media_type/base64 避免大 base64 clone。
let mt = media_type.unwrap_or_else(|| "image/png".into());
let data = base64.unwrap_or_else(|| {
// 完整性兜底:当前 url 模式不可达(前端 Phase2b 只产 base64 图片片)。
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
// 此处会发空 data 致 Anthropic 400warn 留痕但不阻塞(避免静默吞数据)。
@@ -391,8 +389,11 @@ impl AnthropicCompatProvider {
}
if let Some(calls) = &m.tool_calls {
for tc in calls {
let input: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null);
// B-260618-25: arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
// → 空 object 兜底。Anthropic/GLM 要求 tool_use.input 必为 object,
// null 直触发 1214「messages 参数非法」。
let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
.unwrap_or_else(|_| serde_json::json!({}));
content.push(serde_json::json!({
"type": "tool_use",
"id": tc.id,
@@ -415,7 +416,13 @@ impl AnthropicCompatProvider {
.map(|d| AnthropicToolDef {
name: d.function.name,
description: Some(d.function.description).filter(|s| !s.is_empty()),
input_schema: d.function.parameters,
// B-260618-25: input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
// 防 Anthropic 拒非法 tool schema(当前全走 object_schema 恒 object,纯防御)。
input_schema: if d.function.parameters.is_object() {
d.function.parameters
} else {
serde_json::json!({"type": "object"})
},
})
.collect()
});
@@ -478,10 +485,22 @@ impl LlmProvider for AnthropicCompatProvider {
.header("anthropic-version", ANTHROPIC_VERSION)
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(60))
.version(reqwest::Version::HTTP_11)
.json(&body);
let resp = match rb.send().await {
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链(is_*/source)。原仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
url = ?e.url(),
source = ?std::error::Error::source(&e),
"Anthropic 同步 send 失败"
);
if is_reqwest_error_retryable(&e) {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
}
@@ -558,11 +577,29 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 流式调用");
let resp = self
let resp = match self
.auth_headers(self.client.post(self.messages_url()))
.json(&body)
.version(reqwest::Version::HTTP_11)
.send()
.await?;
.await
{
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
url = %self.messages_url(),
source = ?std::error::Error::source(&e),
"Anthropic 流式 send 失败"
);
anyhow::bail!("Anthropic 流式 send 失败: {}", e);
}
};
if !resp.status().is_success() {
let status = resp.status();

View File

@@ -41,8 +41,31 @@ impl Default for TokenEstimator {
impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计)
///
/// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符0.35 偏保守)。
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let content_tokens = (msg.content.chars().count() as f32 * self.chars_ratio).ceil() as u32;
let mut char_count = msg.content.chars().count();
if let Some(parts) = &msg.parts {
for p in parts {
match p {
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
crate::provider::ContentPart::Image { base64, url, .. } => {
// CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
// base64 优先多模态主载荷url 次之url 模式无字节,仅按 URL 长度估
if let Some(b) = base64 {
char_count += b.chars().count();
} else if let Some(u) = url {
char_count += u.chars().count();
}
}
}
}
}
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销role=Assistant 时可能有)
@@ -366,58 +389,93 @@ impl ContextManager {
}
}
if orphaned_ids.is_empty() && partial_keep.is_empty() {
return messages;
}
// step 3保序过滤 + 部分闭合头重写 tool_calls
let sanitized: Vec<ChatMessage> = messages
.into_iter()
.enumerate()
.filter_map(|(i, mut m)| match m.role {
MessageRole::Assistant => {
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
if calls.is_empty() {
return Some(m);
let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
messages
} else {
// step 3保序过滤 + 部分闭合头重写 tool_calls
let sanitized: Vec<ChatMessage> = messages
.into_iter()
.enumerate()
.filter_map(|(i, mut m)| match m.role {
MessageRole::Assistant => {
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
if calls.is_empty() {
return Some(m);
}
if let Some(keep) = partial_keep.get(&i) {
// 部分闭合:重写 tool_calls 为仅已闭合子集
m.tool_calls = Some(keep.clone());
Some(m)
} else {
// 全闭合保留全未闭合id 全在 orphaned_ids丢弃
let all_orphaned =
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
None
} else {
Some(m)
}
}
}
if let Some(keep) = partial_keep.get(&i) {
// 部分闭合:重写 tool_calls 为仅已闭合子集
m.tool_calls = Some(keep.clone());
Some(m)
} else {
// 全闭合保留全未闭合id 全在 orphaned_ids丢弃
let all_orphaned =
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
MessageRole::Tool => {
// 无主 tool_result其 id 命中 orphaned_ids丢弃其余保留
if m
.tool_call_id
.as_deref()
.is_some_and(|id| orphaned_ids.contains(id))
{
None
} else {
Some(m)
}
}
_ => Some(m),
})
.collect();
tracing::warn!(
full_drop_heads,
partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(),
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
);
sanitized
};
// step 4序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
Self::ensure_sequence_legal(after_triplet)
}
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
///
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
/// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
/// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
///
/// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
let mut skipped = 0u32;
let result: Vec<ChatMessage> = messages
.into_iter()
.skip_while(|m| {
if matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
skipped += 1;
true
} else {
false
}
MessageRole::Tool => {
// 无主 tool_result其 id 命中 orphaned_ids丢弃其余保留
if m
.tool_call_id
.as_deref()
.is_some_and(|id| orphaned_ids.contains(id))
{
None
} else {
Some(m)
}
}
_ => Some(m),
})
.collect();
tracing::warn!(
full_drop_heads,
partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(),
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
);
sanitized
if skipped > 0 {
tracing::warn!(
skipped,
"序列修复:丢弃开头的 assistant/tool 消息(Anthropic 要求首条 user,避免 1214)"
);
}
result
}
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
@@ -758,15 +816,17 @@ mod tests {
#[test]
fn sanitize_keeps_well_formed_triplet() {
// 正常三元组:head 的每个 tool_call 有匹配 tool_result → 剔除
// 正常三元组:user → head(tool_call 有匹配 tool_result)tool_result,不被剔除
// 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}")],
));
mgr.push(ChatMessage::tool_result("call_a", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
assert_eq!(msgs.len(), 2, "正常三元组不应被 sanitize 剔除");
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
}
#[test]
@@ -838,6 +898,46 @@ mod tests {
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
}
#[test]
fn estimate_message_counts_parts_tokens() {
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
let est = TokenEstimator::default();
// 纯文本基线
let text_msg = ChatMessage::user("短文本");
let text_tokens = est.estimate_message(&text_msg);
// 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
let multimodal = ChatMessage::user_parts(
"短文本",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
);
let mm_tokens = est.estimate_message(&multimodal);
assert!(
mm_tokens > text_tokens,
"含图消息 token({}) 应高于纯文本({})",
mm_tokens,
text_tokens
);
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
assert!(
mm_tokens > 500,
"大 base64 应贡献可观 token实际 {}",
mm_tokens
);
// url 模式(无字节)也按 URL 长度估算,不爆
let url_msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
);
let url_tokens = est.estimate_message(&url_msg);
assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
}
#[test]
fn short_history_no_trim() {
let mut mgr = ContextManager::new(cfg(100_000));

View File

@@ -15,10 +15,26 @@ pub mod router;
pub mod retry;
use provider::LlmProvider;
use reqwest::Client;
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
pub use df_ai_core;
/// SW-260618-10: 构建 Provider 共用的 reqwest ClientOpenAI/Anthropic Provider::new DRY
///
/// connect_timeout 30s 防连接阶段静默 hang网络静默断不设总 timeout——reqwest 的
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
/// stream_llm 的 idle timeout 兜底。builder 失败回退 `Client::new()` 保可用warn 日志)。
pub(crate) fn build_provider_client() -> Client {
Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
})
}
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match
///
/// `anthropic` 协议走 AnthropicCompatProviderGLM 订阅端点 / Claude 官方),

View File

@@ -83,23 +83,25 @@ pub fn probe(model_id: &str) -> ModelConfig {
// 启发式推断(模型名命名模式 → 4 维度)
// ────────────────────────────────────────────────────────────
/// 模型名启发式推断。命名是行业惯例(flash/mini/v/embed/code),可信度 Medium。
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
///
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
///
/// 规则(任务规格,设计文档 §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]
/// - cost_tier / intelligence → 中性默认 Medium / Standard(不靠名字猜)
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;
// 中性默认:不靠模型名猜档位(B-260618-04)
let cost_tier = CostTier::Medium;
let intelligence = IntelligenceTier::Standard;
// —— Embedding 优先判定(改变模态集合,且通常独占) ——
// 规则:含 embed / embedding / 以 "e-" / "embedding-" 起首
@@ -118,8 +120,8 @@ fn heuristic_infer(model_id: &str) -> ModelConfig {
label: None,
modalities,
capabilities,
cost_tier: CostTier::Low, // embedding 普遍低价
intelligence: IntelligenceTier::Lite, // embedding 无智力概念,归 Lite
cost_tier,
intelligence,
weight: 50,
context_window: 8192,
probe_source: None,
@@ -144,24 +146,6 @@ fn heuristic_infer(model_id: &str) -> ModelConfig {
// —— 对话模型默认 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,
@@ -184,41 +168,6 @@ 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-")
}
// ────────────────────────────────────────────────────────────
// 测试
// ────────────────────────────────────────────────────────────
@@ -257,7 +206,9 @@ mod tests {
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);
// B-260618-04:预设表不再写死 cost_tier/intelligence,由 serde default 兜底中性值
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
#[test]
@@ -326,20 +277,22 @@ mod tests {
assert!(m.modalities.contains(&Modality::Vision));
}
// ── 启发式:Lite 智力 ──
// ── 启发式:档位中性(flash/mini 不再猜 Lite) ──
#[test]
fn heuristic_lite_from_flash() {
fn heuristic_flash_keeps_neutral_tier() {
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);
// B-260618-04:cost/intel 一律中性,不靠名字猜
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
#[test]
fn heuristic_lite_from_mini() {
fn heuristic_mini_keeps_neutral_tier() {
let m = probe("test-mini-model");
assert_eq!(m.intelligence, IntelligenceTier::Lite);
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
// ── 启发式:CodeGen ──
@@ -377,24 +330,25 @@ mod tests {
assert!(m.modalities.is_empty());
}
// ── 启发式:Plus/Ultra 智力 + High 价格 ──
// ── 启发式:档位中性(pro/opus/4o 不再猜 Plus/Ultra/High) ──
#[test]
fn heuristic_plus_from_pro() {
fn heuristic_pro_keeps_neutral_tier() {
let m = probe("acme-pro");
assert_eq!(m.intelligence, IntelligenceTier::Plus);
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
#[test]
fn heuristic_ultra_from_opus() {
fn heuristic_opus_keeps_neutral_tier() {
let m = probe("acme-opus");
assert_eq!(m.intelligence, IntelligenceTier::Ultra);
assert_eq!(m.intelligence, IntelligenceTier::Standard);
}
#[test]
fn heuristic_high_cost_from_4o() {
fn heuristic_4o_keeps_neutral_cost() {
let m = probe("acme-4o");
assert_eq!(m.cost_tier, CostTier::High);
assert_eq!(m.cost_tier, CostTier::Medium);
}
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
@@ -413,10 +367,10 @@ mod tests {
#[test]
fn probe_preset_beats_heuristic() {
// "glm-4-flash" 在预设表 = Lite;若走启发式也会是 Lite,但 source 应为 PresetTable
// "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
// 此处仅校验 source 标注为 PresetTable
let m = probe("glm-4-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.intelligence, IntelligenceTier::Lite);
}
#[test]

View File

@@ -77,6 +77,8 @@ struct OpenAiResponse {
#[derive(Debug, Deserialize)]
struct OpenAiChoice {
message: OpenAiMessageResp,
// SW-260618-24: OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)]
finish_reason: Option<String>,
}
@@ -92,6 +94,8 @@ struct OpenAiMessageResp {
#[derive(Debug, Deserialize)]
struct OpenAiToolCallResp {
id: String,
// SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)]
#[serde(rename = "type")]
call_type: String,
function: OpenAiFunctionResp,
@@ -255,16 +259,9 @@ impl OpenAICompatProvider {
/// - `api_key`: API 密钥
/// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
// connect_timeout 防连接阶段无限 hang网络静默断不设总 timeout——
// reqwest 的 .timeout() 会限制整个响应 body 时长,流式长生成任务会被误砍
// 读取阶段的中途静默由上层 stream_llm 的 idle timeout 兜底。
let client = Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
});
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client与 Anthropic 共用 DRY
// connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client
let client = crate::build_provider_client();
Self {
client,
api_key: api_key.into(),

View File

@@ -8,61 +8,58 @@
//!
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
use std::cmp::Reverse;
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
/// 任务对模型的需求(5 维度,对齐设计 §6.1)。
/// 任务对模型的需求(3 维度)。
///
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/智力/成本/上下文,
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
/// 交 ModelRouter::select 在候选池中选最优模型。
///
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
/// 保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone)]
pub struct TaskRequirements {
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
pub modalities: Vec<Modality>,
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
pub needs_tool_use: bool,
/// 最低智力要求(模型 intelligence 必须 >= min_intelligence)
pub min_intelligence: IntelligenceTier,
/// 成本上限(可选:None 表示不设上限)
pub max_cost: Option<CostTier>,
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
pub estimated_context: usize,
}
/// 模型路由器(单元结构,无状态)。
///
/// select 为关联函数:给定需求 + 候选池,执行 7 步过滤链选最优模型。
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
pub struct ModelRouter;
impl ModelRouter {
/// 在候选池中选出最优模型(7 步过滤链,严格对齐设计 §6.1 L469-478)。
/// 在候选池中选出最优模型(过滤链)。
///
/// 步骤:
/// 1. enabled — 只选启用的
/// 2. 模态匹配 — 任务所需模态全在模型模态里
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
/// 4. 智力达标intelligence >= min_intelligence
/// 5. 成本可控 — max_cost 设定时 cost_tier <= max_cost
/// 6. 窗口够大 — context_window >= estimated_context
/// 7. max_by_key 选最优:权重优先,同权重选便宜(cost_tier 小)
/// 4. 窗口够大context_window >= estimated_context
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
///
/// 第 7 步取反说明:CostTier derive Ord(Free<Low<Medium<High),
/// "选便宜"=选小 Ord 值,但 max_by_key 默认选大
/// 用 `std::cmp::Reverse(m.cost_tier)` 包装让小值变"大":
/// Free(Reverse 值大)胜过 High(Reverse 值小)。比 `-(as i32)` 取反更 idiomatic
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回
pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> {
pool.iter()
.filter(|m| m.enabled) // 1. 只选启用的
.filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配
.filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配
.filter(|m| m.intelligence >= req.min_intelligence) // 4. 智力达标
.filter(|m| req.max_cost.map_or(true, |max| m.cost_tier <= max)) // 5. 成本可控
.filter(|m| m.context_window >= req.estimated_context) // 6. 窗口够大
.max_by_key(|m| (m.weight, Reverse(m.cost_tier))) // 7. 权重优先,同权重选便宜
.filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大
.max_by_key(|m| m.weight) // 5. 纯 weight 主导
}
}
@@ -105,8 +102,6 @@ mod tests {
TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: false,
min_intelligence: IntelligenceTier::Lite,
max_cost: None,
estimated_context: 0,
}
}
@@ -163,6 +158,63 @@ mod tests {
assert!(ModelRouter::select(&r, &pool).is_none());
}
#[test]
fn modality_subset_multimodal_required_filters_text_only() {
// 多模态子集匹配:任务需 [Text, Vision](两个模态都得支持),
// 模型仅 [Text](缺 Vision)→ 步骤 2 `.all(|r| m.modalities.contains(r))` 不成立,滤掉。
// 区别于 modality_mismatch_filtered(单模态 Vision):本测覆盖「任务需多模态全子集」路径。
let pool = vec![ModelConfig {
modalities: vec![Modality::Text],
..model("text-only")
}];
let r = TaskRequirements {
modalities: vec![Modality::Text, Modality::Vision],
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
// 对照:模型补全 Vision 后通过(Text+Vision ⊇ 任务需求 Text+Vision)
let pool_ok = vec![ModelConfig {
modalities: vec![Modality::Text, Modality::Vision],
..model("vision-capable")
}];
assert_eq!(
ModelRouter::select(&r, &pool_ok).unwrap().model_id,
"vision-capable"
);
}
#[test]
fn enabled_false_excluded_from_routing() {
// enabled=false 不参与路由:混池里禁用候选 weight 更高(100),
// 但应被步骤 1 `.filter(|m| m.enabled)` 滤掉,只选 enabled=true 的低 weight 候选。
// 区别于 all_disabled_returns_none(全禁用返 None):本测覆盖「部分禁用」混池场景。
let pool = vec![
ModelConfig {
enabled: false,
weight: 100, // 若未被 enabled 过滤,weight 100 会胜
..model("disabled-heavy")
},
ModelConfig {
enabled: true,
weight: 30,
..model("enabled-light")
},
];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"enabled-light"
);
}
#[test]
fn empty_candidate_pool_returns_none() {
// 空候选池 → None(与 select_model_id_empty_pool_returns_none 对应,
// 本测覆盖 select() 关联函数本身而非 helper;语义等价但断言点不同)。
let pool: Vec<ModelConfig> = vec![];
assert!(ModelRouter::select(&req(), &pool).is_none());
}
// ── 步骤 3:能力匹配(ToolUse) ──
#[test]
@@ -196,53 +248,7 @@ mod tests {
);
}
// ── 步骤 4:智力达标 ──
#[test]
fn intelligence_below_min_filtered() {
// 任务最低 Plus,池里模型 Standard
let pool = vec![model("standard")];
let r = TaskRequirements {
min_intelligence: IntelligenceTier::Plus,
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
// ── 步骤 5:成本可控 ──
#[test]
fn max_cost_filters_expensive() {
// 任务上限 Low,池里模型 High
let pool = vec![ModelConfig {
cost_tier: CostTier::High,
..model("expensive")
}];
let r = TaskRequirements {
max_cost: Some(CostTier::Low),
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
#[test]
fn max_cost_none_allows_any() {
// 任务不设上限,池里模型 High 仍入选
let pool = vec![ModelConfig {
cost_tier: CostTier::High,
..model("expensive")
}];
let r = TaskRequirements {
max_cost: None,
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"expensive"
);
}
// ── 步骤 6:窗口够大 ──
// ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
#[test]
fn context_window_insufficient() {
@@ -255,7 +261,7 @@ mod tests {
assert!(ModelRouter::select(&r, &pool).is_none());
}
// ── 步骤 7:max_by_key (weight, Reverse(cost)) ──
// ── 步骤 5:max_by_key (weight) ──
#[test]
fn single_match_returns_it() {
@@ -286,8 +292,9 @@ mod tests {
}
#[test]
fn same_weight_picks_cheaper_cost_tier() {
// 同 weight 70,cost Free 胜 cost High(Reverse 让 Free 的 Ord 值变大)
fn same_weight_picks_first_match() {
// 同 weight 70,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key
// 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。
let pool = vec![
ModelConfig {
weight: 70,
@@ -296,28 +303,29 @@ mod tests {
},
ModelConfig {
weight: 70,
cost_tier: CostTier::Free,
..model("free")
cost_tier: CostTier::Low,
..model("low-cost")
},
];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"free"
"low-cost"
);
}
#[test]
fn all_dimensions_match_picks_best() {
// 3+ 候选各维度参差,验证 7 步全过 + max_by_key 选最优组合
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
//
// 候选:
// a: weight 60, cost Medium → 通过全部过滤,key=(60, Reverse(Medium))
// b: weight 80, cost High → 通过,key=(80, Reverse(High)) — weight 最高,胜
// c: weight 80, cost Free → 通过,key=(80, Reverse(Free)) — 同 weight 80,Free 比 High 便宜,胜过 b
// d: weight 90, cost Low, 但 intelligence Lite(< min Standard) → 步骤 4 滤掉
// a: weight 60 → 通过全部过滤,key=60
// b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
// c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
// d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
// e: enabled=false → 步骤 1 滤掉
//
// 预期:c 胜(weight 80 最高档中,Free 最便宜)
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
let pool = vec![
ModelConfig {
weight: 60,
@@ -333,14 +341,14 @@ mod tests {
},
ModelConfig {
weight: 80,
cost_tier: CostTier::Free,
cost_tier: CostTier::Low,
intelligence: IntelligenceTier::Plus,
..model("c")
},
ModelConfig {
weight: 90,
cost_tier: CostTier::Low,
intelligence: IntelligenceTier::Lite, // 低于 min,滤掉
intelligence: IntelligenceTier::Lite,
..model("d")
},
ModelConfig {
@@ -352,10 +360,8 @@ mod tests {
let r = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: true,
min_intelligence: IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0,
};
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "c");
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
}
}