Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -604,9 +604,21 @@ pub(crate) fn decode_file_source(block: &Map<String, Value>) -> FileSource {
}
return FileSource::Raw(Value::Object(file.clone()));
}
// A Responses `input_file` carries the payload directly on the block instead
// of nesting it under `file`, so read that shape too. Chat blocks never reach
// here with these keys, having matched the nested branch above.
if let Some(file_id) = block.get("file_id").and_then(Value::as_str) {
return FileSource::FileId(file_id.to_string());
}
if let Some(file_data) = block.get("file_data").and_then(Value::as_str) {
return FileSource::FileData {
data: file_data.to_string(),
filename: block
.get("filename")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
};
}
FileSource::Raw(Value::Object(block.clone()))
}

Expand Down
123 changes: 116 additions & 7 deletions crates/switchyard-translation/src/codecs/responses/buffered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ use crate::diagnostic::TranslationDiagnostic;
use crate::error::{Result, TranslationError};
use crate::format::{FormatId, WireFormat};
use crate::llm::{
AggLlmResponse, ContentBlock, InstructionBlock, LlmRequest, MediaSource, Message, OutputParams,
ProviderExtensions, ReasoningParams, ResponseOutput, Role, SamplingParams, StopReason,
ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage,
AggLlmResponse, ContentBlock, FileSource, ImageSource, InstructionBlock, LlmRequest,
MediaSource, Message, OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role,
SamplingParams, StopReason, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage,
};
use crate::policy::{DeterministicIdPolicy, TranslationPolicy};
use crate::util::{
Expand Down Expand Up @@ -1159,9 +1159,18 @@ fn encode_responses_content(
ContentBlock::Refusal { text } => {
blocks.push(json!({"type": "refusal", "refusal": text}));
}
ContentBlock::Image { source } => {
blocks.push(json!({"type": "input_image", "image_url": source}));
}
ContentBlock::Image { source } => match responses_image_part(source) {
Some(part) => blocks.push(part),
None => {
push_lossy(
diagnostics,
policy,
"Responses codec could not map image content",
)?;
let raw = serde_json::to_value(source).unwrap_or_default();
blocks.push(json!({"type": "input_text", "text": json_string(&raw)}));
}
},
ContentBlock::Audio { source } => blocks.push(match source {
MediaSource::Raw(raw) => json!({"type": "input_text", "text": json_string(raw)}),
MediaSource::Url { url, media_type } => json!({
Expand All @@ -1187,7 +1196,7 @@ fn encode_responses_content(
}),
}),
ContentBlock::File { source } => {
blocks.push(json!({"type": "input_file", "file": source}));
blocks.push(responses_file_part(source));
}
ContentBlock::Unknown { raw, .. } => {
push_lossy(
Expand All @@ -1205,6 +1214,106 @@ fn encode_responses_content(
Ok(Value::Array(blocks))
}

// Encodes a normalized image source as a Responses `input_image` part.
//
// `ImageSource` is adjacently tagged (`#[serde(tag = "type", content = "data")]`),
// so serializing it inline emits `{"type": "url", "data": {..}}` where the
// Responses API requires `image_url` to be a bare URL or data-URI string. The
// Chat and Anthropic codecs destructure it for this reason; this mirrors
// `openai_chat::openai_image_part`.
fn responses_image_part(source: &ImageSource) -> Option<Value> {
match source {
ImageSource::Url { url, detail } => {
let mut part = json!({"type": "input_image", "image_url": url});
if let Some(detail) = detail {
part["detail"] = Value::String(detail.clone());
}
Some(part)
}
ImageSource::Base64 { media_type, data } => media_type.as_ref().map(|media_type| {
json!({
"type": "input_image",
"image_url": format!("data:{media_type};base64,{data}"),
})
}),
ImageSource::Raw(raw) => responses_raw_image_part(raw),
}
}

// Recovers a Responses `input_image` part from a provider image source that has
// no normalized representation.
fn responses_raw_image_part(raw: &Value) -> Option<Value> {
if let Some(url) = raw.as_str() {
return Some(json!({"type": "input_image", "image_url": url}));
}
let object = raw.as_object()?;
// An Anthropic image arrives as the whole `{"type": "image", "source": {..}}`
// block, so the payload lives one level down.
let object = if object.get("type").and_then(Value::as_str) == Some("image") {
let source = object.get("source").and_then(Value::as_object)?;
if !matches!(
source.get("type").and_then(Value::as_str),
Some("base64" | "url")
) {
return None;
}
source
} else {
object
};
for key in ["url", "image_url"] {
if let Some(url) = object.get(key).and_then(Value::as_str) {
return Some(json!({"type": "input_image", "image_url": url}));
}
}
let data = object.get("data").and_then(Value::as_str)?;
let media_type = object
.get("media_type")
.and_then(Value::as_str)
.unwrap_or("application/octet-stream");
Some(json!({
"type": "input_image",
"image_url": format!("data:{media_type};base64,{data}"),
}))
}

// Encodes a normalized file source as a Responses `input_file` part.
//
// `FileSource` carries the same adjacent tagging as `ImageSource`, so it cannot
// be serialized inline. Unlike OpenAI Chat, which nests the payload under a
// `file` object, the Responses wire carries `file_id`, `file_data` and
// `filename` directly on the part -- nesting them produces a block upstream
// cannot read, so the file is accepted and then silently ignored, the same
// failure this commit fixes for images.
fn responses_file_part(source: &FileSource) -> Value {
let mut part = json!({"type": "input_file"});
match source {
FileSource::FileId(file_id) => {
part["file_id"] = Value::String(file_id.clone());
}
FileSource::FileData { data, filename } => {
part["file_data"] = Value::String(data.clone());
if let Some(filename) = filename {
part["filename"] = Value::String(filename.clone());
}
}
// A raw source is whatever the decoder could not normalize. It is an
// object in every path that builds one, so lift its keys onto the part
// rather than re-nesting them; `type` is already set above.
FileSource::Raw(raw) => match raw.as_object() {
Some(object) => {
for (key, value) in object {
if key != "type" {
part[key.as_str()] = value.clone();
}
}
}
None => part["file_data"] = raw.clone(),
},
}
part
}

// Encodes normalized tool definitions into Responses tool JSON.
// Encodes normalized tools as Responses entries.
//
Expand Down
Loading