modelmux/converter/openai_to_anthropic.rs
1//!
2//! OpenAI to Anthropic format converter for API request translation.
3//!
4//! Converts OpenAI-compatible chat completion requests to Anthropic/Vertex AI format.
5//! Handles message conversion, tool calling, and streaming configuration while
6//! maintaining semantic equivalence between the two API formats.
7//!
8//! Authors:
9//! Jaro <yarenty@gmail.com>
10//!
11//! Copyright (c) 2026 SkyCorp
12
13/* --- uses ------------------------------------------------------------------------------------ */
14
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17
18use crate::config::LogLevel;
19use crate::error::{ProxyError, Result};
20
21/* --- helper functions ----------------------------------------------------------------------- */
22
23///
24/// Custom serialization helper for tools field.
25///
26/// Skips serialization when tools is None or empty to avoid sending invalid data to Vertex AI.
27///
28/// # Arguments
29/// * `tools` - optional tools vector
30///
31/// # Returns
32/// * true if field should be skipped (None or empty), false otherwise
33fn skip_empty_tools(tools: &Option<Vec<AnthropicTool>>) -> bool {
34 match tools {
35 None => true,
36 Some(vec) => vec.is_empty(),
37 }
38}
39
40/* --- types ----------------------------------------------------------------------------------- */
41
42///
43/// OpenAI chat completion request structure.
44///
45/// Represents an incoming request in OpenAI's chat completions API format.
46/// Contains messages, model configuration, and optional tool definitions.
47#[derive(Debug, Deserialize)]
48pub struct OpenAiRequest {
49 /** the model identifier to use for completion */
50 pub model: Option<String>,
51 /** conversation messages array */
52 pub messages: Vec<OpenAiMessage>,
53 /** maximum number of tokens to generate */
54 pub max_tokens: Option<u32>,
55 /** sampling temperature for response generation */
56 pub temperature: Option<f64>,
57 /** whether to stream the response */
58 pub stream: Option<bool>,
59 /** available tools for function calling */
60 pub tools: Option<Vec<OpenAiTool>>,
61 /** tool choice configuration */
62 pub tool_choice: Option<OpenAiToolChoice>,
63}
64
65///
66/// OpenAI message structure within a chat completion request.
67///
68/// Represents a single message in the conversation with role-based content
69/// and optional tool call information.
70#[derive(Debug, Deserialize)]
71pub struct OpenAiMessage {
72 /** message role: system, user, assistant, or tool */
73 pub role: String,
74 /** message content, can be string or structured blocks */
75 pub content: Option<OpenAiContent>,
76 /** tool calls made by the assistant */
77 pub tool_calls: Option<Vec<OpenAiToolCall>>,
78 /** tool call ID for tool response messages */
79 #[serde(rename = "tool_call_id")]
80 pub tool_call_id: Option<String>,
81}
82
83///
84/// OpenAI content union type for flexible message content.
85///
86/// Supports both simple string content and structured content blocks
87/// for multimodal messages including text and images.
88#[derive(Debug, Deserialize)]
89#[serde(untagged)]
90pub enum OpenAiContent {
91 /** simple string content */
92 String(String),
93 /** structured content blocks array */
94 Array(Vec<OpenAiContentBlock>),
95}
96
97///
98/// OpenAI structured content block for multimodal messages.
99///
100/// Represents individual content elements within a message, supporting
101/// text and image content types with appropriate metadata.
102#[derive(Debug, Deserialize)]
103pub struct OpenAiContentBlock {
104 /** content block type: text or image_url */
105 #[serde(rename = "type")]
106 pub block_type: String,
107 /** text content for text blocks */
108 pub text: Option<String>,
109 /** image URL reference for image blocks */
110 #[serde(rename = "image_url")]
111 pub image_url: Option<ImageUrl>,
112}
113
114///
115/// Image URL reference structure for image content blocks.
116///
117/// Contains the URL pointing to the image resource.
118#[derive(Debug, Deserialize)]
119pub struct ImageUrl {
120 /** the image URL */
121 pub url: String,
122}
123
124///
125/// OpenAI tool call structure for function invocations.
126///
127/// Represents a function call made by the assistant during response generation.
128#[derive(Debug, Deserialize)]
129pub struct OpenAiToolCall {
130 /** unique identifier for this tool call */
131 pub id: String,
132 /** tool call type, typically "function" */
133 #[serde(rename = "type")]
134 #[allow(dead_code)]
135 pub call_type: String,
136 /** function call details */
137 pub function: OpenAiFunction,
138}
139
140///
141/// OpenAI function call details within a tool call.
142///
143/// Contains the function name and arguments for execution.
144#[derive(Debug, Deserialize)]
145pub struct OpenAiFunction {
146 /** function name to call */
147 pub name: String,
148 /** function arguments as JSON value */
149 pub arguments: serde_json::Value,
150}
151
152///
153/// OpenAI tool definition for available functions.
154///
155/// Describes a function that can be called by the model during response generation.
156#[derive(Debug, Deserialize)]
157pub struct OpenAiTool {
158 /** tool type, typically "function" */
159 #[serde(rename = "type")]
160 #[allow(dead_code)]
161 pub tool_type: String,
162 /** function definition and schema */
163 pub function: OpenAiToolFunction,
164}
165
166///
167/// OpenAI function definition within a tool.
168///
169/// Contains function metadata and parameter schema for validation.
170#[derive(Debug, Deserialize)]
171pub struct OpenAiToolFunction {
172 /** function name */
173 pub name: String,
174 /** function description */
175 pub description: String,
176 /** JSON schema for function parameters */
177 pub parameters: serde_json::Value,
178}
179
180///
181/// OpenAI tool choice configuration.
182///
183/// Controls how the model should choose which tools to use during generation.
184#[derive(Debug, Deserialize)]
185#[serde(untagged)]
186pub enum OpenAiToolChoice {
187 /** string choice: "auto", "none", etc. */
188 String(String),
189 /** object choice with specific function */
190 Object(OpenAiToolChoiceObject),
191}
192
193///
194/// OpenAI tool choice object for specific function selection.
195///
196/// Allows forcing the model to use a specific function.
197#[derive(Debug, Deserialize)]
198pub struct OpenAiToolChoiceObject {
199 /** choice type */
200 #[serde(rename = "type")]
201 #[allow(dead_code)]
202 pub choice_type: String,
203 /** specific function to choose */
204 pub function: Option<OpenAiToolChoiceFunction>,
205}
206
207///
208/// OpenAI specific function choice within tool choice object.
209///
210/// Identifies the exact function to use.
211#[derive(Debug, Deserialize)]
212pub struct OpenAiToolChoiceFunction {
213 /** function name to force */
214 pub name: String,
215}
216
217///
218/// Anthropic chat completion request structure.
219///
220/// Target format for requests to Anthropic's Claude API via Vertex AI.
221/// Contains converted messages and configuration from OpenAI format.
222#[derive(Debug, Serialize)]
223pub struct AnthropicRequest {
224 /** Anthropic API version identifier */
225 #[serde(rename = "anthropic_version")]
226 pub anthropic_version: String,
227 /** conversation messages in Anthropic format */
228 pub messages: Vec<AnthropicMessage>,
229 /** maximum tokens to generate */
230 #[serde(rename = "max_tokens")]
231 pub max_tokens: u32,
232 /** sampling temperature */
233 pub temperature: f64,
234 /** whether to stream the response */
235 pub stream: bool,
236 /** available tools in Anthropic format */
237 #[serde(skip_serializing_if = "skip_empty_tools")]
238 pub tools: Option<Vec<AnthropicTool>>,
239 /** tool choice configuration in Anthropic format */
240 #[serde(rename = "tool_choice", skip_serializing_if = "Option::is_none")]
241 pub tool_choice: Option<AnthropicToolChoice>,
242}
243
244///
245/// Anthropic message structure for chat conversations.
246///
247/// Contains role and content blocks in Anthropic's preferred format.
248#[derive(Debug, Serialize)]
249pub struct AnthropicMessage {
250 /** message role: user or assistant */
251 pub role: String,
252 /** message content as structured blocks */
253 pub content: Vec<AnthropicContentBlock>,
254}
255
256///
257/// Anthropic content block for message content.
258///
259/// Supports text, tool usage, tool results, and image content types
260/// with proper tagging for serialization.
261#[derive(Debug, Serialize)]
262#[serde(tag = "type")]
263pub enum AnthropicContentBlock {
264 /** text content block */
265 #[serde(rename = "text")]
266 Text {
267 /** the text content */
268 text: String,
269 },
270 /** tool usage block for function calls */
271 #[serde(rename = "tool_use")]
272 ToolUse {
273 /** tool call identifier */
274 id: String,
275 /** function name */
276 name: String,
277 /** function input arguments */
278 input: serde_json::Value,
279 },
280 /** tool result block for function responses */
281 #[serde(rename = "tool_result")]
282 ToolResult {
283 /** corresponding tool use identifier */
284 #[serde(rename = "tool_use_id")]
285 tool_use_id: String,
286 /** tool execution result */
287 content: AnthropicToolResultContent,
288 },
289 /** image content block */
290 #[serde(rename = "image")]
291 Image {
292 /** image source information */
293 source: ImageSource,
294 },
295}
296
297///
298/// Anthropic tool result content union type.
299///
300/// Supports both simple string results and structured array results
301/// for complex tool responses.
302#[derive(Debug, Serialize)]
303#[serde(untagged)]
304pub enum AnthropicToolResultContent {
305 /** simple string result */
306 String(String),
307 /** structured array result */
308 Array(Vec<serde_json::Value>),
309}
310
311///
312/// Image source information for Anthropic image blocks.
313///
314/// Contains metadata about image resources.
315#[derive(Debug, Serialize)]
316pub struct ImageSource {
317 /** source type identifier */
318 #[serde(rename = "type")]
319 pub source_type: String,
320 /** image URL */
321 pub url: String,
322}
323
324///
325/// Anthropic tool definition for function calling.
326///
327/// Describes available functions in Anthropic's format.
328#[derive(Debug, Serialize)]
329pub struct AnthropicTool {
330 /** function name */
331 pub name: String,
332 /** function description */
333 pub description: String,
334 /** function input schema */
335 #[serde(rename = "input_schema")]
336 pub input_schema: serde_json::Value,
337}
338
339///
340/// Anthropic tool choice configuration.
341///
342/// Controls tool selection behavior in Anthropic format.
343#[derive(Debug, Serialize)]
344#[serde(tag = "type")]
345pub enum AnthropicToolChoice {
346 /** automatic tool selection */
347 #[serde(rename = "auto")]
348 Auto,
349 /** force specific tool usage */
350 #[serde(rename = "tool")]
351 Tool {
352 /** tool name to force */
353 name: String,
354 },
355}
356
357///
358/// Converter from OpenAI format to Anthropic format.
359///
360/// Follows Single Responsibility Principle - handles only format conversion
361/// from OpenAI chat completions to Anthropic message format.
362pub struct OpenAiToAnthropicConverter {
363 /** logging level for debug output */
364 log_level: LogLevel,
365}
366
367/* --- constants ------------------------------------------------------------------------------ */
368
369/** Anthropic API version to use for requests */
370const ANTHROPIC_VERSION: &str = "vertex-2023-10-16";
371
372/** Default maximum tokens if not specified */
373const DEFAULT_MAX_TOKENS: u32 = 8000;
374
375/** Default temperature if not specified */
376const DEFAULT_TEMPERATURE: f64 = 0.9;
377
378/* --- start of code -------------------------------------------------------------------------- */
379
380impl OpenAiToAnthropicConverter {
381 ///
382 /// Create a new OpenAI to Anthropic converter.
383 ///
384 /// # Arguments
385 /// * `log_level` - logging level for debug output
386 ///
387 /// # Returns
388 /// * New converter instance
389 pub fn new(log_level: LogLevel) -> Self {
390 Self { log_level }
391 }
392
393 ///
394 /// Convert OpenAI request to Anthropic request format.
395 ///
396 /// Transforms the entire request structure including messages, tools, and
397 /// configuration parameters. Handles system messages, tool calls, and
398 /// multimodal content appropriately.
399 ///
400 /// # Arguments
401 /// * `request` - OpenAI format request to convert
402 ///
403 /// # Returns
404 /// * Converted Anthropic format request
405 /// * `ProxyError::Conversion` if conversion fails
406 pub fn convert(&self, request: OpenAiRequest) -> Result<AnthropicRequest> {
407 self.debug(&format!(
408 "Converting {} message(s) from OpenAI to Anthropic format",
409 request.messages.len()
410 ));
411
412 let mut anthropic_messages = Vec::new();
413 let mut pending_tool_results = Vec::new();
414 let mut last_assistant_message: Option<&'_ OpenAiMessage> = None;
415 let mut system_messages = Vec::new();
416
417 self.process_messages(
418 &request.messages,
419 &mut anthropic_messages,
420 &mut pending_tool_results,
421 &mut last_assistant_message,
422 &mut system_messages,
423 )?;
424
425 self.handle_remaining_tool_results(
426 &mut anthropic_messages,
427 &mut pending_tool_results,
428 last_assistant_message,
429 )?;
430
431 self.prepend_system_messages(&mut anthropic_messages, system_messages);
432
433 let tools = self.convert_tools(request.tools);
434 let tool_choice = self.convert_tool_choice(request.tool_choice);
435
436 let anthropic_request = AnthropicRequest {
437 anthropic_version: ANTHROPIC_VERSION.to_string(),
438 messages: anthropic_messages,
439 max_tokens: request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
440 temperature: request.temperature.unwrap_or(DEFAULT_TEMPERATURE),
441 stream: request.stream.unwrap_or(false),
442 tools,
443 tool_choice,
444 };
445
446 self.debug(&format!(
447 "Converted Anthropic request with {} messages",
448 anthropic_request.messages.len()
449 ));
450
451 Ok(anthropic_request)
452 }
453
454 ///
455 /// Process all messages in the OpenAI request.
456 ///
457 /// Iterates through messages and converts them based on role type,
458 /// managing tool calls and results properly.
459 ///
460 /// # Arguments
461 /// * `messages` - OpenAI messages to process
462 /// * `anthropic_messages` - output Anthropic messages
463 /// * `pending_tool_results` - accumulated tool results
464 /// * `last_assistant_message` - reference to last assistant message
465 /// * `system_messages` - accumulated system messages
466 ///
467 /// # Returns
468 /// * `Ok(())` on successful processing
469 /// * `ProxyError::Conversion` if message conversion fails
470 fn process_messages<'a>(
471 &self,
472 messages: &'a [OpenAiMessage],
473 anthropic_messages: &mut Vec<AnthropicMessage>,
474 pending_tool_results: &mut Vec<(String, AnthropicToolResultContent)>,
475 last_assistant_message: &mut Option<&'a OpenAiMessage>,
476 system_messages: &mut Vec<String>,
477 ) -> Result<()> {
478 for msg in messages {
479 self.debug(&format!("Processing message with role: {}", msg.role));
480
481 match msg.role.as_str() {
482 "system" => {
483 self.process_system_message(msg, system_messages);
484 }
485 "assistant" => {
486 self.process_assistant_message(
487 msg,
488 anthropic_messages,
489 pending_tool_results,
490 last_assistant_message,
491 )?;
492 }
493 "tool" => {
494 self.process_tool_message(msg, pending_tool_results);
495 }
496 "user" => {
497 self.process_user_message(
498 msg,
499 anthropic_messages,
500 pending_tool_results,
501 *last_assistant_message,
502 )?;
503 }
504 _ => {
505 return Err(ProxyError::Conversion(format!(
506 "Unknown message role: {}",
507 msg.role
508 )));
509 }
510 }
511 }
512 Ok(())
513 }
514
515 ///
516 /// Process a system message by extracting its content.
517 ///
518 /// # Arguments
519 /// * `msg` - system message to process
520 /// * `system_messages` - collection to add system content to
521 fn process_system_message(&self, msg: &OpenAiMessage, system_messages: &mut Vec<String>) {
522 if let Some(OpenAiContent::String(content)) = &msg.content {
523 system_messages.push(content.clone());
524 }
525 }
526
527 ///
528 /// Process an assistant message with optional tool calls.
529 ///
530 /// # Arguments
531 /// * `msg` - assistant message to process
532 /// * `anthropic_messages` - output Anthropic messages
533 /// * `pending_tool_results` - accumulated tool results
534 /// * `last_assistant_message` - reference to last assistant message
535 ///
536 /// # Returns
537 /// * `Ok(())` on successful processing
538 /// * `ProxyError::Conversion` if conversion fails
539 fn process_assistant_message<'a>(
540 &self,
541 msg: &'a OpenAiMessage,
542 anthropic_messages: &mut Vec<AnthropicMessage>,
543 pending_tool_results: &mut Vec<(String, AnthropicToolResultContent)>,
544 last_assistant_message: &mut Option<&'a OpenAiMessage>,
545 ) -> Result<()> {
546 if last_assistant_message.is_some() && !pending_tool_results.is_empty() {
547 self.attach_tool_results(anthropic_messages, pending_tool_results)?;
548 }
549
550 let anthropic_msg = self.convert_assistant_message(msg)?;
551 anthropic_messages.push(anthropic_msg);
552 *last_assistant_message = Some(msg);
553 Ok(())
554 }
555
556 ///
557 /// Process a tool message by collecting its result.
558 ///
559 /// # Arguments
560 /// * `msg` - tool message to process
561 /// * `pending_tool_results` - collection to add tool result to
562 fn process_tool_message(
563 &self,
564 msg: &OpenAiMessage,
565 pending_tool_results: &mut Vec<(String, AnthropicToolResultContent)>,
566 ) {
567 if let Some(tool_call_id) = &msg.tool_call_id {
568 let content = self.convert_tool_result_content(&msg.content);
569 pending_tool_results.push((tool_call_id.clone(), content));
570 self.debug(&format!("Collected tool result for tool_call_id: {}", tool_call_id));
571 }
572 }
573
574 ///
575 /// Process a user message and attach any pending tool results.
576 ///
577 /// # Arguments
578 /// * `msg` - user message to process
579 /// * `anthropic_messages` - output Anthropic messages
580 /// * `pending_tool_results` - accumulated tool results
581 /// * `last_assistant_message` - optional reference to last assistant message
582 ///
583 /// # Returns
584 /// * `Ok(())` on successful processing
585 /// * `ProxyError::Conversion` if conversion fails
586 fn process_user_message<'a>(
587 &self,
588 msg: &'a OpenAiMessage,
589 anthropic_messages: &mut Vec<AnthropicMessage>,
590 pending_tool_results: &mut Vec<(String, AnthropicToolResultContent)>,
591 last_assistant_message: Option<&'a OpenAiMessage>,
592 ) -> Result<()> {
593 if last_assistant_message.is_some() && !pending_tool_results.is_empty() {
594 self.debug(&format!(
595 "Attaching {} tool result(s) before user message",
596 pending_tool_results.len()
597 ));
598 self.attach_tool_results(anthropic_messages, pending_tool_results)?;
599 }
600
601 let anthropic_msg = self.convert_user_message(msg)?;
602 anthropic_messages.push(anthropic_msg);
603 Ok(())
604 }
605
606 ///
607 /// Convert tool result content from OpenAI to Anthropic format.
608 ///
609 /// # Arguments
610 /// * `content` - OpenAI message content to convert
611 ///
612 /// # Returns
613 /// * Converted tool result content
614 fn convert_tool_result_content(
615 &self,
616 content: &Option<OpenAiContent>,
617 ) -> AnthropicToolResultContent {
618 match content {
619 Some(OpenAiContent::String(s)) => AnthropicToolResultContent::String(s.clone()),
620 Some(OpenAiContent::Array(arr)) => {
621 let mut json_blocks = Vec::new();
622 for block in arr {
623 match block.block_type.as_str() {
624 "text" => {
625 if let Some(text) = &block.text {
626 json_blocks.push(json!({ "type": "text", "text": text }));
627 }
628 }
629 "image_url" => {
630 if let Some(img) = &block.image_url {
631 json_blocks.push(
632 json!({ "type": "image_url", "image_url": { "url": img.url } }),
633 );
634 }
635 }
636 _ => {}
637 }
638 }
639 AnthropicToolResultContent::Array(json_blocks)
640 }
641 None => AnthropicToolResultContent::String(String::new()),
642 }
643 }
644
645 ///
646 /// Handle any remaining tool results after processing all messages.
647 ///
648 /// # Arguments
649 /// * `anthropic_messages` - output Anthropic messages
650 /// * `pending_tool_results` - accumulated tool results
651 /// * `last_assistant_message` - optional reference to last assistant message
652 ///
653 /// # Returns
654 /// * `Ok(())` on successful processing
655 /// * `ProxyError::Conversion` if attachment fails
656 fn handle_remaining_tool_results(
657 &self,
658 anthropic_messages: &mut Vec<AnthropicMessage>,
659 pending_tool_results: &mut Vec<(String, AnthropicToolResultContent)>,
660 last_assistant_message: Option<&OpenAiMessage>,
661 ) -> Result<()> {
662 if last_assistant_message.is_some() && !pending_tool_results.is_empty() {
663 self.attach_tool_results(anthropic_messages, pending_tool_results)?;
664 }
665 Ok(())
666 }
667
668 ///
669 /// Prepend system messages to the first user message.
670 ///
671 /// # Arguments
672 /// * `anthropic_messages` - output Anthropic messages to modify
673 /// * `system_messages` - system messages to prepend
674 fn prepend_system_messages(
675 &self,
676 anthropic_messages: &mut [AnthropicMessage],
677 system_messages: Vec<String>,
678 ) {
679 if !system_messages.is_empty() && !anthropic_messages.is_empty() {
680 let system_text = system_messages.join("\n\n");
681 if let Some(first_user_msg) = anthropic_messages.iter_mut().find(|m| m.role == "user") {
682 self.prepend_system_text(first_user_msg, &system_text);
683 }
684 }
685 }
686
687 ///
688 /// Convert OpenAI tools to Anthropic format.
689 ///
690 /// # Arguments
691 /// * `tools` - optional OpenAI tools to convert
692 ///
693 /// # Returns
694 /// * Converted Anthropic tools or None
695 fn convert_tools(&self, tools: Option<Vec<OpenAiTool>>) -> Option<Vec<AnthropicTool>> {
696 tools.map(|tools| {
697 self.debug(&format!(
698 "Converting {} tool(s) from OpenAI to Anthropic format",
699 tools.len()
700 ));
701 tools
702 .into_iter()
703 .map(|tool| AnthropicTool {
704 name: tool.function.name,
705 description: tool.function.description,
706 input_schema: tool.function.parameters,
707 })
708 .collect()
709 })
710 }
711
712 ///
713 /// Convert OpenAI tool choice to Anthropic format.
714 ///
715 /// # Arguments
716 /// * `tool_choice` - optional OpenAI tool choice to convert
717 ///
718 /// # Returns
719 /// * Converted Anthropic tool choice or None
720 fn convert_tool_choice(
721 &self,
722 tool_choice: Option<OpenAiToolChoice>,
723 ) -> Option<AnthropicToolChoice> {
724 tool_choice.and_then(|choice| {
725 self.debug(&format!("Tool choice: {:?}", choice));
726 match choice {
727 OpenAiToolChoice::String(s) if s == "auto" => Some(AnthropicToolChoice::Auto),
728 OpenAiToolChoice::String(s) if s == "none" => {
729 self.debug("Tool choice 'none' not supported by Anthropic, omitting");
730 None
731 }
732 OpenAiToolChoice::Object(obj) => {
733 if let Some(function) = obj.function {
734 self.debug(&format!("Forced tool choice: {}", function.name));
735 Some(AnthropicToolChoice::Tool { name: function.name })
736 } else {
737 None
738 }
739 }
740 _ => None,
741 }
742 })
743 }
744
745 ///
746 /// Convert an OpenAI assistant message to Anthropic format.
747 ///
748 /// Handles both text content and tool calls within the message.
749 ///
750 /// # Arguments
751 /// * `msg` - OpenAI assistant message to convert
752 ///
753 /// # Returns
754 /// * Converted Anthropic message
755 /// * `ProxyError::Conversion` if conversion fails
756 fn convert_assistant_message(&self, msg: &OpenAiMessage) -> Result<AnthropicMessage> {
757 let mut content = Vec::new();
758
759 self.add_text_content(&mut content, &msg.content);
760 self.add_tool_calls(&mut content, &msg.tool_calls)?;
761
762 if content.is_empty() {
763 content.push(AnthropicContentBlock::Text { text: String::new() });
764 }
765
766 Ok(AnthropicMessage { role: "assistant".to_string(), content })
767 }
768
769 ///
770 /// Add text content from OpenAI message to Anthropic content blocks.
771 ///
772 /// # Arguments
773 /// * `content` - content blocks to add to
774 /// * `openai_content` - OpenAI content to extract text from
775 fn add_text_content(
776 &self,
777 content: &mut Vec<AnthropicContentBlock>,
778 openai_content: &Option<OpenAiContent>,
779 ) {
780 match openai_content {
781 Some(OpenAiContent::String(text)) if !text.is_empty() => {
782 content.push(AnthropicContentBlock::Text { text: text.clone() });
783 }
784 Some(OpenAiContent::Array(blocks)) => {
785 for block in blocks {
786 if block.block_type == "text"
787 && let Some(text) = &block.text {
788 content.push(AnthropicContentBlock::Text { text: text.clone() });
789 }
790 }
791 }
792 _ => {}
793 }
794 }
795
796 ///
797 /// Add tool calls from OpenAI message to Anthropic content blocks.
798 ///
799 /// # Arguments
800 /// * `content` - content blocks to add to
801 /// * `tool_calls` - OpenAI tool calls to convert
802 ///
803 /// # Returns
804 /// * `Ok(())` on successful addition
805 /// * `ProxyError::Conversion` if tool call conversion fails
806 fn add_tool_calls(
807 &self,
808 content: &mut Vec<AnthropicContentBlock>,
809 tool_calls: &Option<Vec<OpenAiToolCall>>,
810 ) -> Result<()> {
811 if let Some(tool_calls) = tool_calls {
812 self.debug(&format!(
813 "Converting {} tool call(s) from assistant message",
814 tool_calls.len()
815 ));
816 for tool_call in tool_calls {
817 let args = self.parse_tool_arguments(&tool_call.function.arguments);
818 content.push(AnthropicContentBlock::ToolUse {
819 id: tool_call.id.clone(),
820 name: tool_call.function.name.clone(),
821 input: args,
822 });
823 }
824 }
825 Ok(())
826 }
827
828 ///
829 /// Parse tool call arguments from JSON value.
830 ///
831 /// # Arguments
832 /// * `arguments` - JSON arguments value
833 ///
834 /// # Returns
835 /// * Parsed JSON value for tool input
836 fn parse_tool_arguments(&self, arguments: &serde_json::Value) -> serde_json::Value {
837 match arguments {
838 serde_json::Value::String(s) => {
839 serde_json::from_str(s).unwrap_or_else(|_| arguments.clone())
840 }
841 _ => arguments.clone(),
842 }
843 }
844
845 ///
846 /// Convert an OpenAI user message to Anthropic format.
847 ///
848 /// Handles text, image, and multimodal content appropriately.
849 ///
850 /// # Arguments
851 /// * `msg` - OpenAI user message to convert
852 ///
853 /// # Returns
854 /// * Converted Anthropic message
855 /// * `ProxyError::Conversion` if conversion fails
856 fn convert_user_message(&self, msg: &OpenAiMessage) -> Result<AnthropicMessage> {
857 let content = match &msg.content {
858 Some(OpenAiContent::String(text)) => {
859 vec![AnthropicContentBlock::Text { text: text.clone() }]
860 }
861 Some(OpenAiContent::Array(blocks)) => self.convert_content_blocks(blocks),
862 None => vec![AnthropicContentBlock::Text { text: String::new() }],
863 };
864
865 Ok(AnthropicMessage { role: "user".to_string(), content })
866 }
867
868 ///
869 /// Convert OpenAI content blocks to Anthropic content blocks.
870 ///
871 /// # Arguments
872 /// * `blocks` - OpenAI content blocks to convert
873 ///
874 /// # Returns
875 /// * Converted Anthropic content blocks
876 fn convert_content_blocks(&self, blocks: &[OpenAiContentBlock]) -> Vec<AnthropicContentBlock> {
877 blocks
878 .iter()
879 .filter_map(|block| match block.block_type.as_str() {
880 "text" => {
881 block.text.as_ref().map(|t| AnthropicContentBlock::Text { text: t.clone() })
882 }
883 "image_url" => block.image_url.as_ref().map(|img| AnthropicContentBlock::Image {
884 source: ImageSource { source_type: "url".to_string(), url: img.url.clone() },
885 }),
886 _ => None,
887 })
888 .collect()
889 }
890
891 ///
892 /// Attach pending tool results to the conversation.
893 ///
894 /// Creates a user message containing tool result blocks and adds it
895 /// to the conversation after the last assistant message.
896 ///
897 /// # Arguments
898 /// * `anthropic_messages` - messages to add tool results to
899 /// * `pending_tool_results` - tool results to attach
900 ///
901 /// # Returns
902 /// * `Ok(())` on successful attachment
903 /// * `ProxyError::Conversion` if attachment fails
904 fn attach_tool_results(
905 &self,
906 anthropic_messages: &mut Vec<AnthropicMessage>,
907 pending_tool_results: &mut Vec<(String, AnthropicToolResultContent)>,
908 ) -> Result<()> {
909 if let Some(last_msg) = anthropic_messages.last() {
910 if last_msg.role == "assistant" {
911 let tool_results: Vec<AnthropicContentBlock> = pending_tool_results
912 .drain(..)
913 .map(|(tool_use_id, content)| AnthropicContentBlock::ToolResult {
914 tool_use_id,
915 content,
916 })
917 .collect();
918
919 self.debug(&format!(
920 "Adding tool results user message with {} result(s)",
921 tool_results.len()
922 ));
923
924 anthropic_messages
925 .push(AnthropicMessage { role: "user".to_string(), content: tool_results });
926 } else {
927 self.debug("WARNING: Last message is not assistant, cannot attach tool results");
928 }
929 }
930 Ok(())
931 }
932
933 ///
934 /// Prepend system text to the first text block of a message.
935 ///
936 /// Either modifies the first existing text block or inserts a new
937 /// text block at the beginning with the system content.
938 ///
939 /// # Arguments
940 /// * `msg` - message to prepend system text to
941 /// * `system_text` - system text to prepend
942 fn prepend_system_text(&self, msg: &mut AnthropicMessage, system_text: &str) {
943 if let Some(first_text_block) =
944 msg.content.iter_mut().find(|c| matches!(c, AnthropicContentBlock::Text { .. }))
945 {
946 if let AnthropicContentBlock::Text { text } = first_text_block {
947 *text = format!("{}\n\n{}", system_text, text);
948 }
949 } else {
950 msg.content.insert(0, AnthropicContentBlock::Text { text: system_text.to_string() });
951 }
952 }
953
954 ///
955 /// Log debug message if trace logging is enabled.
956 ///
957 /// # Arguments
958 /// * `msg` - debug message to log
959 pub(crate) fn debug(&self, msg: &str) {
960 if self.log_level.is_trace_enabled() {
961 tracing::debug!("[TRACE] {}", msg);
962 }
963 }
964}