From fc6b15a407d5c567829de123fb4d93b88591bd34 Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Sun, 9 Aug 2026 21:54:07 +0800 Subject: [PATCH] fix(providers): capture reasoning from multiple field names and echo both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replay error ('reasoning_text in thinking mode must be passed back') persisted because the endpoint emits reasoning under a name other than reasoning_content — e.g. reasoning_text — which we never captured, so the thinking never reached the transcript. - capture reasoning from reasoning_content / reasoning_text / reasoning / thinking (first non-empty wins) - echo the captured reasoning back as BOTH reasoning_content and reasoning_text on assistant messages - FOCUS_DEBUG_FILE= appends every raw network chunk to a file for diagnosing provider-protocol mismatches (both providers) - regression tests: reasoning_text capture, dual-field replay --- crates/focus-providers/src/anthropic.rs | 1 + crates/focus-providers/src/common.rs | 22 +++++ crates/focus-providers/src/openai.rs | 37 ++++++-- crates/focus-providers/tests/openai_tests.rs | 99 ++++++++++++++++++++ 4 files changed, 150 insertions(+), 9 deletions(-) diff --git a/crates/focus-providers/src/anthropic.rs b/crates/focus-providers/src/anthropic.rs index f081657..db47bd4 100644 --- a/crates/focus-providers/src/anthropic.rs +++ b/crates/focus-providers/src/anthropic.rs @@ -122,6 +122,7 @@ impl StreamProvider for AnthropicProvider { let chunk = chunks.next_chunk().await.map_err(|e| e.to_string())?; match chunk { Some(bytes) => { + common::debug_chunk(&bytes); sse.feed(&bytes); while let Some(ev) = sse.next_event() { match turn.handle_sse(&ev, &tx) { diff --git a/crates/focus-providers/src/common.rs b/crates/focus-providers/src/common.rs index d135842..f81ee92 100644 --- a/crates/focus-providers/src/common.rs +++ b/crates/focus-providers/src/common.rs @@ -92,3 +92,25 @@ pub(crate) fn error_message(model: &str, msg: &str) -> AssistantMessage { pub(crate) fn send(tx: &SyncSender, event: StreamEvent) -> bool { tx.send(event).is_ok() } + +/// 诊断日志:设置 `FOCUS_DEBUG_FILE` 时,把每个原始网络 chunk 追加到该文件, +/// 便于排查 provider 协议不兼容(如推理字段名差异)。 +/// Diagnostics: when `FOCUS_DEBUG_FILE` is set, append every raw network chunk +/// to that file, for debugging provider-protocol mismatches (e.g. reasoning +/// field names). +pub(crate) fn debug_chunk(bytes: &[u8]) { + let Ok(path) = std::env::var("FOCUS_DEBUG_FILE") else { + return; + }; + if path.is_empty() { + return; + } + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = writeln!(f, "{}", String::from_utf8_lossy(bytes)); + } +} diff --git a/crates/focus-providers/src/openai.rs b/crates/focus-providers/src/openai.rs index 57026f9..e938a0f 100644 --- a/crates/focus-providers/src/openai.rs +++ b/crates/focus-providers/src/openai.rs @@ -142,6 +142,7 @@ impl StreamProvider for OpenAiProvider { let chunk = chunks.next_chunk().await.map_err(|e| e.to_string())?; match chunk { Some(bytes) => { + common::debug_chunk(&bytes); sse.feed(&bytes); while let Some(ev) = sse.next_event() { match turn.handle_sse(&ev, &tx) { @@ -309,7 +310,13 @@ fn chat_message(m: &Message) -> JsonValue { }) .collect(); if !reasoning.is_empty() { - o.insert("reasoning_content", reasoning.into()).ok(); + // 同时写 reasoning_content(DeepSeek 官方)与 reasoning_text + //(部分兼容层);推理端点普遍接受这两个字段名。 + // Write both reasoning_content (DeepSeek official) and + // reasoning_text (some compatible layers); reasoning-capable + // endpoints generally accept either name. + o.insert("reasoning_content", reasoning.clone().into()).ok(); + o.insert("reasoning_text", reasoning.into()).ok(); } let calls: Vec<&ToolCall> = a.content.iter().filter_map(|c| c.as_tool_call()).collect(); if !calls.is_empty() { @@ -591,14 +598,26 @@ impl OpenAiTurn { } } } - // 推理内容(如 DeepSeek reasoner)→ 思考块。 - // Reasoning content (e.g. DeepSeek reasoner) → thinking block. - if let Some(reasoning) = delta.get_str("reasoning_content") { - if !reasoning.is_empty() { - for ev in self.reducer.thinking_delta(reasoning) { - if !common::send(tx, ev) { - return Ok(false); - } + // 推理内容 → 思考块。不同端点字段名不同(DeepSeek 官方用 + // reasoning_content,部分兼容层用 reasoning_text / reasoning / + // thinking),逐个尝试,取第一个非空。 + // Reasoning → thinking block. Field names differ across + // endpoints (DeepSeek official uses reasoning_content; some + // compatible layers use reasoning_text / reasoning / + // thinking); try each in order, keep the first non-empty. + const REASONING_FIELDS: [&str; 4] = [ + "reasoning_content", + "reasoning_text", + "reasoning", + "thinking", + ]; + if let Some(reasoning) = REASONING_FIELDS + .iter() + .find_map(|k| delta.get_str(k).filter(|v| !v.is_empty())) + { + for ev in self.reducer.thinking_delta(reasoning) { + if !common::send(tx, ev) { + return Ok(false); } } } diff --git a/crates/focus-providers/tests/openai_tests.rs b/crates/focus-providers/tests/openai_tests.rs index b6fba00..a196105 100644 --- a/crates/focus-providers/tests/openai_tests.rs +++ b/crates/focus-providers/tests/openai_tests.rs @@ -441,3 +441,102 @@ fn chat_replays_reasoning_content() { // Tool calls are still replayed. assert!(assistant.get_arr("tool_calls").is_some()); } + +/// 部分兼容端点用 reasoning_text 而非 reasoning_content 输出推理——都必须捕获。 +/// Some compatible endpoints emit reasoning as reasoning_text instead of +/// reasoning_content — both must be captured. +#[test] +fn chat_captures_reasoning_text() { + let mut mock = common::MockTransport::new(); + mock.push_body( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_text\":\"\"},\"finish_reason\":null}]}\n\n\ + data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_text\":\"让我先\"},\"finish_reason\":null}]}\n\n\ + data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_text\":\"分析结构\"},\"finish_reason\":null}]}\n\n\ + data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"答案是\"},\"finish_reason\":null}]}\n\n\ + data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\ + data: [DONE]\n\n", + ); + let provider = provider(mock, OpenAiProtocol::ChatCompletions); + let events = common::collect(&provider, &request()); + + let msg = final_message(&events); + assert_eq!(msg.content.len(), 2, "thinking + text"); + match &msg.content[0] { + ContentBlock::Thinking(t) => { + assert_eq!(t.thinking, "让我先分析结构"); + } + other => panic!("expected thinking block, got {:?}", other), + } + let text = msg.content[1].as_text().expect("text block"); + assert_eq!(text.text, "答案是"); +} + +/// 回传时同时携带 reasoning_content 与 reasoning_text。 +/// Replays carry both reasoning_content and reasoning_text. +#[test] +fn chat_replays_both_reasoning_field_names() { + 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, + })], + model: "deepseek-v4-flash".into(), + usage: Usage::default(), + stop_reason: StopReason::Stop, + error_message: None, + 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]; + assert_eq!(assistant.get_str("reasoning_content"), Some("思考内容")); + assert_eq!(assistant.get_str("reasoning_text"), Some("思考内容")); +}