Skip to main content

vtcode_llm/providers/anthropic/
compat.rs

1use crate::error_display;
2use crate::provider::{
3    AnthropicOptionalStringOverride, AnthropicOptionalU32Override, AnthropicRequestOverrides,
4    AnthropicThinkingDisplayOverride, AnthropicThinkingModeOverride, ContentPart, FinishReason, LLMError, LLMRequest,
5    LLMResponse, Message, MessageContent, MessageRole, ParallelToolConfig, ToolCall, ToolChoice, ToolDefinition,
6};
7use crate::providers::anthropic_types::{
8    AnthropicOutputConfig, AnthropicOutputFormat, ThinkingConfig, ThinkingDisplay,
9};
10use crate::providers::common::normalize_reasoning_detail_object;
11use serde::ser::SerializeMap;
12use serde::{Deserialize, Serialize, Serializer};
13use serde_json::{Value, json};
14use std::sync::Arc;
15
16/// Anthropic Messages API request.
17#[derive(Debug, Deserialize, Clone)]
18pub struct AnthropicMessagesRequest {
19    pub(crate) model: String,
20    pub(crate) max_tokens: u32,
21    pub(crate) messages: Vec<AnthropicMessage>,
22    #[serde(default)]
23    pub(crate) system: Option<AnthropicSystemPrompt>,
24    #[serde(default)]
25    pub(crate) stream: bool,
26    #[serde(default)]
27    pub(crate) stop_sequences: Option<Vec<String>>,
28    /// Sampling parameters. Removed by API version `2026-10-21`: a request
29    /// that sends them is rejected, so they are accepted for parsing (a local
30    /// Anthropic-compatible gateway may still forward them) but never
31    /// forwarded to the upstream request.
32    #[serde(default)]
33    pub(crate) temperature: Option<f32>,
34    #[serde(default)]
35    pub(crate) top_p: Option<f32>,
36    #[serde(default)]
37    pub(crate) top_k: Option<i32>,
38    #[serde(default)]
39    pub(crate) tools: Option<Vec<AnthropicTool>>,
40    #[serde(default)]
41    pub(crate) tool_choice: Option<Value>,
42    #[serde(default)]
43    pub(crate) thinking: Option<ThinkingConfig>,
44    #[serde(default)]
45    pub(crate) betas: Option<Vec<String>>,
46    #[serde(default)]
47    pub(crate) context_management: Option<Value>,
48    #[serde(default)]
49    pub(crate) output_config: Option<AnthropicOutputConfig>,
50}
51
52#[derive(Debug, Deserialize, Serialize, Clone)]
53pub struct AnthropicMessage {
54    pub(crate) role: String,
55    pub(crate) content: AnthropicContent,
56}
57
58#[derive(Debug, Deserialize, Serialize, Clone)]
59#[serde(untagged)]
60pub enum AnthropicContent {
61    Text(String),
62    Blocks(Vec<AnthropicContentBlock>),
63}
64
65#[derive(Debug, Deserialize, Serialize, Clone)]
66#[serde(tag = "type")]
67pub enum AnthropicContentBlock {
68    #[serde(rename = "text")]
69    Text {
70        text: String,
71        #[serde(default)]
72        citations: Option<Value>,
73        #[serde(default)]
74        cache_control: Option<Value>,
75    },
76    #[serde(rename = "image")]
77    Image { source: AnthropicImageSource },
78    #[serde(rename = "tool_use")]
79    ToolUse { id: String, name: String, input: Value },
80    /// Anthropic only accepts `document` blocks in `user` messages under API
81    /// version `2026-10-21`; an assistant block is a 400. The block is
82    /// rejected during conversion rather than dropped, so a caller cannot
83    /// silently lose an attachment.
84    #[serde(rename = "document")]
85    Document { source: Value },
86    #[serde(rename = "tool_result")]
87    ToolResult {
88        tool_use_id: String,
89        content: AnthropicContent,
90        is_error: Option<bool>,
91    },
92    #[serde(rename = "thinking")]
93    Thinking {
94        thinking: String,
95        #[serde(default)]
96        signature: Option<String>,
97    },
98    #[serde(rename = "redacted_thinking")]
99    RedactedThinking { data: String },
100    #[serde(rename = "server_tool_use")]
101    ServerToolUse { id: String, name: String, input: Value },
102    #[serde(rename = "container_upload")]
103    ContainerUpload { file_id: String },
104    #[serde(rename = "code_execution_tool_result")]
105    CodeExecutionToolResult { tool_use_id: String, content: Value },
106    #[serde(rename = "bash_code_execution_tool_result")]
107    BashCodeExecutionToolResult { tool_use_id: String, content: Value },
108    #[serde(rename = "text_editor_code_execution_tool_result")]
109    TextEditorCodeExecutionToolResult { tool_use_id: String, content: Value },
110    #[serde(rename = "web_search_tool_result")]
111    WebSearchToolResult { tool_use_id: String, content: Value },
112    #[serde(rename = "advisor_tool_result")]
113    AdvisorToolResult { tool_use_id: String, content: Value },
114    /// Catch-all for unknown content block types added by the Anthropic API.
115    #[serde(other)]
116    Unknown,
117}
118
119#[derive(Debug, Deserialize, Serialize, Clone)]
120pub struct AnthropicImageSource {
121    r#type: String,
122    media_type: String,
123    data: String,
124}
125
126#[derive(Debug, Deserialize, Serialize, Clone)]
127#[serde(untagged)]
128pub enum AnthropicSystemPrompt {
129    Text(String),
130    Blocks(Vec<AnthropicContentBlock>),
131}
132
133#[derive(Debug, Deserialize, Clone)]
134#[serde(untagged)]
135pub enum AnthropicTool {
136    Advisor {
137        #[serde(rename = "type")]
138        tool_type: String,
139        name: String,
140        model: String,
141        #[serde(default)]
142        max_uses: Option<u32>,
143        #[serde(default)]
144        max_tokens: Option<u32>,
145        #[serde(default)]
146        caching: Option<AnthropicAdvisorCachingCompat>,
147    },
148    Function {
149        name: String,
150        description: Option<String>,
151        input_schema: Value,
152        #[serde(default)]
153        input_examples: Option<Vec<Value>>,
154        #[serde(default)]
155        strict: Option<bool>,
156        #[serde(default)]
157        allowed_callers: Option<Vec<String>>,
158    },
159    Native {
160        #[serde(rename = "type")]
161        tool_type: String,
162        name: String,
163        #[serde(flatten, default)]
164        options: serde_json::Map<String, Value>,
165    },
166}
167
168impl Serialize for AnthropicTool {
169    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
170    where
171        S: Serializer,
172    {
173        match self {
174            Self::Advisor {
175                tool_type,
176                name,
177                model,
178                max_uses,
179                max_tokens,
180                caching,
181            } => {
182                let mut map = serializer.serialize_map(None)?;
183                map.serialize_entry("type", tool_type)?;
184                map.serialize_entry("name", name)?;
185                map.serialize_entry("model", model)?;
186                map.serialize_entry("max_uses", max_uses)?;
187                map.serialize_entry("max_tokens", max_tokens)?;
188                map.serialize_entry("caching", caching)?;
189                map.end()
190            }
191            Self::Function {
192                name,
193                description,
194                input_schema,
195                input_examples,
196                strict,
197                allowed_callers,
198            } => {
199                let mut map = serializer.serialize_map(None)?;
200                map.serialize_entry("name", name)?;
201                map.serialize_entry("description", description)?;
202                map.serialize_entry("input_schema", input_schema)?;
203                map.serialize_entry("input_examples", input_examples)?;
204                map.serialize_entry("strict", strict)?;
205                map.serialize_entry("allowed_callers", allowed_callers)?;
206                map.end()
207            }
208            Self::Native { tool_type, name, options } => {
209                if let Some(key) = ["type", "name"].into_iter().find(|key| options.contains_key(*key)) {
210                    return Err(serde::ser::Error::custom(format!(
211                        "Anthropic native tool extension key '{key}' collides with a reserved wire field"
212                    )));
213                }
214                let mut map = serializer.serialize_map(None)?;
215                map.serialize_entry("type", tool_type)?;
216                map.serialize_entry("name", name)?;
217                for (key, value) in options {
218                    map.serialize_entry(key, value)?;
219                }
220                map.end()
221            }
222        }
223    }
224}
225
226#[derive(Debug, Deserialize, Serialize, Clone)]
227pub struct AnthropicAdvisorCachingCompat {
228    #[serde(rename = "type")]
229    cache_type: String,
230    ttl: String,
231}
232
233#[derive(Debug, Serialize, Clone)]
234pub struct AnthropicMessagesResponse {
235    pub(crate) id: String,
236    pub(crate) r#type: String,
237    pub(crate) role: String,
238    pub(crate) model: String,
239    pub(crate) content: Vec<AnthropicContentBlock>,
240    pub(crate) stop_reason: Option<String>,
241    pub(crate) stop_sequence: Option<String>,
242    pub(crate) usage: AnthropicUsage,
243}
244
245#[derive(Debug, Serialize, Clone)]
246pub struct AnthropicUsage {
247    pub(crate) input_tokens: u32,
248    pub(crate) output_tokens: u32,
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn native_tool_rejects_reserved_extension_keys() {
257        let mut options = serde_json::Map::new();
258        options.insert("type".to_string(), json!("unexpected"));
259        let tool = AnthropicTool::Native {
260            tool_type: "web_search_20250305".to_string(),
261            name: "web_search".to_string(),
262            options,
263        };
264
265        let error = serde_json::to_value(tool).expect_err("reserved extension keys must be rejected");
266        assert!(error.to_string().contains("collides with a reserved wire field"));
267    }
268
269    #[test]
270    fn updates_display_is_only_forwarded_to_models_that_accept_it() {
271        use crate::providers::anthropic::request_builder::{RequestBuilderContext, convert_to_anthropic_format};
272        use vtcode_config::core::{AnthropicConfig, AnthropicPromptCacheSettings};
273
274        let payload_thinking = |model: &str| {
275            let request: AnthropicMessagesRequest = serde_json::from_value(json!({
276                "model": model,
277                "max_tokens": 1024,
278                "messages": [{ "role": "user", "content": "hello" }],
279                "thinking": { "type": "adaptive", "display": "updates" },
280            }))
281            .expect("compat request");
282            let llm_request = convert_anthropic_to_llm_request(request).expect("converted request");
283            let anthropic_config = AnthropicConfig::default();
284            let prompt_cache_settings = AnthropicPromptCacheSettings::default();
285            let ctx = RequestBuilderContext {
286                prompt_cache_enabled: false,
287                prompt_cache_settings: &prompt_cache_settings,
288                anthropic_config: &anthropic_config,
289                model,
290                server_side_fallbacks_available: false,
291            };
292            convert_to_anthropic_format(&llm_request, &ctx).expect("payload conversion")["thinking"].clone()
293        };
294
295        assert_eq!(payload_thinking("claude-opus-5-5"), json!({ "type": "adaptive", "display": "updates" }));
296        for model in ["claude-opus-4-8", "claude-sonnet-5", "claude-opus-5"] {
297            assert_eq!(payload_thinking(model), json!({ "type": "adaptive" }), "{model}");
298        }
299    }
300}
301
302#[derive(Debug, Serialize)]
303#[serde(tag = "type")]
304pub enum AnthropicStreamEvent {
305    #[serde(rename = "message_start")]
306    MessageStart { message: AnthropicMessagesResponse },
307    #[serde(rename = "content_block_start")]
308    ContentBlockStart {
309        index: u32,
310        content_block: AnthropicContentBlock,
311    },
312    #[serde(rename = "content_block_delta")]
313    ContentBlockDelta { index: u32, delta: AnthropicContentDelta },
314    #[serde(rename = "content_block_stop")]
315    ContentBlockStop { index: u32 },
316    #[serde(rename = "message_delta")]
317    MessageDelta {
318        delta: AnthropicDelta,
319        usage: AnthropicUsage,
320    },
321    #[serde(rename = "message_stop")]
322    MessageStop,
323    #[serde(rename = "ping")]
324    Ping {},
325    #[serde(rename = "error")]
326    Error { error: AnthropicError },
327}
328
329#[derive(Debug, Serialize)]
330#[serde(tag = "type")]
331pub enum AnthropicContentDelta {
332    #[serde(rename = "text_delta")]
333    TextDelta { text: String },
334    #[serde(rename = "input_json_delta")]
335    InputJsonDelta { partial_json: String },
336    #[serde(rename = "thinking_delta")]
337    ThinkingDelta { thinking: String },
338    #[serde(rename = "signature_delta")]
339    SignatureDelta { signature: String },
340}
341
342#[derive(Debug, Serialize)]
343pub struct AnthropicDelta {
344    pub(crate) stop_reason: Option<String>,
345    pub(crate) stop_sequence: Option<String>,
346}
347
348#[derive(Debug, Serialize)]
349pub struct AnthropicError {
350    pub(crate) r#type: String,
351    pub(crate) message: String,
352}
353
354#[derive(Default)]
355struct ConvertedAnthropicBlocks {
356    content_parts: Vec<ContentPart>,
357    tool_calls: Vec<ToolCall>,
358    reasoning_chunks: Vec<String>,
359    reasoning_details: Vec<Value>,
360    emitted_messages: Vec<Message>,
361}
362
363pub(crate) fn convert_anthropic_to_llm_request(request: AnthropicMessagesRequest) -> Result<LLMRequest, LLMError> {
364    let (tool_choice, parallel_tool_config) = parse_anthropic_tool_choice(request.tool_choice.as_ref());
365    let effort = request.output_config.as_ref().and_then(|config| config.effort.clone());
366    let output_format = request.output_config.as_ref().and_then(|config| {
367        config.format.as_ref().and_then(|format| match format {
368            AnthropicOutputFormat::JsonSchema { schema } => Some(schema.clone()),
369            AnthropicOutputFormat::Unknown => None,
370        })
371    });
372    let task_budget_tokens = request
373        .output_config
374        .as_ref()
375        .and_then(|config| config.task_budget.as_ref())
376        .map(|budget| budget.total);
377    let anthropic_request_overrides = Some(AnthropicRequestOverrides {
378        speed: None,
379        thinking_mode: compatibility_thinking_mode(&request.model, request.thinking.as_ref()),
380        thinking_display: compatibility_thinking_display(request.thinking.as_ref()),
381        effort: effort
382            .as_ref()
383            .map(|effort| AnthropicOptionalStringOverride::Explicit(effort.clone()))
384            .unwrap_or(AnthropicOptionalStringOverride::Omit),
385        task_budget_tokens: task_budget_tokens
386            .map(AnthropicOptionalU32Override::Explicit)
387            .unwrap_or(AnthropicOptionalU32Override::Omit),
388    });
389
390    let system_prompt = request
391        .system
392        .map(extract_system_prompt_text)
393        .filter(|value| !value.is_empty())
394        .map(Arc::from);
395
396    let mut messages = Vec::new();
397    for anthropic_msg in request.messages {
398        let role = anthropic_role_to_message_role(&anthropic_msg.role);
399
400        match anthropic_msg.content {
401            AnthropicContent::Text(text) => {
402                if !text.is_empty() {
403                    messages.push(Message::base(role, MessageContent::Text(text)));
404                }
405            }
406            AnthropicContent::Blocks(blocks) => {
407                // API version 2026-10-21 rejects a `document` block placed
408                // directly in an `assistant` message; documents belong in
409                // `user` messages.
410                if role == MessageRole::Assistant
411                    && blocks
412                        .iter()
413                        .any(|block| matches!(block, AnthropicContentBlock::Document { .. }))
414                {
415                    let formatted_error = error_display::format_llm_error(
416                        "Anthropic",
417                        "document content blocks are only accepted in user messages; send the document in a user message instead.",
418                    );
419                    return Err(LLMError::InvalidRequest { message: formatted_error, metadata: None });
420                }
421
422                let converted = convert_anthropic_blocks(&blocks);
423                messages.extend(converted.emitted_messages);
424
425                if !converted.content_parts.is_empty()
426                    || !converted.tool_calls.is_empty()
427                    || !converted.reasoning_chunks.is_empty()
428                {
429                    let mut message = Message::base(role, message_content_from_parts(converted.content_parts));
430                    if !converted.tool_calls.is_empty() {
431                        message.tool_calls = Some(converted.tool_calls);
432                    }
433                    if !converted.reasoning_chunks.is_empty() && message.role == MessageRole::Assistant {
434                        message.reasoning = Some(converted.reasoning_chunks.join("\n"));
435                    }
436                    if !converted.reasoning_details.is_empty() && message.role == MessageRole::Assistant {
437                        message.reasoning_details = Some(converted.reasoning_details);
438                    }
439                    messages.push(message);
440                }
441            }
442        }
443    }
444
445    let tools = if let Some(anthropic_tools) = request.tools {
446        let mut converted_tools = Vec::new();
447        for tool in anthropic_tools {
448            let tool_def = match tool {
449                AnthropicTool::Advisor {
450                    tool_type: _,
451                    name: _,
452                    model,
453                    max_uses,
454                    max_tokens,
455                    caching,
456                } => {
457                    let mut options = serde_json::Map::new();
458                    options.insert("model".to_string(), Value::String(model));
459                    if let Some(max_uses) = max_uses {
460                        options.insert("max_uses".to_string(), Value::Number(max_uses.into()));
461                    }
462                    if let Some(max_tokens) = max_tokens {
463                        options.insert("max_tokens".to_string(), Value::Number(max_tokens.into()));
464                    }
465                    if let Some(caching) = caching {
466                        options.insert(
467                            "caching".to_string(),
468                            json!({
469                                "type": caching.cache_type,
470                                "ttl": caching.ttl,
471                            }),
472                        );
473                    }
474                    ToolDefinition {
475                        tool_type: "advisor_20260301".to_string(),
476                        function: None,
477                        allowed_callers: None,
478                        input_examples: None,
479                        web_search: None,
480                        hosted_tool_config: None,
481                        shell: None,
482                        grammar: None,
483                        strict: None,
484                        defer_loading: None,
485                        namespace: None,
486                        advisor: Some(Value::Object(options)),
487                    }
488                }
489                AnthropicTool::Function {
490                    name,
491                    description,
492                    input_schema,
493                    input_examples,
494                    strict,
495                    allowed_callers,
496                } => {
497                    let mut tool = ToolDefinition::function(name, description.unwrap_or_default(), input_schema);
498                    tool.input_examples = input_examples;
499                    tool.strict = strict;
500                    tool.allowed_callers = allowed_callers;
501                    tool
502                }
503                AnthropicTool::Native { tool_type, options, .. } => {
504                    if tool_type.starts_with("web_search_") {
505                        ToolDefinition {
506                            tool_type,
507                            function: None,
508                            allowed_callers: None,
509                            input_examples: None,
510                            web_search: (!options.is_empty()).then_some(Value::Object(options)),
511                            hosted_tool_config: None,
512                            shell: None,
513                            grammar: None,
514                            strict: None,
515                            defer_loading: None,
516                            namespace: None,
517                            advisor: None,
518                        }
519                    } else if tool_type.starts_with("code_execution_") || tool_type.starts_with("memory_") {
520                        ToolDefinition {
521                            tool_type,
522                            function: None,
523                            allowed_callers: None,
524                            input_examples: None,
525                            web_search: None,
526                            hosted_tool_config: None,
527                            shell: None,
528                            grammar: None,
529                            strict: None,
530                            defer_loading: None,
531                            namespace: None,
532                            advisor: None,
533                        }
534                    } else {
535                        continue;
536                    }
537                }
538            };
539            converted_tools.push(tool_def);
540        }
541        if converted_tools.is_empty() {
542            None
543        } else {
544            Some(Arc::new(converted_tools))
545        }
546    } else {
547        None
548    };
549
550    Ok(LLMRequest {
551        messages: Arc::new(messages),
552        system_prompt,
553        tools,
554        model: request.model,
555        max_tokens: Some(request.max_tokens),
556        temperature: None,
557        stream: request.stream,
558        output_format,
559        tool_choice,
560        parallel_tool_calls: None,
561        parallel_tool_config,
562        reasoning_effort: None,
563        effort,
564        verbosity: None,
565        do_sample: None,
566        top_p: None,
567        top_k: None,
568        presence_penalty: None,
569        frequency_penalty: None,
570        stop_sequences: request.stop_sequences,
571        thinking_budget: None,
572        betas: request.betas,
573        context_management: request.context_management,
574        coding_agent_settings: None,
575        metadata: None,
576        previous_response_id: None,
577        response_store: None,
578        responses_include: None,
579        service_tier: None,
580        prompt_cache_key: None,
581        prompt_cache_profile: None,
582        fallbacks: None,
583        fallback_credit_token: None,
584        anthropic_request_overrides,
585    })
586}
587
588pub(crate) fn convert_llm_to_anthropic_response(response: LLMResponse) -> AnthropicMessagesResponse {
589    use uuid::Uuid;
590
591    let mut content_blocks = Vec::new();
592    let mut preserved_reasoning = false;
593
594    if let Some(reasoning_details) = response.reasoning_details.as_ref() {
595        for detail in reasoning_details {
596            let Some(normalized) = normalize_reasoning_detail_object(&Value::String(detail.clone())) else {
597                continue;
598            };
599
600            match normalized.get("type").and_then(|value| value.as_str()) {
601                Some("thinking") => {
602                    let thinking = normalized
603                        .get("thinking")
604                        .and_then(|value| value.as_str())
605                        .unwrap_or_default()
606                        .to_string();
607                    let signature = normalized
608                        .get("signature")
609                        .and_then(|value| value.as_str())
610                        .map(ToOwned::to_owned);
611                    content_blocks.push(AnthropicContentBlock::Thinking { thinking, signature });
612                    preserved_reasoning = true;
613                }
614                Some("redacted_thinking") => {
615                    let data = normalized
616                        .get("data")
617                        .and_then(|value| value.as_str())
618                        .unwrap_or_default()
619                        .to_string();
620                    content_blocks.push(AnthropicContentBlock::RedactedThinking { data });
621                    preserved_reasoning = true;
622                }
623                _ => {}
624            }
625        }
626    }
627
628    if !preserved_reasoning
629        && let Some(reasoning) = response.reasoning.as_ref()
630        && !reasoning.trim().is_empty()
631    {
632        content_blocks.push(AnthropicContentBlock::Thinking { thinking: reasoning.clone(), signature: None });
633    }
634
635    if let Some(content) = response.content.as_ref()
636        && !content.is_empty()
637    {
638        content_blocks.push(AnthropicContentBlock::Text {
639            text: content.clone(),
640            citations: None,
641            cache_control: None,
642        });
643    }
644
645    if let Some(tool_calls) = response.tool_calls.as_ref() {
646        for call in tool_calls {
647            if let Some(func) = &call.function {
648                let input = call
649                    .parsed_arguments()
650                    .unwrap_or_else(|_| Value::String(func.arguments.clone()));
651                content_blocks.push(AnthropicContentBlock::ToolUse {
652                    id: call.id.clone(),
653                    name: func.name.clone(),
654                    input,
655                });
656            }
657        }
658    }
659
660    let usage = response.usage.unwrap_or_default();
661    let model = if response.model.trim().is_empty() {
662        "unknown".to_string()
663    } else {
664        response.model
665    };
666
667    AnthropicMessagesResponse {
668        id: Uuid::new_v4().to_string(),
669        r#type: "message".to_string(),
670        role: "assistant".to_string(),
671        model,
672        content: content_blocks,
673        stop_reason: Some(anthropic_stop_reason(response.finish_reason)),
674        stop_sequence: None,
675        usage: AnthropicUsage {
676            input_tokens: usage.prompt_tokens,
677            output_tokens: usage.completion_tokens,
678        },
679    }
680}
681
682pub(crate) fn anthropic_stop_reason(finish_reason: FinishReason) -> String {
683    match finish_reason {
684        FinishReason::Stop => "end_turn".to_string(),
685        FinishReason::Length => "max_tokens".to_string(),
686        FinishReason::ToolCalls => "tool_use".to_string(),
687        FinishReason::ContentFilter => "content_filter".to_string(),
688        FinishReason::Pause => "pause_turn".to_string(),
689        FinishReason::Refusal => "refusal".to_string(),
690        FinishReason::Error(message) => message,
691    }
692}
693
694fn parse_anthropic_tool_choice(tool_choice: Option<&Value>) -> (Option<ToolChoice>, Option<Box<ParallelToolConfig>>) {
695    let Some(choice) = tool_choice else {
696        return (None, None);
697    };
698    let Some(choice_obj) = choice.as_object() else {
699        return (None, None);
700    };
701
702    let disable_parallel_tool_use = choice_obj
703        .get("disable_parallel_tool_use")
704        .and_then(Value::as_bool)
705        .unwrap_or(false);
706
707    let parsed_tool_choice = match choice_obj.get("type").and_then(Value::as_str) {
708        Some("auto") => Some(ToolChoice::Auto),
709        Some("none") => Some(ToolChoice::None),
710        Some("any") => Some(ToolChoice::Any),
711        Some("tool") => choice_obj
712            .get("name")
713            .and_then(Value::as_str)
714            .map(|name| ToolChoice::function(name.to_string())),
715        _ => None,
716    };
717
718    let parallel_tool_config = disable_parallel_tool_use.then(|| {
719        Box::new(ParallelToolConfig {
720            disable_parallel_tool_use: true,
721            max_parallel_tools: Some(1),
722            encourage_parallel: false,
723        })
724    });
725
726    (parsed_tool_choice, parallel_tool_config)
727}
728
729fn anthropic_role_to_message_role(role: &str) -> MessageRole {
730    match role {
731        "assistant" => MessageRole::Assistant,
732        "system" => MessageRole::System,
733        "tool" => MessageRole::Tool,
734        _ => MessageRole::User,
735    }
736}
737
738fn extract_system_prompt_text(system_prompt: AnthropicSystemPrompt) -> String {
739    match system_prompt {
740        AnthropicSystemPrompt::Text(text) => text,
741        AnthropicSystemPrompt::Blocks(blocks) => blocks
742            .iter()
743            .map(anthropic_block_text)
744            .filter(|text| !text.is_empty())
745            .collect::<Vec<_>>()
746            .join("\n"),
747    }
748}
749
750fn convert_anthropic_blocks(blocks: &[AnthropicContentBlock]) -> ConvertedAnthropicBlocks {
751    let mut converted = ConvertedAnthropicBlocks::default();
752
753    for block in blocks {
754        match block {
755            AnthropicContentBlock::Text { text, .. } => {
756                converted.content_parts.push(ContentPart::text(text.clone()));
757            }
758            AnthropicContentBlock::Image { source } => {
759                converted
760                    .content_parts
761                    .push(ContentPart::image(source.data.clone(), source.media_type.clone()));
762            }
763            AnthropicContentBlock::ToolUse { id, name, input }
764            | AnthropicContentBlock::ServerToolUse { id, name, input } => {
765                converted
766                    .tool_calls
767                    .push(ToolCall::function(id.clone(), name.clone(), input.to_string()));
768            }
769            AnthropicContentBlock::ToolResult { tool_use_id, content, .. } => converted
770                .emitted_messages
771                .push(Message::tool_response(tool_use_id.clone(), anthropic_content_text(content))),
772            AnthropicContentBlock::Thinking { thinking, signature } => {
773                converted.reasoning_chunks.push(thinking.clone());
774                let mut detail = json!({
775                    "type": "thinking",
776                    "thinking": thinking,
777                });
778                if let Some(signature) = signature
779                    && let Some(obj) = detail.as_object_mut()
780                {
781                    obj.insert("signature".to_string(), Value::String(signature.clone()));
782                }
783                converted.reasoning_details.push(detail);
784            }
785            AnthropicContentBlock::RedactedThinking { data } => {
786                converted.reasoning_details.push(json!({
787                    "type": "redacted_thinking",
788                    "data": data,
789                }));
790            }
791            AnthropicContentBlock::ContainerUpload { file_id } => {
792                converted.content_parts.push(ContentPart::file_from_id(file_id.clone()));
793            }
794            AnthropicContentBlock::Document { .. } => {
795                converted.content_parts.push(ContentPart::text("[Document]".to_string()));
796            }
797            AnthropicContentBlock::CodeExecutionToolResult { tool_use_id, content }
798            | AnthropicContentBlock::BashCodeExecutionToolResult { tool_use_id, content }
799            | AnthropicContentBlock::TextEditorCodeExecutionToolResult { tool_use_id, content }
800            | AnthropicContentBlock::WebSearchToolResult { tool_use_id, content }
801            | AnthropicContentBlock::AdvisorToolResult { tool_use_id, content } => converted
802                .emitted_messages
803                .push(Message::tool_response(tool_use_id.clone(), serialize_value(content))),
804            // Unknown content block types are silently skipped.
805            AnthropicContentBlock::Unknown => {}
806        }
807    }
808
809    converted
810}
811
812fn message_content_from_parts(parts: Vec<ContentPart>) -> MessageContent {
813    if parts.len() == 1
814        && let ContentPart::Text { text } = &parts[0]
815    {
816        return MessageContent::Text(text.clone());
817    }
818
819    MessageContent::Parts(parts)
820}
821
822fn anthropic_content_text(content: &AnthropicContent) -> String {
823    match content {
824        AnthropicContent::Text(text) => text.clone(),
825        AnthropicContent::Blocks(blocks) => blocks
826            .iter()
827            .map(anthropic_block_text)
828            .filter(|text| !text.is_empty())
829            .collect::<Vec<_>>()
830            .join("\n"),
831    }
832}
833
834fn anthropic_block_text(block: &AnthropicContentBlock) -> String {
835    match block {
836        AnthropicContentBlock::Text { text, .. } => text.clone(),
837        AnthropicContentBlock::Thinking { thinking, .. } => thinking.clone(),
838        AnthropicContentBlock::RedactedThinking { .. } => "[REDACTED THINKING]".to_string(),
839        AnthropicContentBlock::Image { .. } => "[Image]".to_string(),
840        AnthropicContentBlock::ContainerUpload { file_id } => format!("[File: {file_id}]"),
841        AnthropicContentBlock::Document { .. } => "[Document]".to_string(),
842        AnthropicContentBlock::ToolUse { name, input, .. }
843        | AnthropicContentBlock::ServerToolUse { name, input, .. } => {
844            format!("[Tool call: {name} with args: {input}]")
845        }
846        AnthropicContentBlock::ToolResult { tool_use_id, content, .. } => {
847            format!("[Tool result {}: {}]", tool_use_id, anthropic_content_text(content))
848        }
849        AnthropicContentBlock::CodeExecutionToolResult { tool_use_id, content }
850        | AnthropicContentBlock::BashCodeExecutionToolResult { tool_use_id, content }
851        | AnthropicContentBlock::TextEditorCodeExecutionToolResult { tool_use_id, content }
852        | AnthropicContentBlock::WebSearchToolResult { tool_use_id, content }
853        | AnthropicContentBlock::AdvisorToolResult { tool_use_id, content } => {
854            format!("[Tool result {}: {}]", tool_use_id, serialize_value(content))
855        }
856        AnthropicContentBlock::Unknown => "[Unknown Content Block]".to_string(),
857    }
858}
859
860fn compatibility_thinking_mode(_model: &str, thinking: Option<&ThinkingConfig>) -> AnthropicThinkingModeOverride {
861    match thinking {
862        Some(ThinkingConfig::Adaptive { .. }) => AnthropicThinkingModeOverride::Adaptive,
863        Some(ThinkingConfig::Enabled { budget_tokens, .. }) => {
864            AnthropicThinkingModeOverride::ManualBudget(*budget_tokens)
865        }
866        // `between_tools` is the "thinking off" spelling for the models that
867        // reject `disabled`; the builder rewrites it back for such a model.
868        Some(ThinkingConfig::Disabled) | Some(ThinkingConfig::BetweenTools) | Some(ThinkingConfig::Unknown) => {
869            AnthropicThinkingModeOverride::Disabled
870        }
871        None => AnthropicThinkingModeOverride::Disabled,
872    }
873}
874
875fn compatibility_thinking_display(thinking: Option<&ThinkingConfig>) -> AnthropicThinkingDisplayOverride {
876    let display = match thinking {
877        Some(ThinkingConfig::Adaptive { display }) | Some(ThinkingConfig::Enabled { display, .. }) => *display,
878        Some(ThinkingConfig::Disabled) | Some(ThinkingConfig::BetweenTools) | Some(ThinkingConfig::Unknown) | None => {
879            None
880        }
881    };
882
883    match display {
884        Some(ThinkingDisplay::Summarized) => AnthropicThinkingDisplayOverride::Summarized,
885        Some(ThinkingDisplay::Omitted) => AnthropicThinkingDisplayOverride::Omitted,
886        Some(ThinkingDisplay::Updates) => AnthropicThinkingDisplayOverride::Updates,
887        Some(ThinkingDisplay::Unknown) | None => AnthropicThinkingDisplayOverride::Inherit,
888    }
889}
890
891fn serialize_value(value: &Value) -> String {
892    serde_json::to_string(value).unwrap_or_else(|_| json!({ "value": value }).to_string())
893}