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#[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#[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
88fn 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}