//! OpenAI Request Builder //! //! Converts Open Responses native Item model to OpenAI Chat Completions API format. use serde::Serialize; use serde_json::Value; use crate::llm_client::{ Request, capability::{ModelCapability, ReasoningControl, ReasoningSupport}, types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments}, }; use super::OpenAIScheme; /// OpenAI API request body #[derive(Debug, Serialize)] pub(crate) struct OpenAIRequest { pub model: String, #[serde(skip_serializing_if = "Option::is_none")] pub max_completion_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens: Option, // Legacy field for compatibility (e.g. Ollama) #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] pub top_p: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub stop: Vec, pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] pub stream_options: Option, pub messages: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub tools: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub tool_choice: Option, /// Reasoning effort(o1 / o3 / o4 / gpt-5 系で有効)。 #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, } #[derive(Debug, Serialize)] pub(crate) struct StreamOptions { pub include_usage: bool, } /// OpenAI message #[derive(Debug, Serialize)] pub(crate) struct OpenAIMessage { pub role: String, pub content: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub tool_calls: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, } /// OpenAI content #[allow(dead_code)] #[derive(Debug, Serialize)] #[serde(untagged)] pub(crate) enum OpenAIContent { Text(String), Parts(Vec), } /// OpenAI content part #[allow(dead_code)] #[derive(Debug, Serialize)] #[serde(tag = "type")] pub(crate) enum OpenAIContentPart { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "image_url")] ImageUrl { image_url: ImageUrl }, } #[derive(Debug, Serialize)] pub(crate) struct ImageUrl { pub url: String, } /// OpenAI tool definition #[derive(Debug, Serialize)] pub(crate) struct OpenAITool { pub r#type: String, pub function: OpenAIToolFunction, } #[derive(Debug, Serialize)] pub(crate) struct OpenAIToolFunction { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub parameters: Value, } /// OpenAI tool call in message #[derive(Debug, Serialize)] pub(crate) struct OpenAIToolCall { pub id: String, pub r#type: String, pub function: OpenAIToolCallFunction, } #[derive(Debug, Serialize)] pub(crate) struct OpenAIToolCallFunction { pub name: String, pub arguments: String, } impl OpenAIScheme { /// Build OpenAI request from Request pub(crate) fn build_request( &self, model: &str, request: &Request, capability: &ModelCapability, ) -> OpenAIRequest { let mut messages = Vec::new(); // Add system message if present if let Some(system) = &request.system_prompt { messages.push(OpenAIMessage { role: "system".to_string(), content: Some(OpenAIContent::Text(system.clone())), tool_calls: vec![], tool_call_id: None, name: None, }); } // Convert items to messages messages.extend(self.convert_items_to_messages(&request.items, capability.vision)); let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect(); let (max_tokens, max_completion_tokens) = if self.use_legacy_max_tokens { (request.config.max_tokens, None) } else { (None, request.config.max_tokens) }; // Reasoning の投影: capability が Effort / Both をサポートし、 // request 側で effort が指定されているときだけ reasoning_effort を付ける。 let supports_effort = matches!( capability.reasoning, Some(ReasoningSupport::Effort | ReasoningSupport::Both), ); let reasoning_effort = request .config .reasoning .as_ref() .filter(|_| supports_effort) .and_then(|rc| match rc { ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()), ReasoningControl::BudgetTokens(_) => None, }); OpenAIRequest { model: model.to_string(), max_completion_tokens, max_tokens, temperature: request.config.temperature, top_p: request.config.top_p, stop: request.config.stop_sequences.clone(), stream: true, stream_options: Some(StreamOptions { include_usage: true, }), messages, tools, tool_choice: None, reasoning_effort, } } /// Convert Open Responses Items to OpenAI Messages /// /// OpenAI uses a message-based model where: /// - User messages have role "user" /// - Assistant messages have role "assistant" /// - Tool calls are within assistant messages as tool_calls array /// - Tool results have role "tool" with tool_call_id fn convert_items_to_messages( &self, items: &[Item], supports_images: bool, ) -> Vec { let mut messages = Vec::new(); let mut pending_tool_calls: Vec = Vec::new(); let mut pending_assistant_text: Option = None; for item in items { match item { Item::Message { role, content, .. } => { // Flush pending tool calls self.flush_pending_assistant( &mut messages, &mut pending_tool_calls, &mut pending_assistant_text, ); let openai_role = match role { Role::User => "user", Role::Assistant => "assistant", Role::System => "system", }; let has_image = matches!(role, Role::User) && supports_images && content .iter() .any(|part| matches!(part, ContentPart::Image { .. })); let message_content = if has_image { OpenAIContent::Parts( content .iter() .map(|part| match part { ContentPart::Text { text } => { OpenAIContentPart::Text { text: text.clone() } } ContentPart::Image { media_type, source } => { OpenAIContentPart::ImageUrl { image_url: ImageUrl { url: image_data_url(media_type, source.data()), }, } } ContentPart::Refusal { refusal } => OpenAIContentPart::Text { text: refusal.clone(), }, }) .collect(), ) } else { OpenAIContent::Text( content .iter() .map(ContentPart::as_text) .collect::>() .join(""), ) }; messages.push(OpenAIMessage { role: openai_role.to_string(), content: Some(message_content), tool_calls: vec![], tool_call_id: None, name: None, }); } Item::ToolCall { call_id, name, arguments, .. } => { // Normalize non-object / legacy "null" payloads to "{}" so // OpenAI gets a valid JSON object string. let normalized_args = parse_tool_arguments(arguments).to_string(); pending_tool_calls.push(OpenAIToolCall { id: call_id.clone(), r#type: "function".to_string(), function: OpenAIToolCallFunction { name: name.clone(), arguments: normalized_args, }, }); } Item::ToolResult { call_id, summary, content, .. } => { // Flush pending tool calls before tool result self.flush_pending_assistant( &mut messages, &mut pending_tool_calls, &mut pending_assistant_text, ); let text = match content { Some(c) => format!("{summary}\n{c}"), None => summary.clone(), }; messages.push(OpenAIMessage { role: "tool".to_string(), content: Some(OpenAIContent::Text(text)), tool_calls: vec![], tool_call_id: Some(call_id.clone()), name: None, }); } Item::Reasoning { text, .. } => { // Reasoning is treated as assistant text in OpenAI // (OpenAI doesn't have native reasoning support like Claude) if let Some(ref mut existing) = pending_assistant_text { existing.push_str(text); } else { pending_assistant_text = Some(text.clone()); } } } } // Flush remaining pending items self.flush_pending_assistant( &mut messages, &mut pending_tool_calls, &mut pending_assistant_text, ); messages } fn flush_pending_assistant( &self, messages: &mut Vec, pending_tool_calls: &mut Vec, pending_assistant_text: &mut Option, ) { if !pending_tool_calls.is_empty() || pending_assistant_text.is_some() { messages.push(OpenAIMessage { role: "assistant".to_string(), content: pending_assistant_text.take().map(OpenAIContent::Text), tool_calls: std::mem::take(pending_tool_calls), tool_call_id: None, name: None, }); } } fn convert_tool(&self, tool: &ToolDefinition) -> OpenAITool { OpenAITool { r#type: "function".to_string(), function: OpenAIToolFunction { name: tool.name.clone(), description: tool.description.clone(), parameters: tool.input_schema.clone(), }, } } } #[cfg(test)] mod tests { use super::*; use crate::llm_client::capability::{ CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport, }; fn cap() -> ModelCapability { ModelCapability { tool_calling: ToolCallingSupport::Parallel, structured_output: StructuredOutput::JsonSchema, reasoning: None, vision: false, prompt_caching: CacheStrategy::Auto, } } fn vision_cap() -> ModelCapability { ModelCapability { vision: true, ..cap() } } #[test] fn test_build_simple_request() { let scheme = OpenAIScheme::new(); let request = Request::new().system("System prompt").user("Hello"); let body = scheme.build_request("gpt-4o", &request, &cap()); assert_eq!(body.model, "gpt-4o"); assert_eq!(body.messages.len(), 2); assert_eq!(body.messages[0].role, "system"); assert_eq!(body.messages[1].role, "user"); if let Some(OpenAIContent::Text(text)) = &body.messages[0].content { assert_eq!(text, "System prompt"); } else { panic!("Expected text content"); } } #[test] fn test_build_request_with_tool() { let scheme = OpenAIScheme::new(); let request = Request::new() .user("Check weather") .tool(ToolDefinition::new("weather").description("Get weather")); let body = scheme.build_request("gpt-4o", &request, &cap()); assert_eq!(body.tools.len(), 1); assert_eq!(body.tools[0].function.name, "weather"); } #[test] fn test_build_request_legacy_max_tokens() { let scheme = OpenAIScheme::new().with_legacy_max_tokens(true); let request = Request::new().user("Hello").max_tokens(100); let body = scheme.build_request("llama3", &request, &cap()); assert_eq!(body.max_tokens, Some(100)); assert!(body.max_completion_tokens.is_none()); } #[test] fn test_build_request_modern_max_tokens() { let scheme = OpenAIScheme::new(); let request = Request::new().user("Hello").max_tokens(100); let body = scheme.build_request("gpt-4o", &request, &cap()); assert_eq!(body.max_completion_tokens, Some(100)); assert!(body.max_tokens.is_none()); } #[test] fn reasoning_effort_projected_when_supported() { let scheme = OpenAIScheme::new(); let mut request = Request::new().user("Hello"); request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Other( "provider-native".into(), ))); let capability = ModelCapability { reasoning: Some(ReasoningSupport::Effort), ..cap() }; let body = scheme.build_request("gpt-5", &request, &capability); assert_eq!(body.reasoning_effort.as_deref(), Some("provider-native")); } #[test] fn budget_reasoning_not_projected_to_openai_chat() { let scheme = OpenAIScheme::new(); let mut request = Request::new().user("Hello"); request.config.reasoning = Some(ReasoningControl::BudgetTokens(4096)); let capability = ModelCapability { reasoning: Some(ReasoningSupport::Both), ..cap() }; let body = scheme.build_request("gpt-5", &request, &capability); assert!(body.reasoning_effort.is_none()); } #[test] fn test_tool_call_and_result() { let scheme = OpenAIScheme::new(); let request = Request::new() .user("Check weather") .item(Item::tool_call( "call_123", "get_weather", r#"{"city":"Tokyo"}"#, )) .item(Item::tool_result("call_123", "Sunny, 25°C")); let body = scheme.build_request("gpt-4o", &request, &cap()); assert_eq!(body.messages.len(), 3); assert_eq!(body.messages[0].role, "user"); assert_eq!(body.messages[1].role, "assistant"); assert_eq!(body.messages[1].tool_calls.len(), 1); assert_eq!(body.messages[2].role, "tool"); } #[test] fn parallel_tool_results_precede_synthetic_image_message() { let scheme = OpenAIScheme::new(); let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]); let request = Request::new() .item(Item::tool_call("call_image", "ViewImage", "{}")) .item(Item::tool_call("call_text", "Read", "{}")) .item(Item::tool_result_item( "call_image", "Attached image", None, false, )) .item(Item::tool_result_item( "call_text", "Read text", None, false, )) .item(Item::user_message_parts(vec![ContentPart::image( "image/png", image, )])); let json = serde_json::to_value( &scheme .build_request("gpt-4o", &request, &vision_cap()) .messages, ) .unwrap(); assert_eq!(json[0]["role"], "assistant"); assert_eq!(json[1]["role"], "tool"); assert_eq!(json[2]["role"], "tool"); assert_eq!(json[3]["role"], "user"); assert_eq!(json[3]["content"][0]["type"], "image_url"); } #[test] fn tool_image_is_structured_as_following_user_content_without_persisting_bytes() { let scheme = OpenAIScheme::new(); let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]); let attachment = crate::tool::Attachment::Image(crate::tool::ImageAttachment::new( "image/png", image.clone(), )); let item = Item::tool_result_item_with_attachments( "call_image", "Attached image", None, false, vec![attachment], ); let persisted = serde_json::to_string(&item).unwrap(); assert!(!persisted.contains("base64")); assert!(!persisted.contains("attachments")); let request = Request::new() .item(Item::tool_call( "call_image", "ViewImage", r#"{"path":"a.png"}"#, )) .item(item) .item(Item::user_message_parts(vec![ContentPart::image( "image/png", image, )])); let body = scheme.build_request("gpt-4o", &request, &vision_cap()); let json = serde_json::to_value(&body.messages).unwrap(); assert_eq!(json[0]["role"], "assistant"); assert_eq!(json[1]["role"], "tool"); assert_eq!(json[2]["role"], "user"); assert_eq!(json[2]["content"][0]["type"], "image_url"); assert!( json[2]["content"][0]["image_url"]["url"] .as_str() .unwrap() .starts_with("data:image/png;base64,") ); let mut no_vision = cap(); no_vision.vision = false; let disabled = serde_json::to_string(&scheme.build_request("gpt-4o", &request, &no_vision)).unwrap(); assert!(!disabled.contains("data:image")); } }