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::{Map, Value, json};
7
8use crate::codecs::common::{
9    ANTHROPIC_REQUEST_KEY, is_anthropic_request, is_known_role_name, provider_extensions,
10    text_from_blocks,
11};
12use crate::codecs::openai_chat::{decode_file_source, decode_image_source};
13use crate::codecs::structured_output::decode_openai_schema_enforcement;
14use crate::codecs::{
15    DecodedRequest, DecodedResponse, EncodedRequest, EncodedResponse, FormatCodec,
16};
17use crate::diagnostic::TranslationDiagnostic;
18use crate::error::{Result, TranslationError};
19use crate::format::{FormatId, WireFormat};
20use crate::llm::{
21    AggLlmResponse, ContentBlock, FileSource, ImageSource, InstructionBlock, LlmRequest,
22    MediaSource, Message, OutputParams, ProviderExtensions, ReasoningParams, ResponseOutput, Role,
23    SamplingParams, StopReason, Tool, ToolCall, ToolChoice, ToolDefinition, ToolResult, Usage,
24};
25use crate::policy::{DeterministicIdPolicy, TranslationPolicy};
26use crate::util::{
27    capture_request_preservation, capture_response_preservation, desanitize_anthropic_tool_use_id,
28    embed_preservation, exact_preserved_request, exact_preserved_response,
29    sanitize_anthropic_tool_use_id,
30};
31use crate::util::{
32    json_string, push_lossy, reject_responses_native_input_item, stable_id, string_value,
33    validate_request_capabilities,
34};
35use base64::{Engine as _, engine::general_purpose::STANDARD};
36
37/// Format codec for Anthropic Messages payloads.
38pub struct AnthropicMessagesCodec;
39
40impl FormatCodec for AnthropicMessagesCodec {
41    fn format(&self) -> FormatId {
42        WireFormat::AnthropicMessages.into()
43    }
44
45    fn decode_request(&self, body: &Value, policy: &TranslationPolicy) -> Result<DecodedRequest> {
46        let body = crate::util::object(body, "$")?;
47        if body
48            .get("fallback_credit_token")
49            .is_some_and(|value| !value.is_null())
50            && body.get("fallbacks").is_some_and(|value| !value.is_null())
51        {
52            return Err(TranslationError::InvalidValue {
53                path: "$.fallback_credit_token".to_string(),
54                message: "fallback_credit_token cannot be combined with fallbacks".to_string(),
55            });
56        }
57        let mut diagnostics = Vec::new();
58        let max_output_tokens = body
59            .get("max_tokens")
60            .map(|value| {
61                value
62                    .as_u64()
63                    .ok_or_else(|| TranslationError::InvalidValue {
64                        path: "$.max_tokens".to_string(),
65                        message: "expected a non-negative integer".to_string(),
66                    })
67            })
68            .transpose()?;
69        let response_format = decode_anthropic_output_format(body, &mut diagnostics, policy)?;
70        let mut request = LlmRequest {
71            model: body
72                .get("model")
73                .and_then(Value::as_str)
74                .filter(|model| !model.is_empty())
75                .map(ToOwned::to_owned),
76            output: OutputParams {
77                max_output_tokens,
78                is_schema_enforced: response_format.as_ref().map(|_| true),
79                response_format,
80            },
81            sampling: SamplingParams {
82                temperature: body.get("temperature").and_then(Value::as_f64),
83                top_p: body.get("top_p").and_then(Value::as_f64),
84                top_k: body.get("top_k").and_then(Value::as_i64),
85            },
86            reasoning: ReasoningParams {
87                effort: body
88                    .get("output_config")
89                    .and_then(Value::as_object)
90                    .and_then(|object| object.get("effort"))
91                    .and_then(Value::as_str)
92                    .map(ToOwned::to_owned),
93                raw: body.get("thinking").cloned(),
94            },
95            stream: body.get("stream").and_then(Value::as_bool).unwrap_or(false),
96            preservation: capture_request_preservation(
97                WireFormat::AnthropicMessages,
98                &Value::Object(body.clone()),
99                policy,
100            ),
101            ..LlmRequest::default()
102        };
103        if let Some(system) = body.get("system")
104            && let Some(content) = decode_anthropic_system(system)?
105        {
106            request.instructions.push(InstructionBlock {
107                role: Role::System,
108                content,
109            });
110        }
111        if let Some(messages) = body.get("messages") {
112            let messages = messages
113                .as_array()
114                .ok_or_else(|| TranslationError::InvalidType {
115                    path: "$.messages".to_string(),
116                    expected: "array",
117                })?;
118            let mut generated_id = 0;
119            for (index, message) in messages.iter().enumerate() {
120                let Some(message) = message.as_object() else {
121                    push_lossy(
122                        &mut diagnostics,
123                        policy,
124                        format!("Anthropic message at index {index} is not an object"),
125                    )?;
126                    continue;
127                };
128                // Request decoding enforces the provider contract: an unknown
129                // role is rejected rather than coerced to `user`. Anthropic
130                // Messages only defines `user`/`assistant`, but other known
131                // role names stay lenient (mapped to `user`) to preserve
132                // historical cross-format behaviour.
133                let role = match message.get("role").and_then(Value::as_str) {
134                    Some("assistant") => Role::Assistant,
135                    None => Role::User,
136                    Some(other) if is_known_role_name(other) => Role::User,
137                    Some(other) => {
138                        return Err(TranslationError::unsupported_role(
139                            format!("$.messages[{index}].role"),
140                            other,
141                        ));
142                    }
143                };
144                generated_id += 1;
145                let content = decode_anthropic_content(
146                    message
147                        .get("content")
148                        .unwrap_or(&Value::String(String::new())),
149                    role,
150                    generated_id,
151                    &mut diagnostics,
152                    policy,
153                )?;
154                request.messages.push(Message { role, content });
155            }
156        }
157        request.tools = decode_anthropic_tools(body.get("tools"));
158        request.tool_choice = body.get("tool_choice").map(decode_anthropic_tool_choice);
159        request.extensions.fields = provider_extensions(
160            body,
161            &[
162                "model",
163                "messages",
164                "system",
165                "tools",
166                "tool_choice",
167                "max_tokens",
168                "temperature",
169                "top_p",
170                "top_k",
171                "thinking",
172                "output_config",
173                "output_format",
174                "stream",
175                // OpenAI-only identity fields must not leak through Anthropic decoding.
176                "safety_identifier",
177            ],
178        );
179        if let Some(is_disabled) = body
180            .get("tool_choice")
181            .and_then(|choice| choice.get("disable_parallel_tool_use"))
182            .and_then(Value::as_bool)
183        {
184            request
185                .extensions
186                .fields
187                .insert("parallel_tool_calls".to_string(), Value::Bool(!is_disabled));
188        }
189        request
190            .extensions
191            .fields
192            .insert(ANTHROPIC_REQUEST_KEY.to_string(), Value::Bool(true));
193
194        Ok(DecodedRequest {
195            request,
196            diagnostics,
197        })
198    }
199
200    fn encode_request(
201        &self,
202        request: &LlmRequest,
203        policy: &TranslationPolicy,
204    ) -> Result<EncodedRequest> {
205        if let Some(body) =
206            exact_preserved_request(&request.preservation, WireFormat::AnthropicMessages, policy)
207        {
208            return Ok(EncodedRequest {
209                body,
210                diagnostics: Vec::new(),
211            });
212        }
213        let mut diagnostics = Vec::new();
214        validate_request_capabilities(request, &mut diagnostics, policy)?;
215        let allowed = crate::codecs::common::allowed_function_tools(request)?;
216        let (tools, tool_choice) = allowed.as_ref().map_or(
217            (request.tools.as_slice(), request.tool_choice.as_ref()),
218            |(tools, choice)| (tools.as_slice(), Some(choice)),
219        );
220        let mut body = Map::new();
221        if let Some(model) = &request.model {
222            body.insert("model".to_string(), Value::String(model.clone()));
223        }
224        let system_text = request
225            .instructions
226            .iter()
227            .flat_map(|instruction| instruction.content.iter())
228            .filter_map(|block| match block {
229                ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
230                _ => None,
231            })
232            .collect::<Vec<_>>()
233            .join("\n\n");
234        if !system_text.is_empty() {
235            body.insert("system".to_string(), Value::String(system_text));
236        }
237
238        body.insert(
239            "messages".to_string(),
240            Value::Array(encode_anthropic_messages(
241                &request.messages,
242                &mut diagnostics,
243                policy,
244            )?),
245        );
246
247        if !tools.is_empty() {
248            body.insert("tools".to_string(), encode_anthropic_tools(tools)?);
249        }
250        let parallel_tool_calls = request
251            .extensions
252            .fields
253            .get("parallel_tool_calls")
254            .and_then(Value::as_bool);
255        if tool_choice.is_some() || (parallel_tool_calls.is_some() && !tools.is_empty()) {
256            let mut choice = encode_anthropic_tool_choice(tool_choice.unwrap_or(&ToolChoice::Auto));
257            if let Some(is_enabled) = parallel_tool_calls
258                && let Some(object) = choice.as_object_mut()
259                && object.get("type").and_then(Value::as_str) != Some("none")
260            {
261                object.insert(
262                    "disable_parallel_tool_use".to_string(),
263                    Value::Bool(!is_enabled),
264                );
265            }
266            body.insert("tool_choice".to_string(), choice);
267        }
268        if is_anthropic_request(request) {
269            for field in [
270                "inference_geo",
271                "service_tier",
272                "stop_sequences",
273                "metadata",
274                "cache_control",
275                "container",
276                "speed",
277                "diagnostics",
278                "fallback_credit_token",
279            ] {
280                if let Some(value) = request.extensions.fields.get(field) {
281                    body.insert(field.to_string(), value.clone());
282                }
283            }
284        }
285        if let Some(stop_sequences) =
286            anthropic_stop_sequences_from_extensions(&request.extensions.fields)
287        {
288            body.entry("stop_sequences").or_insert(stop_sequences);
289        }
290        if let Some(max_tokens) = request.output.max_output_tokens {
291            body.insert("max_tokens".to_string(), json!(max_tokens));
292        } else {
293            body.insert("max_tokens".to_string(), json!(64_000));
294        }
295        if let Some(value) = request.sampling.temperature {
296            body.insert("temperature".to_string(), json!(value));
297        }
298        if let Some(value) = request.sampling.top_p {
299            body.insert("top_p".to_string(), json!(value));
300        }
301        if let Some(value) = request.sampling.top_k {
302            body.insert("top_k".to_string(), json!(value));
303        }
304        if request.stream {
305            body.insert("stream".to_string(), Value::Bool(true));
306        }
307        // Native thinking controls the mode independently of output effort.
308        // Other codecs store their own provider's reasoning object in `raw`.
309        if is_anthropic_request(request)
310            && let Some(thinking) = &request.reasoning.raw
311        {
312            body.insert("thinking".to_string(), thinking.clone());
313        }
314        if let Some(effort) = &request.reasoning.effort {
315            body.entry("thinking".to_string())
316                .or_insert_with(|| json!({"type": "adaptive"}));
317            body.insert("output_config".to_string(), json!({"effort": effort}));
318        }
319        if let Some(response_format) = &request.output.response_format
320            && let Some(format) = encode_anthropic_output_format(
321                response_format,
322                request.output.is_schema_enforced,
323                &mut diagnostics,
324                policy,
325            )?
326        {
327            let output_config = body
328                .entry("output_config".to_string())
329                .or_insert_with(|| Value::Object(Map::new()));
330            let Some(output_config) = output_config.as_object_mut() else {
331                return Err(TranslationError::InvalidType {
332                    path: "$.output_config".to_string(),
333                    expected: "object",
334                });
335            };
336            output_config.insert("format".to_string(), format);
337        } else if request.output.response_format.is_none()
338            && request.output.is_schema_enforced == Some(true)
339        {
340            push_lossy(
341                &mut diagnostics,
342                policy,
343                "Structured-output schema enforcement requires a response format with a schema",
344            )?;
345        }
346
347        let body = embed_preservation(Value::Object(body), &request.preservation, policy);
348        Ok(EncodedRequest { body, diagnostics })
349    }
350
351    fn decode_response(
352        &self,
353        body: &Value,
354        _policy: &TranslationPolicy,
355    ) -> Result<DecodedResponse> {
356        let body = crate::util::object(body, "$")?;
357        let mut content = Vec::new();
358        if let Some(blocks) = body.get("content").and_then(Value::as_array) {
359            for (index, block) in blocks.iter().enumerate() {
360                if let Some(block) = block.as_object() {
361                    content.extend(decode_anthropic_content_block(
362                        block,
363                        Role::Assistant,
364                        index + 1,
365                        &mut Vec::new(),
366                        &TranslationPolicy::default(),
367                    )?);
368                }
369            }
370        }
371        if content.is_empty() {
372            content.push(ContentBlock::Text {
373                text: String::new(),
374            });
375        }
376        let response = AggLlmResponse {
377            id: body
378                .get("id")
379                .and_then(Value::as_str)
380                .map(ToOwned::to_owned),
381            model: body
382                .get("model")
383                .and_then(Value::as_str)
384                .map(ToOwned::to_owned),
385            outputs: vec![ResponseOutput {
386                url_citations: Vec::new(),
387                role: Role::Assistant,
388                content,
389                stop_reason: Some(map_anthropic_stop_reason(
390                    body.get("stop_reason").and_then(Value::as_str),
391                )),
392            }],
393            usage: decode_anthropic_usage(body.get("usage")),
394            extensions: ProviderExtensions {
395                fields: provider_extensions(
396                    body,
397                    &[
398                        "id",
399                        "type",
400                        "role",
401                        "model",
402                        "content",
403                        "stop_reason",
404                        "usage",
405                    ],
406                ),
407            },
408            preservation: capture_response_preservation(
409                WireFormat::AnthropicMessages,
410                &Value::Object(body.clone()),
411                _policy,
412            ),
413        };
414        Ok(DecodedResponse {
415            response,
416            diagnostics: Vec::new(),
417        })
418    }
419
420    fn encode_response(
421        &self,
422        response: &AggLlmResponse,
423        _policy: &TranslationPolicy,
424    ) -> Result<EncodedResponse> {
425        super::super::responses::validate_response_output(response, WireFormat::AnthropicMessages)?;
426        if let Some(body) = exact_preserved_response(
427            &response.preservation,
428            WireFormat::AnthropicMessages,
429            _policy,
430        ) {
431            return Ok(EncodedResponse {
432                body,
433                diagnostics: Vec::new(),
434            });
435        }
436        let output = response.first_output();
437        let content = output
438            .map(|output| encode_anthropic_content(&output.content))
439            .transpose()?
440            .unwrap_or_else(|| vec![json!({"type": "text", "text": ""})]);
441        let normalized_stop_reason = output.and_then(|output| output.stop_reason);
442        let body = json!({
443            "id": response.id.clone().unwrap_or_else(|| "msg_switchyard".to_string()),
444            "type": "message",
445            "role": "assistant",
446            "model": response.model.clone().unwrap_or_else(|| "unknown".to_string()),
447            "content": content,
448            "stop_reason": normalized_stop_reason
449                .map(anthropic_stop_reason)
450                .unwrap_or("end_turn"),
451            "stop_sequence": Value::Null,
452            "stop_details": normalized_stop_reason
453                .map(|reason| {
454                    anthropic_stop_details(reason, response.extensions.fields.get("stop_details"))
455                })
456                .unwrap_or(Value::Null),
457            "usage": encode_anthropic_usage(&response.usage),
458        });
459        Ok(EncodedResponse {
460            body: embed_preservation(body, &response.preservation, _policy),
461            diagnostics: Vec::new(),
462        })
463    }
464}
465
466// Reads the current `output_config.format`, or the beta `output_format` it replaced,
467// into the neutral OpenAI-shaped response format.
468fn decode_anthropic_output_format(
469    body: &Map<String, Value>,
470    diagnostics: &mut Vec<TranslationDiagnostic>,
471    policy: &TranslationPolicy,
472) -> Result<Option<Value>> {
473    let Some(format) = body
474        .get("output_config")
475        .and_then(Value::as_object)
476        .and_then(|config| config.get("format"))
477        .or_else(|| body.get("output_format"))
478    else {
479        return Ok(None);
480    };
481    let Some(format) = format.as_object() else {
482        push_lossy(
483            diagnostics,
484            policy,
485            "Anthropic structured output format is not an object; the requested format was dropped",
486        )?;
487        return Ok(None);
488    };
489    if format.get("type").and_then(Value::as_str) != Some("json_schema") {
490        push_lossy(
491            diagnostics,
492            policy,
493            "Anthropic structured output maps only a json_schema format; the requested format was dropped",
494        )?;
495        return Ok(None);
496    }
497    // A non-object schema would reach the upstream as a malformed `json_schema.schema`.
498    let Some(schema) = format.get("schema").filter(|schema| schema.is_object()) else {
499        push_lossy(
500            diagnostics,
501            policy,
502            "Anthropic structured output requires format.schema to be an object; the requested format was dropped",
503        )?;
504        return Ok(None);
505    };
506    Ok(Some(json!({
507        "type": "json_schema",
508        "json_schema": {
509            // Anthropic identifies the schema by position; the neutral shape needs a name.
510            "name": "response",
511            "schema": schema.clone(),
512        },
513    })))
514}
515
516/// Maps the neutral OpenAI-shaped JSON schema to Anthropic's output format.
517fn encode_anthropic_output_format(
518    response_format: &Value,
519    enforcement: Option<bool>,
520    diagnostics: &mut Vec<TranslationDiagnostic>,
521    policy: &TranslationPolicy,
522) -> Result<Option<Value>> {
523    let Some(json_schema) = response_format
524        .as_object()
525        .filter(|format| format.get("type").and_then(Value::as_str) == Some("json_schema"))
526        .and_then(|format| format.get("json_schema"))
527        .and_then(Value::as_object)
528    else {
529        push_lossy(
530            diagnostics,
531            policy,
532            "Anthropic structured output requires an OpenAI JSON schema response format",
533        )?;
534        return Ok(None);
535    };
536    let Some(schema) = json_schema.get("schema") else {
537        push_lossy(
538            diagnostics,
539            policy,
540            "Anthropic structured output requires json_schema.schema",
541        )?;
542        return Ok(None);
543    };
544
545    if enforcement.or_else(|| decode_openai_schema_enforcement(Some(response_format)))
546        == Some(false)
547    {
548        push_lossy(
549            diagnostics,
550            policy,
551            "Anthropic structured output enforces the schema; advisory schema enforcement was strengthened",
552        )?;
553    }
554    let mut schema = schema.clone();
555    if strip_anthropic_unsupported_constraints(&mut schema) {
556        push_lossy(
557            diagnostics,
558            policy,
559            "Anthropic structured output dropped unsupported JSON Schema constraints",
560        )?;
561    }
562    Ok(Some(json!({"type": "json_schema", "schema": schema})))
563}
564
565/// Removes constraints unsupported by Anthropic's structured-output grammar.
566fn strip_anthropic_unsupported_constraints(value: &mut Value) -> bool {
567    match value {
568        Value::Object(object) => {
569            let mut removed = false;
570            for key in ["minimum", "maximum", "minLength", "maxLength"] {
571                removed |= object.remove(key).is_some();
572            }
573            for value in object.values_mut() {
574                removed |= strip_anthropic_unsupported_constraints(value);
575            }
576            removed
577        }
578        Value::Array(values) => {
579            let mut removed = false;
580            for value in values {
581                removed |= strip_anthropic_unsupported_constraints(value);
582            }
583            removed
584        }
585        _ => false,
586    }
587}
588
589// Decodes Anthropic's `system` field into instruction blocks.
590fn decode_anthropic_system(value: &Value) -> Result<Option<Vec<ContentBlock>>> {
591    match value {
592        Value::String(text) if !text.is_empty() => {
593            Ok(Some(vec![ContentBlock::Text { text: text.clone() }]))
594        }
595        Value::String(_) | Value::Null => Ok(None),
596        Value::Array(blocks) => {
597            let mut content = Vec::new();
598            for block in blocks {
599                if let Some(block) = block.as_object()
600                    && block.get("type").and_then(Value::as_str) == Some("text")
601                {
602                    let text = block
603                        .get("text")
604                        .and_then(Value::as_str)
605                        .unwrap_or_default()
606                        .to_string();
607                    content.push(ContentBlock::Text { text });
608                }
609            }
610            Ok((!content.is_empty()).then_some(content))
611        }
612        _ => Err(TranslationError::InvalidType {
613            path: "$.system".to_string(),
614            expected: "string or array of text blocks",
615        }),
616    }
617}
618
619// Decodes Anthropic message content into normalized content blocks.
620fn decode_anthropic_content(
621    value: &Value,
622    role: Role,
623    generated_counter: usize,
624    diagnostics: &mut Vec<TranslationDiagnostic>,
625    policy: &TranslationPolicy,
626) -> Result<Vec<ContentBlock>> {
627    match value {
628        Value::String(text) => Ok(vec![ContentBlock::Text { text: text.clone() }]),
629        Value::Null => Ok(vec![ContentBlock::Text {
630            text: String::new(),
631        }]),
632        Value::Array(blocks) => {
633            let mut content = Vec::new();
634            for (index, block) in blocks.iter().enumerate() {
635                let Some(block) = block.as_object() else {
636                    push_lossy(
637                        diagnostics,
638                        policy,
639                        format!("Anthropic content block {index} is not an object"),
640                    )?;
641                    continue;
642                };
643                content.extend(decode_anthropic_content_block(
644                    block,
645                    role,
646                    generated_counter + index,
647                    diagnostics,
648                    policy,
649                )?);
650            }
651            if content.is_empty() {
652                content.push(ContentBlock::Text {
653                    text: String::new(),
654                });
655            }
656            Ok(content)
657        }
658        other => Ok(vec![ContentBlock::Text {
659            text: string_value(other).unwrap_or_default(),
660        }]),
661    }
662}
663
664// Decodes one Anthropic content block into one or more IR blocks.
665fn decode_anthropic_content_block(
666    block: &Map<String, Value>,
667    _role: Role,
668    generated_counter: usize,
669    _diagnostics: &mut Vec<TranslationDiagnostic>,
670    policy: &TranslationPolicy,
671) -> Result<Vec<ContentBlock>> {
672    Ok(match block.get("type").and_then(Value::as_str) {
673        Some("text") => vec![ContentBlock::Text {
674            text: block
675                .get("text")
676                .and_then(Value::as_str)
677                .unwrap_or_default()
678                .to_string(),
679        }],
680        Some("thinking") => vec![ContentBlock::Reasoning {
681            text: block
682                .get("thinking")
683                .and_then(Value::as_str)
684                .unwrap_or_default()
685                .to_string(),
686            signature: block
687                .get("signature")
688                .and_then(Value::as_str)
689                .filter(|signature| !signature.is_empty())
690                .map(ToOwned::to_owned),
691            details: Vec::new(),
692        }],
693        Some("tool_use") => vec![ContentBlock::ToolCall(ToolCall {
694            id: block
695                .get("id")
696                .and_then(Value::as_str)
697                .filter(|id| !id.is_empty())
698                .map(desanitize_anthropic_tool_use_id)
699                .unwrap_or_else(|| match &policy.deterministic_ids {
700                    DeterministicIdPolicy::GenerateStable { prefix } => {
701                        stable_id(prefix, generated_counter)
702                    }
703                    DeterministicIdPolicy::Preserve => String::new(),
704                }),
705            name: block
706                .get("name")
707                .and_then(Value::as_str)
708                .unwrap_or_default()
709                .to_string(),
710            arguments: block.get("input").cloned().unwrap_or_else(|| json!({})),
711        })],
712        Some("tool_result") => vec![ContentBlock::ToolResult(ToolResult {
713            tool_call_id: block
714                .get("tool_use_id")
715                .and_then(Value::as_str)
716                .map(desanitize_anthropic_tool_use_id)
717                .unwrap_or_default(),
718            content: decode_tool_result_content(block.get("content").unwrap_or(&Value::Null)),
719            is_error: block.get("is_error").and_then(Value::as_bool),
720        })],
721        Some("image") => vec![ContentBlock::Image {
722            source: ImageSource::Raw(Value::Object(block.clone())),
723        }],
724        Some("input_image") | Some("image_url") => decode_image_source(block)
725            .map(|source| vec![ContentBlock::Image { source }])
726            .unwrap_or_default(),
727        Some("input_file") | Some("file") => vec![ContentBlock::File {
728            source: decode_file_source(block),
729        }],
730        Some("document") => vec![ContentBlock::File {
731            source: decode_anthropic_file_source(block),
732        }],
733        _ => vec![ContentBlock::Unknown {
734            provider: WireFormat::AnthropicMessages.into(),
735            raw: Value::Object(block.clone()),
736        }],
737    })
738}
739
740// Preserves supported Anthropic tool-result blocks in the neutral IR.
741fn decode_tool_result_content(value: &Value) -> Vec<ContentBlock> {
742    match value {
743        Value::String(text) => vec![ContentBlock::Text { text: text.clone() }],
744        Value::Array(blocks) => {
745            let mut content = Vec::new();
746            for block in blocks {
747                if let Some(block) = block.as_object() {
748                    match block.get("type").and_then(Value::as_str) {
749                        Some("text") => content.push(ContentBlock::Text {
750                            text: block
751                                .get("text")
752                                .and_then(Value::as_str)
753                                .unwrap_or_default()
754                                .to_string(),
755                        }),
756                        Some("image") => content.push(ContentBlock::Image {
757                            source: ImageSource::Raw(Value::Object(block.clone())),
758                        }),
759                        Some("document") => content.push(ContentBlock::File {
760                            source: decode_anthropic_file_source(block),
761                        }),
762                        _ => content.push(ContentBlock::Unknown {
763                            provider: WireFormat::AnthropicMessages.into(),
764                            raw: Value::Object(block.clone()),
765                        }),
766                    }
767                }
768            }
769            content
770        }
771        Value::Null => vec![ContentBlock::Text {
772            text: String::new(),
773        }],
774        other => vec![ContentBlock::Text {
775            text: json_string(other),
776        }],
777    }
778}
779
780// Keeps Anthropic document fields together for same-format re-encoding.
781fn decode_anthropic_file_source(block: &Map<String, Value>) -> FileSource {
782    FileSource::Raw(Value::Object(block.clone()))
783}
784
785// Decodes Anthropic tool definitions into normalized tool definitions.
786fn decode_anthropic_tools(value: Option<&Value>) -> Vec<Tool> {
787    value
788        .and_then(Value::as_array)
789        .into_iter()
790        .flatten()
791        .filter_map(Value::as_object)
792        .filter_map(|tool| {
793            if tool
794                .get("type")
795                .and_then(Value::as_str)
796                .is_some_and(|kind| kind != "custom")
797            {
798                return Some(Tool::Provider {
799                    provider: WireFormat::AnthropicMessages.into(),
800                    raw: Value::Object(tool.clone()),
801                });
802            }
803            let name = tool.get("name").and_then(Value::as_str)?.to_string();
804            (!name.is_empty()).then(|| {
805                Tool::Function(ToolDefinition {
806                    name,
807                    description: tool
808                        .get("description")
809                        .and_then(Value::as_str)
810                        .map(ToOwned::to_owned),
811                    parameters: tool
812                        .get("input_schema")
813                        .cloned()
814                        .unwrap_or_else(|| json!({})),
815                    strict: tool.get("strict").and_then(Value::as_bool),
816                })
817            })
818        })
819        .collect()
820}
821
822// Decodes Anthropic tool-choice values into normalized policy.
823fn decode_anthropic_tool_choice(value: &Value) -> ToolChoice {
824    match value {
825        Value::String(text) if text == "auto" => ToolChoice::Auto,
826        Value::String(text) if text == "any" => ToolChoice::Required,
827        Value::String(text) if text == "none" => ToolChoice::None,
828        Value::Object(object) => match object.get("type").and_then(Value::as_str) {
829            Some("auto") => ToolChoice::Auto,
830            Some("any") => ToolChoice::Required,
831            Some("none") => ToolChoice::None,
832            Some("tool") => object
833                .get("name")
834                .and_then(Value::as_str)
835                .map(|name| ToolChoice::Tool {
836                    name: name.to_string(),
837                })
838                .unwrap_or_else(|| ToolChoice::Raw(value.clone())),
839            _ => ToolChoice::Raw(value.clone()),
840        },
841        _ => ToolChoice::Raw(value.clone()),
842    }
843}
844
845// Encodes one normalized message into Anthropic message JSON.
846fn encode_anthropic_message(
847    message: &Message,
848    diagnostics: &mut Vec<TranslationDiagnostic>,
849    policy: &TranslationPolicy,
850) -> Result<Value> {
851    let role = match message.role {
852        Role::Assistant => "assistant",
853        Role::User | Role::Tool | Role::System | Role::Developer => "user",
854    };
855    let content = encode_anthropic_content_with_policy(&message.content, diagnostics, policy)?;
856    let simple_text = content.len() == 1
857        && content
858            .first()
859            .and_then(Value::as_object)
860            .and_then(|object| object.get("type"))
861            .and_then(Value::as_str)
862            == Some("text");
863    let content = if simple_text {
864        content
865            .first()
866            .and_then(Value::as_object)
867            .and_then(|object| object.get("text"))
868            .cloned()
869            .unwrap_or_else(|| Value::String(String::new()))
870    } else {
871        Value::Array(content)
872    };
873    Ok(json!({"role": role, "content": content}))
874}
875
876// Encodes messages while grouping adjacent tool-result-only messages correctly.
877fn encode_anthropic_messages(
878    messages: &[Message],
879    diagnostics: &mut Vec<TranslationDiagnostic>,
880    policy: &TranslationPolicy,
881) -> Result<Vec<Value>> {
882    let mut encoded = Vec::new();
883    let mut index = 0;
884
885    while let Some(message) = messages.get(index) {
886        if !message_is_tool_result_only(message) {
887            encoded.push(encode_anthropic_message(message, diagnostics, policy)?);
888            index += 1;
889            continue;
890        }
891
892        let mut content = Vec::new();
893        while let Some(tool_message) = messages.get(index) {
894            if !message_is_tool_result_only(tool_message) {
895                break;
896            }
897            content.extend(encode_anthropic_content_with_policy(
898                &tool_message.content,
899                diagnostics,
900                policy,
901            )?);
902            index += 1;
903        }
904        encoded.push(json!({"role": "user", "content": content}));
905    }
906
907    Ok(encoded)
908}
909
910// Maps preserved OpenAI-style stop extensions to Anthropic stop sequences.
911fn anthropic_stop_sequences_from_extensions(extensions: &Map<String, Value>) -> Option<Value> {
912    match extensions.get("stop") {
913        Some(Value::String(stop)) => Some(json!([stop])),
914        Some(Value::Array(stops)) => Some(Value::Array(stops.clone())),
915        _ => None,
916    }
917}
918
919// Checks whether a message contains only tool-result blocks.
920fn message_is_tool_result_only(message: &Message) -> bool {
921    (message.role == Role::Tool || message.role == Role::User)
922        && !message.content.is_empty()
923        && message
924            .content
925            .iter()
926            .all(|block| matches!(block, ContentBlock::ToolResult(_)))
927}
928
929// Encodes content while applying lossy-conversion policy to unknown blocks.
930fn encode_anthropic_content_with_policy(
931    content: &[ContentBlock],
932    diagnostics: &mut Vec<TranslationDiagnostic>,
933    policy: &TranslationPolicy,
934) -> Result<Vec<Value>> {
935    let mut blocks = Vec::new();
936    for block in content {
937        crate::codecs::openai_media::validate_media(block, WireFormat::AnthropicMessages)?;
938        match block {
939            ContentBlock::Unknown { provider, raw } => {
940                reject_responses_native_input_item(provider, raw, WireFormat::AnthropicMessages)?;
941                push_lossy(
942                    diagnostics,
943                    policy,
944                    "unknown content block encoded as text for Anthropic",
945                )?;
946                blocks.push(json!({"type": "text", "text": json_string(raw)}));
947            }
948            other => blocks.extend(encode_one_anthropic_block(other)?),
949        }
950    }
951    if blocks.is_empty() {
952        blocks.push(json!({"type": "text", "text": ""}));
953    }
954    Ok(blocks)
955}
956
957fn encode_anthropic_file(source: &FileSource) -> Result<Value> {
958    match source {
959        FileSource::FileData { data, filename } => {
960            let (media_type, data) = split_base64_data_uri(data)
961                .unwrap_or_else(|| (document_media_type(filename.as_deref()), data.as_str()));
962            let source = match media_type {
963                "text/plain" => {
964                    let bytes =
965                        STANDARD
966                            .decode(data)
967                            .map_err(|_| TranslationError::InvalidValue {
968                                path: "file_data".into(),
969                                message: "invalid base64 text document".into(),
970                            })?;
971                    let text =
972                        String::from_utf8(bytes).map_err(|_| TranslationError::InvalidValue {
973                            path: "file_data".into(),
974                            message: "text document must be UTF-8".into(),
975                        })?;
976                    json!({"type": "text", "media_type": media_type, "data": text})
977                }
978                "application/pdf" => {
979                    json!({"type": "base64", "media_type": media_type, "data": data})
980                }
981                _ => {
982                    return Err(TranslationError::LossyConversion(
983                        "Anthropic requires PDF or plain-text documents".into(),
984                    ));
985                }
986            };
987            let mut block = json!({"type": "document", "source": source});
988            if let Some(filename) = filename {
989                block["title"] = filename.clone().into();
990            }
991            Ok(block)
992        }
993        FileSource::Raw(raw) if raw.get("type").and_then(Value::as_str) == Some("input_file") => {
994            let url = raw.get("file_url").and_then(Value::as_str).ok_or_else(|| {
995                TranslationError::LossyConversion("unsupported Anthropic document source".into())
996            })?;
997            let mut block = json!({"type": "document", "source": {"type": "url", "url": url}});
998            if let Some(filename) = raw.get("filename") {
999                block["title"] = filename.clone();
1000            }
1001            Ok(block)
1002        }
1003        FileSource::Raw(raw) => Ok(raw.clone()),
1004        FileSource::FileId(file_id) => Ok(json!({
1005            "type": "document", "source": {"type": "file", "file_id": file_id}
1006        })),
1007    }
1008}
1009
1010// Encodes content without producing diagnostics for response paths.
1011fn encode_anthropic_content(content: &[ContentBlock]) -> Result<Vec<Value>> {
1012    let mut blocks = Vec::new();
1013    for block in content {
1014        crate::codecs::openai_media::validate_media(block, WireFormat::AnthropicMessages)?;
1015        blocks.extend(encode_one_anthropic_response_block(block)?);
1016    }
1017    if blocks.is_empty() {
1018        blocks.push(json!({"type": "text", "text": ""}));
1019    }
1020    Ok(blocks)
1021}
1022
1023// Encodes response content, where synthetic reasoning may be shown to clients.
1024fn encode_one_anthropic_response_block(block: &ContentBlock) -> Result<Vec<Value>> {
1025    match block {
1026        ContentBlock::Reasoning {
1027            text,
1028            signature: None,
1029            ..
1030        } => Ok(vec![json!({
1031            "type": "thinking",
1032            "thinking": text,
1033            "signature": "",
1034        })]),
1035        other => encode_one_anthropic_block(other),
1036    }
1037}
1038
1039// Encodes a single normalized content block into Anthropic block JSON.
1040fn encode_one_anthropic_block(block: &ContentBlock) -> Result<Vec<Value>> {
1041    Ok(match block {
1042        ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
1043            vec![json!({"type": "text", "text": text})]
1044        }
1045        ContentBlock::Reasoning {
1046            text,
1047            signature: Some(signature),
1048            ..
1049        } if !signature.is_empty() => vec![json!({
1050            "type": "thinking",
1051            "thinking": text,
1052            "signature": signature,
1053        })],
1054        ContentBlock::Reasoning { .. } => Vec::new(),
1055        ContentBlock::ToolCall(call) => vec![json!({
1056            "type": "tool_use",
1057            "id": sanitize_anthropic_tool_use_id(&call.id),
1058            "name": call.name,
1059            "input": anthropic_tool_input(&call.arguments),
1060        })],
1061        ContentBlock::ToolResult(result) => {
1062            let content = if result.content.iter().all(|block| {
1063                matches!(
1064                    block,
1065                    ContentBlock::Text { .. } | ContentBlock::Refusal { .. }
1066                )
1067            }) {
1068                Value::String(text_from_blocks(&result.content, " "))
1069            } else {
1070                let mut content = Vec::new();
1071                for block in &result.content {
1072                    content.extend(encode_one_anthropic_tool_result_block(block)?);
1073                }
1074                Value::Array(content)
1075            };
1076            let mut item = json!({
1077                "type": "tool_result",
1078                "tool_use_id": sanitize_anthropic_tool_use_id(&result.tool_call_id),
1079                "content": content,
1080            });
1081            if let Some(is_error) = result.is_error {
1082                item["is_error"] = Value::Bool(is_error);
1083            }
1084            vec![item]
1085        }
1086        ContentBlock::Image { source } => vec![match source {
1087            ImageSource::Url { url, .. } => match split_base64_data_uri(url) {
1088                Some((media_type, data)) => json!({
1089                    "type": "image",
1090                    "source": {"type": "base64", "media_type": media_type, "data": data},
1091                }),
1092                None => json!({"type": "image", "source": {"type": "url", "url": url}}),
1093            },
1094            ImageSource::Base64 { media_type, data } => json!({
1095                "type": "image",
1096                "source": {
1097                    "type": "base64",
1098                    "media_type": media_type.clone().unwrap_or_else(|| "image/png".to_string()),
1099                    "data": data,
1100                },
1101            }),
1102            ImageSource::Raw(raw) => raw.clone(),
1103        }],
1104        ContentBlock::File { source } => vec![encode_anthropic_file(source)?],
1105        ContentBlock::Audio { source } => vec![match source {
1106            MediaSource::Url { url, media_type } => {
1107                json!({"type": "audio", "source": {"type": "url", "url": url, "media_type": media_type}})
1108            }
1109            MediaSource::Base64 { media_type, data } => json!({
1110                "type": "audio",
1111                "source": {
1112                    "type": "base64",
1113                    "media_type": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
1114                    "data": data,
1115                },
1116            }),
1117            MediaSource::Raw(raw) => raw.clone(),
1118        }],
1119        ContentBlock::Video { source } => vec![match source {
1120            MediaSource::Url { url, media_type } => {
1121                json!({"type": "video", "source": {"type": "url", "url": url, "media_type": media_type}})
1122            }
1123            MediaSource::Base64 { media_type, data } => json!({
1124                "type": "video",
1125                "source": {
1126                    "type": "base64",
1127                    "media_type": media_type.clone().unwrap_or_else(|| "video/mp4".to_string()),
1128                    "data": data,
1129                },
1130            }),
1131            MediaSource::Raw(raw) => raw.clone(),
1132        }],
1133        ContentBlock::Mcp(_) => {
1134            return Err(TranslationError::LossyConversion(
1135                "MCP history encoding is not implemented".into(),
1136            ));
1137        }
1138        ContentBlock::Unknown { raw, .. } => vec![raw.clone()],
1139    })
1140}
1141
1142// Encodes only provider-safe block shapes inside Anthropic tool results.
1143fn encode_one_anthropic_tool_result_block(block: &ContentBlock) -> Result<Vec<Value>> {
1144    match block {
1145        ContentBlock::Text { .. }
1146        | ContentBlock::Refusal { .. }
1147        | ContentBlock::Image { .. }
1148        | ContentBlock::File { .. } => encode_one_anthropic_block(block),
1149        ContentBlock::Unknown { provider, raw }
1150            if provider.as_str() == WireFormat::AnthropicMessages.as_str() =>
1151        {
1152            Ok(vec![raw.clone()])
1153        }
1154        ContentBlock::Unknown { raw, .. } => {
1155            Ok(vec![json!({"type": "text", "text": json_string(raw)})])
1156        }
1157        ContentBlock::Mcp(_) => Err(TranslationError::LossyConversion(
1158            "MCP history encoding is not implemented".into(),
1159        )),
1160        ContentBlock::Reasoning { .. }
1161        | ContentBlock::Audio { .. }
1162        | ContentBlock::Video { .. }
1163        | ContentBlock::ToolCall(_)
1164        | ContentBlock::ToolResult(_) => Ok(Vec::new()),
1165    }
1166}
1167
1168// Anthropic accepts PDF and plain-text documents; a file with no media type is a PDF
1169// unless its name says otherwise.
1170fn document_media_type(filename: Option<&str>) -> &'static str {
1171    match filename
1172        .and_then(|name| name.rsplit_once('.'))
1173        .map(|(_, ext)| ext)
1174    {
1175        Some(ext) if ext.eq_ignore_ascii_case("txt") || ext.eq_ignore_ascii_case("md") => {
1176            "text/plain"
1177        }
1178        _ => "application/pdf",
1179    }
1180}
1181
1182// Splits `data:<media type>[;<parameter>...];base64,<payload>`, the form OpenAI-compatible
1183// clients use for inline images. A percent-encoded payload has to be decoded before it is
1184// base64, and a data URI with no media type leaves Anthropic's `media_type` with nothing to
1185// carry, so both keep the handling they had before and are relayed as-is.
1186fn split_base64_data_uri(url: &str) -> Option<(&str, &str)> {
1187    let (metadata, data) = url.strip_prefix("data:")?.split_once(',')?;
1188    let parameters = metadata.strip_suffix(";base64")?;
1189    let media_type = parameters
1190        .split_once(';')
1191        .map_or(parameters, |(media_type, _)| media_type);
1192    if media_type.is_empty() || data.contains('%') {
1193        return None;
1194    }
1195    Some((media_type, data))
1196}
1197
1198// Anthropic requires `tool_use.input` to be object-shaped, while OpenAI and
1199// Responses commonly carry function-call arguments as JSON strings.
1200fn anthropic_tool_input(arguments: &Value) -> Value {
1201    match arguments {
1202        Value::Object(object) => Value::Object(object.clone()),
1203        Value::String(text) => serde_json::from_str::<Value>(text)
1204            .map_or_else(|_| json!({"raw": text}), ensure_anthropic_tool_input_object),
1205        Value::Null => json!({}),
1206        other => json!({"value": other}),
1207    }
1208}
1209
1210// Preserve valid objects and wrap every other JSON shape in a dictionary so
1211// translated requests satisfy Anthropic's schema without discarding payloads.
1212fn ensure_anthropic_tool_input_object(arguments: Value) -> Value {
1213    match arguments {
1214        Value::Object(_) => arguments,
1215        Value::Null => json!({}),
1216        other => json!({"value": other}),
1217    }
1218}
1219
1220// Encodes normalized tool definitions into Anthropic tool JSON.
1221fn encode_anthropic_tools(tools: &[Tool]) -> Result<Value> {
1222    Ok(Value::Array(
1223        tools
1224            .iter()
1225            .map(|tool| {
1226                if let Tool::Provider { provider, raw } = tool
1227                    && provider.as_str() == WireFormat::AnthropicMessages.as_str()
1228                {
1229                    return Ok(raw.clone());
1230                }
1231                let tool = tool.function().ok_or_else(|| {
1232                    TranslationError::LossyConversion(
1233                        "MCP and provider tool encoding is not implemented".into(),
1234                    )
1235                })?;
1236                let mut item = json!({
1237                    "name": tool.name,
1238                    "description": tool.description.clone().unwrap_or_default(),
1239                    "input_schema": tool.parameters,
1240                });
1241                if let Some(strict) = tool.strict {
1242                    item["strict"] = Value::Bool(strict);
1243                }
1244                Ok(item)
1245            })
1246            .collect::<Result<_>>()?,
1247    ))
1248}
1249
1250// Encodes normalized tool choice into Anthropic tool-choice JSON.
1251fn encode_anthropic_tool_choice(choice: &ToolChoice) -> Value {
1252    match choice {
1253        ToolChoice::Auto => json!({"type": "auto"}),
1254        ToolChoice::Required => json!({"type": "any"}),
1255        ToolChoice::None => json!({"type": "none"}),
1256        ToolChoice::Tool { name } => json!({"type": "tool", "name": name}),
1257        ToolChoice::Raw(value) => value.clone(),
1258    }
1259}
1260
1261// Normalizes Anthropic usage fields.
1262fn decode_anthropic_usage(value: Option<&Value>) -> Usage {
1263    let Some(value) = value.and_then(Value::as_object) else {
1264        return Usage::default();
1265    };
1266    let input_tokens = value.get("input_tokens").and_then(Value::as_u64);
1267    let cached_input_tokens = value.get("cache_read_input_tokens").and_then(Value::as_u64);
1268    let cache_creation_input_tokens = value
1269        .get("cache_creation_input_tokens")
1270        .and_then(Value::as_u64);
1271    let output_tokens = value.get("output_tokens").and_then(Value::as_u64);
1272    Usage {
1273        provider_cost: crate::codecs::common::provider_cost(value),
1274        input_tokens,
1275        cache: crate::codecs::common::cache_usage_details(
1276            value,
1277            cached_input_tokens,
1278            cache_creation_input_tokens,
1279        ),
1280        output_tokens,
1281        total_tokens: input_tokens.zip(output_tokens).map(|(input, output)| {
1282            input
1283                + cached_input_tokens.unwrap_or(0)
1284                + cache_creation_input_tokens.unwrap_or(0)
1285                + output
1286        }),
1287        reasoning_tokens: value.get("output_tokens_details").and_then(|details| {
1288            details
1289                .get("thinking_tokens")
1290                .and_then(Value::as_u64)
1291                .or_else(|| details.get("reasoning_tokens").and_then(Value::as_u64))
1292        }),
1293    }
1294}
1295
1296// Encodes normalized usage into Anthropic usage JSON.
1297fn encode_anthropic_usage(usage: &Usage) -> Value {
1298    let mut value = json!({
1299        "input_tokens": usage.input_tokens.unwrap_or(0),
1300        "output_tokens": usage.output_tokens.unwrap_or(0),
1301    });
1302    if let Some(cached_tokens) = usage.cached_input_tokens() {
1303        value["cache_read_input_tokens"] = json!(cached_tokens);
1304    }
1305    if let Some(cache_creation_tokens) = usage.cache_creation_input_tokens() {
1306        value["cache_creation_input_tokens"] = json!(cache_creation_tokens);
1307    }
1308    if let Some(thinking_tokens) = usage.reasoning_tokens {
1309        value["output_tokens_details"] = json!({"thinking_tokens": thinking_tokens});
1310    }
1311    crate::codecs::common::encode_usage_metadata(value, usage)
1312}
1313
1314// Maps Anthropic stop reasons to normalized stop reasons.
1315fn map_anthropic_stop_reason(reason: Option<&str>) -> StopReason {
1316    match reason {
1317        Some("max_tokens") => StopReason::MaxTokens,
1318        Some("tool_use") => StopReason::ToolUse,
1319        Some("refusal") => StopReason::ContentFilter,
1320        Some("end_turn") | None => StopReason::EndTurn,
1321        _ => StopReason::Unknown,
1322    }
1323}
1324
1325// Maps normalized stop reasons back to Anthropic's vocabulary.
1326fn anthropic_stop_reason(reason: StopReason) -> &'static str {
1327    match reason {
1328        StopReason::MaxTokens => "max_tokens",
1329        StopReason::ToolUse => "tool_use",
1330        StopReason::ContentFilter => "refusal",
1331        StopReason::EndTurn | StopReason::Error | StopReason::Unknown => "end_turn",
1332    }
1333}
1334
1335// Emits the metadata object Anthropic pairs with a `refusal` stop reason.
1336//
1337// An Anthropic source keeps its `stop_details` in provider extensions, so replay that
1338// object and preserve the named policy category and its explanation. Only a refusal
1339// synthesized from a provider that reports no category falls back to the null form,
1340// which Anthropic documents as the normal value for a refusal that maps to no named
1341// category.
1342fn anthropic_stop_details(reason: StopReason, source: Option<&Value>) -> Value {
1343    match reason {
1344        StopReason::ContentFilter => source
1345            .filter(|details| !details.is_null())
1346            .cloned()
1347            .unwrap_or_else(|| {
1348                json!({
1349                    "type": "refusal",
1350                    "category": Value::Null,
1351                    "explanation": Value::Null,
1352                })
1353            }),
1354        _ => Value::Null,
1355    }
1356}