补齐Anthropic推理捕获

传递capture_reasoning并解析thinking内容
增加非流式与流式推理回归测试
This commit is contained in:
2026-09-14 20:50:20 +08:00
parent 59289199ed
commit 6ba4b46b4f
2 changed files with 107 additions and 9 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
2. `OpenAiChat``OpenAiResponses``Anthropic` 三类 API kind 都支持 JSON 请求、非流式响应和 SSE 流式响应;默认 API kind 仍为 `OpenAiResponses`
3. 三类协议都使用统一的 `function_tools` / `tool_choice` 输入和 `LlmRunResponse.tool_calls` 输出。Anthropic 请求使用顶层 `tools[].input_schema` 与对象形态 `tool_choice`Anthropic URL 默认在 base URL 后拼 `/v1/messages`,如果 base URL 已以 `/v1` 结尾则只拼 `/messages`
4. Anthropic 当前仍不支持 `web_search`、图片内容和纯 system 消息;至少需要一条非 system 文本消息。角色动画、图片、视频、资产轮询仍留在其他平台适配和业务模块任务里。
5. 流式 `on_delta` 发送正文增量、可选的独立 reasoning 增量与完成原因;reasoning 只有在 `LlmRunRequest.capture_reasoning=true` 时才累计,默认关闭。Responses 的 reasoning summary 按 `summary_index` 分段累计,单段 `.done` 只校正对应段,不覆盖其它段。工具调用增量在 crate 内按 slot 聚合,完整调用只从最终 `LlmRunResponse.tool_calls` 读取。reasoning 不进入 `delta_text``accumulated_text`、正式 assistant message 或工具参数;上下文管理、后台执行和业务状态不写回本 crate。
5. 流式 `on_delta` 发送正文增量、可选的独立 reasoning 增量与完成原因;reasoning 只有在 `LlmRunRequest.capture_reasoning=true` 时才累计,默认关闭。Responses 的 reasoning summary 按 `summary_index` 分段累计,单段 `.done` 只校正对应段,不覆盖其它段Anthropic 的 `thinking_delta` 同样进入独立 reasoning 通道。工具调用增量在 crate 内按 slot 聚合,完整调用只从最终 `LlmRunResponse.tool_calls` 读取。reasoning 不进入 `delta_text``accumulated_text`、正式 assistant message 或工具参数;上下文管理、后台执行和业务状态不写回本 crate。
6. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回本 crate。
7. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API。
8. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现。
+106 -8
View File
@@ -667,6 +667,8 @@ struct AnthropicContentBlock {
block_type: Option<String>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
thinking: Option<String>,
// tool_use block 字段:id 与 name 标识调用,input 是已解析的 JSON object。
#[serde(default)]
id: Option<String>,
@@ -3335,7 +3337,9 @@ fn parse_text_response(
capture_reasoning,
raw_text,
),
LlmApiKind::Anthropic => parse_anthropic_response(provider, fallback_model, raw_text),
LlmApiKind::Anthropic => {
parse_anthropic_response(provider, fallback_model, capture_reasoning, raw_text)
}
}
}
@@ -3470,6 +3474,7 @@ fn parse_responses_response_with_capture(
fn parse_anthropic_response(
provider: LlmProvider,
fallback_model: &str,
capture_reasoning: bool,
raw_text: &str,
) -> Result<LlmRunResponse, LlmError> {
let parsed: AnthropicResponseEnvelope = serde_json::from_str(raw_text).map_err(|error| {
@@ -3494,9 +3499,16 @@ fn parse_anthropic_response(
Ok(LlmRunResponse {
provider,
model: parsed.model.unwrap_or_else(|| fallback_model.to_string()),
model: parsed
.model
.clone()
.unwrap_or_else(|| fallback_model.to_string()),
text: content,
reasoning: String::new(),
reasoning: if capture_reasoning {
extract_anthropic_reasoning(&parsed).unwrap_or_default()
} else {
String::new()
},
finish_reason: parsed.stop_reason,
response_id: parsed.id,
usage: parsed.usage.map(map_anthropic_usage),
@@ -3606,6 +3618,16 @@ fn extract_anthropic_text(parsed: &AnthropicResponseEnvelope) -> Option<String>
if text.is_empty() { None } else { Some(text) }
}
fn extract_anthropic_reasoning(parsed: &AnthropicResponseEnvelope) -> Option<String> {
let mut reasoning = String::new();
for block in &parsed.content {
if block.block_type.as_deref() == Some("thinking") {
append_reasoning(&mut reasoning, block.thinking.as_deref());
}
}
(!reasoning.is_empty()).then_some(reasoning)
}
fn extract_message_text(choice: &ChatCompletionsChoice) -> Option<String> {
choice
.message
@@ -4245,6 +4267,16 @@ fn parse_anthropic_sse_event(data: &str) -> Result<Option<ParsedStreamEvent>, Ll
}));
}
if delta_type == "thinking_delta" {
return Ok(Some(ParsedStreamEvent {
reasoning_delta: delta
.and_then(|value| value.get("thinking"))
.and_then(serde_json::Value::as_str)
.map(str::to_string),
..Default::default()
}));
}
if delta_type != "text_delta" {
return Ok(None);
}
@@ -5095,8 +5127,9 @@ mod tests {
]
}"#;
let response = parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", raw)
.expect("tool-only response should parse");
let response =
parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw)
.expect("tool-only response should parse");
assert_eq!(response.text, "");
assert!(response.reasoning.is_empty());
@@ -5123,19 +5156,44 @@ mod tests {
]
}"#;
let response = parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", raw)
.expect("mixed response should parse");
let response =
parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw)
.expect("mixed response should parse");
assert_eq!(response.text, "我来帮你查询。");
assert_eq!(response.tool_calls.len(), 1);
assert_eq!(response.tool_calls[0].arguments, "{}");
}
#[test]
fn anthropic_response_captures_thinking_only_when_enabled() {
let raw = r#"{
"id": "msg_thinking",
"model": "model-a",
"content": [
{ "type": "thinking", "thinking": "先分析需求。" },
{ "type": "text", "text": "最终答案" }
],
"stop_reason": "end_turn"
}"#;
let captured =
parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", true, raw)
.expect("Anthropic thinking should parse");
assert_eq!(captured.text, "最终答案");
assert_eq!(captured.reasoning, "先分析需求。");
let hidden =
parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw)
.expect("Anthropic response should parse without capture");
assert!(hidden.reasoning.is_empty());
}
#[test]
fn anthropic_response_without_text_or_tool_calls_is_empty() {
let raw = r#"{ "id": "msg_3", "model": "model-a", "content": [] }"#;
let error = parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", raw)
let error = parse_anthropic_response(LlmProvider::OpenAiCompatible, "fallback", false, raw)
.expect_err("empty content should fail");
assert_eq!(error, LlmError::EmptyResponse);
@@ -7297,6 +7355,46 @@ mod tests {
);
}
#[tokio::test]
async fn stream_run_captures_anthropic_thinking_separately_when_enabled() {
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "text/event-stream; charset=utf-8",
body: concat!(
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"先分析。"}}"#, "\n\n",
r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"答案"}}"#, "\n\n",
r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}"#, "\n\n",
r#"data: {"type":"message_stop"}"#, "\n\n"
)
.to_string(),
extra_headers: Vec::new(),
}]);
let mut updates = Vec::new();
let response = build_test_client(server_url, 0)
.stream_run(
LlmRunRequest::single_turn("系统", "用户")
.with_anthropic()
.with_reasoning_capture(true),
|delta| updates.push((delta.delta_text.clone(), delta.reasoning_delta.clone())),
)
.await
.expect("Anthropic thinking stream should parse");
assert_eq!(response.text, "答案");
assert_eq!(response.reasoning, "先分析。");
assert!(
updates
.iter()
.any(|(text, reasoning)| text.is_empty() && reasoning == "先分析。")
);
assert!(
updates
.iter()
.any(|(text, reasoning)| text == "答案" && reasoning.is_empty())
);
}
#[tokio::test]
async fn stream_run_keeps_multiple_responses_reasoning_summary_parts() {
let server_url = spawn_mock_server(vec![MockResponse {