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