feat(proxy): extend tool-result media handling to all conversion bridges

Generalize the Codex Responses-to-Chat tool media mechanism from 6c9d444c
to the remaining protocol bridges, so image-bearing tool results are never
tokenized as base64 text on any conversion path:

- Claude-to-Chat: tool_result images become native image_url parts,
  batched into one synthetic user message after the tool message batch.
- Claude-to-Responses: JSON-string, MCP, and nested variants are restored
  as native input_image parts inside function_call_output.
- Codex/GrokBuild-to-Anthropic: non-standard tool images are restored as
  Anthropic image blocks (case-insensitive data:/http prefix parsing).
- Claude-to-Gemini: Gemini 3 uses multimodal functionResponse.parts;
  older models get inlineData parts in the same user turn. The new
  InlineImagesOnly scope keeps remote URLs and malformed data URLs in the
  legacy text form instead of emitting fileData the API would reject.

Shared changes in tool_media:

- Centralize the plan/queue/flush helpers used by both Chat bridges.
- strip_and_clamp_media_from_tool_value clamps residual base64 inside
  parsed JSON strings at any nesting depth before re-serialization.
- Emitted Chat parts no longer carry cache_control or
  prompt_cache_breakpoint, preserving the strip-all-cache_control
  contract that strict upstreams (GLM/Qwen) depend on.
- Media detection now requires full convertibility, keeping detection
  and extraction symmetric by construction.

The media sanitizer detects and strips the new shapes symmetrically
(Chat string tool results, Anthropic string tool results, Gemini
inlineData/fileData and functionResponse.parts); the legacy typed-block
replacement runs first so replacement stays a superset of detection and
cache_control survives on replaced Anthropic blocks.

