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());