From 6ba4b46b4ff8d435a7b717e237e67f8aee068d6d Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 14 Sep 2026 20:50:20 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90Anthropic=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E6=8D=95=E8=8E=B7=20=E4=BC=A0=E9=80=92capture=5Freasoning?= =?UTF-8?q?=E5=B9=B6=E8=A7=A3=E6=9E=90thinking=E5=86=85=E5=AE=B9=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=9D=9E=E6=B5=81=E5=BC=8F=E4=B8=8E=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=8E=A8=E7=90=86=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-rs/crates/platform-llm/README.md | 2 +- server-rs/crates/platform-llm/src/lib.rs | 114 +++++++++++++++++++++-- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/server-rs/crates/platform-llm/README.md b/server-rs/crates/platform-llm/README.md index dc6f5a0b7..31938a225 100644 --- a/server-rs/crates/platform-llm/README.md +++ b/server-rs/crates/platform-llm/README.md @@ -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` / 业务模块任务里另行实现。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index b3a6d00a3..13576dfa0 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -667,6 +667,8 @@ struct AnthropicContentBlock { block_type: Option, #[serde(default)] text: Option, + #[serde(default)] + thinking: Option, // tool_use block 字段:id 与 name 标识调用,input 是已解析的 JSON object。 #[serde(default)] id: Option, @@ -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 { 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 if text.is_empty() { None } else { Some(text) } } +fn extract_anthropic_reasoning(parsed: &AnthropicResponseEnvelope) -> Option { + 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 { choice .message @@ -4245,6 +4267,16 @@ fn parse_anthropic_sse_event(data: &str) -> Result, 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 {