Skip to content
Draft
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
8 changes: 6 additions & 2 deletions bindings/golang/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use llm_tokenizer::traits::Tokenizer;
use openai_protocol::chat::ChatCompletionRequest;
use smg::routers::grpc::utils::{extract_thinking_from_kwargs, should_mark_reasoning_started};
use smg::routers::grpc::utils::{resolve_user_thinking, should_mark_reasoning_started};
use uuid::Uuid;

/// Helper function to generate tool call ID (matches router implementation)
Expand Down Expand Up @@ -32,7 +32,11 @@ pub(crate) fn chat_requires_reasoning(
tokenizer: &dyn Tokenizer,
) -> bool {
should_mark_reasoning_started(
extract_thinking_from_kwargs(request.chat_template_kwargs.as_ref(), tokenizer),
resolve_user_thinking(
request.chat_template_kwargs.as_ref(),
request.reasoning_effort.as_deref(),
tokenizer,
),
tokenizer,
)
}
38 changes: 38 additions & 0 deletions crates/protocols/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,25 @@ pub struct ChatCompletionRequest {
pub other: Map<String, Value>,
}

/// Map an OpenAI `reasoning_effort` to a thinking on/off preference.
///
/// This is the protocol-level interpretation of "does the caller want
/// reasoning?" — independent of any model/template. `reasoning_effort` is a
/// *level* (`"low"`/`"medium"`/`"high"`) plus the vendor-extension `"none"`.
///
/// Both `"none"` and `"minimal"` map to thinking OFF (`Some(false)`).
/// `"minimal"` is treated as an off-signal deliberately: templates that expose
/// only a boolean thinking toggle (GLM/Qwen3) cannot do "a little" reasoning,
/// so the lowest OpenAI level is the closest available "do not reason".
/// Level values return `None` — no opinion, defer to the template default or an
/// explicit thinking kwarg.
pub fn thinking_from_reasoning_effort(reasoning_effort: Option<&str>) -> Option<bool> {
match reasoning_effort {
Some("none") | Some("minimal") => Some(false),
_ => None,
}
}

// ============================================================================
// Validation Functions
// ============================================================================
Expand Down Expand Up @@ -750,3 +769,22 @@ pub struct ChatStreamChoice {
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_stop: Option<Value>,
}

#[cfg(test)]
mod tests {
use super::thinking_from_reasoning_effort;

#[test]
fn thinking_from_reasoning_effort_maps_disable_values() {
// "none"/"minimal" mean do-not-reason -> thinking OFF.
assert_eq!(thinking_from_reasoning_effort(Some("none")), Some(false));
assert_eq!(thinking_from_reasoning_effort(Some("minimal")), Some(false));
// Level values do not toggle thinking on their own.
assert_eq!(thinking_from_reasoning_effort(Some("low")), None);
assert_eq!(thinking_from_reasoning_effort(Some("medium")), None);
assert_eq!(thinking_from_reasoning_effort(Some("high")), None);
// Unspecified / unknown -> defer.
assert_eq!(thinking_from_reasoning_effort(None), None);
assert_eq!(thinking_from_reasoning_effort(Some("bogus")), None);
}
}
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
76 changes: 76 additions & 0 deletions crates/tokenizer/src/chat_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ pub enum ThinkingKeyName {
Thinking,
}

impl ThinkingKeyName {
/// The template kwarg name this toggle uses.
pub fn as_kwarg(self) -> &'static str {
match self {
ThinkingKeyName::EnableThinking => "enable_thinking",
ThinkingKeyName::Thinking => "thinking",
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ThinkingToggle {
/// Template has no thinking toggle. The model either always reasons
Expand Down Expand Up @@ -466,6 +476,11 @@ pub struct ChatTemplateParams<'a> {
/// Special tokens to inject into the template context.
/// Many templates reference `{{ bos_token }}`, `{{ eos_token }}`, etc.
pub special_tokens: Option<&'a crate::traits::SpecialTokens>,
/// Resolved thinking preference. When `Some`, `apply` sets the template's
/// own thinking-toggle key (`enable_thinking`/`thinking`, per detection) to
/// this value as a default. An explicit `template_kwargs` entry for that
/// key still wins.
pub thinking: Option<bool>,
}

/// JSON separator pair passed through HuggingFace's `tojson` filter.
Expand Down Expand Up @@ -1064,6 +1079,28 @@ impl ChatTemplateState {
https://huggingface.co/docs/transformers/main/en/chat_templating",
)
})?;

// Apply the resolved thinking preference under the template's own toggle
// key (`enable_thinking` vs `thinking`, per detection). Skip entirely
// (no clone) when the caller already set that key explicitly — the
// explicit value wins.
if let (Some(thinking), Some(key)) = (params.thinking, self.thinking_key_name) {
let kwarg_key = key.as_kwarg();
if params
.template_kwargs
.is_none_or(|k| !k.contains_key(kwarg_key))
{
let mut kwargs = params.template_kwargs.cloned().unwrap_or_default();
kwargs.insert(kwarg_key.to_string(), serde_json::Value::Bool(thinking));
let params = ChatTemplateParams {
template_kwargs: Some(&kwargs),
thinking: None,
..params
};
return render_chat_template(env, messages, params);
}
}

