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