use crate::apis::amazon_bedrock::{ AnyChoice, AutoChoice, ContentBlock, ConversationRole, ConverseRequest, InferenceConfiguration, Message as BedrockMessage, SystemContentBlock, Tool as BedrockTool, ToolChoice as BedrockToolChoice, ToolChoiceSpec, ToolConfiguration, ToolInputSchema, ToolSpecDefinition, }; use crate::apis::anthropic::{ MessagesContentBlock, MessagesMessage, MessagesMessageContent, MessagesRequest, MessagesRole, MessagesSystemPrompt, MessagesTool, MessagesToolChoice, MessagesToolChoiceType, ToolResultContent, }; use crate::apis::openai::{ ChatCompletionsRequest, FunctionCall as OpenAIFunctionCall, Message, MessageContent, Role, Tool, ToolCall as OpenAIToolCall, ToolChoice, ToolChoiceType, }; use crate::apis::openai_responses::{ InputContent, InputItem, InputParam, MessageRole, Modality, ReasoningEffort, ResponsesAPIRequest, Tool as ResponsesTool, ToolChoice as ResponsesToolChoice, }; use crate::clients::TransformError; use crate::transforms::lib::*; use crate::transforms::*; type AnthropicMessagesRequest = MessagesRequest; // ============================================================================ // RESPONSES API INPUT CONVERSION // ============================================================================ /// Helper struct for converting ResponsesAPI input to OpenAI messages pub struct ResponsesInputConverter { pub input: InputParam, pub instructions: Option, } impl TryFrom for Vec { type Error = TransformError; fn try_from(converter: ResponsesInputConverter) -> Result { // Convert input to messages match converter.input { InputParam::Text(text) => { // Simple text input becomes a user message let mut messages = Vec::new(); // Add instructions as system message if present if let Some(instructions) = converter.instructions { messages.push(Message { role: Role::System, content: Some(MessageContent::Text(instructions)), name: None, tool_call_id: None, tool_calls: None, }); } // Add the user message messages.push(Message { role: Role::User, content: Some(MessageContent::Text(text)), name: None, tool_call_id: None, tool_calls: None, }); Ok(messages) } InputParam::SingleItem(item) => { // Some clients send a single object instead of an array. let nested = ResponsesInputConverter { input: InputParam::Items(vec![item]), instructions: converter.instructions, }; Vec::::try_from(nested) } InputParam::Items(items) => { // Convert input items to messages let mut converted_messages = Vec::new(); // Add instructions as system message if present if let Some(instructions) = converter.instructions { converted_messages.push(Message { role: Role::System, content: Some(MessageContent::Text(instructions)), name: None, tool_call_id: None, tool_calls: None, }); } // Convert each input item for item in items { match item { InputItem::Message(input_msg) => { let role = match input_msg.role { MessageRole::User => Role::User, MessageRole::Assistant => Role::Assistant, MessageRole::System => Role::System, MessageRole::Developer => Role::Developer, MessageRole::Tool => Role::Tool, }; // Convert content based on MessageContent type let content = match &input_msg.content { crate::apis::openai_responses::MessageContent::Text(text) => { // Simple text content MessageContent::Text(text.clone()) } crate::apis::openai_responses::MessageContent::Items( content_items, ) => { // Check if it's a single text item (can use simple text format) if content_items.len() == 1 { if let InputContent::InputText { text } = &content_items[0] { MessageContent::Text(text.clone()) } else { // Single non-text item - use parts format MessageContent::Parts( content_items .iter() .filter_map(|c| match c { InputContent::InputText { text } => { Some(crate::apis::openai::ContentPart::Text { text: text.clone(), }) } InputContent::InputImage { image_url, .. } => { Some(crate::apis::openai::ContentPart::ImageUrl { image_url: crate::apis::openai::ImageUrl { url: image_url.clone(), detail: None, }, }) } InputContent::InputFile { .. } => None, // Skip files for now InputContent::InputAudio { .. } => None, // Skip audio for now }) .collect(), ) } } else { // Multiple content items - convert to parts MessageContent::Parts( content_items .iter() .filter_map(|c| match c { InputContent::InputText { text } => { Some(crate::apis::openai::ContentPart::Text { text: text.clone(), }) } InputContent::InputImage { image_url, .. } => { Some(crate::apis::openai::ContentPart::ImageUrl { image_url: crate::apis::openai::ImageUrl { url: image_url.clone(), detail: None, }, }) } InputContent::InputFile { .. } => None, // Skip files for now InputContent::InputAudio { .. } => None, // Skip audio for now }) .collect(), ) } } }; converted_messages.push(Message { role, content: Some(content), name: None, tool_call_id: None, tool_calls: None, }); } InputItem::FunctionCallOutput { item_type: _, call_id, output, } => { // Preserve tool result so upstream models do not re-issue the same tool call. let output_text = match output { serde_json::Value::String(s) => s.clone(), other => serde_json::to_string(&other).unwrap_or_default(), }; converted_messages.push(Message { role: Role::Tool, content: Some(MessageContent::Text(output_text)), name: None, tool_call_id: Some(call_id), tool_calls: None, }); } InputItem::FunctionCall { item_type: _, name, arguments, call_id, } => { let tool_call = OpenAIToolCall { id: call_id, call_type: "function".to_string(), function: OpenAIFunctionCall { name, arguments }, }; // Prefer attaching tool_calls to the preceding assistant message when present. if let Some(last) = converted_messages.last_mut() { if matches!(last.role, Role::Assistant) { if let Some(existing) = &mut last.tool_calls { existing.push(tool_call); } else { last.tool_calls = Some(vec![tool_call]); } continue; } } converted_messages.push(Message { role: Role::Assistant, content: None, name: None, tool_call_id: None, tool_calls: Some(vec![tool_call]), }); } InputItem::ItemReference { .. } => { // Item references/unknown entries are metadata-like and can be skipped // for chat-completions conversion. } } } Ok(converted_messages) } } } } // ============================================================================ // MAIN REQUEST TRANSFORMATIONS // ============================================================================ impl From for MessagesSystemPrompt { fn from(val: Message) -> Self { MessagesSystemPrompt::Single(val.content.extract_text()) } } impl TryFrom for MessagesMessage { type Error = TransformError; fn try_from(message: Message) -> Result { let role = match message.role { Role::User => MessagesRole::User, Role::Assistant => MessagesRole::Assistant, Role::Tool => { // Tool messages become user messages with tool results // Extract content text first, before moving tool_call_id let content_text = message.content.extract_text(); let tool_call_id = message.tool_call_id.ok_or_else(|| { TransformError::MissingField( "tool_call_id required for Tool messages".to_string(), ) })?; return Ok(MessagesMessage { role: MessagesRole::User, content: MessagesMessageContent::Blocks(vec![ MessagesContentBlock::ToolResult { tool_use_id: tool_call_id, is_error: None, content: ToolResultContent::Blocks(vec![MessagesContentBlock::Text { text: content_text, cache_control: None, }]), cache_control: None, }, ]), }); } Role::System | Role::Developer => { return Err(TransformError::UnsupportedConversion( "System messages should be handled separately".to_string(), )); } }; let content_blocks = convert_openai_message_to_anthropic_content(&message)?; let content = build_anthropic_content(content_blocks); Ok(MessagesMessage { role, content }) } } impl TryFrom for BedrockMessage { type Error = TransformError; fn try_from(message: Message) -> Result { let role = match message.role { Role::User => ConversationRole::User, Role::Assistant => ConversationRole::Assistant, Role::Tool => ConversationRole::User, // Tool results become user messages in Bedrock Role::System | Role::Developer => { return Err(TransformError::UnsupportedConversion( "System messages should be handled separately in Bedrock".to_string(), )); } }; let mut content_blocks = Vec::new(); // Handle different message types match message.role { Role::User => { // Convert user message content to content blocks match message.content { Some(MessageContent::Text(text)) if !text.is_empty() => { content_blocks.push(ContentBlock::Text { text }); } Some(MessageContent::Text(_)) => {} Some(MessageContent::Parts(parts)) => { // Convert OpenAI content parts to Bedrock ContentBlocks for part in parts { match part { crate::apis::openai::ContentPart::Text { text } => { if !text.is_empty() { content_blocks.push(ContentBlock::Text { text }); } } crate::apis::openai::ContentPart::ImageUrl { image_url } => { // Convert image URL to Bedrock image format if image_url.url.starts_with("data:") { if let Some((media_type, data)) = parse_data_url(&image_url.url) { content_blocks.push(ContentBlock::Image { image: crate::apis::amazon_bedrock::ImageBlock { source: crate::apis::amazon_bedrock::ImageSource::Base64 { media_type, data, }, }, }); } else { return Err(TransformError::UnsupportedConversion( format!( "Invalid data URL format: {}", image_url.url ), )); } } else { return Err(TransformError::UnsupportedConversion( "Only base64 data URLs are supported for images in Bedrock".to_string() )); } } } } } None => { // Empty content for user - shouldn't happen but handle gracefully } } // Ensure we have at least one content block if content_blocks.is_empty() { content_blocks.push(ContentBlock::Text { text: " ".to_string(), }); } } Role::Assistant => { // Handle text content - but only add if non-empty OR if we don't have tool calls let text_content = message.content.extract_text(); let has_tool_calls = message .tool_calls .as_ref() .is_some_and(|calls| !calls.is_empty()); // Add text content if it's non-empty, or if we have no tool calls (to avoid empty content) if !text_content.is_empty() { content_blocks.push(ContentBlock::Text { text: text_content }); } else if !has_tool_calls { // If we have empty content and no tool calls, add a minimal placeholder // This prevents the "blank text field" error content_blocks.push(ContentBlock::Text { text: " ".to_string(), }); } // Convert tool calls to ToolUse content blocks if let Some(tool_calls) = message.tool_calls { for tool_call in tool_calls { // Parse the arguments string as JSON let input: serde_json::Value = serde_json::from_str(&tool_call.function.arguments).map_err(|e| { TransformError::UnsupportedConversion(format!( "Failed to parse tool arguments as JSON: {}. Arguments: {}", e, tool_call.function.arguments )) })?; content_blocks.push(ContentBlock::ToolUse { tool_use: crate::apis::amazon_bedrock::ToolUseBlock { tool_use_id: tool_call.id, name: tool_call.function.name, input, }, }); } } // Bedrock requires at least one content block if content_blocks.is_empty() { content_blocks.push(ContentBlock::Text { text: " ".to_string(), }); } } Role::Tool => { // Tool messages become user messages with ToolResult content blocks let tool_call_id = message.tool_call_id.ok_or_else(|| { TransformError::MissingField( "tool_call_id required for Tool messages".to_string(), ) })?; let tool_content = message.content.extract_text(); // Create ToolResult content block let tool_result_content = if tool_content.is_empty() { // Even for tool results, we need non-empty content vec![crate::apis::amazon_bedrock::ToolResultContentBlock::Text { text: " ".to_string(), }] } else { vec![crate::apis::amazon_bedrock::ToolResultContentBlock::Text { text: tool_content, }] }; content_blocks.push(ContentBlock::ToolResult { tool_result: crate::apis::amazon_bedrock::ToolResultBlock { tool_use_id: tool_call_id, content: tool_result_content, status: Some(crate::apis::amazon_bedrock::ToolResultStatus::Success), // Default to success }, }); } Role::System | Role::Developer => { // Already handled above with early return unreachable!() } } Ok(BedrockMessage { role, content: content_blocks, }) } } impl TryFrom for ChatCompletionsRequest { type Error = TransformError; fn try_from(req: ResponsesAPIRequest) -> Result { fn normalize_function_parameters( parameters: Option, fallback_extra: Option, ) -> serde_json::Value { // ChatCompletions function tools require JSON Schema with top-level type=object. let mut base = serde_json::json!({ "type": "object", "properties": {}, }); if let Some(serde_json::Value::Object(mut obj)) = parameters { // Enforce a valid object schema shape regardless of upstream tool format. obj.insert( "type".to_string(), serde_json::Value::String("object".to_string()), ); if !obj.contains_key("properties") { obj.insert( "properties".to_string(), serde_json::Value::Object(serde_json::Map::new()), ); } base = serde_json::Value::Object(obj); } if let Some(extra) = fallback_extra { if let serde_json::Value::Object(ref mut map) = base { map.insert("x-custom-format".to_string(), extra); } } base } let mut converted_chat_tools: Vec = Vec::new(); let mut web_search_options: Option = None; if let Some(tools) = req.tools.clone() { for (idx, tool) in tools.into_iter().enumerate() { match tool { ResponsesTool::Function { name, description, parameters, strict, } => converted_chat_tools.push(Tool { tool_type: "function".to_string(), function: crate::apis::openai::Function { name, description, parameters: normalize_function_parameters(parameters, None), strict, }, }), ResponsesTool::WebSearchPreview { search_context_size, user_location, .. } => { if web_search_options.is_none() { let user_location_value = user_location.map(|loc| { let mut approx = serde_json::Map::new(); if let Some(city) = loc.city { approx.insert( "city".to_string(), serde_json::Value::String(city), ); } if let Some(country) = loc.country { approx.insert( "country".to_string(), serde_json::Value::String(country), ); } if let Some(region) = loc.region { approx.insert( "region".to_string(), serde_json::Value::String(region), ); } if let Some(timezone) = loc.timezone { approx.insert( "timezone".to_string(), serde_json::Value::String(timezone), ); } serde_json::json!({ "type": loc.location_type, "approximate": serde_json::Value::Object(approx), }) }); let mut web_search = serde_json::Map::new(); if let Some(size) = search_context_size { web_search.insert( "search_context_size".to_string(), serde_json::Value::String(size), ); } if let Some(location) = user_location_value { web_search.insert("user_location".to_string(), location); } web_search_options = Some(serde_json::Value::Object(web_search)); } } ResponsesTool::Custom { name, description, format, } => { // Custom tools do not have a strict ChatCompletions equivalent for all // providers. Map them to a permissive function tool for compatibility. let tool_name = name.unwrap_or_else(|| format!("custom_tool_{}", idx + 1)); let parameters = normalize_function_parameters( Some(serde_json::json!({ "type": "object", "properties": { "input": { "type": "string" } }, "required": ["input"], "additionalProperties": true, })), format, ); converted_chat_tools.push(Tool { tool_type: "function".to_string(), function: crate::apis::openai::Function { name: tool_name, description, parameters, strict: Some(false), }, }); } ResponsesTool::FileSearch { .. } => { return Err(TransformError::UnsupportedConversion( "FileSearch tool is not supported in ChatCompletions API. Only function/custom/web search tools are supported in this conversion." .to_string(), )); } ResponsesTool::CodeInterpreter => { return Err(TransformError::UnsupportedConversion( "CodeInterpreter tool is not supported in ChatCompletions API conversion." .to_string(), )); } ResponsesTool::Computer { .. } => { return Err(TransformError::UnsupportedConversion( "Computer tool is not supported in ChatCompletions API conversion." .to_string(), )); } } } } let tools = if converted_chat_tools.is_empty() { None } else { Some(converted_chat_tools) }; // Convert input to messages using the shared converter let converter = ResponsesInputConverter { input: req.input, instructions: req.instructions.clone(), }; let messages: Vec = converter.try_into()?; // Build the ChatCompletionsRequest Ok(ChatCompletionsRequest { model: req.model, messages, temperature: req.temperature, top_p: req.top_p, max_completion_tokens: req.max_output_tokens.map(|t| t as u32), stream: req.stream, metadata: req.metadata, user: req.user, store: req.store, service_tier: req.service_tier, top_logprobs: req.top_logprobs.map(|t| t as u32), modalities: req.modalities.map(|mods| { mods.into_iter() .map(|m| match m { Modality::Text => "text".to_string(), Modality::Audio => "audio".to_string(), }) .collect() }), stream_options: req .stream_options .map(|opts| crate::apis::openai::StreamOptions { include_usage: opts.include_usage, }), reasoning_effort: req.reasoning_effort.map(|effort| match effort { ReasoningEffort::Low => "low".to_string(), ReasoningEffort::Medium => "medium".to_string(), ReasoningEffort::High => "high".to_string(), }), tools, tool_choice: req.tool_choice.map(|choice| { match choice { ResponsesToolChoice::String(s) => { match s.as_str() { "auto" => ToolChoice::Type(ToolChoiceType::Auto), "required" => ToolChoice::Type(ToolChoiceType::Required), "none" => ToolChoice::Type(ToolChoiceType::None), _ => ToolChoice::Type(ToolChoiceType::Auto), // Default to auto for unknown strings } } ResponsesToolChoice::Named { function, .. } => ToolChoice::Function { choice_type: "function".to_string(), function: crate::apis::openai::FunctionChoice { name: function.name, }, }, } }), parallel_tool_calls: req.parallel_tool_calls, web_search_options, ..Default::default() }) } } impl TryFrom for AnthropicMessagesRequest { type Error = TransformError; fn try_from(req: ChatCompletionsRequest) -> Result { let mut system_prompt = None; let mut messages = Vec::new(); for message in req.messages { match message.role { Role::System | Role::Developer => { system_prompt = Some(message.into()); } _ => { let anthropic_message: MessagesMessage = message.try_into()?; messages.push(anthropic_message); } } } // Convert tools and tool choice let anthropic_tools = req.tools.map(convert_openai_tools); let anthropic_tool_choice = convert_openai_tool_choice(req.tool_choice, req.parallel_tool_calls); Ok(AnthropicMessagesRequest { model: req.model, system: system_prompt, messages, max_tokens: req .max_completion_tokens .or(req.max_tokens) .unwrap_or(DEFAULT_MAX_TOKENS), container: None, mcp_servers: None, service_tier: None, thinking: None, temperature: req.temperature, top_p: req.top_p, top_k: None, // OpenAI doesn't have top_k stream: req.stream, stop_sequences: req.stop, tools: anthropic_tools, tool_choice: anthropic_tool_choice, metadata: None, }) } } impl TryFrom for ConverseRequest { type Error = TransformError; fn try_from(req: ChatCompletionsRequest) -> Result { // Separate system messages from user/assistant messages let mut system_messages = Vec::new(); let mut conversation_messages = Vec::new(); for message in req.messages { match message.role { Role::System | Role::Developer => { let system_text = message.content.extract_text(); system_messages.push(SystemContentBlock::Text { text: system_text }); } _ => { let bedrock_message: BedrockMessage = message.try_into()?; conversation_messages.push(bedrock_message); } } } // Convert system messages let system = if system_messages.is_empty() { None } else { Some(system_messages) }; // Convert conversation messages let messages = if conversation_messages.is_empty() { None } else { Some(conversation_messages) }; // Build inference configuration let max_tokens = req.max_completion_tokens.or(req.max_tokens); let inference_config = if max_tokens.is_some() || req.temperature.is_some() || req.top_p.is_some() || req.stop.is_some() { Some(InferenceConfiguration { max_tokens, temperature: req.temperature, top_p: req.top_p, stop_sequences: req.stop, }) } else { None }; // Convert tools and tool choice to ToolConfiguration let tool_config = if req.tools.is_some() || req.tool_choice.is_some() { let tools = req.tools.map(|openai_tools| { openai_tools .into_iter() .map(|tool| BedrockTool::ToolSpec { tool_spec: ToolSpecDefinition { name: tool.function.name, description: tool.function.description, input_schema: ToolInputSchema { json: tool.function.parameters, }, }, }) .collect() }); let tool_choice = req .tool_choice .map(|choice| { match choice { ToolChoice::Type(tool_type) => match tool_type { ToolChoiceType::Auto => BedrockToolChoice::Auto { auto: AutoChoice {}, }, ToolChoiceType::Required => { BedrockToolChoice::Any { any: AnyChoice {} } } ToolChoiceType::None => BedrockToolChoice::Auto { auto: AutoChoice {}, }, // Bedrock doesn't have explicit "none" }, ToolChoice::Function { function, .. } => BedrockToolChoice::Tool { tool: ToolChoiceSpec { name: function.name, }, }, } }) .or_else(|| { // If tools are present but no tool_choice specified, default to "auto" if tools.is_some() { Some(BedrockToolChoice::Auto { auto: AutoChoice {}, }) } else { None } }); Some(ToolConfiguration { tools, tool_choice }) } else { None }; Ok(ConverseRequest { model_id: req.model, messages, system, inference_config, tool_config, stream: req.stream.unwrap_or(false), guardrail_config: None, additional_model_request_fields: None, additional_model_response_field_paths: None, performance_config: None, prompt_variables: None, request_metadata: None, metadata: None, }) } } /// Convert OpenAI tools to Anthropic format fn convert_openai_tools(tools: Vec) -> Vec { tools .into_iter() .map(|tool| MessagesTool { name: tool.function.name, description: tool.function.description, input_schema: tool.function.parameters, }) .collect() } /// Convert OpenAI tool choice to Anthropic format fn convert_openai_tool_choice( tool_choice: Option, parallel_tool_calls: Option, ) -> Option { tool_choice.map(|choice| match choice { ToolChoice::Type(tool_type) => match tool_type { ToolChoiceType::Auto => MessagesToolChoice { kind: MessagesToolChoiceType::Auto, name: None, disable_parallel_tool_use: parallel_tool_calls.map(|p| !p), }, ToolChoiceType::Required => MessagesToolChoice { kind: MessagesToolChoiceType::Any, name: None, disable_parallel_tool_use: parallel_tool_calls.map(|p| !p), }, ToolChoiceType::None => MessagesToolChoice { kind: MessagesToolChoiceType::None, name: None, disable_parallel_tool_use: None, }, }, ToolChoice::Function { function, .. } => MessagesToolChoice { kind: MessagesToolChoiceType::Tool, name: Some(function.name), disable_parallel_tool_use: parallel_tool_calls.map(|p| !p), }, }) } /// Build Anthropic message content from content blocks fn build_anthropic_content(content_blocks: Vec) -> MessagesMessageContent { if content_blocks.len() == 1 { match &content_blocks[0] { MessagesContentBlock::Text { text, .. } => MessagesMessageContent::Single(text.clone()), _ => MessagesMessageContent::Blocks(content_blocks), } } else if content_blocks.is_empty() { MessagesMessageContent::Single("".to_string()) } else { MessagesMessageContent::Blocks(content_blocks) } } /// Parse a data URL into media type and base64 data /// Supports format: data:image/jpeg;base64, fn parse_data_url(url: &str) -> Option<(String, String)> { if !url.starts_with("data:") { return None; } let without_prefix = &url[5..]; // Remove "data:" prefix let parts: Vec<&str> = without_prefix.splitn(2, ',').collect(); if parts.len() != 2 { return None; } let header = parts[0]; let data = parts[1]; // Parse header: "image/jpeg;base64" or just "image/jpeg" let header_parts: Vec<&str> = header.split(';').collect(); if header_parts.is_empty() { return None; } let media_type = header_parts[0].to_string(); // Check if it's base64 encoded if header_parts.len() > 1 && header_parts[1] == "base64" { Some((media_type, data.to_string())) } else { // For now, only support base64 encoding None } } #[cfg(test)] mod tests { use super::*; use crate::apis::amazon_bedrock::{ ContentBlock, ConversationRole, ConverseRequest, SystemContentBlock, ToolChoice as BedrockToolChoice, }; use crate::apis::openai::{ ChatCompletionsRequest, Function, FunctionChoice, Message, MessageContent, Role, Tool, ToolChoice, ToolChoiceType, }; use serde_json::json; #[test] fn test_openai_to_bedrock_basic_request() { let openai_request = ChatCompletionsRequest { model: "gpt-4".to_string(), messages: vec![ Message { role: Role::System, content: Some(MessageContent::Text( "You are a helpful assistant.".to_string(), )), name: None, tool_call_id: None, tool_calls: None, }, Message { role: Role::User, content: Some(MessageContent::Text("Hello, how are you?".to_string())), name: None, tool_call_id: None, tool_calls: None, }, ], temperature: Some(0.7), top_p: Some(0.9), max_completion_tokens: Some(1000), stop: Some(vec!["STOP".to_string()]), stream: Some(false), tools: None, tool_choice: None, ..Default::default() }; let bedrock_request: ConverseRequest = openai_request.try_into().unwrap(); assert_eq!(bedrock_request.model_id, "gpt-4"); assert!(bedrock_request.system.is_some()); assert_eq!(bedrock_request.system.as_ref().unwrap().len(), 1); if let SystemContentBlock::Text { text } = &bedrock_request.system.as_ref().unwrap()[0] { assert_eq!(text, "You are a helpful assistant."); } else { panic!("Expected system text block"); } assert!(bedrock_request.messages.is_some()); let messages = bedrock_request.messages.as_ref().unwrap(); assert_eq!(messages.len(), 1); assert_eq!(messages[0].role, ConversationRole::User); if let ContentBlock::Text { text } = &messages[0].content[0] { assert_eq!(text, "Hello, how are you?"); } else { panic!("Expected text content block"); } let inference_config = bedrock_request.inference_config.as_ref().unwrap(); assert_eq!(inference_config.temperature, Some(0.7)); assert_eq!(inference_config.top_p, Some(0.9)); assert_eq!(inference_config.max_tokens, Some(1000)); assert_eq!( inference_config.stop_sequences, Some(vec!["STOP".to_string()]) ); } #[test] fn test_openai_to_bedrock_with_tools() { let openai_request = ChatCompletionsRequest { model: "gpt-4".to_string(), messages: vec![Message { role: Role::User, content: Some(MessageContent::Text("What's the weather like?".to_string())), name: None, tool_call_id: None, tool_calls: None, }], temperature: None, top_p: None, max_completion_tokens: Some(1000), stop: None, stream: None, tools: Some(vec![Tool { tool_type: "function".to_string(), function: Function { name: "get_weather".to_string(), description: Some("Get current weather information".to_string()), parameters: json!({ "type": "object", "properties": { "location": { "type": "string", "description": "The city name" } }, "required": ["location"] }), strict: None, }, }]), tool_choice: Some(ToolChoice::Function { choice_type: "function".to_string(), function: FunctionChoice { name: "get_weather".to_string(), }, }), ..Default::default() }; let bedrock_request: ConverseRequest = openai_request.try_into().unwrap(); assert_eq!(bedrock_request.model_id, "gpt-4"); assert!(bedrock_request.tool_config.is_some()); let tool_config = bedrock_request.tool_config.as_ref().unwrap(); assert!(tool_config.tools.is_some()); let tools = tool_config.tools.as_ref().unwrap(); assert_eq!(tools.len(), 1); let crate::apis::amazon_bedrock::Tool::ToolSpec { tool_spec } = &tools[0]; assert_eq!(tool_spec.name, "get_weather"); assert_eq!( tool_spec.description, Some("Get current weather information".to_string()) ); if let Some(BedrockToolChoice::Tool { tool }) = &tool_config.tool_choice { assert_eq!(tool.name, "get_weather"); } else { panic!("Expected specific tool choice"); } } #[test] fn test_openai_to_bedrock_auto_tool_choice() { let openai_request = ChatCompletionsRequest { model: "gpt-4".to_string(), messages: vec![Message { role: Role::User, content: Some(MessageContent::Text("Help me with something".to_string())), name: None, tool_call_id: None, tool_calls: None, }], temperature: None, top_p: None, max_completion_tokens: Some(500), stop: None, stream: None, tools: Some(vec![Tool { tool_type: "function".to_string(), function: Function { name: "help_tool".to_string(), description: Some("A helpful tool".to_string()), parameters: json!({ "type": "object", "properties": {} }), strict: None, }, }]), tool_choice: Some(ToolChoice::Type(ToolChoiceType::Auto)), ..Default::default() }; let bedrock_request: ConverseRequest = openai_request.try_into().unwrap(); assert!(bedrock_request.tool_config.is_some()); let tool_config = bedrock_request.tool_config.as_ref().unwrap(); assert!(matches!( tool_config.tool_choice, Some(BedrockToolChoice::Auto { .. }) )); } #[test] fn test_openai_to_bedrock_multi_message_conversation() { let openai_request = ChatCompletionsRequest { model: "gpt-4".to_string(), messages: vec![ Message { role: Role::System, content: Some(MessageContent::Text("Be concise".to_string())), name: None, tool_call_id: None, tool_calls: None, }, Message { role: Role::User, content: Some(MessageContent::Text("Hello".to_string())), name: None, tool_call_id: None, tool_calls: None, }, Message { role: Role::Assistant, content: Some(MessageContent::Text( "Hi there! How can I help you?".to_string(), )), name: None, tool_call_id: None, tool_calls: None, }, Message { role: Role::User, content: Some(MessageContent::Text("What's 2+2?".to_string())), name: None, tool_call_id: None, tool_calls: None, }, ], temperature: Some(0.5), top_p: None, max_completion_tokens: Some(100), stop: None, stream: None, tools: None, tool_choice: None, ..Default::default() }; let bedrock_request: ConverseRequest = openai_request.try_into().unwrap(); assert!(bedrock_request.messages.is_some()); let messages = bedrock_request.messages.as_ref().unwrap(); assert_eq!(messages.len(), 3); // System message is separate assert_eq!(messages[0].role, ConversationRole::User); assert_eq!(messages[1].role, ConversationRole::Assistant); assert_eq!(messages[2].role, ConversationRole::User); // Check system prompt assert!(bedrock_request.system.is_some()); if let SystemContentBlock::Text { text } = &bedrock_request.system.as_ref().unwrap()[0] { assert_eq!(text, "Be concise"); } else { panic!("Expected system text block"); } } #[test] fn test_openai_message_to_bedrock_conversion() { let openai_message = Message { role: Role::User, content: Some(MessageContent::Text("Test message".to_string())), name: None, tool_call_id: None, tool_calls: None, }; let bedrock_message: BedrockMessage = openai_message.try_into().unwrap(); assert_eq!(bedrock_message.role, ConversationRole::User); assert_eq!(bedrock_message.content.len(), 1); if let ContentBlock::Text { text } = &bedrock_message.content[0] { assert_eq!(text, "Test message"); } else { panic!("Expected text content block"); } } #[test] fn test_responses_custom_tool_maps_to_function_tool_for_chat_completions() { use crate::apis::openai_responses::{ InputParam, ResponsesAPIRequest, Tool as ResponsesTool, }; let req = ResponsesAPIRequest { model: "gpt-5.3-codex".to_string(), input: InputParam::Text("use custom tool".to_string()), tools: Some(vec![ResponsesTool::Custom { name: Some("run_patch".to_string()), description: Some("Apply structured patch".to_string()), format: Some(serde_json::json!({ "kind": "patch", "version": "v1" })), }]), include: None, parallel_tool_calls: None, store: None, instructions: None, stream: None, stream_options: None, conversation: None, tool_choice: None, max_output_tokens: None, temperature: None, top_p: None, metadata: None, previous_response_id: None, modalities: None, audio: None, text: None, reasoning_effort: None, truncation: None, user: None, max_tool_calls: None, service_tier: None, background: None, top_logprobs: None, }; let converted = ChatCompletionsRequest::try_from(req).expect("conversion should succeed"); let tools = converted.tools.expect("tools should be present"); assert_eq!(tools.len(), 1); assert_eq!(tools[0].tool_type, "function"); assert_eq!(tools[0].function.name, "run_patch"); assert_eq!( tools[0].function.description.as_deref(), Some("Apply structured patch") ); } #[test] fn test_responses_web_search_maps_to_chat_web_search_options() { use crate::apis::openai_responses::{ InputParam, ResponsesAPIRequest, Tool as ResponsesTool, UserLocation, }; let req = ResponsesAPIRequest { model: "gpt-5.3-codex".to_string(), input: InputParam::Text("find project docs".to_string()), tools: Some(vec![ResponsesTool::WebSearchPreview { domains: Some(vec!["docs.planoai.dev".to_string()]), search_context_size: Some("medium".to_string()), user_location: Some(UserLocation { location_type: "approximate".to_string(), city: Some("San Francisco".to_string()), country: Some("US".to_string()), region: Some("CA".to_string()), timezone: Some("America/Los_Angeles".to_string()), }), }]), include: None, parallel_tool_calls: None, store: None, instructions: None, stream: None, stream_options: None, conversation: None, tool_choice: None, max_output_tokens: None, temperature: None, top_p: None, metadata: None, previous_response_id: None, modalities: None, audio: None, text: None, reasoning_effort: None, truncation: None, user: None, max_tool_calls: None, service_tier: None, background: None, top_logprobs: None, }; let converted = ChatCompletionsRequest::try_from(req).expect("conversion should succeed"); assert!(converted.web_search_options.is_some()); } #[test] fn test_responses_function_call_output_maps_to_tool_message() { use crate::apis::openai_responses::{ InputItem, InputParam, ResponsesAPIRequest, Tool as ResponsesTool, }; let req = ResponsesAPIRequest { model: "gpt-5.3-codex".to_string(), input: InputParam::Items(vec![InputItem::FunctionCallOutput { item_type: "function_call_output".to_string(), call_id: "call_123".to_string(), output: serde_json::json!({"status":"ok","stdout":"hello"}), }]), tools: Some(vec![ResponsesTool::Function { name: "exec_command".to_string(), description: Some("Execute a shell command".to_string()), parameters: Some(serde_json::json!({ "type": "object", "properties": { "cmd": { "type": "string" } }, "required": ["cmd"] })), strict: Some(false), }]), include: None, parallel_tool_calls: None, store: None, instructions: None, stream: None, stream_options: None, conversation: None, tool_choice: None, max_output_tokens: None, temperature: None, top_p: None, metadata: None, previous_response_id: None, modalities: None, audio: None, text: None, reasoning_effort: None, truncation: None, user: None, max_tool_calls: None, service_tier: None, background: None, top_logprobs: None, }; let converted = ChatCompletionsRequest::try_from(req).expect("conversion should succeed"); assert_eq!(converted.messages.len(), 1); assert!(matches!(converted.messages[0].role, Role::Tool)); assert_eq!( converted.messages[0].tool_call_id.as_deref(), Some("call_123") ); } #[test] fn test_responses_function_call_and_output_preserve_call_id_link() { use crate::apis::openai_responses::{ InputItem, InputMessage, MessageContent as ResponsesMessageContent, MessageRole, ResponsesAPIRequest, }; let req = ResponsesAPIRequest { model: "gpt-5.3-codex".to_string(), input: InputParam::Items(vec![ InputItem::Message(InputMessage { role: MessageRole::Assistant, content: ResponsesMessageContent::Items(vec![]), }), InputItem::FunctionCall { item_type: "function_call".to_string(), name: "exec_command".to_string(), arguments: "{\"cmd\":\"pwd\"}".to_string(), call_id: "toolu_abc123".to_string(), }, InputItem::FunctionCallOutput { item_type: "function_call_output".to_string(), call_id: "toolu_abc123".to_string(), output: serde_json::Value::String("ok".to_string()), }, ]), tools: None, include: None, parallel_tool_calls: None, store: None, instructions: None, stream: None, stream_options: None, conversation: None, tool_choice: None, max_output_tokens: None, temperature: None, top_p: None, metadata: None, previous_response_id: None, modalities: None, audio: None, text: None, reasoning_effort: None, truncation: None, user: None, max_tool_calls: None, service_tier: None, background: None, top_logprobs: None, }; let converted = ChatCompletionsRequest::try_from(req).expect("conversion should succeed"); assert_eq!(converted.messages.len(), 2); assert!(matches!(converted.messages[0].role, Role::Assistant)); let tool_calls = converted.messages[0] .tool_calls .as_ref() .expect("assistant tool_calls should be present"); assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].id, "toolu_abc123"); assert!(matches!(converted.messages[1].role, Role::Tool)); assert_eq!( converted.messages[1].tool_call_id.as_deref(), Some("toolu_abc123") ); } }