1use serde::Serialize;
7use serde_json::{Value, json};
8
9use crate::LlmResponseChunk;
10use crate::codecs::common::{
11 collect_responses_reasoning_text, encrypted_reasoning_data, encrypted_reasoning_item_id,
12};
13use crate::codecs::stream::{
14 StreamCodec, StreamTranslationState, record_source_identity,
15 target_message_id_or_source_message_id, target_model_or_source_model,
16};
17use crate::format::{FormatId, WireFormat};
18use crate::llm::Usage;
19
20use super::buffered::encode_responses_usage;
21
22pub struct OpenAiResponsesStreamCodec;
24
25impl StreamCodec for OpenAiResponsesStreamCodec {
26 fn format(&self) -> FormatId {
27 WireFormat::OpenAiResponses.into()
28 }
29
30 fn is_terminal_event(&self, event: &Value) -> bool {
31 matches!(
32 event
33 .get("type")
34 .or_else(|| event.get("event"))
35 .and_then(Value::as_str),
36 Some("response.completed" | "response.incomplete" | "response.failed")
37 )
38 }
39
40 fn sse_done_is_terminal(&self) -> bool {
41 true
42 }
43
44 fn stamp_streamed_response_model(&self, event: &mut Value, served_model: &str) {
45 if let Some(response) = event.get_mut("response").and_then(Value::as_object_mut) {
46 response.insert("model".to_string(), Value::String(served_model.to_string()));
47 }
48 }
49
50 fn decode_event(
51 &self,
52 state: &mut StreamTranslationState,
53 event: &Value,
54 ) -> Vec<LlmResponseChunk> {
55 decode_responses_stream(state, event)
56 }
57
58 fn encode_event(
59 &self,
60 state: &mut StreamTranslationState,
61 event: LlmResponseChunk,
62 ) -> Vec<Value> {
63 let events = encode_responses_stream(state, event);
64 add_sequence_numbers(state, events)
65 }
66
67 fn observe_replayed_event(
68 &self,
69 state: &mut StreamTranslationState,
70 raw: &Value,
71 normalized: Vec<LlmResponseChunk>,
72 ) {
73 let replayed_terminal = normalized
74 .iter()
75 .any(|chunk| matches!(chunk, LlmResponseChunk::MessageStop { .. }));
76 for chunk in normalized {
79 drop(encode_responses_stream(state, chunk));
80 }
81 state.response_sequence_number = raw
82 .get("sequence_number")
83 .and_then(Value::as_u64)
84 .map_or(state.response_sequence_number.saturating_add(1), |number| {
85 number.saturating_add(1)
86 });
87 if replayed_terminal {
88 state.finished = true;
89 }
90 }
91
92 fn finish(&self, state: &mut StreamTranslationState) -> Vec<Value> {
93 let events = finish_responses_stream(state);
94 add_sequence_numbers(state, events)
95 }
96}
97
98#[derive(Serialize)]
100struct ResponsesStreamResponse {
101 id: String,
102 object: &'static str,
103 created_at: u64,
104 completed_at: Option<u64>,
105 error: Option<Value>,
106 incomplete_details: Option<Value>,
107 instructions: Option<Value>,
108 metadata: Option<Value>,
109 model: String,
110 output: Vec<Value>,
111 parallel_tool_calls: bool,
112 frequency_penalty: Option<f64>,
113 presence_penalty: Option<f64>,
114 status: &'static str,
115 temperature: Option<f64>,
116 tool_choice: &'static str,
117 tools: Vec<Value>,
118 top_p: Option<f64>,
119 usage: Value,
120}
121
122fn decode_responses_stream(
124 state: &mut StreamTranslationState,
125 event: &Value,
126) -> Vec<LlmResponseChunk> {
127 tracing::trace!(target: "switchyard_translation::responses::raw", raw = %event);
130 let event_type = event
131 .get("type")
132 .or_else(|| event.get("event"))
133 .and_then(Value::as_str);
134 match event_type {
135 Some("response.created") => {
136 state.saw_message_start = true;
137 let response = event.get("response").and_then(Value::as_object);
138 if let Some(model) = response
139 .and_then(|response| response.get("model"))
140 .and_then(Value::as_str)
141 {
142 state.model = Some(model.to_string());
143 }
144 if let Some(id) = response
145 .and_then(|response| response.get("id"))
146 .and_then(Value::as_str)
147 {
148 state.message_id = Some(id.to_string());
149 }
150 vec![LlmResponseChunk::MessageStart {
151 id: state.message_id.clone(),
152 model: state.model.clone(),
153 }]
154 }
155 Some("response.output_text.delta") => event
156 .get("delta")
157 .and_then(Value::as_str)
158 .map(|text| {
159 let index = event
160 .get("output_index")
161 .and_then(Value::as_u64)
162 .unwrap_or(0) as usize;
163 let content_index = event
164 .get("content_index")
165 .and_then(Value::as_u64)
166 .unwrap_or(0) as usize;
167 state
168 .decoded_response_text
169 .entry(index)
170 .or_default()
171 .entry(content_index)
172 .or_default()
173 .push_str(text);
174 vec![LlmResponseChunk::TextDelta {
175 index,
176 text: text.to_string(),
177 }]
178 })
179 .unwrap_or_default(),
180 Some("response.reasoning_text.delta") | Some("response.reasoning_summary_text.delta") => {
181 event
182 .get("delta")
183 .or_else(|| event.get("text"))
184 .and_then(Value::as_str)
185 .map(|text| {
186 let index = event
187 .get("output_index")
188 .and_then(Value::as_u64)
189 .unwrap_or(0) as usize;
190 state
193 .decoded_reasoning
194 .entry(index)
195 .or_default()
196 .push_str(text);
197 vec![LlmResponseChunk::ReasoningDelta {
198 index,
199 text: text.to_string(),
200 }]
201 })
202 .unwrap_or_default()
203 }
204 Some("response.output_item.added") => decode_responses_output_item_added(event, state),
205 Some("response.function_call_arguments.delta") => {
206 let output_index = event
207 .get("output_index")
208 .and_then(Value::as_u64)
209 .unwrap_or(0);
210 event
211 .get("delta")
212 .and_then(Value::as_str)
213 .filter(|_| {
215 !state
216 .tool_states
217 .get(&(output_index as usize))
218 .is_some_and(|tool| tool.decoded_custom_call)
219 })
220 .map(|delta| {
221 state.decoded_tool_call = true;
222 state
225 .tool_states
226 .entry(output_index as usize)
227 .or_default()
228 .decoded_arguments
229 .push_str(delta);
230 vec![LlmResponseChunk::ToolCallDelta {
231 index: output_index as usize,
232 id: None,
233 name: None,
234 arguments_delta: Some(delta.to_string()),
235 }]
236 })
237 .unwrap_or_default()
238 }
239 Some("response.output_item.done") => decode_responses_output_item_done(event, state),
240 Some("response.reasoning_text.done") | Some("response.reasoning_summary_text.done") => {
241 let index = event
242 .get("output_index")
243 .and_then(Value::as_u64)
244 .unwrap_or(0) as usize;
245 event
246 .get("text")
247 .and_then(Value::as_str)
248 .filter(|text| !text.is_empty())
249 .filter(|_| {
250 state
251 .decoded_reasoning
252 .get(&index)
253 .is_none_or(String::is_empty)
254 })
255 .map(|text| {
256 state.decoded_reasoning.insert(index, text.to_string());
257 vec![LlmResponseChunk::ReasoningDelta {
258 index,
259 text: text.to_string(),
260 }]
261 })
262 .unwrap_or_default()
263 }
264 Some("response.reasoning_summary_part.done") => {
265 let index = event
266 .get("output_index")
267 .and_then(Value::as_u64)
268 .unwrap_or(0) as usize;
269 event
270 .get("part")
271 .and_then(|part| part.get("text"))
272 .and_then(Value::as_str)
273 .filter(|text| !text.is_empty())
274 .filter(|_| {
275 state
276 .decoded_reasoning
277 .get(&index)
278 .is_none_or(String::is_empty)
279 })
280 .map(|text| {
281 state.decoded_reasoning.insert(index, text.to_string());
282 vec![LlmResponseChunk::ReasoningDelta {
283 index,
284 text: text.to_string(),
285 }]
286 })
287 .unwrap_or_default()
288 }
289 Some("response.completed") => {
290 let mut out = Vec::new();
291 if let Some(items) = event
294 .get("response")
295 .and_then(|response| response.get("output"))
296 .and_then(Value::as_array)
297 {
298 for (position, item) in items.iter().enumerate() {
299 if let Some(item) = item.as_object() {
300 out.extend(decode_responses_completed_item(item, position, state));
301 if matches!(out.last(), Some(LlmResponseChunk::DecodeError { .. })) {
302 return out;
303 }
304 }
305 }
306 }
307 decode_terminal_usage(state, event, &mut out);
308 let reason = state.decoded_tool_call.then(|| "tool_use".to_string());
314 out.push(LlmResponseChunk::MessageStop { reason });
315 out
316 }
317 Some("response.incomplete") => {
319 let mut out = Vec::new();
320 decode_terminal_usage(state, event, &mut out);
321 out.push(LlmResponseChunk::MessageStop {
322 reason: Some("max_tokens".to_string()),
323 });
324 out
325 }
326 Some("response.failed") => vec![LlmResponseChunk::StreamError {
327 message: event
328 .get("response")
329 .and_then(|response| response.get("error"))
330 .and_then(|error| error.get("message"))
331 .and_then(Value::as_str)
332 .unwrap_or("unknown Responses stream error")
333 .to_string(),
334 }],
335 Some("error") => vec![LlmResponseChunk::StreamError {
336 message: event
337 .get("message")
338 .and_then(Value::as_str)
339 .unwrap_or("unknown Responses stream error")
340 .to_string(),
341 }],
342 _ => Vec::new(),
343 }
344}
345
346fn encode_responses_stream(
348 state: &mut StreamTranslationState,
349 event: LlmResponseChunk,
350) -> Vec<Value> {
351 if state.errored {
353 return Vec::new();
354 }
355 match event {
356 LlmResponseChunk::MessageStart { id, model } => {
357 record_source_identity(state, id, model);
358 ensure_responses_created(state)
359 }
360 LlmResponseChunk::TextDelta { text, .. } => encode_responses_text_delta(state, text),
361 LlmResponseChunk::ReasoningDelta { index, text } => {
362 encode_responses_reasoning_delta(state, index, text)
363 }
364 LlmResponseChunk::ReasoningDetailsDelta {
365 index,
366 details,
367 text,
368 } => {
369 let data = encrypted_reasoning_data(&details);
372 let item = state.response_reasoning.entry(index).or_default();
373 for detail in &details {
374 if detail.get("type").and_then(Value::as_str) == Some("anthropic.signature_delta")
375 && let Some(signature) = detail.get("signature").and_then(Value::as_str)
376 {
377 item.anthropic_signature
378 .get_or_insert_default()
379 .push_str(signature);
380 }
381 }
382 match reasoning_details_item_id(&details) {
383 Some(id) if item.started && item.item_id.as_deref() != Some(id.as_str()) => {
384 tracing::warn!(
387 item_id = %id,
388 "encrypted reasoning arrived after its item opened under a different id; dropping payload"
389 );
390 }
391 id => {
392 if id.is_some() {
393 item.item_id = id;
394 }
395 if data.is_some() {
396 item.encrypted = data;
397 }
398 }
399 }
400 if !item.started && item.item_id.is_none() && summary_lacks_item_id(&details) {
404 item.pending_text.push_str(&text);
405 return ensure_responses_created(state);
406 }
407 let mut out = ensure_responses_reasoning_started(state, index);
408 if !text.is_empty() {
409 out.extend(encode_responses_reasoning_delta(state, index, text));
410 }
411 out
412 }
413 LlmResponseChunk::ToolCallDelta {
414 index,
415 id,
416 name,
417 arguments_delta,
418 } => encode_responses_tool_delta(state, index, id, name, arguments_delta),
419 LlmResponseChunk::Usage(usage) => {
420 state.usage = usage;
421 state.saw_backend_usage = true;
422 Vec::new()
423 }
424 LlmResponseChunk::MessageStop { reason } => {
425 state.stop_reason = reason.or_else(|| state.stop_reason.clone());
426 Vec::new()
427 }
428 LlmResponseChunk::DecodeError { message } | LlmResponseChunk::StreamError { message } => {
429 state.finished = true; state.errored = true; vec![json!({"type": "error", "message": message})]
433 }
434 }
435}
436
437fn finish_responses_stream(state: &mut StreamTranslationState) -> Vec<Value> {
439 if state.finished {
440 return Vec::new();
441 }
442 let is_truncated = matches!(
443 state.stop_reason.as_deref(),
444 Some("length") | Some("max_tokens")
445 );
446 let (event_type, status) = if is_truncated {
447 ("response.incomplete", "incomplete")
448 } else {
449 ("response.completed", "completed")
450 };
451 let incomplete_details = is_truncated.then(|| json!({ "reason": "max_output_tokens" }));
452 let mut out = open_held_reasoning(state, None);
453 out.extend(ensure_responses_created(state));
454 if state.response_text_started
455 && let Some(output_index) = state.response_text_output_index
456 {
457 let item_id = responses_item_id(state, "msg", output_index);
458 out.push(json!({
459 "type": "response.output_text.done",
460 "item_id": item_id,
461 "output_index": output_index,
462 "content_index": 0,
463 "text": state.response_text,
464 "logprobs": [],
465 }));
466 out.push(json!({
467 "type": "response.content_part.done",
468 "item_id": item_id,
469 "output_index": output_index,
470 "content_index": 0,
471 "part": {"type": "output_text", "text": state.response_text, "annotations": [], "logprobs": []},
472 }));
473 out.push(json!({
474 "type": "response.output_item.done",
475 "output_index": output_index,
476 "item": {
477 "type": "message",
478 "id": item_id,
479 "role": "assistant",
480 "status": status,
481 "content": [{"type": "output_text", "text": state.response_text, "annotations": [], "logprobs": []}],
482 },
483 }));
484 }
485
486 let mut final_items: Vec<(usize, Value)> = Vec::new();
487 let mut reasoning_items: Vec<(usize, usize)> = state
488 .response_reasoning
489 .iter()
490 .filter(|(_, item)| item.started)
491 .filter_map(|(index, item)| item.output_index.map(|output| (*index, output)))
492 .collect();
493 reasoning_items.sort_by_key(|(_, output_index)| *output_index);
494 for (index, output_index) in reasoning_items {
495 let item_id = responses_reasoning_item_id(state, index);
498 let reasoning = &state.response_reasoning[&index];
499 let mut summary = Vec::new();
500 if !reasoning.text.is_empty() {
501 out.push(json!({
502 "type": "response.reasoning_summary_text.done",
503 "item_id": item_id,
504 "output_index": output_index,
505 "summary_index": 0,
506 "text": reasoning.text,
507 }));
508 out.push(json!({
509 "type": "response.reasoning_summary_part.done",
510 "item_id": item_id,
511 "output_index": output_index,
512 "summary_index": 0,
513 "part": {"type": "summary_text", "text": reasoning.text},
514 }));
515 summary.push(json!({
516 "type": "summary_text",
517 "text": reasoning.text,
518 }));
519 }
520 let mut item = json!({
521 "type": "reasoning",
522 "id": item_id,
523 "status": "completed",
524 "summary": summary,
525 });
526 if let Some(signature) = &reasoning.anthropic_signature {
527 item["encrypted_content"] =
528 Value::String(super::encode_anthropic_thinking(&reasoning.text, signature));
529 } else if let Some(encrypted) = &reasoning.encrypted {
530 item["encrypted_content"] = Value::String(encrypted.clone());
531 }
532 out.push(json!({
533 "type": "response.output_item.done",
534 "output_index": output_index,
535 "item": item,
536 }));
537 final_items.push((output_index, item));
538 }
539 if state.response_text_started
540 && let Some(output_index) = state.response_text_output_index
541 {
542 final_items.push((
543 output_index,
544 json!({
545 "type": "message",
546 "id": responses_item_id(state, "msg", output_index),
547 "role": "assistant",
548 "status": status,
549 "content": [{"type": "output_text", "text": state.response_text, "annotations": [], "logprobs": []}],
550 }),
551 ));
552 }
553
554 for tool in state.tool_states.values() {
555 if !tool.started {
556 continue;
557 }
558 let output_index = tool.response_output_index.unwrap_or(0);
559 let item_id = tool
560 .response_item_id
561 .clone()
562 .unwrap_or_else(|| responses_item_id(state, "fc", output_index));
563 out.push(json!({
564 "type": "response.function_call_arguments.done",
565 "item_id": item_id,
566 "output_index": output_index,
567 "name": tool.name.clone().unwrap_or_default(),
568 "arguments": tool.arguments,
569 }));
570 let item = json!({
571 "type": "function_call",
572 "id": item_id,
573 "call_id": tool.id.clone().unwrap_or_else(|| format!("call_{output_index}")),
574 "name": tool.name.clone().unwrap_or_default(),
575 "arguments": tool.arguments,
576 "status": "completed",
577 });
578 out.push(json!({
579 "type": "response.output_item.done",
580 "output_index": output_index,
581 "item": item,
582 }));
583 final_items.push((output_index, item));
584 }
585
586 final_items.sort_by_key(|(index, _)| *index);
587 let output = final_items
588 .into_iter()
589 .map(|(_, item)| item)
590 .collect::<Vec<_>>();
591
592 out.push(json!({
593 "type": event_type,
594 "response": responses_stream_response(state, status, incomplete_details, output),
595 }));
596 state.finished = true;
597 out
598}
599
600fn decode_responses_reasoning_item(
607 item: &serde_json::Map<String, Value>,
608 index: usize,
609 state: &mut StreamTranslationState,
610) -> Vec<LlmResponseChunk> {
611 let mut out = Vec::new();
612 let has_encrypted = item
613 .get("encrypted_content")
614 .and_then(Value::as_str)
615 .is_some_and(|data| !data.is_empty());
616 if let Some(id) = item
620 .get("id")
621 .and_then(Value::as_str)
622 .filter(|id| !id.is_empty())
623 && !state.decoded_reasoning_ids.contains(&index)
624 {
625 state.decoded_reasoning_ids.insert(index);
626 if !has_encrypted {
627 out.push(LlmResponseChunk::ReasoningDetailsDelta {
628 index,
629 details: vec![json!({"type": "reasoning.encrypted", "id": id})],
630 text: String::new(),
631 });
632 }
633 }
634 let mut parts = Vec::new();
635 collect_responses_reasoning_text(item.get("content"), &mut parts);
636 collect_responses_reasoning_text(item.get("summary"), &mut parts);
637 collect_responses_reasoning_text(item.get("text"), &mut parts);
638 let text = parts.join("\n");
639 let already = state
640 .decoded_reasoning
641 .get(&index)
642 .map(String::as_str)
643 .unwrap_or("");
644 if !text.is_empty() && already.is_empty() {
645 state.decoded_reasoning.insert(index, text.clone());
646 out.push(LlmResponseChunk::ReasoningDelta { index, text });
647 }
648 if let Some(data) = item
649 .get("encrypted_content")
650 .and_then(Value::as_str)
651 .filter(|data| !data.is_empty())
652 && !state.decoded_reasoning_encrypted.contains(&index)
653 {
654 state.decoded_reasoning_encrypted.insert(index);
655 let mut detail = json!({"type": "reasoning.encrypted", "data": data});
657 if let Some(id) = item
658 .get("id")
659 .and_then(Value::as_str)
660 .filter(|id| !id.is_empty())
661 {
662 detail["id"] = Value::String(id.to_string());
663 }
664 out.push(LlmResponseChunk::ReasoningDetailsDelta {
665 index,
666 details: vec![detail],
667 text: String::new(),
668 });
669 }
670 out
671}
672
673fn decode_responses_output_item_added(
674 event: &Value,
675 state: &mut StreamTranslationState,
676) -> Vec<LlmResponseChunk> {
677 let Some(item) = event.get("item").and_then(Value::as_object) else {
678 return Vec::new();
679 };
680 let index = event
681 .get("output_index")
682 .and_then(Value::as_u64)
683 .unwrap_or(0) as usize;
684 if item.get("type").and_then(Value::as_str) == Some("reasoning") {
685 return decode_responses_reasoning_item(item, index, state);
686 }
687 let item_type = item.get("type").and_then(Value::as_str);
688 if item_type != Some("function_call") && item_type != Some("custom_tool_call") {
689 return Vec::new();
690 }
691 state.decoded_tool_call = true;
692 let id = item
693 .get("call_id")
694 .or_else(|| item.get("id"))
695 .and_then(Value::as_str);
696 let name = item.get("name").and_then(Value::as_str);
697 let tool = state.tool_states.entry(index).or_default();
698 tool.has_decoded_identity |=
699 id.is_some_and(|id| !id.is_empty()) && name.is_some_and(|name| !name.is_empty());
700 let custom = item_type == Some("custom_tool_call");
701 tool.decoded_custom_call |= custom;
702 let arguments_delta = if custom {
705 None
706 } else {
707 item.get("arguments")
708 .and_then(Value::as_str)
709 .filter(|arguments| !arguments.is_empty())
710 .map(ToOwned::to_owned)
711 };
712 if let Some(arguments) = arguments_delta.as_deref() {
713 tool.decoded_arguments.push_str(arguments);
714 }
715 vec![LlmResponseChunk::ToolCallDelta {
716 index,
717 id: id.map(ToOwned::to_owned),
718 name: name.map(ToOwned::to_owned),
719 arguments_delta,
720 }]
721}
722
723fn decode_responses_output_item_done(
724 event: &Value,
725 state: &mut StreamTranslationState,
726) -> Vec<LlmResponseChunk> {
727 let Some(item) = event.get("item").and_then(Value::as_object) else {
728 return Vec::new();
729 };
730 let index = event
731 .get("output_index")
732 .and_then(Value::as_u64)
733 .unwrap_or(0) as usize;
734 decode_responses_completed_item(item, index, state)
735}
736
737fn decode_responses_completed_item(
738 item: &serde_json::Map<String, Value>,
739 index: usize,
740 state: &mut StreamTranslationState,
741) -> Vec<LlmResponseChunk> {
742 if item.get("type").and_then(Value::as_str) == Some("reasoning") {
743 return decode_responses_reasoning_item(item, index, state);
744 }
745 let item_type = item.get("type").and_then(Value::as_str);
746 if item_type == Some("message") {
747 let mut out = Vec::new();
748 if let Some(content) = item.get("content").and_then(Value::as_array) {
749 for (content_index, part) in content.iter().enumerate() {
750 if part.get("type").and_then(Value::as_str) != Some("output_text") {
751 continue;
752 }
753 if let Some(text) = part.get("text").and_then(Value::as_str) {
754 let decoded = state
755 .decoded_response_text
756 .entry(index)
757 .or_default()
758 .entry(content_index)
759 .or_default();
760 match snapshot_suffix(decoded, text) {
761 Ok(Some(text)) => out.push(LlmResponseChunk::TextDelta { index, text }),
762 Ok(None) => {}
763 Err(error) => return vec![error],
764 }
765 }
766 }
767 }
768 return out;
769 }
770 if item_type != Some("function_call") && item_type != Some("custom_tool_call") {
771 return Vec::new();
772 }
773 state.decoded_tool_call = true;
774 let custom_arguments = (item_type == Some("custom_tool_call")).then(|| {
775 json!({
776 crate::codex_custom_tools::INPUT_ARGUMENT:
777 item.get("input").and_then(Value::as_str).unwrap_or_default()
778 })
779 .to_string()
780 });
781 let arguments = custom_arguments
782 .as_deref()
783 .or_else(|| item.get("arguments").and_then(Value::as_str));
784 let tool = state.tool_states.entry(index).or_default();
785 let arguments_delta =
786 match arguments.map(|arguments| snapshot_suffix(&mut tool.decoded_arguments, arguments)) {
787 Some(Ok(delta)) => delta,
788 Some(Err(error)) => return vec![error],
789 None => None,
790 };
791 let id = item
792 .get("call_id")
793 .or_else(|| item.get("id"))
794 .and_then(Value::as_str);
795 let name = item.get("name").and_then(Value::as_str);
796 let needs_identity = !tool.has_decoded_identity;
797 tool.has_decoded_identity |=
798 id.is_some_and(|id| !id.is_empty()) && name.is_some_and(|name| !name.is_empty());
799 if needs_identity || arguments_delta.is_some() {
800 return vec![LlmResponseChunk::ToolCallDelta {
801 index,
802 id: id.filter(|_| needs_identity).map(ToOwned::to_owned),
803 name: name.filter(|_| needs_identity).map(ToOwned::to_owned),
804 arguments_delta,
805 }];
806 }
807 Vec::new()
808}
809
810fn snapshot_suffix(
813 decoded: &mut String,
814 snapshot: &str,
815) -> Result<Option<String>, LlmResponseChunk> {
816 let Some(suffix) = snapshot.strip_prefix(decoded.as_str()) else {
817 return Err(LlmResponseChunk::DecodeError {
818 message: "Responses snapshot conflicts with streamed content".to_string(),
819 });
820 };
821 if suffix.is_empty() {
822 return Ok(None);
823 }
824 decoded.push_str(suffix);
825 Ok(Some(suffix.to_owned()))
826}
827
828fn ensure_responses_created(state: &mut StreamTranslationState) -> Vec<Value> {
830 if state.response_created {
831 return Vec::new();
832 }
833 if state.target_message_id.is_none() {
835 state.target_message_id = Some(responses_id(state));
836 }
837 state.response_created = true;
838 vec![json!({
839 "type": "response.created",
840 "response": responses_stream_response(state, "in_progress", None, Vec::new()),
841 })]
842}
843
844fn responses_stream_response(
846 state: &StreamTranslationState,
847 status: &'static str,
848 incomplete_details: Option<Value>,
849 output: Vec<Value>,
850) -> ResponsesStreamResponse {
851 ResponsesStreamResponse {
852 id: responses_id(state),
853 object: "response",
854 created_at: 0,
855 completed_at: None,
856 error: None,
857 incomplete_details,
858 instructions: None,
859 metadata: None,
860 model: target_model_or_source_model(state),
861 output,
862 parallel_tool_calls: true,
863 frequency_penalty: None,
864 presence_penalty: None,
865 status,
866 temperature: None,
867 tool_choice: "auto",
868 tools: Vec::new(),
869 top_p: None,
870 usage: encode_responses_usage(&state.usage),
871 }
872}
873
874fn add_sequence_numbers(state: &mut StreamTranslationState, mut events: Vec<Value>) -> Vec<Value> {
876 for event in &mut events {
877 if let Some(object) = event.as_object_mut() {
878 object.insert(
879 "sequence_number".to_string(),
880 Value::from(state.response_sequence_number),
881 );
882 state.response_sequence_number = state.response_sequence_number.saturating_add(1);
883 }
884 }
885 events
886}
887
888fn encode_responses_text_delta(state: &mut StreamTranslationState, text: String) -> Vec<Value> {
890 let mut out = open_held_reasoning(state, None);
891 out.extend(ensure_responses_created(state));
892 if !state.response_text_started {
893 state.response_text_started = true;
894 let output_index = state.next_response_output_index;
895 state.next_response_output_index += 1;
896 state.response_text_output_index = Some(output_index);
897 out.push(json!({
898 "type": "response.output_item.added",
899 "output_index": output_index,
900 "item": {
901 "type": "message",
902 "id": responses_item_id(state, "msg", output_index),
903 "role": "assistant",
904 "status": "in_progress",
905 "content": [],
906 },
907 }));
908 out.push(json!({
909 "type": "response.content_part.added",
910 "item_id": responses_item_id(state, "msg", output_index),
911 "output_index": output_index,
912 "content_index": 0,
913 "part": {"type": "output_text", "text": "", "annotations": [], "logprobs": []},
914 }));
915 }
916 state.response_text.push_str(&text);
917 out.push(json!({
918 "type": "response.output_text.delta",
919 "item_id": responses_item_id(state, "msg", state.response_text_output_index.unwrap_or(0)),
920 "output_index": state.response_text_output_index.unwrap_or(0),
921 "content_index": 0,
922 "delta": text,
923 "logprobs": [],
924 }));
925 out
926}
927
928fn reasoning_details_item_id(details: &[Value]) -> Option<String> {
932 encrypted_reasoning_item_id(details).or_else(|| {
933 details
934 .iter()
935 .filter_map(Value::as_object)
936 .filter(|detail| {
937 matches!(
938 detail.get("type").and_then(Value::as_str),
939 Some("reasoning.summary" | "reasoning.text")
940 )
941 })
942 .find_map(|detail| {
943 detail
944 .get("id")
945 .and_then(Value::as_str)
946 .filter(|id| !id.is_empty())
947 })
948 .map(ToOwned::to_owned)
949 })
950}
951
952fn summary_lacks_item_id(details: &[Value]) -> bool {
954 details.iter().filter_map(Value::as_object).any(|detail| {
955 detail.get("type").and_then(Value::as_str) == Some("reasoning.summary")
956 && detail
957 .get("id")
958 .and_then(Value::as_str)
959 .is_none_or(str::is_empty)
960 })
961}
962
963fn open_held_reasoning(state: &mut StreamTranslationState, before: Option<usize>) -> Vec<Value> {
968 let held: Vec<usize> = state
969 .response_reasoning
970 .iter()
971 .filter(|(index, item)| {
972 before.is_none_or(|bound| **index < bound)
973 && !item.started
974 && !item.pending_text.is_empty()
975 })
976 .map(|(index, _)| *index)
977 .collect();
978 let mut out = Vec::new();
979 for index in held {
980 out.extend(ensure_responses_reasoning_started(state, index));
981 }
982 out
983}
984
985fn responses_reasoning_item_id(state: &StreamTranslationState, index: usize) -> String {
988 let item = state.response_reasoning.get(&index);
989 item.and_then(|item| item.item_id.clone())
990 .unwrap_or_else(|| {
991 let output_index = item.and_then(|item| item.output_index).unwrap_or(0);
992 responses_item_id(state, "rs", output_index)
993 })
994}
995
996fn ensure_responses_reasoning_started(
998 state: &mut StreamTranslationState,
999 index: usize,
1000) -> Vec<Value> {
1001 let mut out = ensure_responses_created(state);
1002 if state
1003 .response_reasoning
1004 .get(&index)
1005 .is_some_and(|item| item.started)
1006 {
1007 return out;
1008 }
1009 out.extend(open_held_reasoning(state, Some(index)));
1011 let output_index = state.next_response_output_index;
1012 state.next_response_output_index += 1;
1013 let item = state.response_reasoning.entry(index).or_default();
1014 item.started = true;
1015 item.output_index = Some(output_index);
1016 let pending = std::mem::take(&mut item.pending_text);
1017 let item_id = responses_reasoning_item_id(state, index);
1018 out.push(json!({
1021 "type": "response.output_item.added",
1022 "output_index": output_index,
1023 "item": {
1024 "type": "reasoning",
1025 "id": item_id,
1026 "status": "in_progress",
1027 "summary": [],
1028 },
1029 }));
1030 if !pending.is_empty() {
1032 out.extend(encode_responses_reasoning_delta(state, index, pending));
1033 }
1034 out
1035}
1036
1037fn ensure_responses_reasoning_summary_started(
1041 state: &mut StreamTranslationState,
1042 index: usize,
1043) -> Vec<Value> {
1044 let mut out = ensure_responses_reasoning_started(state, index);
1045 let item_id = responses_reasoning_item_id(state, index);
1046 let item = state.response_reasoning.entry(index).or_default();
1047 if item.summary_started {
1048 return out;
1049 }
1050 item.summary_started = true;
1051 out.push(json!({
1052 "type": "response.reasoning_summary_part.added",
1053 "item_id": item_id,
1054 "output_index": item.output_index.unwrap_or(0),
1055 "summary_index": 0,
1056 "part": {"type": "summary_text", "text": ""},
1057 }));
1058 out
1059}
1060
1061fn encode_responses_reasoning_delta(
1063 state: &mut StreamTranslationState,
1064 index: usize,
1065 text: String,
1066) -> Vec<Value> {
1067 if let Some(item) = state.response_reasoning.get_mut(&index)
1069 && !item.started
1070 && !item.pending_text.is_empty()
1071 {
1072 item.pending_text.push_str(&text);
1073 return Vec::new();
1074 }
1075 if text.is_empty() {
1077 return ensure_responses_reasoning_started(state, index);
1078 }
1079 let mut out = ensure_responses_reasoning_summary_started(state, index);
1080 let item = state.response_reasoning.entry(index).or_default();
1081 item.text.push_str(&text);
1082 let output_index = item.output_index.unwrap_or(0);
1083 out.push(json!({
1084 "type": "response.reasoning_summary_text.delta",
1085 "item_id": responses_reasoning_item_id(state, index),
1086 "output_index": output_index,
1087 "summary_index": 0,
1088 "delta": text,
1089 }));
1090 out
1091}
1092
1093fn encode_responses_tool_delta(
1095 state: &mut StreamTranslationState,
1096 index: usize,
1097 id: Option<String>,
1098 name: Option<String>,
1099 arguments_delta: Option<String>,
1100) -> Vec<Value> {
1101 let mut out = ensure_responses_created(state);
1102 let resp_id = responses_id(state);
1103 let tool = state.tool_states.entry(index).or_default();
1104 if id.is_some() {
1105 tool.id = id;
1106 }
1107 if name.is_some() {
1108 tool.name = name;
1109 }
1110 if let Some(delta) = arguments_delta {
1111 tool.arguments.push_str(&delta);
1112 tool.pending_arguments.push_str(&delta);
1113 }
1114
1115 if !tool.started {
1116 let Some(name) = tool.name.clone() else {
1117 return out;
1118 };
1119 out.extend(open_held_reasoning(state, None));
1121 let output_index = state.next_response_output_index;
1122 state.next_response_output_index += 1;
1123 let tool = state.tool_states.entry(index).or_default();
1124 tool.response_output_index = Some(output_index);
1125 tool.response_item_id = Some(responses_item_id_from(&resp_id, "fc", output_index));
1126 tool.started = true;
1127 out.push(json!({
1128 "type": "response.output_item.added",
1129 "output_index": output_index,
1130 "item": {
1131 "type": "function_call",
1132 "id": tool.response_item_id.clone().unwrap_or_else(|| responses_item_id_from(&resp_id, "fc", output_index)),
1133 "call_id": tool.id.clone().unwrap_or_else(|| format!("call_{index}")),
1134 "name": name,
1135 "arguments": "",
1136 "status": "in_progress",
1137 },
1138 }));
1139 if !tool.pending_arguments.is_empty() {
1140 out.push(json!({
1141 "type": "response.function_call_arguments.delta",
1142 "item_id": tool.response_item_id,
1143 "output_index": output_index,
1144 "delta": tool.pending_arguments,
1145 }));
1146 tool.pending_arguments.clear();
1147 }
1148 return out;
1149 }
1150
1151 if let Some(output_index) = tool.response_output_index
1152 && !tool.pending_arguments.is_empty()
1153 {
1154 out.push(json!({
1155 "type": "response.function_call_arguments.delta",
1156 "item_id": tool.response_item_id,
1157 "output_index": output_index,
1158 "delta": tool.pending_arguments,
1159 }));
1160 tool.pending_arguments.clear();
1161 }
1162 out
1163}
1164
1165fn decode_terminal_usage(
1167 state: &mut StreamTranslationState,
1168 event: &Value,
1169 out: &mut Vec<LlmResponseChunk>,
1170) {
1171 if let Some(usage) = event
1172 .get("response")
1173 .and_then(Value::as_object)
1174 .and_then(|response| response.get("usage"))
1175 .and_then(Value::as_object)
1176 {
1177 let usage = responses_usage(usage);
1178 state.usage = usage.clone();
1179 state.saw_backend_usage = true;
1180 out.push(LlmResponseChunk::Usage(usage));
1181 }
1182}
1183
1184fn responses_usage(usage: &serde_json::Map<String, Value>) -> Usage {
1186 let aggregate_input_tokens = usage.get("input_tokens").and_then(Value::as_u64);
1187 let cached_input_tokens = usage
1188 .get("input_tokens_details")
1189 .and_then(|details| details.get("cached_tokens"))
1190 .and_then(Value::as_u64);
1191 let cache_creation_input_tokens = usage
1192 .get("input_tokens_details")
1193 .and_then(|details| details.get("cache_write_tokens"))
1194 .and_then(Value::as_u64);
1195 let input_tokens = aggregate_input_tokens.map(|tokens| {
1196 tokens
1197 .saturating_sub(cached_input_tokens.unwrap_or(0))
1198 .saturating_sub(cache_creation_input_tokens.unwrap_or(0))
1199 });
1200 let output_tokens = usage.get("output_tokens").and_then(Value::as_u64);
1201 Usage {
1202 provider_cost: crate::codecs::common::provider_cost(usage),
1203 input_tokens,
1204 cache: crate::codecs::common::cache_usage_details(
1205 usage,
1206 cached_input_tokens,
1207 cache_creation_input_tokens,
1208 ),
1209 output_tokens,
1210 total_tokens: usage
1211 .get("total_tokens")
1212 .and_then(Value::as_u64)
1213 .or_else(|| Some(aggregate_input_tokens.unwrap_or(0) + output_tokens.unwrap_or(0))),
1214 reasoning_tokens: usage
1215 .get("output_tokens_details")
1216 .and_then(|details| details.get("reasoning_tokens"))
1217 .or_else(|| {
1218 usage
1219 .get("completion_tokens_details")
1220 .and_then(|details| details.get("reasoning_tokens"))
1221 })
1222 .and_then(Value::as_u64),
1223 }
1224}
1225
1226const ITEM_ID_DISCRIMINATOR_CHARS: usize = 40;
1230
1231fn responses_item_id(state: &StreamTranslationState, prefix: &str, output_index: usize) -> String {
1235 responses_item_id_from(&responses_id(state), prefix, output_index)
1236}
1237
1238fn responses_item_id_from(resp_id: &str, prefix: &str, output_index: usize) -> String {
1241 let resp = resp_id.strip_prefix("resp_").unwrap_or(resp_id);
1242 if resp.chars().count() <= ITEM_ID_DISCRIMINATOR_CHARS {
1243 format!("{prefix}_{resp}_{output_index}")
1244 } else {
1245 format!("{prefix}_{:016x}_{output_index}", fnv1a_64(resp))
1246 }
1247}
1248
1249fn fnv1a_64(text: &str) -> u64 {
1252 text.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
1253 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
1254 })
1255}
1256
1257fn responses_id(state: &StreamTranslationState) -> String {
1259 target_message_id_or_source_message_id(state)
1260 .unwrap_or("resp_switchyard")
1261 .to_string()
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266 use super::*;
1267
1268 #[test]
1269 fn recognizes_stream_completion() {
1270 let codec = OpenAiResponsesStreamCodec;
1271 assert!(codec.sse_done_is_terminal());
1272 for kind in [
1273 "response.completed",
1274 "response.incomplete",
1275 "response.failed",
1276 ] {
1277 assert!(codec.is_terminal_event(&json!({"type": kind})));
1278 assert!(codec.is_terminal_event(&json!({"event": kind})));
1279 }
1280 assert!(!codec.is_terminal_event(&json!({"type": "response.created"})));
1281 }
1282
1283 #[test]
1284 fn responses_replay_stamps_the_served_model_inside_response() {
1285 let mut event = json!({
1286 "type": "response.created",
1287 "response": {
1288 "id": "resp_1",
1289 "model": "provider/model",
1290 "provider_extension": true,
1291 },
1292 });
1293
1294 OpenAiResponsesStreamCodec.stamp_streamed_response_model(&mut event, "served/model");
1295
1296 assert_eq!(event["response"]["model"], "served/model");
1297 assert_eq!(event["response"]["provider_extension"], true);
1298 }
1299}