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