补齐Anthropic推理捕获
传递capture_reasoning并解析thinking内容 增加非流式与流式推理回归测试
This commit is contained in:
@@ -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` / 业务模块任务里另行实现。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user