新增: anthropic 思维链解析入库与思考卡展示

- 流式与非流式 thinking 块接入 reasoning 通道
- 签名与密文块合规静默,请求侧零改动
- 历史映射透传 reasoning_content
- 思考卡数据源真推理文本优先
This commit is contained in:
lxy
2026-10-01 19:35:23 +08:00
parent bf0db10ef7
commit 1e6db6f310
5 changed files with 69 additions and 6 deletions
+38 -2
View File
@@ -636,8 +636,9 @@ impl LlmProvider for AnthropicCompatProvider {
return AttemptOutcome::Fatal(format!("响应解析失败: {}", e));
}
};
// content 块中拼接 text,收集 tool_use
// content 块中拼接 text,收集 tool_use,累积 thinking
let mut text = String::new();
let mut reasoning = String::new();
let mut tool_calls: Vec<ToolCall> = Vec::new();
// CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。
// 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。
@@ -649,6 +650,15 @@ impl LlmProvider for AnthropicCompatProvider {
text.push_str(&t);
}
}
"thinking" => {
// thinking 块正文回填 reasoning_content 通道(与 DeepSeek 同路入库展示)。
// 仅入库与展示,绝不回传 anthropic 历史(丢弃 signature 的伪造回传必 400)。
if let Some(t) = block.thinking {
reasoning.push_str(&t);
}
}
// redacted_thinking 为端点侧密文块,不可解析也不可伪造回传,合规丢弃不告警
"redacted_thinking" => {}
"tool_use" => {
// CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。
// 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用
@@ -688,7 +698,8 @@ impl LlmProvider for AnthropicCompatProvider {
model: resp.model,
usage,
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
reasoning_content: None,
// 无 thinking 块时保持 None(语义与 DeepSeek 路径一致,空串不落库)
reasoning_content: if reasoning.is_empty() { None } else { Some(reasoning) },
})
}
})
@@ -963,6 +974,31 @@ mod tests {
assert!(!c.finished);
}
/// thinking_delta → reasoning_content 增量(不混入正文 delta);signature_delta 安静忽略。
/// 推理正文走 reasoning_content 通道与 DeepSeek 同路入库展示。
#[test]
fn anthropic_thinking_delta_accumulates_reasoning() {
let mut acc: Option<TokenUsage> = None;
let think1 = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"先查"}}"#;
let c1 = apply_anthropic_event(think1, &mut acc);
assert_eq!(c1.reasoning_content.as_deref(), Some("先查"), "thinking_delta 应产出 reasoning_content");
assert_eq!(c1.delta, "", "思考增量不得混入正文 delta");
assert!(!c1.finished);
let think2 = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"用户意图"}}"#;
let c2 = apply_anthropic_event(think2, &mut acc);
assert_eq!(c2.reasoning_content.as_deref(), Some("用户意图"));
// signature_delta:thinking 块签名字段,本侧无处消费,安静跳过(空 chunk 不告警)
let sig = r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-abc"}}"#;
let c3 = apply_anthropic_event(sig, &mut acc);
assert!(c3.reasoning_content.is_none());
assert!(c3.delta.is_empty());
assert!(!c3.finished);
// 思考块之后正文照常
let c4 = apply_anthropic_event(&text_delta("答案"), &mut acc);
assert_eq!(c4.delta, "答案");
assert!(c4.reasoning_content.is_none());
}
/// error 事件 → error=Some + finished=false避免残缺响应被当正常完成入库,
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
#[test]