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