From 2e1416276ef70a4799172878a8752f688c62eb42 Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 4 Jun 2026 23:02:23 +1000 Subject: [PATCH 1/2] Fold input system/developer messages into native provider system param --- src/providers/anthropic/convert.rs | 91 ++++++++++++++++++++++++++++-- src/providers/azure_openai/mod.rs | 6 ++ src/providers/bedrock/convert.rs | 87 ++++++++++++++++++++++++++-- src/providers/vertex/convert.rs | 91 +++++++++++++++++++++++++++--- 4 files changed, 259 insertions(+), 16 deletions(-) diff --git a/src/providers/anthropic/convert.rs b/src/providers/anthropic/convert.rs index 5a2952e5..6d4f4c0e 100644 --- a/src/providers/anthropic/convert.rs +++ b/src/providers/anthropic/convert.rs @@ -411,11 +411,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 +454,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 +491,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 +635,36 @@ 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")) + } +} + +/// Extract the concatenated text from an easy-input message content value. +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. +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") } /// Convert Responses API content items to Anthropic content blocks. @@ -1772,6 +1817,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..6c3c5288 100644 --- a/src/providers/bedrock/convert.rs +++ b/src/providers/bedrock/convert.rs @@ -432,11 +432,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 +473,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_bedrock(&msg.content); + if !text.is_empty() { + system_parts.push(text); + } continue; } }; @@ -497,6 +508,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_bedrock(&msg.content); + if !text.is_empty() { + system_parts.push(text); + } continue; } }; @@ -630,7 +646,36 @@ 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"))]) + } +} + +/// Extract the concatenated text from an easy-input message content value. +fn easy_content_text_bedrock(content: &EasyInputMessageContent) -> String { + match content { + EasyInputMessageContent::Text(text) => text.clone(), + EasyInputMessageContent::Parts(parts) => input_content_text_bedrock(parts), + } +} + +/// Extract the concatenated `input_text` from a list of input content items. +fn input_content_text_bedrock(parts: &[ResponseInputContentItem]) -> String { + parts + .iter() + .filter_map(|part| match part { + ResponseInputContentItem::InputText { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n\n") } /// Convert Responses API content items to Bedrock content blocks. @@ -1729,6 +1774,40 @@ 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::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.") + ); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, "user"); + } } #[cfg(test)] diff --git a/src/providers/vertex/convert.rs b/src/providers/vertex/convert.rs index dcf45ac9..37dfd8ff 100644 --- a/src/providers/vertex/convert.rs +++ b/src/providers/vertex/convert.rs @@ -500,15 +500,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 +546,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_vertex(&msg.content); + if !text.is_empty() { + system_parts.push(text); + } continue; } }; @@ -577,6 +586,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_vertex(&msg.content); + if !text.is_empty() { + system_parts.push(text); + } continue; } }; @@ -706,7 +720,39 @@ 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"))], + }) + } +} + +/// Extract the concatenated text from an easy-input message content value. +fn easy_content_text_vertex(content: &EasyInputMessageContent) -> String { + match content { + EasyInputMessageContent::Text(text) => text.clone(), + EasyInputMessageContent::Parts(parts) => input_content_text_vertex(parts), + } +} + +/// Extract the concatenated `input_text` from a list of input content items. +fn input_content_text_vertex(parts: &[ResponseInputContentItem]) -> String { + parts + .iter() + .filter_map(|part| match part { + ResponseInputContentItem::InputText { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n\n") } /// Convert Responses API content items to Vertex parts. @@ -1212,6 +1258,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![ From a444f7c464f0610f7ff05323927dd137f35534ef Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Sat, 6 Jun 2026 20:27:44 +1000 Subject: [PATCH 2/2] Review fixes --- src/providers/anthropic/convert.rs | 25 ++++----------------- src/providers/bedrock/convert.rs | 36 ++++++++++-------------------- src/providers/convert_utils.rs | 28 +++++++++++++++++++++++ src/providers/mod.rs | 1 + src/providers/vertex/convert.rs | 29 +++++------------------- 5 files changed, 51 insertions(+), 68 deletions(-) create mode 100644 src/providers/convert_utils.rs diff --git a/src/providers/anthropic/convert.rs b/src/providers/anthropic/convert.rs index 6d4f4c0e..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, }; @@ -647,26 +650,6 @@ fn join_system_parts(parts: Vec) -> Option { } } -/// Extract the concatenated text from an easy-input message content value. -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. -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") -} - /// Convert Responses API content items to Anthropic content blocks. pub fn convert_responses_content_to_blocks( items: &[ResponseInputContentItem], diff --git a/src/providers/bedrock/convert.rs b/src/providers/bedrock/convert.rs index 6c3c5288..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, }; @@ -474,7 +477,7 @@ pub(super) fn convert_responses_input_to_bedrock_messages( EasyInputMessageRole::Assistant => "assistant", EasyInputMessageRole::System | EasyInputMessageRole::Developer => { // Fold system/developer input messages into the system blocks. - let text = easy_content_text_bedrock(&msg.content); + let text = easy_content_text(&msg.content); if !text.is_empty() { system_parts.push(text); } @@ -509,7 +512,7 @@ pub(super) fn convert_responses_input_to_bedrock_messages( InputMessageItemRole::User => "user", InputMessageItemRole::System | InputMessageItemRole::Developer => { // Fold system/developer input messages into the system blocks. - let text = input_content_text_bedrock(&msg.content); + let text = input_content_text(&msg.content); if !text.is_empty() { system_parts.push(text); } @@ -658,26 +661,6 @@ fn join_system_parts_bedrock(parts: Vec) -> Option String { - match content { - EasyInputMessageContent::Text(text) => text.clone(), - EasyInputMessageContent::Parts(parts) => input_content_text_bedrock(parts), - } -} - -/// Extract the concatenated `input_text` from a list of input content items. -fn input_content_text_bedrock(parts: &[ResponseInputContentItem]) -> String { - parts - .iter() - .filter_map(|part| match part { - ResponseInputContentItem::InputText { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n\n") -} - /// Convert Responses API content items to Bedrock content blocks. /// /// When content items have `cache_control`, a separate `cachePoint` block is inserted @@ -1787,6 +1770,11 @@ mod tool_result_status_tests { 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, @@ -1803,7 +1791,7 @@ mod tool_result_status_tests { assert_eq!(system.len(), 1); assert_eq!( system[0].text.as_deref(), - Some("You are a helpful assistant.\n\nBe concise.") + Some("You are a helpful assistant.\n\nBe concise.\n\nUse markdown.") ); assert_eq!(messages.len(), 1); assert_eq!(messages[0].role, "user"); 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 37dfd8ff..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, }; @@ -547,7 +550,7 @@ pub(super) fn convert_responses_input_to_vertex( EasyInputMessageRole::Assistant => "model", EasyInputMessageRole::System | EasyInputMessageRole::Developer => { // Fold system/developer input messages into the system instruction. - let text = easy_content_text_vertex(&msg.content); + let text = easy_content_text(&msg.content); if !text.is_empty() { system_parts.push(text); } @@ -587,7 +590,7 @@ pub(super) fn convert_responses_input_to_vertex( InputMessageItemRole::User => "user", InputMessageItemRole::System | InputMessageItemRole::Developer => { // Fold system/developer input messages into the system instruction. - let text = input_content_text_vertex(&msg.content); + let text = input_content_text(&msg.content); if !text.is_empty() { system_parts.push(text); } @@ -735,26 +738,6 @@ fn join_system_parts_vertex(parts: Vec) -> Option { } } -/// Extract the concatenated text from an easy-input message content value. -fn easy_content_text_vertex(content: &EasyInputMessageContent) -> String { - match content { - EasyInputMessageContent::Text(text) => text.clone(), - EasyInputMessageContent::Parts(parts) => input_content_text_vertex(parts), - } -} - -/// Extract the concatenated `input_text` from a list of input content items. -fn input_content_text_vertex(parts: &[ResponseInputContentItem]) -> String { - parts - .iter() - .filter_map(|part| match part { - ResponseInputContentItem::InputText { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n\n") -} - /// Convert Responses API content items to Vertex parts. pub(super) fn convert_responses_content_to_vertex_parts( items: &[ResponseInputContentItem],