Merge pull request #414 from stabey/fix/claude-tool-results-openai-chat

fix: preserve Claude tool results in OpenAI chat conversion
This commit is contained in:
fawney19
2026-05-10 02:50:36 +08:00
committed by GitHub
3 changed files with 663 additions and 28 deletions

View File

@@ -157,7 +157,7 @@ fn request_context(mapped_model: &str, upstream_is_stream: bool) -> FormatContex
#[cfg(test)]
mod tests {
use serde_json::json;
use serde_json::{json, Value};
use super::{
convert_openai_chat_request_to_claude_request,
@@ -215,4 +215,339 @@ mod tests {
assert_eq!(converted["messages"][0]["role"], "user");
assert_eq!(converted["messages"][0]["content"], "hello");
}
#[test]
fn request_normalizer_preserves_multiple_claude_tool_results() {
let body = json!({
"model": "claude-sonnet",
"messages": [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": "lookup",
"input": {"query": "alpha"}
},
{
"type": "tool_use",
"id": "toolu_2",
"name": "lookup",
"input": {"query": "beta"}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "alpha result"
},
{
"type": "tool_result",
"tool_use_id": "toolu_2",
"content": [{"type": "text", "text": "beta result"}]
}
]
}
],
"max_tokens": 128,
});
let converted =
normalize_claude_request_to_openai_chat_request(&body).expect("openai chat request");
let messages = converted["messages"].as_array().expect("messages");
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["tool_calls"].as_array().unwrap().len(), 2);
assert_eq!(messages[0]["tool_calls"][0]["id"], "toolu_1");
assert_eq!(messages[0]["tool_calls"][1]["id"], "toolu_2");
assert_eq!(messages[1]["role"], "tool");
assert_eq!(messages[1]["tool_call_id"], "toolu_1");
assert_eq!(messages[1]["content"], "alpha result");
assert_eq!(messages[2]["role"], "tool");
assert_eq!(messages[2]["tool_call_id"], "toolu_2");
assert_eq!(messages[2]["content"], "beta result");
}
#[test]
fn request_normalizer_preserves_claude_tool_result_order_around_text() {
let body = json!({
"model": "claude-sonnet",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "before"},
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "first"
},
{"type": "text", "text": "between"},
{
"type": "tool_result",
"tool_use_id": "toolu_2",
"content": "second"
}
]
}],
"max_tokens": 128,
});
let converted =
normalize_claude_request_to_openai_chat_request(&body).expect("openai chat request");
let messages = converted["messages"].as_array().expect("messages");
assert_eq!(messages.len(), 4);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[0]["content"], "before");
assert_eq!(messages[1]["role"], "tool");
assert_eq!(messages[1]["tool_call_id"], "toolu_1");
assert_eq!(messages[1]["content"], "first");
assert_eq!(messages[2]["role"], "user");
assert_eq!(messages[2]["content"], "between");
assert_eq!(messages[3]["role"], "tool");
assert_eq!(messages[3]["tool_call_id"], "toolu_2");
assert_eq!(messages[3]["content"], "second");
}
#[test]
fn request_normalizer_marks_claude_error_tool_result_string_and_object_content() {
let object_result = json!({"code": "ENOENT", "message": "missing"});
let body = json!({
"model": "claude-sonnet",
"messages": [{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_error_string",
"content": "lookup failed",
"is_error": true
},
{
"type": "tool_result",
"tool_use_id": "toolu_error_empty",
"content": "",
"is_error": true
},
{
"type": "tool_result",
"tool_use_id": "toolu_error_object",
"content": object_result,
"is_error": true
},
{
"type": "tool_result",
"tool_use_id": "toolu_ok",
"content": "still ok"
}
]
}],
"max_tokens": 128,
});
let converted =
normalize_claude_request_to_openai_chat_request(&body).expect("openai chat request");
let messages = converted["messages"].as_array().expect("messages");
assert_eq!(messages.len(), 4);
assert_eq!(messages[0]["role"], "tool");
assert_eq!(messages[0]["tool_call_id"], "toolu_error_string");
assert_eq!(messages[0]["content"], "[tool error]\nlookup failed");
assert_eq!(messages[1]["role"], "tool");
assert_eq!(messages[1]["tool_call_id"], "toolu_error_empty");
assert_eq!(messages[1]["content"], "[tool error]");
assert_eq!(messages[2]["role"], "tool");
assert_eq!(messages[2]["tool_call_id"], "toolu_error_object");
let object_content = messages[2]["content"].as_str().expect("object content");
let serialized_object = object_content
.strip_prefix("[tool error]\n")
.expect("error prefix");
assert_eq!(
serde_json::from_str::<Value>(serialized_object).expect("serialized object"),
object_result
);
assert_eq!(messages[3]["role"], "tool");
assert_eq!(messages[3]["tool_call_id"], "toolu_ok");
assert_eq!(messages[3]["content"], "still ok");
}
#[test]
fn request_normalizer_marks_claude_error_tool_result_multipart_image_content() {
let body = json!({
"model": "claude-sonnet",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_error_image",
"content": [
{"type": "text", "text": "preview"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "aW1hZ2U="
}
}
],
"is_error": true
}]
}],
"max_tokens": 128,
});
let converted =
normalize_claude_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"], "tool");
assert_eq!(messages[0]["tool_call_id"], "toolu_error_image");
let content = messages[0]["content"]
.as_array()
.expect("multipart error content");
assert_eq!(
content.as_slice(),
&[
json!({"type": "text", "text": "[tool error]"}),
json!({"type": "text", "text": "preview"}),
json!({
"type": "image_url",
"image_url": {"url": "data:image/png;base64,aW1hZ2U="}
}),
]
);
}
#[test]
fn request_normalizer_preserves_legal_openai_tool_content_for_claude_variants() {
let anthropic_blocks = json!([
{"type": "text", "text": "preview"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": "aGVsbG8="
}
},
{
"type": "image",
"source": {
"type": "url",
"url": "https://example.com/image.jpg"
}
},
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": "JVBERi0x"
}
},
{
"type": "document",
"source": {
"type": "url",
"url": "https://example.com/report.pdf"
}
},
{
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": "document body"
}
}
]);
let object_result = json!({"answer": 42, "ok": true});
let body = json!({
"model": "claude-sonnet",
"messages": [{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_object",
"content": object_result
},
{
"type": "tool_result",
"tool_use_id": "toolu_text_blocks",
"content": [
{"type": "text", "text": "line one"},
{"type": "text", "text": "line two"}
]
},
{
"type": "tool_result",
"tool_use_id": "toolu_anthropic_blocks",
"content": anthropic_blocks
}
]
}],
"max_tokens": 128,
});
let converted =
normalize_claude_request_to_openai_chat_request(&body).expect("openai chat request");
let messages = converted["messages"].as_array().expect("messages");
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["role"], "tool");
assert_eq!(messages[0]["tool_call_id"], "toolu_object");
let object_content = messages[0]["content"].as_str().expect("object content");
assert_eq!(
serde_json::from_str::<Value>(object_content).expect("serialized object"),
object_result
);
assert_eq!(messages[1]["role"], "tool");
assert_eq!(messages[1]["tool_call_id"], "toolu_text_blocks");
assert_eq!(messages[1]["content"], "line one\n\nline two");
assert_eq!(messages[2]["role"], "tool");
assert_eq!(messages[2]["tool_call_id"], "toolu_anthropic_blocks");
let block_content = messages[2]["content"]
.as_array()
.expect("multipart anthropic block content");
assert_eq!(
block_content.as_slice(),
&[
json!({"type": "text", "text": "preview"}),
json!({
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,aGVsbG8="}
}),
json!({
"type": "image_url",
"image_url": {"url": "https://example.com/image.jpg"}
}),
json!({
"type": "file",
"file": {"file_data": "data:application/pdf;base64,JVBERi0x"}
}),
json!({"type": "text", "text": "[File: https://example.com/report.pdf]"}),
json!({
"type": "text",
"text": "[Claude tool_result document content omitted: text/plain]"
}),
]
);
let block_content_json = Value::Array(block_content.clone()).to_string();
assert!(!block_content_json.contains("\"source\""));
assert!(!block_content_json.contains("document body"));
}
}