No-media tool outputs keep byte-identical legacy representations on all
bridges to protect prompt-cache prefixes.
This commit is contained in:
Jason
2026-07-24 12:12:22 +08:00
parent 6c9d444c8a
commit 878c26f31e
8 changed files with 1666 additions and 185 deletions
@@ -18,6 +18,9 @@ use super::transform_responses::{sanitize_anthropic_tool_use_input, TOOL_RESULT_
use crate::proxy::error::ProxyError;
use crate::proxy::json_canonical::canonical_json_string;
use crate::proxy::sse::{strip_sse_field, take_sse_block};
use crate::proxy::tool_media::{
strip_and_clamp_media_from_tool_value, ToolMediaScope, TOOL_RESULT_MEDIA_ATTACHED_MARKER,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashSet};
@@ -723,10 +726,12 @@ struct ToolResultContent {
fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
match item.get("output") {
Some(Value::String(text)) => ToolResultContent {
content: json!(text),
is_error: false,
},
Some(text @ Value::String(_)) => {
alternate_image_tool_result_content(text).unwrap_or_else(|| ToolResultContent {
content: text.clone(),
is_error: false,
})
}
Some(Value::Array(parts)) => {
let mut content = Vec::new();
let mut is_error = false;
@@ -761,10 +766,26 @@ fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
}));
}
}
_ => content.push(json!({
"type":"text",
"text":canonical_json_string(part)
})),
_ => {
if let Some(alternate) = alternate_image_tool_result_content(part) {
is_error |= alternate.is_error;
match alternate.content {
Value::Array(mut blocks) => content.append(&mut blocks),
Value::String(text) => {
content.push(json!({"type":"text","text":text}))
}
other => content.push(json!({
"type":"text",
"text":canonical_json_string(&other)
})),
}
} else {
content.push(json!({
"type":"text",
"text":canonical_json_string(part)
}));
}
}
}
}
ToolResultContent {
@@ -772,10 +793,12 @@ fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
is_error,
}
}
Some(value) => ToolResultContent {
content: json!(canonical_json_string(value)),
is_error: false,
},
Some(value) => {
alternate_image_tool_result_content(value).unwrap_or_else(|| ToolResultContent {
content: json!(canonical_json_string(value)),
is_error: false,
})
}
None => ToolResultContent {
content: json!(canonical_json_string(item)),
is_error: false,
@@ -783,6 +806,96 @@ fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
}
}
/// Convert image-bearing tool-output variants that are not native Responses
/// content blocks. The shared traversal recognizes JSON strings, MCP image
/// blocks, Anthropic image blocks, Chat image_url blocks, nested `content`
/// wrappers, and whole image data URLs.
fn alternate_image_tool_result_content(value: &Value) -> Option<ToolResultContent> {
let mut cleaned = value.clone();
let replacement_block = json!({
"type":"input_text",
"text":TOOL_RESULT_MEDIA_ATTACHED_MARKER
});
let mut chat_media_parts = Vec::new();
let replaced = strip_and_clamp_media_from_tool_value(
&mut cleaned,
&mut chat_media_parts,
ToolMediaScope::ImagesOnly,
&replacement_block,
TOOL_RESULT_MEDIA_ATTACHED_MARKER,
);
if replaced == 0 {
return None;
}
let mut content = Vec::new();
let mut is_error = false;
append_sanitized_tool_result_value(&cleaned, &mut content, &mut is_error);
content.extend(
chat_media_parts
.iter()
.filter_map(image_block_from_input_image),
);
Some(ToolResultContent {
content: Value::Array(content),
is_error,
})
}
fn append_sanitized_tool_result_value(
value: &Value,
content: &mut Vec<Value>,
is_error: &mut bool,
) {
match value {
Value::String(text) => {
if text == TOOL_RESULT_ERROR_MARKER {
*is_error = true;
} else if !text.is_empty() {
content.push(json!({"type":"text","text":text}));
}
}
Value::Array(parts) => {
for part in parts {
match part.get("type").and_then(Value::as_str) {
Some("input_text" | "output_text" | "text") => {
if let Some(text) = part.get("text").and_then(Value::as_str) {
if text == TOOL_RESULT_ERROR_MARKER {
*is_error = true;
} else {
content.push(json!({"type":"text","text":text}));
}
}
}
_ => content.push(json!({
"type":"text",
"text":canonical_json_string(part)
})),
}
}
}
Value::Object(object)
if matches!(
object.get("type").and_then(Value::as_str),
Some("input_text" | "output_text" | "text")
) =>
{
if let Some(text) = object.get("text").and_then(Value::as_str) {
if text == TOOL_RESULT_ERROR_MARKER {
*is_error = true;
} else {
content.push(json!({"type":"text","text":text}));
}
}
}
other => content.push(json!({
"type":"text",
"text":canonical_json_string(other)
})),
}
}
/// Ensures the first message is a user: compacted/resumed sessions may start with
/// assistant or function_call, but Anthropic requires the first to be user, else 400.
/// An empty array is not handled (the caller decides whether to error).
@@ -1071,8 +1184,12 @@ fn image_block_from_input_image(part: &Value) -> Option<Value> {
.or_else(|| v.get("url").and_then(|u| u.as_str()).map(str::to_string))
})?;
if let Some(rest) = url.strip_prefix("data:") {
if url
.get(..5)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
{
// data:<media_type>;base64,<data>
let rest = &url[5..];
let (meta, data) = rest.split_once(',')?;
let media_type = meta.split(';').next().unwrap_or("image/png");
Some(json!({
@@ -1083,7 +1200,13 @@ fn image_block_from_input_image(part: &Value) -> Option<Value> {
"data": data
}
}))
} else if url.starts_with("http://") || url.starts_with("https://") {
} else if url
.get(..7)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
|| url
.get(..8)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
{
Some(json!({
"type": "image",
"source": { "type": "url", "url": url }
@@ -2555,6 +2678,80 @@ mod tests {
assert_eq!(content[1]["type"], "image");
}
#[test]
fn test_alternate_mcp_tool_image_is_not_stringified_for_anthropic() {
let response = responses_request_to_anthropic(
json!({
"model": "c",
"input": [
{"type": "function_call", "call_id": "c1", "name": "inspect", "arguments": "{}"},
{"type": "function_call_output", "call_id": "c1", "output": [{
"type": "image",
"mimeType": "image/webp",
"data": "MCP_ANTHROPIC_IMAGE_SENTINEL"
}]}
]
}),
4096,
)
.unwrap();
let content = &response["messages"][2]["content"][0]["content"];
assert_eq!(content[0]["type"], "text");
assert!(!content[0]["text"]
.as_str()
.unwrap()
.contains("MCP_ANTHROPIC_IMAGE_SENTINEL"));
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["media_type"], "image/webp");
assert_eq!(content[1]["source"]["data"], "MCP_ANTHROPIC_IMAGE_SENTINEL");
}
#[test]
fn test_json_string_nested_tool_image_is_not_text_for_anthropic() {
let residual_base64 = "A".repeat(20_000);
let encoded_output = json!({
"content": [
{"type": "input_text", "text": "caption"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,STRING_IMAGE_SENTINEL"
}
},
{"type": "video", "data": residual_base64}
]
})
.to_string();
let response = responses_request_to_anthropic(
json!({
"model": "c",
"input": [
{"type": "function_call", "call_id": "c1", "name": "inspect", "arguments": "{}"},
{"type": "function_call_output", "call_id": "c1", "output": encoded_output}
]
}),
4096,
)
.unwrap();
let content = response["messages"][2]["content"][0]["content"]
.as_array()
.unwrap();
let image = content
.iter()
.find(|block| block["type"] == "image")
.expect("stringified tool image should become an Anthropic image block");
assert_eq!(image["source"]["data"], "STRING_IMAGE_SENTINEL");
assert!(content
.iter()
.filter_map(|block| block.get("text").and_then(Value::as_str))
.all(|text| !text.contains("STRING_IMAGE_SENTINEL")));
let serialized = response.to_string();
assert!(serialized.contains("[cc-switch: omitted 20000 bytes]"));
assert!(!serialized.contains(&"A".repeat(64)));
}
#[test]
fn test_structured_tool_output_restores_error_file_and_unknown_parts() {
let response = responses_request_to_anthropic(