Skip to content

Commit dcd6558

Browse files
committed
fix(translation): harden Responses compatibility
Signed-off-by: Alex Steiner <asteiner@nvidia.com>
1 parent a17efa9 commit dcd6558

5 files changed

Lines changed: 240 additions & 21 deletions

File tree

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,22 @@ fn decode_responses_input(
327327
)?;
328328
continue;
329329
};
330-
match item.get("type").and_then(Value::as_str) {
331-
Some("message") => {
330+
let item_type = match item.get("type") {
331+
Some(Value::String(item_type)) => Some(item_type.as_str()),
332+
Some(_) => {
333+
return Err(TranslationError::InvalidType {
334+
path: format!("$.input[{index}].type"),
335+
expected: "a string",
336+
});
337+
}
338+
None => None,
339+
};
340+
let is_message = item_type == Some("message")
341+
|| (item_type.is_none()
342+
&& item.contains_key("role")
343+
&& item.contains_key("content"));
344+
match item_type {
345+
_ if is_message => {
332346
let role = request_role_from_responses(
333347
item.get("role").and_then(Value::as_str),
334348
&format!("$.input[{index}].role"),
@@ -423,6 +437,13 @@ fn decode_responses_input(
423437
is_error: None,
424438
});
425439
}
440+
None => {
441+
return Err(TranslationError::InvalidValue {
442+
path: format!("$.input[{index}].type"),
443+
message: "missing type discriminator on a non-message input item"
444+
.to_string(),
445+
});
446+
}
426447
_ => {
427448
let message = Message {
428449
role: Role::User,

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

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
//! Streaming codec for OpenAI Responses API events.
55
6+
use serde::Serialize;
67
use serde_json::{Value, json};
78

89
use crate::LlmResponseChunk;
@@ -34,14 +35,63 @@ impl StreamCodec for OpenAiResponsesStreamCodec {
3435
state: &mut StreamTranslationState,
3536
event: LlmResponseChunk,
3637
) -> Vec<Value> {
37-
encode_responses_stream(state, event)
38+
let events = encode_responses_stream(state, event);
39+
add_sequence_numbers(state, events)
40+
}
41+
42+
fn observe_replayed_event(
43+
&self,
44+
state: &mut StreamTranslationState,
45+
raw: &Value,
46+
normalized: Vec<LlmResponseChunk>,
47+
) {
48+
let replayed_terminal = normalized
49+
.iter()
50+
.any(|chunk| matches!(chunk, LlmResponseChunk::MessageStop { .. }));
51+
for chunk in normalized {
52+
drop(encode_responses_stream(state, chunk));
53+
}
54+
state.response_sequence_number = raw
55+
.get("sequence_number")
56+
.and_then(Value::as_u64)
57+
.map_or(state.response_sequence_number.saturating_add(1), |number| {
58+
number.saturating_add(1)
59+
});
60+
if replayed_terminal {
61+
state.finished = true;
62+
}
3863
}
3964

4065
fn finish(&self, state: &mut StreamTranslationState) -> Vec<Value> {
41-
finish_responses_stream(state)
66+
let events = finish_responses_stream(state);
67+
add_sequence_numbers(state, events)
4268
}
4369
}
4470

71+
/// Required fields shared by Responses stream snapshots.
72+
#[derive(Serialize)]
73+
struct ResponsesStreamResponse {
74+
id: String,
75+
object: &'static str,
76+
created_at: u64,
77+
completed_at: Option<u64>,
78+
error: Option<Value>,
79+
incomplete_details: Option<Value>,
80+
instructions: Option<Value>,
81+
metadata: Option<Value>,
82+
model: String,
83+
output: Vec<Value>,
84+
parallel_tool_calls: bool,
85+
frequency_penalty: Option<f64>,
86+
presence_penalty: Option<f64>,
87+
status: &'static str,
88+
temperature: Option<f64>,
89+
tool_choice: &'static str,
90+
tools: Vec<Value>,
91+
top_p: Option<f64>,
92+
usage: Value,
93+
}
94+
4595
// Decodes one OpenAI Responses event into neutral streaming events.
4696
fn decode_responses_stream(
4797
state: &mut StreamTranslationState,
@@ -224,6 +274,7 @@ fn finish_responses_stream(state: &mut StreamTranslationState) -> Vec<Value> {
224274
"output_index": output_index,
225275
"item": {
226276
"type": "message",
277+
"id": format!("msg_{output_index}"),
227278
"role": "assistant",
228279
"status": status,
229280
"content": [{"type": "output_text", "text": state.response_text}],
@@ -265,6 +316,7 @@ fn finish_responses_stream(state: &mut StreamTranslationState) -> Vec<Value> {
265316
output_index,
266317
json!({
267318
"type": "message",
319+
"id": format!("msg_{output_index}"),
268320
"role": "assistant",
269321
"status": status,
270322
"content": [{"type": "output_text", "text": state.response_text}],
@@ -306,15 +358,7 @@ fn finish_responses_stream(state: &mut StreamTranslationState) -> Vec<Value> {
306358

307359
out.push(json!({
308360
"type": event_type,
309-
"response": {
310-
"id": responses_id(state),
311-
"object": "response",
312-
"status": status,
313-
"incomplete_details": incomplete_details,
314-
"model": target_model_or_source_model(state),
315-
"output": output,
316-
"usage": responses_usage_value(&state.usage),
317-
},
361+
"response": responses_stream_response(state, status, incomplete_details, output),
318362
}));
319363
state.finished = true;
320364
out
@@ -393,17 +437,54 @@ fn ensure_responses_created(state: &mut StreamTranslationState) -> Vec<Value> {
393437
state.response_created = true;
394438
vec![json!({
395439
"type": "response.created",
396-
"response": {
397-
"id": responses_id(state),
398-
"object": "response",
399-
"status": "in_progress",
400-
"model": target_model_or_source_model(state),
401-
"output": [],
402-
"usage": responses_usage_value(&state.usage),
403-
},
440+
"response": responses_stream_response(state, "in_progress", None, Vec::new()),
404441
})]
405442
}
406443

444+
// Builds a schema-complete Responses snapshot for strict generated clients.
445+
fn responses_stream_response(
446+
state: &StreamTranslationState,
447+
status: &'static str,
448+
incomplete_details: Option<Value>,
449+
output: Vec<Value>,
450+
) -> ResponsesStreamResponse {
451+
ResponsesStreamResponse {
452+
id: responses_id(state),
453+
object: "response",
454+
created_at: 0,
455+
completed_at: None,
456+
error: None,
457+
incomplete_details,
458+
instructions: None,
459+
metadata: None,
460+
model: target_model_or_source_model(state),
461+
output,
462+
parallel_tool_calls: true,
463+
frequency_penalty: None,
464+
presence_penalty: None,
465+
status,
466+
temperature: None,
467+
tool_choice: "auto",
468+
tools: Vec::new(),
469+
top_p: None,
470+
usage: responses_usage_value(&state.usage),
471+
}
472+
}
473+
474+
// Assigns monotonically increasing sequence numbers to generated Responses events.
475+
fn add_sequence_numbers(state: &mut StreamTranslationState, mut events: Vec<Value>) -> Vec<Value> {
476+
for event in &mut events {
477+
if let Some(object) = event.as_object_mut() {
478+
object.insert(
479+
"sequence_number".to_string(),
480+
Value::from(state.response_sequence_number),
481+
);
482+
state.response_sequence_number = state.response_sequence_number.saturating_add(1);
483+
}
484+
}
485+
events
486+
}
487+
407488
// Accumulates assistant text and emits Responses text delta events.
408489
fn encode_responses_text_delta(state: &mut StreamTranslationState, text: String) -> Vec<Value> {
409490
let mut out = ensure_responses_created(state);

crates/switchyard-translation/src/codecs/stream.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ pub struct StreamTranslationState {
5858
pub(crate) response_reasoning_output_index: Option<usize>,
5959
pub(crate) response_reasoning_text: String,
6060
pub(crate) next_response_output_index: usize,
61+
pub(crate) response_sequence_number: u64,
6162

6263
pub(crate) reasoning_block_index: Option<usize>,
6364
pub(crate) reasoning_block_started: bool,

crates/switchyard-translation/tests/request_translation.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,52 @@ fn responses_unknown_input_item_is_preserved_for_openai_chat() -> TestResult {
728728
Ok(())
729729
}
730730

731+
// Responses accepts message-shaped input items without an explicit discriminator.
732+
#[test]
733+
fn responses_input_message_without_type_translates_normally() -> TestResult {
734+
let engine = TranslationEngine::default();
735+
let body = json!({
736+
"model": "gpt-4",
737+
"input": [{"role": "user", "content": "hello"}]
738+
});
739+
740+
let output = engine
741+
.translate_request(
742+
WireFormat::OpenAiResponses,
743+
WireFormat::OpenAiChat,
744+
&body,
745+
&TranslationPolicy::default(),
746+
)?
747+
.body;
748+
749+
assert_eq!(
750+
output["messages"],
751+
json!([{"role": "user", "content": "hello"}])
752+
);
753+
Ok(())
754+
}
755+
756+
// A discriminator-less object that is not message-shaped must not silently become prompt text.
757+
#[test]
758+
fn responses_input_without_type_or_message_shape_is_rejected() {
759+
let engine = TranslationEngine::default();
760+
let body = json!({
761+
"model": "gpt-4",
762+
"input": [{"payload": "ambiguous"}]
763+
});
764+
765+
let error = engine
766+
.translate_request(
767+
WireFormat::OpenAiResponses,
768+
WireFormat::OpenAiChat,
769+
&body,
770+
&TranslationPolicy::default(),
771+
)
772+
.expect_err("ambiguous input item should be rejected");
773+
774+
assert!(error.to_string().contains("$.input[0].type"));
775+
}
776+
731777
// Verifies orphan Responses tool outputs degrade to readable user text.
732778
#[test]
733779
fn responses_orphan_function_call_output_degrades_to_user_text_for_openai_chat() -> TestResult {

crates/switchyard-translation/tests/stream_translation.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,76 @@ fn openai_chat_stream_usage_without_breakdowns_still_emits_responses_usage_detai
986986
Ok(())
987987
}
988988

989+
// Responses terminal snapshots include every field required by strict generated clients.
990+
#[test]
991+
fn responses_completed_event_is_schema_complete_and_retains_message_id() -> TestResult {
992+
let engine = TranslationEngine::default();
993+
let mut state =
994+
StreamTranslationState::new(WireFormat::OpenAiChat, WireFormat::OpenAiResponses);
995+
let chunk = json!({
996+
"id": "chatcmpl-test",
997+
"object": "chat.completion.chunk",
998+
"model": "gpt-4o",
999+
"choices": [{
1000+
"index": 0,
1001+
"delta": {"content": "hello"},
1002+
"finish_reason": "stop"
1003+
}]
1004+
});
1005+
1006+
let mut events = engine.translate_event(
1007+
&mut state,
1008+
WireFormat::OpenAiChat,
1009+
WireFormat::OpenAiResponses,
1010+
&chunk,
1011+
)?;
1012+
events.extend(engine.finish_stream(&mut state, WireFormat::OpenAiResponses)?);
1013+
1014+
for (expected, event) in events.iter().enumerate() {
1015+
assert_eq!(event["sequence_number"], expected as u64);
1016+
}
1017+
let completed = events
1018+
.iter()
1019+
.find(|event| event["type"] == "response.completed")
1020+
.ok_or("expected response.completed")?;
1021+
let response = completed["response"]
1022+
.as_object()
1023+
.ok_or("completed response should be an object")?;
1024+
for field in [
1025+
"id",
1026+
"object",
1027+
"created_at",
1028+
"completed_at",
1029+
"error",
1030+
"incomplete_details",
1031+
"instructions",
1032+
"metadata",
1033+
"model",
1034+
"output",
1035+
"parallel_tool_calls",
1036+
"frequency_penalty",
1037+
"presence_penalty",
1038+
"status",
1039+
"temperature",
1040+
"tool_choice",
1041+
"tools",
1042+
"top_p",
1043+
"usage",
1044+
] {
1045+
assert!(
1046+
response.contains_key(field),
1047+
"missing response field {field}"
1048+
);
1049+
}
1050+
assert_eq!(response["output"][0]["id"], "msg_0");
1051+
let done = events
1052+
.iter()
1053+
.find(|event| event["type"] == "response.output_item.done")
1054+
.ok_or("expected response.output_item.done")?;
1055+
assert_eq!(done["item"]["id"], "msg_0");
1056+
Ok(())
1057+
}
1058+
9891059
// Verifies a streamed token-limit stop terminates with response.incomplete.
9901060
#[test]
9911061
fn openai_chat_length_finish_translates_to_responses_incomplete_event() -> TestResult {

0 commit comments

Comments
 (0)