优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 400,warn 留痕但不阻塞(避免静默吞数据)。
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 Client(OpenAI/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` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user