diff --git a/components/src/dynamo/common/backend/tests/test_backend_bindings.py b/components/src/dynamo/common/backend/tests/test_backend_bindings.py
index c5df2e048ef9..c6403b75e6ef 100644
--- a/components/src/dynamo/common/backend/tests/test_backend_bindings.py
+++ b/components/src/dynamo/common/backend/tests/test_backend_bindings.py
@@ -26,6 +26,10 @@
# Import-time skip: if the extension hasn't been built, all tests below
# are skipped rather than crashing the collection phase.
+core = pytest.importorskip(
+ "dynamo._core",
+ reason="dynamo._core not built — run `maturin develop` first",
+)
backend = pytest.importorskip(
"dynamo._core.backend",
reason="dynamo._core.backend not built — run `maturin develop` first",
@@ -114,11 +118,58 @@ def test_worker_config_accepts_parser_runtime_settings():
namespace="dynamo",
tool_call_parser="kimi_k2",
reasoning_parser="kimi_k25",
+ default_thinking_mode="disabled",
exclude_tools_when_tool_choice_none=False,
enable_local_indexer=False,
)
+def test_worker_config_preserves_legacy_positional_argument_order():
+ """New optional fields must be appended after every existing argument."""
+ backend.WorkerConfig(
+ "dynamo", # namespace
+ "backend", # component
+ "generate", # endpoint
+ "", # model_name
+ None, # served_model_name
+ core.ModelInput.Tokens, # model_input
+ "chat,completions", # endpoint_types
+ None, # custom_jinja_template
+ None, # tool_call_parser
+ None, # reasoning_parser
+ False, # exclude_tools_when_tool_choice_none
+ False, # enable_local_indexer
+ )
+
+
+@pytest.mark.unified
+def test_python_worker_config_preserves_legacy_positional_argument_order():
+ from dynamo.common.backend.worker import WorkerConfig
+
+ config = WorkerConfig(
+ "dynamo", # namespace
+ "backend", # component
+ "generate", # endpoint
+ "", # model_name
+ None, # served_model_name
+ core.ModelInput.Tokens, # model_input
+ "chat,completions", # endpoint_types
+ "etcd", # discovery_backend
+ "tcp", # request_plane
+ None, # event_plane
+ False, # use_kv_events
+ None, # custom_jinja_template
+ None, # tool_call_parser
+ None, # reasoning_parser
+ False, # exclude_tools_when_tool_choice_none
+ False, # enable_local_indexer
+ )
+
+ assert config.exclude_tools_when_tool_choice_none is False
+ assert config.enable_local_indexer is False
+ assert config.default_thinking_mode is None
+
+
def test_worker_config_accepts_media_configuration():
"""Unified registration can advertise frontend media decoding."""
from dynamo.llm import MediaDecoder, MediaFetcher
@@ -157,6 +208,7 @@ def test_python_worker_config_from_runtime_config_copies_parser_settings():
runtime_cfg.custom_jinja_template = None
runtime_cfg.dyn_tool_call_parser = "kimi_k2"
runtime_cfg.dyn_reasoning_parser = "kimi_k25"
+ runtime_cfg.dyn_default_thinking_mode = "disabled"
runtime_cfg.exclude_tools_when_tool_choice_none = False
runtime_cfg.enable_local_indexer = False
runtime_cfg.dyn_enable_structural_tag = True
@@ -171,6 +223,7 @@ def test_python_worker_config_from_runtime_config_copies_parser_settings():
assert config.tool_call_parser == "kimi_k2"
assert config.reasoning_parser == "kimi_k25"
+ assert config.default_thinking_mode == "disabled"
assert config.exclude_tools_when_tool_choice_none is False
assert config.enable_local_indexer is False
assert config.structural_tag_mode == "on"
@@ -195,6 +248,7 @@ class _BareRuntime:
assert cfg.endpoint_types == "chat,completions"
assert cfg.use_kv_events is False
assert cfg.custom_jinja_template is None
+ assert cfg.default_thinking_mode is None
assert cfg.structural_tag_mode == "off"
assert cfg.structural_tag_scope == "auto"
assert cfg.structural_tag_schema == "auto"
diff --git a/components/src/dynamo/common/backend/worker.py b/components/src/dynamo/common/backend/worker.py
index 61d4952da4c7..beed575c456a 100644
--- a/components/src/dynamo/common/backend/worker.py
+++ b/components/src/dynamo/common/backend/worker.py
@@ -146,6 +146,7 @@ class WorkerConfig:
media_fetcher: Optional[MediaFetcher] = None
# KV event/recovery ownership endpoint. None uses this worker's serving endpoint.
kv_state_endpoint: Optional[str] = None
+ default_thinking_mode: Optional[str] = None
@classmethod
def from_runtime_config(
@@ -180,6 +181,9 @@ def from_runtime_config(
),
"tool_call_parser": getattr(runtime_cfg, "dyn_tool_call_parser", None),
"reasoning_parser": getattr(runtime_cfg, "dyn_reasoning_parser", None),
+ "default_thinking_mode": getattr(
+ runtime_cfg, "dyn_default_thinking_mode", None
+ ),
"exclude_tools_when_tool_choice_none": getattr(
runtime_cfg, "exclude_tools_when_tool_choice_none", True
),
@@ -266,6 +270,7 @@ async def run(self) -> None:
custom_jinja_template=self.config.custom_jinja_template,
tool_call_parser=self.config.tool_call_parser,
reasoning_parser=self.config.reasoning_parser,
+ default_thinking_mode=self.config.default_thinking_mode,
exclude_tools_when_tool_choice_none=(
self.config.exclude_tools_when_tool_choice_none
),
diff --git a/components/src/dynamo/common/configuration/groups/runtime_args.py b/components/src/dynamo/common/configuration/groups/runtime_args.py
index 81282625cdd3..8337b994aac6 100644
--- a/components/src/dynamo/common/configuration/groups/runtime_args.py
+++ b/components/src/dynamo/common/configuration/groups/runtime_args.py
@@ -37,6 +37,7 @@ class DynamoRuntimeConfig(ConfigBase):
dyn_tool_call_parser: Optional[str] = None
dyn_reasoning_parser: Optional[str] = None
+ dyn_default_thinking_mode: Optional[str] = None
exclude_tools_when_tool_choice_none: bool = True
dyn_enable_structural_tag: bool = False
dyn_structural_tag_scope: str = "auto"
@@ -196,6 +197,16 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
help="Reasoning parser name for the model. If not specified, no reasoning parsing is performed.",
choices=get_reasoning_parser_names(),
)
+ add_argument(
+ g,
+ flag_name="--dyn-default-thinking-mode",
+ env_var="DYN_DEFAULT_THINKING_MODE",
+ default=None,
+ choices=["enabled", "disabled"],
+ help="Deployment-level default thinking mode for chat templates. "
+ "Client request thinking, reasoning_effort, chat_template_args, or "
+ "chat_template_kwargs values override this default.",
+ )
# NOTE: This flag also exists in FrontendArgGroup (frontend_args.py).
# Both definitions are needed: this one controls the Rust-native chat
# template path (oai.rs), while the frontend copy controls the Python
diff --git a/components/src/dynamo/common/tests/configuration/test_runtime_args.py b/components/src/dynamo/common/tests/configuration/test_runtime_args.py
index 7bf842b833ef..a24676b5e156 100644
--- a/components/src/dynamo/common/tests/configuration/test_runtime_args.py
+++ b/components/src/dynamo/common/tests/configuration/test_runtime_args.py
@@ -122,3 +122,28 @@ def test_fpm_trace_help_lists_flag_and_env(monkeypatch):
assert "--fpm-trace" in help_text
assert "--no-fpm-trace" in help_text
assert "DYN_FPM_TRACE" in help_text
+
+
+@pytest.mark.parametrize("mode", ["enabled", "disabled"])
+def test_default_thinking_mode_cli(mode, monkeypatch):
+ monkeypatch.delenv("DYN_DEFAULT_THINKING_MODE", raising=False)
+
+ config, help_text = _parse_runtime_args(["--dyn-default-thinking-mode", mode])
+
+ assert config.dyn_default_thinking_mode == mode
+ assert "DYN_DEFAULT_THINKING_MODE" in help_text
+
+
+def test_default_thinking_mode_env(monkeypatch):
+ monkeypatch.setenv("DYN_DEFAULT_THINKING_MODE", "disabled")
+
+ config, _ = _parse_runtime_args([])
+
+ assert config.dyn_default_thinking_mode == "disabled"
+
+
+def test_default_thinking_mode_rejects_invalid_value(monkeypatch):
+ monkeypatch.delenv("DYN_DEFAULT_THINKING_MODE", raising=False)
+
+ with pytest.raises(SystemExit):
+ _parse_runtime_args(["--dyn-default-thinking-mode", "adaptive"])
diff --git a/components/src/dynamo/frontend/prepost.py b/components/src/dynamo/frontend/prepost.py
index 55b69a303e64..96591fec072b 100644
--- a/components/src/dynamo/frontend/prepost.py
+++ b/components/src/dynamo/frontend/prepost.py
@@ -23,6 +23,8 @@
from vllm.tool_parsers import ToolParser
from vllm.utils.async_utils import make_async
+from .thinking import apply_default_thinking_mode_to_template_kwargs
+
class _Renderer(Protocol):
"""Structural type for vLLM's chat-template renderer."""
@@ -94,6 +96,7 @@ def _prepare_request(
exclude_tools_when_tool_choice_none: bool = True,
enable_auto_tool_choice: bool = False,
default_chat_template_kwargs: dict[str, Any] | None = None,
+ default_thinking_mode: str | None = None,
) -> tuple[ChatCompletionRequest, ToolParser | None, dict[str, Any], Any, ChatParams]:
"""Validate request and build arguments for template rendering.
@@ -151,10 +154,20 @@ def _prepare_request(
request_for_sampling.chat_template_kwargs or raw_template_args or {},
)
)
- # Don't let an absent top-level field clobber a nested reasoning_effort.
+ # reasoning_effort is a request-level thinking control. Put an explicit
+ # value into the kwargs before applying the deployment default so the two
+ # cannot produce contradictory template controls.
if request_for_sampling.reasoning_effort is not None:
chat_template_kwargs["reasoning_effort"] = request_for_sampling.reasoning_effort
- else:
+ chat_template_kwargs = apply_default_thinking_mode_to_template_kwargs(
+ chat_template_kwargs,
+ default_thinking_mode,
+ request_has_root_thinking=(
+ isinstance(request, dict) and request.get("thinking") is not None
+ ),
+ )
+ # Don't let an absent top-level field clobber a nested reasoning_effort.
+ if request_for_sampling.reasoning_effort is None:
chat_template_kwargs.setdefault("reasoning_effort", None)
# Mistral warns that tokenize=False is unsafe for chat templates.
@@ -201,6 +214,7 @@ async def preprocess_chat_request(
exclude_tools_when_tool_choice_none: bool = True,
enable_auto_tool_choice: bool = False,
default_chat_template_kwargs: dict[str, Any] | None = None,
+ default_thinking_mode: str | None = None,
) -> PreprocessResult:
(
request_for_sampling,
@@ -215,6 +229,7 @@ async def preprocess_chat_request(
exclude_tools_when_tool_choice_none=exclude_tools_when_tool_choice_none,
enable_auto_tool_choice=enable_auto_tool_choice,
default_chat_template_kwargs=default_chat_template_kwargs,
+ default_thinking_mode=default_thinking_mode,
)
_, engine_prompt = await renderer.render_messages_async(messages, chat_params)
diff --git a/components/src/dynamo/frontend/sglang_prepost.py b/components/src/dynamo/frontend/sglang_prepost.py
index 36f6562cae99..5ba4f0fca8b4 100644
--- a/components/src/dynamo/frontend/sglang_prepost.py
+++ b/components/src/dynamo/frontend/sglang_prepost.py
@@ -28,6 +28,7 @@
)
from sglang.srt.parser.reasoning_parser import ReasoningParser
+from .thinking import apply_default_thinking_mode_to_template_kwargs
from .utils import PreprocessError, random_call_id
logger = logging.getLogger(__name__)
@@ -410,6 +411,7 @@ def _flatten_message_content(content: Any) -> Any:
def _normalize_openai_thinking_template_kwargs(
request: dict[str, Any],
+ default_thinking_mode: str | None = None,
) -> dict[str, Any]:
request = copy.copy(request)
chat_template_kwargs = dict(
@@ -434,9 +436,18 @@ def setdefault_reasoning(enabled: bool) -> None:
elif thinking_type == "disabled":
setdefault_reasoning(False)
- if request.get("reasoning_effort") == "none":
+ reasoning_effort = request.get("reasoning_effort")
+ if reasoning_effort is not None:
+ chat_template_kwargs["reasoning_effort"] = reasoning_effort
+ if reasoning_effort == "none":
setdefault_reasoning(False)
+ chat_template_kwargs = apply_default_thinking_mode_to_template_kwargs(
+ chat_template_kwargs,
+ default_thinking_mode,
+ request_has_root_thinking=request.get("thinking") is not None,
+ )
+
if chat_template_kwargs:
request["chat_template_kwargs"] = chat_template_kwargs
return request
@@ -703,6 +714,7 @@ def preprocess_chat_request(
reasoning_parser_name: str | None,
exclude_tools_when_tool_choice_none: bool = True,
template_force_reasoning: bool = False,
+ default_thinking_mode: str | None = None,
) -> SglangPreprocessResult:
"""Preprocess a chat request using SGLang tokenizer and parser APIs.
@@ -713,7 +725,7 @@ def preprocess_chat_request(
Synchronous -- suitable for both main-process and worker-process execution.
"""
- request = _normalize_openai_thinking_template_kwargs(request)
+ request = _normalize_openai_thinking_template_kwargs(request, default_thinking_mode)
messages = _materialize_messages(request.get("messages", []))
# Generation mode is independent of whether the client wants reasoning
diff --git a/components/src/dynamo/frontend/sglang_processor.py b/components/src/dynamo/frontend/sglang_processor.py
index 454f99b2e863..70ae4aaf0ce1 100644
--- a/components/src/dynamo/frontend/sglang_processor.py
+++ b/components/src/dynamo/frontend/sglang_processor.py
@@ -37,6 +37,7 @@
detect_force_reasoning_from_template,
preprocess_chat_request,
)
+from .thinking import runtime_default_thinking_mode
from .utils import (
PreprocessError,
extract_mm_urls,
@@ -145,6 +146,7 @@ def _map_finish_reason(raw: str | None) -> str | None:
_w_reasoning_parser_name: str | None = None
_w_exclude_tools_when_tool_choice_none: bool = True
_w_template_force_reasoning: bool = False
+_w_default_thinking_mode: str | None = None
def _load_chat_template(chat_template: str | None) -> str | None:
@@ -194,10 +196,12 @@ def _init_worker(
trust_remote_code: bool = False,
template_force_reasoning: bool = False,
chat_template: str | None = None,
+ default_thinking_mode: str | None = None,
) -> None:
"""Initialize a worker process with its own tokenizer."""
global _w_tokenizer, _w_tool_call_parser_name, _w_reasoning_parser_name
global _w_exclude_tools_when_tool_choice_none, _w_template_force_reasoning
+ global _w_default_thinking_mode
_w_tokenizer = _load_tokenizer(model_path, trust_remote_code)
if chat_template is not None:
_w_tokenizer.chat_template = chat_template
@@ -205,6 +209,7 @@ def _init_worker(
_w_reasoning_parser_name = reasoning_parser_name
_w_exclude_tools_when_tool_choice_none = exclude_tools_when_tool_choice_none
_w_template_force_reasoning = template_force_reasoning
+ _w_default_thinking_mode = default_thinking_mode
def _preprocess_worker(
@@ -220,6 +225,7 @@ def _preprocess_worker(
reasoning_parser_name=_w_reasoning_parser_name,
exclude_tools_when_tool_choice_none=_w_exclude_tools_when_tool_choice_none,
template_force_reasoning=_w_template_force_reasoning,
+ default_thinking_mode=_w_default_thinking_mode,
)
n = request.get("n", 1)
@@ -358,6 +364,7 @@ def __init__(
preprocess_pool: ProcessPoolExecutor | None = None,
preprocess_workers: int = 0,
stream_interval: int = 1,
+ default_thinking_mode: str | None = None,
):
self.tokenizer = tokenizer
# Detect force_reasoning once from the chat template, matching
@@ -381,6 +388,7 @@ def __init__(
self.eos_token_ids = _normalize_eos_token_ids(eos_token_ids)
self.debug_perf = debug_perf
self.stream_interval = stream_interval
+ self.default_thinking_mode = default_thinking_mode
self.preprocess_pool = preprocess_pool
if preprocess_pool is not None:
self._worker_semaphore: asyncio.Semaphore | None = asyncio.Semaphore(
@@ -437,6 +445,7 @@ async def _generator_inner(
reasoning_parser_name=self.reasoning_parser_name,
exclude_tools_when_tool_choice_none=self.exclude_tools_when_tool_choice_none,
template_force_reasoning=self.template_force_reasoning,
+ default_thinking_mode=self.default_thinking_mode,
)
if self.debug_perf:
@@ -789,11 +798,14 @@ async def chat_engine_factory(
self.reasoning_parser_name
or _runtime_config_parser_name(mdc, "reasoning_parser")
)
+ default_thinking_mode = runtime_default_thinking_mode(mdc.runtime_config())
if tool_call_parser_name:
logger.info("SGLang tool call parser: %s", tool_call_parser_name)
if reasoning_parser_name:
logger.info("SGLang reasoning parser: %s", reasoning_parser_name)
+ if default_thinking_mode:
+ logger.info("SGLang default thinking mode: %s", default_thinking_mode)
preprocess_pool = None
preprocess_workers = self.config.preprocess_workers
@@ -814,6 +826,7 @@ async def chat_engine_factory(
self.trust_remote_code,
template_force_reasoning,
chat_template,
+ default_thinking_mode,
),
)
futures = [
@@ -849,6 +862,7 @@ async def chat_engine_factory(
preprocess_pool=preprocess_pool,
preprocess_workers=preprocess_workers,
stream_interval=self.stream_interval,
+ default_thinking_mode=default_thinking_mode,
)
gen.exclude_tools_when_tool_choice_none = (
self.config.exclude_tools_when_tool_choice_none
diff --git a/components/src/dynamo/frontend/tests/test_sglang_processor_unit.py b/components/src/dynamo/frontend/tests/test_sglang_processor_unit.py
index 7ccd7b8a446f..00ba85641f01 100644
--- a/components/src/dynamo/frontend/tests/test_sglang_processor_unit.py
+++ b/components/src/dynamo/frontend/tests/test_sglang_processor_unit.py
@@ -1994,8 +1994,10 @@ class FakeTokenizer:
None,
exclude_tools_when_tool_choice_none=True,
chat_template="custom template",
+ default_thinking_mode="disabled",
)
assert sglang_processor_module._w_tokenizer.chat_template == "custom template"
+ assert sglang_processor_module._w_default_thinking_mode == "disabled"
def test_with_reasoning_parser(self, tokenizer):
"""Reasoning parser is attached to result."""
@@ -2331,6 +2333,141 @@ def apply_chat_template(self, messages, **kwargs):
assert result.request["chat_template_kwargs"]["enable_thinking"] is True
assert result.force_reasoning is True
+ def test_default_thinking_mode_disabled_reaches_generic_chat_template(self):
+ captured = {}
+
+ class CapturingTokenizer:
+ chat_template = "template"
+
+ def apply_chat_template(self, messages, **kwargs):
+ captured["kwargs"] = kwargs
+ return [1, 2, 3]
+
+ request = {
+ "model": "generic-model",
+ "messages": [{"role": "user", "content": "Hello"}],
+ }
+
+ result = preprocess_chat_request(
+ request,
+ tokenizer=CapturingTokenizer(),
+ tool_call_parser_name=None,
+ reasoning_parser_name=None,
+ default_thinking_mode="disabled",
+ )
+
+ assert result.prompt_token_ids == [1, 2, 3]
+ assert captured["kwargs"]["thinking"] is False
+ assert captured["kwargs"]["enable_thinking"] is False
+ assert captured["kwargs"]["thinking_mode"] == "disabled"
+ assert result.request["chat_template_kwargs"]["thinking_mode"] == "disabled"
+ assert "chat_template_kwargs" not in request
+
+ def test_default_thinking_mode_does_not_override_request_kwargs(self):
+ captured = {}
+
+ class CapturingTokenizer:
+ chat_template = "template"
+
+ def apply_chat_template(self, messages, **kwargs):
+ captured["kwargs"] = kwargs
+ return [1, 2, 3]
+
+ result = preprocess_chat_request(
+ {
+ "model": "generic-model",
+ "messages": [{"role": "user", "content": "Hello"}],
+ "chat_template_kwargs": {"enable_thinking": True},
+ },
+ tokenizer=CapturingTokenizer(),
+ tool_call_parser_name=None,
+ reasoning_parser_name=None,
+ default_thinking_mode="disabled",
+ )
+
+ assert result.prompt_token_ids == [1, 2, 3]
+ assert captured["kwargs"]["enable_thinking"] is True
+ assert "thinking" not in captured["kwargs"]
+ assert "thinking_mode" not in captured["kwargs"]
+
+ def test_default_thinking_mode_does_not_override_pythonized_args(self):
+ captured = {}
+
+ class CapturingTokenizer:
+ chat_template = "template"
+
+ def apply_chat_template(self, messages, **kwargs):
+ captured["kwargs"] = kwargs
+ return [1, 2, 3]
+
+ result = preprocess_chat_request(
+ {
+ "model": "generic-model",
+ "messages": [{"role": "user", "content": "Hello"}],
+ "chat_template_args": {"enable_thinking": True},
+ },
+ tokenizer=CapturingTokenizer(),
+ tool_call_parser_name=None,
+ reasoning_parser_name=None,
+ default_thinking_mode="disabled",
+ )
+
+ assert result.prompt_token_ids == [1, 2, 3]
+ assert captured["kwargs"]["enable_thinking"] is True
+ assert "thinking" not in captured["kwargs"]
+ assert "thinking_mode" not in captured["kwargs"]
+
+ def test_null_root_thinking_does_not_suppress_deployment_default(self):
+ captured = {}
+
+ class CapturingTokenizer:
+ chat_template = "template"
+
+ def apply_chat_template(self, messages, **kwargs):
+ captured["kwargs"] = kwargs
+ return [1, 2, 3]
+
+ preprocess_chat_request(
+ {
+ "model": "generic-model",
+ "messages": [{"role": "user", "content": "Hello"}],
+ "thinking": None,
+ },
+ tokenizer=CapturingTokenizer(),
+ tool_call_parser_name=None,
+ reasoning_parser_name=None,
+ default_thinking_mode="disabled",
+ )
+
+ assert captured["kwargs"]["enable_thinking"] is False
+
+ def test_reasoning_effort_takes_precedence_over_deployment_default(self):
+ captured = {}
+
+ class CapturingTokenizer:
+ chat_template = "template"
+
+ def apply_chat_template(self, messages, **kwargs):
+ captured["kwargs"] = kwargs
+ return [1, 2, 3]
+
+ preprocess_chat_request(
+ {
+ "model": "generic-model",
+ "messages": [{"role": "user", "content": "Hello"}],
+ "reasoning_effort": "high",
+ },
+ tokenizer=CapturingTokenizer(),
+ tool_call_parser_name=None,
+ reasoning_parser_name=None,
+ default_thinking_mode="disabled",
+ )
+
+ assert captured["kwargs"]["reasoning_effort"] == "high"
+ assert "thinking" not in captured["kwargs"]
+ assert "enable_thinking" not in captured["kwargs"]
+ assert "thinking_mode" not in captured["kwargs"]
+
def test_deepseek_v4_named_tool_choice_filters_encoder_tools(self, monkeypatch):
captured = {}
fake_module = types.ModuleType("sglang.srt.entrypoints.openai.encoding_dsv4")
diff --git a/components/src/dynamo/frontend/tests/test_vllm_processor_unit.py b/components/src/dynamo/frontend/tests/test_vllm_processor_unit.py
index 4f64dda3125d..5baa6b9458dc 100644
--- a/components/src/dynamo/frontend/tests/test_vllm_processor_unit.py
+++ b/components/src/dynamo/frontend/tests/test_vllm_processor_unit.py
@@ -1022,6 +1022,70 @@ def test_reasoning_effort_forwarded_to_template_kwargs(self, tokenizer):
)
assert chat_params.chat_template_kwargs.get("reasoning_effort") == "low"
+ def test_reasoning_effort_takes_precedence_over_deployment_default(self, tokenizer):
+ _, _, chat_template_kwargs, _, _ = _prepare_request(
+ {
+ "model": MODEL,
+ "messages": self._messages(),
+ "reasoning_effort": "high",
+ },
+ tokenizer=tokenizer,
+ tool_parser_class=None,
+ default_thinking_mode="disabled",
+ )
+
+ assert chat_template_kwargs["reasoning_effort"] == "high"
+ assert "thinking" not in chat_template_kwargs
+ assert "enable_thinking" not in chat_template_kwargs
+ assert "thinking_mode" not in chat_template_kwargs
+
+ def test_default_thinking_mode_disabled_reaches_template_kwargs(self, tokenizer):
+ _, _, chat_template_kwargs, _, chat_params = _prepare_request(
+ {
+ "model": MODEL,
+ "messages": self._messages(),
+ },
+ tokenizer=tokenizer,
+ tool_parser_class=None,
+ default_thinking_mode="disabled",
+ )
+
+ for kwargs in (chat_template_kwargs, chat_params.chat_template_kwargs):
+ assert kwargs["thinking"] is False
+ assert kwargs["enable_thinking"] is False
+ assert kwargs["thinking_mode"] == "disabled"
+
+ def test_default_thinking_mode_does_not_override_request_kwargs(self, tokenizer):
+ _, _, chat_template_kwargs, _, chat_params = _prepare_request(
+ {
+ "model": MODEL,
+ "messages": self._messages(),
+ "chat_template_kwargs": {"enable_thinking": True},
+ },
+ tokenizer=tokenizer,
+ tool_parser_class=None,
+ default_thinking_mode="disabled",
+ )
+
+ for kwargs in (chat_template_kwargs, chat_params.chat_template_kwargs):
+ assert kwargs["enable_thinking"] is True
+ assert "thinking" not in kwargs
+ assert "thinking_mode" not in kwargs
+
+ def test_null_root_thinking_does_not_suppress_deployment_default(self, tokenizer):
+ _, _, chat_template_kwargs, _, _ = _prepare_request(
+ {
+ "model": MODEL,
+ "messages": self._messages(),
+ "thinking": None,
+ },
+ tokenizer=tokenizer,
+ tool_parser_class=None,
+ default_thinking_mode="disabled",
+ )
+
+ assert chat_template_kwargs["enable_thinking"] is False
+
@pytest.mark.parametrize(
("runtime_config", "expected"),
diff --git a/components/src/dynamo/frontend/thinking.py b/components/src/dynamo/frontend/thinking.py
new file mode 100644
index 000000000000..ea2504a0fa50
--- /dev/null
+++ b/components/src/dynamo/frontend/thinking.py
@@ -0,0 +1,60 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_THINKING_MODE_RUNTIME_KEY = "default_thinking_mode"
+THINKING_CONTROL_KEYS = (
+ "thinking",
+ "enable_thinking",
+ "thinking_mode",
+ "reasoning_effort",
+)
+
+
+def runtime_default_thinking_mode(runtime_config: dict[str, Any] | None) -> str | None:
+ """Read deployment-level default thinking mode from model runtime metadata."""
+ if not isinstance(runtime_config, dict):
+ return None
+
+ runtime_data = runtime_config.get("runtime_data")
+ if not isinstance(runtime_data, dict):
+ return None
+
+ value = runtime_data.get(DEFAULT_THINKING_MODE_RUNTIME_KEY)
+ return value if isinstance(value, str) and value else None
+
+
+def apply_default_thinking_mode_to_template_kwargs(
+ chat_template_kwargs: dict[str, Any],
+ default_thinking_mode: str | None,
+ *,
+ request_has_root_thinking: bool = False,
+) -> dict[str, Any]:
+ """Merge deployment thinking default unless the request already controls it."""
+ if default_thinking_mode is None:
+ return chat_template_kwargs
+
+ if request_has_root_thinking or any(
+ key in chat_template_kwargs for key in THINKING_CONTROL_KEYS
+ ):
+ return chat_template_kwargs
+
+ if default_thinking_mode not in ("enabled", "disabled"):
+ logger.warning(
+ "Ignoring invalid default_thinking_mode=%r; expected 'enabled' or 'disabled'",
+ default_thinking_mode,
+ )
+ return chat_template_kwargs
+
+ enabled = default_thinking_mode == "enabled"
+ merged = dict(chat_template_kwargs)
+ merged["thinking"] = enabled
+ merged["enable_thinking"] = enabled
+ merged["thinking_mode"] = default_thinking_mode
+ return merged
diff --git a/components/src/dynamo/frontend/vllm_processor.py b/components/src/dynamo/frontend/vllm_processor.py
index 78b952315829..52d5d3e62dfa 100644
--- a/components/src/dynamo/frontend/vllm_processor.py
+++ b/components/src/dynamo/frontend/vllm_processor.py
@@ -39,6 +39,7 @@
from dynamo.llm import ModelCardInstanceId, PythonAsyncEngine, RoutedEngine
from .prepost import StreamingPostProcessor, preprocess_chat_request
+from .thinking import runtime_default_thinking_mode
from .utils import (
extract_mm_urls,
handle_engine_error,
@@ -246,6 +247,7 @@ def __init__(
block_size: int = 16,
enable_auto_tool_choice: bool = False,
default_chat_template_kwargs: dict[str, Any] | None = None,
+ default_thinking_mode: str | None = None,
):
self.tokenizer = tokenizer
self.input_processor = input_processor
@@ -257,6 +259,7 @@ def __init__(
self.block_size = block_size
self.enable_auto_tool_choice = enable_auto_tool_choice
self.default_chat_template_kwargs = default_chat_template_kwargs
+ self.default_thinking_mode = default_thinking_mode
# Sender for mm_kwargs transfer — instantiated lazily on first MM request.
# MmKwargsShmSender for same-node transfers (default), MmKwargsNixlSender
# for cross-node RDMA. Controlled by DYNAMO_MM_TRANSFER env var.
@@ -461,6 +464,7 @@ async def _generator_inner(
exclude_tools_when_tool_choice_none=self.exclude_tools_when_tool_choice_none,
enable_auto_tool_choice=self.enable_auto_tool_choice,
default_chat_template_kwargs=self.default_chat_template_kwargs,
+ default_thinking_mode=self.default_thinking_mode,
)
request_for_sampling = pre.request_for_sampling
@@ -1029,6 +1033,7 @@ async def chat_engine_factory(
)
else:
reasoning_parser_class = None
+ default_thinking_mode = runtime_default_thinking_mode(mdc.runtime_config())
block_size = self.config.kv_cache_block_size or 16
@@ -1044,6 +1049,7 @@ async def chat_engine_factory(
default_chat_template_kwargs=getattr(
self.flags, "default_chat_template_kwargs", None
),
+ default_thinking_mode=default_thinking_mode,
)
gen.exclude_tools_when_tool_choice_none = (
self.config.exclude_tools_when_tool_choice_none
diff --git a/components/src/dynamo/sglang/register.py b/components/src/dynamo/sglang/register.py
index 5b5160c9ed81..794604f3135f 100644
--- a/components/src/dynamo/sglang/register.py
+++ b/components/src/dynamo/sglang/register.py
@@ -363,6 +363,11 @@ async def _get_runtime_config(
# set reasoning parser and tool call parser
runtime_config.reasoning_parser = dynamo_args.dyn_reasoning_parser
runtime_config.tool_call_parser = dynamo_args.dyn_tool_call_parser
+ if dynamo_args.dyn_default_thinking_mode is not None:
+ runtime_config.set_engine_specific(
+ "default_thinking_mode",
+ json.dumps(dynamo_args.dyn_default_thinking_mode),
+ )
runtime_config.exclude_tools_when_tool_choice_none = (
dynamo_args.exclude_tools_when_tool_choice_none
)
diff --git a/components/src/dynamo/trtllm/workers/llm_worker.py b/components/src/dynamo/trtllm/workers/llm_worker.py
index 6dd9445db061..96bfd8179f7d 100644
--- a/components/src/dynamo/trtllm/workers/llm_worker.py
+++ b/components/src/dynamo/trtllm/workers/llm_worker.py
@@ -653,6 +653,11 @@ async def init_llm_worker(
runtime_config.max_num_batched_tokens = engine_args["max_num_tokens"]
runtime_config.reasoning_parser = config.dyn_reasoning_parser
runtime_config.tool_call_parser = config.dyn_tool_call_parser
+ if config.dyn_default_thinking_mode is not None:
+ runtime_config.set_engine_specific(
+ "default_thinking_mode",
+ json.dumps(config.dyn_default_thinking_mode),
+ )
runtime_config.exclude_tools_when_tool_choice_none = (
config.exclude_tools_when_tool_choice_none
)
diff --git a/components/src/dynamo/vllm/main.py b/components/src/dynamo/vllm/main.py
index 4af763843b7e..016c1b8141af 100644
--- a/components/src/dynamo/vllm/main.py
+++ b/components/src/dynamo/vllm/main.py
@@ -700,6 +700,11 @@ async def register_vllm_model(
if worker_type != WorkerType.Prefill:
runtime_config.tool_call_parser = config.dyn_tool_call_parser
runtime_config.reasoning_parser = config.dyn_reasoning_parser
+ if config.dyn_default_thinking_mode is not None:
+ runtime_config.set_engine_specific(
+ "default_thinking_mode",
+ json.dumps(config.dyn_default_thinking_mode),
+ )
runtime_config.exclude_tools_when_tool_choice_none = (
config.exclude_tools_when_tool_choice_none
)
diff --git a/docs/fern/backends/sglang/sglang-reference-guide.md b/docs/fern/backends/sglang/sglang-reference-guide.md
index c00928c6383d..e3a666930865 100644
--- a/docs/fern/backends/sglang/sglang-reference-guide.md
+++ b/docs/fern/backends/sglang/sglang-reference-guide.md
@@ -38,6 +38,7 @@ These arguments are added by Dynamo on top of SGLang's native arguments. For the
| `--use-sglang-tokenizer` | `DYN_SGL_USE_TOKENIZER` | `false` | **[Deprecated]** Use `--dyn-chat-processor sglang` on the frontend instead. See [SGLang Chat Processor](sglang-chat-processor.md). |
| `--dyn-tool-call-parser` | `DYN_TOOL_CALL_PARSER` | `None` | [Tool call](../../tool-calling/README.mdx#supported-tool-call-parsers) parser (overrides SGLang's `--tool-call-parser`) |
| `--dyn-reasoning-parser` | `DYN_REASONING_PARSER` | `None` | [Reasoning](../../reasoning/README.md#supported-reasoning-parsers) parser for chain-of-thought models |
+| `--dyn-default-thinking-mode` | `DYN_DEFAULT_THINKING_MODE` | `None` | Deployment-level `enabled` or `disabled` [thinking default](../../reasoning/README.md#deployment-level-thinking-default); explicit request controls take precedence |
| `--custom-jinja-template` | `DYN_CUSTOM_JINJA_TEMPLATE` | `None` | Custom chat template path (incompatible with `--use-sglang-tokenizer`) |
| `--embedding-worker` | `DYN_SGL_EMBEDDING_WORKER` | `false` | Run as embedding worker (also sets SGLang's `--is-embedding`) |
| `--enable-multimodal` | `DYN_SGL_ENABLE_MULTIMODAL` | `false` | Allow [multimodal](../../features/multimodal/multimodal-sglang.md) inputs on this worker |
diff --git a/docs/fern/backends/trtllm/trtllm-reference-guide.md b/docs/fern/backends/trtllm/trtllm-reference-guide.md
index 636f1baef385..3bc9cd84c034 100644
--- a/docs/fern/backends/trtllm/trtllm-reference-guide.md
+++ b/docs/fern/backends/trtllm/trtllm-reference-guide.md
@@ -12,6 +12,14 @@ The TensorRT-LLM backend accepts Dynamo-specific arguments (model loading, paral
- [TensorRT-LLM Configuration](trtllm-config-reference.mdx) — the `DYN_TRTLLM_*` backend flags.
- [Runtime Configuration](../../reference/runtime-config-reference.mdx) — the `DYN_*` flags shared by every backend.
+## Default Thinking Mode
+
+To set the thinking mode used when a request omits an explicit control, pass
+`--dyn-default-thinking-mode enabled|disabled` to the TensorRT-LLM worker or
+set `DYN_DEFAULT_THINKING_MODE`. Request-level thinking controls, including
+adaptive thinking, take precedence. See
+[Deployment-Level Thinking Default](../../reasoning/README.md#deployment-level-thinking-default).
+
## Building a Custom Container
The Dynamo TensorRT-LLM image layers Dynamo on top of the upstream `nvcr.io/nvidia/tensorrt-llm/release` container — it does not build TensorRT-LLM from source. To rebuild it locally, pin a different upstream TRT-LLM tag, or plug in a TRT-LLM image you built from source, see the [Building a Custom Container](./trtllm-building-custom-container.md) guide.
diff --git a/docs/fern/backends/vllm/vllm-reference-guide.md b/docs/fern/backends/vllm/vllm-reference-guide.md
index 990b6fd4584f..7848c43718e8 100644
--- a/docs/fern/backends/vllm/vllm-reference-guide.md
+++ b/docs/fern/backends/vllm/vllm-reference-guide.md
@@ -29,6 +29,12 @@ The `--help` output is organized into the following groups:
Use `--dyn-tool-call-parser` and `--dyn-reasoning-parser` to match the model's output format when the model emits tool calls and/or reasoning content. The current supported values are documented in [Tool Call Parsing (Dynamo)](../../tool-calling/README.mdx#supported-tool-call-parsers) and [Reasoning Parsing (Dynamo)](../../reasoning/README.md#supported-reasoning-parsers).
+To set the thinking mode used when a request omits an explicit control, pass
+`--dyn-default-thinking-mode enabled|disabled` or set
+`DYN_DEFAULT_THINKING_MODE`. Request-level thinking controls, including
+adaptive thinking, take precedence. See
+[Deployment-Level Thinking Default](../../reasoning/README.md#deployment-level-thinking-default).
+
For reasoning models with structured output (`response_format`, JSON schema,
or required/named tool choice), configure both reasoning parsers on the worker:
diff --git a/docs/fern/reasoning/README.md b/docs/fern/reasoning/README.md
index aa108b773eb6..3a1bc293b34f 100644
--- a/docs/fern/reasoning/README.md
+++ b/docs/fern/reasoning/README.md
@@ -106,6 +106,66 @@ Some models emit reasoning separately from their final response. Dynamo can spli
+## Deployment-Level Thinking Default
+
+Set `--dyn-default-thinking-mode` on a backend worker to choose the thinking
+mode used when a request does not provide one. The worker publishes the value
+through model runtime metadata, and the frontend applies it while rendering the
+chat template. The option works with vLLM, SGLang, and TensorRT-LLM workers.
+
+| CLI argument | Environment variable | Values | Default |
+|---|---|---|---|
+| `--dyn-default-thinking-mode` | `DYN_DEFAULT_THINKING_MODE` | `enabled`, `disabled` | Unset |
+
+For example, make thinking disabled by default for a Qwen3 deployment:
+
+```bash
+python -m dynamo.vllm \
+ --model Qwen/Qwen3-0.6B \
+ --dyn-default-thinking-mode disabled
+```
+
+You can configure the same value through the environment:
+
+```bash
+DYN_DEFAULT_THINKING_MODE=disabled \
+ python -m dynamo.vllm --model Qwen/Qwen3-0.6B
+```
+
+Use the same setting on every worker that serves the model. When the option is
+unset, Dynamo preserves the model or chat template's native default.
+
+Thinking controls use this precedence order:
+
+1. An explicit request control
+2. `--dyn-default-thinking-mode` or `DYN_DEFAULT_THINKING_MODE`
+3. The model or chat template's native default
+
+Explicit request controls include root-level `thinking`, top-level
+`reasoning_effort`, and the `thinking`, `enable_thinking`, `thinking_mode`, or
+`reasoning_effort` fields in `chat_template_args` or
+`chat_template_kwargs`.
+
+The deployment option intentionally accepts only `enabled` and `disabled`.
+Models that support adaptive thinking can still select it per request:
+
+```json
+{
+ "model": "MiniMaxAI/MiniMax-M3",
+ "messages": [{"role": "user", "content": "Explain this result."}],
+ "thinking": {"type": "adaptive"}
+}
+```
+
+Dynamo normalizes this request to `thinking_mode=adaptive`. Because adaptive is
+an explicit request control, it takes precedence over an `enabled` or
+`disabled` deployment default.
+
+> [!NOTE]
+> This option controls chat-template rendering. It does not force the inference
+> engine or model to follow the requested mode. Models that ignore their
+> template's thinking control may still emit reasoning.
+
## Supported Reasoning Parsers
Choose a model family, then expand the matching model option to see its parser name and configuration details.
diff --git a/docs/fern/reference/runtime-config-reference.mdx b/docs/fern/reference/runtime-config-reference.mdx
index f409a0eb19f1..e9c406916e06 100644
--- a/docs/fern/reference/runtime-config-reference.mdx
+++ b/docs/fern/reference/runtime-config-reference.mdx
@@ -140,6 +140,14 @@ Unless a field is marked environment-only, it has both a CLI flag and an environ
Environment variable: `DYN_REASONING_PARSER`
+
+ Thinking mode to apply when a request omits an explicit thinking control. Request-level `thinking`, `reasoning_effort`, and controls in `chat_template_args` or `chat_template_kwargs` take precedence. When unset, Dynamo preserves the model or chat template's native default. See [Deployment-Level Thinking Default](../reasoning/README.md#deployment-level-thinking-default) for examples and precedence details.
+
+ Allowed values: enabled disabled
+
+ Environment variable: `DYN_DEFAULT_THINKING_MODE`
+
+
Exclude tool definitions from the chat template when `tool_choice='none'`. Prevents models from generating unsolicited raw XML tool calls in the content field. This flag controls the Rust-native chat template path; a matching flag in `FrontendArgGroup` controls the Python processor side independently.
diff --git a/lib/backend-common/src/worker.rs b/lib/backend-common/src/worker.rs
index 81e358e560f4..82403f7e219f 100644
--- a/lib/backend-common/src/worker.rs
+++ b/lib/backend-common/src/worker.rs
@@ -191,6 +191,8 @@ pub struct WorkerConfig {
/// model deployment card.
pub media_decoder: Option,
pub media_fetcher: Option,
+ /// Deployment-level default thinking mode written to runtime metadata.
+ pub default_thinking_mode: Option,
}
impl WorkerConfig {
@@ -231,6 +233,7 @@ impl Default for WorkerConfig {
route_to_encoder: false,
media_decoder: None,
media_fetcher: None,
+ default_thinking_mode: None,
}
}
}
@@ -1649,6 +1652,14 @@ async fn build_local_model(
_ => None,
};
+ let mut runtime_data = engine_config.runtime_data.clone();
+ if let Some(default_thinking_mode) = config.default_thinking_mode.as_deref() {
+ runtime_data.insert(
+ "default_thinking_mode".to_string(),
+ serde_json::json!(default_thinking_mode),
+ );
+ }
+
let rt_cfg = ModelRuntimeConfig {
context_length: llm.context_length,
total_kv_blocks: llm.total_kv_blocks,
@@ -1665,7 +1676,7 @@ async fn build_local_model(
enable_local_indexer,
kv_state_endpoint: config.kv_state_endpoint.clone(),
disaggregated_endpoint,
- runtime_data: engine_config.runtime_data.clone(),
+ runtime_data,
..ModelRuntimeConfig::default()
};
@@ -1932,6 +1943,7 @@ mod tests {
let config = WorkerConfig {
tool_call_parser: Some("kimi_k2".to_string()),
reasoning_parser: Some("kimi_k25".to_string()),
+ default_thinking_mode: Some("disabled".to_string()),
exclude_tools_when_tool_choice_none: false,
enable_local_indexer: false,
kv_state_endpoint: Some(EndpointId::from("dynamo/kv-state/events")),
@@ -1965,6 +1977,13 @@ mod tests {
assert_eq!(runtime_config.max_num_batched_tokens, Some(8192));
assert_eq!(runtime_config.tool_call_parser.as_deref(), Some("kimi_k2"));
assert_eq!(runtime_config.reasoning_parser.as_deref(), Some("kimi_k25"));
+ assert_eq!(
+ runtime_config
+ .runtime_data
+ .get("default_thinking_mode")
+ .and_then(|value| value.as_str()),
+ Some("disabled")
+ );
assert!(!runtime_config.exclude_tools_when_tool_choice_none);
assert!(!runtime_config.enable_local_indexer);
assert_eq!(
diff --git a/lib/bindings/python/rust/backend.rs b/lib/bindings/python/rust/backend.rs
index e6df7e314079..d691238d9db5 100644
--- a/lib/bindings/python/rust/backend.rs
+++ b/lib/bindings/python/rust/backend.rs
@@ -349,6 +349,7 @@ impl WorkerConfig {
media_decoder = None,
media_fetcher = None,
kv_state_endpoint = None,
+ default_thinking_mode = None,
))]
#[allow(clippy::too_many_arguments)]
fn new(
@@ -377,6 +378,7 @@ impl WorkerConfig {
media_decoder: Option,
media_fetcher: Option,
kv_state_endpoint: Option,
+ default_thinking_mode: Option,
) -> PyResult {
// Delegating to the same conversion used by `register_model`.
let model_input_rs = match model_input {
@@ -446,6 +448,7 @@ impl WorkerConfig {
custom_jinja_template: custom_jinja_template.map(PathBuf::from),
tool_call_parser,
reasoning_parser,
+ default_thinking_mode,
exclude_tools_when_tool_choice_none,
enable_local_indexer,
enable_kv_routing,
diff --git a/lib/bindings/python/src/dynamo/_core.pyi b/lib/bindings/python/src/dynamo/_core.pyi
index 19023592dcb3..61b33a21d3d8 100644
--- a/lib/bindings/python/src/dynamo/_core.pyi
+++ b/lib/bindings/python/src/dynamo/_core.pyi
@@ -3446,6 +3446,7 @@ class backend:
media_decoder: Optional[MediaDecoder] = None,
media_fetcher: Optional[MediaFetcher] = None,
kv_state_endpoint: Optional[str] = None,
+ default_thinking_mode: Optional[str] = None,
) -> None: ...
class Worker:
diff --git a/lib/llm/src/preprocessor.rs b/lib/llm/src/preprocessor.rs
index f878d2b07708..72a9f88d2262 100644
--- a/lib/llm/src/preprocessor.rs
+++ b/lib/llm/src/preprocessor.rs
@@ -228,6 +228,7 @@ fn encode_floats_to_base64(floats: &[f32]) -> String {
pub const ANNOTATION_FORMATTED_PROMPT: &str = "formatted_prompt";
pub const ANNOTATION_TOKEN_IDS: &str = "token_ids";
+const DEFAULT_THINKING_MODE_RUNTIME_KEY: &str = "default_thinking_mode";
/// Drain a standalone router's forwarded `routing_data` onto this request's tracker so the
/// frontend's timing/worker/token surfaces populate, then drop the field to keep it off the
@@ -582,6 +583,67 @@ impl OpenAIPreprocessor {
}
}
+ fn has_request_thinking_control(
+ chat_template_args: Option<&HashMap>,
+ ) -> bool {
+ chat_template_args.is_some_and(|args| {
+ [
+ "thinking",
+ "enable_thinking",
+ "thinking_mode",
+ "reasoning_effort",
+ ]
+ .iter()
+ .any(|key| args.contains_key(*key))
+ })
+ }
+
+ fn apply_default_thinking_mode_from_runtime_config(
+ runtime_config: &crate::local_model::runtime_config::ModelRuntimeConfig,
+ request: &mut NvCreateChatCompletionRequest,
+ ) {
+ if request.thinking.is_some()
+ || Self::has_request_thinking_control(request.chat_template_args.as_ref())
+ {
+ return;
+ }
+
+ let Some(default_mode) = runtime_config
+ .runtime_data
+ .get(DEFAULT_THINKING_MODE_RUNTIME_KEY)
+ .and_then(|value| value.as_str())
+ else {
+ return;
+ };
+
+ let enabled = match default_mode {
+ "enabled" => true,
+ "disabled" => false,
+ other => {
+ tracing::warn!(
+ default_thinking_mode = other,
+ "Ignoring invalid runtime_config default_thinking_mode; expected 'enabled' or 'disabled'"
+ );
+ return;
+ }
+ };
+
+ let args = request.chat_template_args.get_or_insert_with(HashMap::new);
+ args.insert("thinking".to_string(), serde_json::Value::Bool(enabled));
+ args.insert(
+ "enable_thinking".to_string(),
+ serde_json::Value::Bool(enabled),
+ );
+ args.insert(
+ "thinking_mode".to_string(),
+ serde_json::Value::String(if enabled { "enabled" } else { "disabled" }.to_string()),
+ );
+ }
+
+ fn apply_default_thinking_mode(&self, request: &mut NvCreateChatCompletionRequest) {
+ Self::apply_default_thinking_mode_from_runtime_config(&self.runtime_config, request);
+ }
+
fn guided_output_requires_reasoning(
request: &R,
reasoning_parser: Option<&str>,
@@ -3630,6 +3692,10 @@ impl
// Set stream=true for internal processing (after request payload capture)
request.inner.stream = Some(true);
+ // Apply the deployment default before parser-specific normalization so
+ // it can override an implicit model default (for example Kimi K2.5),
+ // while explicit request controls still take precedence.
+ self.apply_default_thinking_mode(&mut request);
Self::normalize_thinking_arg(
&mut request,
self.runtime_config.reasoning_parser.as_deref(),
@@ -4583,6 +4649,132 @@ mod tests {
);
}
+ fn chat_request_with_args(
+ chat_template_args: Option>,
+ ) -> NvCreateChatCompletionRequest {
+ let mut request: NvCreateChatCompletionRequest =
+ serde_json::from_value(serde_json::json!({
+ "model": "test-model",
+ "messages": [{"role": "user", "content": "hi"}]
+ }))
+ .unwrap();
+ request.chat_template_args = chat_template_args;
+ request
+ }
+
+ fn runtime_config_with_default_thinking_mode(
+ mode: &str,
+ ) -> crate::local_model::runtime_config::ModelRuntimeConfig {
+ let mut runtime_config = crate::local_model::runtime_config::ModelRuntimeConfig::new();
+ runtime_config
+ .set_engine_specific(DEFAULT_THINKING_MODE_RUNTIME_KEY, mode)
+ .unwrap();
+ runtime_config
+ }
+
+ #[test]
+ fn test_default_thinking_mode_disabled_adds_template_args() {
+ let runtime_config = runtime_config_with_default_thinking_mode("disabled");
+ let mut request = chat_request_with_args(None);
+
+ OpenAIPreprocessor::apply_default_thinking_mode_from_runtime_config(
+ &runtime_config,
+ &mut request,
+ );
+
+ let args = request.chat_template_args.as_ref().unwrap();
+ assert_eq!(args.get("thinking"), Some(&serde_json::json!(false)));
+ assert_eq!(args.get("enable_thinking"), Some(&serde_json::json!(false)));
+ assert_eq!(
+ args.get("thinking_mode"),
+ Some(&serde_json::json!("disabled"))
+ );
+ }
+
+ #[test]
+ fn test_default_thinking_mode_enabled_adds_template_args() {
+ let runtime_config = runtime_config_with_default_thinking_mode("enabled");
+ let mut request = chat_request_with_args(None);
+
+ OpenAIPreprocessor::apply_default_thinking_mode_from_runtime_config(
+ &runtime_config,
+ &mut request,
+ );
+
+ let args = request.chat_template_args.as_ref().unwrap();
+ assert_eq!(args.get("thinking"), Some(&serde_json::json!(true)));
+ assert_eq!(args.get("enable_thinking"), Some(&serde_json::json!(true)));
+ assert_eq!(
+ args.get("thinking_mode"),
+ Some(&serde_json::json!("enabled"))
+ );
+ }
+
+ #[test]
+ fn test_default_thinking_mode_does_not_override_request() {
+ let runtime_config = runtime_config_with_default_thinking_mode("disabled");
+ let mut request = chat_request_with_args(Some(HashMap::from([(
+ "thinking_mode".to_string(),
+ serde_json::json!("enabled"),
+ )])));
+
+ OpenAIPreprocessor::apply_default_thinking_mode_from_runtime_config(
+ &runtime_config,
+ &mut request,
+ );
+
+ let args = request.chat_template_args.as_ref().unwrap();
+ assert_eq!(
+ args.get("thinking_mode"),
+ Some(&serde_json::json!("enabled"))
+ );
+ assert!(!args.contains_key("thinking"));
+ assert!(!args.contains_key("enable_thinking"));
+ }
+
+ #[test]
+ fn test_default_thinking_mode_precedes_parser_implicit_default() {
+ let runtime_config = runtime_config_with_default_thinking_mode("disabled");
+ let mut request = chat_request_with_args(None);
+
+ OpenAIPreprocessor::apply_default_thinking_mode_from_runtime_config(
+ &runtime_config,
+ &mut request,
+ );
+ OpenAIPreprocessor::normalize_thinking_arg(&mut request, Some("kimi_k25"));
+
+ let args = request.chat_template_args.as_ref().unwrap();
+ assert_eq!(args.get("thinking"), Some(&serde_json::json!(false)));
+ assert_eq!(args.get("enable_thinking"), Some(&serde_json::json!(false)));
+ assert_eq!(
+ args.get("thinking_mode"),
+ Some(&serde_json::json!("disabled"))
+ );
+ }
+
+ #[test]
+ fn test_default_thinking_mode_does_not_override_reasoning_effort() {
+ let runtime_config = runtime_config_with_default_thinking_mode("disabled");
+ let mut request = chat_request_with_args(Some(HashMap::from([(
+ "reasoning_effort".to_string(),
+ serde_json::json!("high"),
+ )])));
+
+ OpenAIPreprocessor::apply_default_thinking_mode_from_runtime_config(
+ &runtime_config,
+ &mut request,
+ );
+
+ let args = request.chat_template_args.as_ref().unwrap();
+ assert_eq!(
+ args.get("reasoning_effort"),
+ Some(&serde_json::json!("high"))
+ );
+ assert!(!args.contains_key("thinking"));
+ assert!(!args.contains_key("enable_thinking"));
+ assert!(!args.contains_key("thinking_mode"));
+ }
+
/// Verifies template reasoning controls are forwarded to a configured parser.
#[test]
fn test_backend_extra_args_forwards_reasoning_template_args() {
diff --git a/lib/llm/src/protocols/openai/chat_completions.rs b/lib/llm/src/protocols/openai/chat_completions.rs
index aab5e5307e3e..e9392eeea8af 100644
--- a/lib/llm/src/protocols/openai/chat_completions.rs
+++ b/lib/llm/src/protocols/openai/chat_completions.rs
@@ -152,6 +152,7 @@ impl NvCreateChatCompletionRequest {
match mode {
OpenAiThinkingMode::Enabled => {
args.insert("thinking".to_string(), serde_json::Value::Bool(true));
+ args.insert("enable_thinking".to_string(), serde_json::Value::Bool(true));
args.insert(
"thinking_mode".to_string(),
serde_json::Value::String("enabled".to_string()),
@@ -159,6 +160,10 @@ impl NvCreateChatCompletionRequest {
}
OpenAiThinkingMode::Disabled => {
args.insert("thinking".to_string(), serde_json::Value::Bool(false));
+ args.insert(
+ "enable_thinking".to_string(),
+ serde_json::Value::Bool(false),
+ );
args.insert(
"thinking_mode".to_string(),
serde_json::Value::String("disabled".to_string()),
@@ -1189,6 +1194,7 @@ mod tests {
.as_ref()
.expect("chat_template_args should be populated");
assert_eq!(args.get("thinking"), Some(&json!(true)));
+ assert_eq!(args.get("enable_thinking"), Some(&json!(true)));
assert_eq!(args.get("thinking_mode"), Some(&json!("enabled")));
assert_eq!(args.get("reasoning_effort"), Some(&json!("max")));
}
@@ -1239,6 +1245,7 @@ mod tests {
.as_ref()
.expect("chat_template_args should be populated");
assert_eq!(args.get("thinking"), Some(&json!(false)));
+ assert_eq!(args.get("enable_thinking"), Some(&json!(false)));
assert_eq!(args.get("thinking_mode"), Some(&json!("disabled")));
}
@@ -1269,6 +1276,7 @@ mod tests {
.as_ref()
.expect("chat_template_args should be populated");
assert_eq!(args.get("thinking"), Some(&json!(false)));
+ assert_eq!(args.get("enable_thinking"), Some(&json!(false)));
assert_eq!(args.get("thinking_mode"), Some(&json!("disabled")));
assert_eq!(args.get("reasoning_effort"), Some(&json!("none")));
assert!(request.thinking.is_none());