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