Skip to content
Merged
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
75 changes: 74 additions & 1 deletion crates/protocols/src/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1528,13 +1528,47 @@ fn default_reasoning_effort() -> Option<ReasoningEffort> {
Some(ReasoningEffort::Medium)
}

#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
/// OpenAI `reasoning.effort` tiers, lowest to highest. `none` turns reasoning
/// off; it is distinct from an absent field, which defaults to `medium`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
Xhigh,
Max,
}

impl ReasoningEffort {
/// The wire value, which is also the Chat `reasoning_effort` string.
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Xhigh => "xhigh",
Self::Max => "max",
}
}

/// Parse a wire value; `None` for anything outside the OpenAI tiers.
pub fn parse(value: &str) -> Option<Self> {
match value {
"none" => Some(Self::None),
"minimal" => Some(Self::Minimal),
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::Xhigh),
"max" => Some(Self::Max),
_ => None,
}
}
}

#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
Expand Down Expand Up @@ -3975,4 +4009,43 @@ mod tests {
assert_eq!(tool.as_str(), serde_tag);
}
}

const REASONING_EFFORT_TIERS: [ReasoningEffort; 7] = [
ReasoningEffort::None,
ReasoningEffort::Minimal,
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Xhigh,
ReasoningEffort::Max,
];

/// `as_str`/`parse` must agree with the serde tag: the Chat pipeline
/// receives the string form, and drift would change a tier in transit.
#[test]
fn reasoning_effort_str_forms_match_serde_tag() {
for effort in REASONING_EFFORT_TIERS {
let tag = serde_json::to_value(effort).unwrap();
assert_eq!(tag, Value::String(effort.as_str().to_string()));
assert_eq!(ReasoningEffort::parse(effort.as_str()), Some(effort));
assert_eq!(
serde_json::from_value::<ReasoningEffort>(tag).unwrap(),
effort
);
}
assert_eq!(ReasoningEffort::parse("bogus"), None);
}

/// Every OpenAI tier deserializes inside `reasoning`; an absent effort
/// still defaults to medium.
#[test]
fn reasoning_param_accepts_every_openai_tier() {
for tier in ["none", "minimal", "low", "medium", "high", "xhigh", "max"] {
let param: ResponseReasoningParam =
serde_json::from_value(serde_json::json!({ "effort": tier })).unwrap();
assert_eq!(param.effort.map(ReasoningEffort::as_str), Some(tier));
}
let param: ResponseReasoningParam = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(param.effort, Some(ReasoningEffort::Medium));
}
}
68 changes: 53 additions & 15 deletions model_gateway/src/routers/grpc/harmony/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,26 @@ fn has_custom_tools(tool_types: &[&str]) -> bool {
/// registration).
pub(crate) struct HarmonyBuilder;

/// Harmony knows only low/medium/high; the outer OpenAI tiers clamp inward.
fn harmony_reasoning_effort(effort: ResponsesReasoningEffort) -> ReasoningEffort {
match effort {
ResponsesReasoningEffort::None
| ResponsesReasoningEffort::Minimal
| ResponsesReasoningEffort::Low => ReasoningEffort::Low,
ResponsesReasoningEffort::Medium => ReasoningEffort::Medium,
ResponsesReasoningEffort::High
| ResponsesReasoningEffort::Xhigh
| ResponsesReasoningEffort::Max => ReasoningEffort::High,
}
}

/// Chat carries the tier as a free-form string; unknown values keep the
/// medium default.
fn harmony_reasoning_effort_from_str(effort: &str) -> ReasoningEffort {
ResponsesReasoningEffort::parse(effort)
.map_or(ReasoningEffort::Medium, harmony_reasoning_effort)
Comment thread
slin1237 marked this conversation as resolved.
}

impl HarmonyBuilder {
/// Create a new Harmony builder
pub fn new() -> Self {
Expand Down Expand Up @@ -444,14 +464,7 @@ impl HarmonyBuilder {
let reasoning_effort = request
.reasoning_effort
.as_deref()
.map(|effort| match effort {
"high" => ReasoningEffort::High,
"medium" => ReasoningEffort::Medium,
"low" => ReasoningEffort::Low,
// Harmony does not support minimal reasoning effort
"minimal" => ReasoningEffort::Low,
_ => ReasoningEffort::Medium,
});
.map(harmony_reasoning_effort_from_str);

let has_tools = request.tools.is_some();
self.build_system_message(reasoning_effort, has_tools)
Expand All @@ -470,13 +483,8 @@ impl HarmonyBuilder {
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,
ResponsesReasoningEffort::Minimal => ReasoningEffort::Low,
});
.and_then(|r| r.effort)
.map(harmony_reasoning_effort);

self.build_system_message(reasoning_effort, with_custom_tools)
}
Expand Down Expand Up @@ -1445,4 +1453,34 @@ mod tests {
Decoded prompt:\n{decoded}",
);
}

#[test]
fn harmony_effort_clamps_outer_openai_tiers_inward() {
use ResponsesReasoningEffort as Tier;

for (tier, expected) in [
(Tier::None, ReasoningEffort::Low),
(Tier::Minimal, ReasoningEffort::Low),
(Tier::Low, ReasoningEffort::Low),
(Tier::Medium, ReasoningEffort::Medium),
(Tier::High, ReasoningEffort::High),
(Tier::Xhigh, ReasoningEffort::High),
(Tier::Max, ReasoningEffort::High),
] {
assert_eq!(harmony_reasoning_effort(tier), expected, "{tier:?}");
assert_eq!(
harmony_reasoning_effort_from_str(tier.as_str()),
expected,
"{tier:?}"
);
}
}

#[test]
fn harmony_effort_from_unknown_string_stays_medium() {
assert_eq!(
harmony_reasoning_effort_from_str("bogus"),
ReasoningEffort::Medium
);
}
}
49 changes: 31 additions & 18 deletions model_gateway/src/routers/grpc/regular/responses/conversions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ use openai_protocol::{
chat::{ChatCompletionRequest, ChatCompletionResponse, ChatMessage, MessageContent},
common::{FunctionCallResponse, JsonSchemaFormat, ResponseFormat, ToolCall, UsageInfo},
responses::{
ReasoningEffort, ResponseContentPart, ResponseInput, ResponseInputOutputItem,
ResponseOutputItem, ResponseReasoningContent::ReasoningText, ResponseStatus,
ResponsesRequest, ResponsesResponse, ResponsesUsage, StringOrContentParts, TextConfig,
TextFormat,
ResponseContentPart, ResponseInput, ResponseInputOutputItem, ResponseOutputItem,
ResponseReasoningContent::ReasoningText, ResponseStatus, ResponsesRequest,
ResponsesResponse, ResponsesUsage, StringOrContentParts, TextConfig, TextFormat,
},
UNKNOWN_MODEL_ID,
};
Expand Down Expand Up @@ -250,24 +249,12 @@ pub(crate) fn responses_to_chat(req: &ResponsesRequest) -> Result<ChatCompletion
reasoning_effort: req
.reasoning
.as_ref()
.and_then(|r| r.effort.as_ref())
.map(reasoning_effort_to_str)
.map(str::to_string),
.and_then(|r| r.effort)
.map(|effort| effort.as_str().to_string()),
..Default::default()
})
}

