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