mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user