Skip to content

Commit c689466

Browse files
fix(translation): preserve multimodal tool results (#389)
* fix(translation): preserve multimodal tool results Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> * fix(translation): preserve raw document wrappers Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> * fix(translation): preserve Anthropic multimodal wrappers Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com> --------- Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Signed-off-by: Elias Shieh <32212900+ting-hong-shieh@users.noreply.github.com>
1 parent e9767d6 commit c689466

3 files changed

Lines changed: 332 additions & 33 deletions

File tree

crates/switchyard-translation/src/codecs/anthropic/buffered.rs

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -554,51 +554,55 @@ fn decode_anthropic_content_block(
554554
content: decode_tool_result_content(block.get("content").unwrap_or(&Value::Null)),
555555
is_error: block.get("is_error").and_then(Value::as_bool),
556556
})],
557-
Some("image") => {
558-
let source = block
559-
.get("source")
560-
.cloned()
561-
.map(ImageSource::Raw)
562-
.unwrap_or_else(|| ImageSource::Raw(Value::Object(block.clone())));
563-
vec![ContentBlock::Image { source }]
564-
}
557+
Some("image") => vec![ContentBlock::Image {
558+
source: ImageSource::Raw(Value::Object(block.clone())),
559+
}],
565560
Some("input_image") | Some("image_url") => decode_image_source(block)
566561
.map(|source| vec![ContentBlock::Image { source }])
567562
.unwrap_or_default(),
568563
Some("input_file") | Some("file") => vec![ContentBlock::File {
569564
source: decode_file_source(block),
570565
}],
566+
Some("document") => vec![ContentBlock::File {
567+
source: decode_anthropic_file_source(block),
568+
}],
571569
_ => vec![ContentBlock::Unknown {
572570
provider: WireFormat::AnthropicMessages.into(),
573571
raw: Value::Object(block.clone()),
574572
}],
575573
})
576574
}
577575

