Skip to main content

rig_core/providers/anthropic/
streaming.rs

1use async_stream::stream;
2use futures::StreamExt;
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use tracing::{Level, enabled, info_span};
6use tracing_futures::Instrument;
7
8use super::completion::{
9    AnthropicCompatibleProvider, AnthropicCompletionRequest, AnthropicRequestParams, CacheTtl,
10    Content, GenericCompletionModel, Usage,
11};
12use crate::completion::{CompletionError, CompletionRequest, GetTokenUsage};
13use crate::http_client::sse::{Event, GenericEventSource};
14use crate::http_client::{self, HttpClientExt};
15use crate::message::ReasoningContent;
16use crate::streaming::{
17    self, RawStreamingChoice, RawStreamingToolCall, StreamingResult, ToolCallDeltaContent,
18};
19use crate::telemetry::SpanCombinator;
20use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
21use std::collections::HashMap;
22
23/// Build the Anthropic streaming request body.
24///
25/// Derives it from the *same* typed [`AnthropicCompletionRequest`] the blocking
26/// path builds (in `completion.rs`), rather than re-assembling the body by hand.
27/// The previous hand-rolled `json!` body had drifted from the blocking one and
28/// silently dropped `output_schema` (structured-output config); reaching for the
29/// typed request fixes that and keeps the two in lockstep. The one intentional
30/// streaming-only difference — an explicit `tool_choice: auto` when tools are
31/// advertised but the caller left the choice unset — is re-applied below so the
32/// streaming request bytes stay stable.
33fn create_streaming_request_body(
34    request_model: String,
35    mut completion_request: CompletionRequest,
36    max_tokens: u64,
37    prompt_caching: bool,
38    automatic_caching: bool,
39    automatic_caching_ttl: Option<CacheTtl>,
40) -> Result<Value, CompletionError> {
41    // The typed request's `TryFrom` requires `max_tokens`; feed it the value the
42    // caller already resolved (the request's own value, else the model default).
43    completion_request.max_tokens = Some(max_tokens);
44
45    let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
46        model: &request_model,
47        request: completion_request,
48        prompt_caching,
49        automatic_caching,
50        automatic_caching_ttl,
51    })?;
52
53    let mut body = serde_json::to_value(&request)?;
54    if let Some(map) = body.as_object_mut() {
55        // `AnthropicCompletionRequest` has no `stream` field (the blocking path
56        // omits it, defaulting to non-streaming); set it for the streaming endpoint.
57        map.insert("stream".to_string(), Value::Bool(true));
58
59        // Preserve the streaming path's long-standing `tool_choice` shape, which
60        // emitted `tool_choice` *iff* a non-empty tool set was advertised (Anthropic
61        // rejects `tool_choice` without `tools`). The blocking typed request instead
62        // serializes any caller-set `tool_choice` regardless of tools and omits it
63        // when unset, so reconcile here:
64        //   - tools present, choice unset -> add the explicit `auto` the streaming
65        //     wire has always carried (equivalent to Anthropic's default);
66        //   - tools absent -> drop a caller-set `tool_choice` that would otherwise
67        //     be sent without `tools` and rejected.
68        if map.contains_key("tools") {
69            map.entry("tool_choice")
70                .or_insert_with(|| json!({ "type": "auto" }));
71        } else {
72            map.remove("tool_choice");
73        }
74    }
75
76    Ok(body)
77}
78
79#[derive(Debug, Deserialize)]
80#[serde(tag = "type", rename_all = "snake_case")]
81pub enum StreamingEvent {
82    MessageStart {
83        message: MessageStart,
84    },
85    ContentBlockStart {
86        index: usize,
87        content_block: Content,
88    },
89    ContentBlockDelta {
90        index: usize,
91        delta: ContentDelta,
92    },
93    ContentBlockStop {
94        index: usize,
95    },
96    MessageDelta {
97        delta: MessageDelta,
98        usage: PartialUsage,
99    },
100    MessageStop,
101    Ping,
102    #[serde(other)]
103    Unknown,
104}
105
106#[derive(Debug, Deserialize)]
107pub struct MessageStart {
108    pub id: String,
109    pub role: String,
110    pub content: Vec<Content>,
111    pub model: String,
112    pub stop_reason: Option<String>,
113    pub stop_sequence: Option<String>,
114    pub usage: Usage,
115}
116
117#[derive(Debug, Deserialize)]
118#[serde(tag = "type", rename_all = "snake_case")]
119pub enum ContentDelta {
120    TextDelta {
121        text: String,
122    },
123    InputJsonDelta {
124        partial_json: String,
125    },
126    ThinkingDelta {
127        thinking: String,
128    },
129    SignatureDelta {
130        signature: String,
131    },
132    CitationsDelta {
133        citation: super::completion::Citation,
134    },
135    /// Forward-compatibility fallback. Any delta type Anthropic adds in the
136    /// future that this crate does not yet model deserializes here so the
137    /// surrounding [`StreamingEvent`] still parses.
138    #[serde(other)]
139    Unknown,
140}
141
142#[derive(Debug, Deserialize)]
143pub struct MessageDelta {
144    pub stop_reason: Option<String>,
145    pub stop_sequence: Option<String>,
146}
147
148#[derive(Debug, Deserialize, Clone, Serialize, Default)]
149pub struct PartialUsage {
150    pub output_tokens: usize,
151    #[serde(default)]
152    pub input_tokens: Option<usize>,
153    #[serde(default)]
154    pub cache_creation_input_tokens: Option<u64>,
155    #[serde(default)]
156    pub cache_read_input_tokens: Option<u64>,
157}
158
159impl GetTokenUsage for PartialUsage {
160    fn token_usage(&self) -> crate::completion::Usage {
161        let mut usage = crate::completion::Usage::new();
162
163        usage.input_tokens = self.input_tokens.unwrap_or_default() as u64;
164        usage.output_tokens = self.output_tokens as u64;
165        usage.cached_input_tokens = self.cache_read_input_tokens.unwrap_or(0);
166        usage.cache_creation_input_tokens = self.cache_creation_input_tokens.unwrap_or(0);
167        usage.total_tokens = usage.input_tokens
168            + usage.cached_input_tokens
169            + usage.cache_creation_input_tokens
170            + usage.output_tokens;
171        usage
172    }
173}
174
175#[derive(Default)]
176struct ToolCallState {
177    name: String,
178    id: String,
179    internal_call_id: String,
180    input_json: String,
181}
182
183struct ServerToolUseState {
184    name: String,
185    id: String,
186    initial_input: Value,
187    input_json: String,
188}
189
190#[derive(Default)]
191struct ThinkingState {
192    thinking: String,
193    signature: String,
194}
195
196#[derive(Clone, Debug, Deserialize, Serialize)]
197pub struct StreamingCompletionResponse {
198    pub usage: PartialUsage,
199}
200
201impl GetTokenUsage for StreamingCompletionResponse {
202    fn token_usage(&self) -> crate::completion::Usage {
203        let mut usage = crate::completion::Usage::new();
204        usage.input_tokens = self.usage.input_tokens.unwrap_or(0) as u64;
205        usage.output_tokens = self.usage.output_tokens as u64;
206        usage.cached_input_tokens = self.usage.cache_read_input_tokens.unwrap_or(0);
207        usage.cache_creation_input_tokens = self.usage.cache_creation_input_tokens.unwrap_or(0);
208        usage.total_tokens = usage.input_tokens
209            + usage.cached_input_tokens
210            + usage.cache_creation_input_tokens
211            + usage.output_tokens;
212
213        usage
214    }
215}
216
217impl<Ext, T> GenericCompletionModel<Ext, T>
218where
219    T: HttpClientExt + Clone + Default + 'static,
220    Ext: AnthropicCompatibleProvider + Clone + WasmCompatSend + WasmCompatSync + 'static,
221{
222    pub(crate) async fn stream(
223        &self,
224        completion_request: CompletionRequest,
225    ) -> Result<streaming::StreamingCompletionResponse<StreamingCompletionResponse>, CompletionError>
226    {
227        let request_model = completion_request
228            .model
229            .clone()
230            .unwrap_or_else(|| self.model.clone());
231        let span = if tracing::Span::current().is_disabled() {
232            info_span!(
233                target: "rig::completions",
234                "chat_streaming",
235                gen_ai.operation.name = "chat_streaming",
236                gen_ai.provider.name = Ext::PROVIDER_NAME,
237                gen_ai.request.model = &request_model,
238                gen_ai.system_instructions = &completion_request.preamble,
239                gen_ai.response.id = tracing::field::Empty,
240                gen_ai.response.model = &request_model,
241                gen_ai.usage.output_tokens = tracing::field::Empty,
242                gen_ai.usage.input_tokens = tracing::field::Empty,
243                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
244                gen_ai.usage.cache_creation.input_tokens = tracing::field::Empty,
245                gen_ai.input.messages = tracing::field::Empty,
246                gen_ai.output.messages = tracing::field::Empty,
247            )
248        } else {
249            tracing::Span::current()
250        };
251        let max_tokens = if let Some(tokens) = completion_request.max_tokens {
252            tokens
253        } else if let Some(tokens) = self.default_max_tokens {
254            tokens
255        } else {
256            return Err(CompletionError::RequestError(
257                "`max_tokens` must be set for Anthropic".into(),
258            ));
259        };
260
261        let body = create_streaming_request_body(
262            request_model,
263            completion_request,
264            max_tokens,
265            self.prompt_caching,
266            self.automatic_caching,
267            self.automatic_caching_ttl.clone(),
268        )?;
269
270        if enabled!(Level::TRACE) {
271            tracing::trace!(
272                target: "rig::completions",
273                "Anthropic completion request: {}",
274                serde_json::to_string_pretty(&body)?
275            );
276        }
277
278        let body: Vec<u8> = serde_json::to_vec(&body)?;
279
280        let req = self
281            .client
282            .post("/v1/messages")?
283            .body(body)
284            .map_err(http_client::Error::Protocol)?;
285
286        let stream = GenericEventSource::new(self.client.clone(), req);
287
288        // Use our SSE decoder to directly handle Server-Sent Events format
289        let stream: StreamingResult<StreamingCompletionResponse> = Box::pin(stream! {
290            let mut current_tool_call: Option<ToolCallState> = None;
291            let mut server_tool_uses: HashMap<usize, ServerToolUseState> = HashMap::new();
292            let mut current_thinking: Option<ThinkingState> = None;
293            let mut sse_stream = Box::pin(stream);
294            let mut input_tokens = 0;
295            let mut final_usage = None;
296
297            let mut text_content = String::new();
298
299            while let Some(sse_result) = sse_stream.next().await {
300                match sse_result {
301                    Ok(Event::Open) => {}
302                    Ok(Event::Message(sse)) => {
303                        // Parse the SSE data as a StreamingEvent
304                        match serde_json::from_str::<StreamingEvent>(&sse.data) {
305                            Ok(event) => {
306                                match &event {
307                                    StreamingEvent::MessageStart { message } => {
308                                        input_tokens = message.usage.input_tokens;
309
310                                        let span = tracing::Span::current();
311                                        span.record("gen_ai.response.id", &message.id);
312                                        span.record("gen_ai.response.model", &message.model);
313                                    },
314                                    StreamingEvent::MessageDelta { delta, usage } => {
315                                        if delta.stop_reason.is_some() {
316                                            // cache_creation_input_tokens and cache_read_input_tokens
317                                            // are cumulative totals on message_delta.usage per the
318                                            // Anthropic streaming API spec — use them directly.
319                                            let usage = PartialUsage {
320                                                 output_tokens: usage.output_tokens,
321                                                 input_tokens: usize::try_from(input_tokens).ok(),
322                                                 cache_creation_input_tokens: usage.cache_creation_input_tokens,
323                                                 cache_read_input_tokens: usage.cache_read_input_tokens
324                                            };
325
326                                            let span = tracing::Span::current();
327                                            span.record_token_usage(&usage);
328                                            final_usage = Some(usage);
329                                            break;
330                                        }
331                                    }
332                                    _ => {}
333                                }
334
335                                if let Some(result) = handle_event(
336                                    &event,
337                                    &mut current_tool_call,
338                                    &mut server_tool_uses,
339                                    &mut current_thinking,
340                                ) {
341                                    if let Ok(RawStreamingChoice::Message(ref text)) = result {
342                                        text_content += text;
343                                    }
344                                    yield result;
345                                }
346                            },
347                            Err(e) => {
348                                if !sse.data.trim().is_empty() {
349                                    yield Err(CompletionError::ResponseError(
350                                        format!("Failed to parse JSON: {} (Data: {})", e, sse.data)
351                                    ));
352                                }
353                            }
354                        }
355                    },
356                    Err(e) => {
357                        yield Err(CompletionError::from_stream_transport(e));
358                        break;
359                    }
360                }
361            }
362
363            // Ensure event source is closed when stream ends
364            sse_stream.close();
365
366            yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse {
367                usage: final_usage.unwrap_or_default()
368            }))
369        }.instrument(span));
370
371        Ok(streaming::StreamingCompletionResponse::stream(stream))
372    }
373}
374
375fn handle_event(
376    event: &StreamingEvent,
377    current_tool_call: &mut Option<ToolCallState>,
378    server_tool_uses: &mut HashMap<usize, ServerToolUseState>,
379    current_thinking: &mut Option<ThinkingState>,
380) -> Option<Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>> {
381    match event {
382        StreamingEvent::ContentBlockDelta { index, delta } => match delta {
383            ContentDelta::TextDelta { text } => {
384                if current_tool_call.is_none() {
385                    return Some(Ok(RawStreamingChoice::Message(text.clone())));
386                }
387                None
388            }
389            ContentDelta::InputJsonDelta { partial_json } => {
390                if let Some(server_tool_use) = server_tool_uses.get_mut(index) {
391                    server_tool_use.input_json.push_str(partial_json);
392                    return None;
393                }
394
395                if let Some(tool_call) = current_tool_call {
396                    tool_call.input_json.push_str(partial_json);
397                    // Emit the delta so UI can show progress
398                    return Some(Ok(RawStreamingChoice::ToolCallDelta {
399                        id: tool_call.id.clone(),
400                        internal_call_id: tool_call.internal_call_id.clone(),
401                        content: ToolCallDeltaContent::Delta(partial_json.clone()),
402                    }));
403                }
404                None
405            }
406            ContentDelta::ThinkingDelta { thinking } => {
407                current_thinking
408                    .get_or_insert_with(ThinkingState::default)
409                    .thinking
410                    .push_str(thinking);
411
412                Some(Ok(RawStreamingChoice::ReasoningDelta {
413                    id: None,
414                    reasoning: thinking.clone(),
415                }))
416            }
417            ContentDelta::SignatureDelta { signature } => {
418                current_thinking
419                    .get_or_insert_with(ThinkingState::default)
420                    .signature
421                    .push_str(signature);
422
423                // Don't yield signature chunks, they will be included in the final Reasoning
424                None
425            }
426            ContentDelta::CitationsDelta { citation } => {
427                Some(Ok(RawStreamingChoice::TextAdditionalParams(json!({
428                    "citations": [citation]
429                }))))
430            }
431            ContentDelta::Unknown => None,
432        },
433        StreamingEvent::ContentBlockStart {
434            index,
435            content_block,
436        } => match content_block {
437            Content::Text { citations, .. } => {
438                let additional_params = (!citations.is_empty()).then(|| {
439                    json!({
440                        "citations": citations
441                    })
442                });
443                Some(Ok(RawStreamingChoice::TextStart { additional_params }))
444            }
445            Content::ServerToolUse { id, name, input } => {
446                server_tool_uses.insert(
447                    *index,
448                    ServerToolUseState {
449                        name: name.clone(),
450                        id: id.clone(),
451                        initial_input: input.clone(),
452                        input_json: String::new(),
453                    },
454                );
455                None
456            }
457            raw @ Content::WebSearchToolResult { .. } => Some(Ok(RawStreamingChoice::TextStart {
458                additional_params: Some(json!({
459                    super::completion::ANTHROPIC_RAW_CONTENT_KEY: raw
460                })),
461            })),
462            Content::ToolUse { id, name, .. } => {
463                let internal_call_id = crate::id::generate();
464                *current_tool_call = Some(ToolCallState {
465                    name: name.clone(),
466                    id: id.clone(),
467                    internal_call_id: internal_call_id.clone(),
468                    input_json: String::new(),
469                });
470                Some(Ok(RawStreamingChoice::ToolCallDelta {
471                    id: id.clone(),
472                    internal_call_id,
473                    content: ToolCallDeltaContent::Name(name.clone()),
474                }))
475            }
476            Content::Thinking { .. } => {
477                *current_thinking = Some(ThinkingState::default());
478                None
479            }
480            Content::RedactedThinking { data } => Some(Ok(RawStreamingChoice::Reasoning {
481                id: None,
482                content: ReasoningContent::Redacted { data: data.clone() },
483            })),
484            // Handle other content types - they don't need special handling
485            _ => None,
486        },
487        StreamingEvent::ContentBlockStop { index } => {
488            if let Some(thinking_state) = Option::take(current_thinking)
489                && !thinking_state.thinking.is_empty()
490            {
491                let signature = if thinking_state.signature.is_empty() {
492                    None
493                } else {
494                    Some(thinking_state.signature)
495                };
496
497                return Some(Ok(RawStreamingChoice::Reasoning {
498                    id: None,
499                    content: ReasoningContent::Text {
500                        text: thinking_state.thinking,
501                        signature,
502                    },
503                }));
504            }
505
506            if let Some(server_tool_use) = server_tool_uses.remove(index) {
507                let input = if server_tool_use.input_json.is_empty() {
508                    if server_tool_use.initial_input.is_null() {
509                        json!({})
510                    } else {
511                        server_tool_use.initial_input
512                    }
513                } else {
514                    match serde_json::from_str(&server_tool_use.input_json) {
515                        Ok(json_value) => json_value,
516                        Err(e) => return Some(Err(CompletionError::from(e))),
517                    }
518                };
519
520                return Some(Ok(RawStreamingChoice::TextStart {
521                    additional_params: Some(json!({
522                        super::completion::ANTHROPIC_RAW_CONTENT_KEY: Content::ServerToolUse {
523                            id: server_tool_use.id,
524                            name: server_tool_use.name,
525                            input,
526                        }
527                    })),
528                }));
529            }
530
531            if let Some(tool_call) = Option::take(current_tool_call) {
532                let json_str = if tool_call.input_json.is_empty() {
533                    "{}"
534                } else {
535                    &tool_call.input_json
536                };
537                match serde_json::from_str(json_str) {
538                    Ok(json_value) => {
539                        let raw_tool_call =
540                            RawStreamingToolCall::new(tool_call.id, tool_call.name, json_value)
541                                .with_internal_call_id(tool_call.internal_call_id);
542                        Some(Ok(RawStreamingChoice::ToolCall(raw_tool_call)))
543                    }
544                    Err(e) => Some(Err(CompletionError::from(e))),
545                }
546            } else {
547                None
548            }
549        }
550        // Ignore other event types or handle as needed
551        StreamingEvent::MessageStart { .. }
552        | StreamingEvent::MessageDelta { .. }
553        | StreamingEvent::MessageStop
554        | StreamingEvent::Ping
555        | StreamingEvent::Unknown => None,
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::super::completion::{
562        CLAUDE_OPUS_4_8, CacheControl, CacheTtl, Message, SystemContent,
563        apply_prompt_cache_control, build_tool_definitions, resolve_top_level_cache_control,
564    };
565    use super::*;
566    use crate::OneOrMany;
567    use crate::completion::Message as RigMessage;
568    use crate::completion::request::Document as RigDocument;
569    use async_stream::stream;
570    use futures::StreamExt;
571
572    #[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
573    fn to_stream_result(
574        stream: impl futures::Stream<
575            Item = Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>,
576        > + Send
577        + 'static,
578    ) -> crate::streaming::StreamingResult<StreamingCompletionResponse> {
579        Box::pin(stream)
580    }
581
582    #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
583    fn to_stream_result(
584        stream: impl futures::Stream<
585            Item = Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>,
586        > + 'static,
587    ) -> crate::streaming::StreamingResult<StreamingCompletionResponse> {
588        Box::pin(stream)
589    }
590
591    #[test]
592    fn test_streaming_tool_build_marks_final_combined_tool() {
593        let mut additional_params = json!({
594            "tools": [{
595                "name": "provider_tool",
596                "description": "Provider tool",
597                "input_schema": {"type": "object"}
598            }]
599        });
600
601        let mut tools = build_tool_definitions(
602            vec![crate::completion::ToolDefinition {
603                name: "rig_tool".to_string(),
604                description: "Rig tool".to_string(),
605                parameters: json!({"type": "object", "properties": {}}),
606            }],
607            &mut additional_params,
608        )
609        .unwrap();
610        let mut system: Vec<SystemContent> = Vec::new();
611        let mut messages: Vec<Message> = Vec::new();
612        apply_prompt_cache_control(&mut system, &mut messages, &mut tools, true, None).unwrap();
613
614        assert_eq!(tools.len(), 2);
615        assert!(tools[0].get("cache_control").is_none());
616        assert_eq!(tools[1]["name"], "provider_tool");
617        assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
618    }
619
620    #[test]
621    fn streaming_request_keeps_documents_after_leading_system_messages() {
622        let request = CompletionRequest {
623            model: None,
624            preamble: None,
625            chat_history: OneOrMany::many(vec![
626                RigMessage::system("System prompt"),
627                RigMessage::assistant("Earlier assistant turn"),
628                RigMessage::system("Mid-conversation instruction"),
629                RigMessage::user("Prompt"),
630            ])
631            .unwrap(),
632            documents: vec![RigDocument {
633                id: "doc1".to_string(),
634                text: "Document text.".to_string(),
635                additional_props: Default::default(),
636            }],
637            tools: vec![],
638            temperature: None,
639            max_tokens: Some(64),
640            tool_choice: None,
641            additional_params: None,
642            output_schema: None,
643        };
644
645        let body = create_streaming_request_body(
646            CLAUDE_OPUS_4_8.to_string(),
647            request,
648            64,
649            false,
650            false,
651            None,
652        )
653        .expect("streaming request body should build");
654
655        assert_eq!(body["system"][0]["text"], "System prompt");
656        assert_eq!(body["system"][1]["text"], "Mid-conversation instruction");
657        let messages = body["messages"]
658            .as_array()
659            .expect("messages should be array");
660        assert_eq!(messages.len(), 3);
661        assert_eq!(messages[0]["role"], "user");
662        assert!(
663            messages[0].to_string().contains("<file id: doc1>"),
664            "document message should follow top-level system: {messages:?}"
665        );
666        assert_eq!(messages[1]["role"], "assistant");
667        assert_eq!(messages[2]["role"], "user");
668        assert_eq!(
669            messages
670                .iter()
671                .filter(|message| message.to_string().contains("<file id: doc1>"))
672                .count(),
673            1,
674            "document message should appear exactly once: {messages:?}"
675        );
676    }
677
678    #[test]
679    fn streaming_body_is_blocking_body_plus_stream_flag_and_carries_output_schema() {
680        let schema: schemars::Schema = serde_json::from_value(json!({
681            "title": "WeatherResponse",
682            "type": "object",
683            "properties": { "city": { "type": "string" } }
684        }))
685        .expect("schema should deserialize");
686
687        let request = CompletionRequest {
688            model: None,
689            preamble: Some("You are helpful".to_string()),
690            chat_history: OneOrMany::one(RigMessage::user("What's the weather?")),
691            documents: vec![],
692            tools: vec![],
693            temperature: Some(0.5),
694            max_tokens: Some(64),
695            tool_choice: None,
696            additional_params: None,
697            output_schema: Some(schema),
698        };
699
700        let streaming_body = create_streaming_request_body(
701            CLAUDE_OPUS_4_8.to_string(),
702            request.clone(),
703            64,
704            false,
705            false,
706            None,
707        )
708        .expect("streaming request body should build");
709
710        // The streaming endpoint flag is set.
711        assert_eq!(streaming_body["stream"], serde_json::Value::Bool(true));
712
713        // Regression: `output_schema` now reaches the streaming wire as
714        // `output_config` (the hand-rolled body dropped it entirely, so this
715        // assertion would have failed before the typed-request unification).
716        assert_eq!(
717            streaming_body["output_config"]["format"]["type"],
718            "json_schema"
719        );
720        assert!(
721            streaming_body["output_config"]["format"]["schema"].is_object(),
722            "streaming body must carry the structured-output schema: {streaming_body}"
723        );
724
725        // Unification invariant: the streaming body is exactly the blocking body
726        // (built via the same typed request) plus `stream: true`. Pins the two
727        // wire formats together so a future edit can't reintroduce drift.
728        let blocking = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
729            model: CLAUDE_OPUS_4_8,
730            request,
731            prompt_caching: false,
732            automatic_caching: false,
733            automatic_caching_ttl: None,
734        })
735        .expect("blocking request body should build");
736        let mut expected = serde_json::to_value(&blocking).expect("serialize blocking body");
737        expected
738            .as_object_mut()
739            .expect("body is an object")
740            .insert("stream".to_string(), serde_json::Value::Bool(true));
741
742        assert_eq!(streaming_body, expected);
743    }
744
745    #[test]
746    fn streaming_body_keeps_explicit_tool_choice_auto_when_tools_present_but_unset() {
747        let request = CompletionRequest {
748            model: None,
749            preamble: None,
750            chat_history: OneOrMany::one(RigMessage::user("Add 2 and 3")),
751            documents: vec![],
752            tools: vec![crate::completion::ToolDefinition {
753                name: "add".to_string(),
754                description: "Add x and y".to_string(),
755                parameters: json!({
756                    "type": "object",
757                    "properties": { "x": { "type": "integer" } }
758                }),
759            }],
760            temperature: None,
761            max_tokens: Some(64),
762            tool_choice: None,
763            additional_params: None,
764            output_schema: None,
765        };
766
767        let body = create_streaming_request_body(
768            CLAUDE_OPUS_4_8.to_string(),
769            request,
770            64,
771            false,
772            false,
773            None,
774        )
775        .expect("streaming request body should build");
776
777        // Tools advertised + `tool_choice` unset must still carry the explicit
778        // `auto` the streaming wire format has always sent (parity with recorded
779        // fixtures), even though the blocking typed request omits it.
780        assert_eq!(body["tool_choice"], json!({ "type": "auto" }));
781        assert!(body["tools"].is_array());
782    }
783
784    #[test]
785    fn streaming_body_drops_tool_choice_when_no_tools_are_advertised() {
786        // The typed request serializes a caller-set `tool_choice` regardless of
787        // whether tools are present, but the streaming path has always emitted
788        // `tool_choice` *only* alongside a non-empty tool set (Anthropic rejects it
789        // otherwise). A `tool_choice` set with no tools must not reach the wire.
790        let request = CompletionRequest {
791            model: None,
792            preamble: None,
793            chat_history: OneOrMany::one(RigMessage::user("Hi")),
794            documents: vec![],
795            tools: vec![],
796            temperature: None,
797            max_tokens: Some(64),
798            tool_choice: Some(crate::message::ToolChoice::Auto),
799            additional_params: None,
800            output_schema: None,
801        };
802
803        let body = create_streaming_request_body(
804            CLAUDE_OPUS_4_8.to_string(),
805            request,
806            64,
807            false,
808            false,
809            None,
810        )
811        .expect("streaming request body should build");
812
813        assert!(
814            body.get("tool_choice").is_none(),
815            "tool_choice must be omitted when no tools are advertised: {body}"
816        );
817        assert!(body.get("tools").is_none());
818    }
819
820    #[test]
821    fn test_streaming_prompt_cache_control_uses_raw_top_level_ttl() {
822        let mut additional_params = json!({
823            "cache_control": {"type": "ephemeral", "ttl": "1h"}
824        });
825        let top_level_cache_control =
826            resolve_top_level_cache_control(false, None, &mut additional_params).unwrap();
827        let mut tools = build_tool_definitions(
828            vec![crate::completion::ToolDefinition {
829                name: "rig_tool".to_string(),
830                description: "Rig tool".to_string(),
831                parameters: json!({"type": "object", "properties": {}}),
832            }],
833            &mut additional_params,
834        )
835        .unwrap();
836        let mut system = vec![SystemContent::Text {
837            text: "System prompt".to_string(),
838            cache_control: None,
839        }];
840        let mut messages: Vec<Message> = Vec::new();
841
842        apply_prompt_cache_control(
843            &mut system,
844            &mut messages,
845            &mut tools,
846            true,
847            top_level_cache_control.as_ref(),
848        )
849        .unwrap();
850
851        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
852        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
853        match &system[0] {
854            SystemContent::Text {
855                cache_control: Some(CacheControl::Ephemeral { ttl }),
856                ..
857            } => assert_eq!(ttl.as_ref(), Some(&CacheTtl::OneHour)),
858            other => panic!("expected system cache_control, got {other:?}"),
859        }
860        assert!(additional_params.get("cache_control").is_none());
861    }
862
863    fn handle_event(
864        event: &StreamingEvent,
865        current_tool_call: &mut Option<ToolCallState>,
866        current_thinking: &mut Option<ThinkingState>,
867    ) -> Option<Result<RawStreamingChoice<StreamingCompletionResponse>, CompletionError>> {
868        let mut server_tool_uses = HashMap::new();
869        super::handle_event(
870            event,
871            current_tool_call,
872            &mut server_tool_uses,
873            current_thinking,
874        )
875    }
876
877    #[test]
878    fn test_thinking_delta_deserialization() {
879        let json = r#"{"type": "thinking_delta", "thinking": "Let me think about this..."}"#;
880        let delta: ContentDelta = serde_json::from_str(json).unwrap();
881
882        match delta {
883            ContentDelta::ThinkingDelta { thinking } => {
884                assert_eq!(thinking, "Let me think about this...");
885            }
886            _ => panic!("Expected ThinkingDelta variant"),
887        }
888    }
889
890    #[test]
891    fn test_signature_delta_deserialization() {
892        let json = r#"{"type": "signature_delta", "signature": "abc123def456"}"#;
893        let delta: ContentDelta = serde_json::from_str(json).unwrap();
894
895        match delta {
896            ContentDelta::SignatureDelta { signature } => {
897                assert_eq!(signature, "abc123def456");
898            }
899            _ => panic!("Expected SignatureDelta variant"),
900        }
901    }
902
903    #[test]
904    fn test_thinking_delta_streaming_event_deserialization() {
905        let json = r#"{
906            "type": "content_block_delta",
907            "index": 0,
908            "delta": {
909                "type": "thinking_delta",
910                "thinking": "First, I need to understand the problem."
911            }
912        }"#;
913
914        let event: StreamingEvent = serde_json::from_str(json).unwrap();
915
916        match event {
917            StreamingEvent::ContentBlockDelta { index, delta } => {
918                assert_eq!(index, 0);
919                match delta {
920                    ContentDelta::ThinkingDelta { thinking } => {
921                        assert_eq!(thinking, "First, I need to understand the problem.");
922                    }
923                    _ => panic!("Expected ThinkingDelta"),
924                }
925            }
926            _ => panic!("Expected ContentBlockDelta event"),
927        }
928    }
929
930    #[test]
931    fn test_signature_delta_streaming_event_deserialization() {
932        let json = r#"{
933            "type": "content_block_delta",
934            "index": 0,
935            "delta": {
936                "type": "signature_delta",
937                "signature": "ErUBCkYICBgCIkCaGbqC85F4"
938            }
939        }"#;
940
941        let event: StreamingEvent = serde_json::from_str(json).unwrap();
942
943        match event {
944            StreamingEvent::ContentBlockDelta { index, delta } => {
945                assert_eq!(index, 0);
946                match delta {
947                    ContentDelta::SignatureDelta { signature } => {
948                        assert_eq!(signature, "ErUBCkYICBgCIkCaGbqC85F4");
949                    }
950                    _ => panic!("Expected SignatureDelta"),
951                }
952            }
953            _ => panic!("Expected ContentBlockDelta event"),
954        }
955    }
956
957    #[test]
958    fn test_handle_thinking_delta_event() {
959        let event = StreamingEvent::ContentBlockDelta {
960            index: 0,
961            delta: ContentDelta::ThinkingDelta {
962                thinking: "Analyzing the request...".to_string(),
963            },
964        };
965
966        let mut tool_call_state = None;
967        let mut thinking_state = None;
968        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
969
970        assert!(result.is_some());
971        let choice = result.unwrap().unwrap();
972
973        match choice {
974            RawStreamingChoice::ReasoningDelta { id, reasoning, .. } => {
975                assert_eq!(id, None);
976                assert_eq!(reasoning, "Analyzing the request...");
977            }
978            _ => panic!("Expected ReasoningDelta choice"),
979        }
980
981        // Verify thinking state was updated
982        assert!(thinking_state.is_some());
983        assert_eq!(thinking_state.unwrap().thinking, "Analyzing the request...");
984    }
985
986    #[test]
987    fn test_handle_signature_delta_event() {
988        let event = StreamingEvent::ContentBlockDelta {
989            index: 0,
990            delta: ContentDelta::SignatureDelta {
991                signature: "test_signature".to_string(),
992            },
993        };
994
995        let mut tool_call_state = None;
996        let mut thinking_state = None;
997        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
998
999        // SignatureDelta should not yield anything (returns None)
1000        assert!(result.is_none());
1001
1002        // But signature should be captured in thinking state
1003        assert!(thinking_state.is_some());
1004        assert_eq!(thinking_state.unwrap().signature, "test_signature");
1005    }
1006
1007    #[test]
1008    fn test_handle_redacted_thinking_content_block_start_event() {
1009        let event = StreamingEvent::ContentBlockStart {
1010            index: 0,
1011            content_block: Content::RedactedThinking {
1012                data: "redacted_blob".to_string(),
1013            },
1014        };
1015        let mut tool_call_state = None;
1016        let mut thinking_state = None;
1017        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1018
1019        assert!(result.is_some());
1020        match result.unwrap().unwrap() {
1021            RawStreamingChoice::Reasoning {
1022                content: ReasoningContent::Redacted { data },
1023                ..
1024            } => {
1025                assert_eq!(data, "redacted_blob");
1026            }
1027            _ => panic!("Expected Redacted reasoning chunk"),
1028        }
1029    }
1030
1031    #[test]
1032    fn test_handle_text_delta_event() {
1033        let event = StreamingEvent::ContentBlockDelta {
1034            index: 0,
1035            delta: ContentDelta::TextDelta {
1036                text: "Hello, world!".to_string(),
1037            },
1038        };
1039
1040        let mut tool_call_state = None;
1041        let mut thinking_state = None;
1042        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1043
1044        assert!(result.is_some());
1045        let choice = result.unwrap().unwrap();
1046
1047        match choice {
1048            RawStreamingChoice::Message(text) => {
1049                assert_eq!(text, "Hello, world!");
1050            }
1051            _ => panic!("Expected Message choice"),
1052        }
1053    }
1054
1055    #[test]
1056    fn test_handle_text_block_start_event() {
1057        let event = StreamingEvent::ContentBlockStart {
1058            index: 0,
1059            content_block: Content::Text {
1060                text: String::new(),
1061                citations: Vec::new(),
1062                cache_control: None,
1063            },
1064        };
1065
1066        let mut tool_call_state = None;
1067        let mut thinking_state = None;
1068        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1069
1070        assert!(result.is_some());
1071        let choice = result.unwrap().unwrap();
1072        assert!(matches!(
1073            choice,
1074            RawStreamingChoice::TextStart {
1075                additional_params: None
1076            }
1077        ));
1078    }
1079
1080    #[test]
1081    fn test_thinking_delta_does_not_interfere_with_tool_calls() {
1082        // Thinking deltas should still be processed even if a tool call is in progress
1083        let event = StreamingEvent::ContentBlockDelta {
1084            index: 0,
1085            delta: ContentDelta::ThinkingDelta {
1086                thinking: "Thinking while tool is active...".to_string(),
1087            },
1088        };
1089
1090        let mut tool_call_state = Some(ToolCallState {
1091            name: "test_tool".to_string(),
1092            id: "tool_123".to_string(),
1093            internal_call_id: crate::id::generate(),
1094            input_json: String::new(),
1095        });
1096        let mut thinking_state = None;
1097
1098        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1099
1100        assert!(result.is_some());
1101        let choice = result.unwrap().unwrap();
1102
1103        match choice {
1104            RawStreamingChoice::ReasoningDelta { reasoning, .. } => {
1105                assert_eq!(reasoning, "Thinking while tool is active...");
1106            }
1107            _ => panic!("Expected ReasoningDelta choice"),
1108        }
1109
1110        // Tool call state should remain unchanged
1111        assert!(tool_call_state.is_some());
1112    }
1113
1114    #[test]
1115    fn test_handle_input_json_delta_event() {
1116        let event = StreamingEvent::ContentBlockDelta {
1117            index: 0,
1118            delta: ContentDelta::InputJsonDelta {
1119                partial_json: "{\"arg\":\"value".to_string(),
1120            },
1121        };
1122
1123        let mut tool_call_state = Some(ToolCallState {
1124            name: "test_tool".to_string(),
1125            id: "tool_123".to_string(),
1126            internal_call_id: crate::id::generate(),
1127            input_json: String::new(),
1128        });
1129        let mut thinking_state = None;
1130
1131        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1132
1133        // Should emit a ToolCallDelta
1134        assert!(result.is_some());
1135        let choice = result.unwrap().unwrap();
1136
1137        match choice {
1138            RawStreamingChoice::ToolCallDelta {
1139                id,
1140                internal_call_id: _,
1141                content,
1142            } => {
1143                assert_eq!(id, "tool_123");
1144                match content {
1145                    ToolCallDeltaContent::Delta(delta) => assert_eq!(delta, "{\"arg\":\"value"),
1146                    _ => panic!("Expected Delta content"),
1147                }
1148            }
1149            _ => panic!("Expected ToolCallDelta choice, got {:?}", choice),
1150        }
1151
1152        // Verify the input_json was accumulated
1153        assert!(tool_call_state.is_some());
1154        let state = tool_call_state.unwrap();
1155        assert_eq!(state.input_json, "{\"arg\":\"value");
1156    }
1157
1158    #[test]
1159    fn test_tool_call_accumulation_with_multiple_deltas() {
1160        let mut tool_call_state = Some(ToolCallState {
1161            name: "test_tool".to_string(),
1162            id: "tool_123".to_string(),
1163            internal_call_id: crate::id::generate(),
1164            input_json: String::new(),
1165        });
1166        let mut thinking_state = None;
1167
1168        // First delta
1169        let event1 = StreamingEvent::ContentBlockDelta {
1170            index: 0,
1171            delta: ContentDelta::InputJsonDelta {
1172                partial_json: "{\"location\":".to_string(),
1173            },
1174        };
1175        let result1 = handle_event(&event1, &mut tool_call_state, &mut thinking_state);
1176        assert!(result1.is_some());
1177
1178        // Second delta
1179        let event2 = StreamingEvent::ContentBlockDelta {
1180            index: 0,
1181            delta: ContentDelta::InputJsonDelta {
1182                partial_json: "\"Paris\",".to_string(),
1183            },
1184        };
1185        let result2 = handle_event(&event2, &mut tool_call_state, &mut thinking_state);
1186        assert!(result2.is_some());
1187
1188        // Third delta
1189        let event3 = StreamingEvent::ContentBlockDelta {
1190            index: 0,
1191            delta: ContentDelta::InputJsonDelta {
1192                partial_json: "\"temp\":\"20C\"}".to_string(),
1193            },
1194        };
1195        let result3 = handle_event(&event3, &mut tool_call_state, &mut thinking_state);
1196        assert!(result3.is_some());
1197
1198        // Verify accumulated JSON
1199        assert!(tool_call_state.is_some());
1200        let state = tool_call_state.as_ref().unwrap();
1201        assert_eq!(
1202            state.input_json,
1203            "{\"location\":\"Paris\",\"temp\":\"20C\"}"
1204        );
1205
1206        // Final ContentBlockStop should emit complete tool call
1207        let stop_event = StreamingEvent::ContentBlockStop { index: 0 };
1208        let final_result = handle_event(&stop_event, &mut tool_call_state, &mut thinking_state);
1209        assert!(final_result.is_some());
1210
1211        match final_result.unwrap().unwrap() {
1212            RawStreamingChoice::ToolCall(RawStreamingToolCall {
1213                id,
1214                name,
1215                arguments,
1216                ..
1217            }) => {
1218                assert_eq!(id, "tool_123");
1219                assert_eq!(name, "test_tool");
1220                assert_eq!(
1221                    arguments.get("location").unwrap().as_str().unwrap(),
1222                    "Paris"
1223                );
1224                assert_eq!(arguments.get("temp").unwrap().as_str().unwrap(), "20C");
1225            }
1226            other => panic!("Expected ToolCall, got {:?}", other),
1227        }
1228
1229        // Tool call state should be taken
1230        assert!(tool_call_state.is_none());
1231    }
1232
1233    #[test]
1234    fn test_citations_delta_streaming_event_deserialization() {
1235        let json = r#"{
1236            "type": "content_block_delta",
1237            "index": 0,
1238            "delta": {
1239                "type": "citations_delta",
1240                "citation": {
1241                    "type": "char_location",
1242                    "cited_text": "The grass is green.",
1243                    "document_index": 0,
1244                    "document_title": "Example",
1245                    "start_char_index": 0,
1246                    "end_char_index": 20
1247                }
1248            }
1249        }"#;
1250
1251        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1252        let StreamingEvent::ContentBlockDelta { index, delta } = event else {
1253            panic!("expected ContentBlockDelta");
1254        };
1255        assert_eq!(index, 0);
1256        let ContentDelta::CitationsDelta { citation } = delta else {
1257            panic!("expected CitationsDelta");
1258        };
1259        let crate::providers::anthropic::completion::Citation::CharLocation {
1260            start_char_index,
1261            end_char_index,
1262            ..
1263        } = citation
1264        else {
1265            panic!("expected CharLocation");
1266        };
1267        assert_eq!(start_char_index, 0);
1268        assert_eq!(end_char_index, 20);
1269    }
1270
1271    #[test]
1272    fn test_search_result_citations_delta_streaming_event_deserialization() {
1273        let json = r#"{
1274            "type": "content_block_delta",
1275            "index": 0,
1276            "delta": {
1277                "type": "citations_delta",
1278                "citation": {
1279                    "type": "search_result_location",
1280                    "cited_text": "API requests require a key.",
1281                    "source": "https://docs.example.com/api-reference",
1282                    "title": "API Reference",
1283                    "search_result_index": 0,
1284                    "start_block_index": 0,
1285                    "end_block_index": 1
1286                }
1287            }
1288        }"#;
1289
1290        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1291        let StreamingEvent::ContentBlockDelta { delta, .. } = event else {
1292            panic!("expected ContentBlockDelta");
1293        };
1294        let ContentDelta::CitationsDelta { citation } = delta else {
1295            panic!("expected CitationsDelta");
1296        };
1297        assert!(matches!(
1298            citation,
1299            crate::providers::anthropic::completion::Citation::SearchResultLocation {
1300                search_result_index: 0,
1301                start_block_index: 0,
1302                end_block_index: 1,
1303                ..
1304            }
1305        ));
1306    }
1307
1308    #[test]
1309    fn test_web_search_result_citations_delta_streaming_event_deserialization() {
1310        let json = r#"{
1311            "type": "content_block_delta",
1312            "index": 0,
1313            "delta": {
1314                "type": "citations_delta",
1315                "citation": {
1316                    "type": "web_search_result_location",
1317                    "cited_text": "Claude Shannon was a mathematician.",
1318                    "url": "https://example.com/shannon",
1319                    "title": "Claude Shannon",
1320                    "encrypted_index": "encrypted-reference"
1321                }
1322            }
1323        }"#;
1324
1325        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1326        let StreamingEvent::ContentBlockDelta { delta, .. } = event else {
1327            panic!("expected ContentBlockDelta");
1328        };
1329        let ContentDelta::CitationsDelta { citation } = delta else {
1330            panic!("expected CitationsDelta");
1331        };
1332        assert!(matches!(
1333            citation,
1334            crate::providers::anthropic::completion::Citation::WebSearchResultLocation {
1335                ref url,
1336                ref encrypted_index,
1337                ..
1338            } if url == "https://example.com/shannon"
1339                && encrypted_index == "encrypted-reference"
1340        ));
1341    }
1342
1343    #[test]
1344    fn test_web_search_result_citations_delta_allows_null_title() {
1345        let json = r#"{
1346            "type": "content_block_delta",
1347            "index": 0,
1348            "delta": {
1349                "type": "citations_delta",
1350                "citation": {
1351                    "type": "web_search_result_location",
1352                    "cited_text": "Claude Shannon was a mathematician.",
1353                    "url": "https://example.com/shannon",
1354                    "title": null,
1355                    "encrypted_index": "encrypted-reference"
1356                }
1357            }
1358        }"#;
1359
1360        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1361        let StreamingEvent::ContentBlockDelta { delta, .. } = event else {
1362            panic!("expected ContentBlockDelta");
1363        };
1364        let ContentDelta::CitationsDelta { citation } = delta else {
1365            panic!("expected CitationsDelta");
1366        };
1367        assert!(matches!(
1368            citation,
1369            crate::providers::anthropic::completion::Citation::WebSearchResultLocation {
1370                title: None,
1371                ..
1372            }
1373        ));
1374    }
1375
1376    #[test]
1377    fn test_text_content_block_start_allows_null_citations() {
1378        // The Anthropic Messages API emits an explicit `"citations": null` on the
1379        // first text `content_block_start` event. `#[serde(default)]` alone covers
1380        // a missing field but not an explicit null, so this must deserialize to an
1381        // empty citation list rather than failing the whole stream (see #1971).
1382        let json = r#"{
1383            "type": "content_block_start",
1384            "index": 0,
1385            "content_block": {
1386                "type": "text",
1387                "text": "",
1388                "citations": null
1389            }
1390        }"#;
1391
1392        let event: StreamingEvent = serde_json::from_str(json).unwrap();
1393        let StreamingEvent::ContentBlockStart { content_block, .. } = event else {
1394            panic!("expected ContentBlockStart");
1395        };
1396        let Content::Text {
1397            text, citations, ..
1398        } = content_block
1399        else {
1400            panic!("expected text content block");
1401        };
1402        assert_eq!(text, "");
1403        assert!(citations.is_empty());
1404    }
1405
1406    #[test]
1407    fn test_web_search_content_block_start_events_deserialize() {
1408        let server_tool_use = r#"{
1409            "type": "content_block_start",
1410            "index": 1,
1411            "content_block": {
1412                "type": "server_tool_use",
1413                "id": "srvtoolu_01",
1414                "name": "web_search",
1415                "input": {
1416                    "query": "claude shannon birth date"
1417                }
1418            }
1419        }"#;
1420        let event: StreamingEvent = serde_json::from_str(server_tool_use).unwrap();
1421        assert!(matches!(
1422            event,
1423            StreamingEvent::ContentBlockStart {
1424                content_block: Content::ServerToolUse {
1425                    ref id,
1426                    ref name,
1427                    ref input
1428                },
1429                ..
1430            } if id == "srvtoolu_01"
1431                && name == "web_search"
1432                && input["query"] == "claude shannon birth date"
1433        ));
1434
1435        let web_search_tool_result = r#"{
1436            "type": "content_block_start",
1437            "index": 2,
1438            "content_block": {
1439                "type": "web_search_tool_result",
1440                "tool_use_id": "srvtoolu_01",
1441                "content": [{
1442                    "type": "web_search_result",
1443                    "url": "https://example.com/shannon",
1444                    "title": "Claude Shannon",
1445                    "encrypted_content": "encrypted-content"
1446                }]
1447            }
1448        }"#;
1449        let event: StreamingEvent = serde_json::from_str(web_search_tool_result).unwrap();
1450        assert!(matches!(
1451            event,
1452            StreamingEvent::ContentBlockStart {
1453                content_block: Content::WebSearchToolResult {
1454                    ref tool_use_id,
1455                    ref content
1456                },
1457                ..
1458            } if tool_use_id == "srvtoolu_01"
1459                && content[0]["encrypted_content"] == "encrypted-content"
1460        ));
1461    }
1462
1463    #[tokio::test]
1464    async fn test_streaming_web_search_blocks_are_preserved_on_final_choice() {
1465        let raw_stream = stream! {
1466            let mut tool_call_state = None;
1467            let mut server_tool_uses = HashMap::new();
1468            let mut thinking_state = None;
1469
1470            let server_tool_use_start = super::handle_event(
1471                &StreamingEvent::ContentBlockStart {
1472                    index: 0,
1473                    content_block: Content::ServerToolUse {
1474                        id: "srvtoolu_01".to_string(),
1475                        name: "web_search".to_string(),
1476                        input: serde_json::Value::Null,
1477                    },
1478                },
1479                &mut tool_call_state,
1480                &mut server_tool_uses,
1481                &mut thinking_state,
1482            );
1483            assert!(
1484                server_tool_use_start.is_none(),
1485                "server_tool_use start should be accumulated until its input JSON is complete"
1486            );
1487
1488            let server_tool_use_delta = super::handle_event(
1489                &StreamingEvent::ContentBlockDelta {
1490                    index: 0,
1491                    delta: ContentDelta::InputJsonDelta {
1492                        partial_json: r#"{"query":"claude shannon birth date"}"#.to_string(),
1493                    },
1494                },
1495                &mut tool_call_state,
1496                &mut server_tool_uses,
1497                &mut thinking_state,
1498            );
1499            assert!(
1500                server_tool_use_delta.is_none(),
1501                "server_tool_use input JSON should not be emitted as a Rig tool-call delta"
1502            );
1503
1504            yield super::handle_event(
1505                &StreamingEvent::ContentBlockStop { index: 0 },
1506                &mut tool_call_state,
1507                &mut server_tool_uses,
1508                &mut thinking_state,
1509            )
1510            .expect("server_tool_use stop should produce completed raw metadata");
1511
1512            yield super::handle_event(
1513                &StreamingEvent::ContentBlockStart {
1514                    index: 1,
1515                    content_block: Content::WebSearchToolResult {
1516                        tool_use_id: "srvtoolu_01".to_string(),
1517                        content: serde_json::json!([{
1518                            "type": "web_search_result",
1519                            "url": "https://example.com/shannon",
1520                            "title": "Claude Shannon",
1521                            "encrypted_content": "encrypted-content"
1522                        }]),
1523                    },
1524                },
1525                &mut tool_call_state,
1526                &mut server_tool_uses,
1527                &mut thinking_state,
1528            )
1529            .expect("web_search_tool_result block should produce raw metadata");
1530
1531            yield super::handle_event(
1532                &StreamingEvent::ContentBlockStart {
1533                    index: 2,
1534                    content_block: Content::Text {
1535                        text: String::new(),
1536                        citations: Vec::new(),
1537                        cache_control: None,
1538                    },
1539                },
1540                &mut tool_call_state,
1541                &mut server_tool_uses,
1542                &mut thinking_state,
1543            )
1544            .expect("text block start should produce a raw choice");
1545
1546            yield super::handle_event(
1547                &StreamingEvent::ContentBlockDelta {
1548                    index: 2,
1549                    delta: ContentDelta::TextDelta {
1550                        text: "Claude Shannon was born on April 30, 1916.".to_string(),
1551                    },
1552                },
1553                &mut tool_call_state,
1554                &mut server_tool_uses,
1555                &mut thinking_state,
1556            )
1557            .expect("text delta should produce a raw choice");
1558
1559            yield super::handle_event(
1560                &StreamingEvent::ContentBlockDelta {
1561                    index: 2,
1562                    delta: ContentDelta::CitationsDelta {
1563                        citation: crate::providers::anthropic::completion::Citation::WebSearchResultLocation {
1564                            cited_text: "Claude Shannon was born on April 30, 1916.".to_string(),
1565                            url: "https://example.com/shannon".to_string(),
1566                            title: Some("Claude Shannon".to_string()),
1567                            encrypted_index: "encrypted-index".to_string(),
1568                        },
1569                    },
1570                },
1571                &mut tool_call_state,
1572                &mut server_tool_uses,
1573                &mut thinking_state,
1574            )
1575            .expect("citation delta should produce a raw choice");
1576
1577            yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse {
1578                usage: PartialUsage::default(),
1579            }));
1580        };
1581
1582        let mut stream =
1583            crate::streaming::StreamingCompletionResponse::stream(to_stream_result(raw_stream));
1584        while stream.next().await.is_some() {}
1585
1586        let choice_items: Vec<crate::message::AssistantContent> =
1587            stream.choice.clone().into_iter().collect();
1588        assert_eq!(choice_items.len(), 3);
1589        assert!(
1590            choice_items
1591                .iter()
1592                .all(|item| !matches!(item, crate::message::AssistantContent::ToolCall(_))),
1593            "provider-owned web-search blocks must not become Rig client tool calls"
1594        );
1595
1596        let Some(crate::message::AssistantContent::Text(server_tool_use)) = choice_items.first()
1597        else {
1598            panic!("expected raw server_tool_use metadata");
1599        };
1600        assert_eq!(
1601            server_tool_use.additional_params.as_ref().unwrap()
1602                [crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["type"],
1603            "server_tool_use"
1604        );
1605        assert_eq!(
1606            server_tool_use.additional_params.as_ref().unwrap()
1607                [crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["input"]["query"],
1608            "claude shannon birth date"
1609        );
1610
1611        let Some(crate::message::AssistantContent::Text(web_search_result)) = choice_items.get(1)
1612        else {
1613            panic!("expected raw web_search_tool_result metadata");
1614        };
1615        assert_eq!(
1616            web_search_result.additional_params.as_ref().unwrap()
1617                [crate::providers::anthropic::completion::ANTHROPIC_RAW_CONTENT_KEY]["content"][0]
1618                ["encrypted_content"],
1619            "encrypted-content"
1620        );
1621
1622        let Some(crate::message::AssistantContent::Text(answer)) = choice_items.get(2) else {
1623            panic!("expected answer text");
1624        };
1625        assert_eq!(answer.text, "Claude Shannon was born on April 30, 1916.");
1626        let citations = crate::providers::anthropic::completion::anthropic_citations(answer)
1627            .expect("expected preserved citations");
1628        assert!(matches!(
1629            citations.first(),
1630            Some(crate::providers::anthropic::completion::Citation::WebSearchResultLocation {
1631                encrypted_index,
1632                ..
1633            }) if encrypted_index == "encrypted-index"
1634        ));
1635    }
1636
1637    #[test]
1638    fn test_handle_citations_delta_event_preserves_metadata() {
1639        let event = StreamingEvent::ContentBlockDelta {
1640            index: 0,
1641            delta: ContentDelta::CitationsDelta {
1642                citation: crate::providers::anthropic::completion::Citation::CharLocation {
1643                    cited_text: "The grass is green.".to_string(),
1644                    document_index: 0,
1645                    document_title: Some("Example".to_string()),
1646                    start_char_index: 0,
1647                    end_char_index: 20,
1648                },
1649            },
1650        };
1651
1652        let mut tool_call_state = None;
1653        let mut thinking_state = None;
1654        let result = handle_event(&event, &mut tool_call_state, &mut thinking_state);
1655
1656        assert!(result.is_some());
1657        let choice = result.unwrap().unwrap();
1658        let RawStreamingChoice::TextAdditionalParams(additional_params) = choice else {
1659            panic!("expected TextAdditionalParams choice");
1660        };
1661        assert_eq!(additional_params["citations"][0]["type"], "char_location");
1662    }
1663
1664    #[tokio::test]
1665    async fn test_streaming_citation_deltas_are_preserved_on_final_text() {
1666        let citation = crate::providers::anthropic::completion::Citation::CharLocation {
1667            cited_text: "The grass is green.".to_string(),
1668            document_index: 0,
1669            document_title: Some("Example".to_string()),
1670            start_char_index: 0,
1671            end_char_index: 20,
1672        };
1673
1674        let raw_stream = stream! {
1675            let mut tool_call_state = None;
1676            let mut thinking_state = None;
1677
1678            yield handle_event(
1679                &StreamingEvent::ContentBlockStart {
1680                    index: 0,
1681                    content_block: Content::Text {
1682                        text: String::new(),
1683                        citations: Vec::new(),
1684                        cache_control: None,
1685                    },
1686                },
1687                &mut tool_call_state,
1688                &mut thinking_state,
1689            )
1690            .expect("text block start should produce a raw choice");
1691
1692            yield handle_event(
1693                &StreamingEvent::ContentBlockDelta {
1694                    index: 0,
1695                    delta: ContentDelta::TextDelta {
1696                        text: "the grass is green".to_string(),
1697                    },
1698                },
1699                &mut tool_call_state,
1700                &mut thinking_state,
1701            )
1702            .expect("text delta should produce a raw choice");
1703
1704            yield handle_event(
1705                &StreamingEvent::ContentBlockDelta {
1706                    index: 0,
1707                    delta: ContentDelta::CitationsDelta {
1708                        citation: crate::providers::anthropic::completion::Citation::CharLocation {
1709                            cited_text: "The grass is green.".to_string(),
1710                            document_index: 0,
1711                            document_title: Some("Example".to_string()),
1712                            start_char_index: 0,
1713                            end_char_index: 20,
1714                        },
1715                    },
1716                },
1717                &mut tool_call_state,
1718                &mut thinking_state,
1719            )
1720            .expect("citation delta should produce a raw choice");
1721
1722            yield Ok(RawStreamingChoice::FinalResponse(StreamingCompletionResponse {
1723                usage: PartialUsage::default(),
1724            }));
1725        };
1726
1727        let mut stream =
1728            crate::streaming::StreamingCompletionResponse::stream(to_stream_result(raw_stream));
1729        while stream.next().await.is_some() {}
1730
1731        let choice_items: Vec<crate::message::AssistantContent> =
1732            stream.choice.clone().into_iter().collect();
1733        let Some(crate::message::AssistantContent::Text(text)) = choice_items.first() else {
1734            panic!("expected accumulated text item");
1735        };
1736
1737        assert_eq!(text.text, "the grass is green");
1738        let citations = crate::providers::anthropic::completion::anthropic_citations(text).unwrap();
1739        assert_eq!(citations, vec![citation]);
1740    }
1741
1742    #[test]
1743    fn test_unknown_content_delta_falls_back() {
1744        let json = r#"{"type": "something_new_from_anthropic", "field": "x"}"#;
1745        let delta: ContentDelta = serde_json::from_str(json).unwrap();
1746        assert!(matches!(delta, ContentDelta::Unknown));
1747    }
1748}