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