render_chat_template(env, messages, params)
}

Expand Down Expand Up @@ -1190,4 +1227,43 @@ mod tests {

assert_eq!(result, "<s>hello");
}

#[test]
fn thinking_param_sets_template_key_and_explicit_wins() {
use std::collections::HashMap;

// Template echoes the enable_thinking value so we can observe what was set.
let state = ChatTemplateState::new(Some("{{ enable_thinking }}".to_string())).unwrap();
assert_eq!(
state.thinking_key_name(),
Some(ThinkingKeyName::EnableThinking)
);

// thinking = Some(false) injects enable_thinking=false under the model's key.
let out = state
.apply(
&[],
ChatTemplateParams {
thinking: Some(false),
..Default::default()
},
)
.unwrap();
assert_eq!(out, "false");

// An explicit template_kwargs entry overrides the injected default.
let mut kwargs: HashMap<String, serde_json::Value> = HashMap::new();
kwargs.insert("enable_thinking".to_string(), serde_json::Value::Bool(true));
let out = state
.apply(
&[],
ChatTemplateParams {
thinking: Some(false),
template_kwargs: Some(&kwargs),
..Default::default()
},
)
.unwrap();
assert_eq!(out, "true");
}
}
71 changes: 68 additions & 3 deletions crates/tokenizer/src/huggingface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,14 +486,18 @@ fn detect_renderer_from_config(dir: &std::path::Path) -> Renderer {
// ---------------------------------------------------------------------------
// DeepSeek V3.2 / V4 dispatch shims
// ---------------------------------------------------------------------------
/// Derive the V3.2 / V4 thinking mode from `template_kwargs`. Only the
/// `thinking` key is honored, matching sglang's DeepSeek serving path and
/// the `ThinkingKeyName::Thinking` contract reported by this tokenizer.
/// Derive the V3.2 / V4 thinking mode. These native encoders bypass
/// `ChatTemplateState::apply`, so this is where the resolved thinking preference
/// is consumed. An explicit `template_kwargs["thinking"]` wins; otherwise fall
/// back to `params.thinking` (resolved from `reasoning_effort` / Anthropic
/// `ThinkingConfig`) — same precedence as the Jinja path. Default off, matching
/// the `ThinkingKeyName::Thinking` / `DefaultOff` contract reported here.
fn derive_thinking_mode(params: &ChatTemplateParams) -> deepseek_v32::ThinkingMode {
let enabled = params
.template_kwargs
.and_then(|k| k.get("thinking"))
.and_then(serde_json::Value::as_bool)
.or(params.thinking)
.unwrap_or(false);
if enabled {
deepseek_v32::ThinkingMode::Thinking
Expand Down Expand Up @@ -578,3 +582,64 @@ fn apply_deepseek_v4(
deepseek_v4::encode_messages(msgs, thinking_mode, &encode_params)
.map_err(|e| Error::msg(format!("DeepSeek V4 encode failed: {e}")))
}

#[cfg(test)]
mod tests {
use std::collections::HashMap;

use super::derive_thinking_mode;
use crate::{chat_template::ChatTemplateParams, encoders::deepseek_v32::ThinkingMode};

fn thinking_kwargs(value: bool) -> HashMap<String, serde_json::Value> {
HashMap::from([("thinking".to_string(), serde_json::Value::Bool(value))])
}

// Regression: DeepSeek V3.2/V4 bypass ChatTemplateState::apply, so the
// resolved `params.thinking` (from reasoning_effort / Anthropic ThinkingConfig)
// must be honored here — with an explicit `template_kwargs["thinking"]` still
// winning. Same precedence as the Jinja path.
#[test]
fn derive_thinking_mode_honors_params_thinking_and_explicit_override() {
// No signal at all -> Chat (default off).
assert!(matches!(
derive_thinking_mode(&ChatTemplateParams::default()),
ThinkingMode::Chat
));

// params.thinking is the fallback when there is no explicit kwarg.
assert!(matches!(
derive_thinking_mode(&ChatTemplateParams {
thinking: Some(true),
..Default::default()
}),
ThinkingMode::Thinking
));
assert!(matches!(
derive_thinking_mode(&ChatTemplateParams {
thinking: Some(false),
..Default::default()
}),
ThinkingMode::Chat
));

// An explicit template_kwargs["thinking"] wins over params.thinking.
let on = thinking_kwargs(true);
assert!(matches!(
derive_thinking_mode(&ChatTemplateParams {
thinking: Some(false),
template_kwargs: Some(&on),
..Default::default()
}),
ThinkingMode::Thinking
));
let off = thinking_kwargs(false);
assert!(matches!(
derive_thinking_mode(&ChatTemplateParams {
thinking: Some(true),
template_kwargs: Some(&off),
..Default::default()
}),
ThinkingMode::Chat
));
}
}
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
Loading
Loading