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,
8    ProviderToolKind, ReasoningEffort, Request, Response, Role, TokenUsage, ToolChoice,
9    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        }
40    }
41}
42
43#[derive(Serialize)]
44pub(crate) struct AnthropicRequest {
45    model: String,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    system: Option<Vec<AnthropicSystemBlock>>,
48    messages: Vec<AnthropicMessage>,
49    #[serde(skip_serializing_if = "Vec::is_empty")]
50    tools: Vec<AnthropicTool>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    tool_choice: Option<AnthropicToolChoice>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    temperature: Option<f32>,
55    #[serde(rename = "max_tokens", skip_serializing_if = "Option::is_none")]
56    max_output_tokens: Option<u32>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    disable_parallel_tool_use: Option<bool>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    thinking: Option<AnthropicThinkingConfig>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    effort: Option<AnthropicReasoningEffort>,
63}
64
65/// A system prompt block with optional cache control.
66#[derive(Serialize)]
67pub(crate) struct AnthropicSystemBlock {
68    #[serde(rename = "type")]
69    kind: &'static str,
70    text: String,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    cache_control: Option<AnthropicCacheControl>,
73}
74
75/// Cache control marker for Anthropic prompt caching.
76#[derive(Serialize)]
77pub(crate) struct AnthropicCacheControl {
78    #[serde(rename = "type")]
79    kind: &'static str,
80}
81
82impl AnthropicCacheControl {
83    fn ephemeral() -> Self {
84        Self { kind: "ephemeral" }
85    }
86}
87
88/// Build system blocks from a system prompt string, with cache_control on
89/// the final block to enable prompt caching.
90fn build_system_blocks(system: String) -> Vec<AnthropicSystemBlock> {
91    vec![AnthropicSystemBlock {
92        kind: "text",
93        text: system,
94        cache_control: Some(AnthropicCacheControl::ephemeral()),
95    }]
96}
97
98#[derive(Deserialize)]
99pub(crate) struct AnthropicResponse {
100    pub(crate) id: String,
101    pub(crate) model: String,
102    pub(crate) role: String,
103    #[serde(default)]
104    pub(crate) usage: Option<AnthropicUsage>,
105    content: Vec<AnthropicContentBlock>,
106    stop_reason: Option<String>,
107}
108
109impl TryFrom<AnthropicResponse> for Response {
110    type Error = ProviderError;
111
112    fn try_from(response: AnthropicResponse) -> Result<Self, Self::Error> {
113        Ok(Response {
114            id: response.id,
115            model: response.model,
116            role: match response.role.as_str() {
117                "user" => Role::User,
118                "assistant" => Role::Assistant,
119                _ => Role::Unknown(response.role),
120            },
121            content: response
122                .content
123                .into_iter()
124                .map(ContentBlock::try_from)
125                .collect::<Result<Vec<_>, _>>()?,
126            stop_reason: response.stop_reason,
127            usage: response.usage.and_then(|usage| usage.into_token_usage()),
128        })
129    }
130}
131
132#[derive(Debug, Clone, Deserialize)]
133pub(crate) struct AnthropicUsage {
134    #[serde(default)]
135    pub(crate) input_tokens: Option<u64>,
136    #[serde(default)]
137    pub(crate) output_tokens: Option<u64>,
138    #[serde(default)]
139    pub(crate) cache_read_input_tokens: Option<u64>,
140    #[serde(default)]
141    pub(crate) cache_creation_input_tokens: Option<u64>,
142    #[serde(default)]
143    pub(crate) total_tokens: Option<u64>,
144}
145
146impl AnthropicUsage {
147    pub(crate) fn into_token_usage(self) -> Option<TokenUsage> {
148        let usage = TokenUsage {
149            input_tokens: self.input_tokens,
150            output_tokens: self.output_tokens,
151            total_tokens: self.total_tokens,
152            cache_read_input_tokens: self.cache_read_input_tokens,
153            cache_creation_input_tokens: self.cache_creation_input_tokens,
154            reasoning_tokens: None,
155            thoughts_tokens: None,
156            tool_input_tokens: None,
157        };
158
159        (!usage.is_empty()).then_some(usage)
160    }
161}
162
163impl<'a> TryFrom<Request<'a>> for AnthropicRequest {
164    type Error = ProviderError;
165
166    fn try_from(value: Request<'a>) -> Result<Self, Self::Error> {
167        if value
168            .provider_request_options
169            .reasoning
170            .as_ref()
171            .and_then(|reasoning| reasoning.effort)
172            .is_some()
173            && !supports_anthropic_adaptive_thinking(&value.model)
174        {
175            return Err(ProviderError::InvalidRequest(format!(
176                "Anthropic reasoning effort requires a Claude 4.6 model, got '{}'",
177                value.model
178            )));
179        }
180
181        Ok(AnthropicRequest {
182            model: value.model.into_owned(),
183            system: value.system.map(|s| build_system_blocks(s.into_owned())),
184            messages: value
185                .messages
186                .iter()
187                .map(AnthropicMessage::try_from)
188                .collect::<Result<Vec<_>, _>>()?,
189            tools: build_anthropic_tools(
190                value.tools.as_ref(),
191                value.tool_choice.as_ref(),
192                value.provider_request_options.tool_search_mode,
193            )?,
194            tool_choice: value.tool_choice.map(AnthropicToolChoice::from),
195            temperature: value.temperature,
196            max_output_tokens: value.max_output_tokens,
197            disable_parallel_tool_use: value
198                .provider_request_options
199                .anthropic
200                .disable_parallel_tool_use,
201            thinking: value
202                .provider_request_options
203                .reasoning
204                .as_ref()
205                .filter(|reasoning| reasoning.effort.is_some())
206                .map(|_| AnthropicThinkingConfig::adaptive()),
207            effort: value
208                .provider_request_options
209                .reasoning
210                .and_then(|reasoning| reasoning.effort.map(Into::into)),
211        })
212    }
213}
214
215#[derive(Serialize)]
216struct AnthropicThinkingConfig {
217    #[serde(rename = "type")]
218    kind: &'static str,
219}
220
221impl AnthropicThinkingConfig {
222    fn adaptive() -> Self {
223        Self { kind: "adaptive" }
224    }
225}
226
227#[derive(Serialize)]
228#[serde(rename_all = "snake_case")]
229enum AnthropicReasoningEffort {
230    Low,
231    Medium,
232    High,
233}
234
235impl From<ReasoningEffort> for AnthropicReasoningEffort {
236    fn from(value: ReasoningEffort) -> Self {
237        match value {
238            ReasoningEffort::Low => Self::Low,
239            ReasoningEffort::Medium => Self::Medium,
240            ReasoningEffort::High => Self::High,
241        }
242    }
243}
244
245fn supports_anthropic_adaptive_thinking(model: &str) -> bool {
246    let model = model.strip_prefix("models/").unwrap_or(model);
247    model.contains("claude-opus-4-6") || model.contains("claude-sonnet-4-6")
248}
249
250#[derive(Serialize)]
251struct AnthropicMessage {
252    role: String,
253    content: Vec<AnthropicContentBlock>,
254}
255
256impl TryFrom<Message> for AnthropicMessage {
257    type Error = ProviderError;
258
259    fn try_from(message: Message) -> Result<Self, Self::Error> {
260        AnthropicMessage::try_from(&message)
261    }
262}
263
264impl TryFrom<&Message> for AnthropicMessage {
265    type Error = ProviderError;
266
267    fn try_from(message: &Message) -> Result<Self, Self::Error> {
268        if !matches!(message.role, Role::User) && message_has_image(message) {
269            return Err(ProviderError::InvalidRequest(
270                "Anthropic image inputs are only supported in user messages".to_string(),
271            ));
272        }
273
274        Ok(AnthropicMessage {
275            role: message.role.to_string(),
276            content: message.content.iter().map(|block| block.into()).collect(),
277        })
278    }
279}
280
281#[derive(Serialize, Deserialize)]
282#[serde(tag = "type", rename_all = "snake_case")]
283enum AnthropicContentBlock {
284    Text {
285        text: String,
286    },
287    Image {
288        source: AnthropicImageSource,
289    },
290    ToolUse {
291        id: String,
292        name: String,
293        input: Value,
294    },
295    ToolResult {
296        tool_use_id: String,
297        content: String,
298        is_error: bool,
299    },
300}
301
302#[derive(Serialize, Deserialize)]
303#[serde(tag = "type", rename_all = "snake_case")]
304enum AnthropicImageSource {
305    Base64 { media_type: String, data: String },
306    Url { url: String },
307}
308
309impl From<ContentBlock> for AnthropicContentBlock {
310    fn from(block: ContentBlock) -> Self {
311        AnthropicContentBlock::from(&block)
312    }
313}
314
315impl From<&ContentBlock> for AnthropicContentBlock {
316    fn from(block: &ContentBlock) -> Self {
317        match block {
318            ContentBlock::Text { text } => AnthropicContentBlock::Text { text: text.clone() },
319            ContentBlock::Image { source } => AnthropicContentBlock::Image {
320                source: source.into(),
321            },
322            ContentBlock::ToolUse { id, name, input } => AnthropicContentBlock::ToolUse {
323                id: id.clone(),
324                name: name.clone(),
325                input: input.clone(),
326            },
327            ContentBlock::ToolResult {
328                tool_use_id,
329                content,
330                is_error,
331            } => AnthropicContentBlock::ToolResult {
332                tool_use_id: tool_use_id.clone(),
333                content: content.to_display_string(),
334                is_error: *is_error,
335            },
336            ContentBlock::HostedToolSearch { call } => AnthropicContentBlock::ToolUse {
337                id: call.id.clone(),
338                name: "tool_search".to_string(),
339                input: serde_json::json!({ "query": call.query }),
340            },
341            ContentBlock::HostedWebSearch { call } => AnthropicContentBlock::ToolUse {
342                id: call.id.clone(),
343                name: "web_search".to_string(),
344                input: serde_json::to_value(call.action.clone()).unwrap_or(serde_json::Value::Null),
345            },
346            ContentBlock::ImageGeneration { call } => AnthropicContentBlock::ToolUse {
347                id: call.id.clone(),
348                name: "image_generation".to_string(),
349                input: serde_json::json!({
350                    "status": call.status,
351                    "revised_prompt": call.revised_prompt,
352                }),
353            },
354        }
355    }
356}
357
358impl TryFrom<AnthropicContentBlock> for ContentBlock {
359    type Error = ProviderError;
360
361    fn try_from(block: AnthropicContentBlock) -> Result<Self, Self::Error> {
362        Ok(match block {
363            AnthropicContentBlock::Text { text } => ContentBlock::Text { text },
364            AnthropicContentBlock::Image { source } => ContentBlock::Image {
365                source: source.try_into()?,
366            },
367            AnthropicContentBlock::ToolUse { id, name, input } => {
368                ContentBlock::ToolUse { id, name, input }
369            }
370            AnthropicContentBlock::ToolResult {
371                tool_use_id,
372                content,
373                is_error,
374            } => ContentBlock::ToolResult {
375                tool_use_id,
376                content: ToolResultContent::Text(content),
377                is_error,
378            },
379        })
380    }
381}
382
383impl From<&ImageSource> for AnthropicImageSource {
384    fn from(value: &ImageSource) -> Self {
385        match value {
386            ImageSource::Bytes { media_type, data } => AnthropicImageSource::Base64 {
387                media_type: media_type.clone(),
388                data: STANDARD.encode(data),
389            },
390            ImageSource::Url { url } => AnthropicImageSource::Url { url: url.clone() },
391        }
392    }
393}
394
395impl From<ImageSource> for AnthropicImageSource {
396    fn from(value: ImageSource) -> Self {
397        AnthropicImageSource::from(&value)
398    }
399}
400
401impl TryFrom<AnthropicImageSource> for ImageSource {
402    type Error = ProviderError;
403
404    fn try_from(value: AnthropicImageSource) -> Result<Self, Self::Error> {
405        match value {
406            AnthropicImageSource::Base64 { media_type, data } => {
407                let data = STANDARD.decode(data).map_err(|error| {
408                    ProviderError::InvalidResponse(format!(
409                        "invalid Anthropic image payload for media type {media_type}: {error}"
410                    ))
411                })?;
412                Ok(ImageSource::Bytes { media_type, data })
413            }
414            AnthropicImageSource::Url { url } => Ok(ImageSource::Url { url }),
415        }
416    }
417}
418
419#[derive(Serialize)]
420#[serde(untagged)]
421enum AnthropicTool {
422    Custom(AnthropicCustomTool),
423    HostedSearch(AnthropicHostedSearchTool),
424}
425
426#[derive(Serialize)]
427struct AnthropicCustomTool {
428    name: String,
429    #[serde(skip_serializing_if = "Option::is_none")]
430    description: Option<String>,
431    input_schema: Value,
432    #[serde(skip_serializing_if = "std::ops::Not::not")]
433    defer_loading: bool,
434    #[serde(skip_serializing_if = "Option::is_none")]
435    cache_control: Option<AnthropicCacheControl>,
436}
437
438#[derive(Serialize)]
439struct AnthropicHostedSearchTool {
440    #[serde(rename = "type")]
441    kind: &'static str,
442    name: &'static str,
443}
444
445impl AnthropicTool {
446    fn custom(tool: &ToolSpec, force_immediate: bool, is_last: bool) -> Self {
447        Self::Custom(AnthropicCustomTool {
448            name: tool.name.clone(),
449            description: tool.description.clone(),
450            input_schema: tool.input_schema.clone(),
451            defer_loading: tool.loading_policy == ToolLoadingPolicy::Deferred && !force_immediate,
452            cache_control: if is_last {
453                Some(AnthropicCacheControl::ephemeral())
454            } else {
455                None
456            },
457        })
458    }
459
460    fn hosted_search() -> Self {
461        Self::HostedSearch(AnthropicHostedSearchTool {
462            kind: "tool_search_tool_bm25_20251119",
463            name: "tool_search_tool_bm25",
464        })
465    }
466}
467
468fn build_anthropic_tools(
469    tools: &[ToolSpec],
470    tool_choice: Option<&ToolChoice>,
471    tool_search_mode: ToolSearchMode,
472) -> Result<Vec<AnthropicTool>, ProviderError> {
473    if let Some(tool) = tools
474        .iter()
475        .find(|tool| tool.kind != ProviderToolKind::Function)
476    {
477        return Err(ProviderError::InvalidRequest(format!(
478            "Anthropic does not support provider tool kind {:?} for '{}'",
479            tool.kind, tool.name
480        )));
481    }
482
483    let forced_tool_name = match tool_choice {
484        Some(ToolChoice::Tool { name }) => Some(name.as_str()),
485        _ => None,
486    };
487
488    let has_deferred_tools = tools.iter().any(|tool| {
489        tool.loading_policy == ToolLoadingPolicy::Deferred
490            && forced_tool_name != Some(tool.name.as_str())
491    });
492
493    if has_deferred_tools && tool_search_mode != ToolSearchMode::Hosted {
494        return Err(ProviderError::InvalidRequest(
495            "Anthropic deferred tools require hosted tool search".to_string(),
496        ));
497    }
498
499    let tool_count = tools.len();
500    let mut provider_tools = tools
501        .iter()
502        .enumerate()
503        .map(|(i, tool)| {
504            let is_last = i == tool_count - 1 && !has_deferred_tools;
505            AnthropicTool::custom(tool, forced_tool_name == Some(tool.name.as_str()), is_last)
506        })
507        .collect::<Vec<_>>();
508
509    if has_deferred_tools {
510        provider_tools.push(AnthropicTool::hosted_search());
511    }
512
513    Ok(provider_tools)
514}
515
516#[derive(Serialize)]
517#[serde(tag = "type", rename_all = "snake_case")]
518pub(crate) enum AnthropicToolChoice {
519    Auto,
520    Any,
521    Tool { name: String },
522}
523
524impl From<ToolChoice> for AnthropicToolChoice {
525    fn from(choice: ToolChoice) -> Self {
526        match choice {
527            ToolChoice::Auto => AnthropicToolChoice::Auto,
528            ToolChoice::Any => AnthropicToolChoice::Any,
529            ToolChoice::Tool { name } => AnthropicToolChoice::Tool { name },
530        }
531    }
532}
533
534fn message_has_image(message: &Message) -> bool {
535    message
536        .content
537        .iter()
538        .any(|block| matches!(block, ContentBlock::Image { .. }))
539}
540
541#[cfg(test)]
542mod tests {
543    use std::{borrow::Cow, collections::BTreeMap};
544
545    use time::{OffsetDateTime, format_description::well_known::Rfc3339};
546
547    use crate::{
548        AnthropicRequestOptions, ContentBlock, Message, ModelInfo, ProviderError,
549        ProviderRequestOptions, ReasoningEffort, ReasoningOptions, Request, Role, ToolChoice,
550        ToolLoadingPolicy, ToolResultContent, ToolSearchMode, ToolSpec,
551    };
552
553    use super::{AnthropicContentBlock, AnthropicImageSource, AnthropicModel, AnthropicRequest};
554
555    #[test]
556    fn converts_rfc3339_timestamp_to_offset_datetime() {
557        let raw = "2025-03-04T12:34:56Z";
558        let model = AnthropicModel {
559            id: "claude-test".to_string(),
560            display_name: None,
561            created_at: Some(raw.to_string()),
562        };
563
564        let info = ModelInfo::from(model);
565
566        assert_eq!(
567            info.created_at,
568            Some(OffsetDateTime::parse(raw, &Rfc3339).expect("valid rfc3339"))
569        );
570    }
571
572    #[test]
573    fn serializes_inline_images_into_anthropic_content_blocks() {
574        let request = Request {
575            model: Cow::Borrowed("claude-sonnet"),
576            system: None,
577            messages: Cow::Owned(vec![Message {
578                role: Role::User,
579                content: vec![
580                    ContentBlock::text("Describe this"),
581                    ContentBlock::image_bytes("image/png", [1_u8, 2, 3]),
582                    ContentBlock::ToolResult {
583                        tool_use_id: "call_1".to_string(),
584                        content: ToolResultContent::text("ok"),
585                        is_error: false,
586                    },
587                ],
588            }]),
589            tools: Cow::Owned(vec![]),
590            tool_choice: Some(ToolChoice::Auto),
591            temperature: Some(0.1),
592            max_output_tokens: Some(512),
593            metadata: Cow::Owned(BTreeMap::new()),
594            provider_request_options: ProviderRequestOptions::default(),
595        };
596
597        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
598            .expect("request should serialize");
599
600        assert_eq!(payload["messages"][0]["role"], "user");
601        assert_eq!(payload["messages"][0]["content"][0]["type"], "text");
602        assert_eq!(
603            payload["messages"][0]["content"][0]["text"],
604            "Describe this"
605        );
606        assert_eq!(payload["messages"][0]["content"][1]["type"], "image");
607        assert_eq!(
608            payload["messages"][0]["content"][1]["source"]["type"],
609            "base64"
610        );
611        assert_eq!(
612            payload["messages"][0]["content"][1]["source"]["media_type"],
613            "image/png"
614        );
615        assert_eq!(
616            payload["messages"][0]["content"][1]["source"]["data"],
617            "AQID"
618        );
619        assert_eq!(payload["messages"][0]["content"][2]["type"], "tool_result");
620        assert_eq!(payload["max_tokens"], 512);
621        let temperature = payload["temperature"]
622            .as_f64()
623            .expect("temperature should be numeric");
624        assert!((temperature - 0.1).abs() < 1e-6);
625    }
626
627    #[test]
628    fn rejects_invalid_base64_image_payloads() {
629        let error = ContentBlock::try_from(AnthropicContentBlock::Image {
630            source: AnthropicImageSource::Base64 {
631                media_type: "image/png".to_string(),
632                data: "!not-base64!".to_string(),
633            },
634        })
635        .expect_err("invalid base64 should fail");
636
637        match error {
638            ProviderError::InvalidResponse(message) => {
639                assert!(message.contains("invalid Anthropic image payload"));
640                assert!(message.contains("image/png"));
641            }
642            other => panic!("unexpected error: {other:?}"),
643        }
644    }
645
646    #[test]
647    fn serializes_disable_parallel_tool_use_option() {
648        let request = Request {
649            model: Cow::Borrowed("claude-sonnet"),
650            system: None,
651            messages: Cow::Owned(vec![]),
652            tools: Cow::Owned(vec![]),
653            tool_choice: Some(ToolChoice::Auto),
654            temperature: None,
655            max_output_tokens: None,
656            metadata: Cow::Owned(BTreeMap::new()),
657            provider_request_options: ProviderRequestOptions {
658                tool_search_mode: ToolSearchMode::Disabled,
659                reasoning: None,
660                responses: Default::default(),
661                anthropic: AnthropicRequestOptions {
662                    disable_parallel_tool_use: Some(true),
663                },
664                gemini: Default::default(),
665                session: Default::default(),
666            },
667        };
668
669        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
670            .expect("request should serialize");
671
672        assert_eq!(payload["disable_parallel_tool_use"], true);
673    }
674
675    #[test]
676    fn serializes_reasoning_effort_as_adaptive_thinking() {
677        let request = Request {
678            model: Cow::Borrowed("claude-sonnet-4-6"),
679            system: None,
680            messages: Cow::Owned(vec![]),
681            tools: Cow::Owned(vec![]),
682            tool_choice: Some(ToolChoice::Auto),
683            temperature: None,
684            max_output_tokens: Some(512),
685            metadata: Cow::Owned(BTreeMap::new()),
686            provider_request_options: ProviderRequestOptions {
687                reasoning: Some(ReasoningOptions {
688                    effort: Some(ReasoningEffort::Medium),
689                    summary: None,
690                }),
691                ..Default::default()
692            },
693        };
694
695        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
696            .expect("request should serialize");
697
698        assert_eq!(payload["thinking"]["type"], "adaptive");
699        assert_eq!(payload["effort"], "medium");
700    }
701
702    #[test]
703    fn rejects_reasoning_effort_for_older_anthropic_models() {
704        let request = Request {
705            model: Cow::Borrowed("claude-sonnet-4-5"),
706            system: None,
707            messages: Cow::Owned(vec![]),
708            tools: Cow::Owned(vec![]),
709            tool_choice: Some(ToolChoice::Auto),
710            temperature: None,
711            max_output_tokens: Some(512),
712            metadata: Cow::Owned(BTreeMap::new()),
713            provider_request_options: ProviderRequestOptions {
714                reasoning: Some(ReasoningOptions {
715                    effort: Some(ReasoningEffort::Low),
716                    summary: None,
717                }),
718                ..Default::default()
719            },
720        };
721
722        let error = AnthropicRequest::try_from(request)
723            .err()
724            .expect("request should fail");
725        match error {
726            ProviderError::InvalidRequest(message) => {
727                assert!(message.contains("Claude 4.6"));
728            }
729            other => panic!("unexpected error: {other:?}"),
730        }
731    }
732
733    #[test]
734    fn hosted_tool_search_adds_search_tool_for_deferred_tools() {
735        let request = Request {
736            model: Cow::Borrowed("claude-sonnet"),
737            system: None,
738            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hello"))]),
739            tools: Cow::Owned(vec![ToolSpec {
740                name: "lookup_order".to_string(),
741                description: Some("Look up an order".to_string()),
742                input_schema: serde_json::json!({"type":"object"}),
743                output_schema: None,
744                kind: crate::ProviderToolKind::Function,
745                loading_policy: ToolLoadingPolicy::Deferred,
746                strict: None,
747                options: None,
748            }]),
749            tool_choice: Some(ToolChoice::Auto),
750            temperature: None,
751            max_output_tokens: None,
752            metadata: Cow::Owned(BTreeMap::new()),
753            provider_request_options: ProviderRequestOptions {
754                tool_search_mode: ToolSearchMode::Hosted,
755                ..Default::default()
756            },
757        };
758
759        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
760            .expect("request should serialize");
761
762        assert_eq!(payload["tools"][0]["name"], "lookup_order");
763        assert_eq!(payload["tools"][0]["defer_loading"], true);
764        assert_eq!(
765            payload["tools"][1]["type"],
766            "tool_search_tool_bm25_20251119"
767        );
768        assert_eq!(payload["tools"][1]["name"], "tool_search_tool_bm25");
769    }
770
771    #[test]
772    fn rejects_deferred_tools_without_hosted_tool_search() {
773        let request = Request {
774            model: Cow::Borrowed("claude-sonnet"),
775            system: None,
776            messages: Cow::Owned(vec![]),
777            tools: Cow::Owned(vec![ToolSpec {
778                name: "lookup_order".to_string(),
779                description: None,
780                input_schema: serde_json::json!({"type":"object"}),
781                output_schema: None,
782                kind: crate::ProviderToolKind::Function,
783                loading_policy: ToolLoadingPolicy::Deferred,
784                strict: None,
785                options: None,
786            }]),
787            tool_choice: Some(ToolChoice::Auto),
788            temperature: None,
789            max_output_tokens: None,
790            metadata: Cow::Owned(BTreeMap::new()),
791            provider_request_options: ProviderRequestOptions::default(),
792        };
793
794        let error = AnthropicRequest::try_from(request)
795            .err()
796            .expect("request should fail");
797        match error {
798            ProviderError::InvalidRequest(message) => {
799                assert!(message.contains("deferred tools require hosted tool search"));
800            }
801            other => panic!("unexpected error: {other:?}"),
802        }
803    }
804
805    #[test]
806    fn forced_deferred_tool_serializes_as_immediate() {
807        let request = Request {
808            model: Cow::Borrowed("claude-sonnet"),
809            system: None,
810            messages: Cow::Owned(vec![]),
811            tools: Cow::Owned(vec![ToolSpec {
812                name: "lookup_order".to_string(),
813                description: Some("Look up an order".to_string()),
814                input_schema: serde_json::json!({"type":"object"}),
815                output_schema: None,
816                kind: crate::ProviderToolKind::Function,
817                loading_policy: ToolLoadingPolicy::Deferred,
818                strict: None,
819                options: None,
820            }]),
821            tool_choice: Some(ToolChoice::Tool {
822                name: "lookup_order".to_string(),
823            }),
824            temperature: None,
825            max_output_tokens: None,
826            metadata: Cow::Owned(BTreeMap::new()),
827            provider_request_options: ProviderRequestOptions::default(),
828        };
829
830        let payload = serde_json::to_value(AnthropicRequest::try_from(request).unwrap())
831            .expect("request should serialize");
832
833        assert_eq!(payload["tools"][0]["name"], "lookup_order");
834        assert!(payload["tools"][0].get("defer_loading").is_none());
835        assert!(payload["tools"].get(1).is_none());
836        assert_eq!(payload["tool_choice"]["name"], "lookup_order");
837    }
838}