Skip to content

Commit 8aca287

Browse files
committed
fix(translation): classify OpenAI audio and video inputs
Signed-off-by: Todd Fisher <todd.fisher@gmail.com>
1 parent 7e9ee98 commit 8aca287

4 files changed

Lines changed: 137 additions & 3 deletions

File tree

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,16 @@ pub(crate) fn decode_openai_content(
480480
source: decode_file_source(block),
481481
});
482482
}
483+
Some("audio") | Some("audio_url") | Some("input_audio") => {
484+
content.push(ContentBlock::Audio {
485+
source: decode_media_source(block, "audio"),
486+
});
487+
}
488+
Some("video") | Some("video_url") | Some("input_video") => {
489+
content.push(ContentBlock::Video {
490+
source: decode_media_source(block, "video"),
491+
});
492+
}
483493
_ => content.push(ContentBlock::Unknown {
484494
provider: provider.into(),
485495
raw: Value::Object(block.clone()),
@@ -561,6 +571,76 @@ pub(crate) fn decode_file_source(block: &Map<String, Value>) -> FileSource {
561571
FileSource::Raw(Value::Object(block.clone()))
562572
}
563573

574+
/// Decodes common OpenAI audio and video block shapes into normalized media sources.
575+
pub(crate) fn decode_media_source(block: &Map<String, Value>, kind: &str) -> MediaSource {
576+
let url_key = format!("{kind}_url");
577+
if let Some(value) = block.get(&url_key) {
578+
if let Some(url) = value.as_str() {
579+
return MediaSource::Url {
580+
url: url.to_string(),
581+
media_type: media_type(block, kind),
582+
};
583+
}
584+
if let Some(payload) = value.as_object()
585+
&& let Some(url) = payload.get("url").and_then(Value::as_str)
586+
{
587+
return MediaSource::Url {
588+
url: url.to_string(),
589+
media_type: media_type(payload, kind).or_else(|| media_type(block, kind)),
590+
};
591+
}
592+
}
593+
594+
let input_key = format!("input_{kind}");
595+
for key in [input_key.as_str(), kind] {
596+
let Some(value) = block.get(key) else {
597+
continue;
598+
};
599+
if let Some(data) = value.as_str() {
600+
return MediaSource::Base64 {
601+
media_type: media_type(block, kind),
602+
data: data.to_string(),
603+
};
604+
}
605+
if let Some(payload) = value.as_object() {
606+
if let Some(url) = payload.get("url").and_then(Value::as_str) {
607+
return MediaSource::Url {
608+
url: url.to_string(),
609+
media_type: media_type(payload, kind).or_else(|| media_type(block, kind)),
610+
};
611+
}
612+
if let Some(data) = payload.get("data").and_then(Value::as_str) {
613+
return MediaSource::Base64 {
614+
media_type: media_type(payload, kind).or_else(|| media_type(block, kind)),
615+
data: data.to_string(),
616+
};
617+
}
618+
}
619+
}
620+
621+
if let Some(data) = block.get("data").and_then(Value::as_str) {
622+
return MediaSource::Base64 {
623+
media_type: media_type(block, kind),
624+
data: data.to_string(),
625+
};
626+
}
627+
MediaSource::Raw(Value::Object(block.clone()))
628+
}
629+
630+
fn media_type(object: &Map<String, Value>, kind: &str) -> Option<String> {
631+
object
632+
.get("media_type")
633+
.or_else(|| object.get("format"))
634+
.and_then(Value::as_str)
635+
.map(|value| {
636+
if value.contains('/') {
637+
value.to_string()
638+
} else {
639+
format!("{kind}/{value}")
640+
}
641+
})
642+
}
643+
564644
/// Decodes one OpenAI tool call into a normalized tool call.
565645
pub(crate) fn decode_openai_tool_call(
566646
tool_call: &Value,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ mod stream;
99
pub use buffered::OpenAiChatCodec;
1010
pub use stream::OpenAiChatStreamCodec;
1111

12-
pub(crate) use buffered::{decode_file_source, decode_image_source};
12+
pub(crate) use buffered::{decode_file_source, decode_image_source, decode_media_source};

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use serde_json::{Map, Value, json};
1010
use crate::codecs::common::{
1111
is_known_role_name, provider_extensions, reasoning_text_from_blocks, text_from_blocks,
1212
};
13-
use crate::codecs::openai_chat::{decode_file_source, decode_image_source};
13+
use crate::codecs::openai_chat::{decode_file_source, decode_image_source, decode_media_source};
1414
use crate::codecs::{
1515
DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec,
1616
};
@@ -659,6 +659,16 @@ fn decode_responses_content(value: &Value) -> Vec<ContentBlock> {
659659
Some("input_file") => out.push(ContentBlock::File {
660660
source: decode_file_source(block),
661661
}),
662+
Some("audio") | Some("audio_url") | Some("input_audio") => {
663+
out.push(ContentBlock::Audio {
664+
source: decode_media_source(block, "audio"),
665+
});
666+
}
667+
Some("video") | Some("video_url") | Some("input_video") => {
668+
out.push(ContentBlock::Video {
669+
source: decode_media_source(block, "video"),
670+
});
671+
}
662672
_ => out.push(ContentBlock::Unknown {
663673
provider: WireFormat::OpenAiResponses.into(),
664674
raw: Value::Object(block.clone()),

crates/switchyard-translation/tests/request_translation.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,55 @@
66
use pretty_assertions::assert_eq;
77
use serde_json::{Value, json};
88
use switchyard_translation::{
9-
LossyConversionPolicy, TranslationEngine, TranslationPolicy, WireFormat,
9+
InputModality, LossyConversionPolicy, TranslationEngine, TranslationPolicy, WireFormat,
1010
};
1111

1212
type TestResult = std::result::Result<(), Box<dyn std::error::Error + Send + Sync>>;
1313

14+
#[test]
15+
fn openai_request_media_blocks_are_typed_for_modality_routing() -> TestResult {
16+
let engine = TranslationEngine::default();
17+
let chat = engine.decode_request(
18+
WireFormat::OpenAiChat,
19+
&json!({
20+
"model": "gpt",
21+
"messages": [{
22+
"role": "user",
23+
"content": [{
24+
"type": "input_audio",
25+
"input_audio": {"data": "AAAA", "format": "wav"}
26+
}]
27+
}]
28+
}),
29+
&TranslationPolicy::default(),
30+
)?;
31+
assert_eq!(
32+
chat.request.input_modalities(),
33+
[InputModality::Audio].into_iter().collect()
34+
);
35+
36+
let responses = engine.decode_request(
37+
WireFormat::OpenAiResponses,
38+
&json!({
39+
"model": "gpt",
40+
"input": [{
41+
"type": "message",
42+
"role": "user",
43+
"content": [{
44+
"type": "input_video",
45+
"video": {"media_type": "video/mp4", "data": "AAAA"}
46+
}]
47+
}]
48+
}),
49+
&TranslationPolicy::default(),
50+
)?;
51+
assert_eq!(
52+
responses.request.input_modalities(),
53+
[InputModality::Video].into_iter().collect()
54+
);
55+
Ok(())
56+
}
57+
1458
// Verifies Anthropic-only request fields are dropped or mapped for OpenAI Chat.
1559
#[test]
1660
fn anthropic_request_translates_to_openai_chat_without_anthropic_only_fields() -> TestResult {

0 commit comments

Comments
 (0)