/// Map the Responses `reasoning.effort` enum to the Chat `reasoning_effort`
/// string (verbatim snake_case, as the Chat pipeline expects).
fn reasoning_effort_to_str(effort: &ReasoningEffort) -> &'static str {
match effort {
ReasoningEffort::Minimal => "minimal",
ReasoningEffort::Low => "low",
ReasoningEffort::Medium => "medium",
ReasoningEffort::High => "high",
}
}

/// Extract text content from ResponseContentPart array. `Refusal` is
/// losslessly representable as text and is preserved verbatim. Image / file
/// parts are currently dropped; the gRPC regular path is text-only and
Expand Down Expand Up @@ -447,6 +434,7 @@ mod tests {
use openai_protocol::{
chat::{ChatChoice, ChatCompletionMessage},
common::{StreamOptions, Usage},
responses::ReasoningEffort,
};

use super::*;
Expand Down Expand Up @@ -531,6 +519,31 @@ mod tests {
assert_eq!(chat_req.reasoning_effort.as_deref(), Some("high"));
}

/// The outer OpenAI tiers reach the Chat pipeline verbatim, where `none`
/// already means thinking off.
#[test]
fn test_reasoning_effort_outer_tiers_flow_through() {
use openai_protocol::responses::ResponseReasoningParam;

for effort in [
ReasoningEffort::None,
ReasoningEffort::Xhigh,
ReasoningEffort::Max,
] {
let req = ResponsesRequest {
input: ResponseInput::Text("hi".to_string()),
reasoning: Some(ResponseReasoningParam {
effort: Some(effort),
summary: None,
}),
..Default::default()
};

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

#[test]
fn test_reasoning_effort_absent_when_reasoning_none() {
let req = ResponsesRequest {
Expand Down
15 changes: 15 additions & 0 deletions model_gateway/tests/api/responses_api_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1211,6 +1211,21 @@ fn test_reasoning_param_default() {
assert!(matches!(parsed.effort, Some(ReasoningEffort::Medium)));
}

/// Every OpenAI `reasoning.effort` tier deserializes at the request boundary.
#[test]
fn test_reasoning_effort_accepts_every_openai_tier() {
for tier in ["none", "minimal", "low", "medium", "high", "xhigh", "max"] {
let request: ResponsesRequest = serde_json::from_value(serde_json::json!({
"model": "gpt-5",
"input": "hi",
"reasoning": { "effort": tier }
}))
.unwrap();
let effort = request.reasoning.and_then(|r| r.effort);
assert_eq!(effort.map(ReasoningEffort::as_str), Some(tier));
}
}

#[test]
fn test_json_serialization() {
let request = ResponsesRequest {
Expand Down
Loading