578-
// Converts Anthropic tool-result content into text-like IR blocks.
576+
// Preserves supported Anthropic tool-result blocks in the neutral IR.
579577
fn decode_tool_result_content(value: &Value) -> Vec<ContentBlock> {
580578
match value {
581579
Value::String(text) => vec![ContentBlock::Text { text: text.clone() }],
582580
Value::Array(blocks) => {
583-
let mut text = Vec::new();
581+
let mut content = Vec::new();
584582
for block in blocks {
585583
if let Some(block) = block.as_object() {
586-
if block.get("type").and_then(Value::as_str) == Some("text") {
587-
text.push(
588-
block
584+
match block.get("type").and_then(Value::as_str) {
585+
Some("text") => content.push(ContentBlock::Text {
586+
text: block
589587
.get("text")
590588
.and_then(Value::as_str)
591589
.unwrap_or_default()
592590
.to_string(),
593-
);
594-
} else {
595-
text.push(json_string(&Value::Object(block.clone())));
591+
}),
592+
Some("image") => content.push(ContentBlock::Image {
593+
source: ImageSource::Raw(Value::Object(block.clone())),
594+
}),
595+
Some("document") => content.push(ContentBlock::File {
596+
source: decode_anthropic_file_source(block),
597+
}),
598+
_ => content.push(ContentBlock::Unknown {
599+
provider: WireFormat::AnthropicMessages.into(),
600+
raw: Value::Object(block.clone()),
601+
}),
596602
}
597603
}
598604
}
599-
vec![ContentBlock::Text {
600-
text: text.join(" "),
601-
}]
605+
content
602606
}
603607
Value::Null => vec![ContentBlock::Text {
604608
text: String::new(),
@@ -609,6 +613,11 @@ fn decode_tool_result_content(value: &Value) -> Vec<ContentBlock> {
609613
}
610614
}
611615

616+
// Keeps Anthropic document fields together for same-format re-encoding.
617+
fn decode_anthropic_file_source(block: &Map<String, Value>) -> FileSource {
618+
FileSource::Raw(Value::Object(block.clone()))
619+
}
620+
612621
// Decodes Anthropic tool definitions into normalized tool definitions.
613622
fn decode_anthropic_tools(value: Option<&Value>) -> Vec<ToolDefinition> {
614623
value
@@ -815,11 +824,29 @@ fn encode_one_anthropic_block(block: &ContentBlock) -> Vec<Value> {
815824
"name": call.name,
816825
"input": anthropic_tool_input(&call.arguments),
817826
})],
818-
ContentBlock::ToolResult(result) => vec![json!({
819-
"type": "tool_result",
820-
"tool_use_id": sanitize_anthropic_tool_use_id(&result.tool_call_id),
821-
"content": text_from_blocks(&result.content, " "),
822-
})],
827+
ContentBlock::ToolResult(result) => {
828+
let content = if result.content.iter().all(|block| {
829+
matches!(
830+
block,
831+
ContentBlock::Text { .. } | ContentBlock::Refusal { .. }
832+
)
833+
}) {
834+
Value::String(text_from_blocks(&result.content, " "))
835+
} else {
836+
Value::Array(
837+
result
838+
.content
839+
.iter()
840+
.flat_map(encode_one_anthropic_tool_result_block)
841+
.collect(),
842+
)
843+
};
844+
vec![json!({
845+
"type": "tool_result",
846+
"tool_use_id": sanitize_anthropic_tool_use_id(&result.tool_call_id),
847+
"content": content,
848+
})]
849+
}
823850
ContentBlock::Image { source } => vec![match source {
824851
ImageSource::Url { url, .. } => {
825852
json!({"type": "image", "source": {"type": "url", "url": url}})
@@ -880,6 +907,29 @@ fn encode_one_anthropic_block(block: &ContentBlock) -> Vec<Value> {
880907
}
881908
}
882909

910+
// Encodes only provider-safe block shapes inside Anthropic tool results.
911+
fn encode_one_anthropic_tool_result_block(block: &ContentBlock) -> Vec<Value> {
912+
match block {
913+
ContentBlock::Text { .. }
914+
| ContentBlock::Refusal { .. }
915+
| ContentBlock::Image { .. }
916+
| ContentBlock::File { .. } => encode_one_anthropic_block(block),
917+
ContentBlock::Unknown { provider, raw }
918+
if provider.as_str() == WireFormat::AnthropicMessages.as_str() =>
919+
{
920+
vec![raw.clone()]
921+
}
922+
ContentBlock::Unknown { raw, .. } => {
923+
vec![json!({"type": "text", "text": json_string(raw)})]
924+
}
925+
ContentBlock::Reasoning { .. }
926+
| ContentBlock::Audio { .. }
927+
| ContentBlock::Video { .. }
928+
| ContentBlock::ToolCall(_)
929+
| ContentBlock::ToolResult(_) => Vec::new(),
930+
}
931+
}
932+
883933
// Anthropic requires `tool_use.input` to be object-shaped, while OpenAI and
884934
// Responses commonly carry function-call arguments as JSON strings.
885935
fn anthropic_tool_input(arguments: &Value) -> Value {

crates/switchyard-translation/src/codecs/openai_chat/buffered.rs

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -700,18 +700,30 @@ fn encode_message_with_tool_results_to_openai(
700700

701701
for block in &message.content {
702702
if let ContentBlock::ToolResult(result) = block {
703-
push_pending_openai_message(
704-
&mut out,
705-
message.role,
706-
&mut pending_content,
707-
diagnostics,
708-
policy,
709-
)?;
710703
out.push(json!({
711704
"role": "tool",
712705
"tool_call_id": result.tool_call_id,
713706
"content": text_from_blocks(&result.content, " "),
714707
}));
708+
let non_text = result
709+
.content
710+
.iter()
711+
.filter(|block| {
712+
!matches!(
713+
block,
714+
ContentBlock::Text { .. } | ContentBlock::Refusal { .. }
715+
)
716+
})
717+
.cloned()
718+
.collect::<Vec<_>>();
719+
if !non_text.is_empty() {
720+
push_lossy(
721+
diagnostics,
722+
policy,
723+
"OpenAI Chat tool messages only support text; non-text tool-result content was moved to a user message",
724+
)?;
725+
pending_content.extend(non_text);
726+
}
715727
} else {
716728
pending_content.push(block.clone());
717729
}
@@ -956,6 +968,18 @@ fn openai_image_part(source: &ImageSource) -> Option<Value> {
956968
// Recognizes common raw image shapes emitted by Anthropic and Responses.
957969
fn openai_raw_image_part(raw: &Value) -> Option<Value> {
958970
let object = raw.as_object()?;
971+
let object = if object.get("type").and_then(Value::as_str) == Some("image") {
972+
let source = object.get("source").and_then(Value::as_object)?;
973+
if !matches!(
974+
source.get("type").and_then(Value::as_str),
975+
Some("base64" | "url")
976+
) {
977+
return None;
978+
}
979+
source
980+
} else {
981+
object
982+
};
959983
if let Some(url) = object.get("url").and_then(Value::as_str) {
960984
return Some(json!({"type": "image_url", "image_url": {"url": url}}));
961985
}
@@ -999,8 +1023,26 @@ fn openai_file_part(source: &FileSource) -> Option<Value> {
9991023
}
10001024
Some(json!({"type": "file", "file": file}))
10011025
}
1002-
FileSource::Raw(_) => None,
1026+
FileSource::Raw(raw) => openai_raw_file_part(raw),
1027+
}
1028+
}
1029+
1030+
// Maps portable fields from raw Anthropic documents without forwarding provider-managed IDs.
1031+
fn openai_raw_file_part(raw: &Value) -> Option<Value> {
1032+
let block = raw.as_object()?;
1033+
if block.get("type").and_then(Value::as_str) != Some("document") {
1034+
return None;
1035+
}
1036+
let source = block.get("source").and_then(Value::as_object)?;
1037+
if source.get("type").and_then(Value::as_str) != Some("base64") {
1038+
return None;
1039+
}
1040+
let data = source.get("data").and_then(Value::as_str)?;
1041+
let mut file = json!({"file_data": data});
1042+
if let Some(title) = block.get("title").and_then(Value::as_str) {
1043+
file["filename"] = Value::String(title.to_string());
10031044
}
1045+
Some(json!({"type": "file", "file": file}))
10041046
}
10051047

10061048
// Converts file sources to deterministic text fallback content.

0 commit comments

Comments
 (0)