//! Harmony request builder //! //! Handles encoding of Chat/Responses requests into Harmony format using openai-harmony library. use std::sync::OnceLock; use chrono::Local; use openai_harmony::{ chat::{ Author, ChannelConfig, Content, Conversation, DeveloperContent, Message as HarmonyMessage, ReasoningEffort, Role, SystemContent, TextContent, ToolDescription, }, HarmonyEncoding, HarmonyEncodingName, }; use tracing::debug; use super::types::HarmonyBuildOutput; use crate::protocols::{ chat::{ChatCompletionRequest, ChatMessage, UserMessageContent}, common::{ContentPart, Tool}, responses::{ ReasoningEffort as ResponsesReasoningEffort, ResponseContentPart, ResponseInput, ResponseInputOutputItem, ResponseReasoningContent, ResponseTool, ResponseToolType, ResponsesRequest, StringOrContentParts, }, }; /// Global Harmony encoding (lazy-initialized) static HARMONY_ENCODING: OnceLock = OnceLock::new(); /// Get or initialize the Harmony encoding /// /// Uses HarmonyGptOss encoding which supports the gpt-oss model family. pub(super) fn get_harmony_encoding() -> &'static HarmonyEncoding { HARMONY_ENCODING.get_or_init(|| { openai_harmony::load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss) .expect("Failed to load Harmony encoding") }) } /// Built-in tools that are added to the system message const BUILTIN_TOOLS: &[&str] = &["web_search_preview", "code_interpreter", "container"]; /// Trait for tool-like objects that can be converted to Harmony ToolDescription trait ToolLike { /// Check if this is a built-in tool (should be skipped in developer message) #[allow(dead_code)] fn is_builtin(&self) -> bool; /// Check if this is a custom tool (function or MCP) fn is_custom(&self) -> bool; /// Convert to ToolDescription fn to_tool_description(&self) -> Option; } /// Implement ToolLike for Chat Completion Tool impl ToolLike for Tool { fn is_builtin(&self) -> bool { matches!( self.tool_type.as_str(), "web_search_preview" | "code_interpreter" | "container" ) } fn is_custom(&self) -> bool { matches!(self.tool_type.as_str(), "mcp" | "function") } fn to_tool_description(&self) -> Option { Some(ToolDescription::new( self.function.name.clone(), self.function.description.clone().unwrap_or_default(), Some(self.function.parameters.clone()), )) } } /// Implement ToolLike for Responses API Tool impl ToolLike for ResponseTool { fn is_builtin(&self) -> bool { matches!( self.r#type, ResponseToolType::WebSearchPreview | ResponseToolType::CodeInterpreter ) } fn is_custom(&self) -> bool { matches!( self.r#type, ResponseToolType::Mcp | ResponseToolType::Function ) } fn to_tool_description(&self) -> Option { self.function.as_ref().map(|func| { ToolDescription::new( func.name.clone(), func.description.clone().unwrap_or_default(), Some(func.parameters.clone()), ) }) } } fn has_custom_tools(tool_types: &[&str]) -> bool { !tool_types.iter().all(|t| BUILTIN_TOOLS.contains(t)) } /// Harmony request builder /// /// Converts OpenAI-format requests into Harmony-encoded format with input_ids, /// stop tokens, and selection text for worker routing. pub struct HarmonyBuilder { encoding: &'static HarmonyEncoding, } impl HarmonyBuilder { /// Create a new Harmony builder pub fn new() -> Self { Self { encoding: get_harmony_encoding(), } } /// Build Harmony request from Chat Completion request /// /// # Arguments /// /// * `request` - The ChatCompletionRequest to encode /// /// # Returns /// /// HarmonyBuildOutput containing input_ids, stop_token_ids, selection_text, and messages pub fn build_from_chat( &self, request: &ChatCompletionRequest, ) -> Result { let mut all_messages = Vec::new(); let sys_msg = self.build_system_message_from_chat(request); all_messages.push(sys_msg); let dev_msg = self.build_developer_message_from_chat(request.tools.as_ref()); all_messages.push(dev_msg); let mut user_messages = self.convert_chat_messages(&request.messages)?; all_messages.append(&mut user_messages); let conversation = Conversation::from_messages(all_messages.clone()); let token_ids = self .encoding .render_conversation_for_completion(&conversation, Role::Assistant, None) .map_err(|e| format!("Failed to encode Harmony conversation: {}", e))?; let selection_text = self.extract_selection_text(&all_messages); // Get stop tokens for Harmony assistant actions (<|return|> and <|call|>) let stop_token_ids: Vec = self .encoding .stop_tokens_for_assistant_actions() .into_iter() .flat_map(|set| set.into_iter()) .collect(); Ok(HarmonyBuildOutput { input_ids: token_ids, stop_token_ids, selection_text, harmony_messages: all_messages .into_iter() .map(super::types::HarmonyMessage::from_openai_harmony) .collect(), }) } /// Build Harmony request from Responses request /// /// # Arguments /// /// * `request` - The ResponsesRequest to encode /// /// # Returns /// /// HarmonyBuildOutput containing input_ids, stop_token_ids, selection_text, and messages pub fn build_from_responses( &self, request: &ResponsesRequest, ) -> Result { let all_messages = self.construct_input_messages_with_harmony(request)?; let conversation = Conversation::from_messages(all_messages.clone()); let token_ids = self .encoding .render_conversation_for_completion(&conversation, Role::Assistant, None) .map_err(|e| format!("Failed to encode Harmony conversation: {}", e))?; let selection_text = self.extract_selection_text(&all_messages); // Get stop tokens for Harmony assistant actions (<|return|> and <|call|>) let stop_token_ids: Vec = self .encoding .stop_tokens_for_assistant_actions() .into_iter() .flat_map(|set| set.into_iter()) .collect(); // Decode tokens to see what the model actually receives let decoded_text = self .encoding .tokenizer() .decode_utf8(&token_ids) .unwrap_or_else(|_| "".to_string()); debug!( token_count = token_ids.len(), token_preview = ?&token_ids[..token_ids.len().min(20)], decoded_length = decoded_text.len(), "Encoded conversation to tokens - decoded text follows:" ); debug!("DECODED_TEXT_START\n{}\nDECODED_TEXT_END", decoded_text); Ok(HarmonyBuildOutput { input_ids: token_ids, stop_token_ids, selection_text, harmony_messages: all_messages .into_iter() .map(super::types::HarmonyMessage::from_openai_harmony) .collect(), }) } /// Build system message from ChatCompletionRequest /// Build system message with common logic /// /// # Arguments /// * `reasoning_effort` - Optional reasoning effort level /// * `has_tools` - Whether custom tools are present fn build_system_message( &self, reasoning_effort: Option, has_tools: bool, ) -> HarmonyMessage { let mut sys_content = SystemContent::new(); // Add reasoning_effort if provided if let Some(effort) = reasoning_effort { sys_content = sys_content.with_reasoning_effort(effort); } // Set conversation start date (always current date) sys_content = sys_content.with_conversation_start_date(Local::now().format("%Y-%m-%d").to_string()); // If no tools, remove "commentary" from valid channels if !has_tools { if let Some(channel_config) = &sys_content.channel_config { let valid_channels: Vec = channel_config .valid_channels .iter() .filter(|c| c.as_str() != "commentary") .cloned() .collect(); sys_content = sys_content .with_channel_config(ChannelConfig::require_channels(valid_channels)); } } HarmonyMessage::from_role_and_content(Role::System, sys_content) } fn build_system_message_from_chat(&self, request: &ChatCompletionRequest) -> HarmonyMessage { let reasoning_effort = request .reasoning_effort .as_deref() .map(|effort| match effort { "high" => ReasoningEffort::High, "medium" => ReasoningEffort::Medium, "low" => ReasoningEffort::Low, _ => ReasoningEffort::Medium, }); let has_tools = request.tools.is_some(); self.build_system_message(reasoning_effort, has_tools) } /// Build system message from ResponsesRequest /// /// # Arguments /// * `request` - The ResponsesRequest /// * `with_custom_tools` - Whether custom tools (beyond built-ins) are present fn build_system_message_from_responses( &self, request: &ResponsesRequest, with_custom_tools: bool, ) -> HarmonyMessage { let reasoning_effort = request .reasoning .as_ref() .and_then(|r| r.effort.as_ref()) .map(|effort| match effort { ResponsesReasoningEffort::High => ReasoningEffort::High, ResponsesReasoningEffort::Medium => ReasoningEffort::Medium, ResponsesReasoningEffort::Low => ReasoningEffort::Low, }); self.build_system_message(reasoning_effort, with_custom_tools) } /// Build developer message with common logic /// /// Filters out built-in tools and converts custom tools to ToolDescription /// /// # Arguments /// * `tools` - Optional list of tools /// * `instructions` - Optional instructions (Responses API only) fn build_developer_message( &self, tools: Option<&Vec>, instructions: Option<&str>, ) -> HarmonyMessage { let mut dev_content = DeveloperContent::new(); // Add instructions if provided (Responses API only) if let Some(instructions) = instructions { dev_content = dev_content.with_instructions(instructions.to_string()); } // Early return if no tools let Some(tools) = tools else { return HarmonyMessage::from_role_and_content(Role::Developer, dev_content); }; // Filter to custom tools and convert to ToolDescription let tool_descriptions: Vec = tools .iter() .filter(|t| t.is_custom()) .filter_map(|t| t.to_tool_description()) .collect(); // Add function tools to developer content if !tool_descriptions.is_empty() { dev_content = dev_content.with_function_tools(tool_descriptions); } HarmonyMessage::from_role_and_content(Role::Developer, dev_content) } fn build_developer_message_from_chat(&self, tools: Option<&Vec>) -> HarmonyMessage { self.build_developer_message(tools, None) } /// Build developer message from Responses request /// /// # Arguments /// * `instructions` - Optional instructions (Responses API specific) /// * `tools` - Optional list of tools fn build_developer_message_from_responses( &self, instructions: Option<&str>, tools: Option<&Vec>, ) -> HarmonyMessage { self.build_developer_message(tools, instructions) } /// Construct input messages for Responses API with Harmony /// /// Handles both new conversations and continuations of previous responses. /// /// This handles: /// - New conversation: system message, developer message, and user input /// - Continuing conversation: loads previous messages, cleans up chain-of-thoughts /// - MCP tool allowlisting for special tool types /// - Complex response input parsing with function call tracking /// /// # Arguments /// * `request` - The ResponsesRequest /// * `prev_response` - Optional previous response to continue from fn construct_input_messages_with_harmony( &self, request: &ResponsesRequest, ) -> Result, String> { let mut all_messages = Vec::new(); // Handle new vs continuing conversation if request.previous_response_id.is_none() { // New conversation let tool_types: Vec<&str> = request .tools .as_ref() .map(|tools| { tools .iter() .map(|tool| match tool.r#type { ResponseToolType::Function => "function", ResponseToolType::WebSearchPreview => "web_search_preview", ResponseToolType::CodeInterpreter => "code_interpreter", ResponseToolType::Mcp => "mcp", }) .collect() }) .unwrap_or_default(); let with_custom_tools = has_custom_tools(&tool_types); // Add system message let sys_msg = self.build_system_message_from_responses(request, with_custom_tools); all_messages.push(sys_msg); // Add developer message only if we have custom tools if with_custom_tools { let dev_msg = self.build_developer_message_from_responses( request.instructions.as_deref(), request.tools.as_ref(), ); all_messages.push(dev_msg); } } else { // Continue the previous conversation // NOTE: Previous messages are loaded by serve_harmony_responses() before calling this method. // The request.input will already contain the conversation history when previous_response_id was set. // We just proceed with parsing the input items as normal. debug!("Continuing conversation (history already loaded in request.input)"); } // Append the new input // Responses API supports simple text inputs without chat format match &request.input { ResponseInput::Text(text) => { let user_msg = HarmonyMessage { author: Author { role: Role::User, name: None, }, recipient: None, content: vec![Content::Text(TextContent { text: text.clone() })], channel: None, content_type: None, }; all_messages.push(user_msg); } ResponseInput::Items(items) => { // Track function calls for looking up call_id → name mapping let mut prev_outputs: Vec<&ResponseInputOutputItem> = Vec::new(); for item in items { let msg = self.parse_response_item_to_harmony_message(item, &prev_outputs)?; all_messages.push(msg); // Track function tool calls so that function_call_output can find the name if matches!(item, ResponseInputOutputItem::FunctionToolCall { .. }) { prev_outputs.push(item); } } } } debug!( message_count = all_messages.len(), "Constructed Harmony messages for Responses API" ); Ok(all_messages) } /// Parse a ResponseInputOutputItem into a HarmonyMessage /// /// Handles conversion of various response item types (messages, function calls, reasoning, etc.) /// to Harmony message format. /// /// # Arguments /// * `item` - The ResponseInputOutputItem to parse /// * `prev_outputs` - Previous items for looking up function call names (for function_call_output) fn parse_response_item_to_harmony_message( &self, item: &ResponseInputOutputItem, prev_outputs: &[&ResponseInputOutputItem], ) -> Result { match item { // Regular message (user or assistant) ResponseInputOutputItem::Message { role, content, .. } => { let harmony_role = match role.as_str() { "user" => Role::User, "assistant" => Role::Assistant, "system" => Role::System, _ => Role::User, // Default to user for unknown roles }; // Extract text from content parts let text_parts: Vec = content .iter() .filter_map(|part| match part { ResponseContentPart::OutputText { text, .. } => Some(text.clone()), ResponseContentPart::InputText { text } => Some(text.clone()), ResponseContentPart::Unknown => None, }) .collect(); let text = text_parts.join("\n"); Ok(HarmonyMessage { author: Author { role: harmony_role, name: None, }, recipient: None, content: vec![Content::Text(TextContent { text })], channel: None, content_type: None, }) } // Reasoning content (chain-of-thought) ResponseInputOutputItem::Reasoning { content, .. } => { // Extract reasoning text let reasoning_texts: Vec = content .iter() .map(|rc| match rc { ResponseReasoningContent::ReasoningText { text } => text.clone(), }) .collect(); let text = reasoning_texts.join("\n"); // Reasoning goes in the "analysis" channel for Harmony Ok(HarmonyMessage { author: Author { role: Role::Assistant, name: None, }, recipient: None, content: vec![Content::Text(TextContent { text })], channel: Some("analysis".to_string()), content_type: None, }) } // Function tool call (with optional output) ResponseInputOutputItem::FunctionToolCall { name, arguments, output, .. } => { // If there's an output, this represents the tool result // Otherwise, it's the tool call itself if let Some(output_str) = output { // Tool result - use Tool role with "functions.{name}" as author name // IMPORTANT: Must include recipient="assistant" for parser to recognize it. // We keep channel=None to minimize what the model might copy. let author_name = format!("functions.{}", name); debug!( tool_name = %name, author_name = %author_name, output_preview = %output_str.chars().take(100).collect::(), "Building tool result message with Tool role (recipient=assistant, no channel)" ); Ok(HarmonyMessage { author: Author { role: Role::Tool, name: Some(author_name), }, recipient: Some("assistant".to_string()), content: vec![Content::Text(TextContent { text: output_str.clone(), })], channel: None, content_type: None, }) } else { // Tool call - assistant message in commentary channel with recipient // msg.with_channel("commentary").with_recipient(f"functions.{name}") let recipient = format!("functions.{}", name); debug!( tool_name = %name, recipient = %recipient, "Building tool call message with recipient" ); Ok(HarmonyMessage { author: Author { role: Role::Assistant, name: None, }, recipient: Some(recipient), content: vec![Content::Text(TextContent { text: arguments.clone(), })], channel: Some("commentary".to_string()), content_type: Some("json".to_string()), }) } } // Function call output (separate from call) - requires looking up the original call ResponseInputOutputItem::FunctionCallOutput { call_id, output, .. } => { // Search prev_outputs in reverse order to find the matching function call let call = prev_outputs .iter() .rev() .find_map(|item| match item { ResponseInputOutputItem::FunctionToolCall { id, name, .. } if id == call_id => { Some(name.clone()) } _ => None, }) .ok_or_else(|| format!("No function call found for call_id: {}", call_id))?; // Create Tool message with "functions.{name}" prefix // IMPORTANT: Must include recipient="assistant" for parser to recognize it. // We keep channel=None to minimize what the model might copy. Ok(HarmonyMessage { author: Author { role: Role::Tool, name: Some(format!("functions.{}", call)), }, recipient: Some("assistant".to_string()), content: vec![Content::Text(TextContent { text: output.clone(), })], channel: None, content_type: None, }) } // Simple input message (usually user message) ResponseInputOutputItem::SimpleInputMessage { content, role, .. } => { let harmony_role = match role.as_str() { "user" => Role::User, "assistant" => Role::Assistant, "system" => Role::System, _ => Role::User, }; let text = match content { StringOrContentParts::String(s) => s.clone(), StringOrContentParts::Array(parts) => { // Extract text from content parts parts .iter() .filter_map(|part| match part { ResponseContentPart::OutputText { text, .. } => Some(text.clone()), ResponseContentPart::InputText { text } => Some(text.clone()), ResponseContentPart::Unknown => None, }) .collect::>() .join("\n") } }; Ok(HarmonyMessage { author: Author { role: harmony_role, name: None, }, recipient: None, content: vec![Content::Text(TextContent { text })], channel: None, content_type: None, }) } } } /// Convert OpenAI ChatMessage format to Harmony messages /// /// - Assistant messages with tool_calls create multiple messages (one per tool call) /// - Tool role messages use Role::Tool with proper author /// - Tool-related messages use channel="commentary" fn convert_chat_messages( &self, messages: &[ChatMessage], ) -> Result, String> { let mut harmony_messages = Vec::new(); // Build a map of tool_call_id -> function_name for tool responses let mut tool_call_map = std::collections::HashMap::new(); for msg in messages { if let ChatMessage::Assistant { tool_calls: Some(calls), .. } = msg { for call in calls { tool_call_map.insert(call.id.clone(), call.function.name.clone()); } } } for msg in messages { match msg { ChatMessage::System { content, name } => { // System messages stay as-is let harmony_msg = HarmonyMessage { author: Author { role: Role::System, name: name.clone(), }, recipient: None, content: vec![Content::Text(TextContent { text: content.clone(), })], channel: None, content_type: None, }; harmony_messages.push(harmony_msg); } ChatMessage::User { content, name } => { // Extract text from user content let text = match content { UserMessageContent::Text(text) => text.clone(), UserMessageContent::Parts(parts) => { // For multimodal content, extract text parts parts .iter() .filter_map(|part| { if let ContentPart::Text { text } = part { Some(text.as_str()) } else { None } }) .collect::>() .join("\n") } }; let harmony_msg = HarmonyMessage { author: Author { role: Role::User, name: name.clone(), }, recipient: None, content: vec![Content::Text(TextContent { text })], channel: None, content_type: None, }; harmony_messages.push(harmony_msg); } ChatMessage::Assistant { content, name, tool_calls, reasoning_content, } => { if let Some(calls) = tool_calls { // Create one message per tool call with channel="commentary" for call in calls { let function_name = &call.function.name; let arguments = call.function.arguments.clone().unwrap_or_default(); let tool_call_msg = HarmonyMessage { author: Author { role: Role::Assistant, name: name.clone(), }, recipient: Some(format!("functions.{}", function_name)), content: vec![Content::Text(TextContent { text: arguments })], channel: Some("commentary".to_string()), content_type: Some("json".to_string()), }; harmony_messages.push(tool_call_msg); } } else { // Regular assistant message with content // Combine content with reasoning if present let mut text = content.clone().unwrap_or_default(); if let Some(reasoning) = reasoning_content { if !text.is_empty() { text.push('\n'); } text.push_str(reasoning); } let harmony_msg = HarmonyMessage { author: Author { role: Role::Assistant, name: name.clone(), }, recipient: None, content: vec![Content::Text(TextContent { text })], channel: Some("final".to_string()), content_type: None, }; harmony_messages.push(harmony_msg); } } ChatMessage::Tool { content, tool_call_id, } => { // Look up the function name from the tool_call_id let function_name = tool_call_map .get(tool_call_id) .cloned() .unwrap_or_else(|| tool_call_id.clone()); // Tool result - Must include recipient="assistant" for parser to recognize it. // We keep channel=None to minimize what the model might copy. let harmony_msg = HarmonyMessage { author: Author { role: Role::Tool, name: Some(format!("functions.{}", function_name)), }, recipient: Some("assistant".to_string()), content: vec![Content::Text(TextContent { text: content.clone(), })], channel: None, content_type: None, }; harmony_messages.push(harmony_msg); } ChatMessage::Function { content, name } => { // Function messages also use Role::Tool // Tool result - Must include recipient="assistant" for parser to recognize it. // We keep channel=None to minimize what the model might copy. let harmony_msg = HarmonyMessage { author: Author { role: Role::Tool, name: Some(format!("functions.{}", name)), }, recipient: Some("assistant".to_string()), content: vec![Content::Text(TextContent { text: content.clone(), })], channel: None, content_type: None, }; harmony_messages.push(harmony_msg); } } } Ok(harmony_messages) } /// Extract selection text for worker routing /// /// Uses the last user message for load balancing fn extract_selection_text(&self, messages: &[HarmonyMessage]) -> String { // Find the last user message if let Some(last_user_msg) = messages.iter().rev().find(|m| m.author.role == Role::User) { // Extract full text from content return last_user_msg .content .iter() .filter_map(|c| match c { Content::Text(tc) => Some(tc.text.as_str()), _ => None, }) .collect::>() .join(""); } // Fallback: concatenate all text messages .iter() .flat_map(|m| &m.content) .filter_map(|c| match c { Content::Text(tc) => Some(tc.text.as_str()), _ => None, }) .collect::>() .join(" ") } } impl Default for HarmonyBuilder { fn default() -> Self { Self::new() } }