fix(providers): capture and replay Responses API reasoning items

The real endpoint log (deepseek-v4-flash) shows the Responses protocol: the
thinking arrives as a 'reasoning' output item plus response.reasoning_text.delta
events, which we ignored — so the thinking never reached the transcript and
the replayed history omitted it ('reasoning_text must be passed back', 400).

- output_item.added with item.type 'reasoning' opens a thinking block;
  response.reasoning_text.delta appends to it
- messages_to_responses now replays assistant thinking as a 'reasoning' item
  with a reasoning_text part (before the text item)
- regression tests: reasoning capture and reasoning-item replay
- (the earlier chat-completions reasoning_content/text fixes remain for the
  chat protocol)
This commit is contained in:
DaiChaoXiong 2026-08-09 21:58:16 +08:00
parent fc6b15a407
commit 80333ee127
2 changed files with 125 additions and 0 deletions

View File

@ -367,6 +367,29 @@ fn messages_to_responses(messages: &[Message]) -> JsonValue {
items.push(item).ok(); items.push(item).ok();
} }
Message::Assistant(a) => { Message::Assistant(a) => {
// 推理内容必须回传为 reasoning itemthinking 模式 API 校验)。
// Thinking must be replayed as a reasoning item (thinking-mode
// APIs validate this).
let reasoning: String = a
.content
.iter()
.filter_map(|c| match c {
ContentBlock::Thinking(t) => Some(t.thinking.clone()),
_ => None,
})
.collect();
if !reasoning.is_empty() {
let mut ri = JsonValue::obj();
ri.insert("type", "reasoning".into()).ok();
let mut part = JsonValue::obj();
part.insert("type", "reasoning_text".into()).ok();
part.insert("text", reasoning.into()).ok();
let mut content = JsonValue::arr();
content.push(part).ok();
ri.insert("content", content).ok();
ri.insert("summary", JsonValue::arr()).ok();
items.push(ri).ok();
}
let mut item = JsonValue::obj(); let mut item = JsonValue::obj();
item.insert("role", "assistant".into()).ok(); item.insert("role", "assistant".into()).ok();
let mut content = JsonValue::arr(); let mut content = JsonValue::arr();
@ -694,6 +717,16 @@ impl OpenAiTurn {
} }
} }
} }
"reasoning" => {
// 推理条目:打开思考块,随后的 reasoning_text.delta 填充。
// A reasoning item: open a thinking block; the
// following reasoning_text.delta events fill it.
for e in self.reducer.thinking_delta("") {
if !common::send(tx, e) {
return Ok(false);
}
}
}
_ => {} _ => {}
} }
} }
@ -724,6 +757,20 @@ impl OpenAiTurn {
} }
Ok(true) Ok(true)
} }
"response.reasoning_text.delta" => {
// 推理增量DeepSeek 等 thinking 模式)→ 思考块。
// Reasoning deltas (thinking mode, e.g. DeepSeek) → thinking.
if let Some(delta) = json.get_str("delta") {
if !delta.is_empty() {
for e in self.reducer.thinking_delta(delta) {
if !common::send(tx, e) {
return Ok(false);
}
}
}
}
Ok(true)
}
"response.completed" => { "response.completed" => {
if let Some(resp) = json.get("response") { if let Some(resp) = json.get("response") {
match resp.get_str("status").unwrap_or("completed") { match resp.get_str("status").unwrap_or("completed") {

View File

@ -540,3 +540,81 @@ fn chat_replays_both_reasoning_field_names() {
assert_eq!(assistant.get_str("reasoning_content"), Some("思考内容")); assert_eq!(assistant.get_str("reasoning_content"), Some("思考内容"));
assert_eq!(assistant.get_str("reasoning_text"), Some("思考内容")); assert_eq!(assistant.get_str("reasoning_text"), Some("思考内容"));
} }
/// Responses API 的推理内容reasoning_text.delta必须被捕获为思考块回归
/// 依据真实端点日志deepseek-v4-flash 走 Responses 协议)。
/// Responses API reasoning (reasoning_text.delta) must be captured as a
/// thinking block (regression, based on a real endpoint log).
#[test]
fn responses_captures_reasoning() {
let mut mock = common::MockTransport::new();
mock.push_body(
"event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"r1\",\"status\":\"in_progress\",\"content\":[],\"summary\":[]},\"output_index\":0}\n\n\
event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"content_index\":0,\"delta\":\"The user\",\"item_id\":\"r1\",\"output_index\":0}\n\n\
event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"content_index\":0,\"delta\":\" asks about the project\",\"item_id\":\"r1\",\"output_index\":0}\n\n\
event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"f1\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_x\",\"name\":\"shell\"},\"output_index\":1}\n\n\
event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"command\\\":\\\"ls\\\"}\",\"item_id\":\"f1\",\"output_index\":1}\n\n\
event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":5}}}}\n\n",
);
let provider = provider(mock, OpenAiProtocol::Responses);
let events = common::collect(&provider, &request());
let msg = final_message(&events);
// 顺序:思考块 → 工具调用。
// Order: thinking block → tool call.
assert_eq!(msg.content.len(), 2);
match &msg.content[0] {
ContentBlock::Thinking(t) => {
assert_eq!(t.thinking, "The user asks about the project");
}
other => panic!("expected thinking block, got {:?}", other),
}
let tc = msg.content[1].as_tool_call().expect("tool call");
assert_eq!(tc.name, "shell");
assert_eq!(tc.arguments.get_str("command"), Some("ls"));
}
/// 历史回传时assistant 的思考必须以 reasoning item 回传。
/// Replaying history must include the assistant thinking as a reasoning item.
#[test]
fn responses_replays_reasoning_item() {
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: focus_json::JsonValue::arr(),
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::Responses);
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 input = body.get_arr("input").unwrap();
// [0] = user[1] = reasoning item[2] = assistant item。
// [0] = user, [1] = reasoning item, [2] = assistant item.
let reasoning = &input[1];
assert_eq!(reasoning.get_str("type"), Some("reasoning"));
let content = reasoning.get_arr("content").unwrap();
assert_eq!(content[0].get_str("type"), Some("reasoning_text"));
assert_eq!(content[0].get_str("text"), Some("思考内容"));
}