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