Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/protocols/src/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1531,12 +1531,25 @@ fn default_reasoning_effort() -> Option<ReasoningEffort> {
#[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 {
Expand Down
10 changes: 10 additions & 0 deletions crates/protocols/tests/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }`.
Expand Down
53 changes: 38 additions & 15 deletions model_gateway/src/routers/grpc/harmony/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<ReasoningEffort>, 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
Expand Down Expand Up @@ -535,6 +536,7 @@ impl HarmonyBuilder {
request: &ResponsesRequest,
) -> Result<Vec<HarmonyMessage>, 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() {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions model_gateway/src/routers/grpc/regular/responses/conversions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,11 @@ pub(crate) fn responses_to_chat(req: &ResponsesRequest) -> Result<ChatCompletion
None
},
parallel_tool_calls: req.parallel_tool_calls,
reasoning_effort: req
.reasoning
.as_ref()
.and_then(|reasoning| reasoning.effort.as_ref())
.map(|effort| effort.as_str().to_string()),
top_logprobs: req.top_logprobs,
top_p: req.top_p,
skip_special_tokens: true,
Expand Down Expand Up @@ -446,6 +451,21 @@ mod tests {
assert_eq!(chat_req.temperature, Some(0.7));
}

#[test]
fn test_reasoning_effort_none_conversion() {
let req = ResponsesRequest {
input: ResponseInput::Text("Answer briefly".to_string()),
reasoning: Some(openai_protocol::responses::ResponseReasoningParam {
effort: Some(openai_protocol::responses::ReasoningEffort::None),
summary: None,
}),
..Default::default()
};

let chat_req = responses_to_chat(&req).unwrap();
assert_eq!(chat_req.reasoning_effort.as_deref(), Some("none"));
}

#[test]
fn test_items_input_conversion() {
let req = ResponsesRequest {
Expand Down
20 changes: 18 additions & 2 deletions model_gateway/src/routers/openai/responses/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,8 @@ mod tests {
use openai_protocol::{
common::Detail,
responses::{
Annotation, FileDetail, ResponseContentPart, ResponseInput, ResponseInputOutputItem,
ResponsesRequest,
Annotation, FileDetail, ReasoningEffort, ResponseContentPart, ResponseInput,
ResponseInputOutputItem, ResponseReasoningParam, ResponsesRequest,
},
};
use serde_json::{json, to_value};
Expand Down Expand Up @@ -305,6 +305,22 @@ mod tests {
);
}

#[test]
fn router_serialization_preserves_reasoning_effort_none() {
let req = ResponsesRequest {
model: "gpt-5.4".to_string(),
input: ResponseInput::Text("Answer briefly".to_string()),
reasoning: Some(ResponseReasoningParam {
effort: Some(ReasoningEffort::None),
summary: None,
}),
..Default::default()
};

let payload = serialize_like_router(&req);
assert_eq!(payload["reasoning"]["effort"], json!("none"));
}

#[test]
fn router_serialization_omits_empty_input_image_fields() {
// `file_id` / `image_url` / `detail` are all optional; the wire
Expand Down
31 changes: 31 additions & 0 deletions model_gateway/tests/api/api_endpoints_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,37 @@ mod responses_endpoint_tests {
ctx.shutdown().await;
}

#[tokio::test]
async fn test_v1_responses_accepts_reasoning_effort_none() {
let ctx = AppTestContext::new(vec![MockWorkerConfig {
port: 18959,
worker_type: WorkerType::Regular,
health_status: HealthStatus::Healthy,
response_delay_ms: 0,
fail_rate: 0.0,
}])
.await;

let app = ctx.create_app();
let payload = json!({
"input": "Answer briefly",
"model": "mock-model",
"reasoning": {"effort": "none"},
"stream": false
});
let req = Request::builder()
.method("POST")
.uri("/v1/responses")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_string(&payload).unwrap()))
.unwrap();

let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);

ctx.shutdown().await;
}

#[tokio::test]
async fn test_v1_responses_streaming() {
let ctx = AppTestContext::new(vec![MockWorkerConfig {
Expand Down
Loading