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:
parent
a9de6fe24e
commit
107f1f9f42
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue