fix(providers): echo reasoning_content back for thinking-mode APIs

DeepSeek's thinking mode rejects a replayed assistant message that omits its
reasoning ('the reasoning_text must be passed back to the API', HTTP 400).
The Chat Completions serializer now includes thinking blocks as
reasoning_content on assistant messages; OpenAI official never produces
thinking blocks, so the field is only sent when present — safe for both.
Regression test: a replayed assistant carries reasoning_content.
This commit is contained in:
DaiChaoXiong 2026-08-09 21:48:29 +08:00
parent a9de6fe24e
commit 107f1f9f42
2 changed files with 113 additions and 2 deletions

View File

@ -292,9 +292,25 @@ fn chat_message(m: &Message) -> JsonValue {
Message::Assistant(a) => {
let mut o = JsonValue::obj();
o.insert("role", "assistant".into()).ok();
// 思考块不回传OpenAI 不接受 reasoning 内容)。
// Thinking blocks are not replayed (OpenAI rejects reasoning content).
o.insert("content", join_text(&a.content).into()).ok();
// 推理 API如 DeepSeek thinking 模式)要求把思考内容作为
// reasoning_content 原样回传OpenAI 官方不会产生思考块,因此
// 仅在存在思考块时发送该字段,安全兼容两者。
// Reasoning-capable APIs (e.g. DeepSeek thinking mode) require the
// thinking text to be echoed back as reasoning_content; OpenAI
// official never produces thinking blocks, so sending the field
// only when thinking exists stays safe for both.
let reasoning: String = a
.content
.iter()
.filter_map(|c| match c {
ContentBlock::Thinking(t) => Some(t.thinking.clone()),
_ => None,
})
.collect();
if !reasoning.is_empty() {
o.insert("reasoning_content", reasoning.into()).ok();
}
let calls: Vec<&ToolCall> = a.content.iter().filter_map(|c| c.as_tool_call()).collect();
if !calls.is_empty() {
let mut arr = JsonValue::arr();

View File

@ -346,3 +346,98 @@ fn rich_request() -> ProviderRequest {
temperature: Some(0.2),
}
}
/// DeepSeek 等推理 API思考内容必须作为 reasoning_content 回传(回归)。
/// Reasoning APIs (e.g. DeepSeek): thinking must be echoed back as
/// reasoning_content (regression).
#[test]
fn chat_replays_reasoning_content() {
use focus_core::tool::{ToolEffects, ToolRegistry};
#[derive(Debug)]
struct NoopTool;
impl Tool for NoopTool {
fn name(&self) -> &str {
"noop"
}
fn description(&self) -> &str {
"does nothing"
}
fn parameters(&self) -> focus_json::JsonValue {
focus_json::JsonValue::obj()
}
fn effects(&self) -> ToolEffects {
ToolEffects::NONE
}
fn execute(
&self,
_id: &str,
_args: &focus_json::JsonValue,
_u: Option<&ToolUpdateSink>,
) -> Result<ToolResult, focus_core::CoreError> {
Ok(ToolResult::text("ok"))
}
}
let request = ProviderRequest {
model: "deepseek-v4-flash".into(),
system_prompt: "sys".into(),
messages: vec![
Message::user_text("hi"),
Message::Assistant(AssistantMessage {
content: vec![
ContentBlock::Thinking(ThinkingContent {
thinking: "让我先分析一下".into(),
signature: None,
redacted: false,
}),
ContentBlock::text("让我看看"),
ContentBlock::ToolCall(ToolCall {
id: "call_x".into(),
name: "shell".into(),
arguments: focus_json::parse(r#"{"command":"ls"}"#).unwrap(),
}),
],
model: "deepseek-v4-flash".into(),
usage: Usage::default(),
stop_reason: StopReason::ToolUse,
error_message: None,
timestamp: 0,
}),
Message::ToolResult(ToolResultMessage {
tool_call_id: "call_x".into(),
tool_name: "shell".into(),
content: vec![ContentBlock::text("ok")],
details: focus_json::JsonValue::obj(),
is_error: false,
timestamp: 0,
}),
],
tools: ToolRegistry::with(Box::new(NoopTool)).tool_definitions(),
max_tokens: Some(512),
temperature: None,
};
let mut mock = common::MockTransport::new();
mock.push_body("data: [DONE]\n\n");
let provider = provider(mock.clone(), OpenAiProtocol::ChatCompletions);
let _ = common::collect(&provider, &request);
let req = mock.last_request();
let body: focus_json::JsonValue =
focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap();
let messages = body.get_arr("messages").unwrap();
// [0] = system[1] = user[2] = assistant。
// [0] = system, [1] = user, [2] = assistant.
let assistant = &messages[2];
// 思考内容作为 reasoning_content 回传content 只含文本)。
// Thinking echoed as reasoning_content (content holds only text).
assert_eq!(
assistant.get_str("reasoning_content"),
Some("让我先分析一下")
);
assert_eq!(assistant.get_str("content"), Some("让我看看"));
// 工具调用照常回传。
// Tool calls are still replayed.
assert!(assistant.get_arr("tool_calls").is_some());
}