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