Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
44 changes: 42 additions & 2 deletions components/src/dynamo/frontend/sglang_prepost.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,30 @@ def detect_force_reasoning_from_template(chat_template: str | None) -> bool:
# Reasoning parsers that default to "thinking on" unless the client
# explicitly opts out via chat_template_kwargs. Mirrors sglang's
# serving_chat._get_reasoning_from_request table.
_THINKING_BY_DEFAULT = {"qwen3", "glm45", "nemotron_3", "interns1", "kimi_k2"}
_THINKING_BY_DEFAULT = {
"qwen3",
"glm45",
"nemotron_3",
"interns1",
"kimi_k2",
"minimax-m3",
Comment thread
indrajit96 marked this conversation as resolved.
Resolved
}
_THINKING_OPT_IN = {"deepseek-v3", "deepseek-v4", "gemma4"}

_SGLANG_PARSER_NAME_ALIASES = {
# Dynamo's Rust parser registry accepts these MiniMax-M3 aliases. Native
# SGLang exposes the same parser as "minimax-m3".
"minimax_m3": "minimax-m3",
"minimax_m3_nom": "minimax-m3",
"minimax-m3-nom": "minimax-m3",
}
Comment thread
indrajit96 marked this conversation as resolved.
Resolved


def _normalize_sglang_parser_name(parser_name: str | None) -> str | None:
if not parser_name:
return parser_name
return _SGLANG_PARSER_NAME_ALIASES.get(parser_name, parser_name)


