feat(TASK-042): Responses reasoning-item replay, strict:false, cross-client portability

Add ThinkingBlock::Reasoning to carry OpenAI Responses reasoning items
verbatim. The Responses body builder now requests
reasoning.encrypted_content, marks tool defs strict:false, and replays
each round's reasoning items ahead of the assistant text and function
calls. Both extraction paths (non-streaming and streaming) capture
reasoning output items. Claude and Bedrock replay paths drop the
OpenAI-only variant so mid-session model switches keep working.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 12:13:21 -06:00
1 parent 3a2736b667
commit d4605794e8
4 files changed
+424 -47

No files matched your search

+41 -8
View File
@@ -429,7 +429,9 @@ fn build_chat_completions_body(
let mut user_parts = vec![]; let mut user_parts = vec![];
for (index, tool_result) in tool_results.iter().enumerate() { for (index, tool_result) in tool_results.iter().enumerate() {
for block in &tool_result.thinking { for block in &tool_result.thinking {
assistant_parts.push(converse_reasoning_block(block)); if let Some(part) = converse_reasoning_block(block) {
assistant_parts.push(part);
}
} }
let round_text = if index == 0 && !text.is_empty() { let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str()) Some(text.as_str())
@@ -498,7 +500,9 @@ fn build_chat_completions_body(
chunk_ids.insert(id); chunk_ids.insert(id);
} }
for block in &tool_result.thinking { for block in &tool_result.thinking {
assistant_parts.push(converse_reasoning_block(block)); if let Some(part) = converse_reasoning_block(block) {
assistant_parts.push(part);
}
} }
let round_text = if index == 0 && !text.is_empty() { let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str()) Some(text.as_str())
@@ -663,23 +667,26 @@ fn bedrock_tool_result_content(output: &Value) -> Value {
} }
/// Maps a ThinkingBlock (Anthropic tagged shape) to the Converse /// Maps a ThinkingBlock (Anthropic tagged shape) to the Converse
/// reasoningContent union so signed blocks replay verbatim. /// reasoningContent union so signed blocks replay verbatim. OpenAI Responses
fn converse_reasoning_block(block: &ThinkingBlock) -> Value { /// reasoning items have no Converse encoding, so they are dropped when the
/// session moves to Bedrock.
fn converse_reasoning_block(block: &ThinkingBlock) -> Option<Value> {
match block { match block {
ThinkingBlock::Thinking { ThinkingBlock::Thinking {
thinking, thinking,
signature, signature,
} => json!({ } => Some(json!({
"reasoningContent": { "reasoningContent": {
"reasoningText": { "reasoningText": {
"text": thinking, "text": thinking,
"signature": signature, "signature": signature,
} }
} }
}), })),
ThinkingBlock::RedactedThinking { data } => json!({ ThinkingBlock::RedactedThinking { data } => Some(json!({
"reasoningContent": { "redactedContent": data } "reasoningContent": { "redactedContent": data }
}), })),
ThinkingBlock::Reasoning { .. } => None,
} }
} }
@@ -1187,6 +1194,32 @@ mod tests {
} }
} }
#[test]
fn skips_openai_reasoning_blocks_in_converse_shape() {
let reasoning = ToolResult {
call: ToolCall::new(
"fs_read".into(),
json!({"path": "x"}),
Some("tool_A".into()),
),
output: json!("ok"),
text: None,
thinking: vec![ThinkingBlock::Reasoning {
id: "rs_1".into(),
summary: json!([{ "type": "summary_text", "text": "thinking" }]),
encrypted_content: Some("enc123".into()),
}],
};
for sequence in [false, true] {
let body = build_body_with(vec![reasoning.clone()], "", sequence);
let content = body["messages"][1]["content"].as_array().unwrap();
assert_eq!(content.len(), 1, "body: {body}");
assert!(content[0].get("toolUse").is_some(), "body: {body}");
}
}
#[test] #[test]
fn extract_chat_completions_captures_reasoning_content() { fn extract_chat_completions_captures_reasoning_content() {
let data = json!({ let data = json!({
+34 -2
View File
@@ -407,7 +407,11 @@ pub fn claude_build_chat_completions_body(
let mut user_parts = vec![]; let mut user_parts = vec![];
for (index, tool_result) in tool_results.iter().enumerate() { for (index, tool_result) in tool_results.iter().enumerate() {
for block in &tool_result.thinking { for block in &tool_result.thinking {
assistant_parts.push(json!(block)); // Responses reasoning items are OpenAI-only;
// drop them when the session moves to Claude.
if !matches!(block, ThinkingBlock::Reasoning { .. }) {
assistant_parts.push(json!(block));
}
} }
let round_text = if index == 0 && !text.is_empty() { let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str()) Some(text.as_str())
@@ -475,7 +479,9 @@ pub fn claude_build_chat_completions_body(
chunk_ids.insert(id); chunk_ids.insert(id);
} }
for block in &tool_result.thinking { for block in &tool_result.thinking {
assistant_parts.push(json!(block)); if !matches!(block, ThinkingBlock::Reasoning { .. }) {
assistant_parts.push(json!(block));
}
} }
let round_text = if index == 0 && !text.is_empty() { let round_text = if index == 0 && !text.is_empty() {
Some(text.as_str()) Some(text.as_str())
@@ -875,6 +881,32 @@ mod tests {
assert_eq!(messages.len(), 1, "body: {body}"); assert_eq!(messages.len(), 1, "body: {body}");
} }
#[test]
fn skips_openai_reasoning_blocks_in_replay() {
let reasoning = ToolResult {
call: ToolCall::new(
"fs_read".into(),
json!({"path": "x"}),
Some("toolu_A".into()),
),
output: json!("ok"),
text: None,
thinking: vec![ThinkingBlock::Reasoning {
id: "rs_1".into(),
summary: json!([{ "type": "summary_text", "text": "thinking" }]),
encrypted_content: Some("enc123".into()),
}],
};
for sequence in [false, true] {
let body = build_body_with(vec![reasoning.clone()], "", sequence, false);
let content = body["messages"][1]["content"].as_array().unwrap();
assert_eq!(content.len(), 1, "body: {body}");
assert_eq!(content[0]["type"], "tool_use", "body: {body}");
}
}
#[test] #[test]
fn prompt_cache_places_breakpoints() { fn prompt_cache_places_breakpoints() {
let functions = vec![function_declaration("a"), function_declaration("b")]; let functions = vec![function_declaration("a"), function_declaration("b")];
+73 -5
View File
@@ -188,15 +188,27 @@ pub struct ImageUrl {
pub url: String, pub url: String,
} }
/// An extended-thinking block returned by Anthropic-protocol models. /// An extended-thinking block returned by Anthropic-protocol models or an
/// Serialized to match the API wire format (`type: thinking` / `type: redacted_thinking`) /// OpenAI Responses reasoning item. Serialized to match each API's wire
/// format (`type: thinking` / `type: redacted_thinking` / `type: reasoning`)
/// so blocks can be replayed verbatim, signature intact, in subsequent /// so blocks can be replayed verbatim, signature intact, in subsequent
/// tool-loop rounds as the API requires. /// tool-loop rounds as the APIs require.
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ThinkingBlock { pub enum ThinkingBlock {
Thinking { thinking: String, signature: String }, Thinking {
RedactedThinking { data: String }, thinking: String,
signature: String,
},
RedactedThinking {
data: String,
},
Reasoning {
id: String,
summary: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
},
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -263,3 +275,59 @@ pub fn extract_system_message(messages: &mut Vec<Message>) -> Option<String> {
None None
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn old_session_thinking_blocks_still_deserialize() {
let json = json!([
{ "type": "thinking", "thinking": "hmm", "signature": "sig123" },
{ "type": "redacted_thinking", "data": "b64data" },
]);
let blocks: Vec<ThinkingBlock> = serde_json::from_value(json).unwrap();
assert!(
matches!(&blocks[0], ThinkingBlock::Thinking { thinking, signature } if thinking == "hmm" && signature == "sig123")
);
assert!(
matches!(&blocks[1], ThinkingBlock::RedactedThinking { data } if data == "b64data")
);
}
#[test]
fn reasoning_block_round_trips() {
let json = json!({
"type": "reasoning",
"id": "rs_1",
"summary": [{ "type": "summary_text", "text": "thinking about it" }],
"encrypted_content": "enc123",
});
let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&block, ThinkingBlock::Reasoning { id, encrypted_content, .. } if id == "rs_1" && encrypted_content.as_deref() == Some("enc123"))
);
assert_eq!(serde_json::to_value(&block).unwrap(), json);
}
#[test]
fn reasoning_block_without_encrypted_content_round_trips() {
let json = json!({
"type": "reasoning",
"id": "rs_1",
"summary": [],
});
let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&block, ThinkingBlock::Reasoning { encrypted_content, .. } if encrypted_content.is_none())
);
assert_eq!(serde_json::to_value(&block).unwrap(), json);
}
}
+276 -32
View File
@@ -685,6 +685,11 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
tool_result.text.clone() tool_result.text.clone()
}; };
let mut items = vec![]; let mut items = vec![];
for block in &tool_result.thinking {
if matches!(block, ThinkingBlock::Reasoning { .. }) {
items.push(json!(block));
}
}
if let Some(round_text) = round_text { if let Some(round_text) = round_text {
items.push(json!({ items.push(json!({
"role": MessageRole::Assistant, "role": MessageRole::Assistant,
@@ -717,6 +722,7 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
"model": &model.real_name(), "model": &model.real_name(),
"input": input, "input": input,
"store": false, "store": false,
"include": ["reasoning.encrypted_content"],
}); });
if let Some(v) = model.max_tokens_param() { if let Some(v) = model.max_tokens_param() {
@@ -740,6 +746,7 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) ->
.map(|v| { .map(|v| {
let mut tool = serde_json::to_value(v).unwrap_or_default(); let mut tool = serde_json::to_value(v).unwrap_or_default();
tool["type"] = "function".into(); tool["type"] = "function".into();
tool["strict"] = false.into();
tool tool
}) })
.collect(); .collect();
@@ -766,6 +773,7 @@ pub async fn openai_responses_chat_completions(
pub fn openai_extract_responses(data: &Value) -> Result<ChatCompletionsOutput> { pub fn openai_extract_responses(data: &Value) -> Result<ChatCompletionsOutput> {
let mut text = String::new(); let mut text = String::new();
let mut tool_calls = vec![]; let mut tool_calls = vec![];
let mut thinking = vec![];
if let Some(output) = data["output"].as_array() { if let Some(output) = data["output"].as_array() {
for item in output { for item in output {
@@ -797,6 +805,11 @@ pub fn openai_extract_responses(data: &Value) -> Result<ChatCompletionsOutput> {
)); ));
} }
} }
Some("reasoning") => {
if let Some(block) = reasoning_thinking_block(item) {
thinking.push(block);
}
}
_ => {} _ => {}
} }
} }
@@ -808,10 +821,22 @@ pub fn openai_extract_responses(data: &Value) -> Result<ChatCompletionsOutput> {
Ok(ChatCompletionsOutput { Ok(ChatCompletionsOutput {
text, text,
tool_calls, tool_calls,
thinking,
..Default::default() ..Default::default()
}) })
} }
/// Captures a Responses reasoning item verbatim (summary included) so it can
/// be replayed in later tool-loop rounds; `store: false` makes the replayed
/// `encrypted_content` the model's only access to its prior reasoning.
fn reasoning_thinking_block(item: &Value) -> Option<ThinkingBlock> {
Some(ThinkingBlock::Reasoning {
id: item["id"].as_str()?.to_string(),
summary: item["summary"].clone(),
encrypted_content: item["encrypted_content"].as_str().map(|v| v.to_string()),
})
}
pub async fn openai_responses_streaming( pub async fn openai_responses_streaming(
builder: RequestBuilder, builder: RequestBuilder,
handler: &mut SseHandler, handler: &mut SseHandler,
@@ -822,48 +847,60 @@ pub async fn openai_responses_streaming(
} }
let data: Value = serde_json::from_str(&message.data)?; let data: Value = serde_json::from_str(&message.data)?;
debug!("stream-data: {data}"); debug!("stream-data: {data}");
openai_responses_handle_event(&data, handler)
match data["type"].as_str() {
Some("response.output_text.delta") => {
if let Some(delta) = data["delta"].as_str().filter(|v| !v.is_empty()) {
handler.text(delta)?;
}
}
Some("response.output_item.done") => {
let item = &data["item"];
if item["type"].as_str() == Some("function_call")
&& let (Some(name), Some(arguments_str), Some(call_id)) = (
item["name"].as_str(),
item["arguments"].as_str(),
item["call_id"].as_str(),
)
{
let arguments: Value = arguments_str.parse().with_context(|| {
format!("Tool call '{name}' has non-JSON arguments '{arguments_str}'")
})?;
handler.tool_call(ToolCall::new(
name.to_string(),
arguments,
Some(call_id.to_string()),
))?;
}
}
Some("response.completed") => {
return Ok(true);
}
_ => {}
}
Ok(false)
}; };
sse_stream(builder, handle).await sse_stream(builder, handle).await
} }
fn openai_responses_handle_event(data: &Value, handler: &mut SseHandler) -> Result<bool> {
match data["type"].as_str() {
Some("response.output_text.delta") => {
if let Some(delta) = data["delta"].as_str().filter(|v| !v.is_empty()) {
handler.text(delta)?;
}
}
Some("response.output_item.done") => {
let item = &data["item"];
match item["type"].as_str() {
Some("function_call") => {
if let (Some(name), Some(arguments_str), Some(call_id)) = (
item["name"].as_str(),
item["arguments"].as_str(),
item["call_id"].as_str(),
) {
let arguments: Value = arguments_str.parse().with_context(|| {
format!("Tool call '{name}' has non-JSON arguments '{arguments_str}'")
})?;
handler.tool_call(ToolCall::new(
name.to_string(),
arguments,
Some(call_id.to_string()),
))?;
}
}
Some("reasoning") => {
if let Some(block) = reasoning_thinking_block(item) {
handler.thinking_block(block);
}
}
_ => {}
}
}
Some("response.completed") => {
return Ok(true);
}
_ => {}
}
Ok(false)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::client::access_token::set_access_token; use crate::client::access_token::set_access_token;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::function::{FunctionDeclaration, ToolResult};
use chrono::Utc; use chrono::Utc;
use std::sync::Arc; use std::sync::Arc;
@@ -907,6 +944,213 @@ mod tests {
assert_eq!(messages.len(), 1, "body: {body}"); assert_eq!(messages.len(), 1, "body: {body}");
} }
fn reasoning_block(id: &str) -> ThinkingBlock {
ThinkingBlock::Reasoning {
id: id.to_string(),
summary: json!([{ "type": "summary_text", "text": "thinking" }]),
encrypted_content: Some("enc123".to_string()),
}
}
fn responses_tool_result(
id: &str,
text: Option<&str>,
thinking: Vec<ThinkingBlock>,
) -> ToolResult {
ToolResult {
call: ToolCall::new("fs_read".into(), json!({"path": "x"}), Some(id.into())),
output: json!("ok"),
text: text.map(|t| t.to_string()),
thinking,
}
}
fn build_responses_body(
tool_results: Vec<ToolResult>,
functions: Option<Vec<FunctionDeclaration>>,
) -> Value {
let data = ChatCompletionsData {
messages: vec![
Message::new(MessageRole::User, MessageContent::Text("hello".to_string())),
Message::new(
MessageRole::Assistant,
MessageContent::ToolCalls(MessageContentToolCalls {
tool_results,
text: "first round".to_string(),
sequence: false,
}),
),
],
temperature: None,
top_p: None,
reasoning_effort: None,
functions,
stream: false,
};
openai_build_responses_body(data, &Model::new("openai", "gpt-test"))
}
#[test]
fn responses_body_sets_include_and_strict_false() {
let functions = vec![
FunctionDeclaration {
name: "a".to_string(),
description: "a description".to_string(),
parameters: Default::default(),
agent: false,
},
FunctionDeclaration {
name: "b".to_string(),
description: "b description".to_string(),
parameters: Default::default(),
agent: false,
},
];
let body = build_responses_body(vec![], Some(functions));
assert_eq!(
body["include"],
json!(["reasoning.encrypted_content"]),
"body: {body}"
);
let tools = body["tools"].as_array().unwrap();
assert_eq!(tools.len(), 2, "body: {body}");
for tool in tools {
assert_eq!(tool["strict"], json!(false), "body: {body}");
}
}
#[test]
fn responses_replay_orders_reasoning_first_per_round() {
let body = build_responses_body(
vec![
responses_tool_result("call_A", None, vec![reasoning_block("rs_1")]),
responses_tool_result("call_B", Some("round two"), vec![reasoning_block("rs_2")]),
],
None,
);
let input = body["input"].as_array().unwrap();
let types: Vec<_> = input
.iter()
.map(|item| item["type"].as_str().unwrap_or("text"))
.collect();
assert_eq!(
types,
[
"text",
"reasoning",
"text",
"function_call",
"function_call_output",
"reasoning",
"text",
"function_call",
"function_call_output",
],
"body: {body}"
);
assert_eq!(input[1]["id"], "rs_1", "body: {body}");
assert_eq!(input[1]["encrypted_content"], "enc123", "body: {body}");
assert_eq!(input[2]["content"], "first round", "body: {body}");
assert_eq!(input[3]["call_id"], "call_A", "body: {body}");
assert_eq!(input[5]["id"], "rs_2", "body: {body}");
assert_eq!(input[6]["content"], "round two", "body: {body}");
assert_eq!(input[7]["call_id"], "call_B", "body: {body}");
}
#[test]
fn responses_body_skips_anthropic_thinking_blocks() {
let body = build_responses_body(
vec![responses_tool_result(
"call_A",
None,
vec![
ThinkingBlock::Thinking {
thinking: "hmm".to_string(),
signature: "sig123".to_string(),
},
ThinkingBlock::RedactedThinking {
data: "b64data".to_string(),
},
],
)],
None,
);
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 4, "body: {body}");
assert!(
input.iter().all(|item| !matches!(
item["type"].as_str(),
Some("thinking" | "redacted_thinking" | "reasoning")
)),
"body: {body}"
);
}
#[test]
fn extract_responses_captures_reasoning_items() {
let data = json!({
"output": [
{
"type": "reasoning",
"id": "rs_1",
"summary": [{ "type": "summary_text", "text": "thinking" }],
"encrypted_content": "enc123",
},
{ "type": "message", "content": [{ "type": "output_text", "text": "answer" }] },
]
});
let output = openai_extract_responses(&data).unwrap();
assert_eq!(output.text, "answer");
assert_eq!(output.thinking.len(), 1);
match &output.thinking[0] {
ThinkingBlock::Reasoning {
id,
summary,
encrypted_content,
} => {
assert_eq!(id, "rs_1");
assert_eq!(
summary,
&json!([{ "type": "summary_text", "text": "thinking" }])
);
assert_eq!(encrypted_content.as_deref(), Some("enc123"));
}
other => panic!("unexpected block: {other:?}"),
}
}
#[test]
fn responses_stream_delivers_reasoning_block() {
let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
let abort_signal = crate::utils::create_abort_signal();
let mut handler = SseHandler::new(sender, abort_signal);
let event = json!({
"type": "response.output_item.done",
"item": {
"type": "reasoning",
"id": "rs_1",
"summary": [{ "type": "summary_text", "text": "thinking" }],
"encrypted_content": "enc123",
}
});
let done = openai_responses_handle_event(&event, &mut handler).unwrap();
assert!(!done);
let (_, _, thinking, _) = handler.take();
assert_eq!(thinking.len(), 1);
assert!(matches!(
&thinking[0],
ThinkingBlock::Reasoning { id, encrypted_content, .. }
if id == "rs_1" && encrypted_content.as_deref() == Some("enc123")
));
}
fn openai_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> OpenAIConfig { fn openai_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> OpenAIConfig {
OpenAIConfig { OpenAIConfig {
name: Some(name.into()), name: Some(name.into()),