Skip to main content

rig_core/providers/anthropic/
streaming.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Value, json};
3
4use super::completion::{
5    AnthropicCompatibleProvider, AnthropicCompletionRequest, Content, GenericCompletionModel,
6    Usage, anthropic_usage_totals, map_finish_reason,
7};
8use crate::completion::{CompletionError, CompletionRequest};
9use crate::http_client::sse::GenericEventSource;
10use crate::http_client::{self, HttpClientExt};
11use crate::message::ReasoningContent;
12use crate::providers::internal::adapter::{AdapterOutput, WireAdapter, WireFrame};
13use crate::providers::internal::sse_transport::{
14    OpenLog, SseTransportOptions, open_wire_stream, skip_blank_frames,
15};
16use crate::providers::internal::wire::{self, WireEvent};
17use crate::streaming::{
18    self, MintKind, RawStreamingChoice, RawStreamingResult, StreamFinal, StreamPartId,
19    ToolCallDeltaContent, ToolInputEnd, UnparseableToolInput,
20};
21use crate::telemetry::{CompletionOperation, SpanCombinator};
22use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
23use std::collections::HashMap;
24
25/// Patch the shared typed request into the Anthropic *streaming* request body.
26///
27/// The body derives from the *same* typed [`AnthropicCompletionRequest`] the
28/// blocking path builds (in `completion.rs`), rather than being re-assembled by
29/// hand. The previous hand-rolled `json!` body had drifted from the blocking one
30/// and silently dropped `output_schema` (structured-output config); reaching for
31/// the typed request fixes that and keeps the two in lockstep. Only the two
32/// streaming-only differences documented below are applied here.
33fn streaming_body(request: &AnthropicCompletionRequest) -> Result<Value, CompletionError> {
34    let mut body = serde_json::to_value(request)?;
35    if let Some(map) = body.as_object_mut() {
36        // `AnthropicCompletionRequest` has no `stream` field (the blocking path
37        // omits it, defaulting to non-streaming); set it for the streaming endpoint.
38        map.insert("stream".to_string(), Value::Bool(true));
39
40        // Preserve the streaming path's long-standing `tool_choice` shape, which
41        // emitted `tool_choice` *iff* a non-empty tool set was advertised (Anthropic
42        // rejects `tool_choice` without `tools`). The blocking typed request instead
43        // serializes any caller-set `tool_choice` regardless of tools and omits it
44        // when unset, so reconcile here:
45        //   - tools present, choice unset -> add the explicit `auto` the streaming
46        //     wire has always carried (equivalent to Anthropic's default);
47        //   - tools absent -> drop a caller-set `tool_choice` that would otherwise
48        //     be sent without `tools` and rejected.
49        if map.contains_key("tools") {
50            map.entry("tool_choice")
51                .or_insert_with(|| json!({ "type": "auto" }));
52        } else {
53            map.remove("tool_choice");
54        }
55    }
56
57    Ok(body)
58}
59
60/// The `type` values this client models on the Anthropic Messages SSE wire.
61///
62/// [`classify_tagged_frame`] dispatches on this list: a frame whose `type` is
63/// outside it classifies `Unknown` (driver policy: warn + skip), while a
64/// listed type must pass the full [`StreamingEvent`] decode or classify
65/// `Corrupt`. There is no `#[serde(other)]` fallback — policy lives in the
66/// classify layer, never in serde. The one modeled exception is a novel
67/// *nested* delta type inside `content_block_delta`, which decodes to
68/// [`ContentDelta::Unknown`] (a warned no-op) via its hand-written dispatch.
69const KNOWN_EVENT_TYPES: &[&str] = &[
70    "message_start",
71    "content_block_start",
72    "content_block_delta",
73    "content_block_stop",
74    "message_delta",
75    "message_stop",
76    "ping",
77    "error",
78];
79
80#[derive(Debug, Deserialize)]
81#[serde(tag = "type", rename_all = "snake_case")]
82pub enum StreamingEvent {
83    MessageStart {
84        /// Anthropic-compatible relays (Bedrock's Messages passthrough) can
85        /// emit `message_start` with a null `message`; `None` is a no-op
86        /// rather than a corrupt frame.
87        #[serde(default)]
88        message: Option<MessageStart>,
89    },
90    ContentBlockStart {
91        index: usize,
92        content_block: Content,
93    },
94    ContentBlockDelta {
95        index: usize,
96        delta: ContentDelta,
97    },
98    ContentBlockStop {
99        index: usize,
100    },
101    MessageDelta {
102        delta: MessageDelta,
103        usage: PartialUsage,
104    },
105    MessageStop,
106    /// Keep-alive; a Known no-op, not an unknown event to warn about.
107    Ping,
108    /// Anthropic's top-level error envelope (`{"type":"error","error":{...}}`,
109    /// e.g. `overloaded_error`). A modeled event, not an unknown to warn-skip:
110    /// it surfaces as a provider error like every other family's error
111    /// envelope. The payload stays a raw `Value` so every provider field
112    /// (type, message, extras) survives into the error body.
113    Error {
114        error: serde_json::Value,
115    },
116}
117
118#[derive(Debug, Deserialize)]
119pub struct MessageStart {
120    pub id: String,
121    pub role: String,
122    pub content: Vec<Content>,
123    pub model: String,
124    pub stop_reason: Option<String>,
125    pub stop_sequence: Option<String>,
126    pub usage: Usage,
127}
128
129#[derive(Debug)]
130pub enum ContentDelta {
131    TextDelta {
132        text: String,
133    },
134    InputJsonDelta {
135        partial_json: String,
136    },
137    ThinkingDelta {
138        thinking: String,
139    },
140    SignatureDelta {
141        signature: String,
142    },
143    CitationsDelta {
144        citation: super::completion::Citation,
145    },
146    /// Any nested delta type this client doesn't model. Anthropic's
147    /// versioning policy reserves the right to add new delta types without
148    /// notice, so an unmodeled nested tag must not fail the whole
149    /// `content_block_delta` frame (which would classify it `Corrupt` and
150    /// surface an `Err` item per frame). It decodes to a no-op, warned at the
151    /// interpret site — the same shape as
152    /// [`ContentPartChunkPart::Unknown`](crate::providers::openai::responses_api::streaming::ContentPartChunkPart).
153    Unknown(serde_json::Value),
154}
155
156/// Hand-written tag dispatch instead of a trailing `#[serde(untagged)]`
157/// variant: on an internally-tagged enum the untagged fallback also swallows
158/// a *known* tag with an invalid payload, silently demoting a data-level
159/// defect to a skippable unknown delta. Here a known delta tag must decode
160/// fully or error (the frame classifies `Corrupt`); only an unmodeled (or
161/// absent) tag falls back to [`ContentDelta::Unknown`], preserving the value
162/// verbatim. Same pattern as `ContentPartChunkPart`'s hand dispatch in
163/// `openai/responses_api/streaming.rs`.
164impl<'de> Deserialize<'de> for ContentDelta {
165    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
166    where
167        D: serde::Deserializer<'de>,
168    {
169        let value = serde_json::Value::deserialize(deserializer)?;
170        // A non-object delta is a data-level defect of the tagged shape, not
171        // an unmodeled delta kind: it errors (classifying the frame
172        // `Corrupt`) instead of degrading to an `Unknown` no-op — the
173        // conformance corpus pins `"delta": 42` as Corrupt.
174        if !value.is_object() {
175            return Err(serde::de::Error::custom("content delta must be an object"));
176        }
177        let str_field = |tag: &str, field: &str| -> Result<String, D::Error> {
178            value
179                .get(field)
180                .and_then(serde_json::Value::as_str)
181                .map(ToOwned::to_owned)
182                .ok_or_else(|| {
183                    serde::de::Error::custom(format!(
184                        "`{tag}` content delta is missing a string `{field}` field"
185                    ))
186                })
187        };
188        match value.get("type").cloned() {
189            Some(serde_json::Value::String(tag)) => match tag.as_str() {
190                "text_delta" => Ok(Self::TextDelta {
191                    text: str_field("text_delta", "text")?,
192                }),
193                "input_json_delta" => Ok(Self::InputJsonDelta {
194                    partial_json: str_field("input_json_delta", "partial_json")?,
195                }),
196                "thinking_delta" => Ok(Self::ThinkingDelta {
197                    thinking: str_field("thinking_delta", "thinking")?,
198                }),
199                "signature_delta" => Ok(Self::SignatureDelta {
200                    signature: str_field("signature_delta", "signature")?,
201                }),
202                "citations_delta" => {
203                    let citation = value.get("citation").cloned().ok_or_else(|| {
204                        serde::de::Error::custom(
205                            "`citations_delta` content delta is missing a `citation` field",
206                        )
207                    })?;
208                    Ok(Self::CitationsDelta {
209                        citation: serde_json::from_value(citation)
210                            .map_err(serde::de::Error::custom)?,
211                    })
212                }
213                _ => Ok(Self::Unknown(value)),
214            },
215            Some(_) => Err(serde::de::Error::custom(
216                "content delta `type` must be a string",
217            )),
218            // A content delta without a `type` is malformed, not novel: an
219            // untagged text delta from a compat gateway silently skipping
220            // here would yield a successful *empty* completion. Corrupt
221            // surfaces in-band and the stream keeps consuming.
222            None => Err(serde::de::Error::custom(
223                "content delta is missing a `type` field",
224            )),
225        }
226    }
227}
228
229#[derive(Debug, Deserialize)]
230pub struct MessageDelta {
231    pub stop_reason: Option<String>,
232    pub stop_sequence: Option<String>,
233}
234
235#[derive(Debug, Deserialize, Clone, Serialize, Default)]
236pub struct PartialUsage {
237    pub output_tokens: usize,
238    #[serde(default)]
239    pub input_tokens: Option<usize>,
240    #[serde(default)]
241    pub cache_creation_input_tokens: Option<u64>,
242    /// Per-TTL breakdown of `cache_creation_input_tokens`. Anthropic reports
243    /// it on `message_start`, not the terminal `message_delta`; the adapter
244    /// carries it forward onto the terminal usage.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub cache_creation: Option<super::completion::CacheCreation>,
247    #[serde(default)]
248    pub cache_read_input_tokens: Option<u64>,
249    /// Breakdown of `output_tokens`. Anthropic reports it on the terminal
250    /// `message_delta` — the frame that also carries the final `output_tokens`
251    /// — not on `message_start`, so unlike `cache_creation` it needs no
252    /// carry-forward.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub output_tokens_details: Option<super::completion::OutputTokensDetails>,
255}
256
257impl From<&PartialUsage> for crate::completion::Usage {
258    fn from(value: &PartialUsage) -> crate::completion::Usage {
259        anthropic_usage_totals(
260            value.input_tokens.unwrap_or_default() as u64,
261            value.output_tokens as u64,
262            value.cache_read_input_tokens,
263            value.cache_creation_input_tokens,
264            value.output_tokens_details,
265        )
266    }
267}
268
269impl From<PartialUsage> for crate::completion::Usage {
270    fn from(value: PartialUsage) -> crate::completion::Usage {
271        (&value).into()
272    }
273}
274
275// Client tool-call fragment assembly lives in the shared accumulator
276// (`PartsAccumulator::tool_input_*`); the adapter tracks only the open block's
277// wire id. Server tool use keeps local state because its assembled payload
278// becomes text-block metadata (`ANTHROPIC_RAW_CONTENT_KEY`), not a tool call.
279struct ServerToolUseState {
280    name: String,
281    id: String,
282    initial_input: Value,
283    input_json: String,
284}
285
286#[derive(Default)]
287struct ThinkingState {
288    /// Signature assembled from this block's `signature_delta`s. Only the
289    /// signature is adapter-side state — the wire fragments it across
290    /// deltas and delivers no completed form, so the adapter assembles it
291    /// for the block's end event. Thinking TEXT accumulates in the shared
292    /// accumulator via `ReasoningDelta`s; no restatement buffer exists.
293    signature: String,
294    /// The `signature` `content_block_start` opened the block with.
295    ///
296    /// Recorded traffic always carries the empty string here and delivers the
297    /// whole signature by delta, so this is kept as a FALLBACK for a block
298    /// that never sends a delta — not as a prefix the deltas extend. A wire
299    /// that ever delivered the signature up front still round-trips; a
300    /// delta-bearing block never double-counts the opening value.
301    initial_signature: String,
302}
303
304impl ThinkingState {
305    /// The block's completed signature: deltas win over the opening value,
306    /// and an absent signature is `None`.
307    fn into_signature(self) -> Option<String> {
308        let signature = if self.signature.is_empty() {
309            self.initial_signature
310        } else {
311            self.signature
312        };
313        (!signature.is_empty()).then_some(signature)
314    }
315}
316
317/// The Anthropic Messages SSE wire as a [`WireAdapter`].
318///
319/// Holds the per-stream assembly state (open tool call, server tool uses,
320/// open thinking block, terminal metadata); frame-triage policy lives in
321/// [`run_wire_stream`](crate::providers::internal::adapter::run_wire_stream),
322/// not here.
323#[derive(Default)]
324struct AnthropicAdapter {
325    /// Wire id of the open client tool-use block, when one is streaming.
326    current_tool_call: Option<String>,
327    server_tool_uses: HashMap<usize, ServerToolUseState>,
328    current_thinking: Option<ThinkingState>,
329    input_tokens: u64,
330    /// Per-TTL cache-write breakdown from `message_start`; the terminal
331    /// `message_delta` usage omits it.
332    cache_creation: Option<super::completion::CacheCreation>,
333    message_id: Option<String>,
334    response_model: Option<String>,
335    /// A provider `error` event ended the turn; later frames are dead — the
336    /// provider aborted, and interpreting more output (or a terminal) would
337    /// dress the failure up as a completed turn.
338    failed: bool,
339}
340
341impl WireAdapter for AnthropicAdapter {
342    type Frame = WireFrame;
343    type Event = StreamingEvent;
344    type Response = StreamingCompletionResponse;
345
346    fn classify(&self, frame: WireFrame) -> WireEvent<StreamingEvent> {
347        wire::classify_tagged_frame(&frame.as_str(), "type", |event_type| {
348            KNOWN_EVENT_TYPES.contains(&event_type)
349        })
350    }
351
352    fn interpret(&mut self, event: StreamingEvent, out: &mut AdapterOutput<Self::Response>) {
353        if self.failed {
354            return;
355        }
356
357        match &event {
358            StreamingEvent::MessageStart { message } => {
359                // Bedrock-compat quirk: a `message_start` without a message
360                // body is a no-op, not an error.
361                let Some(message) = message else { return };
362                self.input_tokens = message.usage.input_tokens;
363                self.cache_creation = message.usage.cache_creation.clone();
364                self.message_id = Some(message.id.clone());
365                self.response_model = Some(message.model.clone());
366
367                let span = tracing::Span::current();
368                span.record("gen_ai.response.id", &message.id);
369                span.record("gen_ai.response.model", &message.model);
370                return;
371            }
372            StreamingEvent::MessageDelta { delta, usage } => {
373                // Only a `message_delta` carrying a stop reason is the
374                // provider's genuine terminal; without one it is a no-op.
375                let Some(reason) = delta.stop_reason.as_ref() else {
376                    return;
377                };
378                // cache_creation_input_tokens and cache_read_input_tokens are
379                // cumulative totals on message_delta.usage per the Anthropic
380                // streaming API spec — use them directly.
381                //
382                // `input_tokens` prefers the terminal `message_delta` and falls
383                // back to `message_start`.
384                //
385                // Anthropic proper sends the count on *both* frames and they
386                // agree (every recorded cassette under
387                // `tests/cassettes/anthropic/` reporting it on the delta reports
388                // the same value on the start), so the preference is what runs
389                // there and the fallback is inert. The fallback covers the
390                // reverse split — a delta that omits the count, leaving the one
391                // `message_start` reported.
392                //
393                // It does *not* rescue the Bedrock-compat body-less
394                // `message_start`: that shape returns early above without
395                // setting `self.input_tokens`, so the fallback yields
396                // `Some(0)`. Preferring the delta is what carries a real count
397                // there — do not drop the preference on the theory that the
398                // fallback covers that case.
399                //
400                // Anthropic-*compatible* gateways do not all agree. OpenRouter's
401                // Messages endpoint can send `input_tokens: 0` on
402                // `message_start` and the real count on `message_delta`
403                // (recorded in `gateway_message_delta_metadata`, which OpenRouter
404                // served from an Amazon Bedrock upstream — the split follows what
405                // it routes to, so it is not every response from that endpoint).
406                // Without this preference such a turn surfaces a silent
407                // `Usage { input_tokens: 0 }` — worse than a missing value for a
408                // consumer sizing its context window from it.
409                //
410                // Zero on the delta is read as "not reported" so a gateway with
411                // the inverse split cannot erase a count `message_start` got
412                // right. Note this is a heuristic, not an invariant: a fully
413                // cache-hit prompt legitimately bills zero *uncached* input
414                // tokens, and its real size lives in the cache fields. Nothing
415                // is lost today because both frames then carry the same zero and
416                // the fallback yields it anyway — but do not extend the `> 0`
417                // filter to the `message_start` side or the cache fields, where
418                // a genuine zero would be discarded.
419                let usage = PartialUsage {
420                    output_tokens: usage.output_tokens,
421                    input_tokens: usage
422                        .input_tokens
423                        .filter(|tokens| *tokens > 0)
424                        .or_else(|| usize::try_from(self.input_tokens).ok()),
425                    cache_creation_input_tokens: usage.cache_creation_input_tokens,
426                    cache_creation: usage
427                        .cache_creation
428                        .clone()
429                        .or_else(|| self.cache_creation.clone()),
430                    cache_read_input_tokens: usage.cache_read_input_tokens,
431                    // Taken from this frame alone, with no `message_start`
432                    // fallback: unlike `cache_creation`, Anthropic reports the
433                    // output-token breakdown on the terminal `message_delta`,
434                    // the same frame that carries the final `output_tokens` it
435                    // breaks down. `message_start` has none to carry forward.
436                    output_tokens_details: usage.output_tokens_details,
437                };
438
439                let span = tracing::Span::current();
440                span.record_token_usage(&crate::completion::Usage::from(&usage));
441                out.push(Ok(RawStreamingChoice::FinalResponse(
442                    StreamingCompletionResponse {
443                        usage,
444                        stop_reason: Some(reason.clone()),
445                        // Rides the same `message_delta` as the stop reason,
446                        // and only that frame carries it: `message_start`
447                        // always opens with `null`.
448                        stop_sequence: delta.stop_sequence.clone(),
449                        message_id: self.message_id.clone(),
450                        model: self.response_model.clone(),
451                        // Stamped by the transport layer; the adapter never
452                        // sees connection headers.
453                        provider_request_id: None,
454                    },
455                )));
456                return;
457            }
458            StreamingEvent::Error { error } => {
459                // The provider aborted the turn in-band. Preserve the full
460                // error envelope (code + message + extras) as the error body,
461                // matching the interactions wire's handling; the stream
462                // carries it as an in-band `Err` item, and EOF without
463                // `message_delta` then withholds the terminal record.
464                self.failed = true;
465                let body = serde_json::json!({ "type": "error", "error": error }).to_string();
466                out.push(Err(crate::provider_response::completion_error_from_body(
467                    body,
468                )));
469                return;
470            }
471            _ => {}
472        }
473
474        if let Some(result) = handle_event(
475            &event,
476            &mut self.current_tool_call,
477            &mut self.server_tool_uses,
478            &mut self.current_thinking,
479        ) {
480            out.push(result);
481        }
482    }
483
484    fn finish(&mut self, _out: &mut AdapterOutput<Self::Response>) {
485        // EOF without `message_delta` is truncation: open blocks stay
486        // partial, and no terminal record may be synthesized.
487    }
488
489    fn is_finished(&self) -> bool {
490        // A provider `error` event is the wire's own terminal failure:
491        // `interpret` already pushed the in-band `Err`, so the driver must
492        // stop reading — a later modeled frame (e.g. a stray `message_delta`)
493        // would otherwise dress the aborted turn up as a completed one.
494        self.failed
495    }
496}
497
498/// Anthropic's own terminal stream record, as returned by
499/// [`GenericCompletionModel::raw_stream`].
500///
501/// [`crate::completion::CompletionModel::stream`] maps this once into the
502/// normalized [`StreamFinal`]; callers who want the provider-native shape read
503/// it here instead.
504#[derive(Clone, Debug, Default, Deserialize, Serialize)]
505pub struct StreamingCompletionResponse {
506    /// Token usage carried by the terminal `message_delta` event.
507    pub usage: PartialUsage,
508    /// Anthropic's `stop_reason`, verbatim, when the stream reported one.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub stop_reason: Option<String>,
511    /// Which of the caller's `stop_sequences` actually fired, verbatim, when
512    /// the terminal `message_delta` reported one.
513    ///
514    /// `stop_reason: "stop_sequence"` says only *that* a sequence matched;
515    /// the sequence itself is the part a caller branches on, and Anthropic
516    /// strips it from the text, so the wire is its only source. The blocking
517    /// twin has carried it on
518    /// [`CompletionResponse::stop_sequence`](super::completion::CompletionResponse::stop_sequence)
519    /// all along — the streamed record dropped it after parsing, so the same
520    /// request answered strictly less when streamed.
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub stop_sequence: Option<String>,
523    /// The `message_start` message ID, when the stream reported one.
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub message_id: Option<String>,
526    /// The model named by `message_start`, when the stream reported one.
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub model: Option<String>,
529    /// The transport request id from the SSE connection's `request-id`
530    /// response header — not part of any stream frame; stamped by the
531    /// transport. `None` when the provider did not report one.
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub provider_request_id: Option<String>,
534}
535
536/// Normalize an Anthropic terminal stream record.
537///
538/// The provider descriptor name is an *input* rather than a constant: the
539/// Anthropic Messages stream format is shared by every Anthropic-compatible
540/// provider, so baking in `"anthropic"` here would mislabel all of them.
541impl From<(&str, StreamingCompletionResponse)> for StreamFinal {
542    fn from((provider, response): (&str, StreamingCompletionResponse)) -> Self {
543        StreamFinal::new(provider, crate::completion::Usage::from(&response.usage))
544            .with_optional_finish_reason(response.stop_reason.as_deref().map(map_finish_reason))
545            .with_optional_message_id(response.message_id)
546            .with_optional_provider_request_id(response.provider_request_id)
547            .with_optional_model(response.model)
548    }
549}
550
551impl<Ext, T> GenericCompletionModel<Ext, T>
552where
553    T: HttpClientExt + Clone + Default + 'static,
554    Ext: AnthropicCompatibleProvider + Clone + WasmCompatSend + WasmCompatSync + 'static,
555{
556    /// Open a stream whose terminal record stays Anthropic-native.
557    ///
558    /// This is the escape hatch for provider-specific terminal fields rig does
559    /// not normalize. It shares the request builder, transport, telemetry, and
560    /// error handling with
561    /// [`CompletionModel::stream`](crate::completion::CompletionModel::stream),
562    /// which calls it and then maps the terminal record once through
563    /// [`crate::streaming::normalize_stream`] — one network request either way.
564    pub async fn raw_stream(
565        &self,
566        completion_request: CompletionRequest,
567    ) -> Result<RawStreamingResult<StreamingCompletionResponse>, CompletionError> {
568        let (span, request) =
569            self.prepare_request(completion_request, CompletionOperation::ChatStreaming)?;
570
571        // Logged after the streaming-only patches, not on the shared typed
572        // request: `stream` and the reconciled `tool_choice` are exactly what
573        // makes this body differ from the blocking one.
574        let body = streaming_body(&request)?;
575        crate::providers::internal::trace_json(
576            crate::providers::internal::LogTarget::Completions,
577            "Anthropic completion request",
578            &body,
579        );
580
581        let body: Vec<u8> = serde_json::to_vec(&body)?;
582
583        let req = self
584            .client
585            .post("/v1/messages")?
586            .body(body)
587            .map_err(http_client::Error::Protocol)?;
588
589        let event_source = GenericEventSource::new(self.client.clone(), req);
590        let (event_source, request_id_slot) = match Ext::REQUEST_ID_HEADER {
591            Some(header) => {
592                let (event_source, slot) = event_source.capture_request_id(header);
593                (event_source, Some(slot))
594            }
595            None => (event_source, None),
596        };
597
598        // Anthropic's loop historically had no separate `StreamEnded` arm and
599        // no transport-error log: `StreamEnded` folds into the generic error
600        // mapping, preserved via the options below.
601        let stream = open_wire_stream(
602            event_source,
603            SseTransportOptions {
604                open_log: OpenLog::Silent,
605                stream_ended_is_error: true,
606                log_transport_errors: false,
607            },
608            skip_blank_frames,
609            AnthropicAdapter::default(),
610            span,
611        );
612        Ok(
613            crate::providers::internal::sse_transport::stamp_terminal_request_id(
614                stream,
615                request_id_slot,
616                Ext::REQUEST_ID_HEADER,
617                |response, id| response.provider_request_id = Some(id),
618            ),
619        )
620    }
621
622    pub(crate) async fn stream(
623        &self,
624        completion_request: CompletionRequest,
625    ) -> Result<streaming::StreamingCompletionResponse, CompletionError> {
626        let stream = self.raw_stream(completion_request).await?;
627        let normalized = streaming::normalize_stream(stream, |response| {
628            Ok(StreamFinal::from((Ext::PROVIDER_NAME, response)))
629        });
630
631        Ok(streaming::StreamingCompletionResponse::stream(
632            Ext::PROVIDER_NAME,
633            normalized,
634        ))
635    }
636}
637
638fn handle_event(
639    event: &StreamingEvent,
640    current_tool_call: &mut Option<String>,
641    server_tool_uses: &mut HashMap<usize, ServerToolUseState>,
642    current_thinking: &mut Option<ThinkingState>,
643) -> Option<Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>> {
644    match event {
645        StreamingEvent::ContentBlockDelta { index, delta } => match delta {
646            ContentDelta::TextDelta { text } => {
647                if current_tool_call.is_none() {
648                    return Some(Ok(RawStreamingChoice::Message(text.clone())));
649                }
650                None
651            }
652            ContentDelta::InputJsonDelta { partial_json } => {
653                if let Some(server_tool_use) = server_tool_uses.get_mut(index) {
654                    server_tool_use.input_json.push_str(partial_json);
655                    return None;
656                }
657
658                if let Some(id) = current_tool_call {
659                    // Emit the delta so UI can show progress; the shared
660                    // accumulator assembles the fragments.
661                    return Some(Ok(RawStreamingChoice::ToolCallDelta {
662                        id: StreamPartId::wire(id.clone()),
663                        content: ToolCallDeltaContent::Delta(partial_json.clone()),
664                    }));
665                }
666                None
667            }
668            ContentDelta::ThinkingDelta { thinking } => {
669                current_thinking.get_or_insert_with(ThinkingState::default);
670
671                Some(Ok(RawStreamingChoice::ReasoningDelta {
672                    // Anthropic has no reasoning item id; the content-block
673                    // index is stable across a block's deltas and its stop.
674                    id: MintKind::Block.for_wire_index(*index as u64),
675                    provider_id: None,
676                    reasoning: thinking.clone(),
677                }))
678            }
679            ContentDelta::SignatureDelta { signature } => {
680                current_thinking
681                    .get_or_insert_with(ThinkingState::default)
682                    .signature
683                    .push_str(signature);
684
685                // Wire quirk: the signature is not emitted as its own chunk —
686                // it closes the thinking block, riding on the completed
687                // `Reasoning` the `content_block_stop` restatement emits.
688                None
689            }
690            ContentDelta::CitationsDelta { citation } => {
691                crate::message::AdditionalParams::from_entries([("citations", json!([citation]))])
692                    .map(|params| Ok(RawStreamingChoice::TextAdditionalParams(params)))
693            }
694            ContentDelta::Unknown(value) => {
695                // Structural metadata only: a novel delta type can carry
696                // model output, which must not leak into production WARN
697                // logs (same policy as the adapter's unknown-event warn).
698                tracing::warn!(
699                    delta_type = value.get("type").and_then(serde_json::Value::as_str),
700                    "skipping unrecognized Anthropic content delta type"
701                );
702                None
703            }
704        },
705        StreamingEvent::ContentBlockStart {
706            index,
707            content_block,
708        } => match content_block {
709            // Keep this destructuring exhaustive so new wire fields force an
710            // explicit capture-or-drop decision: block-start `text` arrives
711            // via the deltas, and `cache_control` is a request-side
712            // directive — both deliberately dropped here.
713            Content::Text {
714                text: _,
715                citations,
716                cache_control: _,
717            } => {
718                let additional_params = crate::message::AdditionalParams::from_entries(
719                    (!citations.is_empty()).then(|| ("citations", json!(citations))),
720                );
721                Some(Ok(RawStreamingChoice::TextStart {
722                    // Anthropic has no text item id; the content-block index
723                    // is stable for the block's lifetime.
724                    id: MintKind::Block.for_wire_index(*index as u64),
725                    additional_params,
726                }))
727            }
728            Content::ServerToolUse { id, name, input } => {
729                server_tool_uses.insert(
730                    *index,
731                    ServerToolUseState {
732                        name: name.clone(),
733                        id: id.clone(),
734                        initial_input: input.clone(),
735                        input_json: String::new(),
736                    },
737                );
738                None
739            }
740            raw @ (Content::WebSearchToolResult { .. }
741            | Content::CodeExecutionToolResult { .. }) => Some(Ok(RawStreamingChoice::TextStart {
742                id: MintKind::Block.for_wire_index(*index as u64),
743                additional_params: crate::message::AdditionalParams::from_entries([(
744                    super::completion::ANTHROPIC_RAW_CONTENT_KEY,
745                    json!(raw),
746                )]),
747            })),
748            Content::ToolUse { id, name, .. } => {
749                *current_tool_call = Some(id.clone());
750                Some(Ok(RawStreamingChoice::ToolCallDelta {
751                    id: StreamPartId::wire(id.clone()),
752                    content: ToolCallDeltaContent::Name(name.clone()),
753                }))
754            }
755            Content::Thinking {
756                thinking,
757                signature,
758            } => {
759                // `content_block_start` opens the block with its initial
760                // payload; the old `..` discarded both fields. Adaptive
761                // thinking opens with an empty `thinking`, emits no
762                // `thinking_delta` at all, and delivers the whole signature
763                // by `signature_delta` — so the block's only content is a
764                // signature, which `content_block_stop` must still restate.
765                *current_thinking = Some(ThinkingState {
766                    signature: String::new(),
767                    initial_signature: signature.clone().unwrap_or_default(),
768                });
769                // The opening payload's text is a delta like any other; the
770                // shared accumulator owns the block's text.
771                (!thinking.is_empty()).then(|| {
772                    Ok(RawStreamingChoice::ReasoningDelta {
773                        id: MintKind::Block.for_wire_index(*index as u64),
774                        provider_id: None,
775                        reasoning: thinking.clone(),
776                    })
777                })
778            }
779            Content::RedactedThinking { data } => Some(Ok(RawStreamingChoice::Reasoning {
780                // Derive the key from the content-block index (no wire id).
781                id: MintKind::Block.for_wire_index(*index as u64),
782                provider_id: None,
783                content: ReasoningContent::Redacted { data: data.clone() },
784            })),
785            // Handle other content types - they don't need special handling
786            _ => None,
787        },
788        StreamingEvent::ContentBlockStop { index } => {
789            // Drop only a wholly empty block. A signature-only thinking block
790            // (empty text, complete signature) is the adaptive-thinking wire
791            // shape, and its signature is replay-required provider state that
792            // Anthropic accepts back verbatim (the paired non-streaming
793            // cassette replays that exact empty-text signed block). The
794            // non-streaming path has never gated on text, so gating here was
795            // a unary/streaming divergence that silently dropped the
796            // signature.
797            if let Some(thinking_state) = Option::take(current_thinking) {
798                // `content_block_stop` is the wire's own lifecycle end: the
799                // shared accumulator holds the block's accumulated text, and
800                // the end carries the assembled signature (present for
801                // signed and adaptive signature-only blocks alike — replay-
802                // required provider state either way). A wholly empty block
803                // (no deltas, no signature) closes silently.
804                return Some(Ok(RawStreamingChoice::ReasoningEnd {
805                    id: MintKind::Block.for_wire_index(*index as u64),
806                    reasoning: None,
807                    signature: thinking_state.into_signature(),
808                    // `content_block_stop` is the wire's own end frame, so
809                    // even an unsigned block yields its completed event.
810                    wire_sent: true,
811                }));
812            }
813
814            if let Some(server_tool_use) = server_tool_uses.remove(index) {
815                let input = if server_tool_use.input_json.is_empty() {
816                    if server_tool_use.initial_input.is_null() {
817                        json!({})
818                    } else {
819                        server_tool_use.initial_input
820                    }
821                } else {
822                    match serde_json::from_str(&server_tool_use.input_json) {
823                        Ok(json_value) => json_value,
824                        Err(e) => return Some(Err(CompletionError::from(e))),
825                    }
826                };
827
828                return Some(Ok(RawStreamingChoice::TextStart {
829                    id: MintKind::Block.for_wire_index(*index as u64),
830                    additional_params: crate::message::AdditionalParams::from_entries([(
831                        super::completion::ANTHROPIC_RAW_CONTENT_KEY,
832                        json!(Content::ServerToolUse {
833                            id: server_tool_use.id,
834                            name: server_tool_use.name,
835                            input,
836                        }),
837                    )]),
838                }));
839            }
840
841            // `content_block_stop` promises a complete block: empty input
842            // finalizes to `{}`, malformed input surfaces as an error item
843            // (`UnparseableToolInput::Error`) in the accumulator.
844            Option::take(current_tool_call).map(|id| {
845                Ok(RawStreamingChoice::ToolInputEnd(ToolInputEnd::new(
846                    id,
847                    UnparseableToolInput::Error,
848                )))
849            })
850        }
851        // Interpreted by the adapter (`message_start`/`message_delta`/the
852        // `error` envelope) or Known no-ops (`message_stop`, `ping`).
853        StreamingEvent::MessageStart { .. }
854        | StreamingEvent::MessageDelta { .. }
855        | StreamingEvent::MessageStop
856        | StreamingEvent::Ping
857        | StreamingEvent::Error { .. } => None,
858    }
859}
860
861#[cfg(test)]
862mod tests {
863    use super::super::completion::{
864        AnthropicRequestParams, CLAUDE_OPUS_4_8, CacheControl, CacheTtl, Message, SystemContent,
865        apply_prompt_cache_control, build_tool_definitions, resolve_top_level_cache_control,
866    };
867    use super::*;
868    use crate::completion::Message as RigMessage;
869    use crate::completion::request::Document as RigDocument;
870    use crate::streaming::RawStreamingToolCall;
871    use async_stream::stream;
872    use futures::StreamExt;
873
874    /// Normalize a hand-built Anthropic raw stream exactly as
875    /// [`GenericCompletionModel::stream`] does, so aggregation assertions run
876    /// against the same terminal-record mapping as the real path.
877    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
878    fn to_stream_result(
879        stream: impl futures::Stream<
880            Item = Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>,
881        > + Send
882        + 'static,
883    ) -> crate::streaming::StreamingResult {
884        crate::streaming::normalize_stream(Box::pin(stream), |response| {
885            Ok(StreamFinal::from(("anthropic", response)))
886        })
887    }
888
889    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
890    fn to_stream_result(
891        stream: impl futures::Stream<
892            Item = Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>,
893        > + 'static,
894    ) -> crate::streaming::StreamingResult {
895        crate::streaming::normalize_stream(Box::pin(stream), |response| {
896            Ok(StreamFinal::from(("anthropic", response)))
897        })
898    }
899
900    /// Build the streaming request body the way [`GenericCompletionModel::raw_stream`]
901    /// does — the shared typed request, then the streaming-only patches — without
902    /// needing a client to reach the prelude.
903    fn built_streaming_body(
904        model: &str,
905        request: CompletionRequest,
906        strict_tools: bool,
907    ) -> Result<Value, CompletionError> {
908        let typed = AnthropicCompletionRequest::try_from_params::<
909            crate::providers::anthropic::client::AnthropicExt,
910        >(
911            AnthropicRequestParams {
912                model,
913                request,
914                prompt_caching: false,
915                automatic_caching: false,
916                automatic_caching_ttl: None,
917                static_prefix_cache_ttl: None,
918            },
919            strict_tools,
920        )?;
921
922        streaming_body(&typed)
923    }
924
925    #[test]
926    fn test_streaming_tool_build_marks_final_combined_tool() {
927        let mut additional_params = json!({
928            "tools": [{
929                "name": "provider_tool",
930                "description": "Provider tool",
931                "input_schema": {"type": "object"}
932            }]
933        });
934
935        let mut tools =
936            build_tool_definitions::<crate::providers::anthropic::client::AnthropicExt>(
937                vec![crate::completion::ToolDefinition {
938                    name: "rig_tool".to_string(),
939                    description: "Rig tool".to_string(),
940                    parameters: json!({"type": "object", "properties": {}}),
941                }],
942                &mut additional_params,
943                false,
944            )
945            .unwrap();
946        let mut system: Vec<SystemContent> = Vec::new();
947        let mut messages: Vec<Message> = Vec::new();
948        apply_prompt_cache_control(&mut system, &mut messages, &mut tools, true, None, None)
949            .unwrap();
950
951        assert_eq!(tools.len(), 2);
952        assert!(tools[0].get("cache_control").is_none());
953        assert_eq!(tools[1]["name"], "provider_tool");
954        assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
955    }
956
957    #[test]
958    fn streaming_request_keeps_documents_after_leading_system_messages() {
959        let request = CompletionRequest {
960            model: None,
961            preamble: None,
962            chat_history: vec![
963                RigMessage::system("System prompt"),
964                RigMessage::assistant("Earlier assistant turn"),
965                RigMessage::system("Mid-conversation instruction"),
966                RigMessage::user("Prompt"),
967            ],
968            documents: vec![RigDocument {
969                id: "doc1".to_string(),
970                text: "Document text.".to_string(),
971                additional_props: Default::default(),
972            }],
973            tools: vec![],
974            temperature: None,
975            max_tokens: Some(64),
976            tool_choice: None,
977            additional_params: None,
978            output_schema: None,
979            record_telemetry_content: false,
980        };
981
982        let body = built_streaming_body(CLAUDE_OPUS_4_8, request, false)
983            .expect("streaming request body should build");
984
985        assert_eq!(body["system"][0]["text"], "System prompt");
986        assert_eq!(body["system"][1]["text"], "Mid-conversation instruction");
987        let messages = body["messages"]
988            .as_array()
989            .expect("messages should be array");
990        assert_eq!(messages.len(), 3);
991        assert_eq!(messages[0]["role"], "user");
992        assert!(
993            messages[0].to_string().contains("<file id: doc1>"),
994            "document message should follow top-level system: {messages:?}"
995        );
996        assert_eq!(messages[1]["role"], "assistant");
997        assert_eq!(messages[2]["role"], "user");
998        assert_eq!(
999            messages
1000                .iter()
1001                .filter(|message| message.to_string().contains("<file id: doc1>"))
1002                .count(),
1003            1,
1004            "document message should appear exactly once: {messages:?}"
1005        );
1006    }
1007
1008    #[test]
1009    fn streaming_body_is_blocking_body_plus_stream_flag_and_carries_output_schema() {
1010        let schema: schemars::Schema = serde_json::from_value(json!({
1011            "title": "WeatherResponse",
1012            "type": "object",
1013            "properties": { "city": { "type": "string" } }
1014        }))
1015        .expect("schema should deserialize");
1016
1017        let request = CompletionRequest {
1018            model: None,
1019            preamble: Some("You are helpful".to_string()),
1020            chat_history: vec![RigMessage::user("What's the weather?")],
1021            documents: vec![],
1022            tools: vec![],
1023            temperature: Some(0.5),
1024            max_tokens: Some(64),
1025            tool_choice: None,
1026            additional_params: None,
1027            output_schema: Some(schema),
1028            record_telemetry_content: false,
1029        };
1030
1031        let streaming_body = built_streaming_body(CLAUDE_OPUS_4_8, request.clone(), false)
1032            .expect("streaming request body should build");
1033
1034        // The streaming endpoint flag is set.
1035        assert_eq!(streaming_body["stream"], serde_json::Value::Bool(true));
1036
1037        // Regression: `output_schema` now reaches the streaming wire as
1038        // `output_config` (the hand-rolled body dropped it entirely, so this
1039        // assertion would have failed before the typed-request unification).
1040        assert_eq!(
1041            streaming_body["output_config"]["format"]["type"],
1042            "json_schema"
1043        );
1044        assert!(
1045            streaming_body["output_config"]["format"]["schema"].is_object(),
1046            "streaming body must carry the structured-output schema: {streaming_body}"
1047        );
1048
1049        // Unification invariant: the streaming body is exactly the blocking body
1050        // (built via the same typed request) plus `stream: true`. Pins the two
1051        // wire formats together so a future edit can't reintroduce drift.
1052        let blocking = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
1053            model: CLAUDE_OPUS_4_8,
1054            request,
1055            prompt_caching: false,
1056            automatic_caching: false,
1057            automatic_caching_ttl: None,
1058            static_prefix_cache_ttl: None,
1059        })
1060        .expect("blocking request body should build");
1061        let mut expected = serde_json::to_value(&blocking).expect("serialize blocking body");
1062        expected
1063            .as_object_mut()
1064            .expect("body is an object")
1065            .insert("stream".to_string(), serde_json::Value::Bool(true));
1066
1067        assert_eq!(streaming_body, expected);
1068    }
1069
1070    #[test]
1071    fn streaming_body_keeps_explicit_tool_choice_auto_when_tools_present_but_unset() {
1072        let request = CompletionRequest {
1073            model: None,
1074            preamble: None,
1075            chat_history: vec![RigMessage::user("Add 2 and 3")],
1076            documents: vec![],
1077            tools: vec![crate::completion::ToolDefinition {
1078                name: "add".to_string(),
1079                description: "Add x and y".to_string(),
1080                parameters: json!({
1081                    "type": "object",
1082                    "properties": { "x": { "type": "integer" } }
1083                }),
1084            }],
1085            temperature: None,
1086            max_tokens: Some(64),
1087            tool_choice: None,
1088            additional_params: None,
1089            output_schema: None,
1090            record_telemetry_content: false,
1091        };
1092
1093        let body = built_streaming_body(CLAUDE_OPUS_4_8, request, false)
1094            .expect("streaming request body should build");
1095
1096        // Tools advertised + `tool_choice` unset must still carry the explicit
1097        // `auto` the streaming wire format has always sent (parity with recorded
1098        // fixtures), even though the blocking typed request omits it.
1099        assert_eq!(body["tool_choice"], json!({ "type": "auto" }));
1100        assert!(body["tools"].is_array());
1101    }
1102
1103    #[test]
1104    fn streaming_body_applies_strict_tool_opt_in() {
1105        let request = CompletionRequest {
1106            model: None,
1107            preamble: None,
1108            chat_history: vec![RigMessage::user("Look this up")],
1109            documents: vec![],
1110            tools: vec![crate::completion::ToolDefinition {
1111                name: "lookup".to_string(),
1112                description: "Look up a value".to_string(),
1113                parameters: json!({
1114                    "type": "object",
1115                    "properties": { "query": { "type": "string" } },
1116                    "required": ["query"]
1117                }),
1118            }],
1119            temperature: None,
1120            max_tokens: Some(64),
1121            tool_choice: None,
1122            additional_params: None,
1123            output_schema: None,
1124            record_telemetry_content: false,
1125        };
1126
1127        let body = built_streaming_body(CLAUDE_OPUS_4_8, request, true)
1128            .expect("streaming request body should build");
1129
1130        assert_eq!(body["tools"][0]["strict"], true);
1131        assert_eq!(
1132            body["tools"][0]["input_schema"]["additionalProperties"],
1133            false
1134        );
1135        assert_eq!(
1136            body["tools"][0]["input_schema"]["required"],
1137            json!(["query"])
1138        );
1139    }
1140
1141    #[test]
1142    fn streaming_body_drops_tool_choice_when_no_tools_are_advertised() {
1143        // The typed request serializes a caller-set `tool_choice` regardless of
1144        // whether tools are present, but the streaming path has always emitted
1145        // `tool_choice` *only* alongside a non-empty tool set (Anthropic rejects it
1146        // otherwise). A `tool_choice` set with no tools must not reach the wire.
1147        let request = CompletionRequest {
1148            model: None,
1149            preamble: None,
1150            chat_history: vec![RigMessage::user("Hi")],
1151            documents: vec![],
1152            tools: vec![],
1153            temperature: None,
1154            max_tokens: Some(64),
1155            tool_choice: Some(crate::message::ToolChoice::Auto),
1156            additional_params: None,
1157            output_schema: None,
1158            record_telemetry_content: false,
1159        };
1160
1161        let body = built_streaming_body(CLAUDE_OPUS_4_8, request, false)
1162            .expect("streaming request body should build");
1163
1164        assert!(
1165            body.get("tool_choice").is_none(),
1166            "tool_choice must be omitted when no tools are advertised: {body}"
1167        );
1168        assert!(body.get("tools").is_none());
1169    }
1170
1171    #[test]
1172    fn test_streaming_prompt_cache_control_uses_raw_top_level_ttl() {
1173        let mut additional_params = json!({
1174            "cache_control": {"type": "ephemeral", "ttl": "1h"}
1175        });
1176        let top_level_cache_control =
1177            resolve_top_level_cache_control(false, None, &mut additional_params).unwrap();
1178        let mut tools =
1179            build_tool_definitions::<crate::providers::anthropic::client::AnthropicExt>(
1180                vec![crate::completion::ToolDefinition {
1181                    name: "rig_tool".to_string(),
1182                    description: "Rig tool".to_string(),
1183                    parameters: json!({"type": "object", "properties": {}}),
1184                }],
1185                &mut additional_params,
1186                false,
1187            )
1188            .unwrap();
1189        let mut system = vec![SystemContent::Text {
1190            text: "System prompt".to_string(),
1191            cache_control: None,
1192        }];
1193        let mut messages: Vec<Message> = Vec::new();
1194
1195        apply_prompt_cache_control(
1196            &mut system,
1197            &mut messages,
1198            &mut tools,
1199            true,
1200            None,
1201            top_level_cache_control.as_ref(),
1202        )
1203        .unwrap();
1204
1205        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
1206        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
1207        match &system[0] {
1208            SystemContent::Text {
1209                cache_control: Some(CacheControl::Ephemeral { ttl }),
1210                ..
1211            } => assert_eq!(ttl.as_ref(), Some(&CacheTtl::OneHour)),
1212            other => panic!("expected system cache_control, got {other:?}"),
1213        }
1214        assert!(additional_params.get("cache_control").is_none());
1215    }
1216
1217    fn handle_event(
1218        event: &StreamingEvent,
1219        current_tool_call: &mut Option<String>,
1220        current_thinking: &mut Option<ThinkingState>,
1221    ) -> Option<Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>> {
1222        let mut server_tool_uses = HashMap::new();
1223        super::handle_event(
1224            event,
1225            current_tool_call,
1226            &mut server_tool_uses,
1227            current_thinking,
1228        )
1229    }
1230
1231    #[test]
1232    fn test_thinking_delta_deserialization() {
1233        let json = r#"{"type": "thinking_delta", "thinking": "Let me think about this..."}"#;
1234        let delta: ContentDelta = serde_json::from_str(json).unwrap();
1235
1236        match delta {
1237            ContentDelta::ThinkingDelta { thinking } => {
1238                assert_eq!(thinking, "Let me think about this...");
1239            }
1240            _ => panic!("Expected ThinkingDelta variant"),
1241        }
1242    }
1243
1244    #[test]
1245    fn test_signature_delta_deserialization() {
1246        let json = r#"{"type": "signature_delta", "signature": "abc123def456"}"#;
1247        let delta: ContentDelta = serde_json::from_str(json).unwrap();
1248
1249        match delta {
1250            ContentDelta::SignatureDelta { signature } => {
1251                assert_eq!(signature, "abc123def456");
1252            }
1253            _ => panic!("Expected SignatureDelta variant"),
1254        }
1255    }
1256
1257    #[test]
1258    fn test_thinking_delta_streaming_event_deserialization() {
1259        let json = r#"{
1260            "type": "content_block_delta",
1261            "index": 0,
1262            "delta": {
1263                "type": "thinking_delta",
1264                "thinking": "First, I need to understand the problem."
1265            }
1266        }"#;
1267
1268        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1269
1270        match event {
1271            StreamingEvent::ContentBlockDelta { index, delta } => {
1272                assert_eq!(index, 0);
1273                match delta {
1274                    ContentDelta::ThinkingDelta { thinking } => {
1275                        assert_eq!(thinking, "First, I need to understand the problem.");
1276                    }
1277                    _ => panic!("Expected ThinkingDelta"),
1278                }
1279            }
1280            _ => panic!("Expected ContentBlockDelta event"),
1281        }
1282    }
1283
1284    #[test]
1285    fn test_signature_delta_streaming_event_deserialization() {
1286        let json = r#"{
1287            "type": "content_block_delta",
1288            "index": 0,
1289            "delta": {
1290                "type": "signature_delta",
1291                "signature": "ErUBCkYICBgCIkCaGbqC85F4"
1292            }
1293        }"#;
1294
1295        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1296
1297        match event {
1298            StreamingEvent::ContentBlockDelta { index, delta } => {
1299                assert_eq!(index, 0);
1300                match delta {
1301                    ContentDelta::SignatureDelta { signature } => {
1302                        assert_eq!(signature, "ErUBCkYICBgCIkCaGbqC85F4");
1303                    }
1304                    _ => panic!("Expected SignatureDelta"),
1305                }
1306            }
1307            _ => panic!("Expected ContentBlockDelta event"),
1308        }
1309    }
1310
1311    #[test]
1312    fn test_handle_thinking_delta_event() {
1313        let event = StreamingEvent::ContentBlockDelta {
1314            index: 0,
1315            delta: ContentDelta::ThinkingDelta {
1316                thinking: "Analyzing the request...".to_string(),
1317            },
1318        };
1319
1320        let mut tool_call_state = None;
1321        let mut thinking_state = None;
1322        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1323
1324        assert!(result.is_some());
1325        let choice = result.unwrap().unwrap();
1326
1327        match choice {
1328            RawStreamingChoice::ReasoningDelta { id, reasoning, .. } => {
1329                assert_eq!(id, crate::streaming::MintKind::Block.for_wire_index(0));
1330                assert_eq!(reasoning, "Analyzing the request...");
1331            }
1332            _ => panic!("Expected ReasoningDelta choice"),
1333        }
1334
1335        // The block is tracked (its signature may still arrive); the text
1336        // itself accumulates in the shared accumulator, not here.
1337        assert!(thinking_state.is_some());
1338    }
1339
1340    #[test]
1341    fn test_handle_signature_delta_event() {
1342        let event = StreamingEvent::ContentBlockDelta {
1343            index: 0,
1344            delta: ContentDelta::SignatureDelta {
1345                signature: "test_signature".to_string(),
1346            },
1347        };
1348
1349        let mut tool_call_state = None;
1350        let mut thinking_state = None;
1351        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1352
1353        // SignatureDelta should not yield anything (returns None)
1354        assert!(result.is_none());
1355
1356        // But signature should be captured in thinking state
1357        assert!(thinking_state.is_some());
1358        assert_eq!(thinking_state.unwrap().signature, "test_signature");
1359    }
1360
1361    #[test]
1362    fn test_handle_redacted_thinking_content_block_start_event() {
1363        let event = StreamingEvent::ContentBlockStart {
1364            index: 0,
1365            content_block: Content::RedactedThinking {
1366                data: "redacted_blob".to_string(),
1367            },
1368        };
1369        let mut tool_call_state = None;
1370        let mut thinking_state = None;
1371        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1372
1373        assert!(result.is_some());
1374        match result.unwrap().unwrap() {
1375            RawStreamingChoice::Reasoning {
1376                content: ReasoningContent::Redacted { data },
1377                ..
1378            } => {
1379                assert_eq!(data, "redacted_blob");
1380            }
1381            _ => panic!("Expected Redacted reasoning chunk"),
1382        }
1383    }
1384
1385    /// The adaptive-thinking wire shape, exactly as recorded in
1386    /// `tests/cassettes/anthropic/opus_4_7/messages_adaptive_thinking_streaming_smoke.yaml`:
1387    /// `content_block_start` opens the block with an EMPTY `thinking` and an
1388    /// EMPTY `signature`, a `signature_delta` carries the whole signature, and
1389    /// no `thinking_delta` ever arrives. The block's only content is its
1390    /// signature, and it must survive `content_block_stop`.
1391    #[test]
1392    fn signature_only_thinking_block_survives_content_block_stop() {
1393        let mut tool_call_state = None;
1394        let mut thinking_state = None;
1395
1396        let start = StreamingEvent::ContentBlockStart {
1397            index: 0,
1398            content_block: Content::Thinking {
1399                thinking: String::new(),
1400                signature: Some(String::new()),
1401            },
1402        };
1403        assert!(handle_event(&start, &mut tool_call_state, &mut thinking_state).is_none());
1404
1405        let signature = StreamingEvent::ContentBlockDelta {
1406            index: 0,
1407            delta: ContentDelta::SignatureDelta {
1408                signature: "the_whole_signature".to_string(),
1409            },
1410        };
1411        assert!(handle_event(&signature, &mut tool_call_state, &mut thinking_state).is_none());
1412
1413        let stop = StreamingEvent::ContentBlockStop { index: 0 };
1414        let result = handle_event(&stop, &mut tool_call_state, &mut thinking_state)
1415            .expect("signature-only thinking block must not be dropped")
1416            .expect("thinking block should not be an error");
1417
1418        match result {
1419            RawStreamingChoice::ReasoningEnd { id, signature, .. } => {
1420                assert_eq!(id, crate::streaming::MintKind::Block.for_wire_index(0));
1421                assert_eq!(signature.as_deref(), Some("the_whole_signature"));
1422            }
1423            other => panic!("Expected a signed lifecycle end, got {other:?}"),
1424        }
1425    }
1426
1427    /// Forward compat: a block that delivers its whole signature on
1428    /// `content_block_start` and sends no `signature_delta` keeps it.
1429    #[test]
1430    fn signature_delivered_only_on_content_block_start_is_kept() {
1431        let mut tool_call_state = None;
1432        let mut thinking_state = None;
1433
1434        let start = StreamingEvent::ContentBlockStart {
1435            index: 0,
1436            content_block: Content::Thinking {
1437                thinking: String::new(),
1438                signature: Some("up_front_signature".to_string()),
1439            },
1440        };
1441        assert!(handle_event(&start, &mut tool_call_state, &mut thinking_state).is_none());
1442
1443        let stop = StreamingEvent::ContentBlockStop { index: 0 };
1444        match handle_event(&stop, &mut tool_call_state, &mut thinking_state)
1445            .expect("an up-front signature must not be dropped")
1446            .expect("thinking block should not be an error")
1447        {
1448            RawStreamingChoice::ReasoningEnd { signature, .. } => {
1449                assert_eq!(signature.as_deref(), Some("up_front_signature"));
1450            }
1451            other => panic!("Expected a signed lifecycle end, got {other:?}"),
1452        }
1453    }
1454
1455    /// The opening `signature` is a fallback, never a prefix the deltas
1456    /// extend: a delta-bearing block must publish exactly what the deltas
1457    /// assembled, or the value replayed to Anthropic is corrupt.
1458    #[test]
1459    fn signature_deltas_supersede_the_opening_signature() {
1460        let mut tool_call_state = None;
1461        let mut thinking_state = None;
1462
1463        let start = StreamingEvent::ContentBlockStart {
1464            index: 0,
1465            content_block: Content::Thinking {
1466                thinking: String::new(),
1467                signature: Some("opening".to_string()),
1468            },
1469        };
1470        assert!(handle_event(&start, &mut tool_call_state, &mut thinking_state).is_none());
1471
1472        for fragment in ["delta_", "assembled"] {
1473            let signature = StreamingEvent::ContentBlockDelta {
1474                index: 0,
1475                delta: ContentDelta::SignatureDelta {
1476                    signature: fragment.to_string(),
1477                },
1478            };
1479            assert!(handle_event(&signature, &mut tool_call_state, &mut thinking_state).is_none());
1480        }
1481
1482        let stop = StreamingEvent::ContentBlockStop { index: 0 };
1483        match handle_event(&stop, &mut tool_call_state, &mut thinking_state)
1484            .expect("thinking block should be restated")
1485            .expect("thinking block should not be an error")
1486        {
1487            RawStreamingChoice::ReasoningEnd { signature, .. } => {
1488                assert_eq!(signature.as_deref(), Some("delta_assembled"))
1489            }
1490            other => panic!("Expected a signed lifecycle end, got {other:?}"),
1491        }
1492    }
1493
1494    /// `content_block_start` can carry the block's opening text; discarding it
1495    /// would truncate the restatement the accumulator supersedes deltas with.
1496    #[test]
1497    fn thinking_block_start_text_streams_as_the_first_delta() {
1498        let mut tool_call_state = None;
1499        let mut thinking_state = None;
1500
1501        let start = StreamingEvent::ContentBlockStart {
1502            index: 2,
1503            content_block: Content::Thinking {
1504                thinking: "opening ".to_string(),
1505                signature: None,
1506            },
1507        };
1508        // The opening payload's text is a delta like any other; the shared
1509        // accumulator owns the block's text — no adapter-side restatement
1510        // buffer exists to seed.
1511        match handle_event(&start, &mut tool_call_state, &mut thinking_state)
1512            .expect("the opening text streams")
1513            .expect("not an error")
1514        {
1515            RawStreamingChoice::ReasoningDelta { id, reasoning, .. } => {
1516                assert_eq!(id, crate::streaming::MintKind::Block.for_wire_index(2));
1517                assert_eq!(reasoning, "opening ");
1518            }
1519            other => panic!("Expected the opening delta, got {other:?}"),
1520        }
1521
1522        let delta = StreamingEvent::ContentBlockDelta {
1523            index: 2,
1524            delta: ContentDelta::ThinkingDelta {
1525                thinking: "rest".to_string(),
1526            },
1527        };
1528        assert!(handle_event(&delta, &mut tool_call_state, &mut thinking_state).is_some());
1529
1530        let stop = StreamingEvent::ContentBlockStop { index: 2 };
1531        match handle_event(&stop, &mut tool_call_state, &mut thinking_state)
1532            .expect("the stop emits the lifecycle end")
1533            .expect("not an error")
1534        {
1535            RawStreamingChoice::ReasoningEnd {
1536                id,
1537                reasoning: None,
1538                signature: None,
1539                wire_sent: true,
1540            } => {
1541                assert_eq!(id, crate::streaming::MintKind::Block.for_wire_index(2));
1542            }
1543            other => panic!("Expected a bare lifecycle end, got {other:?}"),
1544        }
1545    }
1546
1547    /// A block with neither text nor signature carries nothing to replay.
1548    #[test]
1549    fn wholly_empty_thinking_block_is_dropped() {
1550        let mut tool_call_state = None;
1551        let mut thinking_state = None;
1552
1553        let start = StreamingEvent::ContentBlockStart {
1554            index: 0,
1555            content_block: Content::Thinking {
1556                thinking: String::new(),
1557                signature: None,
1558            },
1559        };
1560        assert!(handle_event(&start, &mut tool_call_state, &mut thinking_state).is_none());
1561
1562        let stop = StreamingEvent::ContentBlockStop { index: 0 };
1563        // The stop emits a bare lifecycle end; with nothing streamed and no
1564        // signature, the shared accumulator records no part (a bare end for
1565        // a never-opened key is a no-op).
1566        match handle_event(&stop, &mut tool_call_state, &mut thinking_state)
1567            .expect("the stop emits the lifecycle end")
1568            .expect("not an error")
1569        {
1570            RawStreamingChoice::ReasoningEnd {
1571                reasoning: None,
1572                signature: None,
1573                ..
1574            } => {}
1575            other => panic!("Expected a bare lifecycle end, got {other:?}"),
1576        }
1577    }
1578
1579    #[test]
1580    fn test_handle_text_delta_event() {
1581        let event = StreamingEvent::ContentBlockDelta {
1582            index: 0,
1583            delta: ContentDelta::TextDelta {
1584                text: "Hello, world!".to_string(),
1585            },
1586        };
1587
1588        let mut tool_call_state = None;
1589        let mut thinking_state = None;
1590        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1591
1592        assert!(result.is_some());
1593        let choice = result.unwrap().unwrap();
1594
1595        match choice {
1596            RawStreamingChoice::Message(text) => {
1597                assert_eq!(text, "Hello, world!");
1598            }
1599            _ => panic!("Expected Message choice"),
1600        }
1601    }
1602
1603    #[test]
1604    fn test_handle_text_block_start_event() {
1605        let event = StreamingEvent::ContentBlockStart {
1606            index: 0,
1607            content_block: Content::Text {
1608                text: String::new(),
1609                citations: Vec::new(),
1610                cache_control: None,
1611            },
1612        };
1613
1614        let mut tool_call_state = None;
1615        let mut thinking_state = None;
1616        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1617
1618        assert!(result.is_some());
1619        let choice = result.unwrap().unwrap();
1620        assert!(matches!(
1621            choice,
1622            RawStreamingChoice::TextStart {
1623                additional_params: None,
1624                ..
1625            }
1626        ));
1627    }
1628
1629    #[test]
1630    fn test_thinking_delta_does_not_interfere_with_tool_calls() {
1631        // Thinking deltas should still be processed even if a tool call is in progress
1632        let event = StreamingEvent::ContentBlockDelta {
1633            index: 0,
1634            delta: ContentDelta::ThinkingDelta {
1635                thinking: "Thinking while tool is active...".to_string(),
1636            },
1637        };
1638
1639        let mut tool_call_state = Some("tool_123".to_string());
1640        let mut thinking_state = None;
1641
1642        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1643
1644        assert!(result.is_some());
1645        let choice = result.unwrap().unwrap();
1646
1647        match choice {
1648            RawStreamingChoice::ReasoningDelta { reasoning, .. } => {
1649                assert_eq!(reasoning, "Thinking while tool is active...");
1650            }
1651            _ => panic!("Expected ReasoningDelta choice"),
1652        }
1653
1654        // Tool call state should remain unchanged
1655        assert!(tool_call_state.is_some());
1656    }
1657
1658    #[test]
1659    fn test_handle_input_json_delta_event() {
1660        let event = StreamingEvent::ContentBlockDelta {
1661            index: 0,
1662            delta: ContentDelta::InputJsonDelta {
1663                partial_json: "{\"arg\":\"value".to_string(),
1664            },
1665        };
1666
1667        let mut tool_call_state = Some("tool_123".to_string());
1668        let mut thinking_state = None;
1669
1670        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1671
1672        // Should emit a ToolCallDelta
1673        assert!(result.is_some());
1674        let choice = result.unwrap().unwrap();
1675
1676        match choice {
1677            RawStreamingChoice::ToolCallDelta { id, content } => {
1678                assert_eq!(id, crate::streaming::StreamPartId::wire("tool_123"));
1679                match content {
1680                    ToolCallDeltaContent::Delta(delta) => assert_eq!(delta, "{\"arg\":\"value"),
1681                    _ => panic!("Expected Delta content"),
1682                }
1683            }
1684            _ => panic!("Expected ToolCallDelta choice, got {:?}", choice),
1685        }
1686
1687        // The open block stays open; assembly of the fragment happens in the
1688        // shared accumulator.
1689        assert!(tool_call_state.is_some());
1690    }
1691
1692    #[test]
1693    fn test_tool_call_accumulation_with_multiple_deltas() {
1694        let mut tool_call_state = Some("tool_123".to_string());
1695        let mut thinking_state = None;
1696
1697        // First delta
1698        let event1 = StreamingEvent::ContentBlockDelta {
1699            index: 0,
1700            delta: ContentDelta::InputJsonDelta {
1701                partial_json: "{\"location\":".to_string(),
1702            },
1703        };
1704        let result1 = handle_event(&event1, &mut tool_call_state, &mut thinking_state);
1705        assert!(result1.is_some());
1706
1707        // Second delta
1708        let event2 = StreamingEvent::ContentBlockDelta {
1709            index: 0,
1710            delta: ContentDelta::InputJsonDelta {
1711                partial_json: "\"Paris\",".to_string(),
1712            },
1713        };
1714        let result2 = handle_event(&event2, &mut tool_call_state, &mut thinking_state);
1715        assert!(result2.is_some());
1716
1717        // Third delta
1718        let event3 = StreamingEvent::ContentBlockDelta {
1719            index: 0,
1720            delta: ContentDelta::InputJsonDelta {
1721                partial_json: "\"temp\":\"20C\"}".to_string(),
1722            },
1723        };
1724        let result3 = handle_event(&event3, &mut tool_call_state, &mut thinking_state);
1725        assert!(result3.is_some());
1726
1727        assert!(tool_call_state.is_some());
1728
1729        // Final ContentBlockStop hands the block to the shared accumulator,
1730        // which finalizes the assembled fragments (`Error` policy: a stopped
1731        // block promised complete input). End-to-end assembly of exactly this
1732        // fragment sequence is pinned in `streaming::parts` unit tests.
1733        let stop_event = StreamingEvent::ContentBlockStop { index: 0 };
1734        let final_result = handle_event(&stop_event, &mut tool_call_state, &mut thinking_state);
1735        assert!(final_result.is_some());
1736
1737        match final_result.unwrap().unwrap() {
1738            RawStreamingChoice::ToolInputEnd(end) => {
1739                assert_eq!(end.id, crate::streaming::StreamPartId::wire("tool_123"));
1740                assert!(matches!(
1741                    end.on_unparseable,
1742                    crate::streaming::UnparseableToolInput::Error
1743                ));
1744            }
1745            other => panic!("Expected ToolInputEnd, got {:?}", other),
1746        }
1747
1748        // Tool call state should be taken
1749        assert!(tool_call_state.is_none());
1750    }
1751
1752    #[test]
1753    fn test_citations_delta_streaming_event_deserialization() {
1754        let json = r#"{
1755            "type": "content_block_delta",
1756            "index": 0,
1757            "delta": {
1758                "type": "citations_delta",
1759                "citation": {
1760                    "type": "char_location",
1761                    "cited_text": "The grass is green.",
1762                    "document_index": 0,
1763                    "document_title": "Example",
1764                    "start_char_index": 0,
1765                    "end_char_index": 20
1766                }
1767            }
1768        }"#;
1769
1770        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1771        let StreamingEvent::ContentBlockDelta { index, delta } = event else {
1772            panic!("expected ContentBlockDelta");
1773        };
1774        assert_eq!(index, 0);
1775        let ContentDelta::CitationsDelta { citation } = delta else {
1776            panic!("expected CitationsDelta");
1777        };
1778        let crate::providers::anthropic::completion::Citation::CharLocation(citation) = citation
1779        else {
1780            panic!("expected CharLocation");
1781        };
1782        assert_eq!(citation.start_char_index, 0);
1783        assert_eq!(citation.end_char_index, 20);
1784    }
1785
1786    #[test]
1787    fn test_search_result_citations_delta_streaming_event_deserialization() {
1788        let json = r#"{
1789            "type": "content_block_delta",
1790            "index": 0,
1791            "delta": {
1792                "type": "citations_delta",
1793                "citation": {
1794                    "type": "search_result_location",
1795                    "cited_text": "API requests require a key.",
1796                    "source": "https://docs.example.com/api-reference",
1797                    "title": "API Reference",
1798                    "search_result_index": 0,
1799                    "start_block_index": 0,
1800                    "end_block_index": 1
1801                }
1802            }
1803        }"#;
1804
1805        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1806        let StreamingEvent::ContentBlockDelta { delta, .. } = event else {
1807            panic!("expected ContentBlockDelta");
1808        };
1809        let ContentDelta::CitationsDelta { citation } = delta else {
1810            panic!("expected CitationsDelta");
1811        };
1812        assert!(matches!(
1813            citation,
1814            crate::providers::anthropic::completion::Citation::SearchResultLocation(
1815                crate::providers::anthropic::completion::SearchResultLocationCitation {
1816                    search_result_index: 0,
1817                    start_block_index: 0,
1818                    end_block_index: 1,
1819                    ..
1820                }
1821            )
1822        ));
1823    }
1824
1825    #[test]
1826    fn test_web_search_result_citations_delta_streaming_event_deserialization() {
1827        let json = r#"{
1828            "type": "content_block_delta",
1829            "index": 0,
1830            "delta": {
1831                "type": "citations_delta",
1832                "citation": {
1833                    "type": "web_search_result_location",
1834                    "cited_text": "Claude Shannon was a mathematician.",
1835                    "url": "https://example.com/shannon",
1836                    "title": "Claude Shannon",
1837                    "encrypted_index": "encrypted-reference"
1838                }
1839            }
1840        }"#;
1841
1842        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1843        let StreamingEvent::ContentBlockDelta { delta, .. } = event else {
1844            panic!("expected ContentBlockDelta");
1845        };
1846        let ContentDelta::CitationsDelta { citation } = delta else {
1847            panic!("expected CitationsDelta");
1848        };
1849        assert!(matches!(
1850            citation,
1851            crate::providers::anthropic::completion::Citation::WebSearchResultLocation(ref citation)
1852                if citation.url == "https://example.com/shannon"
1853                    && citation.encrypted_index == "encrypted-reference"
1854        ));
1855    }
1856
1857    #[test]
1858    fn test_web_search_result_citations_delta_allows_null_title() {
1859        let json = r#"{
1860            "type": "content_block_delta",
1861            "index": 0,
1862            "delta": {
1863                "type": "citations_delta",
1864                "citation": {
1865                    "type": "web_search_result_location",
1866                    "cited_text": "Claude Shannon was a mathematician.",
1867                    "url": "https://example.com/shannon",
1868                    "title": null,
1869                    "encrypted_index": "encrypted-reference"
1870                }
1871            }
1872        }"#;
1873
1874        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1875        let StreamingEvent::ContentBlockDelta { delta, .. } = event else {
1876            panic!("expected ContentBlockDelta");
1877        };
1878        let ContentDelta::CitationsDelta { citation } = delta else {
1879            panic!("expected CitationsDelta");
1880        };
1881        assert!(matches!(
1882            citation,
1883            crate::providers::anthropic::completion::Citation::WebSearchResultLocation(
1884                crate::providers::anthropic::completion::WebSearchResultLocationCitation {
1885                    title: None,
1886                    ..
1887                }
1888            )
1889        ));
1890    }
1891
1892    #[test]
1893    fn test_text_content_block_start_allows_null_citations() {
1894        // The Anthropic Messages API emits an explicit `"citations": null` on the
1895        // first text `content_block_start` event. `#[serde(default)]` alone covers
1896        // a missing field but not an explicit null, so this must deserialize to an
1897        // empty citation list rather than failing the whole stream (see #1971).
1898        let json = r#"{
1899            "type": "content_block_start",
1900            "index": 0,
1901            "content_block": {
1902                "type": "text",
1903                "text": "",
1904                "citations": null
1905            }
1906        }"#;
1907
1908        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1909        let StreamingEvent::ContentBlockStart { content_block, .. } = event else {
1910            panic!("expected ContentBlockStart");
1911        };
1912        let Content::Text {
1913            text, citations, ..
1914        } = content_block
1915        else {
1916            panic!("expected text content block");
1917        };
1918        assert_eq!(text, "");
1919        assert!(citations.is_empty());
1920    }
1921
1922    #[test]
1923    fn test_web_search_content_block_start_events_deserialize() {
1924        let server_tool_use = r#"{
1925            "type": "content_block_start",
1926            "index": 1,
1927            "content_block": {
1928                "type": "server_tool_use",
1929                "id": "srvtoolu_01",
1930                "name": "web_search",
1931                "input": {
1932                    "query": "claude shannon birth date"
1933                }
1934            }
1935        }"#;
1936        let event: StreamingEvent = serde_json::from_str(server_tool_use).unwrap();
1937        assert!(matches!(
1938            event,
1939            StreamingEvent::ContentBlockStart {
1940                content_block: Content::ServerToolUse {
1941                    ref id,
1942                    ref name,
1943                    ref input
1944                },
1945                ..
1946            } if id == "srvtoolu_01"
1947                && name == "web_search"
1948                && input["query"] == "claude shannon birth date"
1949        ));
1950
1951        let web_search_tool_result = r#"{
1952            "type": "content_block_start",
1953            "index": 2,
1954            "content_block": {
1955                "type": "web_search_tool_result",
1956                "tool_use_id": "srvtoolu_01",
1957                "content": [{
1958                    "type": "web_search_result",
1959                    "url": "https://example.com/shannon",
1960                    "title": "Claude Shannon",
1961                    "encrypted_content": "encrypted-content"
1962                }]
1963            }
1964        }"#;
1965        let event: StreamingEvent = serde_json::from_str(web_search_tool_result).unwrap();
1966        assert!(matches!(
1967            event,
1968            StreamingEvent::ContentBlockStart {
1969                content_block: Content::WebSearchToolResult {
1970                    ref tool_use_id,
1971                    ref content
1972                },
1973                ..
1974            } if tool_use_id == "srvtoolu_01"
1975                && content[0]["encrypted_content"] == "encrypted-content"
1976        ));
1977    }
1978
1979    #[test]
1980    fn test_code_execution_tool_result_block_is_preserved() {
1981        let event: StreamingEvent = serde_json::from_value(serde_json::json!({
1982            "type": "content_block_start",
1983            "index": 1,
1984            "content_block": {
1985                "type": "code_execution_tool_result",
1986                "tool_use_id": "srvtoolu_01",
1987                "content": {
1988                    "type": "code_execution_result",
1989                    "return_code": 0,
1990                    "stdout": "42\n",
1991                    "stderr": "",
1992                    "content": []
1993                }
1994            }
1995        }))
1996        .unwrap();
1997        let mut tool_call_state = None;
1998        let mut server_tool_uses = HashMap::new();
1999        let mut thinking_state = None;
2000
2001        let choice = super::handle_event(
2002            &event,
2003            &mut tool_call_state,
2004            &mut server_tool_uses,
2005            &mut thinking_state,
2006        )
2007        .expect("code_execution_tool_result block should produce raw metadata")
2008        .unwrap();
2009
2010        let RawStreamingChoice::TextStart {
2011            id,
2012            additional_params: Some(additional_params),
2013        } = choice
2014        else {
2015            panic!("expected text-start metadata for code_execution_tool_result");
2016        };
2017        assert_eq!(id, crate::streaming::MintKind::Block.for_wire_index(1));
2018        assert_eq!(
2019            additional_params[crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["type"],
2020            "code_execution_tool_result"
2021        );
2022        assert_eq!(
2023            additional_params[crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["content"]
2024                ["stdout"],
2025            "42\n"
2026        );
2027    }
2028
2029    #[tokio::test]
2030    async fn test_streaming_web_search_blocks_are_preserved_on_final_choice() {
2031        let raw_stream = stream! {
2032            let mut tool_call_state = None;
2033            let mut server_tool_uses = HashMap::new();
2034            let mut thinking_state = None;
2035
2036            let server_tool_use_start = super::handle_event(
2037                &StreamingEvent::ContentBlockStart {
2038                    index: 0,
2039                    content_block: Content::ServerToolUse {
2040                        id: "srvtoolu_01".to_string(),
2041                        name: "web_search".to_string(),
2042                        input: serde_json::Value::Null,
2043                    },
2044                },
2045                &mut tool_call_state,
2046                &mut server_tool_uses,
2047                &mut thinking_state,
2048            );
2049            assert!(
2050                server_tool_use_start.is_none(),
2051                "server_tool_use start should be accumulated until its input JSON is complete"
2052            );
2053
2054            let server_tool_use_delta = super::handle_event(
2055                &StreamingEvent::ContentBlockDelta {
2056                    index: 0,
2057                    delta: ContentDelta::InputJsonDelta {
2058                        partial_json: r#"{"query":"claude shannon birth date"}"#.to_string(),
2059                    },
2060                },
2061                &mut tool_call_state,
2062                &mut server_tool_uses,
2063                &mut thinking_state,
2064            );
2065            assert!(
2066                server_tool_use_delta.is_none(),
2067                "server_tool_use input JSON should not be emitted as a Rig tool-call delta"
2068            );
2069
2070            yield super::handle_event(
2071                &StreamingEvent::ContentBlockStop { index: 0 },
2072                &mut tool_call_state,
2073                &mut server_tool_uses,
2074                &mut thinking_state,
2075            )
2076            .expect("server_tool_use stop should produce completed raw metadata");
2077
2078            yield super::handle_event(
2079                &StreamingEvent::ContentBlockStart {
2080                    index: 1,
2081                    content_block: Content::WebSearchToolResult {
2082                        tool_use_id: "srvtoolu_01".to_string(),
2083                        content: serde_json::json!([{
2084                            "type": "web_search_result",
2085                            "url": "https://example.com/shannon",
2086                            "title": "Claude Shannon",
2087                            "encrypted_content": "encrypted-content"
2088                        }]),
2089                    },
2090                },
2091                &mut tool_call_state,
2092                &mut server_tool_uses,
2093                &mut thinking_state,
2094            )
2095            .expect("web_search_tool_result block should produce raw metadata");
2096
2097            yield super::handle_event(
2098                &StreamingEvent::ContentBlockStart {
2099                    index: 2,
2100                    content_block: Content::Text {
2101                        text: String::new(),
2102                        citations: Vec::new(),
2103                        cache_control: None,
2104                    },
2105                },
2106                &mut tool_call_state,
2107                &mut server_tool_uses,
2108                &mut thinking_state,
2109            )
2110            .expect("text block start should produce a raw choice");
2111
2112            yield super::handle_event(
2113                &StreamingEvent::ContentBlockDelta {
2114                    index: 2,
2115                    delta: ContentDelta::TextDelta {
2116                        text: "Claude Shannon was born on April 30, 1916.".to_string(),
2117                    },
2118                },
2119                &mut tool_call_state,
2120                &mut server_tool_uses,
2121                &mut thinking_state,
2122            )
2123            .expect("text delta should produce a raw choice");
2124
2125            yield super::handle_event(
2126                &StreamingEvent::ContentBlockDelta {
2127                    index: 2,
2128                    delta: ContentDelta::CitationsDelta {
2129                        citation: crate::providers::anthropic::completion::Citation::WebSearchResultLocation(
2130                            crate::providers::anthropic::completion::WebSearchResultLocationCitation {
2131                                cited_text: "Claude Shannon was born on April 30, 1916."
2132                                    .to_string(),
2133                                url: "https://example.com/shannon".to_string(),
2134                                title: Some("Claude Shannon".to_string()),
2135                                encrypted_index: "encrypted-index".to_string(),
2136                            },
2137                        ),
2138                    },
2139                },
2140                &mut tool_call_state,
2141                &mut server_tool_uses,
2142                &mut thinking_state,
2143            )
2144            .expect("citation delta should produce a raw choice");
2145
2146            yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse::default()));
2147        };
2148
2149        let mut stream = crate::streaming::StreamingCompletionResponse::stream(
2150            "anthropic",
2151            to_stream_result(raw_stream),
2152        );
2153        while stream.next().await.is_some() {}
2154
2155        let choice_items: Vec<crate::message::AssistantContent> =
2156            stream.choice.clone().into_iter().collect();
2157        assert_eq!(choice_items.len(), 3);
2158        assert!(
2159            choice_items
2160                .iter()
2161                .all(|item| !matches!(item, crate::message::AssistantContent::ToolCall(_))),
2162            "provider-owned web-search blocks must not become Rig client tool calls"
2163        );
2164
2165        let Some(crate::message::AssistantContent::Text(server_tool_use)) = choice_items.first()
2166        else {
2167            panic!("expected raw server_tool_use metadata");
2168        };
2169        assert_eq!(
2170            server_tool_use.additional_params.as_ref().unwrap()
2171                [crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["type"],
2172            "server_tool_use"
2173        );
2174        assert_eq!(
2175            server_tool_use.additional_params.as_ref().unwrap()
2176                [crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["input"]["query"],
2177            "claude shannon birth date"
2178        );
2179
2180        let Some(crate::message::AssistantContent::Text(web_search_result)) = choice_items.get(1)
2181        else {
2182            panic!("expected raw web_search_tool_result metadata");
2183        };
2184        assert_eq!(
2185            web_search_result.additional_params.as_ref().unwrap()
2186                [crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["content"][0]
2187                ["encrypted_content"],
2188            "encrypted-content"
2189        );
2190
2191        let Some(crate::message::AssistantContent::Text(answer)) = choice_items.get(2) else {
2192            panic!("expected answer text");
2193        };
2194        assert_eq!(answer.text, "Claude Shannon was born on April 30, 1916.");
2195        let citations = crate::providers::anthropic::completion::anthropic_citations(answer)
2196            .expect("expected preserved citations");
2197        assert!(matches!(
2198            citations.first(),
2199            Some(crate::providers::anthropic::completion::Citation::WebSearchResultLocation(citation))
2200                if citation.encrypted_index == "encrypted-index"
2201        ));
2202    }
2203
2204    #[test]
2205    fn test_handle_citations_delta_event_preserves_metadata() {
2206        let event = StreamingEvent::ContentBlockDelta {
2207            index: 0,
2208            delta: ContentDelta::CitationsDelta {
2209                citation: crate::providers::anthropic::completion::Citation::CharLocation(
2210                    crate::providers::anthropic::completion::CharLocationCitation {
2211                        cited_text: "The grass is green.".to_string(),
2212                        document_index: 0,
2213                        document_title: Some("Example".to_string()),
2214                        start_char_index: 0,
2215                        end_char_index: 20,
2216                    },
2217                ),
2218            },
2219        };
2220
2221        let mut tool_call_state = None;
2222        let mut thinking_state = None;
2223        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
2224
2225        assert!(result.is_some());
2226        let choice = result.unwrap().unwrap();
2227        let RawStreamingChoice::TextAdditionalParams(additional_params) = choice else {
2228            panic!("expected TextAdditionalParams choice");
2229        };
2230        assert_eq!(additional_params["citations"][0]["type"], "char_location");
2231    }
2232
2233    #[tokio::test]
2234    async fn test_streaming_citation_deltas_are_preserved_on_final_text() {
2235        let citation = crate::providers::anthropic::completion::Citation::CharLocation(
2236            crate::providers::anthropic::completion::CharLocationCitation {
2237                cited_text: "The grass is green.".to_string(),
2238                document_index: 0,
2239                document_title: Some("Example".to_string()),
2240                start_char_index: 0,
2241                end_char_index: 20,
2242            },
2243        );
2244
2245        let raw_stream = stream! {
2246            let mut tool_call_state = None;
2247            let mut thinking_state = None;
2248
2249            yield handle_event(
2250                &StreamingEvent::ContentBlockStart {
2251                    index: 0,
2252                    content_block: Content::Text {
2253                        text: String::new(),
2254                        citations: Vec::new(),
2255                        cache_control: None,
2256                    },
2257                },
2258                &mut tool_call_state,
2259                &mut thinking_state,
2260            )
2261            .expect("text block start should produce a raw choice");
2262
2263            yield handle_event(
2264                &StreamingEvent::ContentBlockDelta {
2265                    index: 0,
2266                    delta: ContentDelta::TextDelta {
2267                        text: "the grass is green".to_string(),
2268                    },
2269                },
2270                &mut tool_call_state,
2271                &mut thinking_state,
2272            )
2273            .expect("text delta should produce a raw choice");
2274
2275            yield handle_event(
2276                &StreamingEvent::ContentBlockDelta {
2277                    index: 0,
2278                    delta: ContentDelta::CitationsDelta {
2279                        citation: crate::providers::anthropic::completion::Citation::CharLocation(
2280                            crate::providers::anthropic::completion::CharLocationCitation {
2281                                cited_text: "The grass is green.".to_string(),
2282                                document_index: 0,
2283                                document_title: Some("Example".to_string()),
2284                                start_char_index: 0,
2285                                end_char_index: 20,
2286                            },
2287                        ),
2288                    },
2289                },
2290                &mut tool_call_state,
2291                &mut thinking_state,
2292            )
2293            .expect("citation delta should produce a raw choice");
2294
2295            yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse::default()));
2296        };
2297
2298        let mut stream = crate::streaming::StreamingCompletionResponse::stream(
2299            "anthropic",
2300            to_stream_result(raw_stream),
2301        );
2302        while stream.next().await.is_some() {}
2303
2304        let choice_items: Vec<crate::message::AssistantContent> =
2305            stream.choice.clone().into_iter().collect();
2306        let Some(crate::message::AssistantContent::Text(text)) = choice_items.first() else {
2307            panic!("expected accumulated text item");
2308        };
2309
2310        assert_eq!(text.text, "the grass is green");
2311        let citations = crate::providers::anthropic::completion::anthropic_citations(text).unwrap();
2312        assert_eq!(citations, vec![citation]);
2313    }
2314
2315    /// The `#[serde(other)]` policy fallbacks are gone: classification is the
2316    /// only policy site. An unmodeled *top-level* event type is `Unknown`
2317    /// (driver: warn + skip); a `ping` is Known; and a known tag whose payload
2318    /// this client cannot decode is `Corrupt`, never silently demoted to an
2319    /// ignorable unknown. An unmodeled *nested* delta type is the one carved
2320    /// exception (Anthropic's versioning policy reserves the right to add
2321    /// them): it decodes to [`ContentDelta::Unknown`] and stays a Known
2322    /// no-op — see the dedicated tests below.
2323    #[test]
2324    fn classify_dispatches_on_the_known_event_list() {
2325        let adapter = AnthropicAdapter::default();
2326
2327        let frame =
2328            WireFrame::Text(r#"{"type":"something_new_from_anthropic","field":"x"}"#.into());
2329        assert!(matches!(
2330            adapter.classify(frame),
2331            crate::providers::internal::wire::WireEvent::Unknown { event_type, .. }
2332                if event_type == "something_new_from_anthropic"
2333        ));
2334
2335        let frame = WireFrame::Text(r#"{"type":"ping"}"#.into());
2336        assert!(matches!(
2337            adapter.classify(frame),
2338            crate::providers::internal::wire::WireEvent::Known(StreamingEvent::Ping)
2339        ));
2340
2341        let frame = WireFrame::Text("{not json".into());
2342        assert!(matches!(
2343            adapter.classify(frame),
2344            crate::providers::internal::wire::WireEvent::Corrupt(_)
2345        ));
2346    }
2347
2348    /// Forward compat: a novel nested delta type Anthropic ships tomorrow
2349    /// must not corrupt the whole `content_block_delta` frame — it decodes
2350    /// to [`ContentDelta::Unknown`] and interprets as a warned no-op, so the
2351    /// stream continues.
2352    #[test]
2353    fn novel_nested_delta_type_is_a_known_noop() {
2354        let adapter = AnthropicAdapter::default();
2355        let frame = WireFrame::Text(
2356            r#"{"type":"content_block_delta","index":0,"delta":{"type":"banana_delta","x":1}}"#
2357                .into(),
2358        );
2359        let crate::providers::internal::wire::WireEvent::Known(event) = adapter.classify(frame)
2360        else {
2361            panic!("a novel nested delta type must stay a Known event");
2362        };
2363
2364        let mut adapter = AnthropicAdapter::default();
2365        let mut out = Vec::new();
2366        adapter.interpret(event, &mut out);
2367        assert!(out.is_empty(), "an unmodeled nested delta is a no-op");
2368    }
2369
2370    /// Anthropic reports the per-TTL `cache_creation` split on
2371    /// `message_start` only; the terminal `message_delta` usage omits it. The
2372    /// adapter must carry it onto the terminal record. Unit-tested (not a
2373    /// cassette) because the carry-forward is internal adapter state — the
2374    /// wire evidence lives in the recorded `prompt_caching/matrix_*` streaming
2375    /// cassettes, whose `message_start` frames hold the split.
2376    #[test]
2377    fn per_ttl_cache_creation_split_carries_from_message_start_to_terminal() {
2378        let mut adapter = AnthropicAdapter::default();
2379        let mut out = Vec::new();
2380
2381        let start = WireFrame::Text(
2382            r#"{"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"output_tokens":1,"cache_creation_input_tokens":9702,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_1h_input_tokens":9366,"ephemeral_5m_input_tokens":336}}}}"#
2383                .into(),
2384        );
2385        let crate::providers::internal::wire::WireEvent::Known(event) = adapter.classify(start)
2386        else {
2387            panic!("message_start must classify Known");
2388        };
2389        adapter.interpret(event, &mut out);
2390
2391        let delta = WireFrame::Text(
2392            r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":7,"input_tokens":3,"cache_creation_input_tokens":9702,"cache_read_input_tokens":0}}"#
2393                .into(),
2394        );
2395        let crate::providers::internal::wire::WireEvent::Known(event) = adapter.classify(delta)
2396        else {
2397            panic!("message_delta must classify Known");
2398        };
2399        adapter.interpret(event, &mut out);
2400
2401        let terminal = out
2402            .iter()
2403            .find_map(|item| match item {
2404                Ok(crate::streaming::RawStreamingChoice::FinalResponse(response)) => {
2405                    Some(response.clone())
2406                }
2407                _ => None,
2408            })
2409            .expect("terminal message_delta must yield a final response");
2410        let split = terminal
2411            .usage
2412            .cache_creation
2413            .expect("terminal usage must carry the message_start cache_creation split");
2414        assert_eq!(split.ephemeral_1h_input_tokens, 9366);
2415        assert_eq!(split.ephemeral_5m_input_tokens, 336);
2416        assert_eq!(terminal.usage.cache_creation_input_tokens, Some(9702));
2417    }
2418
2419    /// A `content_block_delta` whose `delta` omits `type` is malformed, not
2420    /// novel: silently skipping it would turn a compat gateway's untagged
2421    /// text delta into a successful *empty* completion. It classifies
2422    /// `Corrupt`, surfacing in-band while the stream keeps consuming
2423    /// (#2258 B5).
2424    #[test]
2425    fn delta_missing_its_type_is_corrupt_not_skipped() {
2426        let adapter = AnthropicAdapter::default();
2427        let frame = WireFrame::Text(
2428            r#"{"type":"content_block_delta","index":0,"delta":{"text":"hello"}}"#.into(),
2429        );
2430        assert!(matches!(
2431            adapter.classify(frame),
2432            crate::providers::internal::wire::WireEvent::Corrupt(_)
2433        ));
2434    }
2435
2436    /// Policy preserved: a *known* nested delta tag with a defective payload
2437    /// is a data-level defect, not an unmodeled delta — the frame classifies
2438    /// `Corrupt` instead of degrading to an `Unknown` no-op.
2439    #[test]
2440    fn known_nested_delta_tag_with_defective_payload_is_corrupt() {
2441        let adapter = AnthropicAdapter::default();
2442        let frame = WireFrame::Text(
2443            r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":42}}"#
2444                .into(),
2445        );
2446        assert!(matches!(
2447            adapter.classify(frame),
2448            crate::providers::internal::wire::WireEvent::Corrupt(_)
2449        ));
2450    }
2451
2452    /// Anthropic's top-level `{"type":"error"}` envelope (e.g.
2453    /// `overloaded_error`) is a Known event that surfaces as a provider error
2454    /// carrying the full envelope — never a warn-skipped unknown — and, since
2455    /// no `message_delta` follows, the stream ends with no terminal record.
2456    #[test]
2457    fn top_level_error_event_surfaces_as_a_provider_error() {
2458        let adapter = AnthropicAdapter::default();
2459        let frame = WireFrame::Text(
2460            r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#.into(),
2461        );
2462        let crate::providers::internal::wire::WireEvent::Known(event) = adapter.classify(frame)
2463        else {
2464            panic!("the error envelope must classify as a Known event");
2465        };
2466
2467        let mut adapter = AnthropicAdapter::default();
2468        let mut out = Vec::new();
2469        adapter.interpret(event, &mut out);
2470
2471        assert_eq!(out.len(), 1, "the error envelope maps to one error item");
2472        let Some(Err(error)) = out.pop() else {
2473            panic!("the error envelope must surface as an Err item");
2474        };
2475        let body = error
2476            .provider_response_body()
2477            .expect("the provider's error payload must be preserved");
2478        assert!(
2479            body.contains("overloaded_error") && body.contains("Overloaded"),
2480            "the full envelope must survive into the error body, got: {body}"
2481        );
2482    }
2483
2484    /// Bedrock-compat quirk: `message_start` without a message body is a
2485    /// Known no-op, not a corrupt frame.
2486    #[test]
2487    fn message_start_with_null_message_is_a_known_noop() {
2488        let adapter = AnthropicAdapter::default();
2489        let frame = WireFrame::Text(r#"{"type":"message_start","message":null}"#.into());
2490        let crate::providers::internal::wire::WireEvent::Known(event) = adapter.classify(frame)
2491        else {
2492            panic!("null-message message_start must stay a known event");
2493        };
2494
2495        let mut adapter = AnthropicAdapter::default();
2496        let mut out = Vec::new();
2497        adapter.interpret(event, &mut out);
2498        assert!(out.is_empty(), "a message-less message_start is a no-op");
2499    }
2500
2501    #[tokio::test]
2502    async fn terminal_record_normalizes_stop_reason_usage_and_metadata() {
2503        let raw_stream = stream! {
2504            yield Ok(RawStreamingChoice::Message("hi".to_string()));
2505            yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse {
2506                usage: PartialUsage {
2507                    output_tokens: 5,
2508                    input_tokens: Some(3),
2509                    cache_creation_input_tokens: None,
2510                    cache_creation: None,
2511                    cache_read_input_tokens: Some(2),
2512                    output_tokens_details: None,
2513                },
2514                stop_reason: Some("max_tokens".to_string()),
2515                stop_sequence: None,
2516                message_id: Some("msg_1".to_string()),
2517                model: Some(CLAUDE_OPUS_4_8.to_string()),
2518                provider_request_id: None,
2519            }));
2520        };
2521
2522        let mut stream = crate::streaming::StreamingCompletionResponse::stream(
2523            "anthropic",
2524            to_stream_result(raw_stream),
2525        );
2526        while stream.next().await.is_some() {}
2527
2528        let terminal = stream.response.expect("expected a terminal record");
2529        assert_eq!(terminal.provider, "anthropic");
2530        assert_eq!(terminal.message_id.as_deref(), Some("msg_1"));
2531        assert_eq!(terminal.model.as_deref(), Some(CLAUDE_OPUS_4_8));
2532        assert_eq!(
2533            terminal.finish_reason,
2534            Some(crate::completion::FinishReason::Length)
2535        );
2536        assert_eq!(terminal.usage.input_tokens, 3);
2537        assert_eq!(terminal.usage.output_tokens, 5);
2538        assert_eq!(terminal.usage.cached_input_tokens, 2);
2539        assert_eq!(terminal.usage.total_tokens, 10);
2540    }
2541
2542    #[tokio::test]
2543    async fn terminal_record_upgrades_end_turn_to_tool_calls_after_a_streamed_tool_call() {
2544        // Anthropic normally reports `tool_use`, but the reconciliation
2545        // `normalize_stream` applies must hold whenever the turn actually
2546        // emitted a tool call.
2547        let raw_stream = stream! {
2548            yield Ok(RawStreamingChoice::ToolCall(RawStreamingToolCall::new(
2549                "toolu_1".to_string(),
2550                "add".to_string(),
2551                json!({"x": 1}),
2552            )));
2553            yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse {
2554                stop_reason: Some("end_turn".to_string()),
2555                ..Default::default()
2556            }));
2557        };
2558
2559        let mut stream = crate::streaming::StreamingCompletionResponse::stream(
2560            "anthropic",
2561            to_stream_result(raw_stream),
2562        );
2563        while stream.next().await.is_some() {}
2564
2565        let terminal = stream.response.expect("expected a terminal record");
2566        assert_eq!(
2567            terminal.finish_reason,
2568            Some(crate::completion::FinishReason::ToolCalls)
2569        );
2570    }
2571
2572    #[tokio::test]
2573    async fn unknown_stop_reason_survives_onto_the_terminal_record() {
2574        let raw_stream = stream! {
2575            yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse {
2576                stop_reason: Some("pause_turn".to_string()),
2577                ..Default::default()
2578            }));
2579        };
2580
2581        let mut stream = crate::streaming::StreamingCompletionResponse::stream(
2582            "anthropic",
2583            to_stream_result(raw_stream),
2584        );
2585        while stream.next().await.is_some() {}
2586
2587        let terminal = stream.response.expect("expected a terminal record");
2588        assert_eq!(
2589            terminal.finish_reason,
2590            Some(crate::completion::FinishReason::Other(
2591                "pause_turn".to_owned()
2592            ))
2593        );
2594    }
2595
2596    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
2597    mod terminal_emission {
2598        use super::super::super::completion::CLAUDE_SONNET_4_6;
2599        use crate::client::CompletionClient;
2600        use crate::completion::CompletionModel as _;
2601        use crate::providers::anthropic::Client;
2602        use crate::streaming::StreamedAssistantContent;
2603        use crate::test_utils::MockStreamingClient;
2604        use futures::StreamExt;
2605
2606        const MESSAGE_START: &str = r#"{"type":"message_start","message":{"id":"msg_1","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":5,"output_tokens":0}}}"#;
2607        const TEXT_START: &str =
2608            r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#;
2609        const TEXT_DELTA: &str =
2610            r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}"#;
2611        const MESSAGE_DELTA: &str = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":3}}"#;
2612
2613        fn sse(frames: &[&str]) -> bytes::Bytes {
2614            bytes::Bytes::from(
2615                frames
2616                    .iter()
2617                    .map(|frame| format!("data: {frame}\n\n"))
2618                    .collect::<String>(),
2619            )
2620        }
2621
2622        async fn collect(
2623            sse_bytes: bytes::Bytes,
2624        ) -> (
2625            Vec<String>,
2626            bool,
2627            bool,
2628            crate::streaming::StreamingCompletionResponse,
2629        ) {
2630            let client = Client::builder()
2631                .api_key("test-key")
2632                .http_client(MockStreamingClient { sse_bytes })
2633                .build()
2634                .expect("build client");
2635            let model = client.completion_model(CLAUDE_SONNET_4_6);
2636            let request = model.completion_request("hello").build();
2637            let mut stream = crate::completion::CompletionModel::stream(&model, request)
2638                .await
2639                .expect("stream should open");
2640
2641            let mut texts = Vec::new();
2642            let mut saw_error = false;
2643            let mut saw_terminal = false;
2644            while let Some(item) = stream.next().await {
2645                match item {
2646                    Ok(StreamedAssistantContent::Text(text)) => texts.push(text.text),
2647                    Ok(StreamedAssistantContent::Final(_)) => saw_terminal = true,
2648                    Ok(_) => {}
2649                    Err(_) => saw_error = true,
2650                }
2651            }
2652            (texts, saw_error, saw_terminal, stream)
2653        }
2654
2655        #[tokio::test]
2656        async fn truncated_stream_yields_content_but_no_terminal_record() {
2657            let (texts, saw_error, saw_terminal, stream) =
2658                collect(sse(&[MESSAGE_START, TEXT_START, TEXT_DELTA])).await;
2659
2660            assert_eq!(texts, ["hi"]);
2661            assert!(!saw_error);
2662            assert!(
2663                !saw_terminal,
2664                "EOF without message_delta must not synthesize a terminal record"
2665            );
2666            assert!(stream.response.is_none());
2667        }
2668
2669        #[tokio::test]
2670        async fn errored_stream_forwards_the_error_and_no_terminal_record() {
2671            use crate::test_utils::SequencedStreamingHttpClient;
2672
2673            // A transport failure injected into the byte stream after some
2674            // content must be forwarded (via `from_stream_transport`) and must
2675            // not be papered over with a synthesized terminal record.
2676            let client = Client::builder()
2677                .api_key("test-key")
2678                .http_client(SequencedStreamingHttpClient::new(vec![
2679                    Ok(sse(&[MESSAGE_START, TEXT_START, TEXT_DELTA])),
2680                    Err(crate::http_client::Error::InvalidStatusCodeWithMessage(
2681                        http::StatusCode::BAD_GATEWAY,
2682                        "connection reset".to_string(),
2683                    )),
2684                ]))
2685                .build()
2686                .expect("build client");
2687            let model = client.completion_model(CLAUDE_SONNET_4_6);
2688            let request = model.completion_request("hello").build();
2689            let mut stream = crate::completion::CompletionModel::stream(&model, request)
2690                .await
2691                .expect("stream should open");
2692
2693            let mut texts = Vec::new();
2694            let mut saw_error = false;
2695            let mut saw_terminal = false;
2696            while let Some(item) = stream.next().await {
2697                match item {
2698                    Ok(StreamedAssistantContent::Text(text)) => texts.push(text.text),
2699                    Ok(StreamedAssistantContent::Final(_)) => saw_terminal = true,
2700                    Ok(_) => {}
2701                    Err(_) => saw_error = true,
2702                }
2703            }
2704
2705            assert_eq!(texts, ["hi"]);
2706            assert!(saw_error, "the transport failure must reach the consumer");
2707            assert!(
2708                !saw_terminal,
2709                "a failed stream must not synthesize a terminal record"
2710            );
2711            assert!(stream.response.is_none());
2712        }
2713
2714        #[tokio::test]
2715        async fn provider_error_event_stops_the_stream_before_a_later_terminal() {
2716            // The findings-file probe: an in-band provider `error` event
2717            // followed by a well-formed `message_delta`. The error must reach
2718            // the consumer and NOTHING may follow it — the adapter is
2719            // finished, so the later terminal frame must not be interpreted
2720            // into a successful FinalResponse.
2721            const ERROR_EVENT: &str =
2722                r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#;
2723            let (texts, saw_error, saw_terminal, stream) = collect(sse(&[
2724                MESSAGE_START,
2725                TEXT_START,
2726                TEXT_DELTA,
2727                ERROR_EVENT,
2728                MESSAGE_DELTA,
2729            ]))
2730            .await;
2731
2732            assert_eq!(texts, ["hi"]);
2733            assert!(saw_error, "the provider error must reach the consumer");
2734            assert!(
2735                !saw_terminal,
2736                "a message_delta after an in-band provider error must not read as a completed turn"
2737            );
2738            assert!(stream.response.is_none());
2739        }
2740
2741        /// `input_tokens` precedence between `message_start` and the terminal
2742        /// `message_delta`, across all three wire splits at once.
2743        ///
2744        /// Not a cassette test: one recording can only witness whichever split
2745        /// the endpoint it was recorded against happens to use, and the defect
2746        /// here is the *precedence rule* relating three of them — the gateway
2747        /// split, Anthropic proper, and the inverse. The gateway split is also
2748        /// covered end-to-end by the recorded
2749        /// `anthropic::cassette::streaming::gateway_reports_input_tokens_on_message_delta`;
2750        /// this pins the two cases a single recording structurally cannot show
2751        /// beside it.
2752        #[tokio::test]
2753        async fn input_tokens_prefer_the_terminal_delta_and_fall_back_to_message_start() {
2754            fn message_start(input_tokens: usize) -> String {
2755                format!(
2756                    r#"{{"type":"message_start","message":{{"id":"msg_1","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"stop_sequence":null,"usage":{{"input_tokens":{input_tokens},"output_tokens":0}}}}}}"#
2757                )
2758            }
2759            fn message_delta(input_tokens: usize) -> String {
2760                format!(
2761                    r#"{{"type":"message_delta","delta":{{"stop_reason":"end_turn","stop_sequence":null}},"usage":{{"input_tokens":{input_tokens},"output_tokens":3}}}}"#
2762                )
2763            }
2764
2765            for (start, delta, expected, case) in [
2766                // OpenRouter's Anthropic Messages shape: `message_start`
2767                // reports a placeholder zero and the real prompt size lands on
2768                // the terminal `message_delta`.
2769                (
2770                    message_start(0),
2771                    message_delta(9),
2772                    9,
2773                    "a gateway reporting the prompt size on message_delta must reach the consumer",
2774                ),
2775                // A delta that omits `input_tokens` entirely — the Bedrock-compat
2776                // and older/leaner shapes. (Not current Anthropic, which sends
2777                // the count on both frames; that case is the one below, since
2778                // the two always agree.)
2779                (
2780                    message_start(5),
2781                    MESSAGE_DELTA.to_owned(),
2782                    5,
2783                    "a delta without input_tokens falls back to message_start",
2784                ),
2785                // Anthropic proper: both frames carry the same count.
2786                (
2787                    message_start(5),
2788                    message_delta(5),
2789                    5,
2790                    "agreeing frames report that count",
2791                ),
2792                // The inverse split: a zero on the delta must not erase the
2793                // real count `message_start` already gave us.
2794                (
2795                    message_start(5),
2796                    message_delta(0),
2797                    5,
2798                    "a zero on the delta must not erase the message_start count",
2799                ),
2800            ] {
2801                let (_texts, _saw_error, saw_terminal, stream) =
2802                    collect(sse(&[&start, TEXT_START, TEXT_DELTA, &delta])).await;
2803
2804                assert!(saw_terminal, "{case}: the turn must complete");
2805                let terminal = stream.response.expect("terminal record");
2806                assert_eq!(terminal.usage.input_tokens, expected, "{case}");
2807            }
2808        }
2809
2810        #[tokio::test]
2811        async fn malformed_frame_then_eof_yields_error_and_no_terminal_record() {
2812            let (texts, saw_error, saw_terminal, stream) =
2813                collect(sse(&[MESSAGE_START, TEXT_START, TEXT_DELTA, "{not json"])).await;
2814
2815            assert_eq!(texts, ["hi"]);
2816            assert!(saw_error, "the malformed frame must reach the consumer");
2817            assert!(
2818                !saw_terminal,
2819                "a parse error followed by EOF must not read as a completed turn"
2820            );
2821            assert!(stream.response.is_none());
2822        }
2823
2824        #[tokio::test]
2825        async fn malformed_frame_then_real_terminal_still_completes_the_stream() {
2826            let (texts, saw_error, saw_terminal, stream) = collect(sse(&[
2827                MESSAGE_START,
2828                TEXT_START,
2829                TEXT_DELTA,
2830                "{not json",
2831                MESSAGE_DELTA,
2832            ]))
2833            .await;
2834
2835            assert_eq!(texts, ["hi"]);
2836            assert!(saw_error, "the malformed frame must reach the consumer");
2837            assert!(
2838                saw_terminal,
2839                "a genuine message_delta after a parse error still completes the stream"
2840            );
2841            let terminal = stream.response.expect("terminal record");
2842            assert_eq!(
2843                terminal.finish_reason,
2844                Some(crate::completion::FinishReason::Stop)
2845            );
2846            assert_eq!(terminal.message_id.as_deref(), Some("msg_1"));
2847        }
2848
2849        /// Raw capture on the streaming terminal, through the real
2850        /// `CompletionModel::stream` seam over the mock transport:
2851        /// `normalize_stream` serializes the terminal before mapping it, so
2852        /// the terminal `StreamFinal.raw` is Anthropic's own
2853        /// `StreamingCompletionResponse`. A `message_delta` with
2854        /// `stop_sequence` set is used because the normalized terminal folds
2855        /// it into `FinishReason::Stop` and keeps neither Anthropic's spelling
2856        /// nor which sequence fired — both are readable only off the capture.
2857        #[tokio::test]
2858        async fn terminal_raw_round_trips_into_the_terminal_type() {
2859            const STOP_SEQUENCE_DELTA: &str = r#"{"type":"message_delta","delta":{"stop_reason":"stop_sequence","stop_sequence":"alpha"},"usage":{"output_tokens":3}}"#;
2860
2861            let client = Client::builder()
2862                .api_key("test-key")
2863                .http_client(MockStreamingClient {
2864                    sse_bytes: sse(&[MESSAGE_START, TEXT_START, TEXT_DELTA, STOP_SEQUENCE_DELTA]),
2865                })
2866                .build()
2867                .expect("build client");
2868            let model = client.completion_model(CLAUDE_SONNET_4_6);
2869            let request = model.completion_request("hello").build();
2870            let mut stream = crate::completion::CompletionModel::stream(&model, request)
2871                .await
2872                .expect("stream should open");
2873            while let Some(item) = stream.next().await {
2874                item.expect("stream item");
2875            }
2876            let terminal = stream.response.expect("terminal record");
2877
2878            let raw = &terminal.raw;
2879            let typed: super::super::StreamingCompletionResponse =
2880                serde_json::from_value(raw.clone()).expect("raw must deserialize");
2881            assert_eq!(
2882                serde_json::to_value(&typed).expect("re-serialize"),
2883                *raw,
2884                "the capture must be exactly what the terminal type serializes to"
2885            );
2886            assert_eq!(typed.stop_reason.as_deref(), Some("stop_sequence"));
2887            assert_eq!(typed.stop_sequence.as_deref(), Some("alpha"));
2888            assert_eq!(typed.message_id.as_deref(), Some("msg_1"));
2889
2890            // Re-normalizing the capture tells the same story as the terminal
2891            // the stream produced.
2892            let renormalized = crate::streaming::StreamFinal::from(("anthropic", typed));
2893            assert_eq!(terminal.identity(), renormalized.identity());
2894            assert_eq!(terminal.finish_reason, renormalized.finish_reason);
2895            assert_eq!(terminal.model, renormalized.model);
2896            assert_eq!(terminal.usage, renormalized.usage);
2897            assert_eq!(
2898                terminal.finish_reason,
2899                Some(crate::completion::FinishReason::Stop)
2900            );
2901            assert_eq!(terminal.usage.output_tokens, 3);
2902        }
2903    }
2904}