Harden PII redaction format conversion

This commit is contained in:
elky
2026-06-14 20:36:57 +08:00
parent 68038c182b
commit 669636d3e4
15 changed files with 813 additions and 285 deletions
@@ -723,15 +723,13 @@ mod tests {
"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]"
}),
json!({"type": "text", "text": "document body"}),
]
);
let block_content_json = Value::Array(block_content.clone()).to_string();
assert!(!block_content_json.contains("\"source\""));
assert!(!block_content_json.contains("document body"));
assert!(block_content_json.contains("document body"));
assert!(!block_content_json.contains("content omitted"));
}
#[test]
@@ -960,4 +958,90 @@ mod tests {
"data:image/png;base64,AAAA"
);
}
#[test]
fn claude_request_to_responses_rejects_unrepresentable_tool_result_blocks() {
let body = json!({
"model": "claude-sonnet",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_read",
"content": [{
"type": "image",
"source": {
"type": "unsupported",
"media_type": "image/png",
"data": "AAAA"
}
}]
}]
}],
"max_tokens": 128,
});
let error = registry::convert_request(
"claude:messages",
"openai:responses",
&body,
&FormatContext::default(),
)
.expect_err("unrepresentable Claude tool_result block should fail closed");
assert!(matches!(
error,
registry::FormatError::LossyConversionBlocked {
ref source_format,
ref target_format,
ref field,
..
} if source_format == "claude:messages"
&& target_format == "openai:responses"
&& field == "messages[].content[].tool_result.content"
));
}
#[test]
fn claude_request_to_openai_chat_rejects_unrepresentable_tool_result_blocks() {
let body = json!({
"model": "claude-sonnet",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_read",
"content": [{
"type": "image",
"source": {
"type": "unsupported",
"media_type": "image/png",
"data": "AAAA"
}
}]
}]
}],
"max_tokens": 128,
});
let error = registry::convert_request(
"claude:messages",
"openai:chat",
&body,
&FormatContext::default(),
)
.expect_err("unrepresentable Claude tool_result block should fail closed for Chat");
assert!(matches!(
error,
registry::FormatError::LossyConversionBlocked {
ref source_format,
ref target_format,
ref field,
..
} if source_format == "claude:messages"
&& target_format == "openai:chat"
&& field == "messages[].content[].tool_result.content"
));
}
}
@@ -5,10 +5,11 @@ use crate::{
protocol::canonical::{
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,
openai_response_format_to_canonical, openai_responses_extension, openai_role_to_canonical,
openai_tool_choice_to_canonical, openai_tools_to_canonical, write_openai_generation_config,
canonical_tool_to_openai, is_claude_tool_result, namespace_extension_object,
openai_content_text, openai_extensions, openai_generation_config,
openai_message_content_blocks, openai_response_format_to_canonical,
openai_responses_extension, openai_role_to_canonical, openai_tool_choice_to_canonical,
openai_tools_to_canonical, write_openai_generation_config, CanonicalContentBlock,
CanonicalInstruction, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
@@ -19,6 +20,9 @@ pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
if canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(request) {
return None;
}
let mut body = to_raw(request);
force_stream_options(&mut body, ctx.upstream_is_stream);
Some(body)
@@ -219,6 +223,94 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
Value::Object(output)
}
fn canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(
request: &CanonicalRequest,
) -> bool {
request.messages.iter().any(|message| {
message.content.iter().any(|block| {
let CanonicalContentBlock::ToolResult {
output, extensions, ..
} = block
else {
return false;
};
is_claude_tool_result(extensions)
&& output
.as_ref()
.and_then(Value::as_array)
.is_some_and(|parts| {
!claude_tool_result_parts_are_openai_chat_representable(parts)
})
})
})
}
pub(crate) fn claude_tool_result_parts_are_openai_chat_representable(parts: &[Value]) -> bool {
parts
.iter()
.all(claude_tool_result_part_is_openai_chat_representable)
}
fn claude_tool_result_part_is_openai_chat_representable(part: &Value) -> bool {
let Some(part_object) = part.as_object() else {
return false;
};
match part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => true,
"image" => claude_image_block_is_openai_chat_representable(part_object),
"document" | "file" => claude_document_block_is_openai_chat_representable(part_object),
_ => false,
}
}
fn claude_image_block_is_openai_chat_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" => {
non_empty_source_str(source, "media_type").is_some()
&& non_empty_source_str(source, "data").is_some()
}
"url" => non_empty_source_str(source, "url").is_some(),
_ => false,
}
}
fn claude_document_block_is_openai_chat_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" => {
non_empty_source_str(source, "media_type").is_some()
&& non_empty_source_str(source, "data").is_some()
}
"url" => non_empty_source_str(source, "url").is_some(),
"text" => non_empty_source_str(source, "data").is_some(),
_ => false,
}
}
fn non_empty_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 openai_chat_reasoning_effort(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some("low"),
@@ -738,6 +738,34 @@ fn strip_codex_hosted_tool_choice_name_for_backend(
}
}
fn wrap_codex_responses_string_input_for_backend(
body_object: &mut serde_json::Map<String, Value>,
provider_api_format: &str,
) {
if !aether_ai_formats::is_openai_responses_family_format(provider_api_format) {
return;
}
let Some(text) = body_object
.get("input")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
else {
return;
};
body_object.insert(
"input".to_string(),
json!([{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": text,
}],
}]),
);
}
pub fn apply_codex_openai_responses_special_body_edits(
provider_request_body: &mut Value,
provider_type: &str,
@@ -760,6 +788,7 @@ pub fn apply_codex_openai_responses_special_body_edits(
return;
};
wrap_codex_responses_string_input_for_backend(body_object, provider_api_format);
for field in CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS {
if !body_rules_handle_path(body_rules, field) {
body_object.remove(*field);
@@ -985,6 +1014,34 @@ mod tests {
assert_eq!(provider_request_body["parallel_tool_calls"], json!(false));
}
#[test]
fn codex_responses_body_edits_wrap_string_input_for_backend() {
let mut provider_request_body = json!({
"input": "hello",
"model": "gpt-5.4"
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:responses",
None,
None,
);
assert_eq!(
provider_request_body["input"],
json!([{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "hello"
}]
}])
);
}
#[test]
fn codex_responses_body_edits_preserve_function_tools_for_codex_backend() {
let mut provider_request_body = json!({
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, VecDeque};
use serde_json::{json, Map, Value};
@@ -297,6 +297,8 @@ fn claude_system_instruction_to_responses_part(
fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option<Vec<Value>> {
let mut input = Vec::new();
let mut next_generated_tool_call_index = 0usize;
let mut pending_tool_call_ids = VecDeque::new();
for message in &canonical.messages {
let role = match message.role {
CanonicalRole::Assistant => "assistant",
@@ -315,10 +317,13 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
} => {
flush_responses_message(&mut input, role, &mut content);
saw_tool_item = true;
let call_id = responses_tool_call_id(id, &mut next_generated_tool_call_index);
let tool_name = responses_tool_name(name);
pending_tool_call_ids.push_back(call_id.clone());
input.push(json!({
"type": "function_call",
"call_id": id,
"name": name,
"call_id": call_id,
"name": tool_name,
"arguments": canonicalize_tool_arguments(arguments),
}));
}
@@ -335,10 +340,12 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
output.as_ref(),
content_text.as_deref(),
extensions,
);
)?;
let call_id =
responses_tool_result_call_id(tool_use_id, &mut pending_tool_call_ids)?;
input.push(json!({
"type": "function_call_output",
"call_id": tool_use_id,
"call_id": call_id,
"output": tool_output,
}));
if !extra_user_content.is_empty() {
@@ -393,6 +400,42 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
Some(input)
}
fn responses_tool_call_id(id: &str, next_generated_tool_call_index: &mut usize) -> String {
let trimmed = id.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
let generated = format!("call_auto_{next_generated_tool_call_index}");
*next_generated_tool_call_index += 1;
generated
}
fn responses_tool_result_call_id(
id: &str,
pending_tool_call_ids: &mut VecDeque<String>,
) -> Option<String> {
let trimmed = id.trim();
if !trimmed.is_empty() {
if let Some(position) = pending_tool_call_ids
.iter()
.position(|pending_id| pending_id == trimmed)
{
pending_tool_call_ids.remove(position);
}
return Some(trimmed.to_string());
}
pending_tool_call_ids.pop_front()
}
fn responses_tool_name(name: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
"unknown".to_string()
} else {
trimmed.to_string()
}
}
fn responses_max_output_tokens(canonical: &CanonicalRequest) -> Option<u64> {
canonical.generation.max_tokens.map(|max_tokens| {
if is_claude_messages_request(&canonical.extensions) && max_tokens < 128 {
@@ -773,16 +816,16 @@ fn responses_tool_result_payload(
output: Option<&Value>,
content_text: Option<&str>,
extensions: &BTreeMap<String, Value>,
) -> (Value, Vec<Value>) {
) -> Option<(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);
}
}
(
Some((
responses_tool_result_output(output, content_text),
Vec::new(),
)
))
}
fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value {
@@ -795,15 +838,64 @@ fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&st
Value::String(non_empty_responses_tool_output(&text))
}
fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> (Value, Vec<Value>) {
pub(crate) fn claude_tool_result_parts_are_openai_responses_representable(parts: &[Value]) -> bool {
parts
.iter()
.all(claude_tool_result_part_is_openai_responses_representable)
}
fn claude_tool_result_part_is_openai_responses_representable(part: &Value) -> bool {
let Some(part_object) = part.as_object() else {
return false;
};
match part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => true,
"image" => claude_image_block_is_openai_responses_representable(part_object),
"document" | "file" => claude_document_block_is_openai_responses_representable(part_object),
_ => false,
}
}
fn claude_image_block_is_openai_responses_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" => claude_source_str(source, "data").is_some(),
"url" => claude_source_str(source, "url").is_some(),
_ => false,
}
}
fn claude_document_block_is_openai_responses_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" | "text" => claude_source_str(source, "data").is_some(),
"url" => claude_source_str(source, "url").is_some(),
_ => false,
}
}
fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> Option<(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;
};
let part_object = part.as_object()?;
match part_object
.get("type")
.and_then(Value::as_str)
@@ -820,27 +912,31 @@ fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> (Value, Vec
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));
return None;
}
}
"document" | "file" => {
if let Some(part) = claude_document_block_to_responses_input_part(part_object) {
if let Some(text) = claude_text_document_block_to_responses_output_text(part_object)
{
if !text.is_empty() {
output_texts.push(text.to_string());
}
} else 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));
return None;
}
}
"" => output_texts.push("[Claude tool_result object content omitted]".to_string()),
raw_type => {
output_texts.push(format!("[Claude tool_result {raw_type} content omitted]"))
}
_ => return None,
}
}
(
Some((
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> {
@@ -899,16 +995,15 @@ fn claude_document_block_to_responses_input_part(block: &Map<String, Value>) ->
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_text_document_block_to_responses_output_text(block: &Map<String, Value>) -> Option<&str> {
let source = block.get("source")?.as_object()?;
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => claude_source_str(source, "data"),
_ => None,
}
}
@@ -949,6 +1044,16 @@ mod tests {
CanonicalRole,
};
use serde_json::json;
use std::collections::BTreeMap;
fn claude_tool_result_extensions() -> BTreeMap<String, serde_json::Value> {
let mut extensions = BTreeMap::new();
extensions.insert(
"aether".to_string(),
json!({ "source": "claude_tool_result" }),
);
extensions
}
#[test]
fn json_object_response_injects_json_hint_into_input_when_only_instructions_have_it() {
@@ -974,12 +1079,17 @@ mod tests {
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["text"]["format"]["type"], json!("json_object"));
assert_eq!(body["input"][0]["role"], json!("system"));
assert!(body["input"][0]["content"][0]["text"]
assert_eq!(body["instructions"], json!("Please answer in JSON."));
let input = body["input"].as_array().expect("input");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["role"], json!("system"));
assert!(input[0]["content"][0]["text"]
.as_str()
.expect("hint text")
.to_ascii_lowercase()
.contains("json"));
assert_eq!(input[1]["role"], json!("user"));
assert_eq!(input[1]["content"][0]["text"], json!("hello"));
}
#[test]
@@ -1039,4 +1149,192 @@ mod tests {
assert_eq!(body["input"][0]["call_id"], "call_empty");
assert_eq!(body["input"][0]["output"], "(empty)");
}
#[test]
fn responses_request_replaces_empty_tool_call_identifiers() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![
CanonicalMessage {
role: CanonicalRole::Assistant,
content: vec![CanonicalContentBlock::ToolUse {
id: " ".to_string(),
name: "".to_string(),
input: json!({"q": "rust"}),
extensions: Default::default(),
}],
extensions: Default::default(),
},
CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "".to_string(),
name: None,
output: Some(json!({"ok": true})),
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(), 2);
assert_eq!(body["input"][0]["type"], "function_call");
assert_eq!(body["input"][0]["call_id"], "call_auto_0");
assert_eq!(body["input"][0]["name"], "unknown");
assert_eq!(body["input"][0]["arguments"], "{\"q\":\"rust\"}");
assert_eq!(body["input"][1]["type"], "function_call_output");
assert_eq!(body["input"][1]["call_id"], "call_auto_0");
}
#[test]
fn responses_request_assigns_empty_tool_result_identifiers_from_pending_tool_calls_in_order() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![
CanonicalMessage {
role: CanonicalRole::Assistant,
content: vec![
CanonicalContentBlock::ToolUse {
id: "call_a".to_string(),
name: "lookup_a".to_string(),
input: json!({"q": "a"}),
extensions: Default::default(),
},
CanonicalContentBlock::ToolUse {
id: "call_b".to_string(),
name: "lookup_b".to_string(),
input: json!({"q": "b"}),
extensions: Default::default(),
},
],
extensions: Default::default(),
},
CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![
CanonicalContentBlock::ToolResult {
tool_use_id: " ".to_string(),
name: None,
output: Some(json!("result a")),
content_text: None,
is_error: false,
extensions: Default::default(),
},
CanonicalContentBlock::ToolResult {
tool_use_id: "".to_string(),
name: None,
output: Some(json!("result b")),
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"][0]["call_id"], "call_a");
assert_eq!(body["input"][1]["call_id"], "call_b");
assert_eq!(body["input"][2]["call_id"], "call_a");
assert_eq!(body["input"][2]["output"], "result a");
assert_eq!(body["input"][3]["call_id"], "call_b");
assert_eq!(body["input"][3]["output"], "result b");
}
#[test]
fn responses_request_rejects_orphan_empty_tool_result_identifier() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: " ".to_string(),
name: None,
output: Some(json!({"ok": true})),
content_text: None,
is_error: false,
extensions: Default::default(),
}],
extensions: Default::default(),
}],
..CanonicalRequest::default()
};
assert!(to_raw(&request, "gpt-5.5", false, false).is_none());
}
#[test]
fn responses_request_preserves_claude_text_document_tool_result_content() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "call_doc".to_string(),
name: None,
output: Some(json!([
{"type": "text", "text": "preview"},
{
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": "document body"
}
}
])),
content_text: None,
is_error: false,
extensions: claude_tool_result_extensions(),
}],
extensions: Default::default(),
}],
..CanonicalRequest::default()
};
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["input"][0]["type"], "function_call_output");
assert_eq!(body["input"][0]["output"], "preview\n\ndocument body");
assert!(!body.to_string().contains("content omitted"));
}
#[test]
fn responses_request_rejects_unrepresentable_claude_tool_result_blocks() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "call_img".to_string(),
name: None,
output: Some(json!([{
"type": "image",
"source": {
"type": "unsupported",
"media_type": "image/png",
"data": "AAAA"
}
}])),
content_text: None,
is_error: false,
extensions: claude_tool_result_extensions(),
}],
extensions: Default::default(),
}],
..CanonicalRequest::default()
};
assert!(to_raw(&request, "gpt-5.5", false, false).is_none());
}
}
@@ -124,6 +124,9 @@ pub fn convert_request(
body: &Value,
ctx: &FormatContext,
) -> Result<Value, FormatError> {
let source = parse_format(source_format)?;
let target = parse_format(target_format)?;
validate_runtime_request_conversion(source, target, body)?;
let mut request = parse_request(source_format, body, ctx)?;
if let Some(mapped_model) = ctx
.mapped_model
@@ -135,6 +138,43 @@ pub fn convert_request(
emit_request_inner(target_format, &request, ctx)
}
fn validate_runtime_request_conversion(
source: FormatId,
target: FormatId,
body: &Value,
) -> Result<(), FormatError> {
if source == FormatId::ClaudeMessages {
match target {
FormatId::OpenAiChat
if claude_request_contains_unrepresentable_tool_result_content_for_openai_chat(
body,
) =>
{
return Err(FormatError::LossyConversionBlocked {
source_format: source.as_str().to_string(),
target_format: target.as_str().to_string(),
field: "messages[].content[].tool_result.content".to_string(),
reason: "OpenAI Chat tool messages cannot represent one or more Claude tool_result content blocks".to_string(),
});
}
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact
if claude_request_contains_unrepresentable_tool_result_content_for_openai_responses(
body,
) =>
{
return Err(FormatError::LossyConversionBlocked {
source_format: source.as_str().to_string(),
target_format: target.as_str().to_string(),
field: "messages[].content[].tool_result.content".to_string(),
reason: "OpenAI Responses function_call_output cannot represent one or more Claude tool_result content blocks".to_string(),
});
}
_ => {}
}
}
Ok(())
}
pub fn parse_response(
source_format: &str,
body: &Value,
@@ -2022,6 +2062,54 @@ fn claude_request_contains_tool_result_content_array(body: &Value) -> bool {
})
}
fn claude_request_contains_unrepresentable_tool_result_content_for_openai_chat(
body: &Value,
) -> bool {
claude_request_contains_unrepresentable_tool_result_content(body, |parts| {
!openai_chat::request::claude_tool_result_parts_are_openai_chat_representable(parts)
})
}
fn claude_request_contains_unrepresentable_tool_result_content_for_openai_responses(
body: &Value,
) -> bool {
claude_request_contains_unrepresentable_tool_result_content(body, |parts| {
!openai_responses::request::claude_tool_result_parts_are_openai_responses_representable(
parts,
)
})
}
fn claude_request_contains_unrepresentable_tool_result_content(
body: &Value,
is_unrepresentable: impl Fn(&[Value]) -> bool,
) -> bool {
let Some(messages) = body
.as_object()
.and_then(|object| object.get("messages"))
.and_then(Value::as_array)
else {
return false;
};
messages.iter().any(|message| {
message
.get("content")
.and_then(Value::as_array)
.is_some_and(|blocks| {
blocks.iter().any(|block| {
block
.get("type")
.and_then(Value::as_str)
.is_some_and(|block_type| block_type.eq_ignore_ascii_case("tool_result"))
&& block
.get("content")
.and_then(Value::as_array)
.is_some_and(|parts| is_unrepresentable(parts.as_slice()))
})
})
})
}
fn gemini_request_contains_builtin_tool(body: &Value, camel: &str, snake: &str) -> bool {
let Some(tools) = body
.as_object()
@@ -463,8 +463,11 @@ pub fn from_openai_chat_to_canonical_request(body_json: &Value) -> Option<Canoni
crate::formats::openai::chat::request::from_raw(body_json)
}
pub fn canonical_to_openai_chat_request(canonical: &CanonicalRequest) -> Value {
crate::formats::openai::chat::request::to_raw(canonical)
pub fn canonical_to_openai_chat_request(canonical: &CanonicalRequest) -> Option<Value> {
crate::formats::openai::chat::request::to(
canonical,
&crate::formats::context::FormatContext::default(),
)
}
pub fn from_openai_responses_to_canonical_request(body_json: &Value) -> Option<CanonicalRequest> {
@@ -2771,16 +2774,16 @@ fn anthropic_tool_result_blocks_to_openai_chat_content(parts: &[Value]) -> Value
}
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<_>>();
let mut converted_parts = Vec::with_capacity(parts.len());
for part in parts {
let Some(openai_part) = anthropic_tool_result_block_to_openai_chat_part(part) else {
return Value::String(Value::Array(parts.to_vec()).to_string());
};
if !openai_chat_part_is_text(&openai_part) {
has_media_part = true;
}
converted_parts.push(openai_part);
}
if has_media_part {
Value::Array(converted_parts)
@@ -2801,34 +2804,22 @@ fn anthropic_text_blocks_to_string(parts: &[Value]) -> Option<String> {
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]");
};
fn anthropic_tool_result_block_to_openai_chat_part(part: &Value) -> Option<Value> {
let part_object = part.as_object()?;
match part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => openai_text_part(
"text" => Some(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]")),
)),
"image" => anthropic_image_block_to_openai_chat_part(part_object),
"document" | "file" => anthropic_document_block_to_openai_chat_part(part_object),
_ => None,
}
}
@@ -2883,23 +2874,11 @@ fn anthropic_document_block_to_openai_chat_part(block: &Map<String, Value>) -> O
let url = anthropic_source_str(source, "url")?;
Some(openai_text_part(format!("[File: {url}]")))
}
"text" => anthropic_source_str(source, "data").map(openai_text_part),
_ => 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",
@@ -5785,7 +5764,7 @@ mod tests {
assert_eq!(canonical_unknown_block_count(user_blocks), 1);
assert_eq!(canonical_request_unknown_block_count(&canonical), 1);
let rebuilt = canonical_to_openai_chat_request(&canonical);
let rebuilt = canonical_to_openai_chat_request(&canonical).expect("openai chat request");
assert_eq!(rebuilt["model"], "gpt-5");
assert_eq!(rebuilt["messages"][0]["role"], "system");
assert_eq!(rebuilt["messages"][1]["role"], "developer");
@@ -5873,7 +5852,7 @@ mod tests {
"n": 2
});
let canonical = from_openai_chat_to_canonical_request(&request).expect("canonical request");
let rebuilt = canonical_to_openai_chat_request(&canonical);
let rebuilt = canonical_to_openai_chat_request(&canonical).expect("openai chat request");
assert_eq!(rebuilt["model"], request["model"]);
assert_eq!(rebuilt["messages"], request["messages"]);
assert_eq!(rebuilt["stop"], Value::Array(vec![json!("x"), json!("y")]));
@@ -6368,7 +6347,8 @@ mod tests {
CanonicalContentBlock::ToolUse { ref id, .. } if id == "toolu_auto_0"
));
let openai_chat = canonical_to_openai_chat_request(&canonical);
let openai_chat =
canonical_to_openai_chat_request(&canonical).expect("openai chat request");
assert_eq!(
openai_chat["messages"][2]["reasoning_parts"][0]["signature"],
"sig_123"
@@ -6388,6 +6368,32 @@ mod tests {
assert_eq!(rebuilt["output_config"]["effort"], "medium");
}
#[test]
fn canonical_to_openai_chat_request_rejects_unrepresentable_claude_tool_result() {
let request = json!({
"model": "claude-sonnet",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_read",
"content": [{
"type": "image",
"source": {
"type": "unsupported",
"media_type": "image/png",
"data": "AAAA"
}
}]
}]
}]
});
let canonical = from_claude_to_canonical_request(&request).expect("canonical request");
assert!(canonical_to_openai_chat_request(&canonical).is_none());
}
#[test]
fn claude_response_adapter_preserves_thinking_signature_tool_and_cache_usage() {
let response = json!({