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            for value in object.values_mut() {
609                removed |= strip_anthropic_unsupported_constraints(value);
610            }
611            removed
612        }
613        Value::Array(values) => {
614            let mut removed = false;
615            for value in values {
616                removed |= strip_anthropic_unsupported_constraints(value);
617            }
618            removed
619        }
620        _ => false,
621    }
622}
623
624// Decodes Anthropic's `system` field into instruction blocks.
625fn decode_anthropic_system(value: &Value) -> Result<Option<Vec<ContentBlock>>> {
626    match value {
627        Value::String(text) if !text.is_empty() => {
628            Ok(Some(vec![ContentBlock::Text { text: text.clone() }]))
629        }
630        Value::String(_) | Value::Null => Ok(None),
631        Value::Array(blocks) => {
632            let mut content = Vec::new();
633            for block in blocks {
634                if let Some(block) = block.as_object()
635                    && block.get("type").and_then(Value::as_str) == Some("text")
636                {
637                    let text = block
638                        .get("text")
639                        .and_then(Value::as_str)
640                        .unwrap_or_default()
641                        .to_string();
642                    content.push(ContentBlock::Text { text });
643                }
644            }
645            Ok((!content.is_empty()).then_some(content))
646        }
647        _ => Err(TranslationError::InvalidType {
648            path: "$.system".to_string(),
649            expected: "string or array of text blocks",
650        }),
651    }
652}
653
654// Decodes Anthropic message content into normalized content blocks.
655fn decode_anthropic_content(
656    value: &Value,
657    role: Role,
658    generated_counter: usize,
659    diagnostics: &mut Vec<TranslationDiagnostic>,
660    policy: &TranslationPolicy,
661) -> Result<Vec<ContentBlock>> {
662    match value {
663        Value::String(text) => Ok(vec![ContentBlock::Text { text: text.clone() }]),
664        Value::Null => Ok(vec![ContentBlock::Text {
665            text: String::new(),
666        }]),
667        Value::Array(blocks) => {
668            let mut content = Vec::new();
669            for (index, block) in blocks.iter().enumerate() {
670                let Some(block) = block.as_object() else {
671                    push_lossy(
672                        diagnostics,
673                        policy,
674                        format!("Anthropic content block {index} is not an object"),
675                    )?;
676                    continue;
677                };
678                content.extend(decode_anthropic_content_block(
679                    block,
680                    role,
681                    generated_counter + index,
682                    diagnostics,
683                    policy,
684                )?);
685            }
686            if content.is_empty() {
687                content.push(ContentBlock::Text {
688                    text: String::new(),
689                });
690            }
691            Ok(content)
692        }
693        other => Ok(vec![ContentBlock::Text {
694            text: string_value(other).unwrap_or_default(),
695        }]),
696    }
697}
698
699// Decodes one Anthropic content block into one or more IR blocks.
700fn decode_anthropic_content_block(
701    block: &Map<String, Value>,
702    _role: Role,
703    generated_counter: usize,
704    _diagnostics: &mut Vec<TranslationDiagnostic>,
705    policy: &TranslationPolicy,
706) -> Result<Vec<ContentBlock>> {
707    Ok(match block.get("type").and_then(Value::as_str) {
708        Some("text") => vec![ContentBlock::Text {
709            text: block
710                .get("text")
711                .and_then(Value::as_str)
712                .unwrap_or_default()
713                .to_string(),
714        }],
715        Some("thinking") => vec![ContentBlock::Reasoning {
716            text: block
717                .get("thinking")
718                .and_then(Value::as_str)
719                .unwrap_or_default()
720                .to_string(),
721            signature: block
722                .get("signature")
723                .and_then(Value::as_str)
724                .filter(|signature| !signature.is_empty())
725                .map(ToOwned::to_owned),
726            details: Vec::new(),
727        }],
728        Some("tool_use") => vec![ContentBlock::ToolCall(ToolCall {
729            id: block
730                .get("id")
731                .and_then(Value::as_str)
732                .filter(|id| !id.is_empty())
733                .map(desanitize_anthropic_tool_use_id)
734                .unwrap_or_else(|| match &policy.deterministic_ids {
735                    DeterministicIdPolicy::GenerateStable { prefix } => {
736                        stable_id(prefix, generated_counter)
737                    }
738                    DeterministicIdPolicy::Preserve => String::new(),
739                }),
740            name: block
741                .get("name")
742                .and_then(Value::as_str)
743                .unwrap_or_default()
744                .to_string(),
745            arguments: block.get("input").cloned().unwrap_or_else(|| json!({})),
746        })],
747        Some("tool_result") => vec![ContentBlock::ToolResult(ToolResult {
748            tool_call_id: block
749                .get("tool_use_id")
750                .and_then(Value::as_str)
751                .map(desanitize_anthropic_tool_use_id)
752                .unwrap_or_default(),
753            content: decode_tool_result_content(block.get("content").unwrap_or(&Value::Null)),
754            is_error: block.get("is_error").and_then(Value::as_bool),
755        })],
756        Some("image") => vec![ContentBlock::Image {
757            source: ImageSource::Raw(Value::Object(block.clone())),
758        }],
759        Some("input_image") | Some("image_url") => decode_image_source(block)
760            .map(|source| vec![ContentBlock::Image { source }])
761            .unwrap_or_default(),
762        Some("input_file") | Some("file") => vec![ContentBlock::File {
763            source: decode_file_source(block),
764        }],
765        Some("document") => vec![ContentBlock::File {
766            source: decode_anthropic_file_source(block),
767        }],
768        _ => vec![ContentBlock::Unknown {
769            provider: WireFormat::AnthropicMessages.into(),
770            raw: Value::Object(block.clone()),
771        }],
772    })
773}
774
775// Preserves supported Anthropic tool-result blocks in the neutral IR.
776fn decode_tool_result_content(value: &Value) -> Vec<ContentBlock> {
777    match value {
778        Value::String(text) => vec![ContentBlock::Text { text: text.clone() }],
779        Value::Array(blocks) => {
780            let mut content = Vec::new();
781            for block in blocks {
782                if let Some(block) = block.as_object() {
783                    match block.get("type").and_then(Value::as_str) {
784                        Some("text") => content.push(ContentBlock::Text {
785                            text: block
786                                .get("text")
787                                .and_then(Value::as_str)
788                                .unwrap_or_default()
789                                .to_string(),
790                        }),
791                        Some("image") => content.push(ContentBlock::Image {
792                            source: ImageSource::Raw(Value::Object(block.clone())),
793                        }),
794                        Some("document") => content.push(ContentBlock::File {
795                            source: decode_anthropic_file_source(block),
796                        }),
797                        _ => content.push(ContentBlock::Unknown {
798                            provider: WireFormat::AnthropicMessages.into(),
799                            raw: Value::Object(block.clone()),
800                        }),
801                    }
802                }
803            }
804            content
805        }
806        Value::Null => vec![ContentBlock::Text {
807            text: String::new(),
808        }],
809        other => vec![ContentBlock::Text {
810            text: json_string(other),
811        }],
812    }
813}
814
815// Keeps Anthropic document fields together for same-format re-encoding.
816fn decode_anthropic_file_source(block: &Map<String, Value>) -> FileSource {
817    FileSource::Raw(Value::Object(block.clone()))
818}
819
820// Decodes Anthropic tool definitions into normalized tool definitions.
821fn decode_anthropic_tools(value: Option<&Value>) -> Vec<Tool> {
822    value
823        .and_then(Value::as_array)
824        .into_iter()
825        .flatten()
826        .filter_map(Value::as_object)
827        .filter_map(|tool| {
828            if tool
829                .get("type")
830                .and_then(Value::as_str)
831                .is_some_and(|kind| kind != "custom")
832            {
833                return Some(Tool::Provider {
834                    provider: WireFormat::AnthropicMessages.into(),
835                    raw: Value::Object(tool.clone()),
836                });
837            }
838            let name = tool.get("name").and_then(Value::as_str)?.to_string();
839            (!name.is_empty()).then(|| {
840                Tool::Function(ToolDefinition {
841                    name,
842                    description: tool
843                        .get("description")
844                        .and_then(Value::as_str)
845                        .map(ToOwned::to_owned),
846                    parameters: tool
847                        .get("input_schema")
848                        .cloned()
849                        .unwrap_or_else(|| json!({})),
850                    strict: tool.get("strict").and_then(Value::as_bool),
851                })
852            })
853        })
854        .collect()
855}
856
857// Decodes Anthropic tool-choice values into normalized policy.
858fn decode_anthropic_tool_choice(value: &Value) -> ToolChoice {
859    match value {
860        Value::String(text) if text == "auto" => ToolChoice::Auto,
861        Value::String(text) if text == "any" => ToolChoice::Required,
862        Value::String(text) if text == "none" => ToolChoice::None,
863        Value::Object(object) => match object.get("type").and_then(Value::as_str) {
864            Some("auto") => ToolChoice::Auto,
865            Some("any") => ToolChoice::Required,
866            Some("none") => ToolChoice::None,
867            Some("tool") => object
868                .get("name")
869                .and_then(Value::as_str)
870                .map(|name| ToolChoice::Tool {
871                    name: name.to_string(),
872                })
873                .unwrap_or_else(|| ToolChoice::Raw(value.clone())),
874            _ => ToolChoice::Raw(value.clone()),
875        },
876        _ => ToolChoice::Raw(value.clone()),
877    }
878}
879
880// Encodes one normalized message into Anthropic message JSON.
881fn encode_anthropic_message(
882    message: &Message,
883    diagnostics: &mut Vec<TranslationDiagnostic>,
884    policy: &TranslationPolicy,
885) -> Result<Value> {
886    let role = match message.role {
887        Role::Assistant => "assistant",
888        Role::User | Role::Tool | Role::System | Role::Developer => "user",
889    };
890    let content = encode_anthropic_content_with_policy(&message.content, diagnostics, policy)?;
891    let simple_text = content.len() == 1
892        && content
893            .first()
894            .and_then(Value::as_object)
895            .and_then(|object| object.get("type"))
896            .and_then(Value::as_str)
897            == Some("text");
898    let content = if simple_text {
899        content
900            .first()
901            .and_then(Value::as_object)
902            .and_then(|object| object.get("text"))
903            .cloned()
904            .unwrap_or_else(|| Value::String(String::new()))
905    } else {
906        Value::Array(content)
907    };
908    Ok(json!({"role": role, "content": content}))
909}
910
911// Encodes messages while grouping adjacent tool-result-only messages correctly.
912fn encode_anthropic_messages(
913    messages: &[Message],
914    diagnostics: &mut Vec<TranslationDiagnostic>,
915    policy: &TranslationPolicy,
916) -> Result<Vec<Value>> {
917    let mut encoded = Vec::new();
918    let mut index = 0;
919
920    while let Some(message) = messages.get(index) {
921        // Inline instructions are emitted in the top-level system field.
922        if matches!(message.role, Role::System | Role::Developer) {
923            index += 1;
924            continue;
925        }
926        if !message_is_tool_result_only(message) {
927            encoded.push(encode_anthropic_message(message, diagnostics, policy)?);
928            index += 1;
929            continue;
930        }
931
932        let mut content = Vec::new();
933        while let Some(tool_message) = messages.get(index) {
934            if matches!(tool_message.role, Role::System | Role::Developer) {
935                index += 1;
936                continue;
937            }
938            if !message_is_tool_result_only(tool_message) {
939                break;
940            }
941            content.extend(encode_anthropic_content_with_policy(
942                &tool_message.content,
943                diagnostics,
944                policy,
945            )?);
946            index += 1;
947        }
948        encoded.push(json!({"role": "user", "content": content}));
949    }
950
951    Ok(encoded)
952}
953
954// Maps preserved OpenAI-style stop extensions to Anthropic stop sequences.
955fn anthropic_stop_sequences_from_extensions(extensions: &Map<String, Value>) -> Option<Value> {
956    match extensions.get("stop") {
957        Some(Value::String(stop)) => Some(json!([stop])),
958        Some(Value::Array(stops)) => Some(Value::Array(stops.clone())),
959        _ => None,
960    }
961}
962
963// Checks whether a message contains only tool-result blocks.
964fn message_is_tool_result_only(message: &Message) -> bool {
965    (message.role == Role::Tool || message.role == Role::User)
966        && !message.content.is_empty()
967        && message
968            .content
969            .iter()
970            .all(|block| matches!(block, ContentBlock::ToolResult(_)))
971}
972
973// Encodes content while applying lossy-conversion policy to unknown blocks.
974fn encode_anthropic_content_with_policy(
975    content: &[ContentBlock],
976    diagnostics: &mut Vec<TranslationDiagnostic>,
977    policy: &TranslationPolicy,
978) -> Result<Vec<Value>> {
979    let mut blocks = Vec::new();
980    for block in content {
981        crate::codecs::openai_media::validate_media(block, WireFormat::AnthropicMessages)?;
982        match block {
983            ContentBlock::Unknown { provider, raw } => {
984                reject_responses_native_input_item(provider, raw, WireFormat::AnthropicMessages)?;
985                push_lossy(
986                    diagnostics,
987                    policy,
988                    "unknown content block encoded as text for Anthropic",
989                )?;
990                blocks.push(json!({"type": "text", "text": json_string(raw)}));
991            }
992            other => blocks.extend(encode_one_anthropic_block(other)?),
993        }
994    }
995    if blocks.is_empty() {
996        blocks.push(json!({"type": "text", "text": ""}));
997    }
998    Ok(blocks)
999}
1000
1001fn encode_anthropic_file(source: &FileSource) -> Result<Value> {
1002    match source {
1003        FileSource::FileData { data, filename } => {
1004            let (media_type, data) = split_base64_data_uri(data)
1005                .unwrap_or_else(|| (document_media_type(filename.as_deref()), data.as_str()));
1006            let source = match media_type {
1007                "text/plain" => {
1008                    let bytes =
1009                        STANDARD
1010                            .decode(data)
1011                            .map_err(|_| TranslationError::InvalidValue {
1012                                path: "file_data".into(),
1013                                message: "invalid base64 text document".into(),
1014                            })?;
1015                    let text =
1016                        String::from_utf8(bytes).map_err(|_| TranslationError::InvalidValue {
1017                            path: "file_data".into(),
1018                            message: "text document must be UTF-8".into(),
1019                        })?;
1020                    json!({"type": "text", "media_type": media_type, "data": text})
1021                }
1022                "application/pdf" => {
1023                    json!({"type": "base64", "media_type": media_type, "data": data})
1024                }
1025                _ => {
1026                    return Err(TranslationError::LossyConversion(
1027                        "Anthropic requires PDF or plain-text documents".into(),
1028                    ));
1029                }
1030            };
1031            let mut block = json!({"type": "document", "source": source});
1032            if let Some(filename) = filename {
1033                block["title"] = filename.clone().into();
1034            }
1035            Ok(block)
1036        }
1037        FileSource::Raw(raw) if raw.get("type").and_then(Value::as_str) == Some("input_file") => {
1038            let url = raw.get("file_url").and_then(Value::as_str).ok_or_else(|| {
1039                TranslationError::LossyConversion("unsupported Anthropic document source".into())
1040            })?;
1041            let mut block = json!({"type": "document", "source": {"type": "url", "url": url}});
1042            if let Some(filename) = raw.get("filename") {
1043                block["title"] = filename.clone();
1044            }
1045            Ok(block)
1046        }
1047        FileSource::Raw(raw) => Ok(raw.clone()),
1048        FileSource::FileId(file_id) => Ok(json!({
1049            "type": "document", "source": {"type": "file", "file_id": file_id}
1050        })),
1051    }
1052}
1053
1054// Encodes content without producing diagnostics for response paths.
1055fn encode_anthropic_content(content: &[ContentBlock]) -> Result<Vec<Value>> {
1056    let mut blocks = Vec::new();
1057    for block in content {
1058        crate::codecs::openai_media::validate_media(block, WireFormat::AnthropicMessages)?;
1059        blocks.extend(encode_one_anthropic_response_block(block)?);
1060    }
1061    if blocks.is_empty() {
1062        blocks.push(json!({"type": "text", "text": ""}));
1063    }
1064    Ok(blocks)
1065}
1066
1067// Encodes response content, where synthetic reasoning may be shown to clients.
1068fn encode_one_anthropic_response_block(block: &ContentBlock) -> Result<Vec<Value>> {
1069    match block {
1070        ContentBlock::Reasoning {
1071            text,
1072            signature: None,
1073            ..
1074        } => Ok(vec![json!({
1075            "type": "thinking",
1076            "thinking": text,
1077            "signature": "",
1078        })]),
1079        other => encode_one_anthropic_block(other),
1080    }
1081}
1082
1083// Encodes a single normalized content block into Anthropic block JSON.
1084fn encode_one_anthropic_block(block: &ContentBlock) -> Result<Vec<Value>> {
1085    Ok(match block {
1086        ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
1087            vec![json!({"type": "text", "text": text})]
1088        }
1089        ContentBlock::Reasoning {
1090            text,
1091            signature: Some(signature),
1092            ..
1093        } if !signature.is_empty() => vec![json!({
1094            "type": "thinking",
1095            "thinking": text,
1096            "signature": signature,
1097        })],
1098        ContentBlock::Reasoning { .. } => Vec::new(),
1099        ContentBlock::ToolCall(call) => vec![json!({
1100            "type": "tool_use",
1101            "id": sanitize_anthropic_tool_use_id(&call.id),
1102            "name": call.name,
1103            "input": anthropic_tool_input(&call.arguments),
1104        })],
1105        ContentBlock::ToolResult(result) => {
1106            let content = if result.content.iter().all(|block| {
1107                matches!(
1108                    block,
1109                    ContentBlock::Text { .. } | ContentBlock::Refusal { .. }
1110                )
1111            }) {
1112                Value::String(text_from_blocks(&result.content, " "))
1113            } else {
1114                let mut content = Vec::new();
1115                for block in &result.content {
1116                    content.extend(encode_one_anthropic_tool_result_block(block)?);
1117                }
1118                Value::Array(content)
1119            };
1120            let mut item = json!({
1121                "type": "tool_result",
1122                "tool_use_id": sanitize_anthropic_tool_use_id(&result.tool_call_id),
1123                "content": content,
1124            });
1125            if let Some(is_error) = result.is_error {
1126                item["is_error"] = Value::Bool(is_error);
1127            }
1128            vec![item]
1129        }
1130        ContentBlock::Image { source } => vec![match source {
1131            ImageSource::Url { url, .. } => match split_base64_data_uri(url) {
1132                Some((media_type, data)) => json!({
1133                    "type": "image",
1134                    "source": {"type": "base64", "media_type": media_type, "data": data},
1135                }),
1136                None => json!({"type": "image", "source": {"type": "url", "url": url}}),
1137            },
1138            ImageSource::Base64 { media_type, data } => json!({
1139                "type": "image",
1140                "source": {
1141                    "type": "base64",
1142                    "media_type": media_type.clone().unwrap_or_else(|| "image/png".to_string()),
1143                    "data": data,
1144                },
1145            }),
1146            ImageSource::Raw(raw) => raw.clone(),
1147        }],
1148        ContentBlock::File { source } => vec![encode_anthropic_file(source)?],
1149        ContentBlock::Audio { source } => vec![match source {
1150            MediaSource::Url { url, media_type } => {
1151                json!({"type": "audio", "source": {"type": "url", "url": url, "media_type": media_type}})
1152            }
1153            MediaSource::Base64 { media_type, data } => json!({
1154                "type": "audio",
1155                "source": {
1156                    "type": "base64",
1157                    "media_type": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
1158                    "data": data,
1159                },
1160            }),
1161            MediaSource::Raw(raw) => raw.clone(),
1162        }],
1163        ContentBlock::Video { source } => vec![match source {
1164            MediaSource::Url { url, media_type } => {
1165                json!({"type": "video", "source": {"type": "url", "url": url, "media_type": media_type}})
1166            }
1167            MediaSource::Base64 { media_type, data } => json!({
1168                "type": "video",
1169                "source": {
1170                    "type": "base64",
1171                    "media_type": media_type.clone().unwrap_or_else(|| "video/mp4".to_string()),
1172                    "data": data,
1173                },
1174            }),
1175            MediaSource::Raw(raw) => raw.clone(),
1176        }],
1177        ContentBlock::Mcp(_) => {
1178            return Err(TranslationError::LossyConversion(
1179                "MCP history encoding is not implemented".into(),
1180            ));
1181        }
1182        ContentBlock::Unknown { raw, .. } => vec![raw.clone()],
1183    })
1184}
1185
1186// Encodes only provider-safe block shapes inside Anthropic tool results.
1187fn encode_one_anthropic_tool_result_block(block: &ContentBlock) -> Result<Vec<Value>> {
1188    match block {
1189        ContentBlock::Text { .. }
1190        | ContentBlock::Refusal { .. }
1191        | ContentBlock::Image { .. }
1192        | ContentBlock::File { .. } => encode_one_anthropic_block(block),
1193        ContentBlock::Unknown { provider, raw }
1194            if provider.as_str() == WireFormat::AnthropicMessages.as_str() =>
1195        {
1196            Ok(vec![raw.clone()])
1197        }
1198        ContentBlock::Unknown { raw, .. } => {
1199            Ok(vec![json!({"type": "text", "text": json_string(raw)})])
1200        }
1201        ContentBlock::Mcp(_) => Err(TranslationError::LossyConversion(
1202            "MCP history encoding is not implemented".into(),
1203        )),
1204        ContentBlock::Reasoning { .. }
1205        | ContentBlock::Audio { .. }
1206        | ContentBlock::Video { .. }
1207        | ContentBlock::ToolCall(_)
1208        | ContentBlock::ToolResult(_) => Ok(Vec::new()),
1209    }
1210}
1211
1212// Anthropic accepts PDF and plain-text documents; a file with no media type is a PDF
1213// unless its name says otherwise.
1214fn document_media_type(filename: Option<&str>) -> &'static str {
1215    match filename
1216        .and_then(|name| name.rsplit_once('.'))
1217        .map(|(_, ext)| ext)
1218    {
1219        Some(ext) if ext.eq_ignore_ascii_case("txt") || ext.eq_ignore_ascii_case("md") => {
1220            "text/plain"
1221        }
1222        _ => "application/pdf",
1223    }
1224}
1225
1226// Splits `data:<media type>[;<parameter>...];base64,<payload>`, the form OpenAI-compatible
1227// clients use for inline images. A percent-encoded payload has to be decoded before it is
1228// base64, and a data URI with no media type leaves Anthropic's `media_type` with nothing to
1229// carry, so both keep the handling they had before and are relayed as-is.
1230fn split_base64_data_uri(url: &str) -> Option<(&str, &str)> {
1231    let (metadata, data) = url.strip_prefix("data:")?.split_once(',')?;
1232    let parameters = metadata.strip_suffix(";base64")?;
1233    let media_type = parameters
1234        .split_once(';')
1235        .map_or(parameters, |(media_type, _)| media_type);
1236    if media_type.is_empty() || data.contains('%') {
1237        return None;
1238    }
1239    Some((media_type, data))
1240}
1241
1242// Anthropic requires `tool_use.input` to be object-shaped, while OpenAI and
1243// Responses commonly carry function-call arguments as JSON strings.
1244fn anthropic_tool_input(arguments: &Value) -> Value {
1245    match arguments {
1246        Value::Object(object) => Value::Object(object.clone()),
1247        Value::String(text) => serde_json::from_str::<Value>(text)
1248            .map_or_else(|_| json!({"raw": text}), ensure_anthropic_tool_input_object),
1249        Value::Null => json!({}),
1250        other => json!({"value": other}),
1251    }
1252}
1253
1254// Preserve valid objects and wrap every other JSON shape in a dictionary so
1255// translated requests satisfy Anthropic's schema without discarding payloads.
1256fn ensure_anthropic_tool_input_object(arguments: Value) -> Value {
1257    match arguments {
1258        Value::Object(_) => arguments,
1259        Value::Null => json!({}),
1260        other => json!({"value": other}),
1261    }
1262}
1263
1264// Encodes normalized tool definitions into Anthropic tool JSON.
1265fn encode_anthropic_tools(tools: &[Tool]) -> Result<Value> {
1266    Ok(Value::Array(
1267        tools
1268            .iter()
1269            .map(|tool| {
1270                if let Tool::Provider { provider, raw } = tool
1271                    && provider.as_str() == WireFormat::AnthropicMessages.as_str()
1272                {
1273                    return Ok(raw.clone());
1274                }
1275                let tool = tool.function().ok_or_else(|| {
1276                    TranslationError::LossyConversion(
1277                        "MCP and provider tool encoding is not implemented".into(),
1278                    )
1279                })?;
1280                let mut item = json!({
1281                    "name": tool.name,
1282                    "description": tool.description.clone().unwrap_or_default(),
1283                    "input_schema": tool.parameters,
1284                });
1285                if let Some(strict) = tool.strict {
1286                    item["strict"] = Value::Bool(strict);
1287                }
1288                Ok(item)
1289            })
1290            .collect::<Result<_>>()?,
1291    ))
1292}
1293
1294// Encodes normalized tool choice into Anthropic tool-choice JSON.
1295fn encode_anthropic_tool_choice(choice: &ToolChoice) -> Value {
1296    match choice {
1297        ToolChoice::Auto => json!({"type": "auto"}),
1298        ToolChoice::Required => json!({"type": "any"}),
1299        ToolChoice::None => json!({"type": "none"}),
1300        ToolChoice::Tool { name } => json!({"type": "tool", "name": name}),
1301        ToolChoice::Raw(value) => value.clone(),
1302    }
1303}
1304
1305// Normalizes Anthropic usage fields.
1306fn decode_anthropic_usage(value: Option<&Value>) -> Usage {
1307    let Some(value) = value.and_then(Value::as_object) else {
1308        return Usage::default();
1309    };
1310    let input_tokens = value.get("input_tokens").and_then(Value::as_u64);
1311    let cached_input_tokens = value.get("cache_read_input_tokens").and_then(Value::as_u64);
1312    let cache_creation_input_tokens = value
1313        .get("cache_creation_input_tokens")
1314        .and_then(Value::as_u64);
1315    let output_tokens = value.get("output_tokens").and_then(Value::as_u64);
1316    Usage {
1317        provider_cost: crate::codecs::common::provider_cost(value),
1318        input_tokens,
1319        cache: crate::codecs::common::cache_usage_details(
1320            value,
1321            cached_input_tokens,
1322            cache_creation_input_tokens,
1323        ),
1324        output_tokens,
1325        total_tokens: input_tokens.zip(output_tokens).map(|(input, output)| {
1326            input
1327                + cached_input_tokens.unwrap_or(0)
1328                + cache_creation_input_tokens.unwrap_or(0)
1329                + output
1330        }),
1331        reasoning_tokens: value.get("output_tokens_details").and_then(|details| {
1332            details
1333                .get("thinking_tokens")
1334                .and_then(Value::as_u64)
1335                .or_else(|| details.get("reasoning_tokens").and_then(Value::as_u64))
1336        }),
1337    }
1338}
1339
1340// Encodes normalized usage into Anthropic usage JSON.
1341fn encode_anthropic_usage(usage: &Usage) -> Value {
1342    let mut value = json!({
1343        "input_tokens": usage.input_tokens.unwrap_or(0),
1344        "output_tokens": usage.output_tokens.unwrap_or(0),
1345    });
1346    if let Some(cached_tokens) = usage.cached_input_tokens() {
1347        value["cache_read_input_tokens"] = json!(cached_tokens);
1348    }
1349    if let Some(cache_creation_tokens) = usage.cache_creation_input_tokens() {
1350        value["cache_creation_input_tokens"] = json!(cache_creation_tokens);
1351    }
1352    if let Some(thinking_tokens) = usage.reasoning_tokens {
1353        value["output_tokens_details"] = json!({"thinking_tokens": thinking_tokens});
1354    }
1355    crate::codecs::common::encode_usage_metadata(value, usage)
1356}
1357
1358// Maps Anthropic stop reasons to normalized stop reasons.
1359fn map_anthropic_stop_reason(reason: Option<&str>) -> StopReason {
1360    match reason {
1361        Some("max_tokens") => StopReason::MaxTokens,
1362        Some("tool_use") => StopReason::ToolUse,
1363        Some("refusal") => StopReason::ContentFilter,
1364        Some("end_turn") | None => StopReason::EndTurn,
1365        _ => StopReason::Unknown,
1366    }
1367}
1368
1369// Maps normalized stop reasons back to Anthropic's vocabulary.
1370fn anthropic_stop_reason(reason: StopReason) -> &'static str {
1371    match reason {
1372        StopReason::MaxTokens => "max_tokens",
1373        StopReason::ToolUse => "tool_use",
1374        StopReason::ContentFilter => "refusal",
1375        StopReason::EndTurn | StopReason::Error | StopReason::Unknown => "end_turn",
1376    }
1377}
1378
1379// Emits the metadata object Anthropic pairs with a `refusal` stop reason.
1380//
1381// An Anthropic source keeps its `stop_details` in provider extensions, so replay that
1382// object and preserve the named policy category and its explanation. Only a refusal
1383// synthesized from a provider that reports no category falls back to the null form,
1384// which Anthropic documents as the normal value for a refusal that maps to no named
1385// category.
1386fn anthropic_stop_details(reason: StopReason, source: Option<&Value>) -> Value {
1387    match reason {
1388        StopReason::ContentFilter => source
1389            .filter(|details| !details.is_null())
1390            .cloned()
1391            .unwrap_or_else(|| {
1392                json!({
1393                    "type": "refusal",
1394                    "category": Value::Null,
1395                    "explanation": Value::Null,
1396                })
1397            }),
1398        _ => Value::Null,
1399    }
1400}