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) => {
|
Message::Assistant(a) => {
|
||||||
let mut o = JsonValue::obj();
|
let mut o = JsonValue::obj();
|
||||||
o.insert("role", "assistant".into()).ok();
|
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();
|
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();
|
let calls: Vec<&ToolCall> = a.content.iter().filter_map(|c| c.as_tool_call()).collect();
|
||||||
if !calls.is_empty() {
|
if !calls.is_empty() {
|
||||||
let mut arr = JsonValue::arr();
|
let mut arr = JsonValue::arr();
|
||||||
|
|
|
||||||
|
|
@ -346,3 +346,98 @@ fn rich_request() -> ProviderRequest {
|
||||||
temperature: Some(0.2),
|
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