Skip to main content

switchyard_translation/codecs/anthropic/
buffered.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Buffered codec for Anthropic Messages request and response JSON.
5
6use serde_json::{json, Map, Value};
7
8use crate::codecs::common::{is_known_role_name, provider_extensions, text_from_blocks};
9use crate::codecs::openai_chat::{decode_file_source, decode_image_source};
10use crate::codecs::{
11    DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec,
12};
13use crate::diagnostic::TranslationDiagnostic;
14use crate::error::{Result, TranslationError};
15use crate::format::{FormatId, WireFormat};
16use crate::llm::{
17    ContentBlock, FileSource, ImageSource, InstructionBlock, LlmRequest, LlmResponse, MediaSource,
18    Message, OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role,
19    SamplingParams, StopReason, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage,
20};
21use crate::policy::{DeterministicIdPolicy, TranslationPolicy};
22use crate::util::sanitize_anthropic_tool_use_id;
23use crate::util::{
24    capture_request_preservation, capture_response_preservation, embed_preservation,
25    exact_preserved_request, exact_preserved_response,
26};
27use crate::util::{
28    json_string, push_lossy, stable_id, string_value, validate_request_capabilities,
29};
30
31/// Format codec for Anthropic Messages payloads.
32pub struct AnthropicMessagesCodec;
33
34impl FormatCodec for AnthropicMessagesCodec {
35    fn format(&self) -> FormatId {
36        WireFormat::AnthropicMessages.into()
37    }
38
39    fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedRequest> {
40        let body = crate::util::object(body, "$")?;
41        let mut diagnostics = Vec::new();
42        let mut request = LlmRequest {
43            model: body
44                .get("model")
45                .and_then(Value::as_str)
46                .filter(|model| !model.is_empty())
47                .map(ToOwned::to_owned),
48            output: OutputParams {
49                max_output_tokens: body.get("max_tokens").and_then(Value::as_u64),
50                response_format: None,
51            },
52            sampling: SamplingParams {
53                temperature: body.get("temperature").and_then(Value::as_f64),
54                top_p: body.get("top_p").and_then(Value::as_f64),
55                top_k: body.get("top_k").and_then(Value::as_i64),
56            },
57            reasoning: ReasoningParams {
58                effort: body
59                    .get("output_config")
60                    .and_then(Value::as_object)
61                    .and_then(|object| object.get("effort"))
62                    .and_then(Value::as_str)
63                    .map(ToOwned::to_owned),
64                raw: body.get("thinking").cloned(),
65            },
66            stream: body.get("stream").and_then(Value::as_bool).unwrap_or(false),
67            preservation: capture_request_preservation(
68                WireFormat::AnthropicMessages,
69                &Value::Object(body.clone()),
70                policy,
71            ),
72            ..LlmRequest::default()
73        };
74        if let Some(system) = body.get("system") {
75            if let Some(content) = decode_anthropic_system(system, &mut diagnostics, policy)? {
76                request.instructions.push(InstructionBlock {
77                    role: Role::System,
78                    content,
79                });
80            }
81        }
82        if let Some(messages) = body.get("messages").and_then(Value::as_array) {
83            let mut generated_id = 0;
84            for (index, message) in messages.iter().enumerate() {
85                let Some(message) = message.as_object() else {
86                    push_lossy(
87                        &mut diagnostics,
88                        policy,
89                        format!("Anthropic message at index {index} is not an object"),
90                    )?;
91                    continue;
92                };
93                // Request decoding enforces the provider contract: an unknown
94                // role is rejected rather than coerced to `user`. Anthropic
95                // Messages only defines `user`/`assistant`, but other known
96                // role names stay lenient (mapped to `user`) to preserve
97                // historical cross-format behaviour.
98                let role = match message.get("role").and_then(Value::as_str) {
99                    Some("assistant") => Role::Assistant,
100                    None => Role::User,
101                    Some(other) if is_known_role_name(other) => Role::User,
102                    Some(other) => {
103                        return Err(TranslationError::unsupported_role(
104                            format!("$.messages[{index}].role"),
105                            other,
106                        ));
107                    }
108                };
109                generated_id += 1;
110                let content = decode_anthropic_content(
111                    message
112                        .get("content")
113                        .unwrap_or(&Value::String(String::new())),
114                    role,
115                    generated_id,
116                    &mut diagnostics,
117                    policy,
118                )?;
119                request.messages.push(Message { role, content });
120            }
121        }
122        request.tools = decode_anthropic_tools(body.get("tools"));
123        request.tool_choice = body.get("tool_choice").map(decode_anthropic_tool_choice);
124        request.extensions.fields = provider_extensions(
125            body,
126            &[
127                "model",
128                "messages",
129                "system",
130                "tools",
131                "tool_choice",
132                "max_tokens",
133                "temperature",
134                "top_p",
135                "top_k",
136                "thinking",
137                "output_config",
138                "stream",
139            ],
140        );
141
142        Ok(DecodedRequest {
143            request,
144            diagnostics,
145        })
146    }
147
148    fn encode_request(
149        &self,
150        request: &LlmRequest,
151        policy: &TranslationPolicy,
152    ) -> Result<EncodedRequest> {
153        if let Some(body) =
154            exact_preserved_request(&request.preservation, WireFormat::AnthropicMessages, policy)
155        {
156            return Ok(EncodedRequest {
157                body,
158                diagnostics: Vec::new(),
159            });
160        }
161        let mut diagnostics = Vec::new();
162        validate_request_capabilities(request, &mut diagnostics, policy)?;
163        let mut body = Map::new();
164        if let Some(model) = &request.model {
165            body.insert("model".to_string(), Value::String(model.clone()));
166        }
167        let system_text = request
168            .instructions
169            .iter()
170            .flat_map(|instruction| instruction.content.iter())
171            .filter_map(|block| match block {
172                ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
173                _ => None,
174            })
175            .collect::<Vec<_>>()
176            .join("\n\n");
177        if !system_text.is_empty() {
178            body.insert("system".to_string(), Value::String(system_text));
179        }
180
181        body.insert(
182            "messages".to_string(),
183            Value::Array(encode_anthropic_messages(
184                &request.messages,
185                &mut diagnostics,
186                policy,
187            )?),
188        );
189
190        if !request.tools.is_empty() {
191            body.insert("tools".to_string(), encode_anthropic_tools(&request.tools));
192        }
193        if let Some(choice) = &request.tool_choice {
194            body.insert(
195                "tool_choice".to_string(),
196                encode_anthropic_tool_choice(choice),
197            );
198        }
199        if let Some(stop_sequences) =
200            anthropic_stop_sequences_from_extensions(&request.extensions.fields)
201        {
202            body.insert("stop_sequences".to_string(), stop_sequences);
203        }
204        if let Some(max_tokens) = request.output.max_output_tokens {
205            body.insert("max_tokens".to_string(), json!(max_tokens));
206        } else {
207            body.insert("max_tokens".to_string(), json!(128_000));
208        }
209        if let Some(value) = request.sampling.temperature {
210            body.insert("temperature".to_string(), json!(value));
211        }
212        if let Some(value) = request.sampling.top_p {
213            body.insert("top_p".to_string(), json!(value));
214        }
215        if let Some(value) = request.sampling.top_k {
216            body.insert("top_k".to_string(), json!(value));
217        }
218        if request.stream {
219            body.insert("stream".to_string(), Value::Bool(true));
220        }
221        if let Some(effort) = &request.reasoning.effort {
222            body.insert("thinking".to_string(), json!({"type": "adaptive"}));
223            body.insert("output_config".to_string(), json!({"effort": effort}));
224        }
225
226        let body = embed_preservation(Value::Object(body), &request.preservation, policy);
227        Ok(EncodedRequest { body, diagnostics })
228    }
229
230    fn decode_response(
231        &self,
232        body: &Value,
233        _policy: &TranslationPolicy,
234    ) -> Result<DecodedResponse> {
235        let body = crate::util::object(body, "$")?;
236        let mut content = Vec::new();
237        if let Some(blocks) = body.get("content").and_then(Value::as_array) {
238            for (index, block) in blocks.iter().enumerate() {
239                if let Some(block) = block.as_object() {
240                    content.extend(decode_anthropic_content_block(
241                        block,
242                        Role::Assistant,
243                        index + 1,
244                        &mut Vec::new(),
245                        &TranslationPolicy::default(),
246                    )?);
247                }
248            }
249        }
250        if content.is_empty() {
251            content.push(ContentBlock::Text {
252                text: String::new(),
253            });
254        }
255        let response = LlmResponse {
256            id: body
257                .get("id")
258                .and_then(Value::as_str)
259                .map(ToOwned::to_owned),
260            model: body
261                .get("model")
262                .and_then(Value::as_str)
263                .map(ToOwned::to_owned),
264            outputs: vec![ResponseOutput {
265                role: Role::Assistant,
266                content,
267                stop_reason: Some(map_anthropic_stop_reason(
268                    body.get("stop_reason").and_then(Value::as_str),
269                )),
270            }],
271            usage: decode_anthropic_usage(body.get("usage")),
272            extensions: ProviderExtensions {
273                fields: provider_extensions(
274                    body,
275                    &[
276                        "id",
277                        "type",
278                        "role",
279                        "model",
280                        "content",
281                        "stop_reason",
282                        "usage",
283                    ],
284                ),
285            },
286            preservation: capture_response_preservation(
287                WireFormat::AnthropicMessages,
288                &Value::Object(body.clone()),
289                _policy,
290            ),
291        };
292        Ok(DecodedResponse {
293            response,
294            diagnostics: Vec::new(),
295        })
296    }
297
298    fn encode_response(
299        &self,
300        response: &LlmResponse,
301        _policy: &TranslationPolicy,
302    ) -> Result<EncodedResponse> {
303        if let Some(body) = exact_preserved_response(
304            &response.preservation,
305            WireFormat::AnthropicMessages,
306            _policy,
307        ) {
308            return Ok(EncodedResponse {
309                body,
310                diagnostics: Vec::new(),
311            });
312        }
313        let output = response.first_output();
314        let content = output
315            .map(|output| encode_anthropic_content(&output.content))
316            .unwrap_or_else(|| vec![json!({"type": "text", "text": ""})]);
317        let body = json!({
318            "id": response.id.clone().unwrap_or_else(|| "msg_switchyard".to_string()),
319            "type": "message",
320            "role": "assistant",
321            "model": response.model.clone().unwrap_or_else(|| "unknown".to_string()),
322            "content": content,
323            "stop_reason": output
324                .and_then(|output| output.stop_reason)
325                .map(anthropic_stop_reason)
326                .unwrap_or("end_turn"),
327            "stop_sequence": Value::Null,
328            "usage": encode_anthropic_usage(&response.usage),
329        });
330        Ok(EncodedResponse {
331            body: embed_preservation(body, &response.preservation, _policy),
332            diagnostics: Vec::new(),
333        })
334    }
335}
336
337// Decodes Anthropic's `system` field into instruction blocks.
338fn decode_anthropic_system(
339    value: &Value,
340    diagnostics: &mut Vec<TranslationDiagnostic>,
341    policy: &TranslationPolicy,
342) -> Result<Option<Vec<ContentBlock>>> {
343    match value {
344        Value::String(text) if !text.is_empty() => {
345            Ok(Some(vec![ContentBlock::Text { text: text.clone() }]))
346        }
347        Value::String(_) | Value::Null => Ok(None),
348        Value::Array(blocks) => {
349            let mut content = Vec::new();
350            for block in blocks {
351                if let Some(block) = block.as_object() {
352                    if block.get("type").and_then(Value::as_str) == Some("text") {
353                        let text = block
354                            .get("text")
355                            .and_then(Value::as_str)
356                            .unwrap_or_default()
357                            .to_string();
358                        content.push(ContentBlock::Text { text });
359                    }
360                }
361            }
362            Ok((!content.is_empty()).then_some(content))
363        }
364        other => {
365            push_lossy(diagnostics, policy, "Anthropic system field was not text")?;
366            Ok(Some(vec![ContentBlock::Text {
367                text: string_value(other).unwrap_or_default(),
368            }]))
369        }
370    }
371}
372
373// Decodes Anthropic message content into normalized content blocks.
374fn decode_anthropic_content(
375    value: &Value,
376    role: Role,
377    generated_counter: usize,
378    diagnostics: &mut Vec<TranslationDiagnostic>,
379    policy: &TranslationPolicy,
380) -> Result<Vec<ContentBlock>> {
381    match value {
382        Value::String(text) => Ok(vec![ContentBlock::Text { text: text.clone() }]),
383        Value::Null => Ok(vec![ContentBlock::Text {
384            text: String::new(),
385        }]),
386        Value::Array(blocks) => {
387            let mut content = Vec::new();
388            for (index, block) in blocks.iter().enumerate() {
389                let Some(block) = block.as_object() else {
390                    push_lossy(
391                        diagnostics,
392                        policy,
393                        format!("Anthropic content block {index} is not an object"),
394                    )?;
395                    continue;
396                };
397                content.extend(decode_anthropic_content_block(
398                    block,
399                    role,
400                    generated_counter + index,
401                    diagnostics,
402                    policy,
403                )?);
404            }
405            if content.is_empty() {
406                content.push(ContentBlock::Text {
407                    text: String::new(),
408                });
409            }
410            Ok(content)
411        }
412        other => Ok(vec![ContentBlock::Text {
413            text: string_value(other).unwrap_or_default(),
414        }]),
415    }
416}
417
418// Decodes one Anthropic content block into one or more IR blocks.
419fn decode_anthropic_content_block(
420    block: &Map<String, Value>,
421    _role: Role,
422    generated_counter: usize,
423    _diagnostics: &mut Vec<TranslationDiagnostic>,
424    policy: &TranslationPolicy,
425) -> Result<Vec<ContentBlock>> {
426    Ok(match block.get("type").and_then(Value::as_str) {
427        Some("text") => vec![ContentBlock::Text {
428            text: block
429                .get("text")
430                .and_then(Value::as_str)
431                .unwrap_or_default()
432                .to_string(),
433        }],
434        Some("thinking") => vec![ContentBlock::Reasoning {
435            text: block
436                .get("thinking")
437                .and_then(Value::as_str)
438                .unwrap_or_default()
439                .to_string(),
440            signature: block
441                .get("signature")
442                .and_then(Value::as_str)
443                .filter(|signature| !signature.is_empty())
444                .map(ToOwned::to_owned),
445        }],
446        Some("tool_use") => vec![ContentBlock::ToolCall(ToolCall {
447            id: block
448                .get("id")
449                .and_then(Value::as_str)
450                .filter(|id| !id.is_empty())
451                .map(ToOwned::to_owned)
452                .unwrap_or_else(|| match &policy.deterministic_ids {
453                    DeterministicIdPolicy::GenerateStable { prefix } => {
454                        stable_id(prefix, generated_counter)
455                    }
456                    DeterministicIdPolicy::Preserve => String::new(),
457                }),
458            name: block
459                .get("name")
460                .and_then(Value::as_str)
461                .unwrap_or_default()
462                .to_string(),
463            arguments: block.get("input").cloned().unwrap_or_else(|| json!({})),
464        })],
465        Some("tool_result") => vec![ContentBlock::ToolResult(ToolResult {
466            tool_call_id: block
467                .get("tool_use_id")
468                .and_then(Value::as_str)
469                .unwrap_or_default()
470                .to_string(),
471            content: decode_tool_result_content(block.get("content").unwrap_or(&Value::Null)),
472            is_error: block.get("is_error").and_then(Value::as_bool),
473        })],
474        Some("image") => {
475            let source = block
476                .get("source")
477                .cloned()
478                .map(ImageSource::Raw)
479                .unwrap_or_else(|| ImageSource::Raw(Value::Object(block.clone())));
480            vec![ContentBlock::Image { source }]
481        }
482        Some("input_image") | Some("image_url") => decode_image_source(block)
483            .map(|source| vec![ContentBlock::Image { source }])
484            .unwrap_or_default(),
485        Some("input_file") | Some("file") => vec![ContentBlock::File {
486            source: decode_file_source(block),
487        }],
488        _ => vec![ContentBlock::Unknown {
489            provider: WireFormat::AnthropicMessages.into(),
490            raw: Value::Object(block.clone()),
491        }],
492    })
493}
494
495// Converts Anthropic tool-result content into text-like IR blocks.
496fn decode_tool_result_content(value: &Value) -> Vec<ContentBlock> {
497    match value {
498        Value::String(text) => vec![ContentBlock::Text { text: text.clone() }],
499        Value::Array(blocks) => {
500            let mut text = Vec::new();
501            for block in blocks {
502                if let Some(block) = block.as_object() {
503                    if block.get("type").and_then(Value::as_str) == Some("text") {
504                        text.push(
505                            block
506                                .get("text")
507                                .and_then(Value::as_str)
508                                .unwrap_or_default()
509                                .to_string(),
510                        );
511                    } else {
512                        text.push(json_string(&Value::Object(block.clone())));
513                    }
514                }
515            }
516            vec![ContentBlock::Text {
517                text: text.join(" "),
518            }]
519        }
520        Value::Null => vec![ContentBlock::Text {
521            text: String::new(),
522        }],
523        other => vec![ContentBlock::Text {
524            text: json_string(other),
525        }],
526    }
527}
528
529// Decodes Anthropic tool definitions into normalized tool definitions.
530fn decode_anthropic_tools(value: Option<&Value>) -> Vec<ToolDefinition> {
531    value
532        .and_then(Value::as_array)
533        .into_iter()
534        .flatten()
535        .filter_map(Value::as_object)
536        .filter_map(|tool| {
537            let name = tool.get("name").and_then(Value::as_str)?.to_string();
538            (!name.is_empty()).then(|| ToolDefinition {
539                name,
540                description: tool
541                    .get("description")
542                    .and_then(Value::as_str)
543                    .map(ToOwned::to_owned),
544                parameters: tool
545                    .get("input_schema")
546                    .cloned()
547                    .unwrap_or_else(|| json!({})),
548                strict: None,
549            })
550        })
551        .collect()
552}
553
554// Decodes Anthropic tool-choice values into normalized policy.
555fn decode_anthropic_tool_choice(value: &Value) -> ToolChoice {
556    match value {
557        Value::String(text) if text == "auto" => ToolChoice::Auto,
558        Value::String(text) if text == "any" => ToolChoice::Required,
559        Value::String(text) if text == "none" => ToolChoice::None,
560        Value::Object(object) => match object.get("type").and_then(Value::as_str) {
561            Some("auto") => ToolChoice::Auto,
562            Some("any") => ToolChoice::Required,
563            Some("none") => ToolChoice::None,
564            Some("tool") => object
565                .get("name")
566                .and_then(Value::as_str)
567                .map(|name| ToolChoice::Tool {
568                    name: name.to_string(),
569                })
570                .unwrap_or_else(|| ToolChoice::Raw(value.clone())),
571            _ => ToolChoice::Raw(value.clone()),
572        },
573        _ => ToolChoice::Raw(value.clone()),
574    }
575}
576
577// Encodes one normalized message into Anthropic message JSON.
578fn encode_anthropic_message(
579    message: &Message,
580    diagnostics: &mut Vec<TranslationDiagnostic>,
581    policy: &TranslationPolicy,
582) -> Result<Value> {
583    let role = match message.role {
584        Role::Assistant => "assistant",
585        Role::User | Role::Tool | Role::System | Role::Developer => "user",
586    };
587    let content = encode_anthropic_content_with_policy(&message.content, diagnostics, policy)?;
588    let simple_text = content.len() == 1
589        && content
590            .first()
591            .and_then(Value::as_object)
592            .and_then(|object| object.get("type"))
593            .and_then(Value::as_str)
594            == Some("text");
595    let content = if simple_text {
596        content
597            .first()
598            .and_then(Value::as_object)
599            .and_then(|object| object.get("text"))
600            .cloned()
601            .unwrap_or_else(|| Value::String(String::new()))
602    } else {
603        Value::Array(content)
604    };
605    Ok(json!({"role": role, "content": content}))
606}
607
608// Encodes messages while grouping adjacent tool-result-only messages correctly.
609fn encode_anthropic_messages(
610    messages: &[Message],
611    diagnostics: &mut Vec<TranslationDiagnostic>,
612    policy: &TranslationPolicy,
613) -> Result<Vec<Value>> {
614    let mut encoded = Vec::new();
615    let mut index = 0;
616
617    while let Some(message) = messages.get(index) {
618        if !message_is_tool_result_only(message) {
619            encoded.push(encode_anthropic_message(message, diagnostics, policy)?);
620            index += 1;
621            continue;
622        }
623
624        let mut content = Vec::new();
625        while let Some(tool_message) = messages.get(index) {
626            if !message_is_tool_result_only(tool_message) {
627                break;
628            }
629            content.extend(encode_anthropic_content_with_policy(
630                &tool_message.content,
631                diagnostics,
632                policy,
633            )?);
634            index += 1;
635        }
636        encoded.push(json!({"role": "user", "content": content}));
637    }
638
639    Ok(encoded)
640}
641
642// Maps preserved OpenAI-style stop extensions to Anthropic stop sequences.
643fn anthropic_stop_sequences_from_extensions(extensions: &Map<String, Value>) -> Option<Value> {
644    match extensions.get("stop") {
645        Some(Value::String(stop)) => Some(json!([stop])),
646        Some(Value::Array(stops)) => Some(Value::Array(stops.clone())),
647        _ => None,
648    }
649}
650
651// Checks whether a message contains only tool-result blocks.
652fn message_is_tool_result_only(message: &Message) -> bool {
653    (message.role == Role::Tool || message.role == Role::User)
654        && !message.content.is_empty()
655        && message
656            .content
657            .iter()
658            .all(|block| matches!(block, ContentBlock::ToolResult(_)))
659}
660
661// Encodes content while applying lossy-conversion policy to unknown blocks.
662fn encode_anthropic_content_with_policy(
663    content: &[ContentBlock],
664    diagnostics: &mut Vec<TranslationDiagnostic>,
665    policy: &TranslationPolicy,
666) -> Result<Vec<Value>> {
667    let mut blocks = Vec::new();
668    for block in content {
669        match block {
670            ContentBlock::Unknown { raw, .. } => {
671                push_lossy(
672                    diagnostics,
673                    policy,
674                    "unknown content block encoded as text for Anthropic",
675                )?;
676                blocks.push(json!({"type": "text", "text": json_string(raw)}));
677            }
678            other => blocks.extend(encode_one_anthropic_block(other)),
679        }
680    }
681    if blocks.is_empty() {
682        blocks.push(json!({"type": "text", "text": ""}));
683    }
684    Ok(blocks)
685}
686
687// Encodes content without producing diagnostics for response paths.
688fn encode_anthropic_content(content: &[ContentBlock]) -> Vec<Value> {
689    let mut blocks = content
690        .iter()
691        .flat_map(encode_one_anthropic_response_block)
692        .collect::<Vec<_>>();
693    if blocks.is_empty() {
694        blocks.push(json!({"type": "text", "text": ""}));
695    }
696    blocks
697}
698
699// Encodes response content, where synthetic reasoning may be shown to clients.
700fn encode_one_anthropic_response_block(block: &ContentBlock) -> Vec<Value> {
701    match block {
702        ContentBlock::Reasoning {
703            text,
704            signature: None,
705        } => vec![json!({
706            "type": "thinking",
707            "thinking": text,
708            "signature": "",
709        })],
710        other => encode_one_anthropic_block(other),
711    }
712}
713
714// Encodes a single normalized content block into Anthropic block JSON.
715fn encode_one_anthropic_block(block: &ContentBlock) -> Vec<Value> {
716    match block {
717        ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
718            vec![json!({"type": "text", "text": text})]
719        }
720        ContentBlock::Reasoning {
721            text,
722            signature: Some(signature),
723        } if !signature.is_empty() => vec![json!({
724            "type": "thinking",
725            "thinking": text,
726            "signature": signature,
727        })],
728        ContentBlock::Reasoning { .. } => Vec::new(),
729        ContentBlock::ToolCall(call) => vec![json!({
730            "type": "tool_use",
731            "id": sanitize_anthropic_tool_use_id(&call.id),
732            "name": call.name,
733            "input": anthropic_tool_input(&call.arguments),
734        })],
735        ContentBlock::ToolResult(result) => vec![json!({
736            "type": "tool_result",
737            "tool_use_id": sanitize_anthropic_tool_use_id(&result.tool_call_id),
738            "content": text_from_blocks(&result.content, " "),
739        })],
740        ContentBlock::Image { source } => vec![match source {
741            ImageSource::Url { url, .. } => {
742                json!({"type": "image", "source": {"type": "url", "url": url}})
743            }
744            ImageSource::Base64 { media_type, data } => json!({
745                "type": "image",
746                "source": {
747                    "type": "base64",
748                    "media_type": media_type.clone().unwrap_or_else(|| "image/png".to_string()),
749                    "data": data,
750                },
751            }),
752            ImageSource::Raw(raw) => raw.clone(),
753        }],
754        ContentBlock::File { source } => vec![match source {
755            FileSource::FileId(file_id) => {
756                json!({"type": "document", "source": {"type": "file", "file_id": file_id}})
757            }
758            FileSource::FileData { data, filename } => json!({
759                "type": "document",
760                "source": {
761                    "type": "base64",
762                    "data": data,
763                    "filename": filename,
764                },
765            }),
766            FileSource::Raw(raw) => raw.clone(),
767        }],
768        ContentBlock::Audio { source } => vec![match source {
769            MediaSource::Url { url, media_type } => {
770                json!({"type": "audio", "source": {"type": "url", "url": url, "media_type": media_type}})
771            }
772            MediaSource::Base64 { media_type, data } => json!({
773                "type": "audio",
774                "source": {
775                    "type": "base64",
776                    "media_type": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
777                    "data": data,
778                },
779            }),
780            MediaSource::Raw(raw) => raw.clone(),
781        }],
782        ContentBlock::Video { source } => vec![match source {
783            MediaSource::Url { url, media_type } => {
784                json!({"type": "video", "source": {"type": "url", "url": url, "media_type": media_type}})
785            }
786            MediaSource::Base64 { media_type, data } => json!({
787                "type": "video",
788                "source": {
789                    "type": "base64",
790                    "media_type": media_type.clone().unwrap_or_else(|| "video/mp4".to_string()),
791                    "data": data,
792                },
793            }),
794            MediaSource::Raw(raw) => raw.clone(),
795        }],
796        ContentBlock::Unknown { raw, .. } => vec![raw.clone()],
797    }
798}
799
800// Anthropic requires `tool_use.input` to be object-shaped, while OpenAI and
801// Responses commonly carry function-call arguments as JSON strings.
802fn anthropic_tool_input(arguments: &Value) -> Value {
803    match arguments {
804        Value::Object(object) => Value::Object(object.clone()),
805        Value::String(text) => serde_json::from_str::<Value>(text)
806            .map_or_else(|_| json!({"raw": text}), ensure_anthropic_tool_input_object),
807        Value::Null => json!({}),
808        other => json!({"value": other}),
809    }
810}
811
812// Preserve valid objects and wrap every other JSON shape in a dictionary so
813// translated requests satisfy Anthropic's schema without discarding payloads.
814fn ensure_anthropic_tool_input_object(arguments: Value) -> Value {
815    match arguments {
816        Value::Object(_) => arguments,
817        Value::Null => json!({}),
818        other => json!({"value": other}),
819    }
820}
821
822// Encodes normalized tool definitions into Anthropic tool JSON.
823fn encode_anthropic_tools(tools: &[ToolDefinition]) -> Value {
824    Value::Array(
825        tools
826            .iter()
827            .map(|tool| {
828                json!({
829                    "name": tool.name,
830                    "description": tool.description.clone().unwrap_or_default(),
831                    "input_schema": tool.parameters,
832                })
833            })
834            .collect(),
835    )
836}
837
838// Encodes normalized tool choice into Anthropic tool-choice JSON.
839fn encode_anthropic_tool_choice(choice: &ToolChoice) -> Value {
840    match choice {
841        ToolChoice::Auto => json!({"type": "auto"}),
842        ToolChoice::Required => json!({"type": "any"}),
843        ToolChoice::None => json!({"type": "none"}),
844        ToolChoice::Tool { name } => json!({"type": "tool", "name": name}),
845        ToolChoice::Raw(value) => value.clone(),
846    }
847}
848
849// Normalizes Anthropic usage fields.
850fn decode_anthropic_usage(value: Option<&Value>) -> Usage {
851    let Some(value) = value.and_then(Value::as_object) else {
852        return Usage::default();
853    };
854    let input_tokens = value.get("input_tokens").and_then(Value::as_u64);
855    let output_tokens = value.get("output_tokens").and_then(Value::as_u64);
856    Usage {
857        input_tokens,
858        output_tokens,
859        total_tokens: input_tokens
860            .zip(output_tokens)
861            .map(|(input, output)| input + output),
862        reasoning_tokens: value
863            .get("output_tokens_details")
864            .and_then(|details| details.get("reasoning_tokens"))
865            .and_then(Value::as_u64),
866    }
867}
868
869// Encodes normalized usage into Anthropic usage JSON.
870fn encode_anthropic_usage(usage: &Usage) -> Value {
871    json!({
872        "input_tokens": usage.input_tokens.unwrap_or(0),
873        "output_tokens": usage.output_tokens.unwrap_or(0),
874    })
875}
876
877// Maps Anthropic stop reasons to normalized stop reasons.
878fn map_anthropic_stop_reason(reason: Option<&str>) -> StopReason {
879    match reason {
880        Some("max_tokens") => StopReason::MaxTokens,
881        Some("tool_use") => StopReason::ToolUse,
882        Some("end_turn") | None => StopReason::EndTurn,
883        _ => StopReason::Unknown,
884    }
885}
886
887// Maps normalized stop reasons back to Anthropic's vocabulary.
888fn anthropic_stop_reason(reason: StopReason) -> &'static str {
889    match reason {
890        StopReason::MaxTokens => "max_tokens",
891        StopReason::ToolUse => "tool_use",
892        StopReason::EndTurn
893        | StopReason::ContentFilter
894        | StopReason::Error
895        | StopReason::Unknown => "end_turn",
896    }
897}