Skip to main content

mentra_provider/anthropic/
model.rs

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/// A system prompt block with optional cache control.
66#[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/// Cache control marker for Anthropic prompt caching.
76#[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
88/// Build system blocks from a system prompt string, with cache_control on
89/// the final block to enable prompt caching.
90fn 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}