1use crate::anthropic::sse::parse_sse_events;
2use crate::providers::codex::events::is_terminal_rate_limit_event;
3
4use super::read_rewrite::sanitize_read_args;
5use super::reasoning_signature::{PendingReasoning, ReasoningReplay, encode_reasoning_signature};
6use super::request::ResponsesInputItem;
7
8#[derive(Debug, Clone)]
9pub struct UpstreamStreamError {
10 pub kind: UpstreamErrorKind,
11 pub message: String,
12 pub retry_after_seconds: Option<u64>,
13 pub diagnostics: Option<UpstreamStreamDiagnostics>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum UpstreamErrorKind {
18 RateLimit,
19 Overloaded,
20 Transient,
21 Failed,
22}
23
24#[derive(Debug, Clone, Default, serde::Serialize)]
25pub struct UpstreamStreamDiagnostics {
26 pub event_count: usize,
27 pub last_event_type: Option<String>,
28 pub saw_terminal_event: bool,
29 pub open_blocks: Vec<OpenBlockDiagnostic>,
30}
31
32#[derive(Debug, Clone, serde::Serialize)]
33pub struct OpenBlockDiagnostic {
34 pub output_index: usize,
35 pub anthropic_index: usize,
36 pub kind: String,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub name: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub call_id: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub text_bytes: Option<usize>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub argument_bytes: Option<usize>,
45}
46
47#[derive(Debug, Clone, Default)]
48pub struct CodexUsage {
49 pub input_tokens: Option<u64>,
50 pub output_tokens: Option<u64>,
51 pub input_tokens_details_cached: Option<u64>,
52 pub output_tokens_details_reasoning: Option<u64>,
53}
54
55pub type StopReason = &'static str;
56pub const STOP_END_TURN: &str = "end_turn";
57pub const STOP_TOOL_USE: &str = "tool_use";
58pub const STOP_MAX_TOKENS: &str = "max_tokens";
59
60pub type TerminalType = &'static str;
61pub const TERM_COMPLETED: &str = "response.completed";
62pub const TERM_INCOMPLETE: &str = "response.incomplete";
63pub const TERM_DONE: &str = "response.done";
64
65const BUFFERED_READ_REPAIR_TRAILING_WHITESPACE_BYTES: usize = 1_024;
66const BUFFERED_TOOL_MAX_ARGS_BYTES: usize = 5_000_000;
67
68#[derive(Debug, Clone)]
69pub enum ReducerEvent {
70 ThinkingStart {
71 index: usize,
72 },
73 ThinkingDelta {
74 index: usize,
75 text: String,
76 },
77 ThinkingSignature {
78 index: usize,
79 signature: String,
80 },
81 ThinkingStop {
82 index: usize,
83 },
84 TextStart {
85 index: usize,
86 },
87 TextDelta {
88 index: usize,
89 text: String,
90 },
91 TextStop {
92 index: usize,
93 },
94 ToolStart {
95 index: usize,
96 id: String,
97 name: String,
98 },
99 ToolDelta {
100 index: usize,
101 partial_json: String,
102 },
103 ToolStop {
104 index: usize,
105 },
106 ToolProgress {
107 index: usize,
108 },
109 Progress,
110 WebSearch {
111 index: usize,
112 result_index: usize,
113 id: String,
114 query: String,
115 },
116 Finish {
117 stop_reason: StopReason,
118 terminal_type: String,
119 continuation_eligible: bool,
120 usage: Option<CodexUsage>,
121 web_search_requests: usize,
122 response_id: Option<String>,
123 output_items: Vec<ResponsesInputItem>,
124 },
125}
126
127#[derive(Debug, Clone)]
128pub struct FinishMetadata {
129 pub continuation_eligible: bool,
130 pub response_id: Option<String>,
131 pub output_items: Vec<ResponsesInputItem>,
132}
133
134enum BlockState {
135 Text {
136 index: usize,
137 text_accum: String,
138 },
139 Tool {
140 index: usize,
141 #[allow(dead_code)]
142 output_index: usize,
143 call_id: String,
144 name: String,
145 args_accum: String,
146 had_delta: bool,
147 buffer_until_done: bool,
148 emitted_args: bool,
149 },
150}
151
152#[derive(Clone, Copy)]
153struct ActiveThinking {
154 output_index: usize,
155 anthropic_index: usize,
156}
157
158fn reasoning_input_item(replay: ReasoningReplay) -> ResponsesInputItem {
159 ResponsesInputItem::Reasoning {
160 id: replay.id,
161 summary: Vec::new(),
162 encrypted_content: replay.encrypted_content,
163 }
164}
165
166fn finalize_active_thinking(
167 active: ActiveThinking,
168 out: &mut Vec<ReducerEvent>,
169 reasoning_by_output_index: &mut std::collections::HashMap<usize, PendingReasoning>,
170 output_items_by_index: &mut std::collections::BTreeMap<usize, ResponsesInputItem>,
171) {
172 if let Some(replay) = reasoning_by_output_index
173 .remove(&active.output_index)
174 .and_then(|pending| pending.replay())
175 && let Some(signature) = encode_reasoning_signature(&replay)
176 {
177 out.push(ReducerEvent::ThinkingSignature {
178 index: active.anthropic_index,
179 signature,
180 });
181 output_items_by_index.insert(active.output_index, reasoning_input_item(replay));
182 }
183 out.push(ReducerEvent::ThinkingStop {
184 index: active.anthropic_index,
185 });
186}
187
188fn close_thinking(
189 out: &mut Vec<ReducerEvent>,
190 active_thinking: &mut Option<ActiveThinking>,
191 reasoning_by_output_index: &mut std::collections::HashMap<usize, PendingReasoning>,
192 output_items_by_index: &mut std::collections::BTreeMap<usize, ResponsesInputItem>,
193) {
194 if let Some(active) = active_thinking.take() {
195 finalize_active_thinking(
196 active,
197 out,
198 reasoning_by_output_index,
199 output_items_by_index,
200 );
201 }
202}
203
204fn emit_signature_only_reasoning(
205 output_index: usize,
206 anthropic_index: &mut usize,
207 out: &mut Vec<ReducerEvent>,
208 reasoning_by_output_index: &mut std::collections::HashMap<usize, PendingReasoning>,
209 output_items_by_index: &mut std::collections::BTreeMap<usize, ResponsesInputItem>,
210) {
211 let Some(replay) = reasoning_by_output_index
212 .remove(&output_index)
213 .and_then(|pending| pending.replay())
214 else {
215 return;
216 };
217 let Some(signature) = encode_reasoning_signature(&replay) else {
218 return;
219 };
220 let index = *anthropic_index;
221 *anthropic_index += 1;
222 out.push(ReducerEvent::ThinkingStart { index });
223 out.push(ReducerEvent::ThinkingSignature { index, signature });
224 out.push(ReducerEvent::ThinkingStop { index });
225 output_items_by_index.insert(output_index, reasoning_input_item(replay));
226}
227
228pub fn finish_metadata_from_upstream(
229 input: &[u8],
230) -> Result<Option<FinishMetadata>, UpstreamStreamError> {
231 let events = reduce_upstream_bytes(input)?;
232 Ok(events.into_iter().rev().find_map(|event| match event {
233 ReducerEvent::Finish {
234 continuation_eligible,
235 response_id,
236 output_items,
237 ..
238 } => Some(FinishMetadata {
239 continuation_eligible,
240 response_id,
241 output_items,
242 }),
243 _ => None,
244 }))
245}
246
247pub fn reduce_upstream_bytes(input: &[u8]) -> Result<Vec<ReducerEvent>, UpstreamStreamError> {
248 let sse_events = parse_sse_events(input);
249 let mut out = Vec::new();
250
251 let mut blocks_by_output_index: std::collections::HashMap<usize, BlockState> =
252 std::collections::HashMap::new();
253 let mut output_items_by_index: std::collections::BTreeMap<usize, ResponsesInputItem> =
254 std::collections::BTreeMap::new();
255 let mut item_id_to_output_index: std::collections::HashMap<String, usize> =
256 std::collections::HashMap::new();
257 let mut reasoning_by_output_index: std::collections::HashMap<usize, PendingReasoning> =
258 std::collections::HashMap::new();
259 let mut anthropic_index = 0usize;
260 let mut active_thinking: Option<ActiveThinking> = None;
261 let mut saw_tool_use = false;
262 let mut final_usage: Option<CodexUsage> = None;
263 let mut response_id: Option<String> = None;
264 let mut terminal_type: Option<String> = None;
265 let mut continuation_eligible = false;
266 let mut incomplete = false;
267 let mut web_search_requests = 0usize;
268 let mut _saw_terminal = false;
269 let mut event_count = 0usize;
270 let mut last_event_type: Option<String> = None;
271
272 fn capture_output_item(
273 output_index: usize,
274 state: &BlockState,
275 items: &mut std::collections::BTreeMap<usize, ResponsesInputItem>,
276 ) {
277 match state {
278 BlockState::Text {
279 index: _,
280 text_accum,
281 } => {
282 if text_accum.is_empty() {
283 return;
284 }
285 items.insert(
286 output_index,
287 ResponsesInputItem::Message {
288 role: "assistant".to_string(),
289 content: vec![super::request::ResponsesContentPart::OutputText {
290 text: text_accum.clone(),
291 }],
292 },
293 );
294 }
295 BlockState::Tool {
296 args_accum,
297 name,
298 call_id,
299 ..
300 } => {
301 items.insert(
302 output_index,
303 ResponsesInputItem::FunctionCall {
304 call_id: call_id.clone(),
305 name: name.clone(),
306 arguments: args_accum.clone(),
307 },
308 );
309 }
310 }
311 }
312
313 for evt in &sse_events {
314 let data = evt.data.trim();
315 if data.is_empty() {
316 continue;
317 }
318
319 let p: serde_json::Value = match serde_json::from_str(data) {
320 Ok(v) => v,
321 Err(_) => continue,
322 };
323
324 let t = p
325 .get("type")
326 .and_then(|v| v.as_str())
327 .unwrap_or("")
328 .to_string();
329 event_count += 1;
330 last_event_type = Some(t.clone());
331
332 if t == "codex.rate_limits" {
333 if is_terminal_rate_limit_event(&p) {
334 let retry_after = p
335 .get("rate_limits")
336 .and_then(|r| r.get("primary"))
337 .and_then(|r| r.get("reset_after_seconds"))
338 .and_then(|v| v.as_f64());
339 return Err(UpstreamStreamError {
340 kind: UpstreamErrorKind::RateLimit,
341 message: "rate limit reached".to_string(),
342 retry_after_seconds: retry_after.map(|f| f as u64),
343 diagnostics: None,
344 });
345 }
346 out.push(ReducerEvent::Progress);
347 continue;
348 }
349
350 if t == "keepalive" {
351 out.push(ReducerEvent::Progress);
352 continue;
353 }
354
355 if t == "response.web_search_call.in_progress"
356 || t == "response.web_search_call.searching"
357 || t == "response.web_search_call.completed"
358 {
359 out.push(ReducerEvent::Progress);
360 continue;
361 }
362
363 if t == "response.failed" || t == "response.error" || t == "error" {
364 let msg = p
365 .get("response")
366 .and_then(|r| r.get("error"))
367 .and_then(|e| e.get("message"))
368 .and_then(|v| v.as_str())
369 .or_else(|| {
370 p.get("error")
371 .and_then(|e| e.get("message"))
372 .and_then(|v| v.as_str())
373 })
374 .unwrap_or("Upstream error");
375 let kind = upstream_failure_kind(&p, msg);
376 let retry_after = retry_after_from_payload(&p);
377 return Err(UpstreamStreamError {
378 kind,
379 message: msg.to_string(),
380 retry_after_seconds: retry_after,
381 diagnostics: None,
382 });
383 }
384
385 if t == "response.output_item.added" {
386 let item = match p.get("item") {
387 Some(v) => v,
388 None => continue,
389 };
390 let output_index: usize =
391 p.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
392
393 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
394 if item_type == "reasoning" {
395 reasoning_by_output_index
396 .entry(output_index)
397 .or_default()
398 .capture(item);
399 continue;
400 }
401 if item_type == "web_search_call" {
402 out.push(ReducerEvent::Progress);
403 continue;
404 }
405
406 if item_type == "message" {
407 close_thinking(
408 &mut out,
409 &mut active_thinking,
410 &mut reasoning_by_output_index,
411 &mut output_items_by_index,
412 );
413 let idx = anthropic_index;
414 anthropic_index += 1;
415 if let Some(id) = item.get("id").and_then(|v| v.as_str()) {
416 item_id_to_output_index.insert(id.to_string(), output_index);
417 }
418 blocks_by_output_index.insert(
419 output_index,
420 BlockState::Text {
421 index: idx,
422 text_accum: String::new(),
423 },
424 );
425 out.push(ReducerEvent::TextStart { index: idx });
426 continue;
427 }
428
429 if item_type == "function_call" {
430 close_thinking(
431 &mut out,
432 &mut active_thinking,
433 &mut reasoning_by_output_index,
434 &mut output_items_by_index,
435 );
436 saw_tool_use = true;
437 let idx = anthropic_index;
438 anthropic_index += 1;
439 let call_id = item
440 .get("call_id")
441 .and_then(|v| v.as_str())
442 .unwrap_or("")
443 .to_string();
444 let name = item
445 .get("name")
446 .and_then(|v| v.as_str())
447 .unwrap_or("")
448 .to_string();
449 let buffer_until_done = should_buffer_tool_args(&name);
450 blocks_by_output_index.insert(
451 output_index,
452 BlockState::Tool {
453 index: idx,
454 output_index,
455 call_id: call_id.clone(),
456 name: name.clone(),
457 args_accum: String::new(),
458 had_delta: false,
459 buffer_until_done,
460 emitted_args: false,
461 },
462 );
463 out.push(ReducerEvent::ToolStart {
464 index: idx,
465 id: call_id,
466 name,
467 });
468 continue;
469 }
470
471 continue;
472 }
473
474 if t == "response.reasoning_summary_part.added" {
475 let output_index = p.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
476 if let Some(active) =
477 active_thinking.filter(|active| active.output_index == output_index)
478 {
479 out.push(ReducerEvent::ThinkingDelta {
480 index: active.anthropic_index,
481 text: "\n\n".to_string(),
482 });
483 }
484 continue;
485 }
486
487 if t == "response.reasoning_summary_text.delta" {
488 let output_index = p.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
489 let delta = p.get("delta").and_then(|v| v.as_str()).unwrap_or("");
490 if delta.is_empty() {
491 continue;
492 }
493 if active_thinking.map(|active| active.output_index) != Some(output_index) {
494 close_thinking(
495 &mut out,
496 &mut active_thinking,
497 &mut reasoning_by_output_index,
498 &mut output_items_by_index,
499 );
500 let index = anthropic_index;
501 anthropic_index += 1;
502 active_thinking = Some(ActiveThinking {
503 output_index,
504 anthropic_index: index,
505 });
506 out.push(ReducerEvent::ThinkingStart { index });
507 }
508 out.push(ReducerEvent::ThinkingDelta {
509 index: active_thinking
510 .expect("thinking block was started")
511 .anthropic_index,
512 text: delta.to_string(),
513 });
514 continue;
515 }
516
517 if t == "response.output_text.delta" {
518 close_thinking(
519 &mut out,
520 &mut active_thinking,
521 &mut reasoning_by_output_index,
522 &mut output_items_by_index,
523 );
524 let output_index = p
525 .get("output_index")
526 .and_then(|v| v.as_u64())
527 .map(|v| v as usize);
528 let item_id = p.get("item_id").and_then(|v| v.as_str());
529 let state = if let Some(oi) = output_index {
530 blocks_by_output_index.get_mut(&oi)
531 } else if let Some(id) = item_id {
532 item_id_to_output_index
533 .get(id)
534 .and_then(|oi| blocks_by_output_index.get_mut(oi))
535 } else {
536 None
537 };
538 let delta = p.get("delta").and_then(|v| v.as_str()).unwrap_or("");
539 if delta.is_empty() {
540 continue;
541 }
542 match state {
543 Some(BlockState::Text { index, text_accum }) => {
544 text_accum.push_str(delta);
545 out.push(ReducerEvent::TextDelta {
546 index: *index,
547 text: delta.to_string(),
548 });
549 }
550 _ => continue,
551 }
552 continue;
553 }
554
555 if t == "response.function_call_arguments.delta" {
556 let output_index = match p.get("output_index").and_then(|v| v.as_u64()) {
557 Some(v) => v as usize,
558 None => continue,
559 };
560 let delta = p.get("delta").and_then(|v| v.as_str()).unwrap_or("");
561 if delta.is_empty() {
562 continue;
563 }
564 let state = match blocks_by_output_index.get_mut(&output_index) {
565 Some(s) => s,
566 None => continue,
567 };
568 let mut repaired_read: Option<(usize, String)> = None;
569 match state {
570 BlockState::Tool {
571 args_accum,
572 had_delta,
573 buffer_until_done,
574 emitted_args,
575 name,
576 index,
577 call_id,
578 ..
579 } => {
580 args_accum.push_str(delta);
581 *had_delta = true;
582 if args_accum.len() > BUFFERED_TOOL_MAX_ARGS_BYTES {
583 return Err(UpstreamStreamError {
584 kind: UpstreamErrorKind::Failed,
585 message: format!("Buffered {name} tool arguments exceeded safe limits"),
586 retry_after_seconds: None,
587 diagnostics: None,
588 });
589 }
590
591 if *buffer_until_done {
592 if let Some(repaired) = repair_whitespace_stalled_read_args(
593 name,
594 args_accum,
595 Some(call_id.as_str()),
596 ) {
597 *args_accum = repaired.clone();
598 *emitted_args = true;
599 repaired_read = Some((*index, repaired));
600 } else {
601 out.push(ReducerEvent::ToolProgress { index: *index });
602 }
603 } else {
604 *emitted_args = true;
605 out.push(ReducerEvent::ToolDelta {
606 index: *index,
607 partial_json: delta.to_string(),
608 });
609 }
610 }
611 _ => continue,
612 }
613 if let Some((index, repaired)) = repaired_read {
614 if let Some(state) = blocks_by_output_index.remove(&output_index) {
615 capture_output_item(output_index, &state, &mut output_items_by_index);
616 }
617 out.push(ReducerEvent::ToolDelta {
618 index,
619 partial_json: repaired,
620 });
621 out.push(ReducerEvent::ToolStop { index });
622 let output_items: Vec<ResponsesInputItem> =
623 output_items_by_index.into_values().collect();
624 out.push(ReducerEvent::Finish {
625 stop_reason: STOP_TOOL_USE,
626 terminal_type: TERM_INCOMPLETE.to_string(),
627 continuation_eligible: false,
628 usage: None,
629 web_search_requests,
630 response_id: None,
631 output_items,
632 });
633 return Ok(out);
634 }
635 continue;
636 }
637
638 if t == "response.function_call_arguments.done" {
639 let output_index = match p.get("output_index").and_then(|v| v.as_u64()) {
640 Some(v) => v as usize,
641 None => continue,
642 };
643 if let Some(BlockState::Tool { args_accum, .. }) =
644 blocks_by_output_index.get_mut(&output_index)
645 && let Some(args) = p.get("arguments").and_then(|v| v.as_str())
646 && args_accum.is_empty()
647 {
648 *args_accum = args.to_string();
649 }
650 continue;
651 }
652
653 if t == "response.output_item.done" {
654 let output_index: usize =
655 p.get("output_index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
656 let item = p.get("item");
657
658 if let Some(item_val) = item
659 && item_val.get("type").and_then(|v| v.as_str()) == Some("reasoning")
660 {
661 reasoning_by_output_index
662 .entry(output_index)
663 .or_default()
664 .capture(item_val);
665 let had_active_summary =
666 active_thinking.is_some_and(|active| active.output_index == output_index);
667 close_thinking(
668 &mut out,
669 &mut active_thinking,
670 &mut reasoning_by_output_index,
671 &mut output_items_by_index,
672 );
673 if !had_active_summary {
674 emit_signature_only_reasoning(
675 output_index,
676 &mut anthropic_index,
677 &mut out,
678 &mut reasoning_by_output_index,
679 &mut output_items_by_index,
680 );
681 }
682 continue;
683 }
684
685 if let Some(item_val) = item
686 && item_val.get("type").and_then(|v| v.as_str()) == Some("web_search_call")
687 {
688 close_thinking(
689 &mut out,
690 &mut active_thinking,
691 &mut reasoning_by_output_index,
692 &mut output_items_by_index,
693 );
694 let idx = anthropic_index;
695 anthropic_index += 1;
696 let result_index = anthropic_index;
697 anthropic_index += 1;
698 web_search_requests += 1;
699 let id_val = item_val.get("id").and_then(|v| v.as_str()).unwrap_or("");
700 let id = server_tool_use_id_from_codex_web_search_id(id_val);
701 let query = web_search_query(item_val);
702 out.push(ReducerEvent::WebSearch {
703 index: idx,
704 result_index,
705 id,
706 query,
707 });
708 continue;
709 }
710
711 let state = blocks_by_output_index.remove(&output_index);
712 let mut state = match state {
713 Some(s) => s,
714 None => continue,
715 };
716
717 if let Some(item_val) = item
718 && let BlockState::Tool {
719 args_accum,
720 name,
721 call_id,
722 buffer_until_done,
723 emitted_args,
724 had_delta,
725 index,
726 ..
727 } = &mut state
728 {
729 if let Some(final_args) = item_val
730 .get("arguments")
731 .and_then(|v| v.as_str())
732 .filter(|s| !s.is_empty())
733 {
734 if !args_accum.is_empty() && !*emitted_args {
735 } else if *had_delta {
737 } else {
739 *args_accum = final_args.to_string();
740 }
741 }
742
743 if !args_accum.is_empty() {
744 let sanitized = sanitize_read_args(name, args_accum, Some(call_id.as_str()));
745 *args_accum = sanitized;
746 if *buffer_until_done || !*emitted_args {
747 *emitted_args = true;
748 out.push(ReducerEvent::ToolDelta {
749 index: *index,
750 partial_json: args_accum.clone(),
751 });
752 }
753 }
754 }
755
756 capture_output_item(output_index, &state, &mut output_items_by_index);
757
758 match &state {
759 BlockState::Text { index, .. } => {
760 out.push(ReducerEvent::TextStop { index: *index });
761 }
762 BlockState::Tool { index, .. } => {
763 out.push(ReducerEvent::ToolStop { index: *index });
764 }
765 }
766 continue;
767 }
768
769 if t == "response.completed" || t == "response.incomplete" || t == "response.done" {
770 _saw_terminal = true;
771 terminal_type = Some(t.clone());
772 response_id = p
773 .get("response")
774 .and_then(|r| r.get("id"))
775 .and_then(|v| v.as_str())
776 .map(|s| s.to_string());
777 final_usage = p.get("response").map(parse_codex_usage);
778 if response_is_incomplete(&p, &t) {
779 incomplete = true;
780 }
781 continuation_eligible =
782 (t == "response.completed" || t == "response.done") && !incomplete;
783 continue;
784 }
785 }
786
787 let open_blocks = describe_open_blocks(&blocks_by_output_index);
788 if !_saw_terminal || !open_blocks.is_empty() {
789 let diagnostics = UpstreamStreamDiagnostics {
790 event_count,
791 last_event_type,
792 saw_terminal_event: _saw_terminal,
793 open_blocks,
794 };
795 return Err(UpstreamStreamError {
796 kind: UpstreamErrorKind::Transient,
797 message: if diagnostics.saw_terminal_event {
798 "upstream stream ended with open Codex output blocks".to_string()
799 } else {
800 "upstream stream ended before terminal Codex response event".to_string()
801 },
802 retry_after_seconds: None,
803 diagnostics: Some(diagnostics),
804 });
805 }
806
807 close_thinking(
808 &mut out,
809 &mut active_thinking,
810 &mut reasoning_by_output_index,
811 &mut output_items_by_index,
812 );
813
814 let stop_reason: StopReason = if incomplete {
815 STOP_MAX_TOKENS
816 } else if saw_tool_use {
817 STOP_TOOL_USE
818 } else {
819 STOP_END_TURN
820 };
821
822 let output_items: Vec<ResponsesInputItem> = output_items_by_index.into_values().collect();
823
824 out.push(ReducerEvent::Finish {
825 stop_reason,
826 terminal_type: terminal_type.unwrap_or_else(|| TERM_INCOMPLETE.to_string()),
827 continuation_eligible,
828 usage: final_usage,
829 web_search_requests,
830 response_id,
831 output_items,
832 });
833
834 Ok(out)
835}
836
837fn describe_open_blocks(
838 blocks: &std::collections::HashMap<usize, BlockState>,
839) -> Vec<OpenBlockDiagnostic> {
840 let mut out: Vec<_> = blocks
841 .iter()
842 .map(|(output_index, state)| match state {
843 BlockState::Text { index, text_accum } => OpenBlockDiagnostic {
844 output_index: *output_index,
845 anthropic_index: *index,
846 kind: "text".to_string(),
847 name: None,
848 call_id: None,
849 text_bytes: Some(text_accum.len()),
850 argument_bytes: None,
851 },
852 BlockState::Tool {
853 index,
854 call_id,
855 name,
856 args_accum,
857 ..
858 } => OpenBlockDiagnostic {
859 output_index: *output_index,
860 anthropic_index: *index,
861 kind: "tool".to_string(),
862 name: Some(name.clone()),
863 call_id: Some(call_id.clone()),
864 text_bytes: None,
865 argument_bytes: Some(args_accum.len()),
866 },
867 })
868 .collect();
869 out.sort_by_key(|block| block.output_index);
870 out
871}
872
873fn parse_codex_usage(response: &serde_json::Value) -> CodexUsage {
874 let usage = match response.get("usage") {
875 Some(u) => u,
876 None => return CodexUsage::default(),
877 };
878 CodexUsage {
879 input_tokens: usage.get("input_tokens").and_then(|v| v.as_u64()),
880 output_tokens: usage.get("output_tokens").and_then(|v| v.as_u64()),
881 input_tokens_details_cached: usage
882 .get("input_tokens_details")
883 .and_then(|d| d.get("cached_tokens"))
884 .and_then(|v| v.as_u64()),
885 output_tokens_details_reasoning: usage
886 .get("output_tokens_details")
887 .and_then(|d| d.get("reasoning_tokens"))
888 .and_then(|v| v.as_u64()),
889 }
890}
891
892fn response_is_incomplete(payload: &serde_json::Value, event_type: &str) -> bool {
893 event_type == "response.incomplete"
894 || payload
895 .get("response")
896 .and_then(|r| r.get("status"))
897 .and_then(|v| v.as_str())
898 == Some("incomplete")
899 || payload
900 .get("response")
901 .and_then(|r| r.get("incomplete_details"))
902 .and_then(|d| d.get("reason"))
903 .and_then(|v| v.as_str())
904 .is_some()
905}
906
907fn should_buffer_tool_args(name: &str) -> bool {
908 name == "Read"
909}
910
911fn repair_whitespace_stalled_read_args(
912 name: &str,
913 args: &str,
914 call_id: Option<&str>,
915) -> Option<String> {
916 if name != "Read" {
917 return None;
918 }
919 let trimmed = args.trim_end();
920 let trailing_whitespace = args.len().saturating_sub(trimmed.len());
921 if trailing_whitespace < BUFFERED_READ_REPAIR_TRAILING_WHITESPACE_BYTES {
922 return None;
923 }
924 parse_read_args_candidate(trimmed, call_id).or_else(|| {
925 let with_brace = format!("{trimmed}}}");
926 parse_read_args_candidate(&with_brace, call_id)
927 })
928}
929
930fn parse_read_args_candidate(args: &str, call_id: Option<&str>) -> Option<String> {
931 let parsed: serde_json::Value = serde_json::from_str(args).ok()?;
932 if !is_valid_read_args(&parsed) {
933 return None;
934 }
935 Some(sanitize_read_args(
936 "Read",
937 &serde_json::to_string(&parsed).ok()?,
938 call_id,
939 ))
940}
941
942fn is_valid_read_args(value: &serde_json::Value) -> bool {
943 let Some(obj) = value.as_object() else {
944 return false;
945 };
946 for key in obj.keys() {
947 if !matches!(key.as_str(), "file_path" | "offset" | "limit" | "pages") {
948 return false;
949 }
950 }
951 let Some(file_path) = obj.get("file_path").and_then(|v| v.as_str()) else {
952 return false;
953 };
954 if file_path.is_empty() {
955 return false;
956 }
957 if let Some(offset) = obj.get("offset").and_then(|v| v.as_i64())
958 && offset < 0
959 {
960 return false;
961 }
962 if let Some(limit) = obj.get("limit").and_then(|v| v.as_i64())
963 && limit <= 0
964 {
965 return false;
966 }
967 if obj.get("offset").is_some_and(|v| !v.is_i64()) {
968 return false;
969 }
970 if obj.get("limit").is_some_and(|v| !v.is_i64()) {
971 return false;
972 }
973 if obj.get("pages").is_some_and(|v| !v.is_string()) {
974 return false;
975 }
976 true
977}
978
979fn web_search_query(item: &serde_json::Value) -> String {
980 let action = match item.get("action") {
981 Some(v) => v,
982 None => return String::new(),
983 };
984 if let Some(query) = action.get("query").and_then(|v| v.as_str()) {
985 return query.to_string();
986 }
987 if let Some(queries) = action.get("queries").and_then(|v| v.as_array()) {
988 for q in queries {
989 if let Some(s) = q.as_str() {
990 return s.to_string();
991 }
992 }
993 }
994 String::new()
995}
996
997fn server_tool_use_id_from_codex_web_search_id(id: &str) -> String {
998 let suffix: String = id
999 .chars()
1000 .map(|c| {
1001 if c.is_alphanumeric() || c == '_' {
1002 c
1003 } else {
1004 '_'
1005 }
1006 })
1007 .collect();
1008 format!("srvtoolu_{suffix}")
1009}
1010
1011fn upstream_failure_kind(payload: &serde_json::Value, message: &str) -> UpstreamErrorKind {
1012 let status = payload
1013 .get("status")
1014 .or_else(|| payload.get("status_code"))
1015 .and_then(|v| v.as_u64());
1016 let code = payload
1017 .get("response")
1018 .and_then(|r| r.get("error"))
1019 .and_then(|e| e.get("code"))
1020 .or_else(|| payload.get("error").and_then(|e| e.get("code")))
1021 .and_then(|v| v.as_str());
1022 let err_type = payload
1023 .get("response")
1024 .and_then(|r| r.get("error"))
1025 .and_then(|e| e.get("type"))
1026 .or_else(|| payload.get("error").and_then(|e| e.get("type")))
1027 .and_then(|v| v.as_str());
1028 let lower_msg = message.to_lowercase();
1029
1030 if status == Some(529)
1031 || code == Some("overloaded_error")
1032 || err_type == Some("overloaded_error")
1033 || lower_msg.contains("overloaded")
1034 {
1035 return UpstreamErrorKind::Overloaded;
1036 }
1037
1038 if (status.is_some_and(|s| (500..600).contains(&s)))
1039 || code == Some("server_error")
1040 || code == Some("internal_server_error")
1041 || code == Some("internal_error")
1042 || err_type == Some("server_error")
1043 || err_type == Some("internal_server_error")
1044 || err_type == Some("internal_error")
1045 || is_retryable_transport_message(&lower_msg)
1046 {
1047 return UpstreamErrorKind::Transient;
1048 }
1049
1050 UpstreamErrorKind::Failed
1051}
1052
1053fn retry_after_from_payload(payload: &serde_json::Value) -> Option<u64> {
1054 let raw = payload
1055 .get("response")
1056 .and_then(|r| r.get("error"))
1057 .and_then(|e| e.get("retry_after_seconds"))
1058 .or_else(|| {
1059 payload
1060 .get("error")
1061 .and_then(|e| e.get("retry_after_seconds"))
1062 })
1063 .or_else(|| payload.get("retry_after_seconds"))
1064 .or_else(|| payload.get("headers").and_then(|h| h.get("retry-after")))
1065 .or_else(|| payload.get("headers").and_then(|h| h.get("Retry-After")));
1066 let value = match raw {
1067 Some(v) if v.is_number() => v.as_f64(),
1068 Some(v) if v.is_string() => v.as_str().and_then(|s| s.parse::<f64>().ok()),
1069 _ => None,
1070 };
1071 value.map(|f| f as u64)
1072}
1073
1074fn is_retryable_transport_message(msg: &str) -> bool {
1075 msg.contains("you can retry your request")
1076 || msg.contains("socket connection was closed unexpectedly")
1077 || msg.contains("connection closed unexpectedly")
1078 || msg.contains("connection reset")
1079 || msg.contains("operation timed out")
1080 || msg.contains("econnreset")
1081 || msg.contains("epipe")
1082 || msg.contains("etimedout")
1083 || msg.contains("und_err_socket")
1084 || msg.contains("fetch failed")
1085}
1086
1087pub fn map_codex_usage_to_anthropic(
1088 u: &Option<CodexUsage>,
1089 web_search_requests: Option<usize>,
1090) -> AnthropicUsage {
1091 let usage = match u {
1092 Some(u) => u,
1093 None => return AnthropicUsage::default(),
1094 };
1095 let cached = usage.input_tokens_details_cached.unwrap_or(0);
1096 let total_input = usage.input_tokens.unwrap_or(0);
1097 let input_tokens = total_input.saturating_sub(cached);
1098
1099 let mut result = AnthropicUsage {
1100 input_tokens,
1101 output_tokens: usage.output_tokens.unwrap_or(0),
1102 cache_creation_input_tokens: 0,
1103 cache_read_input_tokens: cached,
1104 server_tool_use: None,
1105 };
1106
1107 if let Some(requests) = web_search_requests
1108 && requests > 0
1109 {
1110 result.server_tool_use = Some(WebSearchUsage {
1111 web_search_requests: requests,
1112 });
1113 }
1114
1115 result
1116}
1117
1118#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
1119pub struct AnthropicUsage {
1120 pub input_tokens: u64,
1121 pub output_tokens: u64,
1122 pub cache_creation_input_tokens: u64,
1123 pub cache_read_input_tokens: u64,
1124 #[serde(default, skip_serializing_if = "Option::is_none")]
1125 pub server_tool_use: Option<WebSearchUsage>,
1126}
1127
1128#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1129pub struct WebSearchUsage {
1130 pub web_search_requests: usize,
1131}
1132
1133#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use serde_json::json;
1141
1142 fn sse(type_name: &str, payload: serde_json::Value) -> String {
1143 let mut obj = payload.as_object().cloned().unwrap_or_default();
1144 obj.insert("type".into(), json!(type_name));
1145 format!("data: {}\n\n", serde_json::to_string(&obj).unwrap())
1146 }
1147
1148 #[test]
1149 fn reduce_text_response() {
1150 let upstream = format!(
1151 "{}{}{}{}",
1152 sse(
1153 "response.output_item.added",
1154 json!({
1155 "output_index": 0,
1156 "item": {"type":"message","id":"msg_up"}
1157 })
1158 ),
1159 sse(
1160 "response.output_text.delta",
1161 json!({
1162 "output_index":0,"delta":"hello"
1163 })
1164 ),
1165 sse(
1166 "response.output_item.done",
1167 json!({
1168 "output_index":0,"item":{"type":"message"}
1169 })
1170 ),
1171 sse(
1172 "response.completed",
1173 json!({
1174 "response":{"id":"resp_1","status":"completed","incomplete_details":null,"usage":{"input_tokens":5,"output_tokens":1}}
1175 })
1176 ),
1177 );
1178 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1179 let last = out.last().unwrap();
1180 if let ReducerEvent::Finish {
1181 stop_reason,
1182 response_id,
1183 usage,
1184 ..
1185 } = last
1186 {
1187 assert_eq!(*stop_reason, "end_turn");
1188 assert_eq!(response_id.as_deref(), Some("resp_1"));
1189 assert_eq!(usage.as_ref().unwrap().input_tokens, Some(5));
1190 } else {
1191 panic!("expected Finish");
1192 }
1193 }
1194
1195 #[test]
1196 fn reduce_tool_use_response() {
1197 let upstream = format!(
1198 "{}{}{}{}{}",
1199 sse(
1200 "response.output_item.added",
1201 json!({
1202 "output_index":0,
1203 "item":{"type":"function_call","call_id":"call_1","name":"Read"}
1204 })
1205 ),
1206 sse(
1207 "response.function_call_arguments.delta",
1208 json!({
1209 "output_index":0,"delta":"{\"file_path\":"
1210 })
1211 ),
1212 sse(
1213 "response.function_call_arguments.delta",
1214 json!({
1215 "output_index":0,"delta":"\"/tmp/a\"}"
1216 })
1217 ),
1218 sse(
1219 "response.output_item.done",
1220 json!({
1221 "output_index":0,
1222 "item":{"type":"function_call","call_id":"call_1","name":"Read","arguments":"{\"file_path\":\"/tmp/a\"}"}
1223 })
1224 ),
1225 sse(
1226 "response.completed",
1227 json!({
1228 "response":{"id":"resp_1","usage":{}}
1229 })
1230 ),
1231 );
1232 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1233 let last = out.last().unwrap();
1234 if let ReducerEvent::Finish { stop_reason, .. } = last {
1235 assert_eq!(*stop_reason, "tool_use");
1236 } else {
1237 panic!("expected Finish");
1238 }
1239 }
1240
1241 #[test]
1242 fn reduce_rate_limit_throws() {
1243 let upstream = sse(
1244 "codex.rate_limits",
1245 json!({"rate_limits":{"limit_reached":true,"primary":{"reset_after_seconds":30}}}),
1246 );
1247 let result = reduce_upstream_bytes(upstream.as_bytes());
1248 assert!(result.is_err());
1249 assert_eq!(result.unwrap_err().kind, UpstreamErrorKind::RateLimit);
1250 }
1251
1252 #[test]
1253 fn reduce_repairs_whitespace_stalled_read_args() {
1254 let upstream = format!(
1255 "{}{}",
1256 sse(
1257 "response.output_item.added",
1258 json!({
1259 "output_index":0,
1260 "item":{"type":"function_call","call_id":"call_1","name":"Read"}
1261 })
1262 ),
1263 sse(
1264 "response.function_call_arguments.delta",
1265 json!({
1266 "output_index":0,
1267 "delta": format!("{{\"file_path\":\"/tmp/a\",\"pages\":\"\"{}", " ".repeat(1024))
1268 })
1269 )
1270 );
1271 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1272 assert!(out.iter().any(|event| {
1273 matches!(
1274 event,
1275 ReducerEvent::ToolDelta { partial_json, .. }
1276 if partial_json == "{\"file_path\":\"/tmp/a\"}"
1277 )
1278 }));
1279 let last = out.last().unwrap();
1280 if let ReducerEvent::Finish {
1281 stop_reason,
1282 continuation_eligible,
1283 ..
1284 } = last
1285 {
1286 assert_eq!(*stop_reason, "tool_use");
1287 assert!(!continuation_eligible);
1288 } else {
1289 panic!("expected Finish");
1290 }
1291 }
1292
1293 #[test]
1294 fn reduce_upstream_error_event() {
1295 let upstream = sse("error", json!({"error":{"message":"upstream failure"}}));
1296 let result = reduce_upstream_bytes(upstream.as_bytes());
1297 assert!(result.is_err());
1298 match result.unwrap_err().kind {
1299 UpstreamErrorKind::Failed => {}
1300 _ => panic!("expected Failed"),
1301 }
1302 }
1303
1304 #[test]
1305 fn reduce_web_search_output() {
1306 let upstream = format!(
1307 "{}{}{}{}{}{}",
1308 sse(
1309 "response.output_item.added",
1310 json!({
1311 "output_index":0,
1312 "item":{"type":"web_search_call","id":"ws_1"}
1313 })
1314 ),
1315 sse(
1316 "response.output_item.done",
1317 json!({
1318 "output_index":0,
1319 "item":{"type":"web_search_call","id":"ws_1","action":{"query":"test query"}}
1320 })
1321 ),
1322 sse(
1323 "response.output_item.added",
1324 json!({
1325 "output_index":1,
1326 "item":{"type":"message","id":"msg_up"}
1327 })
1328 ),
1329 sse(
1330 "response.output_text.delta",
1331 json!({
1332 "output_index":1,"delta":"result"
1333 })
1334 ),
1335 sse(
1336 "response.output_item.done",
1337 json!({
1338 "output_index":1,"item":{"type":"message"}
1339 })
1340 ),
1341 sse(
1342 "response.completed",
1343 json!({
1344 "response":{"id":"resp_1","usage":{"input_tokens":3}}
1345 })
1346 ),
1347 );
1348 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1349 let has_web_search = out
1350 .iter()
1351 .any(|e| matches!(e, ReducerEvent::WebSearch { .. }));
1352 assert!(has_web_search, "expected WebSearch event");
1353 let last = out.last().unwrap();
1354 if let ReducerEvent::Finish {
1355 web_search_requests,
1356 ..
1357 } = last
1358 {
1359 assert_eq!(*web_search_requests, 1);
1360 } else {
1361 panic!("expected Finish");
1362 }
1363 }
1364
1365 #[test]
1366 fn reduce_missing_terminal_is_error() {
1367 let upstream = format!(
1368 "{}{}{}",
1369 sse(
1370 "response.output_item.added",
1371 json!({
1372 "output_index": 0,
1373 "item": {"type":"message","id":"msg_up"}
1374 })
1375 ),
1376 sse(
1377 "response.output_text.delta",
1378 json!({
1379 "output_index":0,"delta":"partial"
1380 })
1381 ),
1382 sse(
1383 "response.output_item.done",
1384 json!({
1385 "output_index":0,"item":{"type":"message"}
1386 })
1387 ),
1388 );
1389 let err = reduce_upstream_bytes(upstream.as_bytes()).unwrap_err();
1390 assert_eq!(err.kind, UpstreamErrorKind::Transient);
1391 assert!(err.message.contains("terminal"));
1392 }
1393
1394 #[test]
1395 fn reduce_incomplete_is_max_tokens() {
1396 let upstream = sse(
1397 "response.incomplete",
1398 json!({"response":{"id":"resp_1","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{}}}),
1399 );
1400 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1401 let last = out.last().unwrap();
1402 if let ReducerEvent::Finish {
1403 stop_reason,
1404 continuation_eligible,
1405 ..
1406 } = last
1407 {
1408 assert_eq!(*stop_reason, "max_tokens");
1409 assert!(!continuation_eligible);
1410 } else {
1411 panic!("expected Finish");
1412 }
1413 }
1414
1415 #[test]
1416 fn reduce_completed_with_null_incomplete_details_is_end_turn() {
1417 let upstream = sse(
1418 "response.completed",
1419 json!({"response":{"id":"resp_1","status":"completed","incomplete_details":null,"usage":{}}}),
1420 );
1421 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1422 let last = out.last().unwrap();
1423 if let ReducerEvent::Finish {
1424 stop_reason,
1425 continuation_eligible,
1426 ..
1427 } = last
1428 {
1429 assert_eq!(*stop_reason, "end_turn");
1430 assert!(continuation_eligible);
1431 } else {
1432 panic!("expected Finish");
1433 }
1434 }
1435
1436 #[test]
1437 fn reduce_completed_is_continuation_eligible() {
1438 let upstream = sse(
1439 "response.completed",
1440 json!({"response":{"id":"resp_1","usage":{}}}),
1441 );
1442 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1443 let last = out.last().unwrap();
1444 if let ReducerEvent::Finish {
1445 continuation_eligible,
1446 ..
1447 } = last
1448 {
1449 assert!(continuation_eligible);
1450 } else {
1451 panic!("expected Finish");
1452 }
1453 }
1454
1455 #[test]
1456 fn finish_metadata_extracts_continuation_state() {
1457 let upstream = format!(
1458 "{}{}{}{}",
1459 sse(
1460 "response.output_item.added",
1461 json!({
1462 "output_index": 0,
1463 "item": {"type":"message","id":"msg_up"}
1464 })
1465 ),
1466 sse(
1467 "response.output_text.delta",
1468 json!({
1469 "output_index":0,"delta":"hello"
1470 })
1471 ),
1472 sse(
1473 "response.output_item.done",
1474 json!({
1475 "output_index":0,"item":{"type":"message"}
1476 })
1477 ),
1478 sse(
1479 "response.completed",
1480 json!({
1481 "response":{"id":"resp_1","usage":{}}
1482 })
1483 ),
1484 );
1485 let metadata = finish_metadata_from_upstream(upstream.as_bytes())
1486 .unwrap()
1487 .unwrap();
1488 assert!(metadata.continuation_eligible);
1489 assert_eq!(metadata.response_id.as_deref(), Some("resp_1"));
1490 assert_eq!(metadata.output_items.len(), 1);
1491 }
1492
1493 #[test]
1494 fn sanitize_tool_args_removes_empty_pages() {
1495 let args = r#"{"file_path":"/tmp/a","pages":""}"#;
1496 let sanitized = sanitize_read_args("Read", args, None);
1497 let parsed: serde_json::Value = serde_json::from_str(&sanitized).unwrap();
1498 assert!(parsed.get("pages").is_none());
1499 assert_eq!(
1500 parsed.get("file_path").and_then(|v| v.as_str()),
1501 Some("/tmp/a")
1502 );
1503 }
1504
1505 #[test]
1506 fn map_usage_reports_cached_prompt_tokens() {
1507 let usage = CodexUsage {
1508 input_tokens: Some(100),
1509 output_tokens: Some(50),
1510 input_tokens_details_cached: Some(20),
1511 output_tokens_details_reasoning: None,
1512 };
1513 let mapped = map_codex_usage_to_anthropic(&Some(usage), None);
1514 assert_eq!(mapped.input_tokens, 80);
1515 assert_eq!(mapped.output_tokens, 50);
1516 assert_eq!(mapped.cache_read_input_tokens, 20);
1517 }
1518
1519 #[test]
1520 #[allow(clippy::format_in_format_args)]
1521 fn reduce_reasoning_summary_before_text() {
1522 let upstream = format!(
1523 "{}{}{}{}{}{}",
1524 sse(
1525 "response.reasoning_summary_text.delta",
1526 json!({"output_index":0,"summary_index":0,"delta":"Plan"})
1527 ),
1528 sse(
1529 "response.reasoning_summary_text.delta",
1530 json!({"output_index":0,"summary_index":0,"delta":"ning"})
1531 ),
1532 sse(
1533 "response.output_item.done",
1534 json!({"output_index":0,"item":{"type":"reasoning","summary":[],"encrypted_content":"enc"}})
1535 ),
1536 sse(
1537 "response.output_item.added",
1538 json!({"output_index":1,"item":{"type":"message","id":"msg_up"}})
1539 ),
1540 sse(
1541 "response.output_text.delta",
1542 json!({"output_index":1,"delta":"answer"})
1543 ),
1544 format!(
1545 "{}{}",
1546 sse(
1547 "response.output_item.done",
1548 json!({"output_index":1,"item":{"type":"message"}})
1549 ),
1550 sse(
1551 "response.completed",
1552 json!({"response":{"id":"resp_1","usage":{}}})
1553 )
1554 ),
1555 );
1556 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1557 assert!(matches!(
1558 out.iter()
1559 .find(|event| matches!(event, ReducerEvent::ThinkingStart { .. })),
1560 Some(ReducerEvent::ThinkingStart { index: 0 })
1561 ));
1562 let thinking_text: String = out
1563 .iter()
1564 .filter_map(|event| match event {
1565 ReducerEvent::ThinkingDelta { text, .. } => Some(text.as_str()),
1566 _ => None,
1567 })
1568 .collect();
1569 assert_eq!(thinking_text, "Planning");
1570 let thinking_stop = out
1571 .iter()
1572 .position(|event| matches!(event, ReducerEvent::ThinkingStop { .. }))
1573 .unwrap();
1574 let text_start = out
1575 .iter()
1576 .position(|event| matches!(event, ReducerEvent::TextStart { .. }))
1577 .unwrap();
1578 assert!(thinking_stop < text_start);
1579 assert!(matches!(
1580 out.iter()
1581 .find(|event| matches!(event, ReducerEvent::TextStart { .. })),
1582 Some(ReducerEvent::TextStart { index: 1 })
1583 ));
1584 }
1585
1586 #[test]
1587 #[allow(clippy::format_in_format_args)]
1588 fn reduce_empty_reasoning_summary_emits_no_thinking() {
1589 let upstream = format!(
1590 "{}{}{}{}",
1591 sse(
1592 "response.output_item.added",
1593 json!({"output_index":0,"item":{"type":"reasoning","summary":[],"encrypted_content":"enc"}})
1594 ),
1595 sse(
1596 "response.output_item.done",
1597 json!({"output_index":0,"item":{"type":"reasoning","summary":[],"encrypted_content":"enc"}})
1598 ),
1599 sse(
1600 "response.output_item.added",
1601 json!({"output_index":1,"item":{"type":"message","id":"msg_up"}})
1602 ),
1603 format!(
1604 "{}{}{}",
1605 sse(
1606 "response.output_text.delta",
1607 json!({"output_index":1,"delta":"answer"})
1608 ),
1609 sse(
1610 "response.output_item.done",
1611 json!({"output_index":1,"item":{"type":"message"}})
1612 ),
1613 sse(
1614 "response.completed",
1615 json!({"response":{"id":"resp_1","usage":{}}})
1616 )
1617 ),
1618 );
1619 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1620 assert!(!out.iter().any(|event| matches!(
1621 event,
1622 ReducerEvent::ThinkingStart { .. }
1623 | ReducerEvent::ThinkingDelta { .. }
1624 | ReducerEvent::ThinkingStop { .. }
1625 )));
1626 }
1627
1628 #[test]
1629 fn reduce_multiple_reasoning_summary_parts() {
1630 let upstream = format!(
1631 "{}{}{}{}{}",
1632 sse(
1633 "response.reasoning_summary_text.delta",
1634 json!({"output_index":0,"summary_index":0,"delta":"part one"})
1635 ),
1636 sse(
1637 "response.reasoning_summary_part.added",
1638 json!({"output_index":0,"summary_index":1,"part":{"type":"summary_text","text":""}})
1639 ),
1640 sse(
1641 "response.reasoning_summary_text.delta",
1642 json!({"output_index":0,"summary_index":1,"delta":"part two"})
1643 ),
1644 sse(
1645 "response.output_item.done",
1646 json!({"output_index":0,"item":{"type":"reasoning","summary":[],"encrypted_content":"enc"}})
1647 ),
1648 sse(
1649 "response.completed",
1650 json!({"response":{"id":"resp_1","usage":{}}})
1651 ),
1652 );
1653 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1654 let deltas: Vec<&str> = out
1655 .iter()
1656 .filter_map(|event| match event {
1657 ReducerEvent::ThinkingDelta { text, .. } => Some(text.as_str()),
1658 _ => None,
1659 })
1660 .collect();
1661 assert_eq!(deltas, vec!["part one", "\n\n", "part two"]);
1662 assert_eq!(
1663 out.iter()
1664 .filter(|event| matches!(event, ReducerEvent::ThinkingStop { .. }))
1665 .count(),
1666 1
1667 );
1668 }
1669
1670 #[test]
1671 fn reduce_two_reasoning_items() {
1672 let upstream = format!(
1673 "{}{}{}{}{}",
1674 sse(
1675 "response.reasoning_summary_text.delta",
1676 json!({"output_index":0,"summary_index":0,"delta":"first"})
1677 ),
1678 sse(
1679 "response.output_item.done",
1680 json!({"output_index":0,"item":{"type":"reasoning","summary":[],"encrypted_content":"enc"}})
1681 ),
1682 sse(
1683 "response.reasoning_summary_text.delta",
1684 json!({"output_index":1,"summary_index":0,"delta":"second"})
1685 ),
1686 sse(
1687 "response.output_item.done",
1688 json!({"output_index":1,"item":{"type":"reasoning","summary":[],"encrypted_content":"enc"}})
1689 ),
1690 sse(
1691 "response.completed",
1692 json!({"response":{"id":"resp_1","usage":{}}})
1693 ),
1694 );
1695 let out = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1696 assert_eq!(
1697 out.iter()
1698 .filter(|event| matches!(event, ReducerEvent::ThinkingStart { .. }))
1699 .count(),
1700 2
1701 );
1702 assert_eq!(
1703 out.iter()
1704 .filter(|event| matches!(event, ReducerEvent::ThinkingStop { .. }))
1705 .count(),
1706 2
1707 );
1708 let deltas: Vec<&str> = out
1709 .iter()
1710 .filter_map(|event| match event {
1711 ReducerEvent::ThinkingDelta { text, .. } => Some(text.as_str()),
1712 _ => None,
1713 })
1714 .collect();
1715 assert_eq!(deltas, vec!["first", "second"]);
1716 }
1717
1718 #[test]
1719 fn reasoning_signature_precedes_stop_and_enters_continuation_transcript() {
1720 let upstream = format!(
1721 "{}{}{}{}",
1722 sse(
1723 "response.output_item.added",
1724 json!({
1725 "output_index":0,
1726 "item":{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"opaque"}
1727 })
1728 ),
1729 sse(
1730 "response.reasoning_summary_text.delta",
1731 json!({"output_index":0,"summary_index":0,"delta":"plan"})
1732 ),
1733 sse(
1734 "response.output_item.done",
1735 json!({
1736 "output_index":0,
1737 "item":{"type":"reasoning","id":"rs_1","summary":[]}
1738 })
1739 ),
1740 sse(
1741 "response.completed",
1742 json!({"response":{"id":"resp_1","usage":{}}})
1743 ),
1744 );
1745 let events = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
1746 let signature_index = events
1747 .iter()
1748 .position(|event| matches!(event, ReducerEvent::ThinkingSignature { .. }))
1749 .unwrap();
1750 let stop_index = events
1751 .iter()
1752 .position(|event| matches!(event, ReducerEvent::ThinkingStop { .. }))
1753 .unwrap();
1754 assert!(signature_index < stop_index);
1755
1756 let ReducerEvent::ThinkingSignature { signature, .. } = &events[signature_index] else {
1757 unreachable!();
1758 };
1759 let replay =
1760 super::super::reasoning_signature::decode_reasoning_signature(signature).unwrap();
1761 assert_eq!(replay.id, "rs_1");
1762 assert_eq!(replay.encrypted_content, "opaque");
1763
1764 let ReducerEvent::Finish { output_items, .. } = events.last().unwrap() else {
1765 panic!("expected Finish");
1766 };
1767 assert!(matches!(
1768 output_items.as_slice(),
1769 [ResponsesInputItem::Reasoning {
1770 id,
1771 encrypted_content,
1772 ..
1773 }] if id == "rs_1" && encrypted_content == "opaque"
1774 ));
1775 }
1776}