fix(providers): capture reasoning from multiple field names and echo both

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=<path> appends every raw network chunk to a file for
  diagnosing provider-protocol mismatches (both providers)
- regression tests: reasoning_text capture, dual-field replay
This commit is contained in:
DaiChaoXiong 2026-08-09 21:54:07 +08:00
parent 107f1f9f42
commit fc6b15a407
4 changed files with 150 additions and 9 deletions

View File

@ -122,6 +122,7 @@ impl StreamProvider for AnthropicProvider {
let chunk = chunks.next_chunk().await.map_err(|e| e.to_string())?; let chunk = chunks.next_chunk().await.map_err(|e| e.to_string())?;
match chunk { match chunk {
Some(bytes) => { Some(bytes) => {
common::debug_chunk(&bytes);
sse.feed(&bytes); sse.feed(&bytes);
while let Some(ev) = sse.next_event() { while let Some(ev) = sse.next_event() {
match turn.handle_sse(&ev, &tx) { match turn.handle_sse(&ev, &tx) {

View File

@ -92,3 +92,25 @@ pub(crate) fn error_message(model: &str, msg: &str) -> AssistantMessage {
pub(crate) fn send(tx: &SyncSender<StreamEvent>, event: StreamEvent) -> bool { pub(crate) fn send(tx: &SyncSender<StreamEvent>, event: StreamEvent) -> bool {
tx.send(event).is_ok() 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));
}
}

View File

@ -142,6 +142,7 @@ impl StreamProvider for OpenAiProvider {
let chunk = chunks.next_chunk().await.map_err(|e| e.to_string())?; let chunk = chunks.next_chunk().await.map_err(|e| e.to_string())?;
match chunk { match chunk {
Some(bytes) => { Some(bytes) => {
common::debug_chunk(&bytes);
sse.feed(&bytes); sse.feed(&bytes);
while let Some(ev) = sse.next_event() { while let Some(ev) = sse.next_event() {
match turn.handle_sse(&ev, &tx) { match turn.handle_sse(&ev, &tx) {
@ -309,7 +310,13 @@ fn chat_message(m: &Message) -> JsonValue {
}) })
.collect(); .collect();
if !reasoning.is_empty() { if !reasoning.is_empty() {
o.insert("reasoning_content", reasoning.into()).ok(); // 同时写 reasoning_contentDeepSeek 官方)与 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(); let calls: Vec<&ToolCall> = a.content.iter().filter_map(|c| c.as_tool_call()).collect();
if !calls.is_empty() { if !calls.is_empty() {
@ -591,17 +598,29 @@ impl OpenAiTurn {
} }
} }
} }
// 推理内容(如 DeepSeek reasoner→ 思考块。 // 推理内容 → 思考块。不同端点字段名不同DeepSeek 官方用
// Reasoning content (e.g. DeepSeek reasoner) → thinking block. // reasoning_content部分兼容层用 reasoning_text / reasoning /
if let Some(reasoning) = delta.get_str("reasoning_content") { // thinking逐个尝试取第一个非空。
if !reasoning.is_empty() { // 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) { for ev in self.reducer.thinking_delta(reasoning) {
if !common::send(tx, ev) { if !common::send(tx, ev) {
return Ok(false); return Ok(false);
} }
} }
} }
}
if let Some(tcs) = delta.get_arr("tool_calls") { if let Some(tcs) = delta.get_arr("tool_calls") {
for tc in tcs { for tc in tcs {
let idx = tc.get_num("index").unwrap_or(0.0) as usize; let idx = tc.get_num("index").unwrap_or(0.0) as usize;

View File

@ -441,3 +441,102 @@ fn chat_replays_reasoning_content() {
// Tool calls are still replayed. // Tool calls are still replayed.
assert!(assistant.get_arr("tool_calls").is_some()); 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<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,
})],
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("思考内容"));
}