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