Skip to main content

claude_codex/providers/codex/translate/
reducer.rs

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                        // Already have accum from deltas - skip
736                    } else if *had_delta {
737                        // Already emitted deltas
738                    } 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// ---------------------------------------------------------------------------
1134// Tests
1135// ---------------------------------------------------------------------------
1136
1137#[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}