diff --git a/crates/focus-providers/src/openai.rs b/crates/focus-providers/src/openai.rs index 103618d..57026f9 100644 --- a/crates/focus-providers/src/openai.rs +++ b/crates/focus-providers/src/openai.rs @@ -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(); diff --git a/crates/focus-providers/tests/openai_tests.rs b/crates/focus-providers/tests/openai_tests.rs index 89dea4e..b6fba00 100644 --- a/crates/focus-providers/tests/openai_tests.rs +++ b/crates/focus-providers/tests/openai_tests.rs @@ -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 { + 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()); +}