Skip to main content

mentra_provider/anthropic/
model.rs

1use base64::{Engine as _, engine::general_purpose::STANDARD};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use time::{OffsetDateTime, format_description::well_known::Rfc3339};
5
6use crate::{
7    BuiltinProvider, ContentBlock, ImageSource, Message, ModelInfo, ProviderError, ProviderId,
8    ProviderToolKind, ReasoningEffort, ReasoningFormat, ReasoningProvenance, Request, Response,
9    Role, TokenUsage, ToolChoice, ToolLoadingPolicy, ToolResultContent, ToolSearchMode, ToolSpec,
10};
11
12#[derive(Deserialize)]
13pub(crate) struct AnthropicModelsPage {
14    pub(crate) data: Vec<AnthropicModel>,
15    pub(crate) has_more: bool,
16    pub(crate) last_id: Option<String>,
17}
18
19#[derive(Deserialize)]
20pub(crate) struct AnthropicModel {
21    pub(crate) id: String,
22    #[serde(default)]
23    pub(crate) display_name: Option<String>,
24    #[serde(default)]
25    pub(crate) created_at: Option<String>,
26}
27
28impl From<AnthropicModel> for ModelInfo {
29    fn from(model: AnthropicModel) -> Self {
30        ModelInfo {
31            id: model.id,
32            provider: BuiltinProvider::Anthropic.into(),
33            display_name: model.display_name,
34            description: None,
35            created_at: model
36                .created_at
37                .as_deref()
38                .and_then(|value| OffsetDateTime::parse(value, &Rfc3339).ok()),
39            // Anthropic's model listing does not report a context window.
40            context_window: None,
41        }
42    }
43}
44
45#[derive(Serialize)]
46pub(crate) struct AnthropicRequest {
47    model: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    system: Option<Vec<AnthropicSystemBlock>>,
50    messages: Vec<AnthropicMessage>,
51    #[serde(skip_serializing_if = "Vec::is_empty")]
52    tools: Vec<AnthropicTool>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    tool_choice: Option<AnthropicToolChoice>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    temperature: Option<f32>,
57    #[serde(rename = "max_tokens", skip_serializing_if = "Option::is_none")]
58    max_output_tokens: Option<u32>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    disable_parallel_tool_use: Option<bool>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    thinking: Option<AnthropicThinkingConfig>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    output_config: Option<AnthropicOutputConfig>,
65}
66
67/// A system prompt block with optional cache control.
68#[derive(Serialize)]
69pub(crate) struct AnthropicSystemBlock {
70    #[serde(rename = "type")]
71    kind: &'static str,
72    text: String,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    cache_control: Option<AnthropicCacheControl>,
75}
76
77/// Cache control marker for Anthropic prompt caching.
78#[derive(Serialize)]
79pub(crate) struct AnthropicCacheControl {
80    #[serde(rename = "type")]
81    kind: &'static str,
82}
83
84impl AnthropicCacheControl {
85    fn ephemeral() -> Self {
86        Self { kind: "ephemeral" }
87    }
88}
89
90/// Build system blocks from a system prompt string, with cache_control on
91/// the final block to enable prompt caching.
92fn build_system_blocks(system: String) -> Vec<AnthropicSystemBlock> {
93    vec![AnthropicSystemBlock {
94        kind: "text",
95        text: system,
96        cache_control: Some(AnthropicCacheControl::ephemeral()),
97    }]
98}
99
100#[derive(Deserialize)]
101pub(crate) struct AnthropicResponse {
102    pub(crate) id: String,
103    pub(crate) model: String,
104    pub(crate) role: String,
105    #[serde(default)]
106    pub(crate) usage: Option<AnthropicUsage>,
107    content: Vec<AnthropicContentBlock>,
108    stop_reason: Option<String>,
109}
110
111impl TryFrom<AnthropicResponse> for Response {
112    type Error = ProviderError;
113
114    fn try_from(response: AnthropicResponse) -> Result<Self, Self::Error> {
115        let provider = ProviderId::from(BuiltinProvider::Anthropic);
116        let requested_model = response.model.clone();
117        response.try_into_response_with_provider(&provider, &requested_model)
118    }
119}
120
121impl AnthropicResponse {
122    fn try_into_response_with_provider(
123        self,
124        provider: &ProviderId,
125        requested_model: &str,
126    ) -> Result<Response, ProviderError> {
127        let provenance = ReasoningProvenance {
128            provider: provider.clone(),
129            model: requested_model.to_string(),
130            format: ReasoningFormat::AnthropicSigned,
131        };
132        Ok(Response {
133            id: self.id,
134            model: self.model,
135            role: match self.role.as_str() {
136                "user" => Role::User,
137                "assistant" => Role::Assistant,
138                _ => Role::Unknown(self.role),
139            },
140            content: self
141                .content
142                .into_iter()
143                .map(|block| ContentBlock::try_from((block, provenance.clone())))
144                .collect::<Result<Vec<_>, _>>()?,
145            stop_reason: self.stop_reason,
146            usage: self.usage.and_then(|usage| usage.into_token_usage()),
147        })
148    }
149}
150
151#[derive(Debug, Clone, Deserialize)]
152pub(crate) struct AnthropicUsage {
153    #[serde(default)]
154    pub(crate) input_tokens: Option<u64>,
155    #[serde(default)]
156    pub(crate) output_tokens: Option<u64>,
157    #[serde(default)]
158    pub(crate) cache_read_input_tokens: Option<u64>,
159    #[serde(default)]
160    pub(crate) cache_creation_input_tokens: Option<u64>,
161    #[serde(default)]
162    pub(crate) total_tokens: Option<u64>,
163}
164
165impl AnthropicUsage {
166    pub(crate) fn into_token_usage(self) -> Option<TokenUsage> {
167        let usage = TokenUsage {
168            input_tokens: self.input_tokens,
169            output_tokens: self.output_tokens,
170            total_tokens: self.total_tokens,
171            cache_read_input_tokens: self.cache_read_input_tokens,
172            cache_creation_input_tokens: self.cache_creation_input_tokens,
173            reasoning_tokens: None,
174            thoughts_tokens: None,
175            tool_input_tokens: None,
176        };
177
178        (!usage.is_empty()).then_some(usage)
179    }
180}
181
182impl<'a> TryFrom<Request<'a>> for AnthropicRequest {
183    type Error = ProviderError;
184
185    fn try_from(value: Request<'a>) -> Result<Self, Self::Error> {
186        let provider = ProviderId::from(BuiltinProvider::Anthropic);
187        Self::try_from_with_provider(value, &provider)
188    }
189}
190
191impl AnthropicRequest {
192    pub(crate) fn try_from_with_provider(
193        value: Request<'_>,
194        target_provider: &ProviderId,
195    ) -> Result<Self, ProviderError> {
196        let reasoning_effort = value
197            .provider_request_options
198            .reasoning
199            .as_ref()
200            .and_then(|reasoning| reasoning.effort);
201
202        let effort_capabilities = reasoning_effort
203            .map(|effort| {
204                let capabilities =
205                    anthropic_effort_capabilities(&value.model).ok_or_else(|| {
206                        ProviderError::InvalidRequest(format!(
207                            "Anthropic reasoning effort is not supported by model '{}'",
208                            value.model
209                        ))
210                    })?;
211                if matches!(effort, ReasoningEffort::Max) && !capabilities.max {
212                    return Err(ProviderError::InvalidRequest(format!(
213                        "Anthropic max reasoning effort is not supported by model '{}'",
214                        value.model
215                    )));
216                }
217                if matches!(effort, ReasoningEffort::XHigh) && !capabilities.xhigh {
218                    return Err(ProviderError::InvalidRequest(format!(
219                        "Anthropic xhigh reasoning effort is not supported by model '{}'",
220                        value.model
221                    )));
222                }
223                Ok(capabilities)
224            })
225            .transpose()?;
226
227        let target_model = value.model.to_string();
228
229        let mut messages = value
230            .messages
231            .iter()
232            .map(|message| {
233                AnthropicMessage::try_from_with_target(message, target_provider, &target_model)
234            })
235            .collect::<Result<Vec<_>, _>>()?;
236        mark_conversation_cache_breakpoints(&mut messages);
237
238        Ok(AnthropicRequest {
239            model: value.model.into_owned(),
240            system: value.system.map(|s| build_system_blocks(s.into_owned())),
241            messages,
242            tools: build_anthropic_tools(
243                value.tools.as_ref(),
244                value.tool_choice.as_ref(),
245                value.provider_request_options.tool_search_mode,
246            )?,
247            tool_choice: value.tool_choice.map(AnthropicToolChoice::from),
248            temperature: value.temperature,
249            max_output_tokens: value.max_output_tokens,
250            disable_parallel_tool_use: value
251                .provider_request_options
252                .anthropic
253                .disable_parallel_tool_use,
254            thinking: effort_capabilities
255                .filter(|capabilities| capabilities.adaptive_thinking)
256                .map(|_| AnthropicThinkingConfig::adaptive()),
257            output_config: reasoning_effort.map(AnthropicOutputConfig::new),
258        })
259    }
260}
261
262#[derive(Serialize)]
263struct AnthropicThinkingConfig {
264    #[serde(rename = "type")]
265    kind: &'static str,
266}
267
268impl AnthropicThinkingConfig {
269    fn adaptive() -> Self {
270        Self { kind: "adaptive" }
271    }
272}
273
274#[derive(Serialize)]
275struct AnthropicOutputConfig {
276    effort: AnthropicReasoningEffort,
277}
278
279impl AnthropicOutputConfig {
280    fn new(effort: ReasoningEffort) -> Self {
281        Self {
282            effort: effort.into(),
283        }
284    }
285}
286
287#[derive(Serialize)]
288#[serde(rename_all = "snake_case")]
289enum AnthropicReasoningEffort {
290    Low,
291    Medium,
292    High,
293    #[serde(rename = "xhigh")]
294    XHigh,
295    Max,
296}
297
298impl From<ReasoningEffort> for AnthropicReasoningEffort {
299    fn from(value: ReasoningEffort) -> Self {
300        match value {
301            ReasoningEffort::Low => Self::Low,
302            ReasoningEffort::Medium => Self::Medium,
303            ReasoningEffort::High => Self::High,
304            ReasoningEffort::XHigh => Self::XHigh,
305            ReasoningEffort::Max => Self::Max,
306        }
307    }
308}
309
310#[derive(Clone, Copy)]
311struct AnthropicEffortCapabilities {
312    adaptive_thinking: bool,
313    max: bool,
314    xhigh: bool,
315}
316
317impl AnthropicEffortCapabilities {
318    const BASIC: Self = Self {
319        adaptive_thinking: false,
320        max: false,
321        xhigh: false,
322    };
323    const ADAPTIVE_WITH_MAX: Self = Self {
324        adaptive_thinking: true,
325        max: true,
326        xhigh: false,
327    };
328    const ALL: Self = Self {
329        adaptive_thinking: true,
330        max: true,
331        xhigh: true,
332    };
333}
334
335fn anthropic_effort_capabilities(model: &str) -> Option<AnthropicEffortCapabilities> {
336    let model = model.strip_prefix("models/").unwrap_or(model);
337    if matches_anthropic_model(model, "claude-opus-4-5") {
338        Some(AnthropicEffortCapabilities::BASIC)
339    } else if matches_anthropic_model(model, "claude-mythos-preview")
340        || matches_anthropic_model(model, "claude-opus-4-6")
341        || matches_anthropic_model(model, "claude-sonnet-4-6")
342    {
343        Some(AnthropicEffortCapabilities::ADAPTIVE_WITH_MAX)
344    } else if matches_anthropic_model(model, "claude-opus-4-7")
345        || matches_anthropic_model(model, "claude-opus-4-8")
346        || matches_anthropic_model(model, "claude-opus-5")
347        || matches_anthropic_model(model, "claude-sonnet-5")
348        || matches_anthropic_model(model, "claude-fable-5")
349        || matches_anthropic_model(model, "claude-mythos-5")
350    {
351        Some(AnthropicEffortCapabilities::ALL)
352    } else {
353        None
354    }
355}
356
357fn matches_anthropic_model(model: &str, canonical: &str) -> bool {
358    model == canonical
359        || model
360            .strip_prefix(canonical)
361            .and_then(|suffix| suffix.strip_prefix('-'))
362            .is_some_and(|snapshot| {
363                snapshot.len() == 8 && snapshot.bytes().all(|byte| byte.is_ascii_digit())
364            })
365}
366
367#[derive(Serialize)]
368struct AnthropicMessage {
369    role: String,
370    content: Vec<AnthropicRequestBlock>,
371}
372
373/// A content block on its way out in a request, with the optional cache
374/// breakpoint that only outbound blocks can carry.
375///
376/// The breakpoint is a property of a block's position in one request, not of
377/// the block itself, so it lives here rather than on [`AnthropicContentBlock`],
378/// which is also what a response is read back into.
379#[derive(Serialize)]
380struct AnthropicRequestBlock {
381    #[serde(flatten)]
382    block: AnthropicContentBlock,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    cache_control: Option<AnthropicCacheControl>,
385}
386
387impl From<AnthropicContentBlock> for AnthropicRequestBlock {
388    fn from(block: AnthropicContentBlock) -> Self {
389        Self {
390            block,
391            cache_control: None,
392        }
393    }
394}
395
396/// Anthropic accepts four cache breakpoints in a request. The system prompt
397/// takes one and the tool list takes another, and both are static for the life
398/// of a session — they cache the preamble and nothing that grows. The other two
399/// go on the conversation.
400const CONVERSATION_CACHE_BREAKPOINTS: usize = 2;
401
402/// Puts a cache breakpoint at the end of each of the last two user messages.
403///
404/// Two, rather than one: a breakpoint marks a position both to write a cache
405/// entry at and to read one from. The newest user message writes the prefix
406/// through the current turn, and the one before it is the position where this
407/// request reads the entry the previous turn wrote. With a single moving
408/// breakpoint the write from the last turn sits at no marked position, and a
409/// conversation pays full input price for its whole transcript every turn.
410///
411/// The end of a user message is the right place because everything before it —
412/// system prompt, tools, and every earlier turn including the tool results just
413/// appended — is settled by then and never rewritten.
414fn mark_conversation_cache_breakpoints(messages: &mut [AnthropicMessage]) {
415    let mut remaining = CONVERSATION_CACHE_BREAKPOINTS;
416
417    for message in messages.iter_mut().rev() {
418        if remaining == 0 {
419            break;
420        }
421        if message.role != Role::User.to_string() {
422            continue;
423        }
424        if let Some(block) = message.content.last_mut() {
425            block.cache_control = Some(AnthropicCacheControl::ephemeral());
426            remaining -= 1;
427        }
428    }
429}
430
431impl TryFrom<Message> for AnthropicMessage {
432    type Error = ProviderError;
433
434    fn try_from(message: Message) -> Result<Self, Self::Error> {
435        AnthropicMessage::try_from(&message)
436    }
437}
438
439impl TryFrom<&Message> for AnthropicMessage {
440    type Error = ProviderError;
441
442    fn try_from(message: &Message) -> Result<Self, Self::Error> {
443        if !matches!(message.role, Role::User) && message_has_image(message) {
444            return Err(ProviderError::InvalidRequest(
445                "Anthropic image inputs are only supported in user messages".to_string(),
446            ));
447        }
448
449        Ok(AnthropicMessage {
450            role: message.role.to_string(),
451            content: message
452                .content
453                .iter()
454                .map(AnthropicContentBlock::from_without_replay_target)
455                .map(AnthropicRequestBlock::from)
456                .collect(),
457        })
458    }
459}
460
461impl AnthropicMessage {
462    fn try_from_with_target(
463        message: &Message,
464        provider: &ProviderId,
465        model: &str,
466    ) -> Result<Self, ProviderError> {
467        if !matches!(message.role, Role::User) && message_has_image(message) {
468            return Err(ProviderError::InvalidRequest(
469                "Anthropic image inputs are only supported in user messages".to_string(),
470            ));
471        }
472
473        let target = AnthropicReplayTarget {
474            provider,
475            model,
476            role: &message.role,
477        };
478        Ok(Self {
479            role: message.role.to_string(),
480            content: message
481                .content
482                .iter()
483                .map(|block| AnthropicContentBlock::from_with_replay_target(block, &target))
484                .map(AnthropicRequestBlock::from)
485                .collect(),
486        })
487    }
488}
489
490struct AnthropicReplayTarget<'a> {
491    provider: &'a ProviderId,
492    model: &'a str,
493    role: &'a Role,
494}
495
496#[derive(Serialize, Deserialize)]
497#[serde(tag = "type", rename_all = "snake_case")]
498enum AnthropicContentBlock {
499    Text {
500        text: String,
501    },
502    Thinking {
503        thinking: String,
504        signature: String,
505    },
506    RedactedThinking {
507        data: String,
508    },
509    Image {
510        source: AnthropicImageSource,
511    },
512    ToolUse {
513        id: String,
514        name: String,
515        input: Value,
516    },
517    ToolResult {
518        tool_use_id: String,
519        content: String,
520        is_error: bool,
521    },
522}
523
524#[derive(Serialize, Deserialize)]
525#[serde(tag = "type", rename_all = "snake_case")]
526enum AnthropicImageSource {
527    Base64 { media_type: String, data: String },
528    Url { url: String },
529}
530
531impl From<ContentBlock> for AnthropicContentBlock {
532    fn from(block: ContentBlock) -> Self {
533        AnthropicContentBlock::from(&block)
534    }
535}
536
537impl From<&ContentBlock> for AnthropicContentBlock {
538    fn from(block: &ContentBlock) -> Self {
539        Self::from_without_replay_target(block)
540    }
541}
542
543impl AnthropicContentBlock {
544    fn from_without_replay_target(block: &ContentBlock) -> Self {
545        match block {
546            ContentBlock::Thinking { .. } => AnthropicContentBlock::Text {
547                text: block
548                    .thinking_fallback_text()
549                    .expect("thinking block has fallback text"),
550            },
551            _ => Self::from_non_thinking(block),
552        }
553    }
554
555    fn from_with_replay_target(block: &ContentBlock, target: &AnthropicReplayTarget<'_>) -> Self {
556        match block {
557            ContentBlock::Thinking {
558                thinking,
559                signature: Some(signature),
560                provenance: Some(provenance),
561                redacted,
562                ..
563            } if matches!(target.role, Role::Assistant)
564                && !signature.is_empty()
565                && provenance.provider == *target.provider
566                && provenance.model == target.model
567                && provenance.format == ReasoningFormat::AnthropicSigned =>
568            {
569                if *redacted {
570                    Self::RedactedThinking {
571                        data: signature.clone(),
572                    }
573                } else {
574                    Self::Thinking {
575                        thinking: thinking.clone(),
576                        signature: signature.clone(),
577                    }
578                }
579            }
580            _ => Self::from_without_replay_target(block),
581        }
582    }
583
584    fn from_non_thinking(block: &ContentBlock) -> Self {
585        match block {
586            ContentBlock::Text { text } => AnthropicContentBlock::Text { text: text.clone() },
587            ContentBlock::Thinking { .. } => {
588                unreachable!("thinking blocks are handled before non-thinking projection")
589            }
590            ContentBlock::Image { source } => AnthropicContentBlock::Image {
591                source: source.into(),
592            },
593            ContentBlock::ToolUse { id, name, input } => AnthropicContentBlock::ToolUse {
594                id: id.clone(),
595                name: name.clone(),
596                input: input.clone(),
597            },
598            ContentBlock::ToolResult {
599                tool_use_id,
600                content,
601                is_error,
602            } => AnthropicContentBlock::ToolResult {
603                tool_use_id: tool_use_id.clone(),
604                content: content.to_display_string(),
605                is_error: *is_error,
606            },
607            ContentBlock::HostedToolSearch { call } => AnthropicContentBlock::ToolUse {
608                id: call.id.clone(),
609                name: "tool_search".to_string(),
610                input: serde_json::json!({ "query": call.query }),
611            },
612            ContentBlock::HostedWebSearch { call } => AnthropicContentBlock::ToolUse {
613                id: call.id.clone(),
614                name: "web_search".to_string(),
615                input: serde_json::to_value(call.action.clone()).unwrap_or(serde_json::Value::Null),
616            },
617            ContentBlock::ImageGeneration { call } => AnthropicContentBlock::ToolUse {
618                id: call.id.clone(),
619                name: "image_generation".to_string(),
620                input: serde_json::json!({
621                    "status": call.status,
622                    "revised_prompt": call.revised_prompt,
623                }),
624            },
625        }
626    }
627}
628
629impl TryFrom<(AnthropicContentBlock, ReasoningProvenance)> for ContentBlock {
630    type Error = ProviderError;
631
632    fn try_from(
633        (block, provenance): (AnthropicContentBlock, ReasoningProvenance),
634    ) -> Result<Self, Self::Error> {
635        Ok(match block {
636            AnthropicContentBlock::Text { text } => ContentBlock::Text { text },
637            AnthropicContentBlock::Thinking {
638                thinking,
639                signature,
640            } => ContentBlock::Thinking {
641                thinking,
642                signature: Some(signature),
643                encrypted_content: None,
644                id: None,
645                provenance: Some(provenance),
646                redacted: false,
647            },
648            AnthropicContentBlock::RedactedThinking { data } => ContentBlock::Thinking {
649                thinking: String::new(),
650                signature: Some(data),
651                encrypted_content: None,
652                id: None,
653                provenance: Some(provenance),
654                redacted: true,
655            },
656            AnthropicContentBlock::Image { source } => ContentBlock::Image {
657                source: source.try_into()?,
658            },
659            AnthropicContentBlock::ToolUse { id, name, input } => {
660                ContentBlock::ToolUse { id, name, input }
661            }
662            AnthropicContentBlock::ToolResult {
663                tool_use_id,
664                content,
665                is_error,
666            } => ContentBlock::ToolResult {
667                tool_use_id,
668                content: ToolResultContent::Text(content),
669                is_error,
670            },
671        })
672    }
673}
674
675impl From<&ImageSource> for AnthropicImageSource {
676    fn from(value: &ImageSource) -> Self {
677        match value {
678            ImageSource::Bytes { media_type, data } => AnthropicImageSource::Base64 {
679                media_type: media_type.clone(),
680                data: STANDARD.encode(data),
681            },
682            ImageSource::Url { url } => AnthropicImageSource::Url { url: url.clone() },
683        }
684    }
685}
686
687impl From<ImageSource> for AnthropicImageSource {
688    fn from(value: ImageSource) -> Self {
689        AnthropicImageSource::from(&value)
690    }
691}
692
693impl TryFrom<AnthropicImageSource> for ImageSource {
694    type Error = ProviderError;
695
696    fn try_from(value: AnthropicImageSource) -> Result<Self, Self::Error> {
697        match value {
698            AnthropicImageSource::Base64 { media_type, data } => {
699                let data = STANDARD.decode(data).map_err(|error| {
700                    ProviderError::InvalidResponse(format!(
701                        "invalid Anthropic image payload for media type {media_type}: {error}"
702                    ))
703                })?;
704                Ok(ImageSource::Bytes { media_type, data })
705            }
706            AnthropicImageSource::Url { url } => Ok(ImageSource::Url { url }),
707        }
708    }
709}
710
711#[derive(Serialize)]
712#[serde(untagged)]
713enum AnthropicTool {
714    Custom(AnthropicCustomTool),
715    HostedSearch(AnthropicHostedSearchTool),
716}
717
718#[derive(Serialize)]
719struct AnthropicCustomTool {
720    name: String,
721    #[serde(skip_serializing_if = "Option::is_none")]
722    description: Option<String>,
723    input_schema: Value,
724    #[serde(skip_serializing_if = "std::ops::Not::not")]
725    defer_loading: bool,
726    #[serde(skip_serializing_if = "Option::is_none")]
727    cache_control: Option<AnthropicCacheControl>,
728}
729
730#[derive(Serialize)]
731struct AnthropicHostedSearchTool {
732    #[serde(rename = "type")]
733    kind: &'static str,
734    name: &'static str,
735}
736
737impl AnthropicTool {
738    fn custom(tool: &ToolSpec, force_immediate: bool, is_last: bool) -> Self {
739        Self::Custom(AnthropicCustomTool {
740            name: tool.name.clone(),
741            description: tool.description.clone(),
742            input_schema: tool.input_schema.clone(),
743            defer_loading: tool.loading_policy == ToolLoadingPolicy::Deferred && !force_immediate,
744            cache_control: if is_last {
745                Some(AnthropicCacheControl::ephemeral())
746            } else {
747                None
748            },
749        })
750    }
751
752    fn hosted_search() -> Self {
753        Self::HostedSearch(AnthropicHostedSearchTool {
754            kind: "tool_search_tool_bm25_20251119",
755            name: "tool_search_tool_bm25",
756        })
757    }
758}
759
760fn build_anthropic_tools(
761    tools: &[ToolSpec],
762    tool_choice: Option<&ToolChoice>,
763    tool_search_mode: ToolSearchMode,
764) -> Result<Vec<AnthropicTool>, ProviderError> {
765    if let Some(tool) = tools
766        .iter()
767        .find(|tool| tool.kind != ProviderToolKind::Function)
768    {
769        return Err(ProviderError::InvalidRequest(format!(
770            "Anthropic does not support provider tool kind {:?} for '{}'",
771            tool.kind, tool.name
772        )));
773    }
774
775    let forced_tool_name = match tool_choice {
776        Some(ToolChoice::Tool { name }) => Some(name.as_str()),
777        _ => None,
778    };
779
780    let has_deferred_tools = tools.iter().any(|tool| {
781        tool.loading_policy == ToolLoadingPolicy::Deferred
782            && forced_tool_name != Some(tool.name.as_str())
783    });
784
785    if has_deferred_tools && tool_search_mode != ToolSearchMode::Hosted {
786        return Err(ProviderError::InvalidRequest(
787            "Anthropic deferred tools require hosted tool search".to_string(),
788        ));
789    }
790
791    let tool_count = tools.len();
792    let mut provider_tools = tools
793        .iter()
794        .enumerate()
795        .map(|(i, tool)| {
796            let is_last = i == tool_count - 1 && !has_deferred_tools;
797            AnthropicTool::custom(tool, forced_tool_name == Some(tool.name.as_str()), is_last)
798        })
799        .collect::<Vec<_>>();
800
801    if has_deferred_tools {
802        provider_tools.push(AnthropicTool::hosted_search());
803    }
804
805    Ok(provider_tools)
806}
807
808#[derive(Serialize)]
809#[serde(tag = "type", rename_all = "snake_case")]
810pub(crate) enum AnthropicToolChoice {
811    Auto,
812    Any,
813    Tool { name: String },
814}
815
816impl From<ToolChoice> for AnthropicToolChoice {
817    fn from(choice: ToolChoice) -> Self {
818        match choice {
819            ToolChoice::Auto => AnthropicToolChoice::Auto,
820            ToolChoice::Any => AnthropicToolChoice::Any,
821            ToolChoice::Tool { name } => AnthropicToolChoice::Tool { name },
822        }
823    }
824}
825
826fn message_has_image(message: &Message) -> bool {
827    message
828        .content
829        .iter()
830        .any(|block| matches!(block, ContentBlock::Image { .. }))
831}
832
833#[cfg(test)]
834mod tests {
835    use std::{borrow::Cow, collections::BTreeMap};
836
837    use time::{OffsetDateTime, format_description::well_known::Rfc3339};
838
839    use crate::{
840        AnthropicRequestOptions, ContentBlock, ImageSource, Message, ModelInfo, ProviderError,
841        ProviderId, ProviderRequestOptions, ReasoningEffort, ReasoningFormat, ReasoningOptions,
842        ReasoningProvenance, Request, Role, ToolChoice, ToolLoadingPolicy, ToolResultContent,
843        ToolSearchMode, ToolSpec,
844    };
845
846    use super::{
847        AnthropicContentBlock, AnthropicImageSource, AnthropicModel, AnthropicRequest,
848        AnthropicResponse,
849    };
850
851    fn request_with_message(model: &str, message: Message) -> Request<'static> {
852        Request {
853            model: Cow::Owned(model.to_string()),
854            system: None,
855            messages: Cow::Owned(vec![message]),
856            tools: Cow::Owned(vec![]),
857            tool_choice: Some(ToolChoice::Auto),
858            temperature: None,
859            max_output_tokens: Some(512),
860            metadata: Cow::Owned(BTreeMap::new()),
861            provider_request_options: ProviderRequestOptions::default(),
862        }
863    }
864
865    fn request_with_effort(model: &str, effort: ReasoningEffort) -> Request<'static> {
866        Request {
867            model: Cow::Owned(model.to_string()),
868            system: None,
869            messages: Cow::Owned(vec![]),
870            tools: Cow::Owned(vec![]),
871            tool_choice: Some(ToolChoice::Auto),
872            temperature: None,
873            max_output_tokens: Some(512),
874            metadata: Cow::Owned(BTreeMap::new()),
875            provider_request_options: ProviderRequestOptions {
876                reasoning: Some(ReasoningOptions {
877                    effort: Some(effort),
878                    summary: None,
879                }),
880                ..Default::default()
881            },
882        }
883    }
884
885    fn anthropic_thinking(
886        thinking: &str,
887        signature: Option<&str>,
888        provider: &str,
889        model: &str,
890        redacted: bool,
891    ) -> ContentBlock {
892        ContentBlock::Thinking {
893            thinking: thinking.to_string(),
894            signature: signature.map(str::to_string),
895            encrypted_content: None,
896            id: None,
897            provenance: Some(ReasoningProvenance {
898                provider: ProviderId::new(provider),
899                model: model.to_string(),
900                format: ReasoningFormat::AnthropicSigned,
901            }),
902            redacted,
903        }
904    }
905
906    #[test]
907    fn the_last_two_user_messages_carry_cache_breakpoints() {
908        // The system prompt and the tool list are cached, and both are static.
909        // Without a breakpoint on the conversation itself every turn re-reads
910        // the whole transcript at full input price. Two breakpoints, not one:
911        // the newer writes the prefix through this turn, the older is where
912        // this request reads the entry the previous turn wrote.
913        let mut request =
914            request_with_message("claude-test", Message::user(ContentBlock::text("first")));
915        request.messages = Cow::Owned(vec![
916            Message::user(ContentBlock::text("first")),
917            Message::assistant(ContentBlock::text("answer")),
918            Message::user(ContentBlock::text("second")),
919            Message::assistant(ContentBlock::text("answer")),
920            Message::user(ContentBlock::text("third")),
921        ]);
922
923        let wire =
924            serde_json::to_value(AnthropicRequest::try_from(request).expect("request converts"))
925                .expect("request serializes");
926
927        let cached: Vec<usize> = wire["messages"]
928            .as_array()
929            .expect("messages array")
930            .iter()
931            .enumerate()
932            .filter(|(_, message)| {
933                message["content"]
934                    .as_array()
935                    .expect("content array")
936                    .iter()
937                    .any(|block| block.get("cache_control").is_some())
938            })
939            .map(|(index, _)| index)
940            .collect();
941
942        assert_eq!(cached, vec![2, 4], "{wire}");
943        assert_eq!(
944            wire["messages"][4]["content"][0]["cache_control"]["type"],
945            "ephemeral"
946        );
947        assert_eq!(wire["messages"][4]["content"][0]["type"], "text");
948        assert_eq!(wire["messages"][4]["content"][0]["text"], "third");
949    }
950
951    #[test]
952    fn converts_rfc3339_timestamp_to_offset_datetime() {
953        let raw = "2025-03-04T12:34:56Z";
954        let model = AnthropicModel {
955            id: "claude-test".to_string(),
956            display_name: None,
957            created_at: Some(raw.to_string()),
958        };
959
960        let info = ModelInfo::from(model);
961
962        assert_eq!(
963            info.created_at,
964            Some(OffsetDateTime::parse(raw, &Rfc3339).expect("valid rfc3339"))
965        );
966    }
967
968    #[test]
969    fn serializes_inline_images_into_anthropic_content_blocks() {
970        let request = Request {
971            model: Cow::Borrowed("claude-sonnet"),
972            system: None,
973            messages: Cow::Owned(vec![Message {
974                role: Role::User,
975                content: vec![
976                    ContentBlock::text("Describe this"),
977                    ContentBlock::image_bytes("image/png", [1_u8, 2, 3]),
978                    ContentBlock::ToolResult {
979                        tool_use_id: "call_1".to_string(),
980                        content: ToolResultContent::text("ok"),
981                        is_error: false,
982                    },
983                ],
984            }]),
985            tools: Cow::Owned(vec![]),
986            tool_choice: Some(ToolChoice::Auto),
987            temperature: Some(0.1),
988            max_output_tokens: Some(512),
989            metadata: Cow::Owned(BTreeMap::new()),
990            provider_request_options: ProviderRequestOptions::default(),
991        };
992
993        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
994            .expect("request should serialize");
995
996        assert_eq!(payload["messages"][0]["role"], "user");
997        assert_eq!(payload["messages"][0]["content"][0]["type"], "text");
998        assert_eq!(
999            payload["messages"][0]["content"][0]["text"],
1000            "Describe this"
1001        );
1002        assert_eq!(payload["messages"][0]["content"][1]["type"], "image");
1003        assert_eq!(
1004            payload["messages"][0]["content"][1]["source"]["type"],
1005            "base64"
1006        );
1007        assert_eq!(
1008            payload["messages"][0]["content"][1]["source"]["media_type"],
1009            "image/png"
1010        );
1011        assert_eq!(
1012            payload["messages"][0]["content"][1]["source"]["data"],
1013            "AQID"
1014        );
1015        assert_eq!(payload["messages"][0]["content"][2]["type"], "tool_result");
1016        assert_eq!(payload["max_tokens"], 512);
1017        let temperature = payload["temperature"]
1018            .as_f64()
1019            .expect("temperature should be numeric");
1020        assert!((temperature - 0.1).abs() < 1e-6);
1021    }
1022
1023    #[test]
1024    fn rejects_invalid_base64_image_payloads() {
1025        let error = ImageSource::try_from(AnthropicImageSource::Base64 {
1026            media_type: "image/png".to_string(),
1027            data: "!not-base64!".to_string(),
1028        })
1029        .expect_err("invalid base64 should fail");
1030
1031        match error {
1032            ProviderError::InvalidResponse(message) => {
1033                assert!(message.contains("invalid Anthropic image payload"));
1034                assert!(message.contains("image/png"));
1035            }
1036            other => panic!("unexpected error: {other:?}"),
1037        }
1038    }
1039
1040    #[test]
1041    fn replays_signed_and_redacted_thinking_only_to_exact_provider_and_model() {
1042        let provider = ProviderId::new("anthropic-edge");
1043        let request = request_with_message(
1044            "claude-requested",
1045            Message {
1046                role: Role::Assistant,
1047                content: vec![
1048                    anthropic_thinking(
1049                        "private chain",
1050                        Some("opaque-signature"),
1051                        "anthropic-edge",
1052                        "claude-requested",
1053                        false,
1054                    ),
1055                    anthropic_thinking(
1056                        "",
1057                        Some("opaque-redacted-data"),
1058                        "anthropic-edge",
1059                        "claude-requested",
1060                        true,
1061                    ),
1062                ],
1063            },
1064        );
1065
1066        let payload = serde_json::to_value(
1067            AnthropicRequest::try_from_with_provider(request, &provider).unwrap(),
1068        )
1069        .unwrap();
1070
1071        assert_eq!(payload["messages"][0]["content"][0]["type"], "thinking");
1072        assert_eq!(
1073            payload["messages"][0]["content"][0]["thinking"],
1074            "private chain"
1075        );
1076        assert_eq!(
1077            payload["messages"][0]["content"][0]["signature"],
1078            "opaque-signature"
1079        );
1080        assert_eq!(
1081            payload["messages"][0]["content"][1]["type"],
1082            "redacted_thinking"
1083        );
1084        assert_eq!(
1085            payload["messages"][0]["content"][1]["data"],
1086            "opaque-redacted-data"
1087        );
1088    }
1089
1090    #[test]
1091    fn downgrades_unreplayable_thinking_to_nonempty_text() {
1092        let provider = ProviderId::new("anthropic-edge");
1093        let cases = [
1094            anthropic_thinking(
1095                "wrong provider",
1096                Some("signature"),
1097                "anthropic-other",
1098                "claude-requested",
1099                false,
1100            ),
1101            anthropic_thinking(
1102                "wrong model",
1103                Some("signature"),
1104                "anthropic-edge",
1105                "claude-other",
1106                false,
1107            ),
1108            anthropic_thinking(
1109                "missing signature",
1110                None,
1111                "anthropic-edge",
1112                "claude-requested",
1113                false,
1114            ),
1115            anthropic_thinking(
1116                "empty signature",
1117                Some(""),
1118                "anthropic-edge",
1119                "claude-requested",
1120                false,
1121            ),
1122            anthropic_thinking("", None, "anthropic-edge", "claude-requested", true),
1123        ];
1124        let request = request_with_message(
1125            "claude-requested",
1126            Message {
1127                role: Role::Assistant,
1128                content: cases.to_vec(),
1129            },
1130        );
1131
1132        let payload = serde_json::to_value(
1133            AnthropicRequest::try_from_with_provider(request, &provider).unwrap(),
1134        )
1135        .unwrap();
1136        let content = payload["messages"][0]["content"].as_array().unwrap();
1137
1138        assert_eq!(content.len(), cases.len());
1139        assert!(content.iter().all(|block| block["type"] == "text"));
1140        assert!(
1141            content
1142                .iter()
1143                .all(|block| { block["text"].as_str().is_some_and(|text| !text.is_empty()) })
1144        );
1145        assert_eq!(content[4]["text"], "[redacted reasoning]");
1146    }
1147
1148    #[test]
1149    fn downgrades_user_role_thinking_even_with_matching_provenance() {
1150        let provider = ProviderId::new("anthropic-edge");
1151        let request = request_with_message(
1152            "claude-requested",
1153            Message::user(anthropic_thinking(
1154                "private chain",
1155                Some("opaque-signature"),
1156                "anthropic-edge",
1157                "claude-requested",
1158                false,
1159            )),
1160        );
1161
1162        let payload = serde_json::to_value(
1163            AnthropicRequest::try_from_with_provider(request, &provider).unwrap(),
1164        )
1165        .unwrap();
1166
1167        assert_eq!(payload["messages"][0]["content"][0]["type"], "text");
1168        assert_eq!(
1169            payload["messages"][0]["content"][0]["text"],
1170            "private chain"
1171        );
1172    }
1173
1174    #[test]
1175    fn non_stream_response_captures_thinking_and_redacted_data() {
1176        let response = AnthropicResponse {
1177            id: "msg-1".to_string(),
1178            model: "claude-resolved".to_string(),
1179            role: "assistant".to_string(),
1180            usage: None,
1181            content: vec![
1182                AnthropicContentBlock::Thinking {
1183                    thinking: "private chain".to_string(),
1184                    signature: "opaque-signature".to_string(),
1185                },
1186                AnthropicContentBlock::RedactedThinking {
1187                    data: "opaque-redacted-data".to_string(),
1188                },
1189            ],
1190            stop_reason: Some("end_turn".to_string()),
1191        };
1192
1193        let converted = response
1194            .try_into_response_with_provider(&ProviderId::new("anthropic-edge"), "claude-requested")
1195            .unwrap();
1196
1197        assert_eq!(
1198            converted.content,
1199            vec![
1200                anthropic_thinking(
1201                    "private chain",
1202                    Some("opaque-signature"),
1203                    "anthropic-edge",
1204                    "claude-requested",
1205                    false,
1206                ),
1207                anthropic_thinking(
1208                    "",
1209                    Some("opaque-redacted-data"),
1210                    "anthropic-edge",
1211                    "claude-requested",
1212                    true,
1213                ),
1214            ]
1215        );
1216    }
1217
1218    #[test]
1219    fn serializes_disable_parallel_tool_use_option() {
1220        let request = Request {
1221            model: Cow::Borrowed("claude-sonnet"),
1222            system: None,
1223            messages: Cow::Owned(vec![]),
1224            tools: Cow::Owned(vec![]),
1225            tool_choice: Some(ToolChoice::Auto),
1226            temperature: None,
1227            max_output_tokens: None,
1228            metadata: Cow::Owned(BTreeMap::new()),
1229            provider_request_options: ProviderRequestOptions {
1230                tool_search_mode: ToolSearchMode::Disabled,
1231                reasoning: None,
1232                responses: Default::default(),
1233                anthropic: AnthropicRequestOptions {
1234                    disable_parallel_tool_use: Some(true),
1235                },
1236                gemini: Default::default(),
1237                session: Default::default(),
1238            },
1239        };
1240
1241        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
1242            .expect("request should serialize");
1243
1244        assert_eq!(payload["disable_parallel_tool_use"], true);
1245    }
1246
1247    #[test]
1248    fn nests_reasoning_effort_under_output_config_with_adaptive_thinking() {
1249        let request = request_with_effort("claude-sonnet-4-6", ReasoningEffort::Medium);
1250
1251        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
1252            .expect("request should serialize");
1253
1254        assert_eq!(payload["thinking"]["type"], "adaptive");
1255        assert_eq!(payload["output_config"]["effort"], "medium");
1256        assert!(payload.get("effort").is_none());
1257    }
1258
1259    #[test]
1260    fn opus_4_5_serializes_effort_without_adaptive_thinking() {
1261        let request = request_with_effort("claude-opus-4-5-20251101", ReasoningEffort::Medium);
1262
1263        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
1264            .expect("request should serialize");
1265
1266        assert_eq!(payload["output_config"]["effort"], "medium");
1267        assert!(payload.get("thinking").is_none());
1268        assert!(payload.get("effort").is_none());
1269    }
1270
1271    #[test]
1272    fn mythos_preview_supports_max_with_adaptive_thinking() {
1273        let request = request_with_effort("claude-mythos-preview", ReasoningEffort::Max);
1274
1275        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
1276            .expect("request should serialize");
1277
1278        assert_eq!(payload["output_config"]["effort"], "max");
1279        assert_eq!(payload["thinking"]["type"], "adaptive");
1280        assert!(payload.get("effort").is_none());
1281    }
1282
1283    #[test]
1284    fn omits_anthropic_reasoning_fields_without_an_effort() {
1285        let request = request_with_message(
1286            "claude-sonnet-4-6",
1287            Message::user(ContentBlock::text("hello")),
1288        );
1289
1290        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
1291            .expect("request should serialize");
1292
1293        assert!(payload.get("thinking").is_none());
1294        assert!(payload.get("output_config").is_none());
1295        assert!(payload.get("effort").is_none());
1296    }
1297
1298    #[test]
1299    fn serializes_all_anthropic_effort_tiers_exactly() {
1300        let cases = [
1301            (ReasoningEffort::Low, "low", "claude-opus-4-5"),
1302            (ReasoningEffort::Medium, "medium", "claude-opus-4-5"),
1303            (ReasoningEffort::High, "high", "claude-opus-4-5"),
1304            (ReasoningEffort::XHigh, "xhigh", "claude-opus-5"),
1305            (ReasoningEffort::Max, "max", "claude-sonnet-4-6"),
1306        ];
1307
1308        for (effort, expected, model) in cases {
1309            let request = request_with_effort(model, effort);
1310            let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
1311                .expect("request should serialize");
1312
1313            assert_eq!(payload["output_config"]["effort"], expected);
1314            assert!(payload.get("effort").is_none());
1315        }
1316    }
1317
1318    #[test]
1319    fn supports_xhigh_on_documented_anthropic_models() {
1320        for model in [
1321            "claude-opus-4-7",
1322            "claude-opus-4-8",
1323            "claude-opus-5",
1324            "claude-sonnet-5",
1325            "claude-fable-5",
1326            "claude-mythos-5",
1327        ] {
1328            let request = request_with_effort(model, ReasoningEffort::XHigh);
1329            let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
1330                .expect("request should serialize");
1331
1332            assert_eq!(payload["output_config"]["effort"], "xhigh");
1333        }
1334    }
1335
1336    #[test]
1337    fn rejects_xhigh_for_claude_4_6() {
1338        let request = request_with_effort("claude-opus-4-6", ReasoningEffort::XHigh);
1339
1340        let error = AnthropicRequest::try_from(request)
1341            .err()
1342            .expect("request should fail");
1343        match error {
1344            ProviderError::InvalidRequest(message) => {
1345                assert!(message.contains("xhigh"));
1346                assert!(message.contains("claude-opus-4-6"));
1347            }
1348            other => panic!("unexpected error: {other:?}"),
1349        }
1350    }
1351
1352    #[test]
1353    fn rejects_max_and_xhigh_for_opus_4_5() {
1354        for effort in [ReasoningEffort::Max, ReasoningEffort::XHigh] {
1355            let request = request_with_effort("claude-opus-4-5", effort);
1356
1357            let error = AnthropicRequest::try_from(request)
1358                .err()
1359                .expect("request should fail");
1360            match error {
1361                ProviderError::InvalidRequest(message) => {
1362                    assert!(message.contains("not supported"));
1363                    assert!(message.contains("claude-opus-4-5"));
1364                }
1365                other => panic!("unexpected error: {other:?}"),
1366            }
1367        }
1368    }
1369
1370    #[test]
1371    fn rejects_reasoning_effort_for_unsupported_anthropic_models() {
1372        let request = request_with_effort("claude-sonnet-4-5", ReasoningEffort::Low);
1373
1374        let error = AnthropicRequest::try_from(request)
1375            .err()
1376            .expect("request should fail");
1377        match error {
1378            ProviderError::InvalidRequest(message) => {
1379                assert!(message.contains("not supported"));
1380                assert!(message.contains("claude-sonnet-4-5"));
1381            }
1382            other => panic!("unexpected error: {other:?}"),
1383        }
1384    }
1385
1386    #[test]
1387    fn rejects_reasoning_effort_for_unknown_anthropic_models() {
1388        let request = request_with_effort("claude-opus-6", ReasoningEffort::Low);
1389
1390        let error = AnthropicRequest::try_from(request)
1391            .err()
1392            .expect("request should fail");
1393        match error {
1394            ProviderError::InvalidRequest(message) => {
1395                assert!(message.contains("not supported"));
1396                assert!(message.contains("claude-opus-6"));
1397            }
1398            other => panic!("unexpected error: {other:?}"),
1399        }
1400    }
1401
1402    #[test]
1403    fn hosted_tool_search_adds_search_tool_for_deferred_tools() {
1404        let request = Request {
1405            model: Cow::Borrowed("claude-sonnet"),
1406            system: None,
1407            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hello"))]),
1408            tools: Cow::Owned(vec![ToolSpec {
1409                name: "lookup_order".to_string(),
1410                description: Some("Look up an order".to_string()),
1411                input_schema: serde_json::json!({"type":"object"}),
1412                output_schema: None,
1413                kind: crate::ProviderToolKind::Function,
1414                loading_policy: ToolLoadingPolicy::Deferred,
1415                strict: None,
1416                options: None,
1417            }]),
1418            tool_choice: Some(ToolChoice::Auto),
1419            temperature: None,
1420            max_output_tokens: None,
1421            metadata: Cow::Owned(BTreeMap::new()),
1422            provider_request_options: ProviderRequestOptions {
1423                tool_search_mode: ToolSearchMode::Hosted,
1424                ..Default::default()
1425            },
1426        };
1427
1428        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
1429            .expect("request should serialize");
1430
1431        assert_eq!(payload["tools"][0]["name"], "lookup_order");
1432        assert_eq!(payload["tools"][0]["defer_loading"], true);
1433        assert_eq!(
1434            payload["tools"][1]["type"],
1435            "tool_search_tool_bm25_20251119"
1436        );
1437        assert_eq!(payload["tools"][1]["name"], "tool_search_tool_bm25");
1438    }
1439
1440    #[test]
1441    fn rejects_deferred_tools_without_hosted_tool_search() {
1442        let request = Request {
1443            model: Cow::Borrowed("claude-sonnet"),
1444            system: None,
1445            messages: Cow::Owned(vec![]),
1446            tools: Cow::Owned(vec![ToolSpec {
1447                name: "lookup_order".to_string(),
1448                description: None,
1449                input_schema: serde_json::json!({"type":"object"}),
1450                output_schema: None,
1451                kind: crate::ProviderToolKind::Function,
1452                loading_policy: ToolLoadingPolicy::Deferred,
1453                strict: None,
1454                options: None,
1455            }]),
1456            tool_choice: Some(ToolChoice::Auto),
1457            temperature: None,
1458            max_output_tokens: None,
1459            metadata: Cow::Owned(BTreeMap::new()),
1460            provider_request_options: ProviderRequestOptions::default(),
1461        };
1462
1463        let error = AnthropicRequest::try_from(request)
1464            .err()
1465            .expect("request should fail");
1466        match error {
1467            ProviderError::InvalidRequest(message) => {
1468                assert!(message.contains("deferred tools require hosted tool search"));
1469            }
1470            other => panic!("unexpected error: {other:?}"),
1471        }
1472    }
1473
1474    #[test]
1475    fn forced_deferred_tool_serializes_as_immediate() {
1476        let request = Request {
1477            model: Cow::Borrowed("claude-sonnet"),
1478            system: None,
1479            messages: Cow::Owned(vec![]),
1480            tools: Cow::Owned(vec![ToolSpec {
1481                name: "lookup_order".to_string(),
1482                description: Some("Look up an order".to_string()),
1483                input_schema: serde_json::json!({"type":"object"}),
1484                output_schema: None,
1485                kind: crate::ProviderToolKind::Function,
1486                loading_policy: ToolLoadingPolicy::Deferred,
1487                strict: None,
1488                options: None,
1489            }]),
1490            tool_choice: Some(ToolChoice::Tool {
1491                name: "lookup_order".to_string(),
1492            }),
1493            temperature: None,
1494            max_output_tokens: None,
1495            metadata: Cow::Owned(BTreeMap::new()),
1496            provider_request_options: ProviderRequestOptions::default(),
1497        };
1498
1499        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
1500            .expect("request should serialize");
1501
1502        assert_eq!(payload["tools"][0]["name"], "lookup_order");
1503        assert!(payload["tools"][0].get("defer_loading").is_none());
1504        assert!(payload["tools"].get(1).is_none());
1505        assert_eq!(payload["tool_choice"]["name"], "lookup_order");
1506    }
1507}