1use 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
37pub 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 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 "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 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 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
501fn 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 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 "name": "response",
546 "schema": schema.clone(),
547 },
548 })))
549}
550
551fn 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
600fn 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 (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
653fn 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
683fn 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
728fn 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
804fn 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
844fn decode_anthropic_file_source(block: &Map<String, Value>) -> FileSource {
846 FileSource::Raw(Value::Object(block.clone()))
847}
848
849fn 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
886fn 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
909fn 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
940fn 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 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
983fn 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
992fn 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
1002fn 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
1083fn 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
1096fn 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
1112fn 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
1215fn 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
1241fn 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
1255fn 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
1271fn 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
1283fn 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
1293fn 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
1330fn 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
1341fn 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
1376fn 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
1394fn 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
1405fn 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
1415fn 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}