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