View File

@@ -3,7 +3,7 @@ use serde_json::{json, Value};
use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_extension_object_mut, canonical_message_to_openai_chat,
canonical_extension_object_mut, canonical_message_to_openai_chat_messages,
canonical_response_format_to_openai, canonical_tool_choice_to_openai,
canonical_tool_to_openai, namespace_extension_object, openai_content_text,
openai_extensions, openai_generation_config, openai_message_content_blocks,
@@ -148,7 +148,7 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
}
}
for message in &canonical.messages {
messages.push(canonical_message_to_openai_chat(message));
messages.extend(canonical_message_to_openai_chat_messages(message));
}
output.insert("messages".to_string(), Value::Array(messages));

View File

@@ -9,6 +9,9 @@ pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame};
pub(crate) const OPENAI_RESPONSES_EXTENSION_NAMESPACE: &str = "openai_responses";
pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli";
const AETHER_EXTENSION_NAMESPACE: &str = "aether";
const CLAUDE_TOOL_RESULT_SOURCE_MARKER: &str = "claude_tool_result";
const OPENAI_CHAT_TOOL_ERROR_PREFIX: &str = "[tool error]";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -1191,6 +1194,14 @@ pub(crate) fn claude_block_to_canonical_block(block: &Value) -> Option<Canonical
}),
"tool_result" => {
let content = block_object.get("content").cloned();
let mut extensions = claude_extensions(
block_object,
&["type", "tool_use_id", "content", "is_error"],
);
extensions.insert(
AETHER_EXTENSION_NAMESPACE.to_string(),
json!({ "source": CLAUDE_TOOL_RESULT_SOURCE_MARKER }),
);
Some(CanonicalContentBlock::ToolResult {
tool_use_id: block_object
.get("tool_use_id")
@@ -1204,10 +1215,7 @@ pub(crate) fn claude_block_to_canonical_block(block: &Value) -> Option<Canonical
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false),
extensions: claude_extensions(
block_object,
&["type", "tool_use_id", "content", "is_error"],
),
extensions,
})
}
_ => Some(CanonicalContentBlock::Unknown {
@@ -2050,7 +2058,64 @@ pub(crate) fn openai_part_to_canonical_block(part: &Value) -> Option<CanonicalCo
}
}
pub(crate) fn canonical_message_to_openai_chat(message: &CanonicalMessage) -> Value {
pub(crate) fn canonical_message_to_openai_chat_messages(message: &CanonicalMessage) -> Vec<Value> {
let mut messages = Vec::new();
let mut pending_start = 0usize;
let mut saw_tool_result = false;
for (index, block) in message.content.iter().enumerate() {
if let CanonicalContentBlock::ToolResult { .. } = block {
saw_tool_result = true;
if pending_start < index {
if let Some(message_value) = canonical_message_blocks_to_openai_chat(
message,
&message.content[pending_start..index],
false,
) {
messages.push(message_value);
}
}
messages.push(canonical_tool_result_to_openai_chat(block));
pending_start = index + 1;
}
}
if !saw_tool_result {
return vec![canonical_message_without_tool_results_to_openai_chat(
message,
)];
}
if pending_start < message.content.len() {
if let Some(message_value) = canonical_message_blocks_to_openai_chat(
message,
&message.content[pending_start..],
false,
) {
messages.push(message_value);
}
}
messages
}
fn canonical_message_without_tool_results_to_openai_chat(message: &CanonicalMessage) -> Value {
debug_assert!(
!message
.content
.iter()
.any(|block| matches!(block, CanonicalContentBlock::ToolResult { .. })),
"single OpenAI Chat message emission requires no ToolResult blocks; use canonical_message_to_openai_chat_messages"
);
canonical_message_blocks_to_openai_chat(message, &message.content, true)
.expect("include_empty=true always emits a chat message")
}
fn canonical_message_blocks_to_openai_chat(
message: &CanonicalMessage,
content: &[CanonicalContentBlock],
include_empty: bool,
) -> Option<Value> {
let mut output = Map::new();
output.insert(
"role".to_string(),
@@ -2069,7 +2134,7 @@ pub(crate) fn canonical_message_to_openai_chat(message: &CanonicalMessage) -> Va
let mut tool_calls = Vec::new();
let mut reasoning_segments = Vec::new();
let mut reasoning_parts = Vec::new();
for block in &message.content {
for block in content {
match block {
CanonicalContentBlock::Thinking {
text,
@@ -2123,25 +2188,7 @@ pub(crate) fn canonical_message_to_openai_chat(message: &CanonicalMessage) -> Va
"arguments": canonicalize_tool_arguments(input),
}
})),
CanonicalContentBlock::ToolResult {
tool_use_id,
content_text,
output: result_output,
..
} => {
output.insert("role".to_string(), Value::String("tool".to_string()));
output.insert(
"tool_call_id".to_string(),
Value::String(tool_use_id.clone()),
);
output.insert(
"content".to_string(),
result_output
.clone()
.unwrap_or_else(|| Value::String(content_text.clone().unwrap_or_default())),
);
return Value::Object(output);
}
CanonicalContentBlock::ToolResult { .. } => {}
other => {
if let Some(part) = canonical_content_block_to_openai_part(other) {
content_parts.push(part);
@@ -2149,6 +2196,14 @@ pub(crate) fn canonical_message_to_openai_chat(message: &CanonicalMessage) -> Va
}
}
}
if !include_empty
&& content_parts.is_empty()
&& tool_calls.is_empty()
&& reasoning_segments.is_empty()
&& reasoning_parts.is_empty()
{
return None;
}
output.insert(
"content".to_string(),
if !tool_calls.is_empty() && content_parts.is_empty() {
@@ -2173,9 +2228,254 @@ pub(crate) fn canonical_message_to_openai_chat(message: &CanonicalMessage) -> Va
if !reasoning_parts.is_empty() {
output.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts));
}
Some(Value::Object(output))
}
fn canonical_tool_result_to_openai_chat(block: &CanonicalContentBlock) -> Value {
let CanonicalContentBlock::ToolResult {
tool_use_id,
content_text,
output: result_output,
is_error,
extensions,
..
} = block
else {
unreachable!("canonical_tool_result_to_openai_chat requires ToolResult");
};
let mut output = Map::new();
output.insert("role".to_string(), Value::String("tool".to_string()));
output.insert(
"tool_call_id".to_string(),
Value::String(tool_use_id.clone()),
);
let content = if is_claude_tool_result(extensions) {
let content =
openai_chat_tool_result_content(result_output.as_ref(), content_text.as_deref());
if *is_error {
openai_chat_tool_error_content(content)
} else {
content
}
} else {
result_output
.clone()
.unwrap_or_else(|| Value::String(content_text.clone().unwrap_or_default()))
};
output.insert("content".to_string(), content);
Value::Object(output)
}
fn is_claude_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
.and_then(|value| value.get("source"))
.and_then(Value::as_str)
== Some(CLAUDE_TOOL_RESULT_SOURCE_MARKER)
}
fn openai_chat_tool_result_content(output: Option<&Value>, content_text: Option<&str>) -> Value {
match output {
Some(Value::String(text)) => Value::String(text.clone()),
Some(Value::Array(parts)) => anthropic_tool_result_blocks_to_openai_chat_content(parts),
Some(value) => Value::String(value.to_string()),
None => Value::String(content_text.unwrap_or_default().to_string()),
}
}
fn openai_chat_tool_error_content(content: Value) -> Value {
match content {
Value::String(text) if text.is_empty() => {
Value::String(OPENAI_CHAT_TOOL_ERROR_PREFIX.to_string())
}
Value::String(text) => Value::String(format!("{OPENAI_CHAT_TOOL_ERROR_PREFIX}\n{text}")),
Value::Array(parts) => {
let mut prefixed_parts = Vec::with_capacity(parts.len() + 1);
prefixed_parts.push(openai_text_part(OPENAI_CHAT_TOOL_ERROR_PREFIX));
prefixed_parts.extend(parts);
Value::Array(prefixed_parts)
}
value => Value::String(format!("{OPENAI_CHAT_TOOL_ERROR_PREFIX}\n{value}")),
}
}
fn anthropic_tool_result_blocks_to_openai_chat_content(parts: &[Value]) -> Value {
if let Some(text) = anthropic_text_blocks_to_string(parts) {
return Value::String(text);
}
let mut has_media_part = false;
let converted_parts = parts
.iter()
.map(|part| {
let openai_part = anthropic_tool_result_block_to_openai_chat_part(part);
if !openai_chat_part_is_text(&openai_part) {
has_media_part = true;
}
openai_part
})
.collect::<Vec<_>>();
if has_media_part {
Value::Array(converted_parts)
} else {
Value::String(openai_text_parts_to_string(&converted_parts))
}
}
fn anthropic_text_blocks_to_string(parts: &[Value]) -> Option<String> {
let mut texts = Vec::with_capacity(parts.len());
for part in parts {
let part_object = part.as_object()?;
if part_object.get("type").and_then(Value::as_str) != Some("text") {
return None;
}
texts.push(part_object.get("text").and_then(Value::as_str)?);
}
Some(texts.join("\n\n"))
}
fn anthropic_tool_result_block_to_openai_chat_part(part: &Value) -> Value {
let Some(part_object) = part.as_object() else {
return openai_text_part("[Claude tool_result non-text content omitted]");
};
match part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => openai_text_part(
part_object
.get("text")
.and_then(Value::as_str)
.unwrap_or_default(),
),
"image" => anthropic_image_block_to_openai_chat_part(part_object).unwrap_or_else(|| {
openai_text_part(anthropic_media_block_summary("image", part_object))
}),
"document" => {
anthropic_document_block_to_openai_chat_part(part_object).unwrap_or_else(|| {
openai_text_part(anthropic_media_block_summary("document", part_object))
})
}
"file" => anthropic_document_block_to_openai_chat_part(part_object).unwrap_or_else(|| {
openai_text_part(anthropic_media_block_summary("file", part_object))
}),
"" => openai_text_part("[Claude tool_result object content omitted]"),
raw_type => openai_text_part(format!("[Claude tool_result {raw_type} content omitted]")),
}
}
fn anthropic_image_block_to_openai_chat_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 = anthropic_source_media_type(source)?;
let data = anthropic_source_str(source, "data")?;
Some(json!({
"type": "image_url",
"image_url": {
"url": format!("data:{media_type};base64,{data}"),
},
}))
}
"url" => {
let url = anthropic_source_str(source, "url")?;
Some(json!({
"type": "image_url",
"image_url": {
"url": url,
},
}))
}
_ => None,
}
}
fn anthropic_document_block_to_openai_chat_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 = anthropic_source_media_type(source)?;
let data = anthropic_source_str(source, "data")?;
Some(json!({
"type": "file",
"file": {
"file_data": format!("data:{media_type};base64,{data}"),
},
}))
}
"url" => {
let url = anthropic_source_str(source, "url")?;
Some(openai_text_part(format!("[File: {url}]")))
}
_ => None,
}
}
fn anthropic_media_block_summary(kind: &str, block: &Map<String, Value>) -> String {
let media_type = block
.get("source")
.and_then(Value::as_object)
.and_then(anthropic_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 openai_text_part(text: impl Into<String>) -> Value {
json!({
"type": "text",
"text": text.into(),
})
}
fn openai_chat_part_is_text(part: &Value) -> bool {
part.as_object()
.and_then(|object| object.get("type"))
.and_then(Value::as_str)
== Some("text")
}
fn openai_text_parts_to_string(parts: &[Value]) -> String {
parts
.iter()
.filter_map(|part| {
part.as_object()
.and_then(|object| object.get("text"))
.and_then(Value::as_str)
})
.collect::<Vec<_>>()
.join("\n\n")
}
fn anthropic_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 anthropic_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())
}
pub(crate) fn canonical_content_block_to_openai_part(
block: &CanonicalContentBlock,
) -> Option<Value> {