def resolve_request_force_reasoning(
request: dict[str, Any],
Expand All @@ -114,6 +135,7 @@ def resolve_request_force_reasoning(
enabled by ``chat_template_kwargs.{thinking,enable_thinking}=True``.
* anything else: follow the statically-detected template default.
"""
reasoning_parser_name = _normalize_sglang_parser_name(reasoning_parser_name)
if not reasoning_parser_name:
return False

Expand All @@ -122,6 +144,8 @@ def resolve_request_force_reasoning(
)

if reasoning_parser_name in _THINKING_BY_DEFAULT:
if reasoning_parser_name == "minimax-m3":
return kwargs.get("thinking_mode") != "disabled"
Comment thread
indrajit96 marked this conversation as resolved.
Resolved
flag_key = (
"thinking" if reasoning_parser_name == "kimi_k2" else "enable_thinking"
)
Expand Down Expand Up @@ -258,6 +282,7 @@ def create_parsers(
if tool_choice == "required" or _is_named_tool_choice(tool_choice):
tool_call_parser = JsonArrayParser()
elif tool_call_parser_name:
tool_call_parser_name = _normalize_sglang_parser_name(tool_call_parser_name)
tool_call_parser = FunctionCallParser(
tools=sglang_tools,
tool_call_parser=tool_call_parser_name,
Expand All @@ -268,6 +293,7 @@ def create_parsers(
tool_choice
)
if reasoning_parser_name and not guided_decoding_active:
reasoning_parser_name = _normalize_sglang_parser_name(reasoning_parser_name)
reasoning_parser = ReasoningParser(
model_type=reasoning_parser_name,
stream_reasoning=True,
Expand Down Expand Up @@ -527,6 +553,7 @@ def build_tool_call_guided_decoding(
),
)
elif tool_call_parser_name:
tool_call_parser_name = _normalize_sglang_parser_name(tool_call_parser_name)
parser = FunctionCallParser(
tools=sglang_tools,
tool_call_parser=tool_call_parser_name,
Expand Down Expand Up @@ -833,18 +860,22 @@ def __init__(
history_tool_calls_count: int = 0,
sglang_tools: list[SglangTool] | None = None,
tool_call_parser_name: str | None = None,
eos_token_ids: list[int] | None = None,
) -> None:
self.tokenizer = tokenizer
self.tool_call_parser = tool_call_parser
self.reasoning_parser = reasoning_parser
self.history_tool_calls_count = history_tool_calls_count
self._sglang_tools = sglang_tools or []
self._tool_call_parser_name = tool_call_parser_name
self._tool_call_parser_name = _normalize_sglang_parser_name(
tool_call_parser_name
)
self._fast_plain_text = tool_call_parser is None and reasoning_parser is None
# Preserve special tokens when a tool call parser is active so
# delimiter tokens (e.g. <|tool_call|>) remain visible to the parser.
self._skip_special_tokens = tool_call_parser is None
self._is_json_array_parser = isinstance(tool_call_parser, JsonArrayParser)
self._eos_token_ids = set(eos_token_ids or [])

self._all_token_ids: list[int] = []
# Tool call accumulation. SGLang's streaming parser returns
Expand All @@ -861,6 +892,13 @@ def __init__(
# Full text accumulator for robust finish-time re-parse.
self._tool_text_parts: list[str] = []

def _strip_trailing_eos_token_ids(self, token_ids: list[int]) -> list[int]:
if not self._eos_token_ids:
return token_ids
while token_ids and token_ids[-1] in self._eos_token_ids:
token_ids.pop()
return token_ids

def _tool_call_id(self, name: str, index: int) -> str:
return _tool_call_id_for_parser(
self._tool_call_parser_name,
Expand Down Expand Up @@ -919,6 +957,8 @@ def process_output(self, engine_response: dict[str, Any]) -> dict[str, Any] | No
raw_ids = engine_response.get("token_ids")
token_ids = raw_ids if isinstance(raw_ids, list) else list(raw_ids or [])
finish_reason = engine_response.get("finish_reason")
if finish_reason:
token_ids = self._strip_trailing_eos_token_ids(list(token_ids))

delta_text = self._incremental_decode(token_ids) if token_ids else ""

Expand Down
3 changes: 3 additions & 0 deletions components/src/dynamo/frontend/sglang_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ async def _generator_inner(
),
sglang_tools=convert_tools(request.get("tools")),
tool_call_parser_name=self.tool_call_parser_name,
eos_token_ids=[self.eos_token_id] if self.eos_token_id is not None else [],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)

async for item in self._generate_and_stream(
Expand Down Expand Up @@ -468,6 +469,7 @@ async def _generator_inner_pool(
),
sglang_tools=convert_tools(request.get("tools")),
tool_call_parser_name=self.tool_call_parser_name,
eos_token_ids=[self.eos_token_id] if self.eos_token_id is not None else [],
)

async for item in self._generate_and_stream(
Expand Down Expand Up @@ -578,6 +580,7 @@ async def _generate_and_stream(
}
if pending_usage:
dynamo_out["usage"] = pending_usage
pending_usage = None
response_nvext: dict[str, Any] = {}
if stop_reason is not None and nvext_extra_field_requested(
request, "stop_reason"
Expand Down
114 changes: 114 additions & 0 deletions components/src/dynamo/frontend/tests/test_sglang_processor_unit.py
Comment thread
indrajit96 marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@
_flatten_message_content,
_normalize_assistant_tool_call_arguments,
_normalize_prompt_token_ids,
_normalize_sglang_parser_name,
_parse_json_array_buffer,
build_tool_call_guided_decoding,
convert_tools,
create_parsers,
preprocess_chat_request,
resolve_request_force_reasoning,
)
from dynamo.frontend.sglang_processor import (
SglangPreprocessWorkerResult,
Expand Down Expand Up @@ -537,6 +539,80 @@ def test_reasoning_active_when_tool_choice_auto(self):
assert tcp is not None
assert rp is not None

def test_minimax_m3_dynamo_aliases_are_normalized_for_sglang(self, monkeypatch):
"""Dynamo parser aliases should not leak into SGLang parser lookup."""

# Test double: capture the parser name Dynamo passes to SGLang without
# depending on SGLang's real parser implementation.
class FakeFunctionCallParser:
def __init__(self, *, tools, tool_call_parser):
self.tools = tools
self.tool_call_parser = tool_call_parser

class FakeReasoningParser:
def __init__(self, *, model_type, stream_reasoning, force_reasoning):
self.model_type = model_type
self.stream_reasoning = stream_reasoning
self.force_reasoning = force_reasoning

monkeypatch.setattr(
sglang_prepost_module,
"FunctionCallParser",
FakeFunctionCallParser,
)
monkeypatch.setattr(
sglang_prepost_module,
"ReasoningParser",
FakeReasoningParser,
)

tcp, rp = create_parsers(
{
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {"type": "object", "properties": {}},
},
}
],
"tool_choice": "auto",
},
tool_call_parser_name="minimax-m3-nom",
reasoning_parser_name="minimax_m3",
)

assert tcp.tool_call_parser == "minimax-m3"
assert rp.model_type == "minimax-m3"


def test_normalize_sglang_parser_name_accepts_minimax_m3_aliases():
assert _normalize_sglang_parser_name("minimax-m3") == "minimax-m3"
assert _normalize_sglang_parser_name("minimax_m3") == "minimax-m3"
assert _normalize_sglang_parser_name("minimax_m3_nom") == "minimax-m3"
assert _normalize_sglang_parser_name("minimax-m3-nom") == "minimax-m3"
assert _normalize_sglang_parser_name("kimi_k2") == "kimi_k2"


def test_minimax_m3_force_reasoning_uses_thinking_mode():
assert (
resolve_request_force_reasoning(
{"chat_template_kwargs": {}},
"minimax_m3",
template_default=False,
)
is True
)
assert (
resolve_request_force_reasoning(
{"chat_template_kwargs": {"thinking_mode": "disabled"}},
"minimax-m3",
template_default=True,
)
is False
)


class TestBuildToolCallGuidedDecoding: # FRONTEND.3 — guided-decoding setup for tool_choice
def test_none_when_no_tools(self):
Expand Down Expand Up @@ -570,6 +646,44 @@ def test_none_when_tool_choice_none(self):
is None
)

def test_auto_tool_guidance_normalizes_minimax_m3_alias(self, monkeypatch):
tools = convert_tools(
[
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {"type": "object", "properties": {}},
},
}
]
)
seen = {}

# Test double: capture the parser name used for guided decoding setup.
class FakeFunctionCallParser:
def __init__(self, *, tools, tool_call_parser):
seen["tool_call_parser"] = tool_call_parser

def get_structure_constraint(self, tool_choice, **kwargs):
assert tool_choice == "auto"
return "structural_tag", {"type": "object"}

monkeypatch.setattr(
sglang_prepost_module,
"FunctionCallParser",
FakeFunctionCallParser,
)

guided = build_tool_call_guided_decoding(
{"tool_choice": "auto"},
tool_call_parser_name="minimax_m3_nom",
sglang_tools=tools,
)

assert seen["tool_call_parser"] == "minimax-m3"
assert guided == {"structural_tag": {"type": "object"}}

def test_required_tool_choice_builds_json_schema_guidance(self):
tools = convert_tools(
[
Expand Down
4 changes: 3 additions & 1 deletion lib/llm/src/http/service/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3701,7 +3701,9 @@ mod tests {
assert_eq!(error_response.0, StatusCode::BAD_REQUEST);
assert_eq!(
error_response.1.message,
format!("{VALIDATION_PREFIX}`thinking.type` must be `enabled` or `disabled`")
format!(
"{VALIDATION_PREFIX}`thinking.type` must be `enabled`, `disabled`, or `adaptive`"
)
);
}
}
Expand Down
21 changes: 19 additions & 2 deletions lib/llm/src/model_card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1438,10 +1438,27 @@ impl ModelDeploymentCard {
) -> anyhow::Result<Self> {
let local_path = local_path.as_ref();

// This is usually the right choice
// Prefer the model config's architectural context length. Some
// multimodal HF configs, including MiniMax-M3-VL, store the language
// model config under `text_config`; tokenizer `model_max_length` can
// be a much larger sentinel/default and must be a last resort.
let architectural_max_context_length =
crate::file_json_field(&local_path.join("config.json"), "max_position_embeddings")
// But sometimes this is
.or_else(|_| {
let text_config: serde_json::Value =
crate::file_json_field(&local_path.join("config.json"), "text_config")?;
serde_json::from_value(
text_config
.get("max_position_embeddings")
.cloned()
.ok_or_else(|| {
anyhow::anyhow!(
"Field 'text_config.max_position_embeddings' not found"
)
})?,
)
.context("Failed to deserialize text_config.max_position_embeddings")
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
.or_else(|_| {
crate::file_json_field(
&local_path.join("tokenizer_config.json"),
Expand Down
Loading
Loading