mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 20:50:20 +08:00
fix(provider): preserve reasoning and Claude tool results in responses conversion
This commit is contained in:
@@ -159,6 +159,8 @@ fn request_context(mapped_model: &str, upstream_is_stream: bool) -> FormatContex
|
||||
mod tests {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::formats::{context::FormatContext, registry};
|
||||
|
||||
use super::{
|
||||
convert_openai_chat_request_to_claude_request,
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
@@ -219,9 +221,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn responses_request_normalizer_keeps_tool_history_chat_safe() {
|
||||
let call_id = "call_weather_123";
|
||||
let tool_output = json!({
|
||||
"toolCallId": call_id,
|
||||
let call_id_one = "call_weather_123";
|
||||
let call_id_two = "call_lookup_456";
|
||||
let tool_output_one = json!({
|
||||
"toolCallId": call_id_one,
|
||||
"input": {"city": "Hangzhou"},
|
||||
"output": {
|
||||
"content": [{"type": "text", "text": "sunny"}],
|
||||
@@ -231,21 +234,44 @@ mod tests {
|
||||
let body = json!({
|
||||
"model": "glm-5.1",
|
||||
"input": [
|
||||
"weather now",
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "weather now"}]
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": "thinking first"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": "planning"
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"id": call_id,
|
||||
"call_id": call_id_one,
|
||||
"id": call_id_one,
|
||||
"name": "mcp__mapsWeather",
|
||||
"arguments": "{\"city\":\"Hangzhou\"}"
|
||||
},
|
||||
{
|
||||
"type": "web_search_call",
|
||||
"id": "ignored_web_search",
|
||||
"action": {"query": "should be skipped"}
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": call_id_two,
|
||||
"id": call_id_two,
|
||||
"name": "mcp__lookupData",
|
||||
"arguments": "{\"query\":\"museum\"}"
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": tool_output.to_string()
|
||||
"call_id": call_id_one,
|
||||
"output": tool_output_one.to_string()
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id_two,
|
||||
"output": "done-2"
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -254,25 +280,57 @@ mod tests {
|
||||
.expect("openai chat request");
|
||||
let messages = converted["messages"].as_array().expect("messages");
|
||||
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(messages.len(), 4);
|
||||
assert_eq!(messages[0]["role"], "user");
|
||||
assert_eq!(messages[0]["content"], "weather now");
|
||||
assert_eq!(messages[1]["role"], "assistant");
|
||||
assert!(messages[1]["content"].is_null());
|
||||
assert_eq!(messages[1]["tool_calls"][0]["id"], call_id);
|
||||
assert_eq!(messages[1]["reasoning_content"], "thinking first");
|
||||
assert_eq!(messages[1]["content"], "planning");
|
||||
assert_eq!(messages[1]["tool_calls"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(messages[1]["tool_calls"][0]["id"], call_id_one);
|
||||
assert_eq!(
|
||||
messages[1]["tool_calls"][0]["function"]["name"],
|
||||
"mcp__mapsWeather"
|
||||
);
|
||||
assert_eq!(messages[1]["tool_calls"][1]["id"], call_id_two);
|
||||
assert_eq!(
|
||||
messages[1]["tool_calls"][1]["function"]["name"],
|
||||
"mcp__lookupData"
|
||||
);
|
||||
assert_eq!(messages[2]["role"], "tool");
|
||||
assert_eq!(messages[2]["tool_call_id"], call_id);
|
||||
assert_eq!(messages[2]["tool_call_id"], call_id_one);
|
||||
let content = messages[2]["content"]
|
||||
.as_str()
|
||||
.expect("tool result content should stay a string");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(content).expect("tool output json"),
|
||||
tool_output
|
||||
tool_output_one
|
||||
);
|
||||
assert_eq!(messages[3]["role"], "tool");
|
||||
assert_eq!(messages[3]["tool_call_id"], call_id_two);
|
||||
assert_eq!(messages[3]["content"], "done-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_normalizer_emits_empty_message_content_as_empty_string() {
|
||||
let body = json!({
|
||||
"model": "glm-5.1",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": null
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let converted = normalize_openai_responses_request_to_openai_chat_request(&body)
|
||||
.expect("openai chat request");
|
||||
let messages = converted["messages"].as_array().expect("messages");
|
||||
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0]["role"], "assistant");
|
||||
assert_eq!(messages[0]["content"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -609,4 +667,67 @@ mod tests {
|
||||
assert!(!block_content_json.contains("\"source\""));
|
||||
assert!(!block_content_json.contains("document body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_request_to_responses_splits_tool_result_media_from_output() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Describe the file"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_read",
|
||||
"name": "Read",
|
||||
"input": {"file_path": "/tmp/photo.png"}
|
||||
}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_read",
|
||||
"content": [
|
||||
{"type": "text", "text": "File metadata: 800x600 PNG"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "AAAA"
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
],
|
||||
"max_tokens": 128,
|
||||
});
|
||||
|
||||
let converted = registry::convert_request(
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
&body,
|
||||
&FormatContext::default(),
|
||||
)
|
||||
.expect("responses request");
|
||||
let input = converted["input"].as_array().expect("responses input");
|
||||
|
||||
assert_eq!(input.len(), 4);
|
||||
assert_eq!(input[1]["type"], "function_call");
|
||||
assert_eq!(input[1]["call_id"], "toolu_read");
|
||||
assert_eq!(input[2]["type"], "function_call_output");
|
||||
assert_eq!(input[2]["call_id"], "toolu_read");
|
||||
assert_eq!(input[2]["output"], "File metadata: 800x600 PNG");
|
||||
assert_eq!(input[3]["role"], "user");
|
||||
assert_eq!(input[3]["content"][0]["type"], "input_image");
|
||||
assert_eq!(
|
||||
input[3]["content"][0]["image_url"],
|
||||
"data:image/png;base64,AAAA"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
|
||||
protocol::canonical::{
|
||||
canonical_response_format_to_openai, canonicalize_tool_arguments, media_data_or_url,
|
||||
namespace_extension_object, openai_content_text, openai_extensions,
|
||||
canonical_response_format_to_openai, canonicalize_tool_arguments, is_claude_tool_result,
|
||||
media_data_or_url, namespace_extension_object, openai_content_text, openai_extensions,
|
||||
openai_response_format_to_canonical, openai_responses_extension,
|
||||
openai_responses_generation_config, openai_responses_input_to_canonical_messages,
|
||||
openai_responses_tool_choice_to_canonical, openai_responses_tools_to_canonical,
|
||||
@@ -223,6 +225,7 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
CanonicalRole::System | CanonicalRole::Developer => continue,
|
||||
};
|
||||
let mut content = Vec::new();
|
||||
let mut saw_tool_item = false;
|
||||
for block in &message.content {
|
||||
match block {
|
||||
CanonicalContentBlock::ToolUse {
|
||||
@@ -232,6 +235,7 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
..
|
||||
} => {
|
||||
flush_responses_message(&mut input, role, &mut content);
|
||||
saw_tool_item = true;
|
||||
input.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": id,
|
||||
@@ -243,16 +247,37 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
tool_use_id,
|
||||
output,
|
||||
content_text,
|
||||
extensions,
|
||||
..
|
||||
} => {
|
||||
flush_responses_message(&mut input, role, &mut content);
|
||||
saw_tool_item = true;
|
||||
let (tool_output, extra_user_content) = responses_tool_result_payload(
|
||||
output.as_ref(),
|
||||
content_text.as_deref(),
|
||||
extensions,
|
||||
);
|
||||
input.push(json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": tool_use_id,
|
||||
"output": responses_tool_result_output(output.as_ref(), content_text.as_deref()),
|
||||
"output": tool_output,
|
||||
}));
|
||||
if !extra_user_content.is_empty() {
|
||||
input.push(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": extra_user_content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::Thinking { text, .. } => {
|
||||
if role == "assistant" && !text.trim().is_empty() {
|
||||
content.push(json!({
|
||||
"type": "output_text",
|
||||
"text": format!("<thinking>{text}</thinking>"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::Thinking { .. } => {}
|
||||
other => {
|
||||
if let Some(part) = canonical_block_to_responses_input_part(other, role) {
|
||||
content.push(part);
|
||||
@@ -260,6 +285,25 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
}
|
||||
}
|
||||
}
|
||||
if content.is_empty() && !saw_tool_item {
|
||||
if role == "assistant" {
|
||||
input.push(json!({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
}],
|
||||
}));
|
||||
} else {
|
||||
input.push(json!({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": "",
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flush_responses_message(&mut input, role, &mut content);
|
||||
}
|
||||
Some(input)
|
||||
@@ -535,13 +579,169 @@ fn canonical_tool_choice_to_responses(choice: &CanonicalToolChoice) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn responses_tool_result_payload(
|
||||
output: Option<&Value>,
|
||||
content_text: Option<&str>,
|
||||
extensions: &BTreeMap<String, Value>,
|
||||
) -> (Value, Vec<Value>) {
|
||||
if is_claude_tool_result(extensions) {
|
||||
if let Some(Value::Array(parts)) = output {
|
||||
return claude_tool_result_parts_to_responses_payload(parts);
|
||||
}
|
||||
}
|
||||
(
|
||||
responses_tool_result_output(output, content_text),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value {
|
||||
match output {
|
||||
Some(Value::String(text)) => Value::String(text.clone()),
|
||||
Some(value) => serde_json::to_string(value)
|
||||
.map(Value::String)
|
||||
.unwrap_or_else(|_| Value::String(String::new())),
|
||||
None => Value::String(content_text.unwrap_or_default().to_string()),
|
||||
let text = match output {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(Value::Null) => String::new(),
|
||||
Some(value) => serde_json::to_string(value).unwrap_or_default(),
|
||||
None => content_text.unwrap_or_default().to_string(),
|
||||
};
|
||||
Value::String(non_empty_responses_tool_output(&text))
|
||||
}
|
||||
|
||||
fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> (Value, Vec<Value>) {
|
||||
let mut output_texts = Vec::new();
|
||||
let mut extra_user_content = Vec::new();
|
||||
|
||||
for part in parts {
|
||||
let Some(part_object) = part.as_object() else {
|
||||
output_texts.push("[Claude tool_result non-text content omitted]".to_string());
|
||||
continue;
|
||||
};
|
||||
match part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"text" => {
|
||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||
if !text.is_empty() {
|
||||
output_texts.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
if let Some(part) = claude_image_block_to_responses_input_part(part_object) {
|
||||
extra_user_content.push(part);
|
||||
} else {
|
||||
output_texts.push(claude_tool_result_media_summary("image", part_object));
|
||||
}
|
||||
}
|
||||
"document" | "file" => {
|
||||
if let Some(part) = claude_document_block_to_responses_input_part(part_object) {
|
||||
extra_user_content.push(part);
|
||||
} else {
|
||||
output_texts.push(claude_tool_result_media_summary("document", part_object));
|
||||
}
|
||||
}
|
||||
"" => output_texts.push("[Claude tool_result object content omitted]".to_string()),
|
||||
raw_type => {
|
||||
output_texts.push(format!("[Claude tool_result {raw_type} content omitted]"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
Value::String(non_empty_responses_tool_output(&output_texts.join("\n\n"))),
|
||||
extra_user_content,
|
||||
)
|
||||
}
|
||||
|
||||
fn claude_image_block_to_responses_input_part(block: &Map<String, Value>) -> Option<Value> {
|
||||
let source = block.get("source")?.as_object()?;
|
||||
match source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"base64" => {
|
||||
let media_type = claude_source_media_type(source).unwrap_or("image/png");
|
||||
let data = claude_source_str(source, "data")?;
|
||||
Some(json!({
|
||||
"type": "input_image",
|
||||
"image_url": format!("data:{media_type};base64,{data}"),
|
||||
}))
|
||||
}
|
||||
"url" => {
|
||||
let url = claude_source_str(source, "url")?;
|
||||
Some(json!({
|
||||
"type": "input_image",
|
||||
"image_url": url,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_document_block_to_responses_input_part(block: &Map<String, Value>) -> Option<Value> {
|
||||
let source = block.get("source")?.as_object()?;
|
||||
let file_data = match source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"base64" => {
|
||||
let media_type = claude_source_media_type(source).unwrap_or("application/octet-stream");
|
||||
let data = claude_source_str(source, "data")?;
|
||||
format!("data:{media_type};base64,{data}")
|
||||
}
|
||||
"url" => claude_source_str(source, "url")?.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let mut part = Map::new();
|
||||
part.insert("type".to_string(), Value::String("input_file".to_string()));
|
||||
part.insert("file_data".to_string(), Value::String(file_data));
|
||||
if let Some(filename) = block
|
||||
.get("title")
|
||||
.or_else(|| block.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
part.insert("filename".to_string(), Value::String(filename.to_string()));
|
||||
}
|
||||
Some(Value::Object(part))
|
||||
}
|
||||
|
||||
fn claude_tool_result_media_summary(kind: &str, block: &Map<String, Value>) -> String {
|
||||
let media_type = block
|
||||
.get("source")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(claude_source_media_type);
|
||||
match media_type {
|
||||
Some(media_type) if !media_type.trim().is_empty() => {
|
||||
format!("[Claude tool_result {kind} content omitted: {media_type}]")
|
||||
}
|
||||
_ => format!("[Claude tool_result {kind} content omitted]"),
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_source_media_type(source: &Map<String, Value>) -> Option<&str> {
|
||||
source
|
||||
.get("media_type")
|
||||
.or_else(|| source.get("mime_type"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn claude_source_str<'a>(source: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
|
||||
source
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn non_empty_responses_tool_output(text: &str) -> String {
|
||||
if text.is_empty() {
|
||||
"(empty)".to_string()
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,4 +791,62 @@ mod tests {
|
||||
.to_ascii_lowercase()
|
||||
.contains("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_preserves_empty_chat_messages() {
|
||||
let request = CanonicalRequest {
|
||||
model: "gpt-5.5".to_string(),
|
||||
messages: vec![
|
||||
CanonicalMessage {
|
||||
role: CanonicalRole::User,
|
||||
content: vec![CanonicalContentBlock::Text {
|
||||
text: String::new(),
|
||||
extensions: Default::default(),
|
||||
}],
|
||||
extensions: Default::default(),
|
||||
},
|
||||
CanonicalMessage {
|
||||
role: CanonicalRole::Assistant,
|
||||
content: Vec::new(),
|
||||
extensions: Default::default(),
|
||||
},
|
||||
],
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
|
||||
|
||||
assert_eq!(body["input"][0]["role"], "user");
|
||||
assert_eq!(body["input"][0]["content"], "");
|
||||
assert_eq!(body["input"][1]["role"], "assistant");
|
||||
assert_eq!(body["input"][1]["content"][0]["type"], "output_text");
|
||||
assert_eq!(body["input"][1]["content"][0]["text"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_uses_empty_marker_for_empty_tool_output() {
|
||||
let request = CanonicalRequest {
|
||||
model: "gpt-5.5".to_string(),
|
||||
messages: vec![CanonicalMessage {
|
||||
role: CanonicalRole::Tool,
|
||||
content: vec![CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: "call_empty".to_string(),
|
||||
name: None,
|
||||
output: Some(json!("")),
|
||||
content_text: None,
|
||||
is_error: false,
|
||||
extensions: Default::default(),
|
||||
}],
|
||||
extensions: Default::default(),
|
||||
}],
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
|
||||
|
||||
assert_eq!(body["input"].as_array().expect("input").len(), 1);
|
||||
assert_eq!(body["input"][0]["type"], "function_call_output");
|
||||
assert_eq!(body["input"][0]["call_id"], "call_empty");
|
||||
assert_eq!(body["input"][0]["output"], "(empty)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,6 +1338,23 @@ pub(crate) fn openai_message_content_blocks(
|
||||
let reasoning_blocks = openai_reasoning_blocks(message);
|
||||
if !reasoning_blocks.is_empty() {
|
||||
blocks.splice(0..0, reasoning_blocks);
|
||||
} else if let Some(reasoning_content) = message
|
||||
.get("reasoning_content")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let mut extensions = BTreeMap::new();
|
||||
canonical_extension_object_mut(&mut extensions, "openai")
|
||||
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
|
||||
blocks.insert(
|
||||
0,
|
||||
CanonicalContentBlock::Thinking {
|
||||
text: reasoning_content.to_string(),
|
||||
signature: None,
|
||||
encrypted_content: None,
|
||||
extensions,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut saw_tool_calls = false;
|
||||
@@ -1505,6 +1522,7 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
Value::Array(items) => {
|
||||
let mut messages = Vec::new();
|
||||
let mut next_generated_tool_call_index = 0usize;
|
||||
let mut pending_reasoning: Option<String> = None;
|
||||
for item in items {
|
||||
if let Some(text) = item.as_str() {
|
||||
if !text.trim().is_empty() {
|
||||
@@ -1517,9 +1535,13 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
pending_reasoning = None;
|
||||
continue;
|
||||
}
|
||||
let item_object = item.as_object()?;
|
||||
let Some(item_object) = item.as_object() else {
|
||||
pending_reasoning = None;
|
||||
continue;
|
||||
};
|
||||
let item_type = item_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1527,6 +1549,12 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match item_type.as_str() {
|
||||
"reasoning" => {
|
||||
let reasoning = openai_responses_reasoning_text(item_object);
|
||||
if !reasoning.is_empty() {
|
||||
pending_reasoning = Some(reasoning);
|
||||
}
|
||||
}
|
||||
"message" => {
|
||||
let role = openai_role_to_canonical(
|
||||
item_object
|
||||
@@ -1549,11 +1577,13 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
),
|
||||
});
|
||||
}
|
||||
pending_reasoning = None;
|
||||
continue;
|
||||
}
|
||||
let is_assistant = role == CanonicalRole::Assistant;
|
||||
messages.push(CanonicalMessage {
|
||||
role,
|
||||
content: openai_responses_content_to_blocks(
|
||||
content: openai_responses_chat_safe_content_to_blocks(
|
||||
item_object.get("content"),
|
||||
)?,
|
||||
extensions: openai_responses_extensions(
|
||||
@@ -1561,13 +1591,17 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
&["type", "role", "content"],
|
||||
),
|
||||
});
|
||||
if !is_assistant {
|
||||
pending_reasoning = None;
|
||||
}
|
||||
}
|
||||
"function_call" => {
|
||||
let name = item_object
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let id = item_object
|
||||
.get("call_id")
|
||||
.or_else(|| item_object.get("id"))
|
||||
@@ -1581,52 +1615,20 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
next_generated_tool_call_index += 1;
|
||||
generated
|
||||
});
|
||||
messages.push(CanonicalMessage {
|
||||
role: CanonicalRole::Assistant,
|
||||
content: vec![CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
input: parse_jsonish_value(item_object.get("arguments")),
|
||||
extensions: openai_responses_extensions(
|
||||
item_object,
|
||||
&["type", "call_id", "id", "name", "arguments"],
|
||||
),
|
||||
}],
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
"web_search_call" => {
|
||||
let id = item_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
let generated =
|
||||
format!("call_auto_{next_generated_tool_call_index}");
|
||||
next_generated_tool_call_index += 1;
|
||||
generated
|
||||
});
|
||||
let query = item_object
|
||||
.get("action")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|action| action.get("query"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
messages.push(CanonicalMessage {
|
||||
role: CanonicalRole::Assistant,
|
||||
content: vec![CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name: "web_search".to_string(),
|
||||
input: json!({ "query": query }),
|
||||
extensions: openai_responses_extensions(
|
||||
item_object,
|
||||
&["type", "id", "status", "action"],
|
||||
),
|
||||
}],
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
let tool_use = CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input: parse_jsonish_value(item_object.get("arguments")),
|
||||
extensions: openai_responses_extensions(
|
||||
item_object,
|
||||
&["type", "call_id", "id", "name", "arguments"],
|
||||
),
|
||||
};
|
||||
append_openai_responses_tool_use(
|
||||
&mut messages,
|
||||
tool_use,
|
||||
&mut pending_reasoning,
|
||||
);
|
||||
}
|
||||
"function_call_output" => {
|
||||
let id = item_object
|
||||
@@ -1675,16 +1677,11 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
}],
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
pending_reasoning = None;
|
||||
}
|
||||
_ => {
|
||||
pending_reasoning = None;
|
||||
}
|
||||
_ => messages.push(CanonicalMessage {
|
||||
role: CanonicalRole::Unknown,
|
||||
content: vec![CanonicalContentBlock::Unknown {
|
||||
raw_type: item_type,
|
||||
payload: item.clone(),
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
extensions: BTreeMap::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Some(messages)
|
||||
@@ -1693,6 +1690,102 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
}
|
||||
}
|
||||
|
||||
fn append_openai_responses_tool_use(
|
||||
messages: &mut Vec<CanonicalMessage>,
|
||||
tool_use: CanonicalContentBlock,
|
||||
pending_reasoning: &mut Option<String>,
|
||||
) {
|
||||
let reasoning = pending_reasoning.take().filter(|value| !value.is_empty());
|
||||
if let Some(last_message) = messages.last_mut() {
|
||||
if last_message.role == CanonicalRole::Assistant {
|
||||
if let Some(reasoning) = reasoning {
|
||||
prepend_openai_responses_reasoning_block(last_message, reasoning);
|
||||
}
|
||||
last_message.content.push(tool_use);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut content = Vec::new();
|
||||
if let Some(reasoning) = reasoning {
|
||||
content.push(openai_responses_reasoning_block(reasoning));
|
||||
}
|
||||
content.push(tool_use);
|
||||
messages.push(CanonicalMessage {
|
||||
role: CanonicalRole::Assistant,
|
||||
content,
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
fn prepend_openai_responses_reasoning_block(message: &mut CanonicalMessage, reasoning: String) {
|
||||
if message
|
||||
.content
|
||||
.iter()
|
||||
.any(|block| matches!(block, CanonicalContentBlock::Thinking { .. }))
|
||||
{
|
||||
return;
|
||||
}
|
||||
message
|
||||
.content
|
||||
.insert(0, openai_responses_reasoning_block(reasoning));
|
||||
}
|
||||
|
||||
fn openai_responses_reasoning_block(text: String) -> CanonicalContentBlock {
|
||||
let mut extensions = BTreeMap::new();
|
||||
canonical_extension_object_mut(&mut extensions, "openai")
|
||||
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
|
||||
CanonicalContentBlock::Thinking {
|
||||
text,
|
||||
signature: None,
|
||||
encrypted_content: None,
|
||||
extensions,
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_responses_reasoning_text(item_object: &Map<String, Value>) -> String {
|
||||
let mut parts = openai_responses_reasoning_text_parts(item_object.get("summary"));
|
||||
if parts.is_empty() {
|
||||
parts = openai_responses_reasoning_text_parts(item_object.get("content"));
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
fn openai_responses_reasoning_text_parts(raw: Option<&Value>) -> Vec<String> {
|
||||
let Some(raw) = raw else {
|
||||
return Vec::new();
|
||||
};
|
||||
match raw {
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(openai_responses_reasoning_text_part)
|
||||
.collect(),
|
||||
other => openai_responses_reasoning_text_part(other)
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_responses_reasoning_text_part(raw: &Value) -> Option<String> {
|
||||
if let Some(text) = raw.as_str() {
|
||||
return (!text.is_empty()).then(|| text.to_string());
|
||||
}
|
||||
let raw_object = raw.as_object()?;
|
||||
let text = raw_object.get("text").and_then(Value::as_str)?;
|
||||
(!text.is_empty()).then(|| text.to_string())
|
||||
}
|
||||
|
||||
fn openai_responses_chat_safe_content_to_blocks(
|
||||
content: Option<&Value>,
|
||||
) -> Option<Vec<CanonicalContentBlock>> {
|
||||
Some(
|
||||
openai_responses_content_to_blocks(content)?
|
||||
.into_iter()
|
||||
.filter(|block| !matches!(block, CanonicalContentBlock::Unknown { .. }))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn openai_responses_content_to_blocks(
|
||||
content: Option<&Value>,
|
||||
) -> Option<Vec<CanonicalContentBlock>> {
|
||||
@@ -2453,7 +2546,7 @@ fn canonical_tool_result_to_openai_chat(block: &CanonicalContentBlock) -> Value
|
||||
Value::Object(output)
|
||||
}
|
||||
|
||||
fn is_claude_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
|
||||
pub(crate) fn is_claude_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
|
||||
extensions
|
||||
.get(AETHER_EXTENSION_NAMESPACE)
|
||||
.and_then(|value| value.get("source"))
|
||||
@@ -2862,6 +2955,9 @@ pub(crate) fn openai_content_value_from_parts(parts: Vec<Value>, tool_only: bool
|
||||
if parts.is_empty() && tool_only {
|
||||
return Value::Null;
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return Value::String(String::new());
|
||||
}
|
||||
if parts.len() == 1 {
|
||||
if let Some(text) = parts[0]
|
||||
.as_object()
|
||||
@@ -5568,6 +5664,41 @@ mod tests {
|
||||
assert_eq!(rebuilt["n"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_request_adapter_preserves_reasoning_content_for_responses() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"reasoning_content": "internal plan",
|
||||
"content": "final answer"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let canonical = from_openai_chat_to_canonical_request(&request).expect("canonical request");
|
||||
assert!(matches!(
|
||||
canonical.messages[1].content.first(),
|
||||
Some(CanonicalContentBlock::Thinking { text, .. }) if text == "internal plan"
|
||||
));
|
||||
|
||||
let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false)
|
||||
.expect("openai responses request");
|
||||
let parts = rebuilt["input"][1]["content"]
|
||||
.as_array()
|
||||
.expect("content parts");
|
||||
|
||||
assert_eq!(parts[0]["type"], "output_text");
|
||||
assert!(parts[0]["text"]
|
||||
.as_str()
|
||||
.expect("reasoning text")
|
||||
.contains("<thinking>internal plan</thinking>"));
|
||||
assert_eq!(parts[1]["type"], "output_text");
|
||||
assert_eq!(parts[1]["text"], "final answer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_request_adapter_preserves_audio_reasoning_tools_and_text_config() {
|
||||
let request = json!({
|
||||
|
||||
Reference in New Issue
Block a user