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