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}