Skip to main content

mentra_provider/anthropic/
model.rs

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