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:
parent
fc6b15a407
commit
80333ee127
|
|
@ -367,6 +367,29 @@ fn messages_to_responses(messages: &[Message]) -> JsonValue {
|
|||
items.push(item).ok();
|
||||
}
|
||||
Message::Assistant(a) => {
|
||||
// 推理内容必须回传为 reasoning item(thinking 模式 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();
|
||||
item.insert("role", "assistant".into()).ok();
|
||||
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)
|
||||
}
|
||||
"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" => {
|
||||
if let Some(resp) = json.get("response") {
|
||||
match resp.get_str("status").unwrap_or("completed") {
|
||||
|
|
|
|||
|
|
@ -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_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("思考内容"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue