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:
4 files changed
+397
-20
No files matched your search
+41
-8
@@ -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!({
|
||||||
|
|||||||
@@ -407,8 +407,12 @@ 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 {
|
||||||
|
// Responses reasoning items are OpenAI-only;
|
||||||
|
// drop them when the session moves to Claude.
|
||||||
|
if !matches!(block, ThinkingBlock::Reasoning { .. }) {
|
||||||
assistant_parts.push(json!(block));
|
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())
|
||||||
} else {
|
} else {
|
||||||
@@ -475,8 +479,10 @@ 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 {
|
||||||
|
if !matches!(block, ThinkingBlock::Reasoning { .. }) {
|
||||||
assistant_parts.push(json!(block));
|
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())
|
||||||
} else {
|
} else {
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+251
-7
@@ -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,7 +847,13 @@ 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)
|
||||||
|
};
|
||||||
|
|
||||||
|
sse_stream(builder, handle).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_responses_handle_event(data: &Value, handler: &mut SseHandler) -> Result<bool> {
|
||||||
match data["type"].as_str() {
|
match data["type"].as_str() {
|
||||||
Some("response.output_text.delta") => {
|
Some("response.output_text.delta") => {
|
||||||
if let Some(delta) = data["delta"].as_str().filter(|v| !v.is_empty()) {
|
if let Some(delta) = data["delta"].as_str().filter(|v| !v.is_empty()) {
|
||||||
@@ -831,13 +862,13 @@ pub async fn openai_responses_streaming(
|
|||||||
}
|
}
|
||||||
Some("response.output_item.done") => {
|
Some("response.output_item.done") => {
|
||||||
let item = &data["item"];
|
let item = &data["item"];
|
||||||
if item["type"].as_str() == Some("function_call")
|
match item["type"].as_str() {
|
||||||
&& let (Some(name), Some(arguments_str), Some(call_id)) = (
|
Some("function_call") => {
|
||||||
|
if let (Some(name), Some(arguments_str), Some(call_id)) = (
|
||||||
item["name"].as_str(),
|
item["name"].as_str(),
|
||||||
item["arguments"].as_str(),
|
item["arguments"].as_str(),
|
||||||
item["call_id"].as_str(),
|
item["call_id"].as_str(),
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
let arguments: Value = arguments_str.parse().with_context(|| {
|
let arguments: Value = arguments_str.parse().with_context(|| {
|
||||||
format!("Tool call '{name}' has non-JSON arguments '{arguments_str}'")
|
format!("Tool call '{name}' has non-JSON arguments '{arguments_str}'")
|
||||||
})?;
|
})?;
|
||||||
@@ -848,15 +879,20 @@ pub async fn openai_responses_streaming(
|
|||||||
))?;
|
))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some("reasoning") => {
|
||||||
|
if let Some(block) = reasoning_thinking_block(item) {
|
||||||
|
handler.thinking_block(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some("response.completed") => {
|
Some("response.completed") => {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
};
|
|
||||||
|
|
||||||
sse_stream(builder, handle).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -864,6 +900,7 @@ 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()),
|
||||||
|
|||||||
Reference in new issue
Block a user