diff --git a/crates/protocols/src/responses.rs b/crates/protocols/src/responses.rs index 55463a3b7..b27fe367b 100644 --- a/crates/protocols/src/responses.rs +++ b/crates/protocols/src/responses.rs @@ -1531,12 +1531,25 @@ fn default_reasoning_effort() -> Option { #[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ReasoningEffort { + None, Minimal, Low, Medium, High, } +impl ReasoningEffort { + pub const fn as_str(&self) -> &'static str { + match self { + Self::None => "none", + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ReasoningSummary { diff --git a/crates/protocols/tests/responses.rs b/crates/protocols/tests/responses.rs index c8cc6075c..5d4bf40a0 100644 --- a/crates/protocols/tests/responses.rs +++ b/crates/protocols/tests/responses.rs @@ -10,6 +10,16 @@ use openai_protocol::{ use serde_json::json; use validator::Validate; +#[test] +fn reasoning_effort_none_round_trips() { + let payload = json!({"effort": "none"}); + let reasoning: ResponseReasoningParam = + serde_json::from_value(payload.clone()).expect("reasoning effort none should deserialize"); + + assert!(matches!(reasoning.effort, Some(ReasoningEffort::None))); + assert_eq!(serde_json::to_value(reasoning).expect("serialize"), payload); +} + #[test] fn summary_text_content_round_trips_spec_shape() { // Spec: `summary: array of SummaryTextContent { text, type: "summary_text" }`. diff --git a/model_gateway/src/routers/grpc/harmony/builder.rs b/model_gateway/src/routers/grpc/harmony/builder.rs index f10efeba3..27c1f929e 100644 --- a/model_gateway/src/routers/grpc/harmony/builder.rs +++ b/model_gateway/src/routers/grpc/harmony/builder.rs @@ -415,28 +415,29 @@ impl HarmonyBuilder { self.build_system_message(reasoning_effort, has_tools) } - /// Build system message from ResponsesRequest + /// Convert Responses reasoning effort to Harmony's supported values. /// /// # Arguments /// * `request` - The ResponsesRequest - /// * `with_custom_tools` - Whether custom tools (beyond built-ins) are present - fn build_system_message_from_responses( - &self, + /// + /// Returns an error for `none`, which Harmony cannot represent. + fn reasoning_effort_from_responses( request: &ResponsesRequest, - with_custom_tools: bool, - ) -> HarmonyMessage { - let reasoning_effort = request + ) -> Result, String> { + 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, - ResponsesReasoningEffort::Minimal => ReasoningEffort::Low, - }); - - self.build_system_message(reasoning_effort, with_custom_tools) + ResponsesReasoningEffort::None => { + Err("reasoning.effort 'none' is not supported by Harmony models".to_string()) + } + ResponsesReasoningEffort::High => Ok(ReasoningEffort::High), + ResponsesReasoningEffort::Medium => Ok(ReasoningEffort::Medium), + ResponsesReasoningEffort::Low => Ok(ReasoningEffort::Low), + ResponsesReasoningEffort::Minimal => Ok(ReasoningEffort::Low), + }) + .transpose() } /// Build developer message with common logic @@ -535,6 +536,7 @@ impl HarmonyBuilder { request: &ResponsesRequest, ) -> Result, String> { let mut all_messages = Vec::new(); + let reasoning_effort = Self::reasoning_effort_from_responses(request)?; // Handle new vs continuing conversation if request.previous_response_id.is_none() { @@ -549,7 +551,7 @@ impl HarmonyBuilder { 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); + let sys_msg = self.build_system_message(reasoning_effort, with_custom_tools); all_messages.push(sys_msg); // Add developer message if we have custom tools or instructions @@ -1169,6 +1171,27 @@ mod tests { use super::*; + #[test] + fn responses_reasoning_effort_none_is_rejected_for_all_harmony_requests() { + for previous_response_id in [None, Some("resp_previous".to_string())] { + let request = ResponsesRequest { + input: ResponseInput::Text("Answer briefly".to_string()), + previous_response_id, + reasoning: Some(openai_protocol::responses::ResponseReasoningParam { + effort: Some(openai_protocol::responses::ReasoningEffort::None), + summary: None, + }), + ..Default::default() + }; + + let result = HarmonyBuilder::new().construct_input_messages_with_harmony(&request); + assert_eq!( + result.err().as_deref(), + Some("reasoning.effort 'none' is not supported by Harmony models") + ); + } + } + /// Invariant: `image_generation` must never be advertised as a /// gpt-oss native builtin tool. If a future change re-adds it, /// gpt-oss's behavior becomes undefined (hallucinated tool call diff --git a/model_gateway/src/routers/grpc/regular/responses/conversions.rs b/model_gateway/src/routers/grpc/regular/responses/conversions.rs index 08c92def4..4b8dd0ba5 100644 --- a/model_gateway/src/routers/grpc/regular/responses/conversions.rs +++ b/model_gateway/src/routers/grpc/regular/responses/conversions.rs @@ -240,6 +240,11 @@ pub(crate) fn responses_to_chat(req: &ResponsesRequest) -> Result