Symptom
The Responses codec serializes the adjacently-tagged ImageSource enum directly into image_url, emitting an object where the Responses API requires a bare URL or data-URI string. The same defect applies to FileSource in input_file. Any image or file translated into the Responses format is unreadable upstream.
Cause
crates/switchyard-translation/src/codecs/responses/buffered.rs (in encode_responses_content):
ContentBlock::Image { source } => {
blocks.push(json!({"type": "input_image", "image_url": source}));
}
...
ContentBlock::File { source } => {
blocks.push(json!({"type": "input_file", "file": source}));
}
ImageSource and FileSource are adjacently tagged (crates/protocol/src/llm.rs):
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
so the emitted item is
{"type": "input_image",
"image_url": {"type": "url", "data": {"url": "https://…", "detail": null}}}
Expected vs. actual
- Expected:
"image_url": "https://…" — a string, as the API requires and as the decoder reads back.
- Actual: a nested tagged object; upstream cannot read it.
⭐ The asymmetry is the clearest evidence this is unintended: all three codecs encode the same ImageSource, and only this one serializes it raw.
| Codec |
Handling |
openai_chat/buffered.rs |
openai_image_part(source) — builds {"url": …} explicitly ✅ |
anthropic/buffered.rs |
match source { … } — destructures ✅ |
responses/buffered.rs |
serializes the enum inline ⛔ |
And within the same match block in the Responses codec, Audio and Video are destructured correctly — only Image and File are not.
Why it survived the test suite
crates/switchyard-translation/tests/lossless_roundtrip.rs uses the Chat shape for the Responses fixture:
{"type": "input_image", "image_url": {"url": "https://example.test/image.png", "detail": "high"}}
decode_image_source accepts both an object and a string, so the fixture round-trips while never pinning the shape the encoder must emit. Real clients send "image_url": "data:image/png;base64,…" as a string.
Reproduction
Translate an Anthropic request carrying a base64 image to openai_responses and inspect input[0].content. The added test anthropic_image_encodes_as_responses_input_image_string fails on main and passes with the fix.
⚠ Note this is not reached on a same-format Responses→Responses route, because encode_request short-circuits to exact_preserved_request and replays the preserved body verbatim. It bites on cross-format routes.
Environment
- Commit:
7f3b2fe9 (main); introduced in the initial commit 86020fab, so present in v0.2.0 too.
Suggested fix
Destructure both enums into the Responses wire shape, mirroring openai_image_part, and record a lossy diagnostic instead of dropping an unmappable source silently. PR: see linked pull request.
Symptom
The Responses codec serializes the adjacently-tagged
ImageSourceenum directly intoimage_url, emitting an object where the Responses API requires a bare URL or data-URI string. The same defect applies toFileSourceininput_file. Any image or file translated into the Responses format is unreadable upstream.Cause
crates/switchyard-translation/src/codecs/responses/buffered.rs(inencode_responses_content):ImageSourceandFileSourceare adjacently tagged (crates/protocol/src/llm.rs):#[serde(tag = "type", content = "data", rename_all = "snake_case")]so the emitted item is
{"type": "input_image", "image_url": {"type": "url", "data": {"url": "https://…", "detail": null}}}Expected vs. actual
"image_url": "https://…"— a string, as the API requires and as the decoder reads back.⭐ The asymmetry is the clearest evidence this is unintended: all three codecs encode the same
ImageSource, and only this one serializes it raw.openai_chat/buffered.rsopenai_image_part(source)— builds{"url": …}explicitly ✅anthropic/buffered.rsmatch source { … }— destructures ✅responses/buffered.rsAnd within the same match block in the Responses codec,
AudioandVideoare destructured correctly — onlyImageandFileare not.Why it survived the test suite
crates/switchyard-translation/tests/lossless_roundtrip.rsuses the Chat shape for the Responses fixture:{"type": "input_image", "image_url": {"url": "https://example.test/image.png", "detail": "high"}}decode_image_sourceaccepts both an object and a string, so the fixture round-trips while never pinning the shape the encoder must emit. Real clients send"image_url": "data:image/png;base64,…"as a string.Reproduction
Translate an Anthropic request carrying a base64 image to
openai_responsesand inspectinput[0].content. The added testanthropic_image_encodes_as_responses_input_image_stringfails onmainand passes with the fix.⚠ Note this is not reached on a same-format Responses→Responses route, because
encode_requestshort-circuits toexact_preserved_requestand replays the preserved body verbatim. It bites on cross-format routes.Environment
7f3b2fe9(main); introduced in the initial commit86020fab, so present inv0.2.0too.Suggested fix
Destructure both enums into the Responses wire shape, mirroring
openai_image_part, and record a lossy diagnostic instead of dropping an unmappable source silently. PR: see linked pull request.