重构: 巨函数拆分 + 清理历史标记注释 + custom_prompt/停止按钮/tunnel 改进
This commit is contained in:
@@ -137,11 +137,11 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
MessageRole::User => {
|
||||
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
|
||||
// F-260614-05 Phase 2a: 多模态 user 消息 → content blocks 数组(text/image)。
|
||||
// 多模态 user 消息 → content blocks 数组(text/image)。
|
||||
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
|
||||
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
|
||||
// Anthropic 协议要求 image 必须内嵌 base64(不接受 URL 直传)。
|
||||
// 现状:前端(Phase2b)只产 base64 模式图片片,url 模式当前不可达。
|
||||
// 现状:前端只产 base64 模式图片片,url 模式当前不可达。
|
||||
// 未来若加 url 图片输入,必须在 commands 层补 url→base64 预拉
|
||||
//(provider 不发额外 HTTP),否则下方兜底会发空 data 致 Anthropic 400。
|
||||
// 纯文本消息(无图)保持原字符串简写,与现有端点零回归。
|
||||
@@ -149,36 +149,7 @@ impl AnthropicCompatProvider {
|
||||
let blocks: Vec<serde_json::Value> = m
|
||||
.flattened_parts()
|
||||
.into_iter()
|
||||
.map(|p| match p {
|
||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}),
|
||||
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
|
||||
// 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 留痕但不阻塞(避免静默吞数据)。
|
||||
if url.is_some() {
|
||||
warn!(
|
||||
url = ?url,
|
||||
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 data(commands 层未补 url→base64 预拉)"
|
||||
);
|
||||
}
|
||||
String::new()
|
||||
});
|
||||
serde_json::json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mt,
|
||||
"data": data,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.map(Self::content_part_to_block)
|
||||
.collect();
|
||||
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
||||
} else {
|
||||
@@ -193,7 +164,7 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
if let Some(calls) = &m.tool_calls {
|
||||
for tc in calls {
|
||||
// B-260618-25: arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
|
||||
// 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)
|
||||
@@ -215,13 +186,13 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
|
||||
|
||||
// B-260619-03: 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
|
||||
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
|
||||
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
|
||||
// tool_result 转成 user 后紧跟 push 新 user → 连续两 user。合并成一条 user 含
|
||||
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
|
||||
Self::merge_consecutive_users(&mut messages);
|
||||
|
||||
// B-260626-01: 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
|
||||
// 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
|
||||
// 上游绕过 ContextManager::sanitize_messages 的调用方(标题生成 / 知识注入 / 工作流 AI
|
||||
// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、
|
||||
// 续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉,直接发触发 precheck
|
||||
@@ -237,7 +208,7 @@ impl AnthropicCompatProvider {
|
||||
.map(|d| AnthropicToolDef {
|
||||
name: d.function.name,
|
||||
description: Some(d.function.description).filter(|s| !s.is_empty()),
|
||||
// B-260618-25: input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
|
||||
// input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
|
||||
// 防 Anthropic 拒非法 tool schema(当前全走 object_schema 恒 object,纯防御)。
|
||||
input_schema: if d.function.parameters.is_object() {
|
||||
d.function.parameters
|
||||
@@ -260,6 +231,43 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个 ContentPart → Anthropic content block(text/image)。
|
||||
/// - Text 片 → {type:text, text}
|
||||
/// - Image 片 → {type:image, source:{type:base64, media_type, data}}
|
||||
/// base64 内嵌;url 模式当前不可达,兜底发空 data + warn(保留原行为)。
|
||||
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
|
||||
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
|
||||
match p {
|
||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}),
|
||||
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
|
||||
let mt = media_type.unwrap_or_else(|| "image/png".into());
|
||||
let data = base64.unwrap_or_else(|| {
|
||||
// 完整性兜底:当前 url 模式不可达(前端只产 base64 图片片)。
|
||||
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
|
||||
// 此处会发空 data 致 Anthropic 400,warn 留痕但不阻塞(避免静默吞数据)。
|
||||
if url.is_some() {
|
||||
warn!(
|
||||
url = ?url,
|
||||
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 data(commands 层未补 url→base64 预拉)"
|
||||
);
|
||||
}
|
||||
String::new()
|
||||
});
|
||||
serde_json::json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mt,
|
||||
"data": data,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将累积的 tool_result 块作为一条 user 消息 flush 进消息列表
|
||||
fn flush_tool_results(
|
||||
messages: &mut Vec<serde_json::Value>,
|
||||
@@ -272,7 +280,7 @@ impl AnthropicCompatProvider {
|
||||
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
||||
}
|
||||
|
||||
/// B-260619-03: 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
|
||||
/// 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
|
||||
/// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。
|
||||
/// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发,
|
||||
/// 前一轮以 tool_result 结尾)。合并成一条 user 含 [tool_result..., text] blocks,合法。
|
||||
@@ -305,7 +313,7 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-260626-01: 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
|
||||
/// 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
|
||||
///
|
||||
/// 上游绕过 `ContextManager::sanitize_messages` 的调用方(标题生成 / 知识注入 / 工作流 AI
|
||||
/// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、续发
|
||||
@@ -342,7 +350,7 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
|
||||
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
|
||||
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
|
||||
/// 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
|
||||
fn summarize_messages(messages: &[serde_json::Value]) -> String {
|
||||
let lines: Vec<String> = messages
|
||||
.iter()
|
||||
@@ -385,7 +393,7 @@ impl AnthropicCompatProvider {
|
||||
format!("{} msgs: {}", lines.len(), lines.join(" | "))
|
||||
}
|
||||
|
||||
/// B-260618-27: 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
|
||||
/// 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
|
||||
/// 覆盖:首条非 user / 连续同 role / tool_use input 非 object / 空 content / orphan tool_result
|
||||
/// (tool_use_id 无前置 tool_use,常见于裁剪/过滤后 assistant 被删但 tool_result 留)。
|
||||
fn precheck_messages(messages: &[serde_json::Value]) -> Result<(), String> {
|
||||
@@ -415,25 +423,7 @@ impl AnthropicCompatProvider {
|
||||
return Err(format!("#{} user content 空数组", i));
|
||||
}
|
||||
for b in blocks {
|
||||
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
||||
"tool_use" => {
|
||||
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
tool_use_ids.push(id);
|
||||
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
|
||||
return Err(format!("#{} tool_use input 非 object", i));
|
||||
}
|
||||
}
|
||||
"tool_result" => {
|
||||
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
|
||||
return Err(format!(
|
||||
"#{} orphan tool_result(tid={} 无前置 tool_use)",
|
||||
i, tid
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Self::check_block(b, i, &mut tool_use_ids)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -442,6 +432,36 @@ impl AnthropicCompatProvider {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验单个 content block(precheck_messages 内部用)。
|
||||
/// - tool_use: 收集 id,校验 input 为 object
|
||||
/// - tool_result: 校验 tool_use_id 有前置 tool_use(非 orphan)
|
||||
fn check_block<'a>(
|
||||
b: &'a serde_json::Value,
|
||||
idx: usize,
|
||||
tool_use_ids: &mut Vec<&'a str>,
|
||||
) -> Result<(), String> {
|
||||
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
||||
"tool_use" => {
|
||||
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
tool_use_ids.push(id);
|
||||
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
|
||||
return Err(format!("#{} tool_use input 非 object", idx));
|
||||
}
|
||||
}
|
||||
"tool_result" => {
|
||||
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
|
||||
return Err(format!(
|
||||
"#{} orphan tool_result(tid={} 无前置 tool_use)",
|
||||
idx, tid
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 统一鉴权头:x-api-key + anthropic-version
|
||||
fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
rb.header("x-api-key", &self.api_key)
|
||||
@@ -457,7 +477,7 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
req.stream = false;
|
||||
let body = self.convert_request(req);
|
||||
|
||||
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||
// 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||
if let Err(reason) = Self::precheck_messages(&body.messages) {
|
||||
let summary = Self::summarize_messages(&body.messages);
|
||||
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
|
||||
@@ -466,9 +486,8 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
|
||||
debug!(model = %body.model, "Anthropic 同步调用");
|
||||
|
||||
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
||||
// 同时补 FR-R4 遗漏: Anthropic 同步路径此前无单请求 timeout(建连后挂起会无限 hang),
|
||||
// 此处加 60s timeout,与 OpenAI 路径对齐。
|
||||
// 指数退避重试: 包裹 send + 状态码判定。
|
||||
// 同时补单请求 timeout(Anthropic 同步路径无 timeout 会 hang),此处加 60s,与 OpenAI 路径对齐。
|
||||
let label = format!("Anthropic[{}]", body.model);
|
||||
retry_with_backoff(&label, move |_| {
|
||||
let client = self.client.clone();
|
||||
@@ -488,7 +507,7 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
let resp = match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// B-260618-26: 记 reqwest 错误源因链(is_*/source)。原仅 Display
|
||||
// 记 reqwest 错误源因链(is_*/source)。原仅 Display
|
||||
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
|
||||
tracing::error!(
|
||||
is_timeout = e.is_timeout(),
|
||||
@@ -580,7 +599,7 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
req.stream = true;
|
||||
let body = self.convert_request(req);
|
||||
|
||||
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||
// 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||
if let Err(reason) = Self::precheck_messages(&body.messages) {
|
||||
let summary = Self::summarize_messages(&body.messages);
|
||||
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
|
||||
@@ -589,9 +608,9 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
|
||||
debug!(model = %body.model, "Anthropic 流式调用");
|
||||
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
|
||||
// send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
|
||||
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
|
||||
// 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout
|
||||
// 流式长生成任务会被误砍。改用 tokio::time::timeout
|
||||
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
|
||||
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
|
||||
let send_future = self
|
||||
@@ -638,9 +657,9 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
|
||||
}
|
||||
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
|
||||
// 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
|
||||
let mut usage_accum: Option<TokenUsage> = None;
|
||||
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
|
||||
// MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
|
||||
let messages_summary = Self::summarize_messages(&body.messages);
|
||||
|
||||
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
|
||||
@@ -870,7 +889,7 @@ mod tests {
|
||||
assert!(acc.is_none());
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
|
||||
// ---------- 多模态 convert_request ----------
|
||||
|
||||
/// 含图 user 消息 → content blocks(text + image source.base64)
|
||||
#[test]
|
||||
@@ -931,7 +950,7 @@ mod tests {
|
||||
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
|
||||
}
|
||||
|
||||
// ---------- B-260626-01: ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
|
||||
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
|
||||
|
||||
/// 辅助:构造 assistant(tool_use) 消息
|
||||
fn msg_assistant_with_tool_use(text: &str, tool_id: &str, tool_name: &str) -> ChatMessage {
|
||||
@@ -941,7 +960,7 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// B-260626-01: 精确复现线上 bug——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
|
||||
/// 精确复现线上场景——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
|
||||
/// 补一条 user 占位后:首条 user、tool_use/tool_result 配对完整保留、precheck 通过。
|
||||
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
|
||||
#[test]
|
||||
@@ -981,7 +1000,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// B-260626-01: 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
|
||||
/// 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_plain_assistant() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
@@ -1007,7 +1026,7 @@ mod tests {
|
||||
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
|
||||
}
|
||||
|
||||
/// B-260626-01: 正常序列(user 开头)不补占位——零回归验证。
|
||||
/// 正常序列(user 开头)不补占位——零回归验证。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
@@ -1033,7 +1052,7 @@ mod tests {
|
||||
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
|
||||
}
|
||||
|
||||
/// B-260626-01: 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
|
||||
/// 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
|
||||
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_three_round_chain() {
|
||||
@@ -1070,7 +1089,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// B-260626-01: 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
|
||||
/// 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
|
||||
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
|
||||
|
||||
@@ -20,7 +20,7 @@ fn cfg(max_tokens: u32) -> ContextConfig {
|
||||
|
||||
#[test]
|
||||
fn estimate_message_counts_parts_tokens() {
|
||||
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
|
||||
// 多模态回归:含图消息的大段 base64 必须计入 token 预算,
|
||||
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
|
||||
let est = TokenEstimator::default();
|
||||
|
||||
@@ -263,7 +263,7 @@ fn system_over_budget_trims_to_protect_zone() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-15 阶段1 辅助方法单测 ──
|
||||
// ── 辅助方法单测 ──
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_marks_compressed_and_returns_refs() {
|
||||
@@ -430,7 +430,7 @@ fn build_eviction_units_keeps_triplet_atomic_public() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ──
|
||||
// ── 消息级溯源:last_assistant/last_user message_id ──
|
||||
|
||||
#[test]
|
||||
fn last_assistant_message_id_returns_latest() {
|
||||
|
||||
@@ -165,7 +165,7 @@ impl ContextManager {
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
@@ -191,7 +191,7 @@ impl ContextManager {
|
||||
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
|
||||
self.history_tokens, available, PROTECT_COUNT
|
||||
);
|
||||
// B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
|
||||
// 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
|
||||
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
|
||||
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
|
||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||
@@ -214,7 +214,7 @@ impl ContextManager {
|
||||
trim_end, removed
|
||||
);
|
||||
let sanitized = Self::sanitize_messages(msgs);
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
(
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
true,
|
||||
@@ -350,7 +350,7 @@ impl ContextManager {
|
||||
self.messages.iter().map(|t| &t.message)
|
||||
}
|
||||
|
||||
/// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。
|
||||
/// 消息级溯源:取末条指定 role 消息的 id(ULID)。
|
||||
///
|
||||
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
|
||||
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
|
||||
@@ -417,7 +417,7 @@ impl ContextManager {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token)
|
||||
/// 可变消息切片(供标记 status="compressed"/"archived_segment" + 调整 token)
|
||||
///
|
||||
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
|
||||
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
|
||||
@@ -427,7 +427,7 @@ impl ContextManager {
|
||||
|
||||
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
|
||||
///
|
||||
/// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
|
||||
/// 供压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
|
||||
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
|
||||
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
@@ -449,7 +449,7 @@ impl ContextManager {
|
||||
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
|
||||
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
|
||||
///
|
||||
/// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
|
||||
/// 公开会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
|
||||
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
|
||||
let mut units = Vec::new();
|
||||
let mut i = 0usize;
|
||||
@@ -488,7 +488,7 @@ impl ContextManager {
|
||||
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
|
||||
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
|
||||
let end = protect_start.min(self.messages.len());
|
||||
// BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
|
||||
// 排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
|
||||
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
|
||||
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
|
||||
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
|
||||
@@ -498,7 +498,7 @@ impl ContextManager {
|
||||
}
|
||||
|
||||
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
|
||||
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
|
||||
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供喂 LLM 摘要)。
|
||||
///
|
||||
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
|
||||
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
|
||||
|
||||
@@ -151,7 +151,7 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
sanitized
|
||||
};
|
||||
|
||||
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||
// step 3.5(占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||
//
|
||||
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
|
||||
@@ -227,7 +227,7 @@ pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
filtered
|
||||
}
|
||||
|
||||
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
|
||||
/// 发送视图出口断言(占位配对完整性):确保所有 tool_result(含审批占位)都有
|
||||
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
|
||||
///
|
||||
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
|
||||
@@ -470,7 +470,7 @@ mod tests {
|
||||
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_reverse_orphan_tool_result() {
|
||||
|
||||
@@ -47,7 +47,7 @@ impl Default for TokenEstimator {
|
||||
impl TokenEstimator {
|
||||
/// 估算单条消息的 token 数(保守估计)
|
||||
///
|
||||
/// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
|
||||
/// 多模态回归修正:`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 偏保守)。
|
||||
@@ -58,7 +58,7 @@ impl TokenEstimator {
|
||||
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 按像素非字节)。
|
||||
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
||||
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
|
||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
|
||||
if let Some(b) = base64 {
|
||||
@@ -145,7 +145,7 @@ pub enum MessageGroup {
|
||||
|
||||
/// 带有 token 缓存和分组信息的消息条目
|
||||
///
|
||||
/// 字段 `pub`:供阶段2 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
|
||||
/// 字段 `pub`:供 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
|
||||
/// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问,
|
||||
/// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。
|
||||
pub struct TrackedMessage {
|
||||
@@ -408,7 +408,7 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
*field = serde_json::Value::String(out.join("\n"));
|
||||
truncated = true;
|
||||
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
|
||||
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
|
||||
// 单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
|
||||
// 按字符数截断保留头尾,保证压缩至少生效。
|
||||
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
|
||||
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
|
||||
@@ -439,7 +439,7 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
let total = lines.len();
|
||||
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||
if total <= kept_boundary {
|
||||
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
|
||||
// 行数少但内容超大的情况(单行 50KB),行级截断无效。
|
||||
// 按字符数截断保证压缩至少生效。
|
||||
let char_count = content.chars().count();
|
||||
if char_count > TOOL_RESULT_CHAR_LIMIT {
|
||||
@@ -524,7 +524,7 @@ fn is_error_line(line: &str) -> bool {
|
||||
|
||||
/// 淘汰单元:连续消息范围 [..end) + token 总和
|
||||
///
|
||||
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。
|
||||
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(调用方读 `end` / `token_sum`)。
|
||||
pub struct EvictionUnit {
|
||||
pub end: usize,
|
||||
pub token_sum: u32,
|
||||
@@ -541,7 +541,7 @@ pub const PROTECT_COUNT: usize = 6;
|
||||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||||
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||
|
||||
/// 阶段2(path_auth 审批链重构):占位配对完整性(解 400 orphan)常量开关。
|
||||
/// 占位配对完整性(解 400 orphan)常量开关。
|
||||
///
|
||||
/// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
/// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→
|
||||
@@ -1118,7 +1118,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_single_huge_line_no_newline_compressed() {
|
||||
// BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||||
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||||
let content = "x".repeat(50_000);
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
|
||||
@@ -1130,7 +1130,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_json_huge_string_field_truncated() {
|
||||
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||||
// JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||||
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
|
||||
let large = "z".repeat(10_000);
|
||||
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
|
||||
@@ -1243,7 +1243,7 @@ mod tests {
|
||||
assert!(toks.contains(&"now".to_string()));
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
|
||||
// ── 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_new_with_marker() {
|
||||
|
||||
@@ -38,9 +38,9 @@ pub mod persona;
|
||||
pub mod plan_executor;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
// 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
|
||||
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
// 避免重写退避/分类逻辑。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
pub mod retry;
|
||||
pub mod sse_parser;
|
||||
pub mod namespace_store;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 厂商模型列表拉取 — F-01 阶段3
|
||||
//! 厂商模型列表拉取。
|
||||
//!
|
||||
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
|
||||
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 模型探测器 — F-01 阶段2
|
||||
//! 模型探测器。
|
||||
//!
|
||||
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
|
||||
//!
|
||||
@@ -100,7 +100,7 @@ mod tests {
|
||||
assert_eq!(m.model_id, "glm-4");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.modalities, vec![Modality::Text]);
|
||||
// B-260618-04:预设表不再写死 cost_tier/intelligence,由 serde default 兜底中性值
|
||||
// 预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ mod tests {
|
||||
fn heuristic_flash_keeps_neutral_tier() {
|
||||
let m = probe("unknown-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
// B-260618-04:cost/intel 一律中性,不靠名字猜
|
||||
// cost/intel 一律中性,不靠名字猜
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
@@ -261,7 +261,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn probe_preset_beats_heuristic() {
|
||||
// "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
|
||||
// "glm-4-flash" 精确命中预设表;预设/启发式档位都中性,
|
||||
// 此处仅校验 source 标注为 PresetTable
|
||||
let m = probe("glm-4-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 模型探测器 — 纯逻辑子模块(F-01 阶段2)
|
||||
//! 模型探测器 — 纯逻辑子模块。
|
||||
//!
|
||||
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
|
||||
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
|
||||
@@ -36,7 +36,7 @@ pub(super) fn presets() -> &'static [ModelConfig] {
|
||||
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
|
||||
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
|
||||
///
|
||||
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
|
||||
/// 取舍:模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
|
||||
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
|
||||
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
|
||||
///
|
||||
@@ -49,7 +49,7 @@ pub(super) 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();
|
||||
// 中性默认:不靠模型名猜档位(B-260618-04)
|
||||
// 中性默认:不靠模型名猜档位
|
||||
let cost_tier = CostTier::Medium;
|
||||
let intelligence = IntelligenceTier::Standard;
|
||||
|
||||
|
||||
@@ -220,7 +220,10 @@ mod tests {
|
||||
let p1 = ns.store("tool_a", content);
|
||||
let p2 = ns.store("tool_b", content);
|
||||
assert_eq!(ns.len(), 1, "相同内容应去重");
|
||||
assert_eq!(ns.read(&p1), ns.read(&p2));
|
||||
// read(&mut self) 返回 Option<&str> 借用 ns,两次调用须各自转 owned 避免双重可变借用
|
||||
let r1 = ns.read(&p1).map(str::to_owned);
|
||||
let r2 = ns.read(&p2).map(str::to_owned);
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -114,7 +114,7 @@ impl OpenAICompatProvider {
|
||||
crate::provider::MessageRole::Assistant => "assistant",
|
||||
crate::provider::MessageRole::Tool => "tool",
|
||||
};
|
||||
// F-260614-05 Phase 2a: 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
|
||||
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
|
||||
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
|
||||
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
|
||||
let content = if m.has_image() {
|
||||
@@ -176,7 +176,7 @@ impl OpenAICompatProvider {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
// 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
|
||||
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
|
||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||
@@ -231,7 +231,7 @@ impl OpenAICompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
/// 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
///
|
||||
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
|
||||
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
|
||||
@@ -307,8 +307,8 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 同步调用");
|
||||
|
||||
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
||||
// 单请求 60s timeout 保持不变(FR-R4),重试是额外层: 3 次 × 60s 最坏 180s,
|
||||
// 指数退避重试: 包裹 send + 状态码判定。
|
||||
// 单请求 60s timeout 保持不变,重试是额外层: 3 次 × 60s 最坏 180s,
|
||||
// 由 retry_with_backoff 内部 30s 总预算主动止损。
|
||||
let label = format!("OpenAI[{}]", openai_req.model);
|
||||
retry_with_backoff(&label, move |_| {
|
||||
@@ -386,7 +386,7 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 流式调用");
|
||||
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。
|
||||
// send 阶段需 timeout 防 hang(同 Anthropic 路径)。
|
||||
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
|
||||
let send_future = self
|
||||
.client
|
||||
@@ -414,7 +414,7 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库。
|
||||
// 原生 SSE 解析器替代 eventsource-stream 库。
|
||||
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
|
||||
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
|
||||
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
|
||||
@@ -616,7 +616,7 @@ mod tests {
|
||||
assert!(!c.finished);
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
|
||||
// ---------- 多模态 convert_request ----------
|
||||
|
||||
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写
|
||||
#[test]
|
||||
@@ -672,9 +672,9 @@ mod tests {
|
||||
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
|
||||
}
|
||||
|
||||
// ---------- B-260626-01: ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
|
||||
// ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
|
||||
|
||||
/// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
|
||||
/// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
|
||||
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
|
||||
#[test]
|
||||
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
|
||||
@@ -699,7 +699,7 @@ mod tests {
|
||||
assert_eq!(out.messages[2].role.as_str(), "user");
|
||||
}
|
||||
|
||||
/// B-260626-01: 正常序列(user 开头)不补占位——零回归。
|
||||
/// 正常序列(user 开头)不补占位——零回归。
|
||||
#[test]
|
||||
fn openai_ensure_leading_user_normal_unchanged() {
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::time::Duration;
|
||||
use rand::Rng;
|
||||
use tracing::warn;
|
||||
|
||||
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
|
||||
/// 最多尝试次数(含初次)。3 次 = 初次 + 2 次重试。
|
||||
///
|
||||
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
|
||||
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
|
||||
@@ -71,8 +71,8 @@ pub fn is_status_retryable(status: u16) -> bool {
|
||||
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
///
|
||||
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
|
||||
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
|
||||
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
pub fn backoff_delay(attempt: u32) -> Duration {
|
||||
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
|
||||
// ±50% jitter,相对 base 时长的浮动比例
|
||||
|
||||
+14
-17
@@ -1,26 +1,23 @@
|
||||
//! 模型路由器 — F-01 阶段4
|
||||
//! 模型路由器。
|
||||
//!
|
||||
//! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。
|
||||
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
|
||||
//! 不接调用点(那是调用方:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
|
||||
//! df-ideas / df-nodes ai_node.rs)。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §6.1。
|
||||
//!
|
||||
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
|
||||
//! ModelRouter 为单元结构,select 是无状态关联函数。
|
||||
|
||||
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
|
||||
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
|
||||
// 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
|
||||
// 直接 import 维度枚举构造 TaskRequirements,re-export 避免调用点
|
||||
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
|
||||
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
|
||||
// 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API
|
||||
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
|
||||
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
|
||||
|
||||
/// 任务对模型的需求(3 维度)。
|
||||
///
|
||||
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
|
||||
/// 由调用点构造,描述本次调用需要什么模态/能力/上下文,
|
||||
/// 交 ModelRouter::select 在候选池中选最优模型。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
|
||||
/// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。
|
||||
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
|
||||
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
|
||||
/// 保留供未来出现真实判别源时再接回。
|
||||
@@ -49,7 +46,7 @@ impl ModelRouter {
|
||||
/// 4. 窗口够大 — context_window >= estimated_context
|
||||
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
|
||||
/// 路由已解耦:原「智力达标」/「成本可控」两步删除,
|
||||
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
|
||||
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
|
||||
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
|
||||
@@ -63,7 +60,7 @@ impl ModelRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 阶段5 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
|
||||
/// 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
|
||||
///
|
||||
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
|
||||
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
|
||||
@@ -106,7 +103,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 阶段5 select_model_id helper ──
|
||||
// ── select_model_id helper ──
|
||||
|
||||
#[test]
|
||||
fn select_model_id_empty_pool_returns_none() {
|
||||
@@ -248,7 +245,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
|
||||
// ── 步骤 4(原智力/成本过滤已解耦):窗口够大 ──
|
||||
|
||||
#[test]
|
||||
fn context_window_insufficient() {
|
||||
@@ -316,13 +313,13 @@ mod tests {
|
||||
#[test]
|
||||
fn all_dimensions_match_picks_best() {
|
||||
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
|
||||
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
|
||||
// (智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
|
||||
//
|
||||
// 候选:
|
||||
// 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 最高,胜
|
||||
// d: weight 90 → 通过(intelligence 不参与过滤),key=90 — weight 最高,胜
|
||||
// e: enabled=false → 步骤 1 滤掉
|
||||
//
|
||||
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
|
||||
//!
|
||||
//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
|
||||
//! eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
|
||||
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
|
||||
//!
|
||||
//! 根因分析:
|
||||
|
||||
Reference in New Issue
Block a user