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:
parent
107f1f9f42
commit
fc6b15a407
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -92,3 +92,25 @@ pub(crate) fn error_message(model: &str, msg: &str) -> AssistantMessage {
|
|||
pub(crate) fn send(tx: &SyncSender<StreamEvent>, 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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("思考内容"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue