Skip to main content

rig_core/providers/anthropic/
completion.rs

1//! Anthropic completion api implementation
2
3use crate::completion::CompletionRequest;
4use crate::completion::NormalizeCompletionResponse;
5use crate::json_utils::string_or_vec;
6use crate::providers::internal::completion_send::send_completion;
7use crate::{
8    client::Provider,
9    completion::{self, CompletionError},
10    http_client::HttpClientExt,
11    message::{self, DocumentMediaType, DocumentSourceKind, MessageError, MimeType, Reasoning},
12    telemetry::{CompletionOperation, CompletionSpanBuilder, ProviderResponseExt, SpanCombinator},
13    wasm_compat::*,
14};
15use serde::{Deserialize, Serialize};
16use std::{convert::Infallible, str::FromStr};
17use tracing::Instrument;
18
19// ================================================================
20// Anthropic Completion API
21// ================================================================
22
23/// `claude-opus-4-6` completion model
24pub const CLAUDE_OPUS_4_6: &str = "claude-opus-4-6";
25/// `claude-opus-4-7` completion model
26pub const CLAUDE_OPUS_4_7: &str = "claude-opus-4-7";
27/// `claude-opus-4-8` completion model
28pub const CLAUDE_OPUS_4_8: &str = "claude-opus-4-8";
29/// `claude-sonnet-4-6` completion model
30pub const CLAUDE_SONNET_4_6: &str = "claude-sonnet-4-6";
31/// `claude-haiku-4-5` completion model
32pub const CLAUDE_HAIKU_4_5: &str = "claude-haiku-4-5";
33
34pub const ANTHROPIC_VERSION_2023_01_01: &str = "2023-01-01";
35pub const ANTHROPIC_VERSION_2023_06_01: &str = "2023-06-01";
36pub const ANTHROPIC_VERSION_LATEST: &str = ANTHROPIC_VERSION_2023_06_01;
37pub(crate) const ANTHROPIC_RAW_CONTENT_KEY: &str = "anthropic_content";
38
39pub trait AnthropicCompatibleProvider: Provider {
40    const PROVIDER_NAME: &'static str;
41
42    /// Response header carrying the provider's transport request id, when the
43    /// provider reports one. Anthropic sends `request-id`; compatible gateways
44    /// that mirror Anthropic's shape usually do too, and one that doesn't
45    /// simply yields `None` — never an error.
46    const REQUEST_ID_HEADER: Option<&'static str> = Some("request-id");
47
48    fn default_max_tokens(model: &str) -> Option<u64> {
49        let _ = model;
50        None
51    }
52
53    /// Apply provider-specific strict tool-use behavior to a Rig-generated tool.
54    ///
55    /// Anthropic-compatible gateways do not necessarily implement Anthropic's
56    /// constrained tool schemas, so the default deliberately leaves tools
57    /// unchanged.
58    fn enable_strict_tool_use(_tool: &mut ToolDefinition) {}
59}
60
61impl AnthropicCompatibleProvider for super::client::AnthropicExt {
62    const PROVIDER_NAME: &'static str = "anthropic";
63
64    fn default_max_tokens(model: &str) -> Option<u64> {
65        default_max_tokens_for_model(model)
66    }
67
68    fn enable_strict_tool_use(tool: &mut ToolDefinition) {
69        sanitize_strict_tool_schema(&mut tool.input_schema);
70        tool.strict = true;
71    }
72}
73
74#[derive(Debug, Deserialize, Serialize)]
75pub struct CompletionResponse {
76    pub content: Vec<Content>,
77    pub id: String,
78    pub model: String,
79    pub role: String,
80    pub stop_reason: Option<String>,
81    pub stop_sequence: Option<String>,
82    pub usage: Usage,
83    /// The transport request id from the `request-id` response header — not
84    /// part of the response body; stamped by the request driver. This is the
85    /// id Anthropic support asks for. `None` when the provider (or a
86    /// compatible gateway) did not report one.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub provider_request_id: Option<String>,
89}
90
91/// Map an Anthropic Messages `stop_reason` onto the normalized vocabulary,
92/// preserving anything unrecognized verbatim.
93///
94/// Shared by the unary and streaming paths so both agree, and so a stop reason
95/// Anthropic adds later surfaces in its own spelling rather than reading as a
96/// natural stop.
97pub(crate) fn map_finish_reason(stop_reason: &str) -> completion::FinishReason {
98    match stop_reason {
99        // `stop_sequence` is a natural termination too: the model completed its
100        // turn by emitting one of the caller's stop sequences.
101        "end_turn" | "stop_sequence" => completion::FinishReason::Stop,
102        "max_tokens" => completion::FinishReason::Length,
103        "tool_use" => completion::FinishReason::ToolCalls,
104        // Anthropic's classifier-driven refusal; the closest normalized reason
105        // is content filtering.
106        "refusal" => completion::FinishReason::ContentFilter,
107        other => completion::FinishReason::Other(other.to_owned()),
108    }
109}
110
111impl ProviderResponseExt for CompletionResponse {
112    type Usage = Usage;
113
114    fn get_response_id(&self) -> Option<String> {
115        Some(self.id.to_owned())
116    }
117
118    fn get_response_model_name(&self) -> Option<String> {
119        Some(self.model.to_owned())
120    }
121
122    fn get_text_response(&self) -> Option<String> {
123        let res = self
124            .content
125            .iter()
126            .filter_map(|x| {
127                if let Content::Text { text, .. } = x {
128                    Some(text.as_str())
129                } else {
130                    None
131                }
132            })
133            .collect::<String>();
134
135        if res.is_empty() { None } else { Some(res) }
136    }
137
138    fn get_usage(&self) -> Option<Self::Usage> {
139        Some(self.usage.clone())
140    }
141}
142
143#[derive(Clone, Debug, Deserialize, Serialize)]
144pub struct Usage {
145    pub input_tokens: u64,
146    pub cache_read_input_tokens: Option<u64>,
147    pub cache_creation_input_tokens: Option<u64>,
148    /// Per-TTL breakdown of `cache_creation_input_tokens`. Absent when the
149    /// provider does not report it; the aggregate above is always authoritative.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub cache_creation: Option<CacheCreation>,
152    pub output_tokens: u64,
153    /// Breakdown of `output_tokens`. Absent when the provider does not report
154    /// it (a turn with extended thinking disabled).
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub output_tokens_details: Option<OutputTokensDetails>,
157}
158
159/// Breakdown of `usage.output_tokens`.
160///
161/// The tokens Claude spent on extended thinking are reported here, *inside*
162/// `output_tokens` rather than beside it — the name says `details`, and every
163/// recorded turn has `thinking_tokens <= output_tokens`. Adding them to a total
164/// would double-count. Unknown buckets a provider may add later are ignored on
165/// deserialization.
166#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
167pub struct OutputTokensDetails {
168    /// Output tokens spent on extended thinking this turn.
169    #[serde(default)]
170    pub thinking_tokens: u64,
171}
172
173/// Per-TTL breakdown of cache-write tokens (`usage.cache_creation`).
174///
175/// Distinguishes 1-hour cache writes (~2x base input token price) from
176/// 5-minute writes (~1.25x), which is what makes a mixed-TTL configuration
177/// (see [`CompletionModel::with_static_prefix_cache_ttl`]) observable.
178/// Unknown buckets a provider may add later are ignored on deserialization.
179#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
180pub struct CacheCreation {
181    /// Tokens written to the 5-minute cache on this turn.
182    #[serde(default)]
183    pub ephemeral_5m_input_tokens: u64,
184    /// Tokens written to the 1-hour cache on this turn.
185    #[serde(default)]
186    pub ephemeral_1h_input_tokens: u64,
187}
188
189impl std::fmt::Display for Usage {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        write!(
192            f,
193            "Input tokens: {}\nCache read input tokens: {}\nCache creation input tokens: {}\nOutput tokens: {}",
194            self.input_tokens,
195            match self.cache_read_input_tokens {
196                Some(token) => token.to_string(),
197                None => "n/a".to_string(),
198            },
199            match self.cache_creation_input_tokens {
200                Some(token) => token.to_string(),
201                None => "n/a".to_string(),
202            },
203            self.output_tokens
204        )
205    }
206}
207
208/// Aggregate an Anthropic token report into rig's usage shape.
209///
210/// Anthropic reports cache reads and cache writes *alongside* `input_tokens`
211/// rather than inside it, so the total is the sum of all four counters.
212/// `thinking_tokens` is the exception: it is a *breakdown* of `output_tokens`,
213/// already counted there, so it populates `reasoning_tokens` without entering
214/// the total. Shared with the streaming path, whose `PartialUsage` carries the
215/// same counters — the parameter is required rather than defaulted so a new
216/// caller cannot silently drop it.
217pub(super) fn anthropic_usage_totals(
218    input_tokens: u64,
219    output_tokens: u64,
220    cache_read: Option<u64>,
221    cache_creation: Option<u64>,
222    output_tokens_details: Option<OutputTokensDetails>,
223) -> crate::completion::Usage {
224    let mut usage = crate::completion::Usage::new();
225
226    usage.input_tokens = input_tokens;
227    usage.output_tokens = output_tokens;
228    usage.cached_input_tokens = cache_read.unwrap_or_default();
229    usage.cache_creation_input_tokens = cache_creation.unwrap_or_default();
230    usage.reasoning_tokens = output_tokens_details
231        .map(|details| details.thinking_tokens)
232        .unwrap_or_default();
233    usage.total_tokens = usage.input_tokens
234        + usage.cached_input_tokens
235        + usage.cache_creation_input_tokens
236        + usage.output_tokens;
237
238    usage
239}
240
241impl From<&Usage> for crate::completion::Usage {
242    fn from(value: &Usage) -> crate::completion::Usage {
243        anthropic_usage_totals(
244            value.input_tokens,
245            value.output_tokens,
246            value.cache_read_input_tokens,
247            value.cache_creation_input_tokens,
248            value.output_tokens_details,
249        )
250    }
251}
252
253impl From<Usage> for crate::completion::Usage {
254    fn from(value: Usage) -> crate::completion::Usage {
255        (&value).into()
256    }
257}
258
259#[derive(Debug, Deserialize, Serialize)]
260pub struct ToolDefinition {
261    pub name: String,
262    pub description: Option<String>,
263    pub input_schema: serde_json::Value,
264    /// Whether Anthropic must constrain tool arguments to `input_schema`.
265    #[serde(default, skip_serializing_if = "is_false")]
266    pub strict: bool,
267    /// Cache breakpoint marker. Set on the last tool in the array to cache
268    /// the tools layer independently of the system prompt. Anthropic accepts
269    /// up to 4 `cache_control` markers per request.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub cache_control: Option<CacheControl>,
272}
273
274fn is_false(value: &bool) -> bool {
275    !value
276}
277
278/// TTL for a cache control breakpoint.
279///
280/// The Anthropic API supports two TTL values:
281/// - `"5m"` — 5 minutes (default when `ttl` is omitted)
282/// - `"1h"` — 1 hour
283#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
284pub enum CacheTtl {
285    /// 5-minute TTL (default).
286    #[default]
287    #[serde(rename = "5m")]
288    FiveMinutes,
289    /// 1-hour TTL.
290    #[serde(rename = "1h")]
291    OneHour,
292}
293
294/// Cache control directive for Anthropic prompt caching.
295///
296/// Serialises to `{"type":"ephemeral"}` (default TTL) or
297/// `{"type":"ephemeral","ttl":"1h"}` (extended TTL).
298#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
299#[serde(tag = "type", rename_all = "snake_case")]
300pub enum CacheControl {
301    Ephemeral {
302        /// Optional TTL. Defaults to `"5m"` when omitted.
303        #[serde(skip_serializing_if = "Option::is_none")]
304        ttl: Option<CacheTtl>,
305    },
306}
307
308impl CacheControl {
309    /// Create a cache control with the default 5-minute TTL.
310    pub fn ephemeral() -> Self {
311        Self::Ephemeral { ttl: None }
312    }
313
314    /// Create a cache control with a 1-hour TTL.
315    pub fn ephemeral_1h() -> Self {
316        Self::Ephemeral {
317            ttl: Some(CacheTtl::OneHour),
318        }
319    }
320}
321
322/// System message content block with optional cache control
323#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
324#[serde(tag = "type", rename_all = "snake_case")]
325pub enum SystemContent {
326    Text {
327        text: String,
328        #[serde(skip_serializing_if = "Option::is_none")]
329        cache_control: Option<CacheControl>,
330    },
331}
332
333/// Normalize an Anthropic Messages response.
334///
335/// The provider descriptor name is an *input* rather than a constant: this same
336/// wire shape is served by every Anthropic-compatible provider (MiniMax, Z.ai,
337/// Moonshot, Xiaomi MiMo), so baking in `"anthropic"` here would mislabel all of
338/// them. Taking it as part of the conversion makes the correct name impossible
339/// to forget.
340impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
341    fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
342        let response = self;
343        let content = response
344            .content
345            .iter()
346            .map(|content| content.clone().try_into())
347            .collect::<Result<Vec<_>, _>>()?;
348
349        // Anthropic has two ways to end a turn that genuinely carried no
350        // content, and an empty list says exactly that:
351        //
352        // - `end_turn` after a tool-result round trip — documented, and it
353        //   used to be normalized into a fabricated empty-text part.
354        // - `stop_sequence` when the matched sequence is the first thing the
355        //   model emits. Anthropic strips the sequence it stopped on, so a
356        //   turn that produced nothing before it arrives with `content: []`
357        //   and a 200. Rejecting that turned a completed provider turn into
358        //   `EMPTY_RESPONSE_ERROR`, and diverged from the streamed twin,
359        //   which finishes the same turn with an empty choice and no error.
360        //
361        // The `stop_sequence` arm additionally requires the sequence itself.
362        // Every recorded stop-sequence turn names the sequence that fired, so
363        // that is the full extent of the evidence; a turn claiming to have
364        // stopped on a sequence while naming none is the malformed shape this
365        // guard exists for, not a legal empty turn. This matters most for the
366        // Anthropic-compatible gateways sharing this mapping, which are the
367        // likeliest to report a stop reason without its companion field.
368        //
369        // Note this narrow shape — empty, `stop_sequence`, no sequence named —
370        // is one the streaming path still finishes cleanly, since it has no
371        // equivalent guard. That asymmetry is deliberate: the parity this
372        // carve-out restores is for *legal* turns, and widening it to keep a
373        // malformed one symmetric would trade a real guard for a cosmetic
374        // match.
375        //
376        // Any *other* empty response is the shared provider defect.
377        let legal_empty_turn = match response.stop_reason.as_deref() {
378            Some("end_turn") => true,
379            Some("stop_sequence") => response.stop_sequence.is_some(),
380            _ => false,
381        };
382        let choice = if content.is_empty() && legal_empty_turn {
383            Vec::new()
384        } else {
385            crate::message::require_non_empty_response(content)?
386        };
387
388        let finish_reason = response.stop_reason.as_deref().map(map_finish_reason);
389
390        Ok(completion::CompletionResponse::new(
391            choice,
392            crate::completion::Usage::from(&response.usage),
393            provider,
394        )
395        .with_optional_message_id(Some(response.id.as_str()).filter(|id| !id.is_empty()))
396        .with_optional_provider_request_id(response.provider_request_id.clone())
397        .with_model(response.model.as_str())
398        .with_optional_finish_reason(finish_reason))
399    }
400}
401
402#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
403pub struct Message {
404    pub role: Role,
405    #[serde(deserialize_with = "string_or_vec")]
406    pub content: Vec<Content>,
407}
408
409#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
410#[serde(rename_all = "lowercase")]
411pub enum Role {
412    User,
413    Assistant,
414    System,
415}
416
417#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
418#[serde(tag = "type", rename_all = "snake_case")]
419pub enum Content {
420    Text {
421        text: String,
422        /// Citations returned by Claude pointing back into the source documents.
423        /// Empty (and skipped during serialization) on request-side blocks.
424        #[serde(
425            default,
426            deserialize_with = "null_as_empty_vec",
427            skip_serializing_if = "Vec::is_empty"
428        )]
429        citations: Vec<Citation>,
430        #[serde(skip_serializing_if = "Option::is_none")]
431        cache_control: Option<CacheControl>,
432    },
433    Image {
434        source: ImageSource,
435        #[serde(skip_serializing_if = "Option::is_none")]
436        cache_control: Option<CacheControl>,
437    },
438    ToolUse {
439        id: String,
440        name: String,
441        input: serde_json::Value,
442    },
443    ServerToolUse {
444        id: String,
445        name: String,
446        #[serde(default)]
447        input: serde_json::Value,
448    },
449    WebSearchToolResult {
450        tool_use_id: String,
451        content: serde_json::Value,
452    },
453    /// The result of an Anthropic-hosted code execution tool call.
454    CodeExecutionToolResult {
455        tool_use_id: String,
456        content: serde_json::Value,
457    },
458    ToolResult {
459        tool_use_id: String,
460        #[serde(deserialize_with = "string_or_vec")]
461        content: Vec<ToolResultContent>,
462        #[serde(skip_serializing_if = "Option::is_none")]
463        is_error: Option<bool>,
464        #[serde(skip_serializing_if = "Option::is_none")]
465        cache_control: Option<CacheControl>,
466    },
467    Document {
468        source: DocumentSource,
469        /// Optional document title, passed to the model but not citable.
470        #[serde(default, skip_serializing_if = "Option::is_none")]
471        title: Option<String>,
472        /// Optional document context (e.g. metadata), passed to the model but
473        /// not citable. Useful for storing additional information about the
474        /// document that should not appear in citation `cited_text`.
475        #[serde(default, skip_serializing_if = "Option::is_none")]
476        context: Option<String>,
477        /// Configuration for enabling citations on this document. When `enabled`
478        /// is true, Claude returns citation metadata on response text blocks
479        /// pointing back into this document's content.
480        #[serde(default, skip_serializing_if = "Option::is_none")]
481        citations: Option<CitationsConfig>,
482        #[serde(skip_serializing_if = "Option::is_none")]
483        cache_control: Option<CacheControl>,
484    },
485    Thinking {
486        thinking: String,
487        #[serde(skip_serializing_if = "Option::is_none")]
488        signature: Option<String>,
489    },
490    RedactedThinking {
491        data: String,
492    },
493}
494
495impl FromStr for Content {
496    type Err = Infallible;
497
498    fn from_str(s: &str) -> Result<Self, Self::Err> {
499        Ok(Content::from(s.to_owned()))
500    }
501}
502
503/// Configuration for enabling citations on a document content block.
504///
505/// When enabled, Claude returns citation metadata on response text blocks,
506/// allowing applications to track where each piece of information in the
507/// response came from. See the [Anthropic citations documentation][docs] for
508/// details on the request/response shapes.
509///
510/// Citations must be enabled on **all or none** of the documents in a request —
511/// the API returns an error if the setting is mixed.
512///
513/// [docs]: https://docs.anthropic.com/en/docs/build-with-claude/citations
514#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
515pub struct CitationsConfig {
516    /// Whether citation tracking is enabled for this document.
517    pub enabled: bool,
518}
519
520/// A citation returned by Claude pointing back to source text.
521///
522/// The variant determines the locator shape, which depends on the source type:
523///
524/// - [`Citation::CharLocation`] — for plain text documents; character indices
525///   are 0-indexed with an exclusive end.
526/// - [`Citation::PageLocation`] — for PDF documents; page numbers are 1-indexed
527///   with an exclusive end.
528/// - [`Citation::ContentBlockLocation`] — for custom-content documents; block
529///   indices are 0-indexed with an exclusive end.
530/// - [`Citation::SearchResultLocation`] — for user-provided search-result
531///   content blocks.
532/// - [`Citation::WebSearchResultLocation`] — for Anthropic's server-side web
533///   search tool results.
534/// - [`Citation::Unknown`] — a forward-compatible fallback preserving raw
535///   citation JSON for citation types this crate does not yet model.
536///
537/// See the [Anthropic citations documentation][docs] for the exact wire format.
538///
539/// [docs]: https://docs.anthropic.com/en/docs/build-with-claude/citations
540#[derive(Debug, Clone, PartialEq, Eq)]
541pub enum Citation {
542    /// A citation locating a character span in a plain text document.
543    CharLocation(CharLocationCitation),
544    /// A citation locating a page range in a PDF document.
545    PageLocation(PageLocationCitation),
546    /// A citation locating a block range in a custom-content document.
547    ContentBlockLocation(ContentBlockLocationCitation),
548    /// A citation locating a block range in a user-provided search result.
549    SearchResultLocation(SearchResultLocationCitation),
550    /// A citation emitted by Anthropic's server-side web search tool.
551    WebSearchResultLocation(WebSearchResultLocationCitation),
552    /// A forward-compatible raw citation payload for citation types this crate
553    /// does not yet model.
554    Unknown(serde_json::Value),
555}
556
557/// Payload of a [`Citation::CharLocation`].
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
559pub struct CharLocationCitation {
560    /// The exact text being cited. Not counted toward output tokens.
561    pub cited_text: String,
562    /// 0-indexed position of the source document in the request's document list.
563    pub document_index: usize,
564    /// Optional title of the source document, echoed back from the request.
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub document_title: Option<String>,
567    /// 0-indexed character offset where the cited span begins.
568    pub start_char_index: usize,
569    /// Character offset where the cited span ends (exclusive).
570    pub end_char_index: usize,
571}
572
573/// Payload of a [`Citation::PageLocation`].
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575pub struct PageLocationCitation {
576    /// The exact text being cited. Not counted toward output tokens.
577    pub cited_text: String,
578    /// 0-indexed position of the source document in the request's document list.
579    pub document_index: usize,
580    /// Optional title of the source document, echoed back from the request.
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub document_title: Option<String>,
583    /// 1-indexed page number where the cited span begins.
584    pub start_page_number: u32,
585    /// 1-indexed page number where the cited span ends (exclusive).
586    pub end_page_number: u32,
587}
588
589/// Payload of a [`Citation::ContentBlockLocation`].
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
591pub struct ContentBlockLocationCitation {
592    /// The exact text being cited. Not counted toward output tokens.
593    pub cited_text: String,
594    /// 0-indexed position of the source document in the request's document list.
595    pub document_index: usize,
596    /// Optional title of the source document, echoed back from the request.
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub document_title: Option<String>,
599    /// 0-indexed content block index where the cited span begins.
600    pub start_block_index: usize,
601    /// Content block index where the cited span ends (exclusive).
602    pub end_block_index: usize,
603}
604
605/// Payload of a [`Citation::SearchResultLocation`].
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
607pub struct SearchResultLocationCitation {
608    /// The exact text being cited. Not counted toward output tokens.
609    pub cited_text: String,
610    /// Source URL or identifier from the original search result.
611    pub source: String,
612    /// Title from the original search result.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub title: Option<String>,
615    /// 0-indexed position of the cited search result across all search
616    /// result blocks in the request.
617    pub search_result_index: usize,
618    /// 0-indexed content block index where the cited span begins.
619    pub start_block_index: usize,
620    /// Content block index where the cited span ends (exclusive).
621    pub end_block_index: usize,
622}
623
624/// Payload of a [`Citation::WebSearchResultLocation`].
625#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
626pub struct WebSearchResultLocationCitation {
627    /// The exact text being cited. Not counted toward output tokens.
628    pub cited_text: String,
629    /// URL of the cited source.
630    pub url: String,
631    /// Title of the cited source. Unlike the document-citation titles this
632    /// carries no `skip_serializing_if`: the wire writes it even when absent,
633    /// as an explicit `"title": null`.
634    pub title: Option<String>,
635    /// Encrypted reference that must be preserved for multi-turn
636    /// conversations.
637    pub encrypted_index: String,
638}
639
640impl Serialize for Citation {
641    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
642    where
643        S: serde::Serializer,
644    {
645        /// Serialize the per-variant DTO and insert the wire `type` tag.
646        fn tagged<S, T>(serializer: S, tag: &str, fields: &T) -> Result<S::Ok, S::Error>
647        where
648            S: serde::Serializer,
649            T: Serialize,
650        {
651            let mut value = serde_json::to_value(fields).map_err(serde::ser::Error::custom)?;
652            if let serde_json::Value::Object(obj) = &mut value {
653                obj.insert("type".into(), serde_json::json!(tag));
654            }
655            value.serialize(serializer)
656        }
657
658        match self {
659            Citation::CharLocation(fields) => tagged(serializer, "char_location", fields),
660            Citation::PageLocation(fields) => tagged(serializer, "page_location", fields),
661            Citation::ContentBlockLocation(fields) => {
662                tagged(serializer, "content_block_location", fields)
663            }
664            Citation::SearchResultLocation(fields) => {
665                tagged(serializer, "search_result_location", fields)
666            }
667            Citation::WebSearchResultLocation(fields) => {
668                tagged(serializer, "web_search_result_location", fields)
669            }
670            Citation::Unknown(raw) => raw.serialize(serializer),
671        }
672    }
673}
674
675impl<'de> Deserialize<'de> for Citation {
676    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
677    where
678        D: serde::Deserializer<'de>,
679    {
680        /// Decode the payload of an already tag-matched citation. A modeled tag
681        /// carrying a defective payload is an error, never a silent
682        /// [`Citation::Unknown`].
683        fn payload<T, E>(value: serde_json::Value) -> Result<T, E>
684        where
685            T: serde::de::DeserializeOwned,
686            E: serde::de::Error,
687        {
688            serde_json::from_value(value).map_err(E::custom)
689        }
690
691        // Hand-written tag dispatch rather than `#[serde(untagged)]`: an
692        // untagged fallback would swallow a *modeled* citation type carrying a
693        // defective payload as `Unknown`, hiding provider drift. Only an
694        // absent, non-string or unmodeled tag reaches `Unknown` here.
695        let value = serde_json::Value::deserialize(deserializer)?;
696        let Some(citation_type) = value.get("type").and_then(serde_json::Value::as_str) else {
697            return Ok(Citation::Unknown(value));
698        };
699
700        match citation_type {
701            "char_location" => Ok(Citation::CharLocation(payload(value)?)),
702            "page_location" => Ok(Citation::PageLocation(payload(value)?)),
703            "content_block_location" => Ok(Citation::ContentBlockLocation(payload(value)?)),
704            "search_result_location" => Ok(Citation::SearchResultLocation(payload(value)?)),
705            "web_search_result_location" => Ok(Citation::WebSearchResultLocation(payload(value)?)),
706            _ => Ok(Citation::Unknown(value)),
707        }
708    }
709}
710
711/// Deserialize a `Vec<T>`, treating an explicit JSON `null` as an empty vec.
712///
713/// `#[serde(default)]` only fills in a *missing* field, but the Anthropic
714/// Messages API emits an explicit `"citations": null` on text
715/// `content_block_start` events. Without this, `Vec` deserialization rejects the
716/// null and the whole stream fails before any text arrives.
717fn null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
718where
719    D: serde::Deserializer<'de>,
720    T: serde::Deserialize<'de>,
721{
722    Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
723}
724
725/// Decoded Anthropic document fields lifted out of [`message::Document::additional_params`]:
726/// optional `title`, optional `context`, and optional [`CitationsConfig`].
727type AnthropicDocParams = (Option<String>, Option<String>, Option<CitationsConfig>);
728
729/// Extract Anthropic-specific document fields (`title`, `context`, `citations`)
730/// from the generic [`message::Document::additional_params`] JSON blob.
731///
732/// Returns `Ok((None, None, None))` if `additional_params` is empty. Returns
733/// an error only if the `citations` field is present but is not a valid
734/// [`CitationsConfig`] — invalid shapes are reported instead of being silently
735/// dropped, so users notice typos.
736fn extract_anthropic_doc_params(
737    additional_params: Option<message::AdditionalParams>,
738) -> Result<AnthropicDocParams, MessageError> {
739    let Some(value) = additional_params else {
740        return Ok((None, None, None));
741    };
742    let title = value
743        .get("title")
744        .and_then(|v| v.as_str())
745        .map(String::from);
746    let context = value
747        .get("context")
748        .and_then(|v| v.as_str())
749        .map(String::from);
750    let citations = value
751        .get("citations")
752        .cloned()
753        .map(serde_json::from_value::<CitationsConfig>)
754        .transpose()
755        .map_err(|e| {
756            MessageError::ConversionError(format!(
757                "Document `additional_params.citations` is not a valid CitationsConfig: {e}",
758            ))
759        })?;
760    Ok((title, context, citations))
761}
762
763/// Extract Anthropic citations attached to a generic [`message::Text`] block.
764///
765/// Citations are returned by Claude on assistant text blocks when the request
766/// enabled them via [`CitationsConfig`]. Internally they are stored as JSON in
767/// [`message::Text::additional_params`] so they survive conversion through the
768/// generic [`message::AssistantContent`] surface.
769///
770/// Returns `Ok(vec![])` when no citations are attached. Unknown citation types
771/// are preserved as [`Citation::Unknown`]. Returns an error if the `citations`
772/// field is malformed or if a known citation type has an invalid shape.
773///
774/// # Example
775///
776/// ```no_run
777/// use rig_core::completion::message::{self, AssistantContent};
778/// use rig_core::providers::anthropic::completion::anthropic_citations;
779///
780/// fn print_citations(content: &AssistantContent) {
781///     if let AssistantContent::Text(text) = content
782///         && let Ok(citations) = anthropic_citations(text)
783///         && !citations.is_empty()
784///     {
785///         println!("{citations:?}");
786///     }
787/// }
788/// # let _ = message::Text::new("");
789/// ```
790pub fn anthropic_citations(text: &message::Text) -> Result<Vec<Citation>, serde_json::Error> {
791    match text
792        .additional_params
793        .as_ref()
794        .and_then(|v| v.get("citations"))
795    {
796        Some(c) => serde_json::from_value::<Vec<Citation>>(c.clone()),
797        None => Ok(Vec::new()),
798    }
799}
800
801fn extract_anthropic_text_citations(text: &message::Text) -> Result<Vec<Citation>, MessageError> {
802    anthropic_citations(text).map_err(|err| {
803        MessageError::ConversionError(format!(
804            "Text `additional_params.citations` is not valid Anthropic citations: {err}"
805        ))
806    })
807}
808
809fn anthropic_text_content_from_message_text(text: message::Text) -> Result<Content, MessageError> {
810    if let Some(raw_content) = extract_anthropic_raw_content(&text)? {
811        if !text.text.is_empty() {
812            return Err(MessageError::ConversionError(format!(
813                "Text `{ANTHROPIC_RAW_CONTENT_KEY}` metadata cannot be combined with non-empty text"
814            )));
815        }
816
817        return Ok(raw_content);
818    }
819
820    let citations = extract_anthropic_text_citations(&text)?;
821    Ok(Content::Text {
822        text: text.text,
823        citations,
824        cache_control: None,
825    })
826}
827
828fn extract_anthropic_raw_content(text: &message::Text) -> Result<Option<Content>, MessageError> {
829    let Some(raw_content) = text
830        .additional_params
831        .as_ref()
832        .and_then(|value| value.get(ANTHROPIC_RAW_CONTENT_KEY))
833    else {
834        return Ok(None);
835    };
836
837    let content = serde_json::from_value::<Content>(raw_content.clone()).map_err(|err| {
838        MessageError::ConversionError(format!(
839            "Text `{ANTHROPIC_RAW_CONTENT_KEY}` metadata is not valid Anthropic content: {err}"
840        ))
841    })?;
842
843    match content {
844        Content::ServerToolUse { .. }
845        | Content::WebSearchToolResult { .. }
846        | Content::CodeExecutionToolResult { .. } => Ok(Some(content)),
847        _ => Err(MessageError::ConversionError(format!(
848            "Text `{ANTHROPIC_RAW_CONTENT_KEY}` metadata only supports Anthropic server_tool_use, web_search_tool_result, and code_execution_tool_result blocks"
849        ))),
850    }
851}
852
853fn anthropic_raw_content_to_message_text(content: Content) -> Result<message::Text, MessageError> {
854    let raw_content = serde_json::to_value(content).map_err(|err| {
855        MessageError::ConversionError(format!("Failed to preserve Anthropic content block: {err}"))
856    })?;
857
858    Ok(message::Text {
859        text: String::new(),
860        additional_params: message::AdditionalParams::from_entries([(
861            ANTHROPIC_RAW_CONTENT_KEY,
862            raw_content,
863        )]),
864    })
865}
866
867fn anthropic_document_additional_params(
868    title: Option<String>,
869    context: Option<String>,
870    citations: Option<CitationsConfig>,
871) -> Result<Option<message::AdditionalParams>, MessageError> {
872    let mut params = serde_json::Map::new();
873
874    if let Some(title) = title {
875        params.insert("title".to_string(), serde_json::Value::String(title));
876    }
877    if let Some(context) = context {
878        params.insert("context".to_string(), serde_json::Value::String(context));
879    }
880    if let Some(citations) = citations {
881        params.insert(
882            "citations".to_string(),
883            serde_json::to_value(citations).map_err(|err| {
884                MessageError::ConversionError(format!(
885                    "Failed to preserve Anthropic document citations metadata: {err}"
886                ))
887            })?,
888        );
889    }
890
891    // The canonical constructor: an empty map is stored as `None`, never as
892    // an empty carrier.
893    Ok(message::AdditionalParams::new(params))
894}
895
896#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
897#[serde(tag = "type", rename_all = "snake_case")]
898pub enum ToolResultContent {
899    Text { text: String },
900    Image { source: ImageSource },
901}
902
903impl FromStr for ToolResultContent {
904    type Err = Infallible;
905
906    fn from_str(s: &str) -> Result<Self, Self::Err> {
907        Ok(ToolResultContent::Text { text: s.to_owned() })
908    }
909}
910
911/// The source of an image content block.
912///
913/// Anthropic supports two source types for images:
914/// - `Base64`: Base64-encoded image data with media type
915/// - `Url`: URL reference to an image
916///
917/// See: <https://docs.anthropic.com/en/api/messages>
918#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
919#[serde(tag = "type", rename_all = "snake_case")]
920pub enum ImageSource {
921    #[serde(rename = "base64")]
922    Base64 {
923        data: String,
924        media_type: ImageFormat,
925    },
926    #[serde(rename = "url")]
927    Url { url: String },
928}
929
930/// The source of a document content block.
931///
932/// Anthropic supports multiple source types for documents:
933/// - `Base64`: Base64-encoded document data (used for PDFs)
934/// - `Text`: Plain text document data
935/// - `Url`: URL reference to a document
936/// - `File`: Provider-side uploaded file reference from the Files API
937#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
938#[serde(tag = "type", rename_all = "snake_case")]
939pub enum DocumentSource {
940    Base64 {
941        data: String,
942        media_type: DocumentFormat,
943    },
944    Text {
945        data: String,
946        media_type: PlainTextMediaType,
947    },
948    Url {
949        url: String,
950    },
951    File {
952        file_id: String,
953    },
954}
955
956#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
957#[serde(rename_all = "lowercase")]
958pub enum ImageFormat {
959    #[serde(rename = "image/jpeg")]
960    JPEG,
961    #[serde(rename = "image/png")]
962    PNG,
963    #[serde(rename = "image/gif")]
964    GIF,
965    #[serde(rename = "image/webp")]
966    WEBP,
967}
968
969/// The media type for base64-encoded documents.
970///
971/// Used with the `DocumentSource::Base64` variant. Currently only PDF is supported
972/// for base64-encoded document sources.
973///
974/// See: <https://docs.anthropic.com/en/docs/build-with-claude/pdf-support>
975#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
976#[serde(rename_all = "lowercase")]
977pub enum DocumentFormat {
978    #[serde(rename = "application/pdf")]
979    PDF,
980}
981
982/// The media type for plain text document sources.
983///
984/// Used with the `DocumentSource::Text` variant.
985///
986/// See: <https://docs.anthropic.com/en/api/messages>
987#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
988pub enum PlainTextMediaType {
989    #[serde(rename = "text/plain")]
990    Plain,
991}
992
993#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
994#[serde(rename_all = "lowercase")]
995pub enum SourceType {
996    BASE64,
997    URL,
998    TEXT,
999}
1000
1001impl From<String> for Content {
1002    fn from(text: String) -> Self {
1003        Content::Text {
1004            text,
1005            citations: Vec::new(),
1006            cache_control: None,
1007        }
1008    }
1009}
1010
1011impl From<String> for ToolResultContent {
1012    fn from(text: String) -> Self {
1013        ToolResultContent::Text { text }
1014    }
1015}
1016
1017impl TryFrom<message::ContentFormat> for SourceType {
1018    type Error = MessageError;
1019
1020    fn try_from(format: message::ContentFormat) -> Result<Self, Self::Error> {
1021        match format {
1022            message::ContentFormat::Base64 => Ok(SourceType::BASE64),
1023            message::ContentFormat::Url => Ok(SourceType::URL),
1024            message::ContentFormat::String => Ok(SourceType::TEXT),
1025        }
1026    }
1027}
1028
1029impl From<SourceType> for message::ContentFormat {
1030    fn from(source_type: SourceType) -> Self {
1031        match source_type {
1032            SourceType::BASE64 => message::ContentFormat::Base64,
1033            SourceType::URL => message::ContentFormat::Url,
1034            SourceType::TEXT => message::ContentFormat::String,
1035        }
1036    }
1037}
1038
1039impl TryFrom<message::ImageMediaType> for ImageFormat {
1040    type Error = MessageError;
1041
1042    fn try_from(media_type: message::ImageMediaType) -> Result<Self, Self::Error> {
1043        Ok(match media_type {
1044            message::ImageMediaType::JPEG => ImageFormat::JPEG,
1045            message::ImageMediaType::PNG => ImageFormat::PNG,
1046            message::ImageMediaType::GIF => ImageFormat::GIF,
1047            message::ImageMediaType::WEBP => ImageFormat::WEBP,
1048            _ => {
1049                return Err(MessageError::ConversionError(
1050                    format!("Unsupported image media type: {media_type:?}").to_owned(),
1051                ));
1052            }
1053        })
1054    }
1055}
1056
1057impl From<ImageFormat> for message::ImageMediaType {
1058    fn from(format: ImageFormat) -> Self {
1059        match format {
1060            ImageFormat::JPEG => message::ImageMediaType::JPEG,
1061            ImageFormat::PNG => message::ImageMediaType::PNG,
1062            ImageFormat::GIF => message::ImageMediaType::GIF,
1063            ImageFormat::WEBP => message::ImageMediaType::WEBP,
1064        }
1065    }
1066}
1067
1068impl TryFrom<DocumentMediaType> for DocumentFormat {
1069    type Error = MessageError;
1070    fn try_from(value: DocumentMediaType) -> Result<Self, Self::Error> {
1071        match value {
1072            DocumentMediaType::PDF => Ok(DocumentFormat::PDF),
1073            other => Err(MessageError::ConversionError(format!(
1074                "DocumentFormat only supports PDF for base64 sources, got: {}",
1075                other.to_mime_type()
1076            ))),
1077        }
1078    }
1079}
1080
1081/// The Anthropic Messages API requires `tool_use.input` to be a JSON OBJECT.
1082/// `ToolCall.function.arguments` can arrive as a JSON-encoded STRING (some
1083/// providers / replayed conversation history) or as `null`/empty (a tool called
1084/// with no arguments); sending any of those verbatim is rejected with
1085/// `messages.N.content.M.tool_use.input: Input should be a valid dictionary` (a
1086/// deterministic 400 that breaks every multi-turn tool conversation, e.g. on the
1087/// managed / MiniMax anthropic-shaped endpoint). Coerce to an object at the send
1088/// boundary so the contract holds regardless of how `arguments` was built. This
1089/// re-adds the fork's tool_use.input invariant that a rig version bump dropped;
1090/// the server-tool path already guards empty input in streaming.rs.
1091fn coerce_tool_input(input: serde_json::Value) -> serde_json::Value {
1092    match input {
1093        v @ serde_json::Value::Object(_) => v,
1094        serde_json::Value::String(s) => match serde_json::from_str::<serde_json::Value>(&s) {
1095            Ok(serde_json::Value::Object(m)) => serde_json::Value::Object(m),
1096            _ => serde_json::json!({}),
1097        },
1098        // null / array / number / bool: no valid object form -> empty args.
1099        _ => serde_json::json!({}),
1100    }
1101}
1102
1103fn anthropic_content_from_assistant_content(
1104    content: message::AssistantContent,
1105) -> Result<Vec<Content>, MessageError> {
1106    match content {
1107        message::AssistantContent::Text(text) => {
1108            // The same empty-block rule the Responses serializer applies:
1109            // the API rejects empty text blocks, so an empty text block
1110            // with no anthropic-deliverable content (raw server-tool
1111            // content is the one extras shape this wire replays; foreign
1112            // extras — e.g. a block annotated by the OpenAI Responses
1113            // ingest — cannot reach this wire) produces no block at all.
1114            // A message left with no blocks fails loudly and locally at
1115            // the non-empty check below, never as a wire 400.
1116            if text.text.is_empty() && extract_anthropic_raw_content(&text)?.is_none() {
1117                return Ok(Vec::new());
1118            }
1119            Ok(vec![anthropic_text_content_from_message_text(text)?])
1120        }
1121        message::AssistantContent::Image(_) => Err(MessageError::ConversionError(
1122            "Anthropic currently doesn't support images.".to_string(),
1123        )),
1124        message::AssistantContent::ToolCall(tool_call) => Ok(vec![Content::ToolUse {
1125            // The wire requires a non-empty id: the provider-issued one when it
1126            // exists, else rig's minted handle.
1127            id: tool_call.wire_call_id().to_owned(),
1128            name: tool_call.function.name,
1129            input: coerce_tool_input(tool_call.function.arguments),
1130        }]),
1131        message::AssistantContent::Reasoning(reasoning) => {
1132            let mut converted = Vec::new();
1133            for block in reasoning.content {
1134                match block {
1135                    message::ReasoningContent::Text { text, signature } => {
1136                        converted.push(Content::Thinking {
1137                            thinking: text,
1138                            signature,
1139                        });
1140                    }
1141                    message::ReasoningContent::Summary(summary) => {
1142                        converted.push(Content::Thinking {
1143                            thinking: summary,
1144                            signature: None,
1145                        });
1146                    }
1147                    message::ReasoningContent::Redacted { data }
1148                    | message::ReasoningContent::Encrypted(data) => {
1149                        converted.push(Content::RedactedThinking { data });
1150                    }
1151                }
1152            }
1153
1154            if converted.is_empty() {
1155                return Err(MessageError::ConversionError(
1156                    "Cannot convert empty reasoning content to Anthropic format".to_string(),
1157                ));
1158            }
1159
1160            Ok(converted)
1161        }
1162    }
1163}
1164
1165impl TryFrom<message::Message> for Message {
1166    type Error = MessageError;
1167
1168    fn try_from(message: message::Message) -> Result<Self, Self::Error> {
1169        Ok(match message {
1170            message::Message::User { content } => Message {
1171                role: Role::User,
1172                content: content.into_iter().map(|content| match content {
1173                    message::UserContent::Text(message::Text { text, .. }) => {
1174                        Ok(Content::from(text))
1175                    }
1176                    message::UserContent::ToolResult(tool_result) => Ok(Content::ToolResult {
1177                        tool_use_id: tool_result.wire_call_id().to_owned(),
1178                        content: tool_result.content.into_iter().map(|content| match content {
1179                            message::ToolResultContent::Text(message::Text { text, .. }) => {
1180                                Ok(ToolResultContent::Text { text })
1181                            }
1182                            message::ToolResultContent::Json { value } => {
1183                                Ok(ToolResultContent::Text {
1184                                    text: value.to_string(),
1185                                })
1186                            }
1187                            message::ToolResultContent::Image(image) => {
1188                                let DocumentSourceKind::Base64(data) = image.data else {
1189                                    return Err(MessageError::ConversionError(
1190                                        "Only base64 strings can be used with the Anthropic API"
1191                                            .to_string(),
1192                                    ));
1193                                };
1194                                let media_type =
1195                                    image.media_type.ok_or(MessageError::ConversionError(
1196                                        "Image media type is required".to_owned(),
1197                                    ))?;
1198                                Ok(ToolResultContent::Image {
1199                                    source: ImageSource::Base64 {
1200                                        data,
1201                                        media_type: media_type.try_into()?,
1202                                    },
1203                                })
1204                            }
1205                        }).collect::<Result<Vec<_>, _>>()?,
1206                        is_error: None,
1207                        cache_control: None,
1208                    }),
1209                    message::UserContent::Image(message::Image {
1210                        data, media_type, ..
1211                    }) => {
1212                        let source = match data {
1213                            DocumentSourceKind::Base64(data) => {
1214                                let media_type =
1215                                    media_type.ok_or(MessageError::ConversionError(
1216                                        "Image media type is required for Claude API".to_string(),
1217                                    ))?;
1218                                ImageSource::Base64 {
1219                                    data,
1220                                    media_type: ImageFormat::try_from(media_type)?,
1221                                }
1222                            }
1223                            DocumentSourceKind::Url(url) => ImageSource::Url { url },
1224                            DocumentSourceKind::Unknown => {
1225                                return Err(MessageError::ConversionError(
1226                                    "Image content has no body".into(),
1227                                ));
1228                            }
1229                            doc => {
1230                                return Err(MessageError::ConversionError(format!(
1231                                    "Unsupported document type: {doc:?}"
1232                                )));
1233                            }
1234                        };
1235
1236                        Ok(Content::Image {
1237                            source,
1238                            cache_control: None,
1239                        })
1240                    }
1241                    message::UserContent::Document(message::Document {
1242                        data,
1243                        media_type,
1244                        additional_params,
1245                    }) => {
1246                        let (title, context, citations) =
1247                            extract_anthropic_doc_params(additional_params)?;
1248
1249                        if let DocumentSourceKind::FileId(file_id) = data {
1250                            return Ok(Content::Document {
1251                                source: DocumentSource::File { file_id },
1252                                title,
1253                                context,
1254                                citations,
1255                                cache_control: None,
1256                            });
1257                        }
1258
1259                        let media_type = match media_type {
1260                            Some(media_type) => media_type,
1261                            // Anthropic's URL document source has no media-type field and is
1262                            // defined specifically for PDFs, so the source itself is sufficient.
1263                            None if matches!(&data, DocumentSourceKind::Url(_)) => {
1264                                DocumentMediaType::PDF
1265                            }
1266                            None => {
1267                                return Err(MessageError::ConversionError(
1268                                    "Document media type is required".to_string(),
1269                                ));
1270                            }
1271                        };
1272
1273                        let source = match media_type {
1274                            DocumentMediaType::PDF => match data {
1275                                DocumentSourceKind::Base64(data)
1276                                | DocumentSourceKind::String(data) => DocumentSource::Base64 {
1277                                    data,
1278                                    media_type: DocumentFormat::PDF,
1279                                },
1280                                DocumentSourceKind::Url(url) => DocumentSource::Url { url },
1281                                _ => {
1282                                    return Err(MessageError::ConversionError(
1283                                        "Only base64 encoded data or URLs are supported for PDF documents".into(),
1284                                    ));
1285                                }
1286                            },
1287                            DocumentMediaType::TXT => {
1288                                let data = match data {
1289                                    DocumentSourceKind::String(data)
1290                                    | DocumentSourceKind::Base64(data) => data,
1291                                    _ => {
1292                                        return Err(MessageError::ConversionError(
1293                                            "Only string or base64 data is supported for plain text documents".into(),
1294                                        ));
1295                                    }
1296                                };
1297                                DocumentSource::Text {
1298                                    data,
1299                                    media_type: PlainTextMediaType::Plain,
1300                                }
1301                            }
1302                            other => {
1303                                return Err(MessageError::ConversionError(format!(
1304                                    "Anthropic only supports PDF and plain text documents, got: {}",
1305                                    other.to_mime_type()
1306                                )));
1307                            }
1308                        };
1309
1310                        Ok(Content::Document {
1311                            source,
1312                            title,
1313                            context,
1314                            citations,
1315                            cache_control: None,
1316                        })
1317                    }
1318                    message::UserContent::Audio { .. } => Err(MessageError::ConversionError(
1319                        "Audio is not supported in Anthropic".to_owned(),
1320                    )),
1321                    message::UserContent::Video { .. } => Err(MessageError::ConversionError(
1322                        "Video is not supported in Anthropic".to_owned(),
1323                    )),
1324                }).collect::<Result<Vec<_>, _>>()?,
1325            },
1326
1327            message::Message::System { content } => Message {
1328                role: Role::System,
1329                content: vec![Content::from(content)],
1330            },
1331
1332            message::Message::Assistant { content, .. } => {
1333                let converted_content = content.into_iter().try_fold(
1334                    Vec::new(),
1335                    |mut accumulated, assistant_content| {
1336                        accumulated
1337                            .extend(anthropic_content_from_assistant_content(assistant_content)?);
1338                        Ok::<Vec<Content>, MessageError>(accumulated)
1339                    },
1340                )?;
1341
1342                Message {
1343                    content: crate::message::require_non_empty(converted_content, || {
1344                        MessageError::ConversionError(
1345                            "Assistant message did not contain Anthropic-compatible content"
1346                                .to_owned(),
1347                        )
1348                    })?,
1349                    role: Role::Assistant,
1350                }
1351            }
1352        })
1353    }
1354}
1355
1356impl TryFrom<Content> for message::AssistantContent {
1357    type Error = MessageError;
1358
1359    fn try_from(content: Content) -> Result<Self, Self::Error> {
1360        Ok(match content {
1361            // Keep this destructuring exhaustive so new wire fields force an
1362            // explicit capture-or-drop decision. `cache_control` is a
1363            // request-side directive, deliberately dropped on response
1364            // ingest.
1365            Content::Text {
1366                text,
1367                citations,
1368                cache_control: _,
1369            } => {
1370                // Preserve citation metadata on the generic text block via
1371                // `additional_params` so callers going through the generic
1372                // `AssistantContent` surface can still recover them (see
1373                // [`anthropic_citations`]).
1374                let additional_params = message::AdditionalParams::from_entries(
1375                    (!citations.is_empty()).then(|| ("citations", serde_json::json!(citations))),
1376                );
1377                message::AssistantContent::Text(message::Text {
1378                    text,
1379                    additional_params,
1380                })
1381            }
1382            Content::ToolUse { id, name, input } => {
1383                message::AssistantContent::tool_call(id, name, input)
1384            }
1385            raw @ (Content::ServerToolUse { .. }
1386            | Content::WebSearchToolResult { .. }
1387            | Content::CodeExecutionToolResult { .. }) => {
1388                message::AssistantContent::Text(anthropic_raw_content_to_message_text(raw)?)
1389            }
1390            Content::Thinking {
1391                thinking,
1392                signature,
1393            } => message::AssistantContent::Reasoning(Reasoning::new_with_signature(
1394                &thinking, signature,
1395            )),
1396            Content::RedactedThinking { data } => {
1397                message::AssistantContent::Reasoning(Reasoning::redacted(data))
1398            }
1399            _ => {
1400                return Err(MessageError::ConversionError(
1401                    "Content did not contain a message, tool call, or reasoning".to_owned(),
1402                ));
1403            }
1404        })
1405    }
1406}
1407
1408impl From<ToolResultContent> for message::ToolResultContent {
1409    fn from(content: ToolResultContent) -> Self {
1410        match content {
1411            ToolResultContent::Text { text, .. } => message::ToolResultContent::text(text),
1412            ToolResultContent::Image { source } => match source {
1413                ImageSource::Base64 { data, media_type } => {
1414                    message::ToolResultContent::image_base64(data, Some(media_type.into()), None)
1415                }
1416                ImageSource::Url { url } => message::ToolResultContent::image_url(url, None, None),
1417            },
1418        }
1419    }
1420}
1421
1422impl TryFrom<Message> for message::Message {
1423    type Error = MessageError;
1424
1425    fn try_from(message: Message) -> Result<Self, Self::Error> {
1426        Ok(match message.role {
1427            Role::User => message::Message::User {
1428                content: message
1429                    .content
1430                    .into_iter()
1431                    .map(|content| {
1432                        Ok(match content {
1433                            Content::Text { text, .. } => message::UserContent::text(text),
1434                            Content::ToolResult {
1435                                tool_use_id,
1436                                content,
1437                                ..
1438                            } => message::UserContent::tool_result_from_wire(
1439                                tool_use_id,
1440                                // Anthropic's wire correlates results by id only
1441                                // and never carries the tool name; this
1442                                // conversion is lossy for name-keyed wires.
1443                                "",
1444                                content.into_iter().map(|content| content.into()).collect(),
1445                            ),
1446                            Content::Image { source, .. } => match source {
1447                                ImageSource::Base64 { data, media_type } => {
1448                                    message::UserContent::Image(message::Image {
1449                                        data: DocumentSourceKind::Base64(data),
1450                                        media_type: Some(media_type.into()),
1451                                        detail: None,
1452                                        additional_params: None,
1453                                    })
1454                                }
1455                                ImageSource::Url { url } => {
1456                                    message::UserContent::Image(message::Image {
1457                                        data: DocumentSourceKind::Url(url),
1458                                        media_type: None,
1459                                        detail: None,
1460                                        additional_params: None,
1461                                    })
1462                                }
1463                            },
1464                            Content::Document {
1465                                source,
1466                                title,
1467                                context,
1468                                citations,
1469                                ..
1470                            } => {
1471                                let additional_params = anthropic_document_additional_params(
1472                                    title, context, citations,
1473                                )?;
1474
1475                                match source {
1476                                    DocumentSource::Base64 { data, media_type } => {
1477                                        let rig_media_type = match media_type {
1478                                            DocumentFormat::PDF => message::DocumentMediaType::PDF,
1479                                        };
1480                                        message::UserContent::Document(message::Document {
1481                                            data: DocumentSourceKind::String(data),
1482                                            media_type: Some(rig_media_type),
1483                                            additional_params,
1484                                        })
1485                                    }
1486                                    DocumentSource::Text { data, .. } => {
1487                                        message::UserContent::Document(message::Document {
1488                                            data: DocumentSourceKind::String(data),
1489                                            media_type: Some(message::DocumentMediaType::TXT),
1490                                            additional_params,
1491                                        })
1492                                    }
1493                                    DocumentSource::Url { url } => {
1494                                        message::UserContent::Document(message::Document {
1495                                            data: DocumentSourceKind::Url(url),
1496                                            media_type: None,
1497                                            additional_params,
1498                                        })
1499                                    }
1500                                    DocumentSource::File { file_id } => {
1501                                        message::UserContent::Document(message::Document {
1502                                            data: DocumentSourceKind::FileId(file_id),
1503                                            media_type: None,
1504                                            additional_params,
1505                                        })
1506                                    }
1507                                }
1508                            }
1509                            _ => {
1510                                return Err(MessageError::ConversionError(
1511                                    "Unsupported content type for User role".to_owned(),
1512                                ));
1513                            }
1514                        })
1515                    })
1516                    .collect::<Result<Vec<_>, _>>()?,
1517            },
1518            Role::Assistant => message::Message::Assistant {
1519                id: None,
1520                content: message
1521                    .content
1522                    .into_iter()
1523                    .map(|content| content.try_into())
1524                    .collect::<Result<Vec<_>, _>>()?,
1525            },
1526            Role::System => {
1527                let content =
1528                    message
1529                        .content
1530                        .into_iter()
1531                        .try_fold(String::new(), |mut content, block| {
1532                            let Content::Text { text, .. } = block else {
1533                                return Err(MessageError::ConversionError(
1534                                    "Unsupported content type for System role".to_owned(),
1535                                ));
1536                            };
1537
1538                            content.push_str(&text);
1539                            Ok(content)
1540                        })?;
1541
1542                message::Message::System { content }
1543            }
1544        })
1545    }
1546}
1547
1548#[doc(hidden)]
1549#[derive(Clone)]
1550pub struct GenericCompletionModel<Ext = super::client::AnthropicExt, T = reqwest::Client> {
1551    pub(crate) client: crate::client::Client<Ext, T>,
1552    pub model: String,
1553    pub default_max_tokens: Option<u64>,
1554    /// Enable manual prompt caching (adds cache_control breakpoints to system prompt,
1555    /// tools, and messages)
1556    pub prompt_caching: bool,
1557    /// Enable Anthropic's automatic prompt caching (adds a top-level `cache_control` field to the
1558    /// request). The API automatically places the breakpoint on the last cacheable block and moves
1559    /// it forward as the conversation grows. No beta header is required.
1560    pub automatic_caching: bool,
1561    /// TTL for automatic caching. `None` uses the API default (5 minutes).
1562    /// Set to `Some(CacheTtl::OneHour)` for a 1-hour TTL.
1563    pub automatic_caching_ttl: Option<CacheTtl>,
1564    /// TTL for the static prefix (tool definitions + system prompt),
1565    /// independent of the conversation-tail breakpoint. `None` inherits the
1566    /// top-level/automatic TTL.
1567    pub static_prefix_cache_ttl: Option<CacheTtl>,
1568    /// Whether Rig-generated tools request provider-supported strict validation.
1569    pub strict_tools: bool,
1570}
1571
1572/// Anthropic completion model.
1573///
1574/// This preserves the historical public generic shape where the first generic
1575/// parameter is the HTTP client type.
1576pub type CompletionModel<T = reqwest::Client> =
1577    GenericCompletionModel<super::client::AnthropicExt, T>;
1578
1579impl<Ext, T> GenericCompletionModel<Ext, T>
1580where
1581    T: HttpClientExt,
1582    Ext: AnthropicCompatibleProvider + Clone + 'static,
1583{
1584    /// The request prelude both the unary and the streaming path run: resolve
1585    /// the model, open the telemetry span, default `max_tokens`, and build the
1586    /// typed request.
1587    ///
1588    /// The span is built *before* `max_tokens` defaulting so a request rejected
1589    /// for a missing limit is still attributable to a model and operation. The
1590    /// caller applies the span its own way — `.instrument(..)` around the unary
1591    /// send, handed to the SSE transport when streaming.
1592    ///
1593    /// Each caller TRACE-logs its own final body rather than this typed request:
1594    /// the streaming body is this request plus `stream` and a reconciled
1595    /// `tool_choice`, and those two fields are the whole reason the streaming
1596    /// path logs at all.
1597    pub(super) fn prepare_request(
1598        &self,
1599        mut completion_request: completion::CompletionRequest,
1600        operation: CompletionOperation,
1601    ) -> Result<(tracing::Span, AnthropicCompletionRequest), CompletionError> {
1602        let request_model = completion_request
1603            .model
1604            .clone()
1605            .unwrap_or_else(|| self.model.clone());
1606        let span = CompletionSpanBuilder::new(Ext::PROVIDER_NAME, &request_model, operation)
1607            .system_instructions(
1608                completion_request.preamble.as_deref(),
1609                completion_request.record_telemetry_content,
1610            )
1611            .build();
1612
1613        if completion_request.max_tokens.is_none() {
1614            let Some(tokens) = self.default_max_tokens else {
1615                return Err(CompletionError::RequestError(
1616                    "`max_tokens` must be set for Anthropic".into(),
1617                ));
1618            };
1619            completion_request.max_tokens = Some(tokens);
1620        }
1621
1622        let request = AnthropicCompletionRequest::try_from_params::<Ext>(
1623            AnthropicRequestParams {
1624                model: &request_model,
1625                request: completion_request,
1626                prompt_caching: self.prompt_caching,
1627                automatic_caching: self.automatic_caching,
1628                automatic_caching_ttl: self.automatic_caching_ttl.clone(),
1629                static_prefix_cache_ttl: self.static_prefix_cache_ttl.clone(),
1630            },
1631            self.strict_tools,
1632        )?;
1633
1634        Ok((span, request))
1635    }
1636
1637    /// A model with every caching / strictness knob off, differing from its
1638    /// siblings only in how `default_max_tokens` was resolved.
1639    fn with_defaults(
1640        client: crate::client::Client<Ext, T>,
1641        model: String,
1642        default_max_tokens: Option<u64>,
1643    ) -> Self {
1644        Self {
1645            client,
1646            model,
1647            default_max_tokens,
1648            prompt_caching: false,
1649            automatic_caching: false,
1650            automatic_caching_ttl: None,
1651            static_prefix_cache_ttl: None,
1652            strict_tools: false,
1653        }
1654    }
1655
1656    pub fn new(client: crate::client::Client<Ext, T>, model: impl Into<String>) -> Self {
1657        let model = model.into();
1658        let default_max_tokens = Ext::default_max_tokens(&model);
1659
1660        Self::with_defaults(client, model, default_max_tokens)
1661    }
1662
1663    pub fn with_model(client: crate::client::Client<Ext, T>, model: &str) -> Self {
1664        let default_max_tokens = Ext::default_max_tokens(model)
1665            .or_else(|| Some(default_max_tokens_with_fallback(model)));
1666
1667        Self::with_defaults(client, model.to_string(), default_max_tokens)
1668    }
1669
1670    /// Enable manual prompt caching.
1671    ///
1672    /// When enabled, cache_control breakpoints are automatically added to:
1673    /// - The system prompt (marked with ephemeral cache)
1674    /// - The final tool definition, when tools are present (marked with ephemeral cache)
1675    /// - The last content block of the last message (marked with ephemeral cache)
1676    ///
1677    /// This allows Anthropic to cache the system prompt, tools layer, and conversation
1678    /// history for cost savings. Use [`with_automatic_caching`] when you want Anthropic
1679    /// to choose and advance a single top-level cache breakpoint automatically.
1680    /// When combined with [`with_automatic_caching`], the top-level automatic breakpoint
1681    /// owns the moving message cache point while Rig still marks tools and system prompt
1682    /// blocks when budget permits.
1683    /// Existing `cache_control` markers in provider-specific tool definitions are preserved
1684    /// and count toward Anthropic's request limit of 4 cache breakpoints.
1685    ///
1686    /// [`with_automatic_caching`]: CompletionModel::with_automatic_caching
1687    pub fn with_prompt_caching(mut self) -> Self {
1688        self.prompt_caching = true;
1689        self
1690    }
1691
1692    /// Enable Anthropic's automatic prompt caching.
1693    ///
1694    /// When enabled, a top-level `cache_control: { "type": "ephemeral" }` field is added to every
1695    /// request. Anthropic's API automatically applies the cache breakpoint to the last cacheable
1696    /// block and moves it forward as the conversation grows — no beta header and no manual
1697    /// breakpoint management are required.
1698    ///
1699    /// This is the recommended approach for multi-turn conversations. Use [`with_prompt_caching`]
1700    /// instead when you need fine-grained, per-block control over what is cached.
1701    ///
1702    /// To use a one-hour TTL instead of the default five minutes, use
1703    /// [`with_automatic_caching_1h`] or pass top-level `cache_control` with
1704    /// `ttl: "1h"` via `additional_params`. Rig normalizes raw top-level
1705    /// `cache_control` before budgeting and ordering manual prompt cache markers.
1706    ///
1707    /// ```ignore
1708    /// let model = client.completion_model(anthropic::completion::CLAUDE_SONNET_4_6)
1709    ///     .with_automatic_caching();
1710    /// ```
1711    ///
1712    /// ## Minimum cacheable prompt length
1713    ///
1714    /// The combined prompt (tools + system + messages up to the automatically chosen breakpoint)
1715    /// must meet the model-specific minimum or caching is silently skipped by the API:
1716    ///
1717    /// | Model | Minimum tokens |
1718    /// |-------|---------------|
1719    /// | `claude-opus-4-7`, `claude-opus-4-6`, `claude-opus-4-5` | 4 096 |
1720    /// | `claude-sonnet-4-6` | 2 048 |
1721    /// | `claude-sonnet-4-5`, `claude-opus-4-1`, `claude-opus-4`, `claude-sonnet-4` | 1 024 |
1722    /// | `claude-haiku-4-5` | 4 096 |
1723    ///
1724    /// [`with_prompt_caching`]: CompletionModel::with_prompt_caching
1725    /// [`with_automatic_caching_1h`]: CompletionModel::with_automatic_caching_1h
1726    pub fn with_automatic_caching(mut self) -> Self {
1727        self.automatic_caching = true;
1728        self
1729    }
1730
1731    /// Enable Anthropic's automatic prompt caching with a 1-hour TTL.
1732    ///
1733    /// Identical to [`with_automatic_caching`] but sets `ttl: "1h"` on the
1734    /// top-level `cache_control` field:
1735    ///
1736    /// ```ignore
1737    /// let model = client.completion_model(anthropic::completion::CLAUDE_SONNET_4_6)
1738    ///     .with_automatic_caching_1h();
1739    /// ```
1740    ///
1741    /// [`with_automatic_caching`]: CompletionModel::with_automatic_caching
1742    pub fn with_automatic_caching_1h(mut self) -> Self {
1743        self.automatic_caching = true;
1744        self.automatic_caching_ttl = Some(CacheTtl::OneHour);
1745        self
1746    }
1747
1748    /// Set the cache TTL for the static prefix (tool definitions + system
1749    /// prompt), independent of the moving conversation-tail breakpoint.
1750    ///
1751    /// An agent's prompt has two parts with very different volatility: the
1752    /// system prompt and tool definitions are byte-identical across sessions,
1753    /// while the conversation tail changes every turn and is worthless an hour
1754    /// later. A 1-hour cache write costs ~2x base input tokens where a
1755    /// 5-minute write costs ~1.25x, so the optimal configuration is usually
1756    /// mixed — `1h` on the prefix, the 5-minute default on the tail:
1757    ///
1758    /// ```ignore
1759    /// let model = client.completion_model(anthropic::completion::CLAUDE_SONNET_4_6)
1760    ///     .with_automatic_caching()
1761    ///     .with_static_prefix_cache_ttl(CacheTtl::OneHour);
1762    /// ```
1763    ///
1764    /// Rig places explicit `cache_control` markers on the final tool
1765    /// definition and the system prompt at this TTL. The conversation tail is
1766    /// unaffected: it follows the automatic/top-level TTL (Anthropic's moving
1767    /// breakpoint in automatic mode, Rig's last-message marker in manual
1768    /// [`with_prompt_caching`] mode). When this knob is unset, the prefix
1769    /// inherits the top-level TTL exactly as before.
1770    ///
1771    /// Anthropic requires 1-hour markers to precede 5-minute ones. The static
1772    /// prefix precedes the tail, so `OneHour` here composes with a 5-minute
1773    /// tail — but setting `FiveMinutes` here alongside
1774    /// [`with_automatic_caching_1h`] is the illegal inversion and fails before
1775    /// any request is sent. The model-specific minimum cacheable prompt
1776    /// lengths tabulated on [`with_automatic_caching`] apply to each marker;
1777    /// below the minimum, Anthropic silently skips caching.
1778    ///
1779    /// [`with_prompt_caching`]: CompletionModel::with_prompt_caching
1780    /// [`with_automatic_caching`]: CompletionModel::with_automatic_caching
1781    /// [`with_automatic_caching_1h`]: CompletionModel::with_automatic_caching_1h
1782    pub fn with_static_prefix_cache_ttl(mut self, ttl: CacheTtl) -> Self {
1783        self.static_prefix_cache_ttl = Some(ttl);
1784        self
1785    }
1786}
1787
1788impl<T> GenericCompletionModel<super::client::AnthropicExt, T>
1789where
1790    T: HttpClientExt,
1791{
1792    /// Enable Anthropic strict tool use for every Rig-generated tool.
1793    ///
1794    /// Anthropic constrains tool inputs to the supported JSON Schema subset
1795    /// when `strict: true` is present on a tool definition. Rig sanitizes each
1796    /// generated tool schema for that subset and leaves provider-specific tools
1797    /// supplied through `additional_params` unchanged. Unsupported validation
1798    /// keywords are retained only as model guidance in schema descriptions;
1799    /// neither Anthropic nor Rig enforces those original constraints, so
1800    /// validate tool inputs before execution when those constraints matter.
1801    ///
1802    /// Anthropic caches compiled schemas for up to 24 hours. Do not include PHI
1803    /// in schema property names, enum or const values, or regex patterns. See
1804    /// Anthropic's [structured output retention guidance](https://platform.claude.com/docs/en/build-with-claude/structured-outputs#data-retention).
1805    /// Anthropic also limits each request to 20 strict tools and applies
1806    /// additional schema-complexity limits; see [schema complexity limits](https://platform.claude.com/docs/en/build-with-claude/structured-outputs#schema-complexity-limits).
1807    pub fn with_strict_tools(mut self) -> Self {
1808        self.strict_tools = true;
1809        self
1810    }
1811}
1812
1813/// Anthropic requires a `max_tokens` parameter to be set, which is dependent on the model. If not
1814/// set or if set too high, the request will fail. The following values are based on Anthropic's
1815/// published synchronous Messages API output limits for current models.
1816fn default_max_tokens_for_model(model: &str) -> Option<u64> {
1817    if model.starts_with("claude-opus-4-8")
1818        || model.starts_with("claude-opus-4-7")
1819        || model.starts_with("claude-opus-4-6")
1820    {
1821        Some(128_000)
1822    } else if model.starts_with("claude-opus-4")
1823        || model.starts_with("claude-sonnet-4")
1824        || model.starts_with("claude-haiku-4-5")
1825    {
1826        Some(64_000)
1827    } else {
1828        None
1829    }
1830}
1831
1832fn default_max_tokens_with_fallback(model: &str) -> u64 {
1833    default_max_tokens_for_model(model).unwrap_or(2_048)
1834}
1835
1836pub(super) fn supports_mid_conversation_system_messages(model: &str) -> bool {
1837    model.starts_with(CLAUDE_OPUS_4_8)
1838}
1839
1840#[derive(Debug, Deserialize, Serialize)]
1841pub struct Metadata {
1842    user_id: Option<String>,
1843}
1844
1845#[derive(Default, Debug, Serialize, Deserialize)]
1846#[serde(tag = "type", rename_all = "snake_case")]
1847pub enum ToolChoice {
1848    #[default]
1849    Auto,
1850    Any,
1851    None,
1852    Tool {
1853        name: String,
1854    },
1855}
1856impl TryFrom<message::ToolChoice> for ToolChoice {
1857    type Error = CompletionError;
1858
1859    fn try_from(value: message::ToolChoice) -> Result<Self, Self::Error> {
1860        let res = match value {
1861            message::ToolChoice::Auto => Self::Auto,
1862            message::ToolChoice::None => Self::None,
1863            message::ToolChoice::Required => Self::Any,
1864            message::ToolChoice::Specific { function_names } => {
1865                if function_names.len() != 1 {
1866                    return Err(CompletionError::ProviderError(
1867                        "Only one tool may be specified to be used by Claude".into(),
1868                    ));
1869                }
1870
1871                let Some(name) = function_names.into_iter().next() else {
1872                    return Err(CompletionError::ProviderError(
1873                        "Only one tool may be specified to be used by Claude".into(),
1874                    ));
1875                };
1876
1877                Self::Tool { name }
1878            }
1879        };
1880
1881        Ok(res)
1882    }
1883}
1884
1885/// Recursively ensures all object schemas respect Anthropic structured output restrictions:
1886/// - `additionalProperties` must be explicitly set to `false` on every object
1887/// - All properties must be listed in `required`
1888///
1889/// Source: <https://docs.anthropic.com/en/docs/build-with-claude/structured-outputs#json-schema-limitations>
1890fn sanitize_schema(schema: &mut serde_json::Value) {
1891    crate::providers::internal::schema::sanitize_schema(
1892        schema,
1893        crate::providers::internal::schema::SanitizeOptions {
1894            strip_ref_siblings: false,
1895            inject_empty_properties: false,
1896            strip_numeric_constraints: true,
1897        },
1898    );
1899}
1900
1901/// Adapt a strict tool schema using Anthropic's SDK transformation policy.
1902///
1903/// Strict tools support optional parameters, so declared `required` lists are
1904/// preserved. Unsupported validation keywords are moved into descriptions as
1905/// model guidance instead of reaching the constrained-decoding compiler.
1906fn sanitize_strict_tool_schema(schema: &mut serde_json::Value) {
1907    let mut original = std::mem::take(schema);
1908    inline_local_root_reference(&mut original);
1909    flatten_root_all_of(&mut original);
1910    // Anthropic requires a tool's top-level input schema to declare an object
1911    // type even when standard JSON Schema would infer it from `properties` or
1912    // a resolved root reference. Nested schemas do not have this tool-input
1913    // restriction.
1914    if let serde_json::Value::Object(source) = &mut original
1915        && !source.contains_key("type")
1916        && (source.contains_key("properties") || source.contains_key("$ref"))
1917    {
1918        source.insert(
1919            "type".to_string(),
1920            serde_json::Value::String("object".to_string()),
1921        );
1922    }
1923    *schema = transform_strict_tool_schema(original);
1924}
1925
1926/// Anthropic rejects `allOf` at the top level of a tool input even when every
1927/// branch describes an object. Merge those object branches into the root while
1928/// preserving per-property collisions as nested `allOf` constraints.
1929fn flatten_root_all_of(schema: &mut serde_json::Value) {
1930    use serde_json::{Map, Value};
1931
1932    let Value::Object(root) = schema else {
1933        return;
1934    };
1935    let Some(all_of) = root.remove("allOf") else {
1936        return;
1937    };
1938    let mut conflicting_constraints = Map::new();
1939    merge_root_all_of(root, all_of, &mut conflicting_constraints);
1940    if !conflicting_constraints.is_empty() {
1941        root.insert(
1942            "rootAllOfConstraints".to_string(),
1943            Value::Object(conflicting_constraints),
1944        );
1945    }
1946}
1947
1948/// Anthropic requires a tool input's root to have `type: object`, but rejects
1949/// `type` beside `$ref`. Resolve local root references before transformation so
1950/// both requirements can be met while retaining definitions needed by nested
1951/// references.
1952fn inline_local_root_reference(schema: &mut serde_json::Value) {
1953    use serde_json::Value;
1954
1955    let mut seen = std::collections::BTreeSet::new();
1956    loop {
1957        let Some(reference) = schema
1958            .get("$ref")
1959            .and_then(serde_json::Value::as_str)
1960            .map(str::to_string)
1961        else {
1962            return;
1963        };
1964        let Some(pointer) = reference.strip_prefix('#') else {
1965            return;
1966        };
1967        if !seen.insert(reference.clone()) {
1968            return;
1969        }
1970        let Some(Value::Object(mut referenced)) = schema.pointer(pointer).cloned() else {
1971            return;
1972        };
1973        let Some(mut root) = schema.as_object().cloned() else {
1974            return;
1975        };
1976        root.remove("$ref");
1977
1978        for keyword in ["$defs", "definitions"] {
1979            let Some(root_definitions) = root.remove(keyword) else {
1980                continue;
1981            };
1982            let definitions =
1983                merge_document_definitions(root_definitions, referenced.remove(keyword));
1984            referenced.insert(keyword.to_string(), definitions);
1985        }
1986
1987        merge_root_reference_siblings(&mut referenced, root);
1988
1989        *schema = Value::Object(referenced);
1990    }
1991}
1992
1993fn merge_document_definitions(
1994    root_definitions: serde_json::Value,
1995    local_definitions: Option<serde_json::Value>,
1996) -> serde_json::Value {
1997    use serde_json::Value;
1998
1999    match (root_definitions, local_definitions) {
2000        (Value::Object(root_definitions), Some(Value::Object(mut local_definitions))) => {
2001            // Absolute JSON pointers still resolve from the document root.
2002            // Keep those root targets authoritative when an inlined schema
2003            // happens to define the same name locally.
2004            local_definitions.extend(root_definitions);
2005            Value::Object(local_definitions)
2006        }
2007        (root_definitions, _) => root_definitions,
2008    }
2009}
2010
2011/// Merge keywords adjacent to a root `$ref` into its resolved object. JSON
2012/// Schema applies those siblings conjunctively; simply replacing the root with
2013/// the referenced object would silently discard valid constraints.
2014fn merge_root_reference_siblings(
2015    referenced: &mut serde_json::Map<String, serde_json::Value>,
2016    siblings: serde_json::Map<String, serde_json::Value>,
2017) {
2018    use serde_json::{Map, Value};
2019
2020    let mut conflicting_constraints = Map::new();
2021    for (keyword, sibling) in siblings {
2022        match keyword.as_str() {
2023            "properties" => merge_schema_properties(referenced, sibling),
2024            "required" => merge_required_properties(referenced, sibling),
2025            "allOf" => merge_root_all_of(referenced, sibling, &mut conflicting_constraints),
2026            // Anthropic rejects union combinators at the tool-input root. A
2027            // conjunction between a referenced object and a union cannot be
2028            // flattened without duplicating the whole base schema, so retain
2029            // it as guidance instead of producing a guaranteed 400.
2030            "anyOf" | "oneOf" => {
2031                conflicting_constraints.insert(keyword, sibling);
2032            }
2033            // These describe the root document rather than adding a second
2034            // validation constraint. Prefer the root-level annotation.
2035            "description" | "title" | "$schema" | "$id" | "$comment" | "default" | "examples"
2036            | "deprecated" | "readOnly" | "writeOnly" => {
2037                referenced.insert(keyword, sibling);
2038            }
2039            _ => match referenced.get(&keyword) {
2040                None => {
2041                    referenced.insert(keyword, sibling);
2042                }
2043                Some(existing) if existing == &sibling => {}
2044                Some(_) => {
2045                    conflicting_constraints.insert(keyword, sibling);
2046                }
2047            },
2048        }
2049    }
2050
2051    if !conflicting_constraints.is_empty() {
2052        // Anthropic rejects allOf at the top level of a tool input. Preserve
2053        // constraints that cannot be structurally merged as model guidance,
2054        // consistent with the rest of the strict-schema transformer.
2055        referenced.insert(
2056            "rootRefSiblingConstraints".to_string(),
2057            Value::Object(conflicting_constraints),
2058        );
2059    }
2060}
2061
2062fn merge_root_all_of(
2063    schema: &mut serde_json::Map<String, serde_json::Value>,
2064    sibling: serde_json::Value,
2065    conflicting_constraints: &mut serde_json::Map<String, serde_json::Value>,
2066) {
2067    use serde_json::Value;
2068
2069    let Value::Array(branches) = sibling else {
2070        conflicting_constraints.insert("allOf".to_string(), sibling);
2071        return;
2072    };
2073    let mut unsupported_branches = Vec::new();
2074    for branch in branches {
2075        match branch {
2076            Value::Object(mut branch) => {
2077                if branch.contains_key("$ref") {
2078                    for keyword in ["$defs", "definitions"] {
2079                        let Some(root_definitions) = schema.get(keyword).cloned() else {
2080                            continue;
2081                        };
2082                        let definitions =
2083                            merge_document_definitions(root_definitions, branch.remove(keyword));
2084                        branch.insert(keyword.to_string(), definitions);
2085                    }
2086                    let mut branch = Value::Object(branch);
2087                    inline_local_root_reference(&mut branch);
2088                    match branch {
2089                        Value::Object(branch) => merge_root_reference_siblings(schema, branch),
2090                        branch => unsupported_branches.push(branch),
2091                    }
2092                } else {
2093                    merge_root_reference_siblings(schema, branch);
2094                }
2095            }
2096            branch => unsupported_branches.push(branch),
2097        }
2098    }
2099    if !unsupported_branches.is_empty() {
2100        conflicting_constraints.insert("allOf".to_string(), Value::Array(unsupported_branches));
2101    }
2102}
2103
2104fn merge_schema_properties(
2105    schema: &mut serde_json::Map<String, serde_json::Value>,
2106    sibling: serde_json::Value,
2107) {
2108    use serde_json::{Map, Value};
2109
2110    let Value::Object(sibling_properties) = sibling else {
2111        schema.entry("properties".to_string()).or_insert(sibling);
2112        return;
2113    };
2114    let properties = schema
2115        .entry("properties".to_string())
2116        .or_insert_with(|| Value::Object(Map::new()));
2117    let Value::Object(properties) = properties else {
2118        return;
2119    };
2120
2121    for (name, sibling_schema) in sibling_properties {
2122        match properties.remove(&name) {
2123            None => {
2124                properties.insert(name, sibling_schema);
2125            }
2126            Some(existing) if existing == sibling_schema => {
2127                properties.insert(name, existing);
2128            }
2129            Some(existing) => {
2130                properties.insert(
2131                    name,
2132                    Value::Object(Map::from_iter([(
2133                        "allOf".to_string(),
2134                        Value::Array(vec![existing, sibling_schema]),
2135                    )])),
2136                );
2137            }
2138        }
2139    }
2140}
2141
2142fn merge_required_properties(
2143    schema: &mut serde_json::Map<String, serde_json::Value>,
2144    sibling: serde_json::Value,
2145) {
2146    use serde_json::Value;
2147
2148    let Value::Array(sibling_required) = sibling else {
2149        schema.entry("required".to_string()).or_insert(sibling);
2150        return;
2151    };
2152    let required = schema
2153        .entry("required".to_string())
2154        .or_insert_with(|| Value::Array(Vec::new()));
2155    let Value::Array(required) = required else {
2156        return;
2157    };
2158    for name in sibling_required {
2159        if !required.contains(&name) {
2160            required.push(name);
2161        }
2162    }
2163}
2164
2165fn transform_strict_tool_schema(schema: serde_json::Value) -> serde_json::Value {
2166    use serde_json::{Map, Value};
2167
2168    let Value::Object(mut source) = schema else {
2169        return schema;
2170    };
2171    let mut strict = Map::new();
2172
2173    for keyword in ["$defs", "definitions"] {
2174        if let Some(definitions) = source.remove(keyword) {
2175            match definitions {
2176                Value::Object(definitions) => {
2177                    strict.insert(
2178                        keyword.to_string(),
2179                        Value::Object(
2180                            definitions
2181                                .into_iter()
2182                                .map(|(name, schema)| (name, transform_strict_tool_schema(schema)))
2183                                .collect(),
2184                        ),
2185                    );
2186                }
2187                definitions => {
2188                    source.insert(keyword.to_string(), definitions);
2189                }
2190            }
2191        }
2192    }
2193
2194    if let Some(reference) = source.remove("$ref") {
2195        strict.insert("$ref".to_string(), reference);
2196        return Value::Object(strict);
2197    }
2198
2199    let schema_type = source.remove("type");
2200    let any_of = source.remove("anyOf");
2201    let one_of = source.remove("oneOf");
2202    let all_of = source.remove("allOf");
2203    let alternatives = match (any_of, one_of, all_of) {
2204        (Some(Value::Array(variants)), _, _) => Some(("anyOf", variants)),
2205        (_, Some(Value::Array(variants)), _) => Some(("anyOf", variants)),
2206        (_, _, Some(Value::Array(variants))) => Some(("allOf", variants)),
2207        _ => None,
2208    };
2209    if let Some((keyword, variants)) = alternatives {
2210        strict.insert(
2211            keyword.to_string(),
2212            Value::Array(
2213                variants
2214                    .into_iter()
2215                    .map(transform_strict_tool_schema)
2216                    .collect(),
2217            ),
2218        );
2219    } else if let Some(schema_type) = schema_type.clone() {
2220        strict.insert("type".to_string(), schema_type);
2221    }
2222
2223    if let Some(Value::Array(values)) = source.remove("enum") {
2224        strict.insert("enum".to_string(), Value::Array(values));
2225    }
2226    if let Some(constant) = source.remove("const") {
2227        strict.insert("const".to_string(), constant);
2228    }
2229    for keyword in ["description", "title"] {
2230        if let Some(Value::String(value)) = source.remove(keyword) {
2231            strict.insert(keyword.to_string(), Value::String(value));
2232        }
2233    }
2234
2235    let has_properties = source.contains_key("properties");
2236    let properties_imply_object = schema_type.is_none() && has_properties;
2237    if properties_imply_object {
2238        strict.insert("type".to_string(), Value::String("object".to_string()));
2239    }
2240    if schema_has_type(schema_type.as_ref(), "object") || has_properties {
2241        let properties = match source.remove("properties") {
2242            Some(Value::Object(properties)) => properties
2243                .into_iter()
2244                .map(|(name, schema)| (name, transform_strict_tool_schema(schema)))
2245                .collect(),
2246            _ => Map::new(),
2247        };
2248        strict.insert("properties".to_string(), Value::Object(properties));
2249        source.remove("additionalProperties");
2250        strict.insert("additionalProperties".to_string(), Value::Bool(false));
2251        if let Some(Value::Array(required)) = source.remove("required") {
2252            strict.insert("required".to_string(), Value::Array(required));
2253        }
2254    }
2255
2256    if schema_has_type(schema_type.as_ref(), "string")
2257        && let Some(format) = source.remove("format")
2258    {
2259        const SUPPORTED_FORMATS: &[&str] = &[
2260            "date-time",
2261            "time",
2262            "date",
2263            "duration",
2264            "email",
2265            "hostname",
2266            "uri",
2267            "ipv4",
2268            "ipv6",
2269            "uuid",
2270        ];
2271        if format
2272            .as_str()
2273            .is_some_and(|format| SUPPORTED_FORMATS.contains(&format))
2274        {
2275            strict.insert("format".to_string(), format);
2276        } else {
2277            source.insert("format".to_string(), format);
2278        }
2279    }
2280
2281    if schema_has_type(schema_type.as_ref(), "array") {
2282        if let Some(items) = source.remove("items") {
2283            strict.insert("items".to_string(), transform_strict_tool_schema(items));
2284        }
2285        if let Some(min_items) = source.remove("minItems") {
2286            if matches!(min_items.as_u64(), Some(0 | 1)) {
2287                strict.insert("minItems".to_string(), min_items);
2288            } else {
2289                source.insert("minItems".to_string(), min_items);
2290            }
2291        }
2292    }
2293
2294    if !source.is_empty() {
2295        let hints = source
2296            .into_iter()
2297            .map(|(keyword, value)| {
2298                let value = match value {
2299                    Value::String(value) => value,
2300                    value => value.to_string(),
2301                };
2302                format!("{keyword}: {value}")
2303            })
2304            .collect::<Vec<_>>()
2305            .join(", ");
2306        let suffix = format!("{{{hints}}}");
2307        match strict.get_mut("description") {
2308            Some(Value::String(description)) => {
2309                description.push_str("\n\n");
2310                description.push_str(&suffix);
2311            }
2312            _ => {
2313                strict.insert("description".to_string(), Value::String(suffix));
2314            }
2315        }
2316    }
2317
2318    Value::Object(strict)
2319}
2320
2321fn schema_has_type(schema_type: Option<&serde_json::Value>, expected: &str) -> bool {
2322    match schema_type {
2323        Some(serde_json::Value::String(schema_type)) => schema_type == expected,
2324        Some(serde_json::Value::Array(schema_types)) => schema_types
2325            .iter()
2326            .any(|schema_type| schema_type.as_str() == Some(expected)),
2327        _ => false,
2328    }
2329}
2330
2331/// Output format specifier for Anthropic's structured output.
2332/// Source: <https://docs.anthropic.com/en/api/messages>
2333#[derive(Debug, Deserialize, Serialize)]
2334#[serde(tag = "type", rename_all = "snake_case")]
2335enum OutputFormat {
2336    /// Constrains the model's response to conform to the provided JSON schema.
2337    JsonSchema { schema: serde_json::Value },
2338}
2339
2340/// Configuration for the model's output format.
2341#[derive(Debug, Deserialize, Serialize)]
2342struct OutputConfig {
2343    format: OutputFormat,
2344}
2345
2346#[derive(Debug, Deserialize, Serialize)]
2347pub(super) struct AnthropicCompletionRequest {
2348    model: String,
2349    messages: Vec<Message>,
2350    max_tokens: u64,
2351    /// System prompt as array of content blocks to support cache_control
2352    #[serde(skip_serializing_if = "Vec::is_empty")]
2353    system: Vec<SystemContent>,
2354    #[serde(skip_serializing_if = "Option::is_none")]
2355    temperature: Option<f64>,
2356    #[serde(skip_serializing_if = "Option::is_none")]
2357    tool_choice: Option<ToolChoice>,
2358    #[serde(skip_serializing_if = "Vec::is_empty")]
2359    tools: Vec<serde_json::Value>,
2360    #[serde(skip_serializing_if = "Option::is_none")]
2361    output_config: Option<OutputConfig>,
2362    #[serde(flatten, skip_serializing_if = "Option::is_none")]
2363    additional_params: Option<serde_json::Value>,
2364    /// Top-level cache_control for Anthropic's automatic caching mode. When set, the API
2365    /// automatically places the cache breakpoint on the last cacheable block and advances it as
2366    /// the conversation grows. No beta header is required.
2367    #[serde(skip_serializing_if = "Option::is_none")]
2368    cache_control: Option<CacheControl>,
2369}
2370
2371/// Helper to set cache_control on a Content block
2372fn set_content_cache_control(content: &mut Content, value: Option<CacheControl>) {
2373    match content {
2374        Content::Text { cache_control, .. } => *cache_control = value,
2375        Content::Image { cache_control, .. } => *cache_control = value,
2376        Content::ToolResult { cache_control, .. } => *cache_control = value,
2377        Content::Document { cache_control, .. } => *cache_control = value,
2378        _ => {}
2379    }
2380}
2381
2382const MAX_CACHE_CONTROL_MARKERS: usize = 4;
2383
2384fn final_cacheable_tool_idx(tools: &[serde_json::Value]) -> Option<usize> {
2385    tools.iter().rposition(|tool| {
2386        tool.as_object().is_some_and(|tool| {
2387            !matches!(
2388                tool.get("defer_loading"),
2389                Some(serde_json::Value::Bool(true))
2390            )
2391        })
2392    })
2393}
2394
2395fn tool_cache_control_count(tools: &[serde_json::Value]) -> usize {
2396    tools
2397        .iter()
2398        .filter(|tool| tool_cache_control_value(tool).is_some())
2399        .count()
2400}
2401
2402fn tool_cache_control_value(tool: &serde_json::Value) -> Option<&serde_json::Value> {
2403    tool.get("cache_control")
2404        .filter(|cache_control| !cache_control.is_null())
2405}
2406
2407fn normalize_tool_cache_control(tools: &mut [serde_json::Value]) {
2408    for tool in tools.iter_mut() {
2409        if let Some(tool) = tool.as_object_mut()
2410            && tool
2411                .get("cache_control")
2412                .is_some_and(serde_json::Value::is_null)
2413        {
2414            tool.remove("cache_control");
2415        }
2416    }
2417}
2418
2419fn build_cache_control(ttl: Option<CacheTtl>) -> CacheControl {
2420    CacheControl::Ephemeral { ttl }
2421}
2422
2423#[derive(Clone, Copy, PartialEq, Eq)]
2424enum CacheControlTtl {
2425    FiveMinutes,
2426    OneHour,
2427}
2428
2429fn cache_control_ttl(cache_control: &CacheControl) -> CacheControlTtl {
2430    match cache_control {
2431        CacheControl::Ephemeral {
2432            ttl: Some(CacheTtl::OneHour),
2433        } => CacheControlTtl::OneHour,
2434        CacheControl::Ephemeral { .. } => CacheControlTtl::FiveMinutes,
2435    }
2436}
2437
2438fn cache_control_ttl_from_json(cache_control: &serde_json::Value) -> CacheControlTtl {
2439    match cache_control.get("ttl") {
2440        Some(serde_json::Value::String(ttl)) if ttl == "1h" => CacheControlTtl::OneHour,
2441        _ => CacheControlTtl::FiveMinutes,
2442    }
2443}
2444
2445fn content_cache_control(content: &Content) -> Option<&CacheControl> {
2446    match content {
2447        Content::Text { cache_control, .. }
2448        | Content::Image { cache_control, .. }
2449        | Content::ToolResult { cache_control, .. }
2450        | Content::Document { cache_control, .. } => cache_control.as_ref(),
2451        _ => None,
2452    }
2453}
2454
2455fn validate_cache_control_ttl(
2456    ttl: CacheControlTtl,
2457    shorter_ttl_seen: &mut bool,
2458) -> Result<(), CompletionError> {
2459    match ttl {
2460        CacheControlTtl::OneHour if *shorter_ttl_seen => Err(CompletionError::RequestError(
2461            "Anthropic cache_control markers with ttl `1h` must appear before markers with \
2462                 the default 5-minute TTL"
2463                .into(),
2464        )),
2465        CacheControlTtl::OneHour => Ok(()),
2466        CacheControlTtl::FiveMinutes => {
2467            *shorter_ttl_seen = true;
2468            Ok(())
2469        }
2470    }
2471}
2472
2473fn validate_cache_control_ttl_order(
2474    system: &[SystemContent],
2475    messages: &[Message],
2476    tools: &[serde_json::Value],
2477    top_level_cache_control: Option<&CacheControl>,
2478) -> Result<(), CompletionError> {
2479    let mut shorter_ttl_seen = false;
2480
2481    for tool in tools {
2482        if let Some(cache_control) = tool_cache_control_value(tool) {
2483            validate_cache_control_ttl(
2484                cache_control_ttl_from_json(cache_control),
2485                &mut shorter_ttl_seen,
2486            )?;
2487        }
2488    }
2489
2490    for SystemContent::Text { cache_control, .. } in system {
2491        if let Some(cache_control) = cache_control {
2492            validate_cache_control_ttl(cache_control_ttl(cache_control), &mut shorter_ttl_seen)?;
2493        }
2494    }
2495
2496    for message in messages {
2497        for content in message.content.iter() {
2498            if let Some(cache_control) = content_cache_control(content) {
2499                validate_cache_control_ttl(
2500                    cache_control_ttl(cache_control),
2501                    &mut shorter_ttl_seen,
2502                )?;
2503            }
2504        }
2505    }
2506
2507    if let Some(cache_control) = top_level_cache_control {
2508        validate_cache_control_ttl(cache_control_ttl(cache_control), &mut shorter_ttl_seen)?;
2509    }
2510
2511    Ok(())
2512}
2513
2514fn top_level_cache_control_ttl(cache_control: Option<&CacheControl>) -> Option<CacheTtl> {
2515    cache_control
2516        .map(|cache_control| match cache_control {
2517            CacheControl::Ephemeral { ttl } => ttl.clone(),
2518        })
2519        .unwrap_or_default()
2520}
2521
2522/// Apply a cache-control breakpoint to the final cacheable tool definition in the request.
2523fn apply_tool_cache_control(
2524    tools: &mut [serde_json::Value],
2525    remaining_cache_markers: &mut usize,
2526    cache_control: &CacheControl,
2527) -> Result<(), CompletionError> {
2528    let Some(idx) = final_cacheable_tool_idx(tools) else {
2529        return Ok(());
2530    };
2531
2532    let Some(tool) = tools
2533        .get_mut(idx)
2534        .and_then(serde_json::Value::as_object_mut)
2535    else {
2536        return Ok(());
2537    };
2538
2539    if tool
2540        .get("cache_control")
2541        .is_some_and(|cache_control| !cache_control.is_null())
2542    {
2543        return Ok(());
2544    }
2545
2546    if *remaining_cache_markers == 0 {
2547        return Err(CompletionError::RequestError(
2548            "Anthropic manual prompt caching requires a cache_control marker on the final \
2549             non-deferred tool, but explicit tool markers exhaust the available cache point budget"
2550                .into(),
2551        ));
2552    }
2553
2554    tool.insert(
2555        "cache_control".to_string(),
2556        serde_json::to_value(cache_control)?,
2557    );
2558    *remaining_cache_markers -= 1;
2559
2560    Ok(())
2561}
2562
2563fn apply_system_cache_control(
2564    system: &mut [SystemContent],
2565    remaining_cache_markers: &mut usize,
2566    cache_control_value: &CacheControl,
2567) {
2568    if *remaining_cache_markers == 0 {
2569        return;
2570    }
2571
2572    if let Some(SystemContent::Text { cache_control, .. }) = system.last_mut()
2573        && cache_control.is_none()
2574    {
2575        *cache_control = Some(cache_control_value.clone());
2576        *remaining_cache_markers -= 1;
2577    }
2578}
2579
2580fn clear_message_cache_control(messages: &mut [Message]) {
2581    for msg in messages.iter_mut() {
2582        for content in msg.content.iter_mut() {
2583            set_content_cache_control(content, None);
2584        }
2585    }
2586}
2587
2588fn apply_message_cache_control(
2589    messages: &mut [Message],
2590    remaining_cache_markers: &mut usize,
2591    cache_control: &CacheControl,
2592) {
2593    clear_message_cache_control(messages);
2594
2595    if *remaining_cache_markers == 0 {
2596        return;
2597    }
2598
2599    if let Some(last_msg) = messages.last_mut()
2600        && let Some(last_content) = last_msg.content.last_mut()
2601    {
2602        set_content_cache_control(last_content, Some(cache_control.clone()));
2603        *remaining_cache_markers -= 1;
2604    }
2605}
2606
2607pub(super) fn apply_prompt_cache_control(
2608    system: &mut [SystemContent],
2609    messages: &mut [Message],
2610    tools: &mut [serde_json::Value],
2611    prompt_caching: bool,
2612    static_prefix_cache_ttl: Option<&CacheTtl>,
2613    top_level_cache_control: Option<&CacheControl>,
2614) -> Result<(), CompletionError> {
2615    normalize_tool_cache_control(tools);
2616
2617    let max_cache_markers = if top_level_cache_control.is_some() {
2618        MAX_CACHE_CONTROL_MARKERS - 1
2619    } else {
2620        MAX_CACHE_CONTROL_MARKERS
2621    };
2622    let tool_cache_markers = tool_cache_control_count(tools);
2623
2624    if tool_cache_markers > max_cache_markers {
2625        return Err(CompletionError::RequestError(
2626            format!(
2627                "Too many Anthropic tool `cache_control` markers: {tool_cache_markers} exceeds \
2628                 the available prompt caching budget of {max_cache_markers}"
2629            )
2630            .into(),
2631        ));
2632    }
2633
2634    let mut remaining_cache_markers = max_cache_markers - tool_cache_markers;
2635
2636    // The static prefix (tools + system) must not carry a shorter TTL than the
2637    // tail that follows it — Anthropic requires 1h markers before 5-minute
2638    // ones. Catch the typed-knob inversion here with an error that names the
2639    // knobs; the generic marker-order validator below would otherwise report
2640    // it in terms of raw markers.
2641    let top_level_ttl = top_level_cache_control_ttl(top_level_cache_control);
2642    if static_prefix_cache_ttl == Some(&CacheTtl::FiveMinutes)
2643        && top_level_ttl == Some(CacheTtl::OneHour)
2644    {
2645        return Err(CompletionError::RequestError(
2646            "`with_static_prefix_cache_ttl(CacheTtl::FiveMinutes)` conflicts with the 1-hour \
2647             top-level cache TTL (`with_automatic_caching_1h` or a raw top-level \
2648             `cache_control`): Anthropic requires 1h markers to precede 5-minute ones, and the \
2649             static prefix precedes the conversation tail"
2650                .into(),
2651        ));
2652    }
2653
2654    // Manual prompt caching marks the prefix and the tail; a static-prefix TTL
2655    // alone marks just the prefix (the tail stays with the automatic/top-level
2656    // breakpoint, or uncached).
2657    if prompt_caching || static_prefix_cache_ttl.is_some() {
2658        let static_cache_control =
2659            build_cache_control(static_prefix_cache_ttl.cloned().or(top_level_ttl.clone()));
2660
2661        apply_tool_cache_control(tools, &mut remaining_cache_markers, &static_cache_control)?;
2662        apply_system_cache_control(system, &mut remaining_cache_markers, &static_cache_control);
2663    }
2664
2665    if prompt_caching {
2666        if top_level_cache_control.is_some() {
2667            clear_message_cache_control(messages);
2668        } else {
2669            let tail_cache_control = build_cache_control(top_level_ttl);
2670            apply_message_cache_control(
2671                messages,
2672                &mut remaining_cache_markers,
2673                &tail_cache_control,
2674            );
2675        }
2676    }
2677
2678    validate_cache_control_ttl_order(system, messages, tools, top_level_cache_control)?;
2679
2680    Ok(())
2681}
2682
2683pub(super) fn extract_top_level_cache_control(
2684    additional_params: &mut serde_json::Value,
2685) -> Result<Option<CacheControl>, CompletionError> {
2686    if let Some(map) = additional_params.as_object_mut()
2687        && let Some(raw_cache_control) = map.remove("cache_control")
2688    {
2689        if raw_cache_control.is_null() {
2690            return Ok(None);
2691        }
2692
2693        return serde_json::from_value::<CacheControl>(raw_cache_control)
2694            .map(Some)
2695            .map_err(|err| {
2696                CompletionError::RequestError(
2697                    format!("Invalid Anthropic `additional_params.cache_control` payload: {err}")
2698                        .into(),
2699                )
2700            });
2701    }
2702
2703    Ok(None)
2704}
2705
2706pub(super) fn resolve_top_level_cache_control(
2707    automatic_caching: bool,
2708    automatic_caching_ttl: Option<CacheTtl>,
2709    additional_params: &mut serde_json::Value,
2710) -> Result<Option<CacheControl>, CompletionError> {
2711    let raw_cache_control = extract_top_level_cache_control(additional_params)?;
2712    let typed_cache_control = automatic_caching.then_some(CacheControl::Ephemeral {
2713        ttl: automatic_caching_ttl.clone(),
2714    });
2715
2716    match (typed_cache_control, raw_cache_control) {
2717        (Some(typed_cache_control), Some(raw_cache_control)) => {
2718            if automatic_caching_ttl.is_some()
2719                && cache_control_ttl(&typed_cache_control) != cache_control_ttl(&raw_cache_control)
2720            {
2721                return Err(CompletionError::RequestError(
2722                    "Anthropic `additional_params.cache_control` conflicts with the typed \
2723                     automatic caching TTL"
2724                        .into(),
2725                ));
2726            }
2727
2728            Ok(Some(raw_cache_control))
2729        }
2730        (Some(typed_cache_control), None) => Ok(Some(typed_cache_control)),
2731        (None, raw_cache_control) => Ok(raw_cache_control),
2732    }
2733}
2734
2735pub(super) fn split_system_messages_from_history(
2736    history: Vec<message::Message>,
2737    preserve_mid_conversation_system_messages: bool,
2738) -> (Vec<SystemContent>, Vec<message::Message>) {
2739    let mut system = Vec::new();
2740    let mut remaining = Vec::new();
2741
2742    for (index, message) in history.iter().enumerate() {
2743        match message {
2744            message::Message::System { content } => {
2745                if !content.is_empty() {
2746                    if preserve_mid_conversation_system_messages
2747                        && is_valid_mid_conversation_system_message(&history, index)
2748                    {
2749                        remaining.push(message.clone());
2750                    } else {
2751                        system.push(SystemContent::Text {
2752                            text: content.clone(),
2753                            cache_control: None,
2754                        });
2755                    }
2756                }
2757            }
2758            other => remaining.push(other.clone()),
2759        }
2760    }
2761
2762    (system, remaining)
2763}
2764
2765fn is_valid_mid_conversation_system_message(history: &[message::Message], index: usize) -> bool {
2766    let follows_valid_turn = index > 0
2767        && history.get(index - 1).is_some_and(|message| {
2768            matches!(message, message::Message::User { .. })
2769                || assistant_ends_in_server_tool_block(message)
2770        });
2771    let is_last_or_precedes_assistant = history
2772        .get(index + 1)
2773        .is_none_or(|message| matches!(message, message::Message::Assistant { .. }));
2774
2775    follows_valid_turn && is_last_or_precedes_assistant
2776}
2777
2778fn assistant_ends_in_server_tool_block(message: &message::Message) -> bool {
2779    let message::Message::Assistant { content, .. } = message else {
2780        return false;
2781    };
2782
2783    let Some(message::AssistantContent::Text(text)) = content.iter().last() else {
2784        return false;
2785    };
2786
2787    let Some(raw_type) = text
2788        .additional_params
2789        .as_ref()
2790        .and_then(|params| params.get(ANTHROPIC_RAW_CONTENT_KEY))
2791        .and_then(|raw_content| raw_content.get("type"))
2792        .and_then(serde_json::Value::as_str)
2793    else {
2794        return false;
2795    };
2796
2797    matches!(
2798        raw_type,
2799        "server_tool_use" | "web_search_tool_result" | "code_execution_tool_result"
2800    )
2801}
2802
2803/// Parameters for building an AnthropicCompletionRequest
2804pub struct AnthropicRequestParams<'a> {
2805    pub model: &'a str,
2806    pub request: CompletionRequest,
2807    pub prompt_caching: bool,
2808    /// Add a top-level `cache_control` field for Anthropic's automatic caching mode.
2809    pub automatic_caching: bool,
2810    /// TTL for the top-level cache_control. `None` omits the `ttl` field (API default is 5 min).
2811    pub automatic_caching_ttl: Option<CacheTtl>,
2812    /// TTL for the static prefix (tools + system). `None` inherits the top-level TTL.
2813    pub static_prefix_cache_ttl: Option<CacheTtl>,
2814}
2815
2816impl AnthropicCompletionRequest {
2817    pub(super) fn try_from_params<Ext>(
2818        params: AnthropicRequestParams<'_>,
2819        strict_tools: bool,
2820    ) -> Result<Self, CompletionError>
2821    where
2822        Ext: AnthropicCompatibleProvider,
2823    {
2824        let AnthropicRequestParams {
2825            model,
2826            request: mut req,
2827            prompt_caching,
2828            automatic_caching,
2829            automatic_caching_ttl,
2830            static_prefix_cache_ttl,
2831        } = params;
2832        let chat_history = req.chat_history_with_documents();
2833
2834        // Check if max_tokens is set, required for Anthropic
2835        let Some(max_tokens) = req.max_tokens else {
2836            return Err(CompletionError::RequestError(
2837                "`max_tokens` must be set for Anthropic".into(),
2838            ));
2839        };
2840
2841        let (history_system, chat_history) = split_system_messages_from_history(
2842            chat_history,
2843            supports_mid_conversation_system_messages(model),
2844        );
2845        let mut full_history = vec![];
2846        full_history.extend(chat_history);
2847
2848        let mut messages = full_history
2849            .into_iter()
2850            .map(Message::try_from)
2851            .collect::<Result<Vec<Message>, _>>()?;
2852
2853        let mut additional_params_payload = req
2854            .additional_params
2855            .take()
2856            .unwrap_or(serde_json::Value::Null);
2857        let top_level_cache_control = resolve_top_level_cache_control(
2858            automatic_caching,
2859            automatic_caching_ttl,
2860            &mut additional_params_payload,
2861        )?;
2862        let mut tools =
2863            build_tool_definitions::<Ext>(req.tools, &mut additional_params_payload, strict_tools)?;
2864
2865        // Convert system prompt to array format for cache_control support
2866        let mut system = if let Some(preamble) = req.preamble {
2867            if preamble.is_empty() {
2868                vec![]
2869            } else {
2870                vec![SystemContent::Text {
2871                    text: preamble,
2872                    cache_control: None,
2873                }]
2874            }
2875        } else {
2876            vec![]
2877        };
2878        system.extend(history_system);
2879
2880        apply_prompt_cache_control(
2881            &mut system,
2882            &mut messages,
2883            &mut tools,
2884            prompt_caching,
2885            static_prefix_cache_ttl.as_ref(),
2886            top_level_cache_control.as_ref(),
2887        )?;
2888
2889        let output_config = if let Some(schema) = req.output_schema {
2890            let mut schema_value = schema.to_value();
2891            sanitize_schema(&mut schema_value);
2892            Some(OutputConfig {
2893                format: OutputFormat::JsonSchema {
2894                    schema: schema_value,
2895                },
2896            })
2897        } else {
2898            None
2899        };
2900
2901        Ok(Self {
2902            model: model.to_string(),
2903            messages,
2904            max_tokens,
2905            system,
2906            temperature: req.temperature,
2907            tool_choice: req.tool_choice.map(ToolChoice::try_from).transpose()?,
2908            tools,
2909            output_config,
2910            // Automatic caching: one top-level field; the API moves the breakpoint automatically.
2911            cache_control: top_level_cache_control,
2912            additional_params: if additional_params_payload.is_null() {
2913                None
2914            } else {
2915                Some(additional_params_payload)
2916            },
2917        })
2918    }
2919}
2920
2921impl TryFrom<AnthropicRequestParams<'_>> for AnthropicCompletionRequest {
2922    type Error = CompletionError;
2923
2924    fn try_from(params: AnthropicRequestParams<'_>) -> Result<Self, Self::Error> {
2925        Self::try_from_params::<super::client::AnthropicExt>(params, false)
2926    }
2927}
2928
2929pub(super) fn extract_tools_from_additional_params(
2930    additional_params: &mut serde_json::Value,
2931) -> Result<Vec<serde_json::Value>, CompletionError> {
2932    if let Some(map) = additional_params.as_object_mut()
2933        && let Some(raw_tools) = map.remove("tools")
2934    {
2935        return serde_json::from_value::<Vec<serde_json::Value>>(raw_tools).map_err(|err| {
2936            CompletionError::RequestError(
2937                format!("Invalid Anthropic `additional_params.tools` payload: {err}").into(),
2938            )
2939        });
2940    }
2941
2942    Ok(Vec::new())
2943}
2944
2945pub(super) fn build_tool_definitions<Ext>(
2946    tools: Vec<completion::ToolDefinition>,
2947    additional_params_payload: &mut serde_json::Value,
2948    strict_tools: bool,
2949) -> Result<Vec<serde_json::Value>, CompletionError>
2950where
2951    Ext: AnthropicCompatibleProvider,
2952{
2953    let mut additional_tools = extract_tools_from_additional_params(additional_params_payload)?;
2954
2955    let mut tools = tools
2956        .into_iter()
2957        .map(|tool| {
2958            let input_schema = tool.parameters;
2959            let mut tool = ToolDefinition {
2960                name: tool.name,
2961                description: Some(tool.description),
2962                input_schema,
2963                strict: false,
2964                cache_control: None,
2965            };
2966            if strict_tools {
2967                Ext::enable_strict_tool_use(&mut tool);
2968            }
2969
2970            tool
2971        })
2972        .map(serde_json::to_value)
2973        .collect::<Result<Vec<_>, _>>()?;
2974    tools.append(&mut additional_tools);
2975
2976    Ok(tools)
2977}
2978
2979impl<Ext, T> GenericCompletionModel<Ext, T>
2980where
2981    T: HttpClientExt + Clone + Default + WasmCompatSend + WasmCompatSync + 'static,
2982    Ext: AnthropicCompatibleProvider + Clone + WasmCompatSend + WasmCompatSync + 'static,
2983{
2984    /// Execute a completion and return Anthropic's own wire response.
2985    ///
2986    /// This is the escape hatch for provider-specific fields rig does not
2987    /// normalize. It shares the request builder, transport, telemetry, and
2988    /// error handling with
2989    /// [`CompletionModel::completion`](completion::CompletionModel::completion),
2990    /// which calls it and then applies the provider-local mapping — one network
2991    /// request either way.
2992    pub async fn raw_completion(
2993        &self,
2994        completion_request: completion::CompletionRequest,
2995    ) -> Result<CompletionResponse, CompletionError> {
2996        let (span, request) =
2997            self.prepare_request(completion_request, CompletionOperation::Chat)?;
2998
2999        crate::providers::internal::trace_json(
3000            crate::providers::internal::LogTarget::Completions,
3001            "Anthropic completion request",
3002            &request,
3003        );
3004
3005        let request: Vec<u8> = serde_json::to_vec(&request)?;
3006
3007        let req = self
3008            .client
3009            .post("/v1/messages")?
3010            .body(request)
3011            .map_err(|e| CompletionError::HttpError(e.into()))?;
3012
3013        let (mut completion, provider_request_id) =
3014            send_completion::<_, ApiResponse<CompletionResponse>, _>(
3015                &self.client,
3016                req,
3017                "Anthropic completion",
3018                Ext::REQUEST_ID_HEADER,
3019                |completion| {
3020                    let span = tracing::Span::current();
3021                    span.record_response_metadata(completion);
3022                    span.record_token_usage(&crate::completion::Usage::from(&completion.usage));
3023                },
3024            )
3025            .instrument(span)
3026            .await?;
3027        completion.provider_request_id = provider_request_id;
3028        Ok(completion)
3029    }
3030}
3031
3032impl<Ext, T> completion::CompletionModel for GenericCompletionModel<Ext, T>
3033where
3034    T: HttpClientExt + Clone + Default + WasmCompatSend + WasmCompatSync + 'static,
3035    Ext: AnthropicCompatibleProvider + Clone + WasmCompatSend + WasmCompatSync + 'static,
3036{
3037    // Anthropic's native structured outputs (constrained decoding) are designed
3038    // to compose with strict tool use, so the schema constraint does not suppress
3039    // tool calls. See issue #1928.
3040    fn capabilities(&self) -> completion::ProviderCapabilities {
3041        completion::ProviderCapabilities::default().with_native_output_tool_composition(true)
3042    }
3043
3044    async fn completion(
3045        &self,
3046        completion_request: completion::CompletionRequest,
3047    ) -> Result<completion::CompletionResponse, CompletionError> {
3048        // Capture before `normalize` consumes the raw value.
3049        let response = self.raw_completion(completion_request).await?;
3050        let captured = serde_json::to_value(&response)?;
3051        Ok(response.normalize(Ext::PROVIDER_NAME)?.with_raw(captured))
3052    }
3053
3054    async fn stream(
3055        &self,
3056        request: CompletionRequest,
3057    ) -> Result<crate::streaming::StreamingCompletionResponse, CompletionError> {
3058        GenericCompletionModel::stream(self, request).await
3059    }
3060}
3061
3062impl<Ext, T> crate::client::ConstructCompletionModel<crate::client::Client<Ext, T>>
3063    for GenericCompletionModel<Ext, T>
3064where
3065    crate::client::Client<Ext, T>: Clone,
3066    T: HttpClientExt,
3067    Ext: AnthropicCompatibleProvider + Clone + 'static,
3068{
3069    fn construct(client: &crate::client::Client<Ext, T>, model: String) -> Self {
3070        Self::new(client.clone(), model)
3071    }
3072}
3073
3074use crate::providers::internal::envelope::ApiErrorResponse;
3075
3076#[derive(Debug, Deserialize)]
3077#[serde(tag = "type", rename_all = "snake_case")]
3078enum ApiResponse<T> {
3079    Message(T),
3080    Error(ApiErrorResponse),
3081}
3082
3083impl<T> crate::providers::internal::envelope::ProviderEnvelope for ApiResponse<T> {
3084    type Payload = T;
3085
3086    fn into_payload(self) -> Result<T, String> {
3087        match self {
3088            Self::Message(payload) => Ok(payload),
3089            Self::Error(ApiErrorResponse { message }) => Err(message),
3090        }
3091    }
3092}
3093
3094#[cfg(test)]
3095mod tests {
3096    use super::*;
3097    use crate::message::EMPTY_RESPONSE_ERROR;
3098    use serde_json::json;
3099    use serde_path_to_error::deserialize;
3100
3101    #[test]
3102    fn current_model_default_max_tokens_match_anthropic_limits() {
3103        assert_eq!(default_max_tokens_for_model(CLAUDE_OPUS_4_8), Some(128_000));
3104        assert_eq!(default_max_tokens_for_model(CLAUDE_OPUS_4_7), Some(128_000));
3105        assert_eq!(default_max_tokens_for_model(CLAUDE_OPUS_4_6), Some(128_000));
3106        assert_eq!(
3107            default_max_tokens_for_model(CLAUDE_SONNET_4_6),
3108            Some(64_000)
3109        );
3110        assert_eq!(default_max_tokens_for_model(CLAUDE_HAIKU_4_5), Some(64_000));
3111    }
3112
3113    #[test]
3114    fn unknown_model_uses_conservative_default_max_tokens_fallback() {
3115        assert_eq!(default_max_tokens_for_model("claude-unknown"), None);
3116        assert_eq!(default_max_tokens_with_fallback("claude-unknown"), 2_048);
3117    }
3118
3119    #[test]
3120    fn system_role_message_deserializes_and_round_trips() {
3121        let message: Message = serde_json::from_str(
3122            r#"
3123        {
3124            "role": "system",
3125            "content": "From now on, require explicit type annotations."
3126        }
3127        "#,
3128        )
3129        .unwrap();
3130
3131        assert_eq!(message.role, Role::System);
3132
3133        let generic: message::Message = message.try_into().unwrap();
3134        assert_eq!(
3135            generic,
3136            message::Message::System {
3137                content: "From now on, require explicit type annotations.".to_string()
3138            }
3139        );
3140
3141        let provider: Message = generic.try_into().unwrap();
3142        assert_eq!(provider.role, Role::System);
3143    }
3144
3145    #[test]
3146    fn test_deserialize_message() {
3147        let assistant_message_json = r#"
3148        {
3149            "role": "assistant",
3150            "content": "\n\nHello there, how may I assist you today?"
3151        }
3152        "#;
3153
3154        let assistant_message_json2 = r#"
3155        {
3156            "role": "assistant",
3157            "content": [
3158                {
3159                    "type": "text",
3160                    "text": "\n\nHello there, how may I assist you today?"
3161                },
3162                {
3163                    "type": "tool_use",
3164                    "id": "toolu_01A09q90qw90lq917835lq9",
3165                    "name": "get_weather",
3166                    "input": {"location": "San Francisco, CA"}
3167                }
3168            ]
3169        }
3170        "#;
3171
3172        let user_message_json = r#"
3173        {
3174            "role": "user",
3175            "content": [
3176                {
3177                    "type": "image",
3178                    "source": {
3179                        "type": "base64",
3180                        "media_type": "image/jpeg",
3181                        "data": "/9j/4AAQSkZJRg..."
3182                    }
3183                },
3184                {
3185                    "type": "text",
3186                    "text": "What is in this image?"
3187                },
3188                {
3189                    "type": "tool_result",
3190                    "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
3191                    "content": "15 degrees"
3192                }
3193            ]
3194        }
3195        "#;
3196
3197        let assistant_message: Message = {
3198            let jd = &mut serde_json::Deserializer::from_str(assistant_message_json);
3199            deserialize(jd).unwrap_or_else(|err| {
3200                panic!("Deserialization error at {}: {}", err.path(), err);
3201            })
3202        };
3203
3204        let assistant_message2: Message = {
3205            let jd = &mut serde_json::Deserializer::from_str(assistant_message_json2);
3206            deserialize(jd).unwrap_or_else(|err| {
3207                panic!("Deserialization error at {}: {}", err.path(), err);
3208            })
3209        };
3210
3211        let user_message: Message = {
3212            let jd = &mut serde_json::Deserializer::from_str(user_message_json);
3213            deserialize(jd).unwrap_or_else(|err| {
3214                panic!("Deserialization error at {}: {}", err.path(), err);
3215            })
3216        };
3217
3218        let Message { role, content } = assistant_message;
3219        assert_eq!(role, Role::Assistant);
3220        assert_eq!(
3221            content.first(),
3222            Some(&Content::Text {
3223                text: "\n\nHello there, how may I assist you today?".to_owned(),
3224                citations: Vec::new(),
3225                cache_control: None,
3226            })
3227        );
3228
3229        let Message { role, content } = assistant_message2;
3230        {
3231            assert_eq!(role, Role::Assistant);
3232            assert_eq!(content.len(), 2);
3233
3234            let mut iter = content.into_iter();
3235
3236            match iter.next().unwrap() {
3237                Content::Text { text, .. } => {
3238                    assert_eq!(text, "\n\nHello there, how may I assist you today?");
3239                }
3240                _ => panic!("Expected text content"),
3241            }
3242
3243            match iter.next().unwrap() {
3244                Content::ToolUse { id, name, input } => {
3245                    assert_eq!(id, "toolu_01A09q90qw90lq917835lq9");
3246                    assert_eq!(name, "get_weather");
3247                    assert_eq!(input, json!({"location": "San Francisco, CA"}));
3248                }
3249                _ => panic!("Expected tool use content"),
3250            }
3251
3252            assert_eq!(iter.next(), None);
3253        }
3254
3255        let Message { role, content } = user_message;
3256        {
3257            assert_eq!(role, Role::User);
3258            assert_eq!(content.len(), 3);
3259
3260            let mut iter = content.into_iter();
3261
3262            match iter.next().unwrap() {
3263                Content::Image { source, .. } => {
3264                    assert_eq!(
3265                        source,
3266                        ImageSource::Base64 {
3267                            data: "/9j/4AAQSkZJRg...".to_owned(),
3268                            media_type: ImageFormat::JPEG,
3269                        }
3270                    );
3271                }
3272                _ => panic!("Expected image content"),
3273            }
3274
3275            match iter.next().unwrap() {
3276                Content::Text { text, .. } => {
3277                    assert_eq!(text, "What is in this image?");
3278                }
3279                _ => panic!("Expected text content"),
3280            }
3281
3282            match iter.next().unwrap() {
3283                Content::ToolResult {
3284                    tool_use_id,
3285                    content,
3286                    is_error,
3287                    ..
3288                } => {
3289                    assert_eq!(tool_use_id, "toolu_01A09q90qw90lq917835lq9");
3290                    assert_eq!(
3291                        content.first(),
3292                        Some(&ToolResultContent::Text {
3293                            text: "15 degrees".to_owned()
3294                        })
3295                    );
3296                    assert_eq!(is_error, None);
3297                }
3298                _ => panic!("Expected tool result content"),
3299            }
3300
3301            assert_eq!(iter.next(), None);
3302        }
3303    }
3304
3305    #[test]
3306    fn test_message_to_message_conversion() {
3307        let user_message: Message = serde_json::from_str(
3308            r#"
3309        {
3310            "role": "user",
3311            "content": [
3312                {
3313                    "type": "image",
3314                    "source": {
3315                        "type": "base64",
3316                        "media_type": "image/jpeg",
3317                        "data": "/9j/4AAQSkZJRg..."
3318                    }
3319                },
3320                {
3321                    "type": "text",
3322                    "text": "What is in this image?"
3323                },
3324                {
3325                    "type": "document",
3326                    "source": {
3327                        "type": "base64",
3328                        "data": "base64_encoded_pdf_data",
3329                        "media_type": "application/pdf"
3330                    }
3331                }
3332            ]
3333        }
3334        "#,
3335        )
3336        .unwrap();
3337
3338        let assistant_message = Message {
3339            role: Role::Assistant,
3340            content: vec![Content::ToolUse {
3341                id: "toolu_01A09q90qw90lq917835lq9".to_string(),
3342                name: "get_weather".to_string(),
3343                input: json!({"location": "San Francisco, CA"}),
3344            }],
3345        };
3346
3347        let tool_message = Message {
3348            role: Role::User,
3349            content: vec![Content::ToolResult {
3350                tool_use_id: "toolu_01A09q90qw90lq917835lq9".to_string(),
3351                content: vec![ToolResultContent::Text {
3352                    text: "15 degrees".to_string(),
3353                }],
3354                is_error: None,
3355                cache_control: None,
3356            }],
3357        };
3358
3359        let converted_user_message: message::Message = user_message.clone().try_into().unwrap();
3360        let converted_assistant_message: message::Message =
3361            assistant_message.clone().try_into().unwrap();
3362        let converted_tool_message: message::Message = tool_message.clone().try_into().unwrap();
3363
3364        match converted_user_message.clone() {
3365            message::Message::User { content } => {
3366                assert_eq!(content.len(), 3);
3367
3368                let mut iter = content.into_iter();
3369
3370                match iter.next().unwrap() {
3371                    message::UserContent::Image(message::Image {
3372                        data, media_type, ..
3373                    }) => {
3374                        assert_eq!(data, DocumentSourceKind::base64("/9j/4AAQSkZJRg..."));
3375                        assert_eq!(media_type, Some(message::ImageMediaType::JPEG));
3376                    }
3377                    _ => panic!("Expected image content"),
3378                }
3379
3380                match iter.next().unwrap() {
3381                    message::UserContent::Text(message::Text { text, .. }) => {
3382                        assert_eq!(text, "What is in this image?");
3383                    }
3384                    _ => panic!("Expected text content"),
3385                }
3386
3387                match iter.next().unwrap() {
3388                    message::UserContent::Document(message::Document {
3389                        data, media_type, ..
3390                    }) => {
3391                        assert_eq!(
3392                            data,
3393                            DocumentSourceKind::String("base64_encoded_pdf_data".into())
3394                        );
3395                        assert_eq!(media_type, Some(message::DocumentMediaType::PDF));
3396                    }
3397                    _ => panic!("Expected document content"),
3398                }
3399
3400                assert_eq!(iter.next(), None);
3401            }
3402            _ => panic!("Expected user message"),
3403        }
3404
3405        match converted_tool_message.clone() {
3406            message::Message::User { content } => {
3407                let message::ToolResult {
3408                    call,
3409                    name,
3410                    content,
3411                    ..
3412                } = match content.first() {
3413                    Some(message::UserContent::ToolResult(tool_result)) => tool_result,
3414                    _ => panic!("Expected tool result content"),
3415                };
3416                assert_eq!(call, "toolu_01A09q90qw90lq917835lq9");
3417                // The Anthropic wire carries no tool name on `tool_result`
3418                // blocks, so the inbound conversion is lossy by design.
3419                assert_eq!(name, "");
3420                match content.first() {
3421                    Some(message::ToolResultContent::Text(message::Text { text, .. })) => {
3422                        assert_eq!(text, "15 degrees");
3423                    }
3424                    _ => panic!("Expected text content"),
3425                }
3426            }
3427            _ => panic!("Expected tool result content"),
3428        }
3429
3430        match converted_assistant_message.clone() {
3431            message::Message::Assistant { content, .. } => {
3432                assert_eq!(content.len(), 1);
3433
3434                match content.first() {
3435                    Some(message::AssistantContent::ToolCall(message::ToolCall {
3436                        id,
3437                        function,
3438                        ..
3439                    })) => {
3440                        assert_eq!(id, "toolu_01A09q90qw90lq917835lq9");
3441                        assert_eq!(function.name, "get_weather");
3442                        assert_eq!(function.arguments, json!({"location": "San Francisco, CA"}));
3443                    }
3444                    _ => panic!("Expected tool call content"),
3445                }
3446            }
3447            _ => panic!("Expected assistant message"),
3448        }
3449
3450        let original_user_message: Message = converted_user_message.try_into().unwrap();
3451        let original_assistant_message: Message = converted_assistant_message.try_into().unwrap();
3452        let original_tool_message: Message = converted_tool_message.try_into().unwrap();
3453
3454        assert_eq!(user_message, original_user_message);
3455        assert_eq!(assistant_message, original_assistant_message);
3456        assert_eq!(tool_message, original_tool_message);
3457    }
3458
3459    #[test]
3460    fn test_content_format_conversion() {
3461        use crate::completion::message::ContentFormat;
3462
3463        let source_type: SourceType = ContentFormat::Url.try_into().unwrap();
3464        assert_eq!(source_type, SourceType::URL);
3465
3466        let content_format: ContentFormat = SourceType::URL.into();
3467        assert_eq!(content_format, ContentFormat::Url);
3468
3469        let source_type: SourceType = ContentFormat::Base64.try_into().unwrap();
3470        assert_eq!(source_type, SourceType::BASE64);
3471
3472        let content_format: ContentFormat = SourceType::BASE64.into();
3473        assert_eq!(content_format, ContentFormat::Base64);
3474
3475        let source_type: SourceType = ContentFormat::String.try_into().unwrap();
3476        assert_eq!(source_type, SourceType::TEXT);
3477
3478        let content_format: ContentFormat = SourceType::TEXT.into();
3479        assert_eq!(content_format, ContentFormat::String);
3480    }
3481
3482    #[test]
3483    fn test_cache_control_serialization() {
3484        // Test SystemContent with cache_control
3485        let system = SystemContent::Text {
3486            text: "You are a helpful assistant.".to_string(),
3487            cache_control: Some(CacheControl::ephemeral()),
3488        };
3489        let json = serde_json::to_string(&system).unwrap();
3490        assert!(json.contains(r#""cache_control":{"type":"ephemeral"}"#));
3491        assert!(json.contains(r#""type":"text""#));
3492
3493        // Test SystemContent without cache_control (should not have cache_control field)
3494        let system_no_cache = SystemContent::Text {
3495            text: "Hello".to_string(),
3496            cache_control: None,
3497        };
3498        let json_no_cache = serde_json::to_string(&system_no_cache).unwrap();
3499        assert!(!json_no_cache.contains("cache_control"));
3500
3501        // Test Content::Text with cache_control
3502        let content = Content::Text {
3503            text: "Test message".to_string(),
3504            citations: Vec::new(),
3505            cache_control: Some(CacheControl::ephemeral()),
3506        };
3507        let json_content = serde_json::to_string(&content).unwrap();
3508        assert!(json_content.contains(r#""cache_control":{"type":"ephemeral"}"#));
3509
3510        // Manual prompt caching over a bare system prompt + conversation: the
3511        // system block and the tail of the last message get the marker.
3512        let mut system_vec = vec![SystemContent::Text {
3513            text: "System prompt".to_string(),
3514            cache_control: None,
3515        }];
3516        let mut messages = vec![
3517            Message {
3518                role: Role::User,
3519                content: vec![Content::Text {
3520                    text: "First message".to_string(),
3521                    citations: Vec::new(),
3522                    cache_control: None,
3523                }],
3524            },
3525            Message {
3526                role: Role::Assistant,
3527                content: vec![Content::Text {
3528                    text: "Response".to_string(),
3529                    citations: Vec::new(),
3530                    cache_control: None,
3531                }],
3532            },
3533        ];
3534
3535        apply_prompt_cache_control(&mut system_vec, &mut messages, &mut [], true, None, None)
3536            .unwrap();
3537
3538        // System should have cache_control
3539        match &system_vec[0] {
3540            SystemContent::Text { cache_control, .. } => {
3541                assert!(cache_control.is_some());
3542            }
3543        }
3544
3545        // Only the last content block of last message should have cache_control
3546        // First message should NOT have cache_control
3547        for content in messages[0].content.iter() {
3548            if let Content::Text { cache_control, .. } = content {
3549                assert!(cache_control.is_none());
3550            }
3551        }
3552
3553        // Last message SHOULD have cache_control
3554        for content in messages[1].content.iter() {
3555            if let Content::Text { cache_control, .. } = content {
3556                assert!(cache_control.is_some());
3557            }
3558        }
3559    }
3560
3561    fn generic_tool(name: &str) -> completion::ToolDefinition {
3562        completion::ToolDefinition {
3563            name: name.to_string(),
3564            description: format!("{name} description"),
3565            parameters: json!({
3566                "type": "object",
3567                "properties": {}
3568            }),
3569        }
3570    }
3571
3572    fn completion_request_with_tools(
3573        tools: Vec<completion::ToolDefinition>,
3574        additional_params: Option<serde_json::Value>,
3575    ) -> CompletionRequest {
3576        CompletionRequest {
3577            model: None,
3578            preamble: Some("System prompt".to_string()),
3579            chat_history: vec![message::Message::from("Hello")],
3580            documents: Vec::new(),
3581            tools,
3582            temperature: None,
3583            max_tokens: Some(64),
3584            tool_choice: None,
3585            additional_params,
3586            output_schema: None,
3587            record_telemetry_content: false,
3588        }
3589    }
3590
3591    fn completion_request_with_history(
3592        chat_history: Vec<message::Message>,
3593        preamble: Option<String>,
3594    ) -> CompletionRequest {
3595        CompletionRequest {
3596            model: None,
3597            preamble,
3598            chat_history,
3599            documents: Vec::new(),
3600            tools: Vec::new(),
3601            temperature: None,
3602            max_tokens: Some(64),
3603            tool_choice: None,
3604            additional_params: None,
3605            output_schema: None,
3606            record_telemetry_content: false,
3607        }
3608    }
3609
3610    #[test]
3611    fn rig_tools_are_non_strict_by_default() {
3612        let request = completion_request_with_tools(vec![generic_tool("lookup")], None);
3613        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
3614            model: CLAUDE_SONNET_4_6,
3615            request,
3616            prompt_caching: false,
3617            automatic_caching: false,
3618            automatic_caching_ttl: None,
3619            static_prefix_cache_ttl: None,
3620        })
3621        .unwrap();
3622
3623        let value = serde_json::to_value(request).unwrap();
3624        assert!(value["tools"][0].get("strict").is_none());
3625        assert!(
3626            value["tools"][0]["input_schema"]
3627                .get("additionalProperties")
3628                .is_none()
3629        );
3630    }
3631
3632    #[test]
3633    fn strict_tool_hook_is_a_noop_for_anthropic_compatible_gateways() {
3634        let mut additional_params = serde_json::Value::Null;
3635        let tools = build_tool_definitions::<crate::providers::minimax::MiniMaxAnthropicExt>(
3636            vec![generic_tool("lookup")],
3637            &mut additional_params,
3638            true,
3639        )
3640        .unwrap();
3641
3642        assert!(tools[0].get("strict").is_none());
3643        assert!(
3644            tools[0]["input_schema"]
3645                .get("additionalProperties")
3646                .is_none()
3647        );
3648    }
3649
3650    #[test]
3651    fn strict_tools_opt_in_marks_and_sanitizes_rig_tools_only() {
3652        let mut tool = generic_tool("lookup");
3653        tool.parameters = json!({
3654            "type": "object",
3655            "additionalProperties": true,
3656            "properties": {
3657                "query": {
3658                    "type": "string",
3659                    "minLength": 2,
3660                    "maxLength": 20,
3661                    "pattern": "^[a-z]+$",
3662                    "format": "uuid"
3663                },
3664                "kind": {
3665                    "type": "string",
3666                    "const": "lookup"
3667                },
3668                "legacy_filter": {
3669                    "$ref": "#/definitions/LegacyFilter"
3670                },
3671                "options": {
3672                    "type": "object",
3673                    "additionalProperties": true,
3674                    "properties": {
3675                        "limit": {
3676                            "type": ["integer", "null"],
3677                            "minimum": 1,
3678                            "maximum": 100,
3679                            "format": "uint32"
3680                        }
3681                    }
3682                }
3683            },
3684            "definitions": {
3685                "LegacyFilter": {
3686                    "type": "object",
3687                    "properties": {
3688                        "term": { "type": "string" }
3689                    }
3690                }
3691            },
3692            "required": ["query"]
3693        });
3694        let request = completion_request_with_tools(
3695            vec![tool],
3696            Some(json!({
3697                "tools": [{
3698                    "type": "mcp_toolset",
3699                    "name": "remote_tools"
3700                }]
3701            })),
3702        );
3703        let request = AnthropicCompletionRequest::try_from_params::<
3704            crate::providers::anthropic::client::AnthropicExt,
3705        >(
3706            AnthropicRequestParams {
3707                model: CLAUDE_SONNET_4_6,
3708                request,
3709                prompt_caching: false,
3710                automatic_caching: false,
3711                automatic_caching_ttl: None,
3712                static_prefix_cache_ttl: None,
3713            },
3714            true,
3715        )
3716        .unwrap();
3717
3718        let value = serde_json::to_value(request).unwrap();
3719        let rig_tool = &value["tools"][0];
3720        assert_eq!(rig_tool["strict"], true);
3721        assert_eq!(rig_tool["input_schema"]["additionalProperties"], false);
3722        let required = rig_tool["input_schema"]["required"]
3723            .as_array()
3724            .expect("strict object schema should list required properties");
3725        assert_eq!(required.len(), 1);
3726        assert!(required.contains(&json!("query")));
3727        assert_eq!(
3728            rig_tool["input_schema"]["properties"]["options"]["additionalProperties"],
3729            false
3730        );
3731        assert!(
3732            rig_tool["input_schema"]["properties"]["options"]
3733                .get("required")
3734                .is_none()
3735        );
3736        let query = &rig_tool["input_schema"]["properties"]["query"];
3737        assert_eq!(query["format"], "uuid");
3738        for keyword in ["minLength", "maxLength", "pattern"] {
3739            assert!(query.get(keyword).is_none());
3740        }
3741        let query_description = query["description"]
3742            .as_str()
3743            .expect("unsupported string constraints should become guidance");
3744        for guidance in ["minLength: 2", "maxLength: 20", "pattern: ^[a-z]+$"] {
3745            assert!(query_description.contains(guidance));
3746        }
3747        assert_eq!(
3748            rig_tool["input_schema"]["properties"]["kind"]["const"],
3749            "lookup"
3750        );
3751        assert_eq!(
3752            rig_tool["input_schema"]["properties"]["legacy_filter"]["$ref"],
3753            "#/definitions/LegacyFilter"
3754        );
3755        assert_eq!(
3756            rig_tool["input_schema"]["definitions"]["LegacyFilter"]["additionalProperties"],
3757            false
3758        );
3759        let limit = &rig_tool["input_schema"]["properties"]["options"]["properties"]["limit"];
3760        assert!(limit.get("format").is_none());
3761        assert!(
3762            ["minimum", "maximum"]
3763                .into_iter()
3764                .all(|keyword| limit.get(keyword).is_none())
3765        );
3766        let limit_description = limit["description"]
3767            .as_str()
3768            .expect("unsupported numeric constraints should become guidance");
3769        for guidance in ["minimum: 1", "maximum: 100", "format: uint32"] {
3770            assert!(limit_description.contains(guidance));
3771        }
3772
3773        let provider_tool = &value["tools"][1];
3774        assert_eq!(provider_tool["type"], "mcp_toolset");
3775        assert!(provider_tool.get("strict").is_none());
3776    }
3777
3778    fn system_has_cache_control(value: &serde_json::Value) -> bool {
3779        value["system"]
3780            .as_array()
3781            .and_then(|blocks| blocks.last())
3782            .and_then(|block| block.get("cache_control"))
3783            .is_some()
3784    }
3785
3786    fn last_message_has_cache_control(value: &serde_json::Value) -> bool {
3787        value["messages"]
3788            .as_array()
3789            .and_then(|messages| messages.last())
3790            .and_then(|message| message["content"].as_array())
3791            .and_then(|content| content.last())
3792            .and_then(|content| content.get("cache_control"))
3793            .is_some()
3794    }
3795
3796    #[test]
3797    fn opus_4_8_preserves_mid_conversation_system_message() {
3798        let request = completion_request_with_history(
3799            vec![
3800                message::Message::System {
3801                    content: "Global history instruction.".to_string(),
3802                },
3803                message::Message::from("Review this code."),
3804                message::Message::System {
3805                    content: "From now on, require explicit type annotations.".to_string(),
3806                },
3807            ],
3808            Some("Top-level instruction.".to_string()),
3809        );
3810
3811        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
3812            model: CLAUDE_OPUS_4_8,
3813            request,
3814            prompt_caching: false,
3815            automatic_caching: false,
3816            automatic_caching_ttl: None,
3817            static_prefix_cache_ttl: None,
3818        })
3819        .unwrap();
3820
3821        let value = serde_json::to_value(request).unwrap();
3822        assert_eq!(value["system"][0]["text"], "Top-level instruction.");
3823        assert_eq!(value["system"][1]["text"], "Global history instruction.");
3824
3825        let messages = value["messages"].as_array().unwrap();
3826        assert_eq!(messages.len(), 2);
3827        assert_eq!(messages[0]["role"], "user");
3828        assert_eq!(messages[1]["role"], "system");
3829        assert_eq!(
3830            messages[1]["content"][0]["text"],
3831            "From now on, require explicit type annotations."
3832        );
3833    }
3834
3835    #[test]
3836    fn opus_4_8_preserves_mid_conversation_system_message_before_assistant_turn() {
3837        let request = completion_request_with_history(
3838            vec![
3839                message::Message::user("Review this code."),
3840                message::Message::System {
3841                    content: "From now on, require explicit type annotations.".to_string(),
3842                },
3843                message::Message::assistant("I will enforce explicit type annotations."),
3844            ],
3845            None,
3846        );
3847
3848        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
3849            model: CLAUDE_OPUS_4_8,
3850            request,
3851            prompt_caching: false,
3852            automatic_caching: false,
3853            automatic_caching_ttl: None,
3854            static_prefix_cache_ttl: None,
3855        })
3856        .unwrap();
3857
3858        let value = serde_json::to_value(request).unwrap();
3859        let messages = value["messages"].as_array().unwrap();
3860        assert_eq!(messages.len(), 3);
3861        assert_eq!(messages[0]["role"], "user");
3862        assert_eq!(messages[1]["role"], "system");
3863        assert_eq!(messages[2]["role"], "assistant");
3864        assert!(value.get("system").is_none());
3865    }
3866
3867    #[test]
3868    fn opus_4_8_hoists_leading_system_message_when_documents_are_present() {
3869        let mut request = completion_request_with_history(
3870            vec![
3871                message::Message::System {
3872                    content: "Global history instruction.".to_string(),
3873                },
3874                message::Message::assistant("Acknowledged."),
3875                message::Message::System {
3876                    content: "Mid-conversation instruction.".to_string(),
3877                },
3878                message::Message::user("Answer from the document."),
3879            ],
3880            None,
3881        );
3882        request.documents = vec![completion::Document {
3883            id: "doc".to_string(),
3884            text: "Document context.".to_string(),
3885            additional_props: Default::default(),
3886        }];
3887
3888        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
3889            model: CLAUDE_OPUS_4_8,
3890            request,
3891            prompt_caching: false,
3892            automatic_caching: false,
3893            automatic_caching_ttl: None,
3894            static_prefix_cache_ttl: None,
3895        })
3896        .unwrap();
3897
3898        let value = serde_json::to_value(request).unwrap();
3899        assert_eq!(value["system"][0]["text"], "Global history instruction.");
3900        assert_eq!(value["system"][1]["text"], "Mid-conversation instruction.");
3901
3902        let messages = value["messages"].as_array().unwrap();
3903        assert_eq!(messages.len(), 3);
3904        assert_eq!(messages[0]["role"], "user");
3905        assert_eq!(messages[1]["role"], "assistant");
3906        assert_eq!(messages[2]["role"], "user");
3907        assert!(
3908            messages[0].to_string().contains("<file id: doc>"),
3909            "document message should follow top-level system: {messages:?}"
3910        );
3911        assert_eq!(
3912            messages
3913                .iter()
3914                .filter(|message| message.to_string().contains("<file id: doc>"))
3915                .count(),
3916            1,
3917            "document message should appear exactly once: {messages:?}"
3918        );
3919        assert!(
3920            messages
3921                .iter()
3922                .all(|message| message["role"].as_str() != Some("system"))
3923        );
3924    }
3925
3926    #[test]
3927    fn opus_4_8_preserves_system_message_after_assistant_server_tool_result() {
3928        let request = completion_request_with_history(
3929            vec![
3930                message::Message::Assistant {
3931                    id: None,
3932                    content: vec![
3933                        message::AssistantContent::Text(message::Text {
3934                            text: String::new(),
3935                            additional_params: crate::message::AdditionalParams::try_from_value(
3936                                json!({
3937                                    ANTHROPIC_RAW_CONTENT_KEY: {
3938                                        "type": "server_tool_use",
3939                                        "id": "srvtoolu_01",
3940                                        "name": "web_search",
3941                                        "input": {
3942                                            "query": "clear daytime sky color"
3943                                        }
3944                                    }
3945                                }),
3946                            )
3947                            .expect("object params"),
3948                        }),
3949                        message::AssistantContent::Text(message::Text {
3950                            text: String::new(),
3951                            additional_params: crate::message::AdditionalParams::try_from_value(
3952                                json!({
3953                                    ANTHROPIC_RAW_CONTENT_KEY: {
3954                                        "type": "web_search_tool_result",
3955                                        "tool_use_id": "srvtoolu_01",
3956                                        "content": {
3957                                            "type": "web_search_tool_result_error",
3958                                            "error_code": "unavailable"
3959                                        }
3960                                    }
3961                                }),
3962                            )
3963                            .expect("object params"),
3964                        }),
3965                    ],
3966                },
3967                message::Message::System {
3968                    content: "For the rest of this conversation, answer in Spanish.".to_string(),
3969                },
3970                message::Message::assistant("Entendido."),
3971            ],
3972            None,
3973        );
3974
3975        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
3976            model: CLAUDE_OPUS_4_8,
3977            request,
3978            prompt_caching: false,
3979            automatic_caching: false,
3980            automatic_caching_ttl: None,
3981            static_prefix_cache_ttl: None,
3982        })
3983        .unwrap();
3984
3985        let value = serde_json::to_value(request).unwrap();
3986        assert!(value.get("system").is_none());
3987
3988        let messages = value["messages"].as_array().unwrap();
3989        assert_eq!(messages.len(), 3);
3990        assert_eq!(messages[0]["role"], "assistant");
3991        assert_eq!(messages[0]["content"][0]["type"], "server_tool_use");
3992        assert_eq!(messages[0]["content"][1]["type"], "web_search_tool_result");
3993        assert_eq!(messages[1]["role"], "system");
3994        assert_eq!(
3995            messages[1]["content"][0]["text"],
3996            "For the rest of this conversation, answer in Spanish."
3997        );
3998        assert_eq!(messages[2]["role"], "assistant");
3999    }
4000
4001    #[test]
4002    fn foreign_annotated_empty_text_produces_no_anthropic_block() {
4003        // The Responses ingest mints empty text blocks whose params carry
4004        // that wire's extras; the agent deliberately keeps them in history.
4005        // Replayed here, they must vanish from the request — the API
4006        // rejects empty text blocks and foreign extras cannot reach this
4007        // wire — while sibling content converts unaffected.
4008        let foreign_annotated_empty = message::AssistantContent::Text(message::Text {
4009            text: String::new(),
4010            additional_params: message::AdditionalParams::try_from_value(json!({
4011                "openai_responses": {"annotations": [{"type": "url_citation"}]}
4012            }))
4013            .expect("object params"),
4014        });
4015        assert_eq!(
4016            anthropic_content_from_assistant_content(foreign_annotated_empty.clone())
4017                .expect("conversion succeeds"),
4018            Vec::new(),
4019            "a foreign-annotated empty block must produce no Anthropic content"
4020        );
4021
4022        let message = message::Message::Assistant {
4023            id: None,
4024            content: vec![
4025                foreign_annotated_empty,
4026                message::AssistantContent::text("real answer"),
4027            ],
4028        };
4029        let converted = Message::try_from(message).expect("message converts");
4030        assert_eq!(converted.content.len(), 1, "only the real block survives");
4031        assert!(matches!(
4032            converted.content.first(),
4033            Some(Content::Text { text, .. }) if text == "real answer"
4034        ));
4035    }
4036
4037    #[test]
4038    fn opus_4_8_preserves_system_message_after_assistant_server_tool_use() {
4039        let request = completion_request_with_history(
4040            vec![
4041                message::Message::Assistant {
4042                    id: None,
4043                    content: vec![message::AssistantContent::Text(message::Text {
4044                        text: String::new(),
4045                        additional_params: crate::message::AdditionalParams::try_from_value(
4046                            json!({
4047                                ANTHROPIC_RAW_CONTENT_KEY: {
4048                                    "type": "server_tool_use",
4049                                    "id": "srvtoolu_01",
4050                                    "name": "web_search",
4051                                    "input": {
4052                                        "query": "clear daytime sky color"
4053                                    }
4054                                }
4055                            }),
4056                        )
4057                        .expect("object params"),
4058                    })],
4059                },
4060                message::Message::System {
4061                    content: "For the rest of this conversation, answer in Spanish.".to_string(),
4062                },
4063                message::Message::assistant("Entendido."),
4064            ],
4065            None,
4066        );
4067
4068        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4069            model: CLAUDE_OPUS_4_8,
4070            request,
4071            prompt_caching: false,
4072            automatic_caching: false,
4073            automatic_caching_ttl: None,
4074            static_prefix_cache_ttl: None,
4075        })
4076        .unwrap();
4077
4078        let value = serde_json::to_value(request).unwrap();
4079        assert!(value.get("system").is_none());
4080
4081        let messages = value["messages"].as_array().unwrap();
4082        assert_eq!(messages.len(), 3);
4083        assert_eq!(messages[0]["role"], "assistant");
4084        assert_eq!(messages[0]["content"][0]["type"], "server_tool_use");
4085        assert_eq!(messages[1]["role"], "system");
4086        assert_eq!(
4087            messages[1]["content"][0]["text"],
4088            "For the rest of this conversation, answer in Spanish."
4089        );
4090        assert_eq!(messages[2]["role"], "assistant");
4091    }
4092
4093    #[test]
4094    fn opus_4_8_hoists_system_message_in_invalid_mid_conversation_position() {
4095        let request = completion_request_with_history(
4096            vec![
4097                message::Message::user("Review this code."),
4098                message::Message::System {
4099                    content: "From now on, require explicit type annotations.".to_string(),
4100                },
4101                message::Message::user("Now review this other file."),
4102            ],
4103            None,
4104        );
4105
4106        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4107            model: CLAUDE_OPUS_4_8,
4108            request,
4109            prompt_caching: false,
4110            automatic_caching: false,
4111            automatic_caching_ttl: None,
4112            static_prefix_cache_ttl: None,
4113        })
4114        .unwrap();
4115
4116        let value = serde_json::to_value(request).unwrap();
4117        assert_eq!(
4118            value["system"][0]["text"],
4119            "From now on, require explicit type annotations."
4120        );
4121
4122        let messages = value["messages"].as_array().unwrap();
4123        assert_eq!(messages.len(), 2);
4124        assert_eq!(messages[0]["role"], "user");
4125        assert_eq!(messages[1]["role"], "user");
4126    }
4127
4128    #[test]
4129    fn older_anthropic_models_hoist_mid_conversation_system_message() {
4130        let request = completion_request_with_history(
4131            vec![
4132                message::Message::from("Review this code."),
4133                message::Message::System {
4134                    content: "From now on, require explicit type annotations.".to_string(),
4135                },
4136            ],
4137            None,
4138        );
4139
4140        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4141            model: CLAUDE_OPUS_4_7,
4142            request,
4143            prompt_caching: false,
4144            automatic_caching: false,
4145            automatic_caching_ttl: None,
4146            static_prefix_cache_ttl: None,
4147        })
4148        .unwrap();
4149
4150        let value = serde_json::to_value(request).unwrap();
4151        assert_eq!(
4152            value["system"][0]["text"],
4153            "From now on, require explicit type annotations."
4154        );
4155
4156        let messages = value["messages"].as_array().unwrap();
4157        assert_eq!(messages.len(), 1);
4158        assert_eq!(messages[0]["role"], "user");
4159    }
4160
4161    #[test]
4162    fn test_tool_definition_cache_control_serialization() {
4163        let tool = ToolDefinition {
4164            name: "cached_tool".to_string(),
4165            description: Some("Cached tool".to_string()),
4166            input_schema: json!({"type": "object"}),
4167            strict: false,
4168            cache_control: Some(CacheControl::ephemeral()),
4169        };
4170
4171        let value = serde_json::to_value(tool).unwrap();
4172        assert_eq!(value["cache_control"]["type"], "ephemeral");
4173
4174        let tool_without_cache = ToolDefinition {
4175            name: "uncached_tool".to_string(),
4176            description: Some("Uncached tool".to_string()),
4177            input_schema: json!({"type": "object"}),
4178            strict: false,
4179            cache_control: None,
4180        };
4181
4182        let value = serde_json::to_value(tool_without_cache).unwrap();
4183        assert!(value.get("cache_control").is_none());
4184    }
4185
4186    #[test]
4187    fn test_apply_tool_cache_control_marks_only_final_tool() {
4188        let mut tools = vec![
4189            json!({
4190                "name": "first_tool",
4191                "description": "First tool",
4192                "input_schema": {"type": "object"}
4193            }),
4194            json!({
4195                "name": "second_tool",
4196                "description": "Second tool",
4197                "input_schema": {"type": "object"}
4198            }),
4199        ];
4200
4201        let mut remaining_cache_markers = 4;
4202        apply_tool_cache_control(
4203            &mut tools,
4204            &mut remaining_cache_markers,
4205            &CacheControl::ephemeral(),
4206        )
4207        .unwrap();
4208
4209        assert!(tools[0].get("cache_control").is_none());
4210        assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4211        assert_eq!(remaining_cache_markers, 3);
4212    }
4213
4214    #[test]
4215    fn test_prompt_caching_skips_final_deferred_tool_in_request() {
4216        let request = completion_request_with_tools(
4217            Vec::new(),
4218            Some(json!({
4219                "tools": [
4220                    {
4221                        "name": "regular_tool",
4222                        "description": "Regular tool",
4223                        "input_schema": {"type": "object"}
4224                    },
4225                    {
4226                        "name": "deferred_tool",
4227                        "description": "Deferred tool",
4228                        "input_schema": {"type": "object"},
4229                        "defer_loading": true
4230                    }
4231                ]
4232            })),
4233        );
4234
4235        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4236            model: "claude-sonnet-4-6",
4237            request,
4238            prompt_caching: true,
4239            automatic_caching: false,
4240            automatic_caching_ttl: None,
4241            static_prefix_cache_ttl: None,
4242        })
4243        .unwrap();
4244
4245        let value = serde_json::to_value(request).unwrap();
4246        let tools = value["tools"].as_array().unwrap();
4247        assert_eq!(tools[0]["name"], "regular_tool");
4248        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4249        assert_eq!(tools[1]["name"], "deferred_tool");
4250        assert!(tools[1].get("cache_control").is_none());
4251    }
4252
4253    #[test]
4254    fn test_prompt_caching_preserves_existing_final_tool_cache_control() {
4255        let request = completion_request_with_tools(
4256            Vec::new(),
4257            Some(json!({
4258                "tools": [{
4259                    "name": "cached_tool",
4260                    "description": "Cached tool",
4261                    "input_schema": {"type": "object"},
4262                    "cache_control": {"type": "ephemeral", "ttl": "1h"}
4263                }]
4264            })),
4265        );
4266
4267        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4268            model: "claude-sonnet-4-6",
4269            request,
4270            prompt_caching: true,
4271            automatic_caching: false,
4272            automatic_caching_ttl: None,
4273            static_prefix_cache_ttl: None,
4274        })
4275        .unwrap();
4276
4277        let value = serde_json::to_value(request).unwrap();
4278        let tools = value["tools"].as_array().unwrap();
4279        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4280        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
4281    }
4282
4283    #[test]
4284    fn test_prompt_caching_all_deferred_tools_do_not_receive_cache_control() {
4285        let request = completion_request_with_tools(
4286            Vec::new(),
4287            Some(json!({
4288                "tools": [
4289                    {
4290                        "name": "first_deferred_tool",
4291                        "description": "First deferred tool",
4292                        "input_schema": {"type": "object"},
4293                        "defer_loading": true
4294                    },
4295                    {
4296                        "name": "second_deferred_tool",
4297                        "description": "Second deferred tool",
4298                        "input_schema": {"type": "object"},
4299                        "defer_loading": true
4300                    }
4301                ]
4302            })),
4303        );
4304
4305        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4306            model: "claude-sonnet-4-6",
4307            request,
4308            prompt_caching: true,
4309            automatic_caching: false,
4310            automatic_caching_ttl: None,
4311            static_prefix_cache_ttl: None,
4312        })
4313        .unwrap();
4314
4315        let value = serde_json::to_value(request).unwrap();
4316        let tools = value["tools"].as_array().unwrap();
4317        assert!(tools[0].get("cache_control").is_none());
4318        assert!(tools[1].get("cache_control").is_none());
4319    }
4320
4321    #[test]
4322    fn test_prompt_caching_preserves_earlier_tool_cache_control() {
4323        let request = completion_request_with_tools(
4324            Vec::new(),
4325            Some(json!({
4326                "tools": [
4327                    {
4328                        "name": "earlier_tool",
4329                        "description": "Earlier tool",
4330                        "input_schema": {"type": "object"},
4331                        "cache_control": {"type": "ephemeral", "ttl": "1h"}
4332                    },
4333                    {
4334                        "name": "later_tool",
4335                        "description": "Later tool",
4336                        "input_schema": {"type": "object"}
4337                    }
4338                ]
4339            })),
4340        );
4341
4342        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4343            model: "claude-sonnet-4-6",
4344            request,
4345            prompt_caching: true,
4346            automatic_caching: false,
4347            automatic_caching_ttl: None,
4348            static_prefix_cache_ttl: None,
4349        })
4350        .unwrap();
4351
4352        let value = serde_json::to_value(request).unwrap();
4353        let tools = value["tools"].as_array().unwrap();
4354        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4355        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
4356        assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4357    }
4358
4359    #[test]
4360    fn test_prompt_caching_deferred_marker_does_not_suppress_loaded_tool_marker() {
4361        let request = completion_request_with_tools(
4362            Vec::new(),
4363            Some(json!({
4364                "tools": [
4365                    {
4366                        "name": "regular_tool",
4367                        "description": "Regular tool",
4368                        "input_schema": {"type": "object"}
4369                    },
4370                    {
4371                        "name": "deferred_cached_tool",
4372                        "description": "Deferred cached tool",
4373                        "input_schema": {"type": "object"},
4374                        "defer_loading": true,
4375                        "cache_control": {"type": "ephemeral"}
4376                    }
4377                ]
4378            })),
4379        );
4380
4381        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4382            model: "claude-sonnet-4-6",
4383            request,
4384            prompt_caching: true,
4385            automatic_caching: false,
4386            automatic_caching_ttl: None,
4387            static_prefix_cache_ttl: None,
4388        })
4389        .unwrap();
4390
4391        let value = serde_json::to_value(request).unwrap();
4392        let tools = value["tools"].as_array().unwrap();
4393        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4394        assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4395    }
4396
4397    #[test]
4398    fn test_prompt_caching_errors_when_tool_cache_control_ttl_order_is_invalid() {
4399        let request = completion_request_with_tools(
4400            Vec::new(),
4401            Some(json!({
4402                "tools": [
4403                    {
4404                        "name": "first_cached_tool",
4405                        "description": "First cached tool",
4406                        "input_schema": {"type": "object"},
4407                        "cache_control": {"type": "ephemeral"}
4408                    },
4409                    {
4410                        "name": "second_cached_tool",
4411                        "description": "Second cached tool",
4412                        "input_schema": {"type": "object"},
4413                        "cache_control": {"type": "ephemeral", "ttl": "1h"}
4414                    }
4415                ]
4416            })),
4417        );
4418
4419        let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4420            model: "claude-sonnet-4-6",
4421            request,
4422            prompt_caching: true,
4423            automatic_caching: false,
4424            automatic_caching_ttl: None,
4425            static_prefix_cache_ttl: None,
4426        })
4427        .unwrap_err();
4428
4429        assert!(err.to_string().contains("ttl `1h`"));
4430    }
4431
4432    #[test]
4433    fn test_prompt_caching_preserves_valid_mixed_ttl_tool_cache_controls() {
4434        let request = completion_request_with_tools(
4435            Vec::new(),
4436            Some(json!({
4437                "tools": [
4438                    {
4439                        "name": "first_cached_tool",
4440                        "description": "First cached tool",
4441                        "input_schema": {"type": "object"},
4442                        "cache_control": {"type": "ephemeral", "ttl": "1h"}
4443                    },
4444                    {
4445                        "name": "second_cached_tool",
4446                        "description": "Second cached tool",
4447                        "input_schema": {"type": "object"},
4448                        "cache_control": {"type": "ephemeral"}
4449                    }
4450                ]
4451            })),
4452        );
4453
4454        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4455            model: "claude-sonnet-4-6",
4456            request,
4457            prompt_caching: true,
4458            automatic_caching: false,
4459            automatic_caching_ttl: None,
4460            static_prefix_cache_ttl: None,
4461        })
4462        .unwrap();
4463
4464        let value = serde_json::to_value(request).unwrap();
4465        let tools = value["tools"].as_array().unwrap();
4466        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4467        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
4468        assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4469        assert!(tools[1]["cache_control"].get("ttl").is_none());
4470    }
4471
4472    #[test]
4473    fn test_prompt_caching_preserves_deferred_tool_cache_control() {
4474        let request = completion_request_with_tools(
4475            Vec::new(),
4476            Some(json!({
4477                "tools": [{
4478                    "name": "deferred_cached_tool",
4479                    "description": "Deferred cached tool",
4480                    "input_schema": {"type": "object"},
4481                    "defer_loading": true,
4482                    "cache_control": {"type": "ephemeral"}
4483                }]
4484            })),
4485        );
4486
4487        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4488            model: "claude-sonnet-4-6",
4489            request,
4490            prompt_caching: true,
4491            automatic_caching: false,
4492            automatic_caching_ttl: None,
4493            static_prefix_cache_ttl: None,
4494        })
4495        .unwrap();
4496
4497        let value = serde_json::to_value(request).unwrap();
4498        let tools = value["tools"].as_array().unwrap();
4499        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4500    }
4501
4502    #[test]
4503    fn test_prompt_caching_budget_preserves_three_tool_markers_and_skips_message() {
4504        let request = completion_request_with_tools(
4505            Vec::new(),
4506            Some(json!({
4507                "tools": [
4508                    {
4509                        "name": "first_cached_tool",
4510                        "description": "First cached tool",
4511                        "input_schema": {"type": "object"},
4512                        "cache_control": {"type": "ephemeral"}
4513                    },
4514                    {
4515                        "name": "second_cached_tool",
4516                        "description": "Second cached tool",
4517                        "input_schema": {"type": "object"},
4518                        "cache_control": {"type": "ephemeral"}
4519                    },
4520                    {
4521                        "name": "third_cached_tool",
4522                        "description": "Third cached tool",
4523                        "input_schema": {"type": "object"},
4524                        "cache_control": {"type": "ephemeral"}
4525                    }
4526                ]
4527            })),
4528        );
4529
4530        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4531            model: "claude-sonnet-4-6",
4532            request,
4533            prompt_caching: true,
4534            automatic_caching: false,
4535            automatic_caching_ttl: None,
4536            static_prefix_cache_ttl: None,
4537        })
4538        .unwrap();
4539
4540        let value = serde_json::to_value(request).unwrap();
4541        let tools = value["tools"].as_array().unwrap();
4542        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4543        assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4544        assert_eq!(tools[2]["cache_control"]["type"], "ephemeral");
4545        assert!(system_has_cache_control(&value));
4546        assert!(!last_message_has_cache_control(&value));
4547    }
4548
4549    #[test]
4550    fn test_prompt_caching_errors_when_explicit_tool_markers_exceed_budget() {
4551        let request = completion_request_with_tools(
4552            Vec::new(),
4553            Some(json!({
4554                "tools": [
4555                    {
4556                        "name": "first_cached_tool",
4557                        "description": "First cached tool",
4558                        "input_schema": {"type": "object"},
4559                        "cache_control": {"type": "ephemeral"}
4560                    },
4561                    {
4562                        "name": "second_cached_tool",
4563                        "description": "Second cached tool",
4564                        "input_schema": {"type": "object"},
4565                        "cache_control": {"type": "ephemeral"}
4566                    },
4567                    {
4568                        "name": "third_cached_tool",
4569                        "description": "Third cached tool",
4570                        "input_schema": {"type": "object"},
4571                        "cache_control": {"type": "ephemeral"}
4572                    },
4573                    {
4574                        "name": "fourth_cached_tool",
4575                        "description": "Fourth cached tool",
4576                        "input_schema": {"type": "object"},
4577                        "cache_control": {"type": "ephemeral"}
4578                    },
4579                    {
4580                        "name": "fifth_cached_tool",
4581                        "description": "Fifth cached tool",
4582                        "input_schema": {"type": "object"},
4583                        "cache_control": {"type": "ephemeral"}
4584                    }
4585                ]
4586            })),
4587        );
4588
4589        let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4590            model: "claude-sonnet-4-6",
4591            request,
4592            prompt_caching: true,
4593            automatic_caching: false,
4594            automatic_caching_ttl: None,
4595            static_prefix_cache_ttl: None,
4596        })
4597        .unwrap_err();
4598
4599        assert!(err.to_string().contains("Too many Anthropic tool"));
4600    }
4601
4602    #[test]
4603    fn test_prompt_caching_errors_when_final_tool_marker_has_no_budget() {
4604        let request = completion_request_with_tools(
4605            Vec::new(),
4606            Some(json!({
4607                "tools": [
4608                    {
4609                        "name": "first_cached_tool",
4610                        "description": "First cached tool",
4611                        "input_schema": {"type": "object"},
4612                        "cache_control": {"type": "ephemeral"}
4613                    },
4614                    {
4615                        "name": "second_cached_tool",
4616                        "description": "Second cached tool",
4617                        "input_schema": {"type": "object"},
4618                        "cache_control": {"type": "ephemeral"}
4619                    },
4620                    {
4621                        "name": "third_cached_tool",
4622                        "description": "Third cached tool",
4623                        "input_schema": {"type": "object"},
4624                        "cache_control": {"type": "ephemeral"}
4625                    },
4626                    {
4627                        "name": "fourth_cached_tool",
4628                        "description": "Fourth cached tool",
4629                        "input_schema": {"type": "object"},
4630                        "cache_control": {"type": "ephemeral"}
4631                    },
4632                    {
4633                        "name": "final_uncached_tool",
4634                        "description": "Final uncached tool",
4635                        "input_schema": {"type": "object"}
4636                    }
4637                ]
4638            })),
4639        );
4640
4641        let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4642            model: "claude-sonnet-4-6",
4643            request,
4644            prompt_caching: true,
4645            automatic_caching: false,
4646            automatic_caching_ttl: None,
4647            static_prefix_cache_ttl: None,
4648        })
4649        .unwrap_err();
4650
4651        assert!(err.to_string().contains("final non-deferred tool"));
4652    }
4653
4654    #[test]
4655    fn test_prompt_caching_replaces_null_final_tool_cache_control() {
4656        let request = completion_request_with_tools(
4657            Vec::new(),
4658            Some(json!({
4659                "tools": [{
4660                    "name": "final_tool",
4661                    "description": "Final tool",
4662                    "input_schema": {"type": "object"},
4663                    "cache_control": null
4664                }]
4665            })),
4666        );
4667
4668        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4669            model: "claude-sonnet-4-6",
4670            request,
4671            prompt_caching: true,
4672            automatic_caching: false,
4673            automatic_caching_ttl: None,
4674            static_prefix_cache_ttl: None,
4675        })
4676        .unwrap();
4677
4678        let value = serde_json::to_value(request).unwrap();
4679        let tools = value["tools"].as_array().unwrap();
4680        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4681    }
4682
4683    #[test]
4684    fn test_prompt_caching_ignores_null_tool_cache_control_when_budgeting() {
4685        let request = completion_request_with_tools(
4686            Vec::new(),
4687            Some(json!({
4688                "tools": [
4689                    {
4690                        "name": "first_null_tool",
4691                        "description": "First null tool",
4692                        "input_schema": {"type": "object"},
4693                        "cache_control": null
4694                    },
4695                    {
4696                        "name": "second_null_tool",
4697                        "description": "Second null tool",
4698                        "input_schema": {"type": "object"},
4699                        "cache_control": null
4700                    },
4701                    {
4702                        "name": "third_null_tool",
4703                        "description": "Third null tool",
4704                        "input_schema": {"type": "object"},
4705                        "cache_control": null
4706                    },
4707                    {
4708                        "name": "fourth_null_tool",
4709                        "description": "Fourth null tool",
4710                        "input_schema": {"type": "object"},
4711                        "cache_control": null
4712                    },
4713                    {
4714                        "name": "final_uncached_tool",
4715                        "description": "Final uncached tool",
4716                        "input_schema": {"type": "object"}
4717                    }
4718                ]
4719            })),
4720        );
4721
4722        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4723            model: "claude-sonnet-4-6",
4724            request,
4725            prompt_caching: true,
4726            automatic_caching: false,
4727            automatic_caching_ttl: None,
4728            static_prefix_cache_ttl: None,
4729        })
4730        .unwrap();
4731
4732        let value = serde_json::to_value(request).unwrap();
4733        let tools = value["tools"].as_array().unwrap();
4734        assert!(tools[0].get("cache_control").is_none());
4735        assert!(tools[1].get("cache_control").is_none());
4736        assert!(tools[2].get("cache_control").is_none());
4737        assert!(tools[3].get("cache_control").is_none());
4738        assert_eq!(tools[4]["cache_control"]["type"], "ephemeral");
4739    }
4740
4741    #[test]
4742    fn test_prompt_caching_preserves_non_null_provider_tool_cache_control_escape_hatch() {
4743        let request = completion_request_with_tools(
4744            Vec::new(),
4745            Some(json!({
4746                "tools": [{
4747                    "name": "provider_tool",
4748                    "description": "Provider tool",
4749                    "input_schema": {"type": "object"},
4750                    "cache_control": {"type": "provider_specific"}
4751                }]
4752            })),
4753        );
4754
4755        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4756            model: "claude-sonnet-4-6",
4757            request,
4758            prompt_caching: true,
4759            automatic_caching: false,
4760            automatic_caching_ttl: None,
4761            static_prefix_cache_ttl: None,
4762        })
4763        .unwrap();
4764
4765        let value = serde_json::to_value(request).unwrap();
4766        let tools = value["tools"].as_array().unwrap();
4767        assert_eq!(tools[0]["cache_control"]["type"], "provider_specific");
4768    }
4769
4770    #[test]
4771    fn test_prompt_caching_automatic_mode_uses_reduced_marker_budget() {
4772        let request = completion_request_with_tools(
4773            Vec::new(),
4774            Some(json!({
4775                "tools": [
4776                    {
4777                        "name": "first_cached_tool",
4778                        "description": "First cached tool",
4779                        "input_schema": {"type": "object"},
4780                        "cache_control": {"type": "ephemeral"}
4781                    },
4782                    {
4783                        "name": "second_cached_tool",
4784                        "description": "Second cached tool",
4785                        "input_schema": {"type": "object"},
4786                        "cache_control": {"type": "ephemeral"}
4787                    },
4788                    {
4789                        "name": "third_cached_tool",
4790                        "description": "Third cached tool",
4791                        "input_schema": {"type": "object"},
4792                        "cache_control": {"type": "ephemeral"}
4793                    }
4794                ]
4795            })),
4796        );
4797
4798        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4799            model: "claude-sonnet-4-6",
4800            request,
4801            prompt_caching: true,
4802            automatic_caching: true,
4803            automatic_caching_ttl: None,
4804            static_prefix_cache_ttl: None,
4805        })
4806        .unwrap();
4807
4808        let value = serde_json::to_value(request).unwrap();
4809        let tools = value["tools"].as_array().unwrap();
4810        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4811        assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4812        assert_eq!(tools[2]["cache_control"]["type"], "ephemeral");
4813        assert_eq!(value["cache_control"]["type"], "ephemeral");
4814        assert!(!system_has_cache_control(&value));
4815        assert!(!last_message_has_cache_control(&value));
4816    }
4817
4818    #[test]
4819    fn test_prompt_caching_automatic_mode_errors_when_final_tool_marker_has_no_budget() {
4820        let request = completion_request_with_tools(
4821            Vec::new(),
4822            Some(json!({
4823                "tools": [
4824                    {
4825                        "name": "first_cached_tool",
4826                        "description": "First cached tool",
4827                        "input_schema": {"type": "object"},
4828                        "cache_control": {"type": "ephemeral"}
4829                    },
4830                    {
4831                        "name": "second_cached_tool",
4832                        "description": "Second cached tool",
4833                        "input_schema": {"type": "object"},
4834                        "cache_control": {"type": "ephemeral"}
4835                    },
4836                    {
4837                        "name": "third_cached_tool",
4838                        "description": "Third cached tool",
4839                        "input_schema": {"type": "object"},
4840                        "cache_control": {"type": "ephemeral"}
4841                    },
4842                    {
4843                        "name": "final_uncached_tool",
4844                        "description": "Final uncached tool",
4845                        "input_schema": {"type": "object"}
4846                    }
4847                ]
4848            })),
4849        );
4850
4851        let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4852            model: "claude-sonnet-4-6",
4853            request,
4854            prompt_caching: true,
4855            automatic_caching: true,
4856            automatic_caching_ttl: None,
4857            static_prefix_cache_ttl: None,
4858        })
4859        .unwrap_err();
4860
4861        assert!(err.to_string().contains("final non-deferred tool"));
4862    }
4863
4864    #[test]
4865    fn test_automatic_caching_errors_when_explicit_tool_markers_exhaust_budget() {
4866        let request = completion_request_with_tools(
4867            Vec::new(),
4868            Some(json!({
4869                "tools": [
4870                    {
4871                        "name": "first_cached_tool",
4872                        "description": "First cached tool",
4873                        "input_schema": {"type": "object"},
4874                        "cache_control": {"type": "ephemeral"}
4875                    },
4876                    {
4877                        "name": "second_cached_tool",
4878                        "description": "Second cached tool",
4879                        "input_schema": {"type": "object"},
4880                        "cache_control": {"type": "ephemeral"}
4881                    },
4882                    {
4883                        "name": "third_cached_tool",
4884                        "description": "Third cached tool",
4885                        "input_schema": {"type": "object"},
4886                        "cache_control": {"type": "ephemeral"}
4887                    },
4888                    {
4889                        "name": "fourth_cached_tool",
4890                        "description": "Fourth cached tool",
4891                        "input_schema": {"type": "object"},
4892                        "cache_control": {"type": "ephemeral"}
4893                    }
4894                ]
4895            })),
4896        );
4897
4898        let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4899            model: "claude-sonnet-4-6",
4900            request,
4901            prompt_caching: false,
4902            automatic_caching: true,
4903            automatic_caching_ttl: None,
4904            static_prefix_cache_ttl: None,
4905        })
4906        .unwrap_err();
4907
4908        assert!(err.to_string().contains("Too many Anthropic tool"));
4909    }
4910
4911    #[test]
4912    fn test_automatic_caching_1h_errors_with_explicit_five_minute_tool_marker() {
4913        let request = completion_request_with_tools(
4914            Vec::new(),
4915            Some(json!({
4916                "tools": [{
4917                    "name": "cached_tool",
4918                    "description": "Cached tool",
4919                    "input_schema": {"type": "object"},
4920                    "cache_control": {"type": "ephemeral"}
4921                }]
4922            })),
4923        );
4924
4925        let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4926            model: "claude-sonnet-4-6",
4927            request,
4928            prompt_caching: false,
4929            automatic_caching: true,
4930            automatic_caching_ttl: Some(CacheTtl::OneHour),
4931            static_prefix_cache_ttl: None,
4932        })
4933        .unwrap_err();
4934
4935        assert!(err.to_string().contains("ttl `1h`"));
4936    }
4937
4938    #[test]
4939    fn test_prompt_and_automatic_caching_1h_uses_1h_generated_markers() {
4940        let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
4941
4942        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4943            model: "claude-sonnet-4-6",
4944            request,
4945            prompt_caching: true,
4946            automatic_caching: true,
4947            automatic_caching_ttl: Some(CacheTtl::OneHour),
4948            static_prefix_cache_ttl: None,
4949        })
4950        .unwrap();
4951
4952        let value = serde_json::to_value(request).unwrap();
4953        let tools = value["tools"].as_array().unwrap();
4954        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4955        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
4956        assert_eq!(
4957            value["system"]
4958                .as_array()
4959                .and_then(|blocks| blocks.last())
4960                .and_then(|block| block["cache_control"].get("ttl")),
4961            Some(&json!("1h"))
4962        );
4963        assert_eq!(value["cache_control"]["ttl"], "1h");
4964        assert!(!last_message_has_cache_control(&value));
4965    }
4966
4967    #[test]
4968    fn test_prompt_and_raw_top_level_automatic_caching_1h_uses_1h_generated_markers() {
4969        let request = completion_request_with_tools(
4970            vec![generic_tool("cached_tool")],
4971            Some(json!({
4972                "cache_control": {"type": "ephemeral", "ttl": "1h"},
4973                "metadata": {"source": "test"}
4974            })),
4975        );
4976
4977        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4978            model: "claude-sonnet-4-6",
4979            request,
4980            prompt_caching: true,
4981            automatic_caching: true,
4982            automatic_caching_ttl: None,
4983            static_prefix_cache_ttl: None,
4984        })
4985        .unwrap();
4986
4987        let value = serde_json::to_value(request).unwrap();
4988        let tools = value["tools"].as_array().unwrap();
4989        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4990        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
4991        assert_eq!(
4992            value["system"]
4993                .as_array()
4994                .and_then(|blocks| blocks.last())
4995                .and_then(|block| block["cache_control"].get("ttl")),
4996            Some(&json!("1h"))
4997        );
4998        assert_eq!(value["cache_control"]["ttl"], "1h");
4999        assert_eq!(value["metadata"]["source"], "test");
5000        assert!(!last_message_has_cache_control(&value));
5001    }
5002
5003    #[test]
5004    fn test_prompt_caching_uses_raw_top_level_cache_control_ttl() {
5005        let request = completion_request_with_tools(
5006            vec![generic_tool("cached_tool")],
5007            Some(json!({
5008                "cache_control": {"type": "ephemeral", "ttl": "1h"},
5009                "metadata": {"source": "raw-cache-control"}
5010            })),
5011        );
5012
5013        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5014            model: "claude-sonnet-4-6",
5015            request,
5016            prompt_caching: true,
5017            automatic_caching: false,
5018            automatic_caching_ttl: None,
5019            static_prefix_cache_ttl: None,
5020        })
5021        .unwrap();
5022
5023        let value = serde_json::to_value(request).unwrap();
5024        let tools = value["tools"].as_array().unwrap();
5025        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
5026        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
5027        assert_eq!(
5028            value["system"]
5029                .as_array()
5030                .and_then(|blocks| blocks.last())
5031                .and_then(|block| block["cache_control"].get("ttl")),
5032            Some(&json!("1h"))
5033        );
5034        assert_eq!(value["cache_control"]["ttl"], "1h");
5035        assert_eq!(value["metadata"]["source"], "raw-cache-control");
5036        assert!(!last_message_has_cache_control(&value));
5037    }
5038
5039    #[test]
5040    fn test_static_prefix_ttl_with_manual_caching_splits_prefix_and_tail() {
5041        let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5042
5043        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5044            model: "claude-sonnet-4-6",
5045            request,
5046            prompt_caching: true,
5047            automatic_caching: false,
5048            automatic_caching_ttl: None,
5049            static_prefix_cache_ttl: Some(CacheTtl::OneHour),
5050        })
5051        .unwrap();
5052
5053        let value = serde_json::to_value(request).unwrap();
5054        let tools = value["tools"].as_array().unwrap();
5055        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
5056        assert_eq!(
5057            value["system"]
5058                .as_array()
5059                .and_then(|blocks| blocks.last())
5060                .and_then(|block| block["cache_control"].get("ttl")),
5061            Some(&json!("1h"))
5062        );
5063        // The tail keeps the 5-minute default: a marker with no `ttl` field.
5064        let tail_cache_control = value["messages"]
5065            .as_array()
5066            .and_then(|messages| messages.last())
5067            .and_then(|message| message["content"].as_array())
5068            .and_then(|content| content.last())
5069            .map(|block| &block["cache_control"])
5070            .unwrap();
5071        assert_eq!(tail_cache_control["type"], "ephemeral");
5072        assert!(tail_cache_control.get("ttl").is_none());
5073        assert!(value.get("cache_control").is_none());
5074    }
5075
5076    #[test]
5077    fn test_static_prefix_ttl_with_automatic_caching_marks_prefix_only() {
5078        let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5079
5080        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5081            model: "claude-sonnet-4-6",
5082            request,
5083            prompt_caching: false,
5084            automatic_caching: true,
5085            automatic_caching_ttl: None,
5086            static_prefix_cache_ttl: Some(CacheTtl::OneHour),
5087        })
5088        .unwrap();
5089
5090        let value = serde_json::to_value(request).unwrap();
5091        let tools = value["tools"].as_array().unwrap();
5092        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
5093        assert_eq!(
5094            value["system"]
5095                .as_array()
5096                .and_then(|blocks| blocks.last())
5097                .and_then(|block| block["cache_control"].get("ttl")),
5098            Some(&json!("1h"))
5099        );
5100        // The moving tail breakpoint is Anthropic's top-level one at the
5101        // 5-minute default; no explicit message marker exists.
5102        assert_eq!(value["cache_control"]["type"], "ephemeral");
5103        assert!(value["cache_control"].get("ttl").is_none());
5104        assert!(!last_message_has_cache_control(&value));
5105    }
5106
5107    #[test]
5108    fn test_static_prefix_ttl_alone_marks_prefix_without_tail_or_top_level() {
5109        let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5110
5111        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5112            model: "claude-sonnet-4-6",
5113            request,
5114            prompt_caching: false,
5115            automatic_caching: false,
5116            automatic_caching_ttl: None,
5117            static_prefix_cache_ttl: Some(CacheTtl::OneHour),
5118        })
5119        .unwrap();
5120
5121        let value = serde_json::to_value(request).unwrap();
5122        let tools = value["tools"].as_array().unwrap();
5123        assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
5124        assert_eq!(
5125            value["system"]
5126                .as_array()
5127                .and_then(|blocks| blocks.last())
5128                .and_then(|block| block["cache_control"].get("ttl")),
5129            Some(&json!("1h"))
5130        );
5131        assert!(value.get("cache_control").is_none());
5132        assert!(!last_message_has_cache_control(&value));
5133    }
5134
5135    #[test]
5136    fn test_static_prefix_ttl_five_minutes_with_automatic_1h_errors_client_side() {
5137        let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5138
5139        let error = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5140            model: "claude-sonnet-4-6",
5141            request,
5142            prompt_caching: false,
5143            automatic_caching: true,
5144            automatic_caching_ttl: Some(CacheTtl::OneHour),
5145            static_prefix_cache_ttl: Some(CacheTtl::FiveMinutes),
5146        })
5147        .unwrap_err();
5148
5149        let message = error.to_string();
5150        assert!(
5151            message.contains("with_static_prefix_cache_ttl"),
5152            "error should name the knob: {message}"
5153        );
5154        assert!(
5155            message.contains("with_automatic_caching_1h"),
5156            "error should name the conflicting knob: {message}"
5157        );
5158    }
5159
5160    #[test]
5161    fn test_static_prefix_ttl_five_minutes_matches_automatic_default_ttl() {
5162        let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5163
5164        // 5m prefix + 5m (default) top-level is uniform, not an inversion.
5165        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5166            model: "claude-sonnet-4-6",
5167            request,
5168            prompt_caching: false,
5169            automatic_caching: true,
5170            automatic_caching_ttl: None,
5171            static_prefix_cache_ttl: Some(CacheTtl::FiveMinutes),
5172        })
5173        .unwrap();
5174
5175        let value = serde_json::to_value(request).unwrap();
5176        let tools = value["tools"].as_array().unwrap();
5177        assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
5178        // The explicit knob serializes an explicit `"5m"`, equivalent to the
5179        // omitted-`ttl` default.
5180        assert_eq!(tools[0]["cache_control"]["ttl"], "5m");
5181    }
5182
5183    #[test]
5184    fn test_static_prefix_ttl_preserves_marker_budget_arithmetic() {
5185        // Automatic mode reserves one marker for the top-level breakpoint; the
5186        // static-prefix knob spends from the same remaining budget as manual
5187        // caching does — two markers (final tool + system), no more.
5188        let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5189
5190        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5191            model: "claude-sonnet-4-6",
5192            request,
5193            prompt_caching: false,
5194            automatic_caching: true,
5195            automatic_caching_ttl: None,
5196            static_prefix_cache_ttl: Some(CacheTtl::OneHour),
5197        })
5198        .unwrap();
5199
5200        let value = serde_json::to_value(request).unwrap();
5201        let marker_count = value["tools"]
5202            .as_array()
5203            .into_iter()
5204            .flatten()
5205            .filter(|tool| !tool["cache_control"].is_null())
5206            .count()
5207            + value["system"]
5208                .as_array()
5209                .into_iter()
5210                .flatten()
5211                .filter(|block| !block["cache_control"].is_null())
5212                .count()
5213            + usize::from(!value["cache_control"].is_null());
5214        assert_eq!(marker_count, 3);
5215        assert!(marker_count <= MAX_CACHE_CONTROL_MARKERS);
5216    }
5217
5218    #[test]
5219    fn test_usage_parses_per_ttl_cache_creation_breakdown() {
5220        let usage: Usage = serde_json::from_str(
5221            r#"{
5222                "input_tokens": 3,
5223                "cache_read_input_tokens": 0,
5224                "cache_creation_input_tokens": 9677,
5225                "cache_creation": {
5226                    "ephemeral_5m_input_tokens": 9677,
5227                    "ephemeral_1h_input_tokens": 0,
5228                    "ephemeral_24h_input_tokens": 0
5229                },
5230                "output_tokens": 7
5231            }"#,
5232        )
5233        .unwrap();
5234
5235        assert_eq!(usage.cache_creation_input_tokens, Some(9677));
5236        let cache_creation = usage.cache_creation.unwrap();
5237        assert_eq!(cache_creation.ephemeral_5m_input_tokens, 9677);
5238        assert_eq!(cache_creation.ephemeral_1h_input_tokens, 0);
5239    }
5240
5241    #[test]
5242    fn test_usage_without_cache_creation_breakdown_parses_as_none() {
5243        let usage: Usage =
5244            serde_json::from_str(r#"{"input_tokens": 3, "output_tokens": 7}"#).unwrap();
5245        assert!(usage.cache_creation.is_none());
5246    }
5247
5248    #[test]
5249    fn test_raw_top_level_automatic_caching_reduces_marker_budget() {
5250        let request = completion_request_with_tools(
5251            Vec::new(),
5252            Some(json!({
5253                "cache_control": {"type": "ephemeral"},
5254                "tools": [
5255                    {
5256                        "name": "first_cached_tool",
5257                        "description": "First cached tool",
5258                        "input_schema": {"type": "object"},
5259                        "cache_control": {"type": "ephemeral"}
5260                    },
5261                    {
5262                        "name": "second_cached_tool",
5263                        "description": "Second cached tool",
5264                        "input_schema": {"type": "object"},
5265                        "cache_control": {"type": "ephemeral"}
5266                    },
5267                    {
5268                        "name": "third_cached_tool",
5269                        "description": "Third cached tool",
5270                        "input_schema": {"type": "object"},
5271                        "cache_control": {"type": "ephemeral"}
5272                    },
5273                    {
5274                        "name": "fourth_cached_tool",
5275                        "description": "Fourth cached tool",
5276                        "input_schema": {"type": "object"},
5277                        "cache_control": {"type": "ephemeral"}
5278                    }
5279                ]
5280            })),
5281        );
5282
5283        let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5284            model: "claude-sonnet-4-6",
5285            request,
5286            prompt_caching: false,
5287            automatic_caching: false,
5288            automatic_caching_ttl: None,
5289            static_prefix_cache_ttl: None,
5290        })
5291        .unwrap_err();
5292
5293        assert!(err.to_string().contains("Too many Anthropic tool"));
5294    }
5295
5296    #[test]
5297    fn test_raw_top_level_automatic_caching_1h_errors_after_explicit_five_minute_tool_marker() {
5298        let request = completion_request_with_tools(
5299            Vec::new(),
5300            Some(json!({
5301                "cache_control": {"type": "ephemeral", "ttl": "1h"},
5302                "tools": [{
5303                    "name": "cached_tool",
5304                    "description": "Cached tool",
5305                    "input_schema": {"type": "object"},
5306                    "cache_control": {"type": "ephemeral"}
5307                }]
5308            })),
5309        );
5310
5311        let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5312            model: "claude-sonnet-4-6",
5313            request,
5314            prompt_caching: false,
5315            automatic_caching: false,
5316            automatic_caching_ttl: None,
5317            static_prefix_cache_ttl: None,
5318        })
5319        .unwrap_err();
5320
5321        assert!(err.to_string().contains("ttl `1h`"));
5322    }
5323
5324    #[test]
5325    fn test_typed_automatic_caching_ttl_errors_on_conflicting_raw_top_level_ttl() {
5326        let request = completion_request_with_tools(
5327            Vec::new(),
5328            Some(json!({
5329                "cache_control": {"type": "ephemeral"}
5330            })),
5331        );
5332
5333        let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5334            model: "claude-sonnet-4-6",
5335            request,
5336            prompt_caching: false,
5337            automatic_caching: true,
5338            automatic_caching_ttl: Some(CacheTtl::OneHour),
5339            static_prefix_cache_ttl: None,
5340        })
5341        .unwrap_err();
5342
5343        assert!(
5344            err.to_string()
5345                .contains("conflicts with the typed automatic caching TTL")
5346        );
5347    }
5348
5349    #[test]
5350    fn test_prompt_caching_marks_final_tool_in_request() {
5351        let request = completion_request_with_tools(
5352            vec![generic_tool("first_tool"), generic_tool("second_tool")],
5353            None,
5354        );
5355
5356        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5357            model: "claude-sonnet-4-6",
5358            request,
5359            prompt_caching: true,
5360            automatic_caching: false,
5361            automatic_caching_ttl: None,
5362            static_prefix_cache_ttl: None,
5363        })
5364        .unwrap();
5365
5366        let value = serde_json::to_value(request).unwrap();
5367        let tools = value["tools"].as_array().unwrap();
5368        assert_eq!(tools.len(), 2);
5369        assert!(tools[0].get("cache_control").is_none());
5370        assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
5371    }
5372
5373    #[test]
5374    fn test_prompt_caching_marks_final_additional_tool_in_request() {
5375        let request = completion_request_with_tools(
5376            vec![generic_tool("rig_tool")],
5377            Some(json!({
5378                "tools": [{
5379                    "name": "provider_tool",
5380                    "description": "Provider tool",
5381                    "input_schema": {"type": "object"}
5382                }],
5383                "metadata": {"source": "test"}
5384            })),
5385        );
5386
5387        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5388            model: "claude-sonnet-4-6",
5389            request,
5390            prompt_caching: true,
5391            automatic_caching: false,
5392            automatic_caching_ttl: None,
5393            static_prefix_cache_ttl: None,
5394        })
5395        .unwrap();
5396
5397        let value = serde_json::to_value(request).unwrap();
5398        let tools = value["tools"].as_array().unwrap();
5399        assert_eq!(tools.len(), 2);
5400        assert!(tools[0].get("cache_control").is_none());
5401        assert_eq!(tools[1]["name"], "provider_tool");
5402        assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
5403        assert_eq!(value["metadata"]["source"], "test");
5404    }
5405
5406    #[test]
5407    fn test_prompt_caching_without_tools_omits_tools() {
5408        let request = completion_request_with_tools(Vec::new(), None);
5409
5410        let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5411            model: "claude-sonnet-4-6",
5412            request,
5413            prompt_caching: true,
5414            automatic_caching: false,
5415            automatic_caching_ttl: None,
5416            static_prefix_cache_ttl: None,
5417        })
5418        .unwrap();
5419
5420        let value = serde_json::to_value(request).unwrap();
5421        assert!(value.get("tools").is_none());
5422    }
5423
5424    #[test]
5425    fn test_plaintext_document_serialization() {
5426        let content = Content::Document {
5427            source: DocumentSource::Text {
5428                data: "Hello, world!".to_string(),
5429                media_type: PlainTextMediaType::Plain,
5430            },
5431            title: None,
5432            context: None,
5433            citations: None,
5434            cache_control: None,
5435        };
5436
5437        let json = serde_json::to_value(&content).unwrap();
5438        assert_eq!(json["type"], "document");
5439        assert_eq!(json["source"]["type"], "text");
5440        assert_eq!(json["source"]["media_type"], "text/plain");
5441        assert_eq!(json["source"]["data"], "Hello, world!");
5442    }
5443
5444    #[test]
5445    fn test_plaintext_document_deserialization() {
5446        let json = r#"
5447        {
5448            "type": "document",
5449            "source": {
5450                "type": "text",
5451                "media_type": "text/plain",
5452                "data": "Hello, world!"
5453            }
5454        }
5455        "#;
5456
5457        let content: Content = serde_json::from_str(json).unwrap();
5458        match content {
5459            Content::Document {
5460                source,
5461                cache_control,
5462                ..
5463            } => {
5464                assert_eq!(
5465                    source,
5466                    DocumentSource::Text {
5467                        data: "Hello, world!".to_string(),
5468                        media_type: PlainTextMediaType::Plain,
5469                    }
5470                );
5471                assert_eq!(cache_control, None);
5472            }
5473            _ => panic!("Expected Document content"),
5474        }
5475    }
5476
5477    #[test]
5478    fn test_base64_pdf_document_serialization() {
5479        let content = Content::Document {
5480            source: DocumentSource::Base64 {
5481                data: "base64data".to_string(),
5482                media_type: DocumentFormat::PDF,
5483            },
5484            title: None,
5485            context: None,
5486            citations: None,
5487            cache_control: None,
5488        };
5489
5490        let json = serde_json::to_value(&content).unwrap();
5491        assert_eq!(json["type"], "document");
5492        assert_eq!(json["source"]["type"], "base64");
5493        assert_eq!(json["source"]["media_type"], "application/pdf");
5494        assert_eq!(json["source"]["data"], "base64data");
5495    }
5496
5497    #[test]
5498    fn test_base64_pdf_document_deserialization() {
5499        let json = r#"
5500        {
5501            "type": "document",
5502            "source": {
5503                "type": "base64",
5504                "media_type": "application/pdf",
5505                "data": "base64data"
5506            }
5507        }
5508        "#;
5509
5510        let content: Content = serde_json::from_str(json).unwrap();
5511        match content {
5512            Content::Document { source, .. } => {
5513                assert_eq!(
5514                    source,
5515                    DocumentSource::Base64 {
5516                        data: "base64data".to_string(),
5517                        media_type: DocumentFormat::PDF,
5518                    }
5519                );
5520            }
5521            _ => panic!("Expected Document content"),
5522        }
5523    }
5524
5525    #[test]
5526    fn test_file_id_document_serialization() {
5527        let content = Content::Document {
5528            source: DocumentSource::File {
5529                file_id: "file_abc".to_string(),
5530            },
5531            title: None,
5532            context: None,
5533            citations: None,
5534            cache_control: None,
5535        };
5536
5537        let json = serde_json::to_value(&content).unwrap();
5538        assert_eq!(json["type"], "document");
5539        assert_eq!(json["source"]["type"], "file");
5540        assert_eq!(json["source"]["file_id"], "file_abc");
5541    }
5542
5543    #[test]
5544    fn test_file_id_document_deserialization() {
5545        let json = r#"
5546        {
5547            "type": "document",
5548            "source": {
5549                "type": "file",
5550                "file_id": "file_abc"
5551            }
5552        }
5553        "#;
5554
5555        let content: Content = serde_json::from_str(json).unwrap();
5556        match content {
5557            Content::Document { source, .. } => {
5558                assert_eq!(
5559                    source,
5560                    DocumentSource::File {
5561                        file_id: "file_abc".to_string(),
5562                    }
5563                );
5564            }
5565            _ => panic!("Expected Document content"),
5566        }
5567    }
5568
5569    #[test]
5570    fn test_file_id_rig_to_anthropic_conversion() {
5571        use crate::completion::message as msg;
5572
5573        let rig_message = msg::Message::User {
5574            content: vec![msg::UserContent::Document(msg::Document {
5575                data: DocumentSourceKind::FileId("file_abc".to_string()),
5576                media_type: None,
5577                additional_params: None,
5578            })],
5579        };
5580
5581        let anthropic_message: Message = rig_message.try_into().unwrap();
5582        assert_eq!(anthropic_message.role, Role::User);
5583
5584        let mut iter = anthropic_message.content.into_iter();
5585        match iter.next().unwrap() {
5586            Content::Document { source, .. } => {
5587                assert_eq!(
5588                    source,
5589                    DocumentSource::File {
5590                        file_id: "file_abc".to_string(),
5591                    }
5592                );
5593            }
5594            other => panic!("Expected Document content, got: {other:?}"),
5595        }
5596    }
5597
5598    #[test]
5599    fn test_file_id_anthropic_to_rig_conversion() {
5600        use crate::completion::message as msg;
5601
5602        let anthropic_message = Message {
5603            role: Role::User,
5604            content: vec![Content::Document {
5605                source: DocumentSource::File {
5606                    file_id: "file_abc".to_string(),
5607                },
5608                title: None,
5609                context: None,
5610                citations: None,
5611                cache_control: None,
5612            }],
5613        };
5614
5615        let rig_message: msg::Message = anthropic_message.try_into().unwrap();
5616        match rig_message {
5617            msg::Message::User { content } => {
5618                let mut iter = content.into_iter();
5619                match iter.next().unwrap() {
5620                    msg::UserContent::Document(msg::Document {
5621                        data, media_type, ..
5622                    }) => {
5623                        assert_eq!(data, DocumentSourceKind::FileId("file_abc".to_string()));
5624                        assert_eq!(media_type, None);
5625                    }
5626                    other => panic!("Expected Document content, got: {other:?}"),
5627                }
5628            }
5629            _ => panic!("Expected User message"),
5630        }
5631    }
5632
5633    #[test]
5634    fn test_plaintext_rig_to_anthropic_conversion() {
5635        use crate::completion::message as msg;
5636
5637        let rig_message = msg::Message::User {
5638            content: vec![msg::UserContent::document(
5639                "Some plain text content".to_string(),
5640                Some(msg::DocumentMediaType::TXT),
5641            )],
5642        };
5643
5644        let anthropic_message: Message = rig_message.try_into().unwrap();
5645        assert_eq!(anthropic_message.role, Role::User);
5646
5647        let mut iter = anthropic_message.content.into_iter();
5648        match iter.next().unwrap() {
5649            Content::Document { source, .. } => {
5650                assert_eq!(
5651                    source,
5652                    DocumentSource::Text {
5653                        data: "Some plain text content".to_string(),
5654                        media_type: PlainTextMediaType::Plain,
5655                    }
5656                );
5657            }
5658            other => panic!("Expected Document content, got: {other:?}"),
5659        }
5660    }
5661
5662    #[test]
5663    fn test_plaintext_anthropic_to_rig_conversion() {
5664        use crate::completion::message as msg;
5665
5666        let anthropic_message = Message {
5667            role: Role::User,
5668            content: vec![Content::Document {
5669                source: DocumentSource::Text {
5670                    data: "Some plain text content".to_string(),
5671                    media_type: PlainTextMediaType::Plain,
5672                },
5673                title: None,
5674                context: None,
5675                citations: None,
5676                cache_control: None,
5677            }],
5678        };
5679
5680        let rig_message: msg::Message = anthropic_message.try_into().unwrap();
5681        match rig_message {
5682            msg::Message::User { content } => {
5683                let mut iter = content.into_iter();
5684                match iter.next().unwrap() {
5685                    msg::UserContent::Document(msg::Document {
5686                        data, media_type, ..
5687                    }) => {
5688                        assert_eq!(
5689                            data,
5690                            DocumentSourceKind::String("Some plain text content".into())
5691                        );
5692                        assert_eq!(media_type, Some(msg::DocumentMediaType::TXT));
5693                    }
5694                    other => panic!("Expected Document content, got: {other:?}"),
5695                }
5696            }
5697            _ => panic!("Expected User message"),
5698        }
5699    }
5700
5701    #[test]
5702    fn test_plaintext_roundtrip_rig_to_anthropic_and_back() {
5703        use crate::completion::message as msg;
5704
5705        let original = msg::Message::User {
5706            content: vec![msg::UserContent::document(
5707                "Round trip text".to_string(),
5708                Some(msg::DocumentMediaType::TXT),
5709            )],
5710        };
5711
5712        let anthropic: Message = original.clone().try_into().unwrap();
5713        let back: msg::Message = anthropic.try_into().unwrap();
5714
5715        match (&original, &back) {
5716            (
5717                msg::Message::User {
5718                    content: orig_content,
5719                },
5720                msg::Message::User {
5721                    content: back_content,
5722                },
5723            ) => match (orig_content.first(), back_content.first()) {
5724                (
5725                    Some(msg::UserContent::Document(msg::Document {
5726                        media_type: orig_mt,
5727                        ..
5728                    })),
5729                    Some(msg::UserContent::Document(msg::Document {
5730                        media_type: back_mt,
5731                        ..
5732                    })),
5733                ) => {
5734                    assert_eq!(orig_mt, back_mt);
5735                }
5736                _ => panic!("Expected Document content in both"),
5737            },
5738            _ => panic!("Expected User messages"),
5739        }
5740    }
5741
5742    #[test]
5743    fn test_unsupported_document_type_returns_error() {
5744        use crate::completion::message as msg;
5745
5746        let rig_message = msg::Message::User {
5747            content: vec![msg::UserContent::Document(msg::Document {
5748                data: DocumentSourceKind::String("data".into()),
5749                media_type: Some(msg::DocumentMediaType::HTML),
5750                additional_params: None,
5751            })],
5752        };
5753
5754        let result: Result<Message, _> = rig_message.try_into();
5755        assert!(result.is_err());
5756        let err = result.unwrap_err().to_string();
5757        assert!(
5758            err.contains("Anthropic only supports PDF and plain text documents"),
5759            "Unexpected error: {err}"
5760        );
5761    }
5762
5763    #[test]
5764    fn test_plaintext_document_url_source_returns_error() {
5765        use crate::completion::message as msg;
5766
5767        let rig_message = msg::Message::User {
5768            content: vec![msg::UserContent::Document(msg::Document {
5769                data: DocumentSourceKind::Url("https://example.com/doc.txt".into()),
5770                media_type: Some(msg::DocumentMediaType::TXT),
5771                additional_params: None,
5772            })],
5773        };
5774
5775        let result: Result<Message, _> = rig_message.try_into();
5776        assert!(result.is_err());
5777        let err = result.unwrap_err().to_string();
5778        assert!(
5779            err.contains("Only string or base64 data is supported for plain text documents"),
5780            "Unexpected error: {err}"
5781        );
5782    }
5783
5784    #[test]
5785    fn test_plaintext_document_with_cache_control() {
5786        let content = Content::Document {
5787            source: DocumentSource::Text {
5788                data: "cached text".to_string(),
5789                media_type: PlainTextMediaType::Plain,
5790            },
5791            title: None,
5792            context: None,
5793            citations: None,
5794            cache_control: Some(CacheControl::ephemeral()),
5795        };
5796
5797        let json = serde_json::to_value(&content).unwrap();
5798        assert_eq!(json["source"]["type"], "text");
5799        assert_eq!(json["source"]["media_type"], "text/plain");
5800        assert_eq!(json["cache_control"]["type"], "ephemeral");
5801    }
5802
5803    #[test]
5804    fn test_message_with_plaintext_document_deserialization() {
5805        let json = r#"
5806        {
5807            "role": "user",
5808            "content": [
5809                {
5810                    "type": "document",
5811                    "source": {
5812                        "type": "text",
5813                        "media_type": "text/plain",
5814                        "data": "Hello from a text file"
5815                    }
5816                },
5817                {
5818                    "type": "text",
5819                    "text": "Summarize this document."
5820                }
5821            ]
5822        }
5823        "#;
5824
5825        let message: Message = serde_json::from_str(json).unwrap();
5826        assert_eq!(message.role, Role::User);
5827        assert_eq!(message.content.len(), 2);
5828
5829        let mut iter = message.content.into_iter();
5830
5831        match iter.next().unwrap() {
5832            Content::Document { source, .. } => {
5833                assert_eq!(
5834                    source,
5835                    DocumentSource::Text {
5836                        data: "Hello from a text file".to_string(),
5837                        media_type: PlainTextMediaType::Plain,
5838                    }
5839                );
5840            }
5841            _ => panic!("Expected Document content"),
5842        }
5843
5844        match iter.next().unwrap() {
5845            Content::Text { text, .. } => {
5846                assert_eq!(text, "Summarize this document.");
5847            }
5848            _ => panic!("Expected Text content"),
5849        }
5850    }
5851
5852    #[test]
5853    fn test_assistant_reasoning_multiblock_to_anthropic_content() {
5854        let reasoning = message::Reasoning {
5855            id: None,
5856            content: vec![
5857                message::ReasoningContent::Text {
5858                    text: "step one".to_string(),
5859                    signature: Some("sig-1".to_string()),
5860                },
5861                message::ReasoningContent::Summary("summary".to_string()),
5862                message::ReasoningContent::Text {
5863                    text: "step two".to_string(),
5864                    signature: Some("sig-2".to_string()),
5865                },
5866                message::ReasoningContent::Redacted {
5867                    data: "redacted block".to_string(),
5868                },
5869            ],
5870        };
5871
5872        let msg = message::Message::Assistant {
5873            id: None,
5874            content: vec![message::AssistantContent::Reasoning(reasoning)],
5875        };
5876        let converted: Message = msg.try_into().expect("convert assistant message");
5877        let converted_content = converted.content.clone();
5878
5879        assert_eq!(converted.role, Role::Assistant);
5880        assert_eq!(converted_content.len(), 4);
5881        assert!(matches!(
5882            converted_content.first(),
5883            Some(Content::Thinking { thinking, signature: Some(signature) })
5884                if thinking == "step one" && signature == "sig-1"
5885        ));
5886        assert!(matches!(
5887            converted_content.get(1),
5888            Some(Content::Thinking { thinking, signature: None }) if thinking == "summary"
5889        ));
5890        assert!(matches!(
5891            converted_content.get(2),
5892            Some(Content::Thinking { thinking, signature: Some(signature) })
5893                if thinking == "step two" && signature == "sig-2"
5894        ));
5895        assert!(matches!(
5896            converted_content.get(3),
5897            Some(Content::RedactedThinking { data }) if data == "redacted block"
5898        ));
5899    }
5900
5901    #[test]
5902    fn test_redacted_thinking_content_to_assistant_reasoning() {
5903        let content = Content::RedactedThinking {
5904            data: "opaque-redacted".to_string(),
5905        };
5906        let converted: message::AssistantContent =
5907            content.try_into().expect("convert redacted thinking");
5908
5909        assert!(matches!(
5910            converted,
5911            message::AssistantContent::Reasoning(message::Reasoning { content, .. })
5912                if matches!(
5913                    content.first(),
5914                    Some(message::ReasoningContent::Redacted { data }) if data == "opaque-redacted"
5915                )
5916        ));
5917    }
5918
5919    #[test]
5920    fn test_assistant_encrypted_reasoning_maps_to_redacted_thinking() {
5921        let reasoning = message::Reasoning {
5922            id: None,
5923            content: vec![message::ReasoningContent::Encrypted(
5924                "ciphertext".to_string(),
5925            )],
5926        };
5927        let msg = message::Message::Assistant {
5928            id: None,
5929            content: vec![message::AssistantContent::Reasoning(reasoning)],
5930        };
5931
5932        let converted: Message = msg.try_into().expect("convert assistant message");
5933        let converted_content = converted.content.clone();
5934
5935        assert_eq!(converted_content.len(), 1);
5936        assert!(matches!(
5937            converted_content.first(),
5938            Some(Content::RedactedThinking { data }) if data == "ciphertext"
5939        ));
5940    }
5941
5942    #[test]
5943    fn empty_end_turn_response_normalizes_to_an_empty_choice() {
5944        let response = CompletionResponse {
5945            content: vec![],
5946            id: "msg_123".to_string(),
5947            model: CLAUDE_SONNET_4_6.to_string(),
5948            role: "assistant".to_string(),
5949            stop_reason: Some("end_turn".to_string()),
5950            stop_sequence: None,
5951            provider_request_id: None,
5952            usage: Usage {
5953                input_tokens: 7,
5954                cache_read_input_tokens: None,
5955                cache_creation_input_tokens: None,
5956                cache_creation: None,
5957                output_tokens: 2,
5958                output_tokens_details: None,
5959            },
5960        };
5961
5962        let parsed: completion::CompletionResponse = response
5963            .normalize("anthropic")
5964            .expect("empty end_turn should not error");
5965
5966        // Anthropic's documented empty `end_turn` is a turn that carried
5967        // nothing. It used to normalize to one fabricated empty-text part
5968        // because the content type could not be empty; the empty list is the
5969        // same turn, said honestly. Everything else about the response is
5970        // unchanged, which is the point of asserting it here.
5971        assert!(parsed.choice.is_empty());
5972        assert_eq!(parsed.provider, "anthropic");
5973        assert_eq!(parsed.message_id.as_deref(), Some("msg_123"));
5974        assert_eq!(parsed.model.as_deref(), Some(CLAUDE_SONNET_4_6));
5975        assert_eq!(parsed.finish_reason(), Some(completion::FinishReason::Stop));
5976    }
5977
5978    /// Build an empty-content response with the given terminal, for exercising
5979    /// the two legal empty cases against everything else.
5980    fn empty_response_with(
5981        stop_reason: Option<&str>,
5982        stop_sequence: Option<&str>,
5983    ) -> CompletionResponse {
5984        CompletionResponse {
5985            content: vec![],
5986            id: "msg_123".to_string(),
5987            model: CLAUDE_SONNET_4_6.to_string(),
5988            role: "assistant".to_string(),
5989            stop_reason: stop_reason.map(str::to_string),
5990            stop_sequence: stop_sequence.map(str::to_string),
5991            provider_request_id: None,
5992            usage: Usage {
5993                input_tokens: 7,
5994                cache_read_input_tokens: None,
5995                cache_creation_input_tokens: None,
5996                cache_creation: None,
5997                output_tokens: 2,
5998                output_tokens_details: None,
5999            },
6000        }
6001    }
6002
6003    #[test]
6004    fn empty_response_outside_the_legal_terminals_still_errors() {
6005        for (stop_reason, stop_sequence) in [
6006            (Some("tool_use"), None),
6007            (Some("max_tokens"), None),
6008            (Some("refusal"), None),
6009            (Some("pause_turn"), None),
6010            (None, None),
6011            // Claims to have stopped on a sequence but names none: the
6012            // malformed shape the guard exists for, not a legal empty turn.
6013            (Some("stop_sequence"), None),
6014            // The inverse: naming a sequence does not make an illegal terminal
6015            // legal. The carve-out gates on the reason first, then the field.
6016            (Some("max_tokens"), Some("alpha")),
6017        ] {
6018            let err = empty_response_with(stop_reason, stop_sequence)
6019                .normalize("anthropic")
6020                .expect_err(&format!(
6021                    "empty {stop_reason:?} response should remain an error"
6022                ));
6023
6024            assert!(matches!(
6025                err,
6026                CompletionError::ResponseError(message) if message == EMPTY_RESPONSE_ERROR
6027            ));
6028        }
6029    }
6030
6031    #[test]
6032    fn empty_stop_sequence_response_naming_its_sequence_is_a_completed_turn() {
6033        let parsed = empty_response_with(Some("stop_sequence"), Some("alpha"))
6034            .normalize("anthropic")
6035            .expect("a completed stop-sequence turn must not normalize into an error");
6036
6037        assert!(parsed.choice.is_empty());
6038        assert_eq!(parsed.finish_reason(), Some(completion::FinishReason::Stop));
6039    }
6040
6041    #[test]
6042    fn stop_reason_maps_onto_the_normalized_vocabulary() {
6043        assert_eq!(
6044            map_finish_reason("end_turn"),
6045            completion::FinishReason::Stop
6046        );
6047        assert_eq!(
6048            map_finish_reason("stop_sequence"),
6049            completion::FinishReason::Stop
6050        );
6051        assert_eq!(
6052            map_finish_reason("max_tokens"),
6053            completion::FinishReason::Length
6054        );
6055        assert_eq!(
6056            map_finish_reason("tool_use"),
6057            completion::FinishReason::ToolCalls
6058        );
6059        assert_eq!(
6060            map_finish_reason("refusal"),
6061            completion::FinishReason::ContentFilter
6062        );
6063    }
6064
6065    #[test]
6066    fn unknown_stop_reason_is_preserved_verbatim() {
6067        // Anthropic's own spelling survives, so a reason this crate does not yet
6068        // model never reads as a natural stop.
6069        assert_eq!(
6070            map_finish_reason("pause_turn"),
6071            completion::FinishReason::Other("pause_turn".to_owned())
6072        );
6073        assert_eq!(
6074            map_finish_reason("model_context_window_exceeded"),
6075            completion::FinishReason::Other("model_context_window_exceeded".to_owned())
6076        );
6077    }
6078
6079    #[test]
6080    fn end_turn_with_a_tool_call_is_reconciled_to_tool_calls() {
6081        // Anthropic reports `tool_use`, but the reconciliation the response
6082        // builder applies must hold for any provider that reports a plain stop
6083        // alongside a tool call.
6084        let response = CompletionResponse {
6085            content: vec![Content::ToolUse {
6086                id: "toolu_1".to_string(),
6087                name: "add".to_string(),
6088                input: json!({"x": 1}),
6089            }],
6090            id: "msg_123".to_string(),
6091            model: CLAUDE_SONNET_4_6.to_string(),
6092            role: "assistant".to_string(),
6093            stop_reason: Some("end_turn".to_string()),
6094            stop_sequence: None,
6095            provider_request_id: None,
6096            usage: Usage {
6097                input_tokens: 7,
6098                cache_read_input_tokens: None,
6099                cache_creation_input_tokens: None,
6100                cache_creation: None,
6101                output_tokens: 2,
6102                output_tokens_details: None,
6103            },
6104        };
6105
6106        let parsed = response
6107            .normalize("anthropic")
6108            .expect("tool-use response should normalize");
6109
6110        assert_eq!(
6111            parsed.finish_reason(),
6112            Some(completion::FinishReason::ToolCalls)
6113        );
6114    }
6115
6116    #[test]
6117    fn test_tool_result_content_in_message_roundtrip() {
6118        let message_json = r#"{
6119            "role": "user",
6120            "content": [
6121                {
6122                    "type": "tool_result",
6123                    "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
6124                    "content": [
6125                        {
6126                            "type": "text",
6127                            "text": "Here is the screenshot:"
6128                        },
6129                        {
6130                            "type": "image",
6131                            "source": {
6132                                "type": "base64",
6133                                "media_type": "image/png",
6134                                "data": "iVBORw0KGgo..."
6135                            }
6136                        }
6137                    ]
6138                }
6139            ]
6140        }"#;
6141
6142        let message: Message = serde_json::from_str(message_json).unwrap();
6143        let serialized = serde_json::to_value(&message).unwrap();
6144
6145        let tool_result = &serialized["content"][0];
6146        assert_eq!(tool_result["type"], "tool_result");
6147
6148        let image_content = &tool_result["content"][1];
6149        assert_eq!(image_content["type"], "image");
6150        assert_eq!(image_content["source"]["type"], "base64");
6151        assert_eq!(image_content["source"]["media_type"], "image/png");
6152        assert_eq!(image_content["source"]["data"], "iVBORw0KGgo...");
6153    }
6154
6155    // -------------------------------------------------------------------
6156    // Citations (#1767)
6157    // -------------------------------------------------------------------
6158
6159    #[test]
6160    fn document_serializes_citations_and_metadata() {
6161        let doc = Content::Document {
6162            source: DocumentSource::Text {
6163                data: "hello".into(),
6164                media_type: PlainTextMediaType::Plain,
6165            },
6166            title: Some("My Doc".into()),
6167            context: None,
6168            citations: Some(CitationsConfig { enabled: true }),
6169            cache_control: None,
6170        };
6171        let value = serde_json::to_value(&doc).unwrap();
6172        assert_eq!(value["citations"]["enabled"], true);
6173        assert_eq!(value["title"], "My Doc");
6174        assert!(
6175            value.get("context").is_none(),
6176            "context should be skipped when None"
6177        );
6178    }
6179
6180    #[test]
6181    fn text_serializes_without_citations_when_empty() {
6182        let content = Content::Text {
6183            text: "hello".into(),
6184            citations: Vec::new(),
6185            cache_control: None,
6186        };
6187        let value = serde_json::to_value(&content).unwrap();
6188        assert!(
6189            value.get("citations").is_none(),
6190            "empty citations vec must be skipped"
6191        );
6192    }
6193
6194    #[test]
6195    fn text_deserializes_char_location_citation() {
6196        let value = json!({
6197            "type": "text",
6198            "text": "the grass is green",
6199            "citations": [{
6200                "type": "char_location",
6201                "cited_text": "The grass is green.",
6202                "document_index": 0,
6203                "document_title": "Example",
6204                "start_char_index": 0,
6205                "end_char_index": 20
6206            }]
6207        });
6208        let parsed: Content = serde_json::from_value(value).unwrap();
6209        let Content::Text { citations, .. } = parsed else {
6210            panic!("expected Content::Text");
6211        };
6212        assert_eq!(citations.len(), 1);
6213        let Citation::CharLocation(citation) = &citations[0] else {
6214            panic!("expected CharLocation");
6215        };
6216        assert_eq!(citation.start_char_index, 0);
6217        assert_eq!(citation.end_char_index, 20);
6218    }
6219
6220    #[test]
6221    fn text_deserializes_search_result_location_citation() {
6222        let value = json!({
6223            "type": "text",
6224            "text": "API keys are required.",
6225            "citations": [{
6226                "type": "search_result_location",
6227                "cited_text": "All API requests must include an API key.",
6228                "source": "https://docs.example.com/api-reference",
6229                "title": "API Reference",
6230                "search_result_index": 0,
6231                "start_block_index": 0,
6232                "end_block_index": 1
6233            }]
6234        });
6235
6236        let parsed: Content = serde_json::from_value(value).unwrap();
6237        let Content::Text { citations, .. } = parsed else {
6238            panic!("expected Content::Text");
6239        };
6240
6241        assert!(matches!(
6242            &citations[0],
6243            Citation::SearchResultLocation(SearchResultLocationCitation {
6244                source,
6245                title: Some(title),
6246                search_result_index: 0,
6247                start_block_index: 0,
6248                end_block_index: 1,
6249                ..
6250            }) if source == "https://docs.example.com/api-reference" && title == "API Reference"
6251        ));
6252    }
6253
6254    #[test]
6255    fn text_deserializes_web_search_result_location_citation() {
6256        let value = json!({
6257            "type": "text",
6258            "text": "Claude Shannon worked at Bell Labs.",
6259            "citations": [{
6260                "type": "web_search_result_location",
6261                "cited_text": "Claude Shannon was a mathematician.",
6262                "url": "https://example.com/shannon",
6263                "title": "Claude Shannon",
6264                "encrypted_index": "encrypted-reference"
6265            }]
6266        });
6267
6268        let parsed: Content = serde_json::from_value(value).unwrap();
6269        let Content::Text { citations, .. } = parsed else {
6270            panic!("expected Content::Text");
6271        };
6272
6273        assert!(matches!(
6274            &citations[0],
6275            Citation::WebSearchResultLocation(WebSearchResultLocationCitation {
6276                url,
6277                title,
6278                encrypted_index,
6279                ..
6280            }) if url == "https://example.com/shannon"
6281                && title.as_deref() == Some("Claude Shannon")
6282                && encrypted_index == "encrypted-reference"
6283        ));
6284    }
6285
6286    #[test]
6287    fn text_deserializes_web_search_result_location_citation_with_null_title() {
6288        let value = json!({
6289            "type": "text",
6290            "text": "Claude Shannon worked at Bell Labs.",
6291            "citations": [{
6292                "type": "web_search_result_location",
6293                "cited_text": "Claude Shannon was a mathematician.",
6294                "url": "https://example.com/shannon",
6295                "title": null,
6296                "encrypted_index": "encrypted-reference"
6297            }]
6298        });
6299
6300        let parsed: Content = serde_json::from_value(value).unwrap();
6301        let Content::Text { citations, .. } = parsed else {
6302            panic!("expected Content::Text");
6303        };
6304
6305        let Citation::WebSearchResultLocation(citation) = &citations[0] else {
6306            panic!("expected WebSearchResultLocation");
6307        };
6308        assert_eq!(citation.title, None);
6309
6310        let serialized = serde_json::to_value(&citations[0]).unwrap();
6311        assert!(serialized.get("title").is_some());
6312        assert!(serialized["title"].is_null());
6313    }
6314
6315    #[test]
6316    fn web_search_response_preserves_raw_blocks_and_citations() {
6317        let value = json!({
6318            "id": "msg_web_search",
6319            "model": CLAUDE_SONNET_4_6,
6320            "role": "assistant",
6321            "stop_reason": "end_turn",
6322            "stop_sequence": null,
6323            "usage": {
6324                "input_tokens": 10,
6325                "output_tokens": 20
6326            },
6327            "content": [
6328                {
6329                    "type": "server_tool_use",
6330                    "id": "srvtoolu_01",
6331                    "name": "web_search",
6332                    "input": {
6333                        "query": "claude shannon birth date"
6334                    }
6335                },
6336                {
6337                    "type": "web_search_tool_result",
6338                    "tool_use_id": "srvtoolu_01",
6339                    "content": [
6340                        {
6341                            "type": "web_search_result",
6342                            "url": "https://example.com/shannon",
6343                            "title": "Claude Shannon",
6344                            "encrypted_content": "encrypted-content",
6345                            "page_age": "April 30, 2025"
6346                        }
6347                    ]
6348                },
6349                {
6350                    "type": "text",
6351                    "text": "Claude Shannon was born on April 30, 1916.",
6352                    "citations": [{
6353                        "type": "web_search_result_location",
6354                        "cited_text": "Claude Shannon was born on April 30, 1916.",
6355                        "url": "https://example.com/shannon",
6356                        "title": "Claude Shannon",
6357                        "encrypted_index": "encrypted-index"
6358                    }]
6359                }
6360            ]
6361        });
6362
6363        let response: CompletionResponse = serde_json::from_value(value).unwrap();
6364        // The wire response is consumed by the conversion, so read the
6365        // provider-native text off it first.
6366        let raw_text_response = response.get_text_response();
6367        let converted = response.normalize("anthropic").unwrap();
6368        assert_eq!(converted.choice.len(), 3);
6369        assert_eq!(
6370            raw_text_response.as_deref(),
6371            Some("Claude Shannon was born on April 30, 1916.")
6372        );
6373
6374        let items = converted.choice.iter().collect::<Vec<_>>();
6375        let message::AssistantContent::Text(server_tool_use) = items[0] else {
6376            panic!("expected raw server_tool_use metadata");
6377        };
6378        assert_eq!(server_tool_use.text, "");
6379        assert_eq!(
6380            server_tool_use.additional_params.as_ref().unwrap()[ANTHROPIC_RAW_CONTENT_KEY]["type"],
6381            "server_tool_use"
6382        );
6383
6384        let message::AssistantContent::Text(web_search_result) = items[1] else {
6385            panic!("expected raw web_search_tool_result metadata");
6386        };
6387        assert_eq!(
6388            web_search_result.additional_params.as_ref().unwrap()[ANTHROPIC_RAW_CONTENT_KEY]["content"]
6389                [0]["encrypted_content"],
6390            "encrypted-content"
6391        );
6392
6393        let message::AssistantContent::Text(answer) = items[2] else {
6394            panic!("expected text answer");
6395        };
6396        let citations = anthropic_citations(answer).unwrap();
6397        assert!(matches!(
6398            citations.first(),
6399            Some(Citation::WebSearchResultLocation(citation))
6400                if citation.encrypted_index == "encrypted-index"
6401        ));
6402
6403        let round_trip: Message = message::Message::Assistant {
6404            id: converted.message_id.clone(),
6405            content: converted.choice,
6406        }
6407        .try_into()
6408        .unwrap();
6409
6410        let round_trip_items = round_trip.content.iter().collect::<Vec<_>>();
6411        assert!(matches!(
6412            round_trip_items.first(),
6413            Some(Content::ServerToolUse { id, name, input })
6414                if id == "srvtoolu_01"
6415                    && name == "web_search"
6416                    && input["query"] == "claude shannon birth date"
6417        ));
6418        assert!(matches!(
6419            round_trip_items.get(1),
6420            Some(Content::WebSearchToolResult {
6421                tool_use_id,
6422                content
6423            }) if tool_use_id == "srvtoolu_01"
6424                && content[0]["encrypted_content"] == "encrypted-content"
6425        ));
6426    }
6427
6428    #[test]
6429    fn web_search_tool_result_error_object_is_preserved_raw() {
6430        let value = json!({
6431            "id": "msg_web_search_error",
6432            "model": CLAUDE_SONNET_4_6,
6433            "role": "assistant",
6434            "stop_reason": "end_turn",
6435            "stop_sequence": null,
6436            "usage": {
6437                "input_tokens": 10,
6438                "output_tokens": 2
6439            },
6440            "content": [{
6441                "type": "web_search_tool_result",
6442                "tool_use_id": "srvtoolu_01",
6443                "content": {
6444                    "type": "web_search_tool_result_error",
6445                    "error_code": "max_uses_exceeded"
6446                }
6447            }]
6448        });
6449
6450        let response: CompletionResponse = serde_json::from_value(value).unwrap();
6451        let converted = response.normalize("anthropic").unwrap();
6452        let Some(message::AssistantContent::Text(web_search_result)) = converted.choice.first()
6453        else {
6454            panic!("expected raw web_search_tool_result metadata");
6455        };
6456
6457        let raw_content =
6458            &web_search_result.additional_params.as_ref().unwrap()[ANTHROPIC_RAW_CONTENT_KEY];
6459        assert_eq!(raw_content["type"], "web_search_tool_result");
6460        assert_eq!(raw_content["content"]["error_code"], "max_uses_exceeded");
6461        assert_eq!(
6462            raw_content["content"]["type"],
6463            "web_search_tool_result_error"
6464        );
6465
6466        let round_trip: Message = message::Message::Assistant {
6467            id: converted.message_id,
6468            content: converted.choice,
6469        }
6470        .try_into()
6471        .unwrap();
6472
6473        assert!(matches!(
6474            round_trip.content.first(),
6475            Some(Content::WebSearchToolResult {
6476                tool_use_id,
6477                content
6478            }) if tool_use_id == "srvtoolu_01"
6479                && content["error_code"] == "max_uses_exceeded"
6480        ));
6481    }
6482
6483    #[test]
6484    fn code_execution_tool_result_variants_deserialize() {
6485        let normal: Content = serde_json::from_value(json!({
6486            "type": "code_execution_tool_result",
6487            "tool_use_id": "srvtoolu_normal",
6488            "content": {
6489                "type": "code_execution_result",
6490                "return_code": 0,
6491                "stdout": "42\n",
6492                "stderr": "",
6493                "content": []
6494            }
6495        }))
6496        .unwrap();
6497        assert!(matches!(
6498            normal,
6499            Content::CodeExecutionToolResult {
6500                ref tool_use_id,
6501                ref content
6502            } if tool_use_id == "srvtoolu_normal"
6503                && content["type"] == "code_execution_result"
6504                && content["stdout"] == "42\n"
6505        ));
6506
6507        let encrypted: Content = serde_json::from_value(json!({
6508            "type": "code_execution_tool_result",
6509            "tool_use_id": "srvtoolu_encrypted",
6510            "content": {
6511                "type": "encrypted_code_execution_result",
6512                "return_code": 1,
6513                "stderr": "failure",
6514                "encrypted_stdout": "encrypted-output",
6515                "content": []
6516            }
6517        }))
6518        .unwrap();
6519        assert!(matches!(
6520            encrypted,
6521            Content::CodeExecutionToolResult {
6522                ref tool_use_id,
6523                ref content
6524            } if tool_use_id == "srvtoolu_encrypted"
6525                && content["type"] == "encrypted_code_execution_result"
6526                && content["encrypted_stdout"] == "encrypted-output"
6527        ));
6528    }
6529
6530    #[test]
6531    fn code_execution_tool_result_is_preserved_and_round_trips() {
6532        let raw_block = json!({
6533            "type": "code_execution_tool_result",
6534            "tool_use_id": "srvtoolu_01",
6535            "content": {
6536                "type": "code_execution_result",
6537                "return_code": 0,
6538                "stdout": "42\n",
6539                "stderr": "",
6540                "content": []
6541            }
6542        });
6543        let value = json!({
6544            "id": "msg_code_execution",
6545            "model": CLAUDE_OPUS_4_8,
6546            "role": "assistant",
6547            "stop_reason": "end_turn",
6548            "stop_sequence": null,
6549            "usage": {
6550                "input_tokens": 10,
6551                "output_tokens": 20
6552            },
6553            "content": [raw_block.clone()]
6554        });
6555
6556        let response: CompletionResponse = serde_json::from_value(value).unwrap();
6557        let converted = response.normalize("anthropic").unwrap();
6558        let Some(message::AssistantContent::Text(code_execution_result)) = converted.choice.first()
6559        else {
6560            panic!("expected raw code_execution_tool_result metadata");
6561        };
6562        assert_eq!(
6563            code_execution_result.additional_params.as_ref().unwrap()[ANTHROPIC_RAW_CONTENT_KEY],
6564            raw_block
6565        );
6566
6567        let round_trip: Message = message::Message::Assistant {
6568            id: converted.message_id,
6569            content: converted.choice,
6570        }
6571        .try_into()
6572        .unwrap();
6573        assert!(matches!(
6574            round_trip.content.first(),
6575            Some(Content::CodeExecutionToolResult {
6576                tool_use_id,
6577                content
6578            }) if tool_use_id == "srvtoolu_01"
6579                && content["type"] == "code_execution_result"
6580                && content["stdout"] == "42\n"
6581        ));
6582    }
6583
6584    #[test]
6585    fn text_deserializes_unknown_citation_without_failing() {
6586        let value = json!({
6587            "type": "text",
6588            "text": "future citation",
6589            "citations": [{
6590                "type": "future_location",
6591                "cited_text": "future text",
6592                "new_field": "kept"
6593            }]
6594        });
6595
6596        let parsed: Content = serde_json::from_value(value).unwrap();
6597        let Content::Text { citations, .. } = parsed else {
6598            panic!("expected Content::Text");
6599        };
6600
6601        assert!(matches!(
6602            &citations[0],
6603            Citation::Unknown(raw)
6604                if raw["type"] == "future_location" && raw["new_field"] == "kept"
6605        ));
6606    }
6607
6608    #[test]
6609    fn page_location_citation_roundtrips() {
6610        let citation = Citation::PageLocation(PageLocationCitation {
6611            cited_text: "Water is essential for life.".into(),
6612            document_index: 1,
6613            document_title: Some("PDF Doc".into()),
6614            start_page_number: 5,
6615            end_page_number: 6,
6616        });
6617        let value = serde_json::to_value(&citation).unwrap();
6618        assert_eq!(value["type"], "page_location");
6619        assert_eq!(value["start_page_number"], 5);
6620        let back: Citation = serde_json::from_value(value).unwrap();
6621        assert_eq!(back, citation);
6622    }
6623
6624    #[test]
6625    fn content_block_location_citation_roundtrips() {
6626        let citation = Citation::ContentBlockLocation(ContentBlockLocationCitation {
6627            cited_text: "These are important findings.".into(),
6628            document_index: 2,
6629            document_title: None,
6630            start_block_index: 0,
6631            end_block_index: 1,
6632        });
6633        let value = serde_json::to_value(&citation).unwrap();
6634        assert_eq!(value["type"], "content_block_location");
6635        assert!(value.get("document_title").is_none());
6636        let back: Citation = serde_json::from_value(value).unwrap();
6637        assert_eq!(back, citation);
6638    }
6639
6640    #[test]
6641    fn anthropic_citations_extracts_from_additional_params() {
6642        let text = message::Text {
6643            text: "the grass is green".into(),
6644            additional_params: crate::message::AdditionalParams::try_from_value(json!({
6645                "citations": [{
6646                    "type": "char_location",
6647                    "cited_text": "The grass is green.",
6648                    "document_index": 0,
6649                    "start_char_index": 0,
6650                    "end_char_index": 20
6651                }]
6652            }))
6653            .expect("object params"),
6654        };
6655        let citations = anthropic_citations(&text).unwrap();
6656        assert_eq!(citations.len(), 1);
6657    }
6658
6659    #[test]
6660    fn anthropic_citations_returns_empty_when_absent() {
6661        let text = message::Text::new("hello".to_string());
6662        assert!(anthropic_citations(&text).unwrap().is_empty());
6663    }
6664
6665    #[test]
6666    fn content_text_with_citations_survives_assistant_conversion() {
6667        let content = Content::Text {
6668            text: "the grass is green".into(),
6669            citations: vec![Citation::CharLocation(CharLocationCitation {
6670                cited_text: "The grass is green.".into(),
6671                document_index: 0,
6672                document_title: None,
6673                start_char_index: 0,
6674                end_char_index: 20,
6675            })],
6676            cache_control: None,
6677        };
6678        let assistant: message::AssistantContent = content.try_into().unwrap();
6679        let message::AssistantContent::Text(text) = assistant else {
6680            panic!("expected text variant");
6681        };
6682        let recovered = anthropic_citations(&text).unwrap();
6683        assert_eq!(recovered.len(), 1);
6684    }
6685
6686    #[test]
6687    fn provider_text_response_concatenates_text_blocks_without_inserted_newlines() {
6688        let response = CompletionResponse {
6689            content: vec![
6690                Content::Text {
6691                    text: "According to the document, ".into(),
6692                    citations: Vec::new(),
6693                    cache_control: None,
6694                },
6695                Content::Text {
6696                    text: "the grass is green".into(),
6697                    citations: Vec::new(),
6698                    cache_control: None,
6699                },
6700                Content::Text {
6701                    text: " and the sky is blue.".into(),
6702                    citations: Vec::new(),
6703                    cache_control: None,
6704                },
6705            ],
6706            id: "msg_1".into(),
6707            model: "claude-test".into(),
6708            role: "assistant".into(),
6709            stop_reason: Some("end_turn".into()),
6710            stop_sequence: None,
6711            provider_request_id: None,
6712            usage: Usage {
6713                input_tokens: 1,
6714                cache_read_input_tokens: None,
6715                cache_creation_input_tokens: None,
6716                cache_creation: None,
6717                output_tokens: 1,
6718                output_tokens_details: None,
6719            },
6720        };
6721
6722        assert_eq!(
6723            response.get_text_response().as_deref(),
6724            Some("According to the document, the grass is green and the sky is blue.")
6725        );
6726    }
6727
6728    #[test]
6729    fn assistant_text_citations_survive_anthropic_request_conversion() {
6730        let assistant = message::Message::Assistant {
6731            id: None,
6732            content: vec![message::AssistantContent::Text(message::Text {
6733                text: "the grass is green".into(),
6734                additional_params: crate::message::AdditionalParams::try_from_value(json!({
6735                    "citations": [{
6736                        "type": "char_location",
6737                        "cited_text": "The grass is green.",
6738                        "document_index": 0,
6739                        "start_char_index": 0,
6740                        "end_char_index": 20
6741                    }]
6742                }))
6743                .expect("object params"),
6744            })],
6745        };
6746
6747        let converted: Message = assistant.try_into().unwrap();
6748        let Some(Content::Text {
6749            citations, text, ..
6750        }) = converted.content.first()
6751        else {
6752            panic!("expected assistant text content");
6753        };
6754
6755        assert_eq!(text, "the grass is green");
6756        assert_eq!(
6757            citations,
6758            &vec![Citation::CharLocation(CharLocationCitation {
6759                cited_text: "The grass is green.".into(),
6760                document_index: 0,
6761                document_title: None,
6762                start_char_index: 0,
6763                end_char_index: 20,
6764            })]
6765        );
6766    }
6767
6768    #[test]
6769    fn assistant_text_invalid_known_citations_are_rejected_for_anthropic_request_conversion() {
6770        let text = message::AssistantContent::Text(message::Text {
6771            text: "bad citation".into(),
6772            additional_params: crate::message::AdditionalParams::try_from_value(json!({
6773                "citations": [{
6774                    "type": "char_location",
6775                    "cited_text": "bad"
6776                }]
6777            }))
6778            .expect("object params"),
6779        });
6780
6781        let result = anthropic_content_from_assistant_content(text);
6782
6783        assert!(
6784            result.is_err(),
6785            "invalid Anthropic citation metadata should not be silently dropped"
6786        );
6787    }
6788
6789    #[test]
6790    fn document_additional_params_forward_to_anthropic_document() {
6791        let doc = message::UserContent::Document(message::Document {
6792            data: message::DocumentSourceKind::String("Hello world.".into()),
6793            media_type: Some(message::DocumentMediaType::TXT),
6794            additional_params: crate::message::AdditionalParams::try_from_value(json!({
6795                "title": "Doc1",
6796                "context": "ctx",
6797                "citations": { "enabled": true }
6798            }))
6799            .expect("object params"),
6800        });
6801        let msg = message::Message::User { content: vec![doc] };
6802        let converted: Message = msg.try_into().unwrap();
6803        let block = converted.content.first();
6804        let Some(Content::Document {
6805            title,
6806            context,
6807            citations,
6808            ..
6809        }) = block
6810        else {
6811            panic!("expected Content::Document");
6812        };
6813        assert_eq!(title.as_deref(), Some("Doc1"));
6814        assert_eq!(context.as_deref(), Some("ctx"));
6815        assert_eq!(citations, &Some(CitationsConfig { enabled: true }));
6816    }
6817
6818    fn assert_reverse_document_metadata(
6819        source: DocumentSource,
6820        expected_data: DocumentSourceKind,
6821        expected_media_type: Option<message::DocumentMediaType>,
6822    ) -> message::Message {
6823        let provider_message = Message {
6824            role: Role::User,
6825            content: vec![Content::Document {
6826                source,
6827                title: Some("Doc1".into()),
6828                context: Some("ctx".into()),
6829                citations: Some(CitationsConfig { enabled: true }),
6830                cache_control: None,
6831            }],
6832        };
6833
6834        let generic: message::Message = provider_message.try_into().unwrap();
6835        let message::Message::User { content } = &generic else {
6836            panic!("expected generic user message");
6837        };
6838        let Some(message::UserContent::Document(document)) = content.first() else {
6839            panic!("expected generic document");
6840        };
6841
6842        assert_eq!(document.data, expected_data);
6843        assert_eq!(document.media_type, expected_media_type);
6844        let additional_params = document
6845            .additional_params
6846            .as_ref()
6847            .expect("expected Anthropic document metadata");
6848        assert_eq!(additional_params["title"], "Doc1");
6849        assert_eq!(additional_params["context"], "ctx");
6850        assert_eq!(additional_params["citations"]["enabled"], true);
6851
6852        generic
6853    }
6854
6855    #[test]
6856    fn anthropic_document_metadata_survives_reverse_conversion_for_all_sources() {
6857        assert_reverse_document_metadata(
6858            DocumentSource::Text {
6859                data: "Hello world.".into(),
6860                media_type: PlainTextMediaType::Plain,
6861            },
6862            DocumentSourceKind::String("Hello world.".into()),
6863            Some(message::DocumentMediaType::TXT),
6864        );
6865        assert_reverse_document_metadata(
6866            DocumentSource::Base64 {
6867                data: "base64-pdf".into(),
6868                media_type: DocumentFormat::PDF,
6869            },
6870            DocumentSourceKind::String("base64-pdf".into()),
6871            Some(message::DocumentMediaType::PDF),
6872        );
6873        assert_reverse_document_metadata(
6874            DocumentSource::Url {
6875                url: "https://example.com/doc.pdf".into(),
6876            },
6877            DocumentSourceKind::Url("https://example.com/doc.pdf".into()),
6878            None,
6879        );
6880        assert_reverse_document_metadata(
6881            DocumentSource::File {
6882                file_id: "file_abc".into(),
6883            },
6884            DocumentSourceKind::FileId("file_abc".into()),
6885            None,
6886        );
6887    }
6888
6889    #[test]
6890    fn anthropic_document_metadata_survives_reverse_round_trip() {
6891        let provider_message = Message {
6892            role: Role::User,
6893            content: vec![Content::Document {
6894                source: DocumentSource::Text {
6895                    data: "Hello world.".into(),
6896                    media_type: PlainTextMediaType::Plain,
6897                },
6898                title: Some("Doc1".into()),
6899                context: Some("ctx".into()),
6900                citations: Some(CitationsConfig { enabled: true }),
6901                cache_control: None,
6902            }],
6903        };
6904
6905        let generic: message::Message = provider_message.try_into().unwrap();
6906        let message::Message::User { content } = &generic else {
6907            panic!("expected generic user message");
6908        };
6909        let Some(message::UserContent::Document(document)) = content.first() else {
6910            panic!("expected generic document");
6911        };
6912        let additional_params = document
6913            .additional_params
6914            .as_ref()
6915            .expect("expected Anthropic document metadata");
6916        assert_eq!(additional_params["title"], "Doc1");
6917        assert_eq!(additional_params["context"], "ctx");
6918        assert_eq!(additional_params["citations"]["enabled"], true);
6919
6920        let round_trip: Message = generic.try_into().unwrap();
6921        let Some(Content::Document {
6922            title,
6923            context,
6924            citations,
6925            ..
6926        }) = round_trip.content.first()
6927        else {
6928            panic!("expected Anthropic document");
6929        };
6930        assert_eq!(title.as_deref(), Some("Doc1"));
6931        assert_eq!(context.as_deref(), Some("ctx"));
6932        assert_eq!(citations, &Some(CitationsConfig { enabled: true }));
6933    }
6934
6935    #[test]
6936    fn anthropic_document_empty_metadata_stays_none_on_reverse_conversion() {
6937        let provider_message = Message {
6938            role: Role::User,
6939            content: vec![Content::Document {
6940                source: DocumentSource::Text {
6941                    data: "Hello world.".into(),
6942                    media_type: PlainTextMediaType::Plain,
6943                },
6944                title: None,
6945                context: None,
6946                citations: None,
6947                cache_control: None,
6948            }],
6949        };
6950
6951        let generic: message::Message = provider_message.try_into().unwrap();
6952        let message::Message::User { content } = &generic else {
6953            panic!("expected generic user message");
6954        };
6955        let Some(message::UserContent::Document(document)) = content.first() else {
6956            panic!("expected generic document");
6957        };
6958
6959        assert_eq!(document.additional_params, None);
6960    }
6961
6962    #[tokio::test]
6963    async fn completion_http_non_success_preserves_status_and_body() {
6964        use crate::client::CompletionClient;
6965        use crate::completion::CompletionModel as _;
6966        use crate::providers::anthropic::Client;
6967        use crate::test_utils::RecordingHttpClient;
6968
6969        let body = r#"{"type":"error","error":{"type":"overloaded_error","message":"slow down"}}"#;
6970        let http_client =
6971            RecordingHttpClient::with_error_response(http::StatusCode::TOO_MANY_REQUESTS, body);
6972        let client = Client::builder()
6973            .api_key("test-key")
6974            .http_client(http_client)
6975            .build()
6976            .expect("build client");
6977        let model = client.completion_model(CLAUDE_SONNET_4_6);
6978        let request = model.completion_request("hello").build();
6979
6980        let error = model
6981            .completion(request)
6982            .await
6983            .expect_err("completion should fail with non-success status");
6984
6985        // rig#2314: a provider with a request-id contract preserves its
6986        // non-success responses as ProviderResponse, so the transport id has
6987        // a home on the error; this mock sent no header, so the id is None.
6988        assert!(matches!(error, CompletionError::ProviderResponse(_)));
6989        assert_eq!(error.provider_request_id(), None);
6990        assert_eq!(
6991            error.provider_response_status(),
6992            Some(http::StatusCode::TOO_MANY_REQUESTS)
6993        );
6994        assert_eq!(error.provider_response_body(), Some(body));
6995    }
6996
6997    #[tokio::test]
6998    async fn completion_2xx_error_envelope_preserves_status_and_body() {
6999        use crate::client::CompletionClient;
7000        use crate::completion::CompletionModel as _;
7001        use crate::providers::anthropic::Client;
7002        use crate::test_utils::RecordingHttpClient;
7003
7004        // Anthropic's `ApiResponse` is internally tagged on `type`; the `Error`
7005        // arm flattens `ApiErrorResponse { message }`, so a 200-OK error envelope
7006        // deserializes from `{"type":"error","message":"..."}` and routes through
7007        // `from_http_response(OK, ..)` into `ProviderResponse`.
7008        let body = r#"{"type":"error","message":"model overloaded"}"#;
7009        let http_client = RecordingHttpClient::new(body); // 200 OK
7010        let client = Client::builder()
7011            .api_key("test-key")
7012            .http_client(http_client)
7013            .build()
7014            .expect("build client");
7015        let model = client.completion_model(CLAUDE_SONNET_4_6);
7016        let request = model.completion_request("hello").build();
7017
7018        let error = model
7019            .completion(request)
7020            .await
7021            .expect_err("completion should fail with provider error envelope");
7022
7023        match &error {
7024            CompletionError::ProviderResponse(stored) => {
7025                assert_eq!(stored.body, body);
7026                assert_eq!(stored.status, Some(http::StatusCode::OK));
7027                assert_eq!(error.provider_response_body(), Some(body));
7028                assert_eq!(error.provider_response_status(), Some(http::StatusCode::OK));
7029            }
7030            other => panic!("expected ProviderResponse, got {other:?}"),
7031        }
7032    }
7033
7034    #[tokio::test]
7035    async fn completion_streaming_http_non_success_preserves_status_and_body() {
7036        use crate::client::CompletionClient;
7037        use crate::completion::CompletionModel as _;
7038        use crate::providers::anthropic::Client;
7039        use crate::test_utils::HttpErrorStreamingClient;
7040        use futures::StreamExt;
7041
7042        let body = r#"{"type":"error","error":{"type":"overloaded_error","message":"slow down"}}"#;
7043        let http_client =
7044            HttpErrorStreamingClient::new(http::StatusCode::SERVICE_UNAVAILABLE, body);
7045        let client = Client::builder()
7046            .api_key("test-key")
7047            .http_client(http_client)
7048            .build()
7049            .expect("build client");
7050        let model = client.completion_model(CLAUDE_SONNET_4_6);
7051        let request = model.completion_request("hello").build();
7052
7053        let mut stream = model.stream(request).await.expect("stream should start");
7054
7055        // The transport failure surfaces as the first error item yielded by the stream.
7056        let error = loop {
7057            match stream.next().await {
7058                Some(Ok(_)) => continue,
7059                Some(Err(error)) => break error,
7060                None => panic!("stream ended without yielding the transport error"),
7061            }
7062        };
7063
7064        // Streaming *connect* failures stay transport-shaped (HttpError):
7065        // rig#2314's ProviderResponse classification covers the unary driver
7066        // and in-band stream envelopes, not the SSE handshake.
7067        assert!(matches!(error, CompletionError::HttpError(_)));
7068        assert_eq!(
7069            error.provider_response_status(),
7070            Some(http::StatusCode::SERVICE_UNAVAILABLE)
7071        );
7072        assert_eq!(error.provider_response_body(), Some(body));
7073
7074        // The transport failure ends the stream: nothing may follow it that
7075        // would read as a successfully completed turn.
7076        assert!(stream.next().await.is_none());
7077        assert!(
7078            stream.response.is_none(),
7079            "a stream cut short by a transport error must not synthesize a terminal record"
7080        );
7081    }
7082
7083    #[test]
7084    fn coerce_tool_input_normalizes_non_object_arguments() {
7085        use serde_json::json;
7086
7087        // Object passes through untouched.
7088        assert_eq!(
7089            coerce_tool_input(json!({"q": "rust", "n": 3})),
7090            json!({"q": "rust", "n": 3})
7091        );
7092
7093        // A JSON string that encodes an object is parsed into that object.
7094        assert_eq!(
7095            coerce_tool_input(json!("{\"q\":\"rust\"}")),
7096            json!({"q": "rust"})
7097        );
7098
7099        // A non-JSON string, a JSON string that is not an object, null, arrays,
7100        // numbers and bools all collapse to an empty object: the only shape the
7101        // Anthropic API accepts for tool_use.input.
7102        assert_eq!(coerce_tool_input(json!("not json")), json!({}));
7103        assert_eq!(coerce_tool_input(json!("[1,2,3]")), json!({}));
7104        assert_eq!(coerce_tool_input(json!(null)), json!({}));
7105        assert_eq!(coerce_tool_input(json!([1, 2, 3])), json!({}));
7106        assert_eq!(coerce_tool_input(json!(42)), json!({}));
7107        assert_eq!(coerce_tool_input(json!(true)), json!({}));
7108    }
7109
7110    // Regression test for issue #1429: PR #1431 added the `DocumentSource::Url`
7111    // wire variant and response-side parsing, but the request-side
7112    // `UserContent::Document` conversion still rejected URL-backed PDFs even
7113    // though the Anthropic Messages API supports
7114    // `"source": {"type": "url", ...}` for PDFs.
7115    // The media type is optional because Anthropic's URL source is implicitly a
7116    // PDF and does not include a media-type field on the wire.
7117    //
7118    // See <https://docs.anthropic.com/en/docs/build-with-claude/pdf-support>
7119    // for URL-sourced PDF documents.
7120    #[test]
7121    fn url_pdf_with_or_without_media_type_converts_to_url_document_source() {
7122        let pdf_url = "https://example.com/resume.pdf";
7123
7124        for media_type in [Some(message::DocumentMediaType::PDF), None] {
7125            let msg = message::Message::User {
7126                content: vec![message::UserContent::document_url(pdf_url, media_type)],
7127            };
7128
7129            let converted = Message::try_from(msg).expect("URL PDF should convert");
7130            let json = serde_json::to_value(&converted).expect("message should serialize");
7131
7132            assert_eq!(
7133                json.pointer("/content/0/source"),
7134                Some(&json!({ "type": "url", "url": pdf_url })),
7135                "URL PDF should map to a url document source: {json:#}"
7136            );
7137        }
7138    }
7139
7140    /// Raw-capture tests: the `normalize` shape through the Anthropic model,
7141    /// driven end to end over a mock transport that hands back a Messages body
7142    /// *and* a `request-id` response header. Anthropic's raw type carries the
7143    /// transport id itself (`CompletionResponse::provider_request_id`, stamped
7144    /// by the driver), which is why the Part A contract here is a plain
7145    /// `raw_completion` → `normalize`, with no id to reattach.
7146    /// `with_error_response_headers` with `200 OK` is the one unary double
7147    /// that carries response headers.
7148    mod raw_capture {
7149        use super::*;
7150        use crate::client::CompletionClient;
7151        use crate::completion::CompletionModel as _;
7152        use crate::providers::anthropic::Client;
7153        use crate::test_utils::RecordingHttpClient;
7154
7155        const REQUEST_ID: &str = "req_unit_anthropic_0001";
7156
7157        /// A Messages body whose `stop_sequence` is set: the normalized
7158        /// response maps it to `FinishReason::Stop` and drops which sequence
7159        /// fired, so the capture provably answers more than `completion()`.
7160        const BODY: &str = r#"{
7161            "id": "msg_raw_1",
7162            "type": "message",
7163            "role": "assistant",
7164            "model": "claude-sonnet-4-6",
7165            "content": [{"type": "text", "text": "hello"}],
7166            "stop_reason": "stop_sequence",
7167            "stop_sequence": "alpha",
7168            "usage": {"input_tokens": 7, "output_tokens": 2}
7169        }"#;
7170
7171        fn model() -> CompletionModel<RecordingHttpClient> {
7172            let mut headers = http::HeaderMap::new();
7173            headers.insert("request-id", http::HeaderValue::from_static(REQUEST_ID));
7174            let http_client = RecordingHttpClient::with_error_response_headers(
7175                http::StatusCode::OK,
7176                BODY,
7177                headers,
7178            );
7179            let client = Client::builder()
7180                .api_key("test-key")
7181                .http_client(http_client)
7182                .build()
7183                .expect("build client");
7184            client.completion_model(CLAUDE_SONNET_4_6)
7185        }
7186
7187        /// The load-bearing capture property: `raw` is Anthropic's
7188        /// `CompletionResponse` as rig parsed it — it deserializes back into
7189        /// that type and re-serializes to the identical value, including the
7190        /// transport id the driver stamped onto the raw type — and
7191        /// re-normalizing that capture reproduces every normalized field, so
7192        /// `raw` and the typed route tell one story. Also reads
7193        /// `stop_sequence` off the capture, which the normalized response does
7194        /// not carry.
7195        #[tokio::test]
7196        async fn completion_captures_raw_that_round_trips_into_the_wire_type() {
7197            let model = model();
7198
7199            let response = model
7200                .completion(model.completion_request("hello").build())
7201                .await
7202                .expect("completion");
7203
7204            let raw = &response.raw;
7205            let typed: CompletionResponse =
7206                serde_json::from_value(raw.clone()).expect("raw must deserialize");
7207            assert_eq!(
7208                serde_json::to_value(&typed).expect("re-serialize"),
7209                *raw,
7210                "the capture must be exactly what the wire type serializes to"
7211            );
7212            assert_eq!(typed.stop_sequence.as_deref(), Some("alpha"));
7213            assert_eq!(typed.provider_request_id.as_deref(), Some(REQUEST_ID));
7214            assert_eq!(raw["stop_sequence"], "alpha");
7215
7216            let renormalized = typed
7217                .normalize(<crate::providers::anthropic::client::AnthropicExt as AnthropicCompatibleProvider>::PROVIDER_NAME)
7218                .expect("re-normalize the capture");
7219            assert_eq!(response.identity(), renormalized.identity());
7220            assert_eq!(response.finish_reason(), renormalized.finish_reason());
7221            assert_eq!(response.model, renormalized.model);
7222            assert_eq!(response.usage, renormalized.usage);
7223            assert_eq!(response.choice, renormalized.choice);
7224            assert_eq!(
7225                response.finish_reason(),
7226                Some(completion::FinishReason::Stop)
7227            );
7228            assert_eq!(response.provider_request_id.as_deref(), Some(REQUEST_ID));
7229        }
7230
7231        /// Part A contract statement for a provider whose raw type carries the
7232        /// transport id: `raw_completion` → `normalize` reproduces
7233        /// `completion()` on identity, finish reason, model and usage — the id
7234        /// included — with nothing to reattach.
7235        #[tokio::test]
7236        async fn raw_completion_then_normalize_reproduces_completion() {
7237            let model = model();
7238
7239            let raw = model
7240                .raw_completion(model.completion_request("hello").build())
7241                .await
7242                .expect("typed route");
7243            assert_eq!(raw.provider_request_id.as_deref(), Some(REQUEST_ID));
7244            let reassembled = raw
7245                .normalize(<crate::providers::anthropic::client::AnthropicExt as AnthropicCompatibleProvider>::PROVIDER_NAME)
7246                .expect("normalize");
7247
7248            let normalized = model
7249                .completion(model.completion_request("hello").build())
7250                .await
7251                .expect("normalized route");
7252
7253            assert_eq!(reassembled.identity(), normalized.identity());
7254            assert_eq!(reassembled.finish_reason(), normalized.finish_reason());
7255            assert_eq!(reassembled.model, normalized.model);
7256            assert_eq!(reassembled.usage, normalized.usage);
7257            assert_eq!(reassembled.provider_request_id.as_deref(), Some(REQUEST_ID));
7258            assert_eq!(normalized.provider_request_id.as_deref(), Some(REQUEST_ID));
7259        }
7260    }
7261}