diff --git a/src/providers/anthropic/convert.rs b/src/providers/anthropic/convert.rs index 5a2952e5..efcedd9c 100644 --- a/src/providers/anthropic/convert.rs +++ b/src/providers/anthropic/convert.rs @@ -26,7 +26,10 @@ use crate::{ ResponsesUsageInputTokensDetails, ResponsesUsageOutputTokensDetails, }, }, - providers::image::parse_data_url, + providers::{ + convert_utils::{easy_content_text, input_content_text}, + image::parse_data_url, + }, services::FileSearchToolArguments, }; @@ -411,11 +414,18 @@ pub fn convert_responses_input_to_messages( input: Option, instructions: Option, ) -> (Option, Vec) { - let system = instructions; + // Seed the system prompt with the top-level `instructions`, then fold in any + // system/developer messages found in the input. Anthropic has no native + // system role, so input system/developer messages must be merged here or + // they would be silently dropped. + let mut system_parts: Vec = Vec::new(); + if let Some(instructions) = instructions { + system_parts.push(instructions); + } let mut messages: Vec = Vec::new(); let Some(input) = input else { - return (system, messages); + return (join_system_parts(system_parts), messages); }; match input { @@ -447,8 +457,12 @@ pub fn convert_responses_input_to_messages( EasyInputMessageRole::User => "user", EasyInputMessageRole::Assistant => "assistant", EasyInputMessageRole::System | EasyInputMessageRole::Developer => { - // System/developer messages are typically handled via instructions - // but if they appear in input, we skip them (already in system) + // Anthropic has no system role: fold system/developer + // input messages into the system prompt. + let text = easy_content_text(&msg.content); + if !text.is_empty() { + system_parts.push(text); + } continue; } }; @@ -480,6 +494,11 @@ pub fn convert_responses_input_to_messages( let role = match msg.role { InputMessageItemRole::User => "user", InputMessageItemRole::System | InputMessageItemRole::Developer => { + // Fold system/developer input messages into the system prompt. + let text = input_content_text(&msg.content); + if !text.is_empty() { + system_parts.push(text); + } continue; } }; @@ -619,7 +638,16 @@ pub fn convert_responses_input_to_messages( } } - (system, messages) + (join_system_parts(system_parts), messages) +} + +/// Join collected system/developer prompt parts with blank lines, or `None`. +fn join_system_parts(parts: Vec) -> Option { + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } } /// Convert Responses API content items to Anthropic content blocks. @@ -1772,6 +1800,42 @@ mod tests { assert_eq!(messages.len(), 1); } + #[test] + fn test_convert_responses_input_folds_system_messages() { + // System/developer messages in the input must be folded into the system + // prompt (Anthropic has no system role), not silently dropped. + let items = vec![ + ResponsesInputItem::EasyMessage(EasyInputMessage { + type_: None, + role: EasyInputMessageRole::System, + content: EasyInputMessageContent::Text("Be concise.".to_string()), + }), + ResponsesInputItem::EasyMessage(EasyInputMessage { + type_: None, + role: EasyInputMessageRole::Developer, + content: EasyInputMessageContent::Text("Use markdown.".to_string()), + }), + ResponsesInputItem::EasyMessage(EasyInputMessage { + type_: None, + role: EasyInputMessageRole::User, + content: EasyInputMessageContent::Text("Hi".to_string()), + }), + ]; + + let (system, messages) = convert_responses_input_to_messages( + Some(ResponsesInput::Items(items)), + Some("You are a helpful assistant.".to_string()), + ); + + assert_eq!( + system, + Some("You are a helpful assistant.\n\nBe concise.\n\nUse markdown.".to_string()) + ); + // Only the user message survives as a turn. + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, "user"); + } + #[test] fn test_convert_responses_input_easy_messages() { let items = vec![ diff --git a/src/providers/azure_openai/mod.rs b/src/providers/azure_openai/mod.rs index 1365fbd7..05bdaba2 100644 --- a/src/providers/azure_openai/mod.rs +++ b/src/providers/azure_openai/mod.rs @@ -228,6 +228,12 @@ impl Provider for AzureOpenAIProvider { let timeout = self.timeout; let stream = payload.stream; + // Drop gateway-managed fields (store, background, models, provider, + // plugins, sovereignty_requirements, skills) that Hadrian consumes + // itself and the upstream must never see, mirroring the OpenAI adapter. + let mut payload = payload; + payload.strip_gateway_fields(); + // Pre-serialize request body before retry loop to avoid repeated serialization let body = serde_json::to_vec(&payload).unwrap_or_default(); diff --git a/src/providers/bedrock/convert.rs b/src/providers/bedrock/convert.rs index a14ddef8..bbff7be3 100644 --- a/src/providers/bedrock/convert.rs +++ b/src/providers/bedrock/convert.rs @@ -27,7 +27,10 @@ use crate::{ ResponsesUsage, ResponsesUsageInputTokensDetails, ResponsesUsageOutputTokensDetails, }, }, - providers::image::parse_data_url, + providers::{ + convert_utils::{easy_content_text, input_content_text}, + image::parse_data_url, + }, services::FileSearchToolArguments, }; @@ -432,11 +435,18 @@ pub(super) fn convert_responses_input_to_bedrock_messages( input: Option, instructions: Option, ) -> (Option>, Vec) { - let system = instructions.map(|text| vec![BedrockSystemContent::text(text)]); + // Seed the system blocks with the top-level `instructions`, then fold in any + // system/developer messages found in the input. Bedrock Converse has no + // system role inside the message list, so input system/developer messages + // must be merged here or they would be silently dropped. + let mut system_parts: Vec = Vec::new(); + if let Some(instructions) = instructions { + system_parts.push(instructions); + } let mut messages: Vec = Vec::new(); let Some(input) = input else { - return (system, messages); + return (join_system_parts_bedrock(system_parts), messages); }; match input { @@ -466,7 +476,11 @@ pub(super) fn convert_responses_input_to_bedrock_messages( EasyInputMessageRole::User => "user", EasyInputMessageRole::Assistant => "assistant", EasyInputMessageRole::System | EasyInputMessageRole::Developer => { - // System/developer messages are handled via instructions + // Fold system/developer input messages into the system blocks. + let text = easy_content_text(&msg.content); + if !text.is_empty() { + system_parts.push(text); + } continue; } }; @@ -497,6 +511,11 @@ pub(super) fn convert_responses_input_to_bedrock_messages( let role = match msg.role { InputMessageItemRole::User => "user", InputMessageItemRole::System | InputMessageItemRole::Developer => { + // Fold system/developer input messages into the system blocks. + let text = input_content_text(&msg.content); + if !text.is_empty() { + system_parts.push(text); + } continue; } }; @@ -630,7 +649,16 @@ pub(super) fn convert_responses_input_to_bedrock_messages( } } - (system, messages) + (join_system_parts_bedrock(system_parts), messages) +} + +/// Join collected system/developer prompt parts into Bedrock system blocks, or `None`. +fn join_system_parts_bedrock(parts: Vec) -> Option> { + if parts.is_empty() { + None + } else { + Some(vec![BedrockSystemContent::text(parts.join("\n\n"))]) + } } /// Convert Responses API content items to Bedrock content blocks. @@ -1729,6 +1757,45 @@ mod tool_result_status_tests { let tool_result = json.get("toolResult").unwrap(); assert_eq!(tool_result.get("status").unwrap(), "success"); } + + #[test] + fn test_responses_input_folds_system_messages() { + use crate::api_types::responses::EasyInputMessage; + + // System/developer messages in the input must be folded into the system + // blocks (Bedrock Converse has no system role inside the message list). + let items = vec![ + ResponsesInputItem::EasyMessage(EasyInputMessage { + type_: None, + role: EasyInputMessageRole::System, + content: EasyInputMessageContent::Text("Be concise.".to_string()), + }), + ResponsesInputItem::EasyMessage(EasyInputMessage { + type_: None, + role: EasyInputMessageRole::Developer, + content: EasyInputMessageContent::Text("Use markdown.".to_string()), + }), + ResponsesInputItem::EasyMessage(EasyInputMessage { + type_: None, + role: EasyInputMessageRole::User, + content: EasyInputMessageContent::Text("Hi".to_string()), + }), + ]; + + let (system, messages) = convert_responses_input_to_bedrock_messages( + Some(ResponsesInput::Items(items)), + Some("You are a helpful assistant.".to_string()), + ); + + let system = system.expect("system blocks present"); + assert_eq!(system.len(), 1); + assert_eq!( + system[0].text.as_deref(), + Some("You are a helpful assistant.\n\nBe concise.\n\nUse markdown.") + ); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, "user"); + } } #[cfg(test)] diff --git a/src/providers/convert_utils.rs b/src/providers/convert_utils.rs new file mode 100644 index 00000000..0d3f8d56 --- /dev/null +++ b/src/providers/convert_utils.rs @@ -0,0 +1,28 @@ +//! Small text-extraction helpers shared by the provider request converters. +//! +//! Anthropic, Bedrock, and Vertex all need to pull the plain text out of a +//! Responses-API message so it can be folded into their native system prompt. +//! The logic is provider-agnostic (it only touches shared `api_types`), so it +//! lives here rather than being duplicated per provider. + +use crate::api_types::responses::{EasyInputMessageContent, ResponseInputContentItem}; + +/// Extract the concatenated text from an easy-input message content value. +pub(crate) fn easy_content_text(content: &EasyInputMessageContent) -> String { + match content { + EasyInputMessageContent::Text(text) => text.clone(), + EasyInputMessageContent::Parts(parts) => input_content_text(parts), + } +} + +/// Extract the concatenated `input_text` from a list of input content items. +pub(crate) fn input_content_text(parts: &[ResponseInputContentItem]) -> String { + parts + .iter() + .filter_map(|part| match part { + ResponseInputContentItem::InputText { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n\n") +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index e46ef743..b050ad4e 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -44,6 +44,7 @@ pub mod azure_openai; #[cfg(feature = "provider-bedrock")] pub mod bedrock; pub mod circuit_breaker; +pub(crate) mod convert_utils; pub mod error; pub mod fallback; pub mod health_check; diff --git a/src/providers/vertex/convert.rs b/src/providers/vertex/convert.rs index dcf45ac9..7c6911a4 100644 --- a/src/providers/vertex/convert.rs +++ b/src/providers/vertex/convert.rs @@ -28,7 +28,10 @@ use crate::{ ResponsesUsageOutputTokensDetails, }, }, - providers::image::parse_data_url, + providers::{ + convert_utils::{easy_content_text, input_content_text}, + image::parse_data_url, + }, services::FileSearchToolArguments, }; @@ -500,15 +503,20 @@ pub(super) fn convert_responses_input_to_vertex( input: Option, instructions: Option, ) -> (Option, Vec) { - let system_instruction = instructions.map(|text| VertexContent { - role: "user".to_string(), - parts: vec![VertexPart::text(text)], - }); + // Seed the system instruction with the top-level `instructions`, then fold + // in any system/developer messages found in the input. Gemini carries the + // system prompt in a dedicated `systemInstruction` field, so input + // system/developer messages must be merged here or they would be silently + // dropped. + let mut system_parts: Vec = Vec::new(); + if let Some(instructions) = instructions { + system_parts.push(instructions); + } let mut contents: Vec = Vec::new(); let Some(input) = input else { - return (system_instruction, contents); + return (join_system_parts_vertex(system_parts), contents); }; // Track function call IDs to function names for tool results @@ -541,7 +549,11 @@ pub(super) fn convert_responses_input_to_vertex( EasyInputMessageRole::User => "user", EasyInputMessageRole::Assistant => "model", EasyInputMessageRole::System | EasyInputMessageRole::Developer => { - // System/developer messages handled via instructions + // Fold system/developer input messages into the system instruction. + let text = easy_content_text(&msg.content); + if !text.is_empty() { + system_parts.push(text); + } continue; } }; @@ -577,6 +589,11 @@ pub(super) fn convert_responses_input_to_vertex( let role = match msg.role { InputMessageItemRole::User => "user", InputMessageItemRole::System | InputMessageItemRole::Developer => { + // Fold system/developer input messages into the system instruction. + let text = input_content_text(&msg.content); + if !text.is_empty() { + system_parts.push(text); + } continue; } }; @@ -706,7 +723,19 @@ pub(super) fn convert_responses_input_to_vertex( } } - (system_instruction, contents) + (join_system_parts_vertex(system_parts), contents) +} + +/// Join collected system/developer prompt parts into a Vertex system instruction, or `None`. +fn join_system_parts_vertex(parts: Vec) -> Option { + if parts.is_empty() { + None + } else { + Some(VertexContent { + role: "user".to_string(), + parts: vec![VertexPart::text(parts.join("\n\n"))], + }) + } } /// Convert Responses API content items to Vertex parts. @@ -1212,6 +1241,37 @@ mod responses_api_tests { assert_eq!(contents[0].role, "user"); } + #[test] + fn test_convert_responses_input_folds_system_messages() { + // System/developer messages in the input must be folded into the system + // instruction, not silently dropped. + let input = Some(ResponsesInput::Items(vec![ + ResponsesInputItem::EasyMessage(EasyInputMessage { + type_: Some(MessageType::Message), + role: EasyInputMessageRole::System, + content: EasyInputMessageContent::Text("Be concise.".to_string()), + }), + ResponsesInputItem::EasyMessage(EasyInputMessage { + type_: Some(MessageType::Message), + role: EasyInputMessageRole::User, + content: EasyInputMessageContent::Text("Hi".to_string()), + }), + ])); + + let (system, contents) = + convert_responses_input_to_vertex(input, Some("You are helpful.".to_string())); + + let system = system.expect("system instruction present"); + assert_eq!(system.role, "user"); + assert_eq!(system.parts.len(), 1); + assert_eq!( + system.parts[0].text, + Some("You are helpful.\n\nBe concise.".to_string()) + ); + assert_eq!(contents.len(), 1); + assert_eq!(contents[0].role, "user"); + } + #[test] fn test_convert_responses_input_easy_messages() { let input = Some(ResponsesInput::Items(vec![