Skip to main content

car_inference/
stream.rs

1//! Streaming inference — SSE parsing for real-time token output.
2//!
3//! Supports streaming from OpenAI-compatible, Anthropic, and Google APIs.
4//! Each provider uses Server-Sent Events (SSE) with different JSON schemas.
5
6use crate::tasks::generate::ToolCall;
7use crate::TokenUsage;
8use std::collections::HashMap;
9
10/// Events emitted during a streaming inference response.
11///
12/// `Serialize`/`Deserialize` are derived so the whole event stream can cross
13/// the on-device inference-worker IPC boundary (car-releases#74) losslessly —
14/// including the `StopReason` variant the daemon's hand-rolled WS-runner JSON
15/// mapping omits. The representation is internal (same crate version on both
16/// ends of the pipe), so the default externally-tagged enum shape is fine.
17#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18pub enum StreamEvent {
19    /// Partial text token from the model.
20    TextDelta(String),
21    /// A tool call is starting (name known, arguments pending).
22    ToolCallStart {
23        name: String,
24        index: usize,
25        id: Option<String>,
26    },
27    /// Partial tool call arguments (JSON fragment).
28    ToolCallDelta {
29        index: usize,
30        arguments_delta: String,
31    },
32    /// Provider-reported cumulative token usage observed mid-stream.
33    ///
34    /// Anthropic emits this twice: once in `message_start` with the
35    /// finalized `input_tokens` (plus a stub `output_tokens: 1`), and
36    /// again in `message_delta` at end of stream with the real
37    /// `output_tokens`. Consumers should prefer per-field monotonicity
38    /// (see [`StreamAccumulator`]) rather than overwriting blindly.
39    Usage {
40        /// Uncached prompt tokens. For providers whose streamed usage reports
41        /// the cached subset inside the prompt total (OpenAI), the parser
42        /// subtracts it here so this stays the uncached portion — matching the
43        /// non-streaming convention.
44        input_tokens: u64,
45        output_tokens: u64,
46        /// Prompt-cache read (hit) tokens reported mid-stream (Anthropic
47        /// `message_start.usage.cache_read_input_tokens`, OpenAI
48        /// `prompt_tokens_details.cached_tokens`). `0` when uncached / absent.
49        cache_read_input_tokens: u64,
50        /// Prompt-cache write tokens reported mid-stream (Anthropic
51        /// `cache_creation_input_tokens`). OpenAI has no write bucket. `0` when
52        /// absent.
53        cache_creation_input_tokens: u64,
54    },
55    /// Provider-reported termination reason, surfaced mid-stream as the raw
56    /// provider string (OpenAI `finish_reason`, Anthropic `delta.stop_reason`).
57    /// A value of `"length"`/`"max_tokens"` signals the output was cut off at
58    /// the token cap. Captured by [`StreamAccumulator`] and returned from
59    /// [`StreamAccumulator::finish_with_usage`].
60    StopReason(String),
61    /// Opaque provider output item that must be replayed verbatim on a later
62    /// turn. The OpenAI Responses API emits completed reasoning items through
63    /// `response.output_item.done`; CAR does not interpret or rewrite their
64    /// `id`, `status`, `summary`, or `encrypted_content`.
65    ProviderOutputItem(serde_json::Value),
66    /// Provider or transport failure observed after the HTTP stream began.
67    /// This is terminal and must never be interpreted as a normal stop/success.
68    /// Messages are sanitized at the provider adapter before entering the event.
69    Error(String),
70    /// Stream is complete. Contains the final aggregated result.
71    Done {
72        text: String,
73        tool_calls: Vec<ToolCall>,
74    },
75}
76
77/// Parse an OpenAI **Responses API** SSE event into `StreamEvent`s. Unlike
78/// chat/completions (a single anonymous `data:` delta stream), the Responses
79/// API tags each event with a typed `event:` line — `response.output_text.delta`
80/// (text), `response.output_item.added` (a function_call starting),
81/// `response.function_call_arguments.delta` (tool-arg JSON), and a terminal
82/// `response.completed` / `response.incomplete` carrying usage + status.
83pub fn parse_openai_responses_sse_line(event_type: &str, data: &str) -> Vec<StreamEvent> {
84    let json: serde_json::Value = match serde_json::from_str(data) {
85        Ok(v) => v,
86        Err(_) => return Vec::new(),
87    };
88    let mut events = Vec::new();
89    match event_type {
90        "response.output_text.delta" => {
91            if let Some(d) = json.get("delta").and_then(|d| d.as_str()) {
92                if !d.is_empty() {
93                    events.push(StreamEvent::TextDelta(d.to_string()));
94                }
95            }
96        }
97        "response.output_item.added" => {
98            if let Some(item) = json.get("item") {
99                if item.get("type").and_then(|t| t.as_str()) == Some("function_call") {
100                    let name = item
101                        .get("name")
102                        .and_then(|n| n.as_str())
103                        .unwrap_or("")
104                        .to_string();
105                    let id = item
106                        .get("call_id")
107                        .or_else(|| item.get("id"))
108                        .and_then(|i| i.as_str())
109                        .map(|s| s.to_string());
110                    let index = json
111                        .get("output_index")
112                        .and_then(|v| v.as_u64())
113                        .unwrap_or(0) as usize;
114                    if !name.is_empty() {
115                        events.push(StreamEvent::ToolCallStart { name, index, id });
116                    }
117                }
118            }
119        }
120        "response.function_call_arguments.delta" => {
121            if let Some(d) = json.get("delta").and_then(|d| d.as_str()) {
122                let index = json
123                    .get("output_index")
124                    .and_then(|v| v.as_u64())
125                    .unwrap_or(0) as usize;
126                events.push(StreamEvent::ToolCallDelta {
127                    index,
128                    arguments_delta: d.to_string(),
129                });
130            }
131        }
132        "response.output_item.done" => {
133            if let Some(item) = json.get("item") {
134                if item.get("type").and_then(|value| value.as_str()) == Some("reasoning") {
135                    events.push(StreamEvent::ProviderOutputItem(item.clone()));
136                }
137            }
138        }
139        "response.completed" | "response.incomplete" => {
140            if let Some(resp) = json.get("response") {
141                if let Some(u) = resp.get("usage") {
142                    // OpenAI Responses: input_tokens is the TOTAL (cached
143                    // included); subtract the cached subset so input_tokens
144                    // stays the uncached portion (non-streaming convention).
145                    let input_total = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
146                    let cached = u
147                        .get("input_tokens_details")
148                        .and_then(|d| d.get("cached_tokens"))
149                        .and_then(|v| v.as_u64())
150                        .unwrap_or(0)
151                        .min(input_total);
152                    events.push(StreamEvent::Usage {
153                        input_tokens: input_total - cached,
154                        output_tokens: u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0),
155                        cache_read_input_tokens: cached,
156                        cache_creation_input_tokens: 0,
157                    });
158                }
159                if event_type == "response.incomplete" {
160                    let reason = resp
161                        .pointer("/incomplete_details/reason")
162                        .and_then(|r| r.as_str())
163                        .unwrap_or("incomplete");
164                    events.push(StreamEvent::StopReason(reason.to_string()));
165                    events.push(StreamEvent::Error(format!(
166                        "managed inference incomplete: {reason}"
167                    )));
168                } else {
169                    // A typed response.completed event is the only positive
170                    // terminal proof accepted by the managed Responses path.
171                    // `[DONE]`, transport EOF, deltas, and usage are not
172                    // completion evidence.
173                    events.push(StreamEvent::Done {
174                        text: String::new(),
175                        tool_calls: Vec::new(),
176                    });
177                }
178            }
179        }
180        "error" | "response.failed" => {
181            let pick = |field: &str| {
182                json.pointer(&format!("/error/{field}"))
183                    .or_else(|| json.pointer(&format!("/response/error/{field}")))
184                    .and_then(|value| value.as_str())
185                    .map(str::trim)
186                    .filter(|value| !value.is_empty())
187                    .map(str::to_string)
188            };
189            let message = pick("message");
190            // `type` and `code` are CLASSIFICATION, not payload — and they are
191            // the difference between "the gateway refused this content" and
192            // "inference crashed". Dropping them left every failure looking
193            // identical: a benchmark could not record a policy refusal as
194            // distinct from a crash, a retry loop burned its budget retrying
195            // into a decision that would never change, and an operator could
196            // not tell a misconfiguration from a content ruling
197            // (Parslee-ai/car#796).
198            //
199            // A bare "managed inference failed" is what a caller sees when the
200            // gateway sends an error object with no message at all — so
201            // reporting the fields we DO have matters most in exactly the case
202            // that reads as least informative.
203            let kind = pick("type");
204            let code = pick("code");
205            let mut detail = message.unwrap_or_else(|| "managed inference failed".to_string());
206            let tags: Vec<String> = [("type", kind), ("code", code)]
207                .into_iter()
208                .filter_map(|(label, value)| value.map(|v| format!("{label}={v}")))
209                .collect();
210            if !tags.is_empty() {
211                detail.push_str(&format!(" ({})", tags.join(", ")));
212            }
213            // Provider payloads, request IDs and stack details still stay out of
214            // the cross-surface event: only the message and these two
215            // classification fields are forwarded.
216            events.push(StreamEvent::Error(detail));
217        }
218        _ => {}
219    }
220    events
221}
222
223/// Recover the `type` / `code` classification tags this module appends to a
224/// gateway error message.
225///
226/// Deliberately adjacent to the code that writes them: #804 forwarded the tags
227/// so a *consumer* could tell a policy refusal from a crash, but CAR itself is
228/// also such a consumer — it decides whether to retry, and whether to blame the
229/// model's health record. Reading them back out of the formatted string keeps
230/// the tags on one wire type instead of threading a structured error through
231/// every stream path; the cost is that writer and reader must agree, which is
232/// why they live together and are tested against each other
233/// (Parslee-ai/car#796).
234pub fn error_tags(detail: &str) -> (Option<&str>, Option<&str>) {
235    let Some(open) = detail.rfind(" (") else {
236        return (None, None);
237    };
238    let Some(close) = detail[open..].rfind(')') else {
239        return (None, None);
240    };
241    let mut kind = None;
242    let mut code = None;
243    for part in detail[open + 2..open + close].split(", ") {
244        if let Some(v) = part.strip_prefix("type=") {
245            kind = Some(v);
246        } else if let Some(v) = part.strip_prefix("code=") {
247            code = Some(v);
248        }
249    }
250    (kind, code)
251}
252
253/// Classify a gateway error as a **content refusal**, from the `type`/`code`
254/// tags #804 forwards.
255///
256/// Lives beside [`error_tags`], which reads what this module's SSE writer
257/// appends, because those three are one contract: writer, tag reader, and the
258/// verdict derived from the tags. It is `pub` because the daemon needs the same
259/// verdict — a refusal that arrives mid-stream reaches `car-server-core` as
260/// flattened `StreamEvent::Error` text rather than a typed `InferenceError`, and
261/// re-deriving "was this a refusal?" there with a fresh substring rule would be
262/// a second, drifting definition of the same thing (Parslee-ai/car#796).
263///
264/// Matched on the classification fields, never the prose. Deliberately NARROW:
265/// over-classifying is the more dangerous direction, because a real inference
266/// failure mislabelled as a refusal is excluded from the model's health record
267/// and silently stops being retried — a crash that looks like a policy decision
268/// is harder to find than a policy decision that looks like a crash.
269///
270/// The matched set is what a filter calls itself across the providers CAR has
271/// seen: `content_policy_violation` (OpenAI-family, and the value in the remote
272/// module's own fixtures), plus the `content_filter` / `moderation` / `safety`
273/// families. #796's own gateway values are NOT yet known — the issue asks for a
274/// re-run to discover them — so this list is expected to grow, and the test
275/// pins the over-classification boundary rather than the exact membership.
276pub fn content_refusal_tags(detail: &str) -> Option<(Option<String>, Option<String>)> {
277    let (kind, code) = error_tags(detail);
278    let refused = |v: &str| {
279        let v = v.to_ascii_lowercase();
280        v.contains("content_policy")
281            || v.contains("content_filter")
282            || v.contains("moderation")
283            || v.contains("safety")
284    };
285    (code.is_some_and(refused) || kind.is_some_and(refused))
286        .then(|| (kind.map(str::to_string), code.map(str::to_string)))
287}
288
289/// Parse one Gemini `:streamGenerateContent?alt=sse` chunk (the JSON after
290/// `data:`) into `StreamEvent`s. Each chunk is a `GenerateContentResponse`:
291/// `candidates[0].content.parts[]` carry `text` deltas and/or complete
292/// `functionCall`s (Gemini sends the whole call in one chunk, not incrementally
293/// — so emit a `ToolCallStart` plus a single `ToolCallDelta` with the full
294/// args), `usageMetadata` carries token counts, and `finishReason` (terminal
295/// chunk) maps to `StopReason`.
296pub fn parse_google_sse_line(data: &str) -> Vec<StreamEvent> {
297    let json: serde_json::Value = match serde_json::from_str(data) {
298        Ok(v) => v,
299        Err(_) => return Vec::new(),
300    };
301    let mut events = Vec::new();
302    if let Some(parts) = json
303        .pointer("/candidates/0/content/parts")
304        .and_then(|p| p.as_array())
305    {
306        for (i, part) in parts.iter().enumerate() {
307            if let Some(t) = part.get("text").and_then(|t| t.as_str()) {
308                if !t.is_empty() {
309                    events.push(StreamEvent::TextDelta(t.to_string()));
310                }
311            }
312            if let Some(fc) = part.get("functionCall") {
313                let name = fc
314                    .get("name")
315                    .and_then(|n| n.as_str())
316                    .unwrap_or("")
317                    .to_string();
318                if !name.is_empty() {
319                    let args = fc
320                        .get("args")
321                        .map(|a| a.to_string())
322                        .unwrap_or_else(|| "{}".to_string());
323                    events.push(StreamEvent::ToolCallStart {
324                        name,
325                        index: i,
326                        id: None,
327                    });
328                    events.push(StreamEvent::ToolCallDelta {
329                        index: i,
330                        arguments_delta: args,
331                    });
332                }
333            }
334        }
335    }
336    if let Some(u) = json.get("usageMetadata") {
337        events.push(StreamEvent::Usage {
338            input_tokens: u
339                .get("promptTokenCount")
340                .and_then(|v| v.as_u64())
341                .unwrap_or(0),
342            output_tokens: u
343                .get("candidatesTokenCount")
344                .and_then(|v| v.as_u64())
345                .unwrap_or(0),
346            // Google prompt caching is not parsed by CAR.
347            cache_read_input_tokens: 0,
348            cache_creation_input_tokens: 0,
349        });
350    }
351    if let Some(fr) = json
352        .pointer("/candidates/0/finishReason")
353        .and_then(|r| r.as_str())
354    {
355        events.push(StreamEvent::StopReason(fr.to_string()));
356    }
357    events
358}
359
360/// Parse a single SSE data line from an OpenAI-compatible streaming response.
361/// Returns all events found in the line (supports multiple tool calls per chunk).
362pub fn parse_openai_sse_line(line: &str) -> Vec<StreamEvent> {
363    let data = match line.strip_prefix("data: ") {
364        Some(d) => d,
365        None => return Vec::new(),
366    };
367    if data == "[DONE]" {
368        return Vec::new();
369    }
370
371    let json: serde_json::Value = match serde_json::from_str(data) {
372        Ok(v) => v,
373        Err(_) => return Vec::new(),
374    };
375
376    let mut events = Vec::new();
377
378    // choices[0].finish_reason — set on the terminal content chunk
379    // (e.g. "stop", "length", "tool_calls"). Surface it so truncation
380    // ("length") is observable on the streaming path.
381    if let Some(reason) = json
382        .get("choices")
383        .and_then(|c| c.as_array())
384        .and_then(|c| c.first())
385        .and_then(|c| c.get("finish_reason"))
386        .and_then(|r| r.as_str())
387    {
388        if !reason.is_empty() {
389            events.push(StreamEvent::StopReason(reason.to_string()));
390        }
391    }
392
393    // choices[].delta — present on every text/tool chunk, absent on the
394    // final usage-only chunk when `stream_options.include_usage=true`.
395    if let Some(delta) = json
396        .get("choices")
397        .and_then(|c| c.as_array())
398        .and_then(|c| c.first())
399        .and_then(|c| c.get("delta"))
400    {
401        if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
402            if !content.is_empty() {
403                events.push(StreamEvent::TextDelta(content.to_string()));
404            }
405        }
406
407        // Tool calls — collect ALL tool call events from this chunk
408        if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) {
409            for tc in tool_calls {
410                let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
411                if let Some(function) = tc.get("function") {
412                    if let Some(name) = function.get("name").and_then(|n| n.as_str()) {
413                        let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
414                        events.push(StreamEvent::ToolCallStart {
415                            name: name.to_string(),
416                            index,
417                            id,
418                        });
419                    }
420                    if let Some(args) = function.get("arguments").and_then(|a| a.as_str()) {
421                        if !args.is_empty() {
422                            events.push(StreamEvent::ToolCallDelta {
423                                index,
424                                arguments_delta: args.to_string(),
425                            });
426                        }
427                    }
428                }
429            }
430        }
431    }
432
433    // OpenAI sends real usage only when the request sets
434    // `stream_options.include_usage=true`; it arrives in a final chunk
435    // with `"choices": []` and a top-level `"usage"` object.
436    if let Some(usage) = json.get("usage") {
437        let input = usage
438            .get("prompt_tokens")
439            .and_then(|n| n.as_u64())
440            .unwrap_or(0);
441        let output = usage
442            .get("completion_tokens")
443            .and_then(|n| n.as_u64())
444            .unwrap_or(0);
445        // prompt_tokens is the TOTAL (cached included); subtract the cached
446        // subset so input_tokens stays uncached (non-streaming convention).
447        let cached = usage
448            .get("prompt_tokens_details")
449            .and_then(|d| d.get("cached_tokens"))
450            .and_then(|n| n.as_u64())
451            .unwrap_or(0)
452            .min(input);
453        if input != 0 || output != 0 {
454            events.push(StreamEvent::Usage {
455                input_tokens: input - cached,
456                output_tokens: output,
457                cache_read_input_tokens: cached,
458                cache_creation_input_tokens: 0,
459            });
460        }
461    }
462
463    events
464}
465
466/// Parse a single SSE data line from an Anthropic streaming response.
467pub fn parse_anthropic_sse_line(event_type: &str, data: &str) -> Vec<StreamEvent> {
468    match event_type {
469        "content_block_delta" => {
470            let json: serde_json::Value = match serde_json::from_str(data) {
471                Ok(v) => v,
472                Err(_) => return Vec::new(),
473            };
474            let delta = match json.get("delta") {
475                Some(d) => d,
476                None => return Vec::new(),
477            };
478            let delta_type = match delta.get("type").and_then(|t| t.as_str()) {
479                Some(t) => t,
480                None => return Vec::new(),
481            };
482
483            match delta_type {
484                "text_delta" => match delta.get("text").and_then(|t| t.as_str()) {
485                    Some(text) => vec![StreamEvent::TextDelta(text.to_string())],
486                    None => Vec::new(),
487                },
488                "input_json_delta" => match delta.get("partial_json").and_then(|p| p.as_str()) {
489                    Some(partial) => {
490                        let index =
491                            json.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
492                        vec![StreamEvent::ToolCallDelta {
493                            index,
494                            arguments_delta: partial.to_string(),
495                        }]
496                    }
497                    None => Vec::new(),
498                },
499                _ => Vec::new(),
500            }
501        }
502        "content_block_start" => {
503            let json: serde_json::Value = match serde_json::from_str(data) {
504                Ok(v) => v,
505                Err(_) => return Vec::new(),
506            };
507            let block = match json.get("content_block") {
508                Some(b) => b,
509                None => return Vec::new(),
510            };
511            if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
512                if let Some(name) = block.get("name").and_then(|n| n.as_str()) {
513                    let index = json.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
514                    let id = block
515                        .get("id")
516                        .and_then(|i| i.as_str())
517                        .map(|s| s.to_string());
518                    return vec![StreamEvent::ToolCallStart {
519                        name: name.to_string(),
520                        index,
521                        id,
522                    }];
523                }
524            }
525            Vec::new()
526        }
527        // Beginning of the response — Anthropic reports the finalized
528        // `input_tokens` here along with a stub `output_tokens: 1`.
529        // Shape: `{"message":{"usage":{"input_tokens":123,"output_tokens":1}}}`
530        "message_start" => {
531            let json: serde_json::Value = match serde_json::from_str(data) {
532                Ok(v) => v,
533                Err(_) => return Vec::new(),
534            };
535            let Some(usage) = json.pointer("/message/usage") else {
536                return Vec::new();
537            };
538            let input = usage
539                .get("input_tokens")
540                .and_then(|n| n.as_u64())
541                .unwrap_or(0);
542            let output = usage
543                .get("output_tokens")
544                .and_then(|n| n.as_u64())
545                .unwrap_or(0);
546            // Anthropic reports the cache split in message_start's usage too —
547            // input_tokens is the uncached prefix; the cached portion is here.
548            let cache_read = usage
549                .get("cache_read_input_tokens")
550                .and_then(|n| n.as_u64())
551                .unwrap_or(0);
552            let cache_creation = usage
553                .get("cache_creation_input_tokens")
554                .and_then(|n| n.as_u64())
555                .unwrap_or(0);
556            if input == 0 && output == 0 && cache_read == 0 && cache_creation == 0 {
557                return Vec::new();
558            }
559            vec![StreamEvent::Usage {
560                input_tokens: input,
561                output_tokens: output,
562                cache_read_input_tokens: cache_read,
563                cache_creation_input_tokens: cache_creation,
564            }]
565        }
566        // End of the response — Anthropic reports the final
567        // `output_tokens` here (input is already known from
568        // `message_start`). Shape: `{"usage":{"output_tokens":456}}`.
569        "message_delta" => {
570            let json: serde_json::Value = match serde_json::from_str(data) {
571                Ok(v) => v,
572                Err(_) => return Vec::new(),
573            };
574            let mut events = Vec::new();
575            // Anthropic carries the termination reason in `delta.stop_reason`
576            // on this terminal event (`end_turn`, `tool_use`, `max_tokens`, …).
577            if let Some(reason) = json.pointer("/delta/stop_reason").and_then(|r| r.as_str()) {
578                if !reason.is_empty() {
579                    events.push(StreamEvent::StopReason(reason.to_string()));
580                }
581            }
582            if let Some(usage) = json.get("usage") {
583                let input = usage
584                    .get("input_tokens")
585                    .and_then(|n| n.as_u64())
586                    .unwrap_or(0);
587                let output = usage
588                    .get("output_tokens")
589                    .and_then(|n| n.as_u64())
590                    .unwrap_or(0);
591                if input != 0 || output != 0 {
592                    events.push(StreamEvent::Usage {
593                        input_tokens: input,
594                        output_tokens: output,
595                        // Cache tokens arrive in message_start, not here; the
596                        // accumulator's per-field max preserves them.
597                        cache_read_input_tokens: 0,
598                        cache_creation_input_tokens: 0,
599                    });
600                }
601            }
602            events
603        }
604        _ => Vec::new(),
605    }
606}
607
608/// Accumulator for building the final result from stream events.
609#[derive(Default)]
610pub struct StreamAccumulator {
611    pub text: String,
612    tool_names: HashMap<usize, String>,
613    tool_args: HashMap<usize, String>,
614    tool_ids: HashMap<usize, String>,
615    /// Highest `input_tokens` value seen in a `Usage` event. Anthropic
616    /// only sends this on `message_start`; other providers may send it
617    /// multiple times and we keep the largest as the authoritative
618    /// count.
619    input_tokens: u64,
620    /// Highest `output_tokens` value seen in a `Usage` event. For
621    /// Anthropic this grows from the `message_start` stub (`1`) to the
622    /// final count in `message_delta`, so we track monotonically.
623    output_tokens: u64,
624    /// Highest prompt-cache read tokens seen in a `Usage` event.
625    cache_read_input_tokens: u64,
626    /// Highest prompt-cache write tokens seen in a `Usage` event.
627    cache_creation_input_tokens: u64,
628    /// Whether any `Usage` event was observed. `false` means the
629    /// provider never reported usage (e.g. OpenAI without
630    /// `stream_options.include_usage=true`) and [`finish_with_usage`]
631    /// should return `None`.
632    saw_usage: bool,
633    /// Provider-reported termination reason, captured from the last
634    /// [`StreamEvent::StopReason`] seen. `None` until one arrives.
635    stop_reason: Option<String>,
636    /// Opaque provider output items observed in stream order.
637    provider_output_items: Vec<serde_json::Value>,
638}
639
640impl StreamAccumulator {
641    pub fn push(&mut self, event: &StreamEvent) {
642        match event {
643            StreamEvent::TextDelta(t) => self.text.push_str(t),
644            StreamEvent::ToolCallStart { name, index, id } => {
645                self.tool_names.insert(*index, name.clone());
646                self.tool_args.entry(*index).or_default();
647                if let Some(id) = id {
648                    self.tool_ids.insert(*index, id.clone());
649                }
650            }
651            StreamEvent::ToolCallDelta {
652                index,
653                arguments_delta,
654            } => {
655                self.tool_args
656                    .entry(*index)
657                    .or_default()
658                    .push_str(arguments_delta);
659            }
660            StreamEvent::Usage {
661                input_tokens,
662                output_tokens,
663                cache_read_input_tokens,
664                cache_creation_input_tokens,
665            } => {
666                self.saw_usage = true;
667                // Per-field max: Anthropic's `message_start` carries
668                // real input + cache tokens + stub output=1; `message_delta`
669                // carries only final output. Neither event should be allowed
670                // to clobber the other's authoritative value. Cache tokens
671                // arrive once (message_start / final OpenAI chunk), so max
672                // preserves them across the otherwise-zero deltas.
673                if *input_tokens > self.input_tokens {
674                    self.input_tokens = *input_tokens;
675                }
676                if *output_tokens > self.output_tokens {
677                    self.output_tokens = *output_tokens;
678                }
679                if *cache_read_input_tokens > self.cache_read_input_tokens {
680                    self.cache_read_input_tokens = *cache_read_input_tokens;
681                }
682                if *cache_creation_input_tokens > self.cache_creation_input_tokens {
683                    self.cache_creation_input_tokens = *cache_creation_input_tokens;
684                }
685            }
686            StreamEvent::StopReason(reason) => {
687                self.stop_reason = Some(reason.clone());
688            }
689            StreamEvent::ProviderOutputItem(item) => {
690                self.provider_output_items.push(item.clone());
691            }
692            StreamEvent::Error(_) => {}
693            StreamEvent::Done { .. } => {}
694        }
695    }
696
697    pub fn finish(self) -> (String, Vec<ToolCall>) {
698        let (text, tool_calls, _, _) = self.finish_with_usage();
699        (text, tool_calls)
700    }
701
702    /// Like [`finish`](crate::stream::StreamAccumulator::finish) but also returns the accumulated [`TokenUsage`]
703    /// when the provider reported any (and the provider-reported
704    /// `stop_reason` when one arrived). Returns `None` for usage if no
705    /// `Usage` event was observed — callers can fall back to their
706    /// own estimator. The 4th element is the raw provider stop_reason
707    /// (`None` if the provider didn't report one) — see
708    /// [`crate::InferenceResult::was_truncated`].
709    pub fn finish_with_usage(self) -> (String, Vec<ToolCall>, Option<TokenUsage>, Option<String>) {
710        let (text, tool_calls, usage, stop_reason, _) = self.finish_with_provider_output_items();
711        (text, tool_calls, usage, stop_reason)
712    }
713
714    /// Like [`finish_with_usage`](crate::stream::StreamAccumulator::finish_with_usage) but also returns opaque provider output
715    /// items in the order the Responses stream emitted them.
716    pub fn finish_with_provider_output_items(
717        self,
718    ) -> (
719        String,
720        Vec<ToolCall>,
721        Option<TokenUsage>,
722        Option<String>,
723        Vec<serde_json::Value>,
724    ) {
725        let mut tool_calls = Vec::new();
726        let mut indices: Vec<usize> = self.tool_names.keys().copied().collect();
727        indices.sort();
728
729        for idx in indices {
730            let id = self.tool_ids.get(&idx).cloned();
731            let name = self.tool_names.get(&idx).cloned().unwrap_or_default();
732            let args_str = self.tool_args.get(&idx).cloned().unwrap_or_default();
733            let arguments: HashMap<String, serde_json::Value> =
734                serde_json::from_str(&args_str).unwrap_or_default();
735            tool_calls.push(ToolCall {
736                id,
737                name,
738                arguments,
739            });
740        }
741
742        let usage = if self.saw_usage {
743            Some(TokenUsage {
744                prompt_tokens: self.input_tokens,
745                completion_tokens: self.output_tokens,
746                total_tokens: self.input_tokens + self.output_tokens,
747                // Context-window sizing comes from model metadata, not
748                // per-response usage — leave it zero and let the
749                // caller populate it if needed.
750                context_window: 0,
751                // Cache buckets decoded from the streamed usage events, so
752                // streamed calls price cache the same as non-streaming.
753                cache_read_input_tokens: self.cache_read_input_tokens,
754                cache_creation_input_tokens: self.cache_creation_input_tokens,
755            })
756        } else {
757            None
758        };
759
760        // In-process (MLX/candle) streams have no structured tool-call channel:
761        // the local model emits `<tool_call>{…}</tool_call>` tags inline in the
762        // text. Recover them into structured tool_calls and strip the tags from
763        // the visible text. Remote streams already produced structured
764        // tool_calls (and carry no tags), so this is a no-op for them.
765        let (text, tag_calls) = crate::tasks::generate::parse_tool_calls(&self.text);
766        let (text, tool_calls) = if tool_calls.is_empty() && !tag_calls.is_empty() {
767            (text, tag_calls)
768        } else {
769            // Keep structured calls; still drop any stray tags from the text.
770            (text, tool_calls)
771        };
772
773        (
774            text,
775            tool_calls,
776            usage,
777            self.stop_reason,
778            self.provider_output_items,
779        )
780    }
781}
782
783/// Parse SSE lines from a raw byte stream. Handles both OpenAI and Anthropic formats.
784/// Returns (event_type, data) pairs. OpenAI doesn't send event types (always "message").
785pub fn parse_sse_lines(chunk: &str) -> Vec<(String, String)> {
786    let mut events = Vec::new();
787    let mut current_event = String::new();
788    let mut current_data = String::new();
789
790    for line in chunk.lines() {
791        if let Some(rest) = line.strip_prefix("event: ") {
792            current_event = rest.to_string();
793        } else if let Some(rest) = line.strip_prefix("data: ") {
794            current_data = rest.to_string();
795        } else if line.is_empty() && !current_data.is_empty() {
796            events.push((
797                if current_event.is_empty() {
798                    "message".to_string()
799                } else {
800                    current_event.clone()
801                },
802                current_data.clone(),
803            ));
804            current_event.clear();
805            current_data.clear();
806        }
807    }
808
809    // Handle case where stream doesn't end with empty line
810    if !current_data.is_empty() {
811        events.push((
812            if current_event.is_empty() {
813                "message".to_string()
814            } else {
815                current_event
816            },
817            current_data,
818        ));
819    }
820
821    events
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827
828    /// A runner's reported usage must survive accumulation (#795).
829    ///
830    /// The delegated-inference path called `finish()`, which drops the usage
831    /// `finish_with_usage()` returns — so a runner that DID report counts had
832    /// them collected and then discarded, and every delegated call surfaced
833    /// `usage: null`. A consumer summing `total_tokens` read a silent zero,
834    /// which is worse than an error because it looks like a valid answer.
835    #[test]
836    fn accumulated_usage_and_stop_reason_survive_finish() {
837        let mut acc = StreamAccumulator::default();
838        acc.push(&StreamEvent::TextDelta("hello".into()));
839        acc.push(&StreamEvent::Usage {
840            input_tokens: 28,
841            output_tokens: 5,
842            cache_read_input_tokens: 0,
843            cache_creation_input_tokens: 0,
844        });
845        acc.push(&StreamEvent::StopReason("length".into()));
846
847        let (text, _tools, usage, stop) = acc.finish_with_usage();
848        assert_eq!(text, "hello");
849        let usage = usage.expect("a reported Usage event must not be dropped");
850        assert_eq!(usage.prompt_tokens, 28);
851        assert_eq!(usage.completion_tokens, 5);
852        assert_eq!(usage.total_tokens, 33);
853        assert_eq!(
854            stop.as_deref(),
855            Some("length"),
856            "the provider stop_reason feeds was_truncated and was being dropped too"
857        );
858    }
859
860    /// No usage event means `None`, not a fabricated zero. CAR cannot know a
861    /// foreign runner's tokenization, and inventing 0 is the bug being fixed.
862    #[test]
863    fn absent_usage_stays_none_rather_than_zero() {
864        let mut acc = StreamAccumulator::default();
865        acc.push(&StreamEvent::TextDelta("hi".into()));
866        let (_text, _tools, usage, stop) = acc.finish_with_usage();
867        assert!(
868            usage.is_none(),
869            "no Usage event must yield None so callers can fall back to an estimator"
870        );
871        assert!(stop.is_none());
872    }
873
874    /// A gateway error must carry its CLASSIFICATION, not just its prose.
875    ///
876    /// `type` / `code` are what let a consumer tell "refused by policy" from
877    /// "inference crashed" — a benchmark scoring a refusal, a retry loop
878    /// declining to retry a decision that will not change, an operator telling
879    /// a misconfiguration from a content ruling. CAR forwarded only `message`,
880    /// so all three were indistinguishable (Parslee-ai/car#796).
881    #[test]
882    fn managed_error_events_carry_type_and_code() {
883        let events = parse_openai_responses_sse_line(
884            "error",
885            r#"{"error":{"message":"content refused","type":"invalid_request_error","code":"content_policy_violation"}}"#,
886        );
887        let StreamEvent::Error(msg) = events.first().expect("an error event") else {
888            panic!("expected StreamEvent::Error, got {:?}", events.first());
889        };
890        assert!(msg.contains("content refused"), "message dropped: {msg}");
891        assert!(
892            msg.contains("type=invalid_request_error"),
893            "type dropped: {msg}"
894        );
895        assert!(
896            msg.contains("code=content_policy_violation"),
897            "code dropped: {msg}"
898        );
899    }
900
901    /// The worst case is an error object with NO message: the caller saw a bare
902    /// "managed inference failed" and nothing else. Whatever classification the
903    /// gateway did send must still come through.
904    #[test]
905    fn a_messageless_managed_error_still_reports_its_code() {
906        let events = parse_openai_responses_sse_line(
907            "response.failed",
908            r#"{"response":{"error":{"code":"content_filter"}}}"#,
909        );
910        let StreamEvent::Error(msg) = events.first().expect("an error event") else {
911            panic!("expected StreamEvent::Error");
912        };
913        assert!(msg.contains("managed inference failed"), "{msg}");
914        assert!(
915            msg.contains("code=content_filter"),
916            "classification lost: {msg}"
917        );
918    }
919
920    /// No classification fields → unchanged text, so nothing downstream that
921    /// matches on the old string breaks.
922    #[test]
923    fn a_bare_managed_error_is_unchanged() {
924        let events = parse_openai_responses_sse_line("error", r#"{"error":{}}"#);
925        let StreamEvent::Error(msg) = events.first().expect("an error event") else {
926            panic!("expected StreamEvent::Error");
927        };
928        assert_eq!(msg, "managed inference failed");
929    }
930
931    #[test]
932    fn parse_openai_text_delta() {
933        let line = r#"data: {"choices":[{"delta":{"content":"Hello"}}]}"#;
934        let events = parse_openai_sse_line(line);
935        assert_eq!(events.len(), 1);
936        match &events[0] {
937            StreamEvent::TextDelta(t) => assert_eq!(t, "Hello"),
938            other => panic!("expected TextDelta, got {:?}", other),
939        }
940    }
941
942    #[test]
943    fn parse_openai_tool_call_start() {
944        let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"edit_file"}}]}}]}"#;
945        let events = parse_openai_sse_line(line);
946        assert_eq!(events.len(), 1);
947        match &events[0] {
948            StreamEvent::ToolCallStart { name, index, .. } => {
949                assert_eq!(name, "edit_file");
950                assert_eq!(*index, 0);
951            }
952            other => panic!("expected ToolCallStart, got {:?}", other),
953        }
954    }
955
956    #[test]
957    fn parse_openai_tool_call_delta() {
958        let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":"}}]}}]}"#;
959        let events = parse_openai_sse_line(line);
960        assert_eq!(events.len(), 1);
961        match &events[0] {
962            StreamEvent::ToolCallDelta {
963                index,
964                arguments_delta,
965            } => {
966                assert_eq!(*index, 0);
967                assert!(arguments_delta.contains("path"));
968            }
969            other => panic!("expected ToolCallDelta, got {:?}", other),
970        }
971    }
972
973    #[test]
974    fn parse_openai_multiple_tool_calls_in_chunk() {
975        // When OpenAI sends multiple tool call deltas in a single SSE chunk
976        let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file"}},{"index":1,"function":{"name":"search"}}]}}]}"#;
977        let events = parse_openai_sse_line(line);
978        assert_eq!(events.len(), 2);
979        match &events[0] {
980            StreamEvent::ToolCallStart { name, index, .. } => {
981                assert_eq!(name, "read_file");
982                assert_eq!(*index, 0);
983            }
984            other => panic!("expected ToolCallStart, got {:?}", other),
985        }
986        match &events[1] {
987            StreamEvent::ToolCallStart { name, index, .. } => {
988                assert_eq!(name, "search");
989                assert_eq!(*index, 1);
990            }
991            other => panic!("expected ToolCallStart, got {:?}", other),
992        }
993    }
994
995    #[test]
996    fn parse_openai_done() {
997        assert!(parse_openai_sse_line("data: [DONE]").is_empty());
998    }
999
1000    #[test]
1001    fn parse_anthropic_text_delta() {
1002        let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}"#;
1003        let events = parse_anthropic_sse_line("content_block_delta", data);
1004        assert_eq!(events.len(), 1);
1005        match &events[0] {
1006            StreamEvent::TextDelta(t) => assert_eq!(t, "world"),
1007            other => panic!("expected TextDelta, got {:?}", other),
1008        }
1009    }
1010
1011    #[test]
1012    fn parse_anthropic_tool_start() {
1013        let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"t1","name":"search","input":{}}}"#;
1014        let events = parse_anthropic_sse_line("content_block_start", data);
1015        assert_eq!(events.len(), 1);
1016        match &events[0] {
1017            StreamEvent::ToolCallStart { name, index, .. } => {
1018                assert_eq!(name, "search");
1019                assert_eq!(*index, 1);
1020            }
1021            other => panic!("expected ToolCallStart, got {:?}", other),
1022        }
1023    }
1024
1025    #[test]
1026    fn accumulator_builds_result() {
1027        let mut acc = StreamAccumulator::default();
1028        acc.push(&StreamEvent::TextDelta("Hello ".into()));
1029        acc.push(&StreamEvent::TextDelta("world".into()));
1030        acc.push(&StreamEvent::ToolCallStart {
1031            name: "search".into(),
1032            index: 0,
1033            id: None,
1034        });
1035        acc.push(&StreamEvent::ToolCallDelta {
1036            index: 0,
1037            arguments_delta: r#"{"q":"test"}"#.into(),
1038        });
1039
1040        let (text, tools) = acc.finish();
1041        assert_eq!(text, "Hello world");
1042        assert_eq!(tools.len(), 1);
1043        assert_eq!(tools[0].name, "search");
1044        assert!(tools[0].arguments.contains_key("q"));
1045    }
1046
1047    #[test]
1048    fn parse_sse_lines_openai_format() {
1049        let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\ndata: [DONE]\n\n";
1050        let events = parse_sse_lines(chunk);
1051        assert_eq!(events.len(), 2);
1052        assert_eq!(events[0].0, "message");
1053        assert_eq!(events[1].1, "[DONE]");
1054    }
1055
1056    #[test]
1057    fn parse_sse_lines_anthropic_format() {
1058        let chunk = "event: content_block_delta\ndata: {\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}\n\n";
1059        let events = parse_sse_lines(chunk);
1060        assert_eq!(events.len(), 1);
1061        assert_eq!(events[0].0, "content_block_delta");
1062    }
1063
1064    #[test]
1065    fn parse_anthropic_message_start_emits_usage() {
1066        let data = r#"{"type":"message_start","message":{"id":"msg_1","role":"assistant","usage":{"input_tokens":245,"output_tokens":1}}}"#;
1067        let events = parse_anthropic_sse_line("message_start", data);
1068        assert_eq!(events.len(), 1);
1069        match &events[0] {
1070            StreamEvent::Usage {
1071                input_tokens,
1072                output_tokens,
1073                ..
1074            } => {
1075                assert_eq!(*input_tokens, 245);
1076                assert_eq!(*output_tokens, 1);
1077            }
1078            other => panic!("expected Usage, got {:?}", other),
1079        }
1080    }
1081
1082    #[test]
1083    fn parse_anthropic_message_delta_emits_stop_reason_and_usage() {
1084        let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":87}}"#;
1085        let events = parse_anthropic_sse_line("message_delta", data);
1086        // message_delta now carries both the termination reason and the
1087        // final output-token count, emitted as StopReason then Usage.
1088        assert_eq!(events.len(), 2);
1089        match &events[0] {
1090            StreamEvent::StopReason(reason) => assert_eq!(reason, "end_turn"),
1091            other => panic!("expected StopReason, got {:?}", other),
1092        }
1093        match &events[1] {
1094            StreamEvent::Usage {
1095                input_tokens,
1096                output_tokens,
1097                ..
1098            } => {
1099                assert_eq!(*input_tokens, 0);
1100                assert_eq!(*output_tokens, 87);
1101            }
1102            other => panic!("expected Usage, got {:?}", other),
1103        }
1104    }
1105
1106    #[test]
1107    fn parse_anthropic_message_delta_max_tokens_stop_reason() {
1108        // A truncation: stop_reason "max_tokens" must surface so
1109        // InferenceResult::was_truncated() can fire on the streaming path.
1110        let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":4096}}"#;
1111        let events = parse_anthropic_sse_line("message_delta", data);
1112        assert!(matches!(
1113            &events[0],
1114            StreamEvent::StopReason(r) if r == "max_tokens"
1115        ));
1116    }
1117
1118    #[test]
1119    fn parse_openai_finish_reason_length_surfaces() {
1120        // OpenAI's terminal content chunk carries finish_reason="length"
1121        // on truncation; it must surface as a StopReason event.
1122        let line = r#"data: {"choices":[{"delta":{"content":""},"finish_reason":"length"}]}"#;
1123        let events = parse_openai_sse_line(line);
1124        assert!(events
1125            .iter()
1126            .any(|e| matches!(e, StreamEvent::StopReason(r) if r == "length")));
1127    }
1128
1129    #[test]
1130    fn accumulator_captures_stop_reason() {
1131        let mut acc = StreamAccumulator::default();
1132        acc.push(&StreamEvent::TextDelta("partial".into()));
1133        acc.push(&StreamEvent::StopReason("max_tokens".into()));
1134        let (_, _, _, stop) = acc.finish_with_usage();
1135        assert_eq!(stop.as_deref(), Some("max_tokens"));
1136    }
1137
1138    #[test]
1139    fn parse_anthropic_message_start_without_usage_is_empty() {
1140        // Some forward-compat payloads may omit usage; don't crash.
1141        let data = r#"{"type":"message_start","message":{"id":"msg_1"}}"#;
1142        assert!(parse_anthropic_sse_line("message_start", data).is_empty());
1143    }
1144
1145    #[test]
1146    fn accumulator_tracks_usage_across_anthropic_stream() {
1147        // Simulate the exact shape of a real Anthropic stream:
1148        // message_start → content_block_start → content_block_delta × 3 → message_delta.
1149        let mut acc = StreamAccumulator::default();
1150        for event in parse_anthropic_sse_line(
1151            "message_start",
1152            r#"{"message":{"usage":{"input_tokens":245,"output_tokens":1}}}"#,
1153        ) {
1154            acc.push(&event);
1155        }
1156        for event in parse_anthropic_sse_line(
1157            "content_block_start",
1158            r#"{"index":0,"content_block":{"type":"text","text":""}}"#,
1159        ) {
1160            acc.push(&event);
1161        }
1162        for (chunk, _) in [
1163            (r#"{"delta":{"type":"text_delta","text":"Hello"}}"#, ()),
1164            (r#"{"delta":{"type":"text_delta","text":", "}}"#, ()),
1165            (r#"{"delta":{"type":"text_delta","text":"world"}}"#, ()),
1166        ] {
1167            for event in parse_anthropic_sse_line("content_block_delta", chunk) {
1168                acc.push(&event);
1169            }
1170        }
1171        for event in parse_anthropic_sse_line("message_delta", r#"{"usage":{"output_tokens":87}}"#)
1172        {
1173            acc.push(&event);
1174        }
1175
1176        let (text, tools, usage, _stop) = acc.finish_with_usage();
1177        assert_eq!(text, "Hello, world");
1178        assert!(tools.is_empty());
1179        let usage = usage.expect("provider reported usage; must surface");
1180        assert_eq!(usage.prompt_tokens, 245);
1181        // message_delta output (87) must win over message_start stub (1).
1182        assert_eq!(usage.completion_tokens, 87);
1183        assert_eq!(usage.total_tokens, 332);
1184    }
1185
1186    #[test]
1187    fn parse_openai_final_chunk_emits_usage() {
1188        // OpenAI's final usage chunk when `stream_options.include_usage`
1189        // is set: `choices` is empty and `usage` carries the real counts.
1190        let line = r#"data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":245,"completion_tokens":87,"total_tokens":332}}"#;
1191        let events = parse_openai_sse_line(line);
1192        assert_eq!(events.len(), 1);
1193        match &events[0] {
1194            StreamEvent::Usage {
1195                input_tokens,
1196                output_tokens,
1197                ..
1198            } => {
1199                assert_eq!(*input_tokens, 245);
1200                assert_eq!(*output_tokens, 87);
1201            }
1202            other => panic!("expected Usage, got {:?}", other),
1203        }
1204    }
1205
1206    #[test]
1207    fn accumulator_tracks_usage_across_openai_stream() {
1208        // Simulate a full OpenAI stream with `stream_options.include_usage`:
1209        // text delta chunks followed by a choiceless usage-only chunk.
1210        let mut acc = StreamAccumulator::default();
1211        for line in [
1212            r#"data: {"choices":[{"delta":{"content":"Hello"}}]}"#,
1213            r#"data: {"choices":[{"delta":{"content":", "}}]}"#,
1214            r#"data: {"choices":[{"delta":{"content":"world"}}]}"#,
1215            r#"data: {"id":"chatcmpl-1","choices":[],"usage":{"prompt_tokens":245,"completion_tokens":87}}"#,
1216        ] {
1217            for event in parse_openai_sse_line(line) {
1218                acc.push(&event);
1219            }
1220        }
1221
1222        let (text, tools, usage, _stop) = acc.finish_with_usage();
1223        assert_eq!(text, "Hello, world");
1224        assert!(tools.is_empty());
1225        let usage = usage.expect("provider reported usage; must surface");
1226        assert_eq!(usage.prompt_tokens, 245);
1227        assert_eq!(usage.completion_tokens, 87);
1228        assert_eq!(usage.total_tokens, 332);
1229    }
1230
1231    #[test]
1232    fn accumulator_returns_no_usage_when_provider_silent() {
1233        // OpenAI without `stream_options.include_usage` — no Usage
1234        // events. `finish_with_usage` returns None so callers can fall
1235        // back to their own estimator.
1236        let mut acc = StreamAccumulator::default();
1237        acc.push(&StreamEvent::TextDelta("hi".into()));
1238        let (_, _, usage, _stop) = acc.finish_with_usage();
1239        assert!(usage.is_none());
1240    }
1241
1242    #[test]
1243    fn anthropic_stream_decodes_cache_tokens_from_message_start() {
1244        // message_start carries the cache split + uncached input; message_delta
1245        // carries the final output. The accumulator must preserve cache tokens
1246        // (which arrive once) across the otherwise-zero delta.
1247        let mut acc = StreamAccumulator::default();
1248        let start = r#"{"message":{"usage":{"input_tokens":50,"output_tokens":1,"cache_read_input_tokens":4000,"cache_creation_input_tokens":600}}}"#;
1249        for e in parse_anthropic_sse_line("message_start", start) {
1250            acc.push(&e);
1251        }
1252        let delta = r#"{"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":87}}"#;
1253        for e in parse_anthropic_sse_line("message_delta", delta) {
1254            acc.push(&e);
1255        }
1256        let (_t, _c, usage, stop) = acc.finish_with_usage();
1257        let u = usage.expect("usage surfaced");
1258        assert_eq!(u.prompt_tokens, 50, "uncached prefix");
1259        assert_eq!(u.completion_tokens, 87, "final output from message_delta");
1260        assert_eq!(u.cache_read_input_tokens, 4000);
1261        assert_eq!(u.cache_creation_input_tokens, 600);
1262        assert_eq!(stop.as_deref(), Some("end_turn"));
1263    }
1264
1265    #[test]
1266    fn openai_stream_normalizes_cached_tokens_out_of_prompt() {
1267        // OpenAI's streamed prompt_tokens (1000) INCLUDES the cached subset
1268        // (800); the parser must subtract so prompt_tokens is the uncached 200
1269        // and 800 lands in cache_read — else cost double-charges the cached part.
1270        let mut acc = StreamAccumulator::default();
1271        let chunk = r#"data: {"choices":[],"usage":{"prompt_tokens":1000,"completion_tokens":40,"prompt_tokens_details":{"cached_tokens":800}}}"#;
1272        for e in parse_openai_sse_line(chunk) {
1273            acc.push(&e);
1274        }
1275        let (_t, _c, usage, _s) = acc.finish_with_usage();
1276        let u = usage.expect("usage surfaced");
1277        assert_eq!(u.prompt_tokens, 200, "uncached = 1000 - 800");
1278        assert_eq!(u.cache_read_input_tokens, 800);
1279        assert_eq!(
1280            u.cache_creation_input_tokens, 0,
1281            "OpenAI has no write bucket"
1282        );
1283    }
1284
1285    #[test]
1286    fn responses_failure_is_a_terminal_safe_error_event() {
1287        let events = parse_openai_responses_sse_line(
1288            "response.failed",
1289            r#"{"response":{"error":{"message":"managed model unavailable","stack":"secret"}}}"#,
1290        );
1291        assert!(matches!(
1292            events.as_slice(),
1293            [StreamEvent::Error(message)] if message == "managed model unavailable"
1294        ));
1295        assert!(!format!("{events:?}").contains("secret"));
1296    }
1297
1298    #[test]
1299    fn responses_reasoning_item_done_is_retained_verbatim() {
1300        let data = r#"{"output_index":0,"item":{"type":"reasoning","id":"rs_1","status":"completed","summary":[{"type":"summary_text","text":"safe summary"}],"encrypted_content":"opaque-ciphertext"}}"#;
1301        let events = parse_openai_responses_sse_line("response.output_item.done", data);
1302        let expected = serde_json::json!({
1303            "type": "reasoning",
1304            "id": "rs_1",
1305            "status": "completed",
1306            "summary": [{"type": "summary_text", "text": "safe summary"}],
1307            "encrypted_content": "opaque-ciphertext",
1308        });
1309        assert!(matches!(
1310            events.as_slice(),
1311            [StreamEvent::ProviderOutputItem(item)] if item == &expected
1312        ));
1313        let mut accumulator = StreamAccumulator::default();
1314        accumulator.push(&events[0]);
1315        let (_, _, _, _, items) = accumulator.finish_with_provider_output_items();
1316        assert_eq!(items, vec![expected]);
1317    }
1318
1319    #[test]
1320    fn responses_incomplete_is_terminal_failure_not_success() {
1321        let events = parse_openai_responses_sse_line(
1322            "response.incomplete",
1323            r#"{"response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":17,"output_tokens":9}}}"#,
1324        );
1325        assert!(
1326            events
1327                .iter()
1328                .any(|event| matches!(event, StreamEvent::Error(_))),
1329            "response.incomplete must emit a terminal error"
1330        );
1331    }
1332}