diff --git a/Cargo.lock b/Cargo.lock index 5415fe434db2..12ec6433df40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2875,9 +2875,9 @@ dependencies = [ [[package]] name = "dynamo-parsers" -version = "9.2.6" +version = "9.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedc2cc2376c35d3bc998f6ec326271703cd19a61ef18ad7e1d21dbde3eeb638" +checksum = "a751a31502748c656f69bd1fab01f1d844b6bb629b69e9fbed0b0da9af916b7a" dependencies = [ "aho-corasick", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 6aa0355874f6..da1fc1ab0927 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,7 @@ dynamo-memory = { path = "lib/memory", version = "1.6.0" } dynamo-mocker = { path = "lib/mocker", version = "1.6.0" } dynamo-kv-router = { path = "lib/kv-router", version = "1.6.0" } dynamo-protocols = { version = "=6.1.0" } -dynamo-parsers = { version = "9.2.6" } +dynamo-parsers = { version = "=9.2.7" } # Published on crates.io. To see all available versions: # web: https://crates.io/crates/dynamo-parsers-v2/versions diff --git a/components/src/dynamo/common/configuration/groups/runtime_args.py b/components/src/dynamo/common/configuration/groups/runtime_args.py index 55911146bafe..30970ef35986 100644 --- a/components/src/dynamo/common/configuration/groups/runtime_args.py +++ b/components/src/dynamo/common/configuration/groups/runtime_args.py @@ -40,8 +40,8 @@ class DynamoRuntimeConfig(ConfigBase): 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" + dyn_enable_structural_tag: bool = True + dyn_structural_tag_scope: str = "always" dyn_structural_tag_schema: str = "auto" custom_jinja_template: Optional[str] = None endpoint_types: str @@ -293,16 +293,18 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: g, flag_name="--dyn-enable-structural-tag", env_var="DYN_ENABLE_STRUCTURAL_TAG", - default=False, - help="Enable structural tag guided decoding for tool calls. " - "Named Kimi K3 tool_choice requests always activate their required " - "XTML structural tag even when this flag is off.", + default=True, + help="Enable structural tag guided decoding for tool calls when the configured " + "parser and backend support it. Disabling this flag suppresses optional " + "guidance; Rust retains native tags for Kimi K2 required/named and " + "Kimi K3 named tool choices. Python vLLM and SGLang respect the opt-out. " + "Unsupported schema constructs can cause backend grammar compilation errors.", ) add_argument( g, flag_name="--dyn-structural-tag-scope", env_var="DYN_STRUCTURAL_TAG_SCOPE", - default="auto", + default="always", choices=["auto", "always"], help="Controls when structural tags are activated. " "'auto': for required/named tool_choice, and if any tool has strict=true " @@ -317,9 +319,10 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: default="auto", choices=["auto", "strict"], help="Controls parameter schema strictness inside structural tags. " - "'auto': real schema unless the tool explicitly sets strict=false; " - "strict=false tools are syntactically constrained but schema-unconstrained. " - "'strict': real parameter schema for all tools.", + "'auto': declared parameter schema for tools with strict omitted or true; " + "tools with strict=false keep the native tool envelope constrained but use " + "schema-unconstrained arguments. " + "'strict': declared parameter schema for all tools, overriding strict=false.", ) add_argument( g, 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 5ee112ab9d54..d1a011815c35 100644 --- a/components/src/dynamo/common/tests/configuration/test_runtime_args.py +++ b/components/src/dynamo/common/tests/configuration/test_runtime_args.py @@ -31,6 +31,35 @@ def _parse_runtime_args(argv: list[str]) -> tuple[DynamoRuntimeConfig, str]: return config, parser.format_help() +def test_structural_tags_default_on_for_supported_parsers(monkeypatch): + monkeypatch.delenv("DYN_ENABLE_STRUCTURAL_TAG", raising=False) + monkeypatch.delenv("DYN_STRUCTURAL_TAG_SCOPE", raising=False) + monkeypatch.delenv("DYN_STRUCTURAL_TAG_SCHEMA", raising=False) + + config, _ = _parse_runtime_args([]) + + assert config.dyn_enable_structural_tag is True + assert config.dyn_structural_tag_scope == "always" + assert config.dyn_structural_tag_schema == "auto" + + +def test_structural_tag_schema_help_describes_omitted_strict(monkeypatch): + monkeypatch.delenv("DYN_STRUCTURAL_TAG_SCHEMA", raising=False) + + _, help_text = _parse_runtime_args([]) + + assert "strict omitted or true" in help_text + assert "overriding strict=false" in help_text + + +def test_structural_tag_global_opt_out(monkeypatch): + monkeypatch.setenv("DYN_ENABLE_STRUCTURAL_TAG", "false") + + config, _ = _parse_runtime_args([]) + + assert config.dyn_enable_structural_tag is False + + def test_fpm_trace_defaults_disabled(monkeypatch): monkeypatch.delenv("DYN_FPM_TRACE", raising=False) diff --git a/components/src/dynamo/frontend/prepost.py b/components/src/dynamo/frontend/prepost.py index f7d4010e47a4..83803e97eaa3 100644 --- a/components/src/dynamo/frontend/prepost.py +++ b/components/src/dynamo/frontend/prepost.py @@ -32,9 +32,22 @@ from dynamo.frontend.vllm_protocol import DeltaFunctionCall, DeltaMessage, DeltaToolCall from dynamo.llm.exceptions import InvalidArgument +from .structural_tag_policy import ( + ToolChoiceKind, + effective_tool_strict, + should_attempt_structural_tag, +) from .thinking import apply_default_thinking_mode_to_template_kwargs from .utils import legacy_guided_decoding +get_model_structural_tag: Any +try: + from vllm.tool_parsers import structural_tag_registry +except ImportError: # Older supported vLLM releases do not expose this registry. + get_model_structural_tag = None +else: + get_model_structural_tag = structural_tag_registry.get_model_structural_tag + if TYPE_CHECKING: from vllm.config import ModelConfig @@ -204,21 +217,32 @@ def _should_build_tool_call_guidance( # enforced guarantee. sglang_prepost.py has the same gap. if tool_choice == "none": return False - if _is_forced_tool_choice(tool_choice): - return True - if structural_tag_mode != "on": - return False - if tool_choice != "auto": - return False - if structural_tag_scope == "always": - return True # An explicit single-call request is enough to attempt structural-tag # guidance. Forced-choice JSON guidance also bounds its array schema below. explicit_single_call = ( "parallel_tool_calls" in request.model_fields_set and request.parallel_tool_calls is False ) - return explicit_single_call or any(_tool_is_strict(tool) for tool in request.tools) + tool_choice_kind: ToolChoiceKind = ( + "required" + if tool_choice == "required" + else "named" + if _is_named_tool_choice(tool_choice) + else "auto" + if tool_choice == "auto" + else "other" + ) + attempt_structural_tag = should_attempt_structural_tag( + mode=structural_tag_mode, + scope=structural_tag_scope, + tool_choice_kind=tool_choice_kind, + has_tools=True, + any_explicit_strict=any(_tool_is_strict(tool) for tool in request.tools), + parallel_tool_calls_explicitly_false=explicit_single_call, + ) + # Forced choices retain the generic JSON fallback when structural tags are + # disabled or unavailable. + return attempt_structural_tag or _is_forced_tool_choice(tool_choice) def _request_for_vllm_structural_tag( @@ -226,13 +250,14 @@ def _request_for_vllm_structural_tag( *, structural_tag_schema: str, ) -> ChatCompletionRequest: - strict_schema = structural_tag_schema == "strict" tools = [ tool.model_copy( update={ "function": tool.function.model_copy( update={ - "strict": True if strict_schema else tool.function.strict, + "strict": effective_tool_strict( + tool.function.strict, structural_tag_schema + ), } ) } @@ -264,7 +289,39 @@ def build_tool_call_guided_decoding( request, structural_tag_schema=structural_tag_schema, ) - structural_tag = tool_parser.get_structural_tag(request_for_tag) + try: + structural_tag_model = getattr(tool_parser, "structural_tag_model", None) + structural_tag_kwargs = {} + if structural_tag_model == "hy_v4" and get_model_structural_tag is not None: + # HYV4's parser detects checkpoint-specific tokens at construction. + # Keep its suffix while Dynamo owns activation of the registry path. + structural_tag_kwargs["token_suffix"] = getattr( + tool_parser, "_extractor" + ).token_suffix + structural_tag = ( + get_model_structural_tag( + model=structural_tag_model, + tools=request_for_tag.tools, + tool_choice=request_for_tag.tool_choice, + reasoning=False, + **structural_tag_kwargs, + ) + if structural_tag_model is not None + and get_model_structural_tag is not None + else tool_parser.get_structural_tag(request_for_tag) + ) + except ( + AttributeError, + KeyError, + NotImplementedError, + RuntimeError, + TypeError, + ValueError, + ): + # Parser implementations are third-party capability providers. A + # failed structural-tag build must preserve the pre-existing JSON + # or unconstrained compatibility path for the request. + structural_tag = None if structural_tag is not None: tag_value = ( structural_tag.model_dump() diff --git a/components/src/dynamo/frontend/sglang_prepost.py b/components/src/dynamo/frontend/sglang_prepost.py index b34e8d6f609a..2c295da24704 100644 --- a/components/src/dynamo/frontend/sglang_prepost.py +++ b/components/src/dynamo/frontend/sglang_prepost.py @@ -33,6 +33,11 @@ from dynamo.common.utils.guided_json import admits_only_empty_object from dynamo.llm.exceptions import InvalidArgument +from .structural_tag_policy import ( + ToolChoiceKind, + effective_tool_strict, + should_attempt_structural_tag, +) from .thinking import apply_default_thinking_mode_to_template_kwargs from .utils import PreprocessError, legacy_guided_decoding, random_call_id @@ -204,13 +209,24 @@ def _client_wants_separate_reasoning(request: dict[str, Any]) -> bool: return bool(value) -def convert_tools(tools: list[dict[str, Any]] | None) -> list[SglangTool] | None: - """Convert OpenAI tool dicts to SGLang Tool objects.""" +def convert_tools( + tools: list[dict[str, Any]] | None, + *, + structural_tag_schema: str | None = None, +) -> list[SglangTool] | None: + """Convert OpenAI tool dicts to SGLang Tool objects. + + ``structural_tag_schema`` is set only for the guidance copy. Prompt and + parser copies retain SGLang's existing omitted-strict representation. + """ if not tools: return None sglang_tools = [] for tool in tools: func = tool.get("function", {}) + strict = func.get("strict", False) + if structural_tag_schema is not None: + strict = effective_tool_strict(func.get("strict"), structural_tag_schema) sglang_tools.append( SglangTool( type=tool.get("type", "function"), @@ -218,7 +234,7 @@ def convert_tools(tools: list[dict[str, Any]] | None) -> list[SglangTool] | None name=func.get("name", ""), description=func.get("description"), parameters=func.get("parameters"), - strict=func.get("strict", False), + strict=strict, ), ) ) @@ -285,6 +301,7 @@ def create_parsers( reasoning_parser_name: str | None, sglang_tools: list[SglangTool] | None = None, force_reasoning: bool = False, + guided_decoding: dict[str, Any] | None = None, tokenizer: Any | None = None, ) -> tuple[ToolCallParserType | None, ReasoningParser | None]: """Create tool call and reasoning parsers for a request. @@ -295,10 +312,9 @@ def create_parsers( If ``sglang_tools`` is provided, reuses them; otherwise converts from the request's ``tools`` field. - For ``tool_choice="required"`` or a named function, uses - :class:`JsonArrayParser` (matching native SGLang) since guided decoding - constrains the output to a JSON array. Otherwise uses the model-specific - :class:`FunctionCallParser`. + Required and named choices use the model-specific :class:`FunctionCallParser` + when the effective guidance is a structural tag. Their JSON fallback keeps + :class:`JsonArrayParser`. Automatic choices use :class:`FunctionCallParser`. """ if sglang_tools is None: sglang_tools = convert_tools(request.get("tools")) @@ -306,7 +322,9 @@ def create_parsers( tool_call_parser: ToolCallParserType | None = None if sglang_tools and tool_choice != "none": - if tool_choice == "required" or _is_named_tool_choice(tool_choice): + if (tool_choice == "required" or _is_named_tool_choice(tool_choice)) and not ( + guided_decoding is not None and "structural_tag" in guided_decoding + ): tool_call_parser = JsonArrayParser() elif tool_call_parser_name: tool_call_parser_name = _normalize_sglang_parser_name(tool_call_parser_name) @@ -587,6 +605,29 @@ def _call_with_optional_parallel_tool_calls( return func(*args) +def _call_structure_constraint( + func: Any, + *args: Any, + parallel_tool_calls: Any, + thinking_mode: bool, +) -> Any: + """Call SGLang parser APIs across supported signature versions.""" + kwargs: dict[str, Any] = {} + if _callable_accepts_kwarg(func, "parallel_tool_calls"): + kwargs["parallel_tool_calls"] = parallel_tool_calls + if _callable_accepts_kwarg(func, "thinking_mode"): + kwargs["thinking_mode"] = thinking_mode + return func(*args, **kwargs) + + +def _enforce_function_strict_level(parser: FunctionCallParser) -> None: + """Keep SGLang's native tool envelope active for policy-selected requests.""" + current_level = getattr(parser, "tool_strict_level", None) + function_level = getattr(type(current_level), "FUNCTION", None) + if function_level is not None and current_level < function_level: + parser.tool_strict_level = function_level + + def _parser_tokenizer_kwargs(parser: Any, tokenizer: Any) -> dict[str, Any]: if tokenizer is not None and _callable_accepts_kwarg(parser, "tokenizer"): return {"tokenizer": tokenizer} @@ -599,6 +640,9 @@ def build_tool_call_guided_decoding( tool_call_parser_name: str | None, tokenizer: Any | None = None, sglang_tools: list[SglangTool] | None, + structural_tag_mode: str = "off", + structural_tag_scope: str = "auto", + structural_tag_schema: str = "auto", ) -> dict[str, Any] | None: """Build native-SGLang-like tool call constraints for guided decoding.""" if not sglang_tools: @@ -615,7 +659,88 @@ def build_tool_call_guided_decoding( parallel_tool_calls = True constraint: Any = None - if tool_choice == "required" or _is_named_tool_choice(tool_choice): + is_named_choice = _is_named_tool_choice(tool_choice) + is_forced_choice = tool_choice == "required" or is_named_choice + sglang_tool_choice: Any = tool_choice + if is_named_choice: + sglang_tool_choice = SglangToolChoice( + type="function", + function=SglangToolChoiceFuncName( + name=tool_choice["function"]["name"], + ), + ) + tool_choice_kind: ToolChoiceKind = ( + "required" + if tool_choice == "required" + else "named" + if is_named_choice + else "auto" + if tool_choice == "auto" + else "other" + ) + raw_tools = request.get("tools") or [] + attempt_structural_tag = should_attempt_structural_tag( + mode=structural_tag_mode, + scope=structural_tag_scope, + tool_choice_kind=tool_choice_kind, + has_tools=bool(raw_tools), + any_explicit_strict=any( + tool.get("function", {}).get("strict") is True for tool in raw_tools + ), + parallel_tool_calls_explicitly_false=( + "parallel_tool_calls" in request and parallel_tool_calls is False + ), + ) + + if attempt_structural_tag and tool_call_parser_name: + guidance_tools = convert_tools( + raw_tools, + structural_tag_schema=structural_tag_schema, + ) + if is_named_choice: + # SGLang's legacy tag builder ignores the name in tool_choice. + guidance_tools = [ + tool + for tool in guidance_tools or [] + if tool.function.name == sglang_tool_choice.function.name + ] + tool_call_parser_name = _normalize_sglang_parser_name(tool_call_parser_name) + try: + parser = FunctionCallParser( + tools=guidance_tools, + tool_call_parser=tool_call_parser_name, + **_parser_tokenizer_kwargs(FunctionCallParser, tokenizer), + ) + # SGLang's AUTO level returns no structural tag when every tool is + # strict:false. Dynamo's policy still requires the function envelope; + # per-tool strict flags continue to control argument enforcement. + _enforce_function_strict_level(parser) + constraint = _call_structure_constraint( + parser.get_structure_constraint, + sglang_tool_choice, + parallel_tool_calls=parallel_tool_calls, + # The backend's reasoning gate consumes the reasoning prefix; + # this grammar starts with the tool payload after that boundary. + thinking_mode=False, + ) + except ( + AttributeError, + KeyError, + NotImplementedError, + RuntimeError, + TypeError, + ValueError, + ): + # This is a third-party capability probe. Unsupported parser/schema + # combinations retain the existing forced-choice JSON fallback or, + # for auto, unconstrained generation. + constraint = None + + guided_decoding = _serialize_tool_constraint(constraint) + if guided_decoding is not None: + return guided_decoding + + if is_forced_choice: if named_closed_zero_arg_tool(request) is not None: return {"regex": r"\{\}"} @@ -623,14 +748,6 @@ def build_tool_call_guided_decoding( # ToolChoice) for the named-function case — passing our raw dict # would silently fall through and return None, disabling guided # decoding and letting the model omit required fields. - sglang_tool_choice: Any = tool_choice - if _is_named_tool_choice(tool_choice): - sglang_tool_choice = SglangToolChoice( - type="function", - function=SglangToolChoiceFuncName( - name=tool_choice["function"]["name"], - ), - ) constraint = ( "json_schema", _call_with_optional_parallel_tool_calls( @@ -640,43 +757,24 @@ def build_tool_call_guided_decoding( parallel_tool_calls=parallel_tool_calls, ), ) - # TODO: this applies a structural-tag constraint for tool_choice="auto" - # whenever a tool-call parser is configured, and reads NONE of - # structural_tag_mode / structural_tag_scope / structural_tag_schema. Those - # knobs are accepted on the CLI and published into the model card by - # sglang/register.py, so an operator setting the mode to "off" still gets a - # constraint on this path. prepost.py requires structural_tag_mode == "on" - # (_should_build_tool_call_guidance) and preprocessor/structural_tag.rs - # requires StructuralTagMode != Off, so at the default mode ("off") the same - # request is unconstrained on both of those paths and constrained here. - # Also unlike them, "auto" is never gated on scope/strict, so this behaves as - # scope="always" with no way to narrow it. - elif tool_call_parser_name: - tool_call_parser_name = _normalize_sglang_parser_name(tool_call_parser_name) - parser = FunctionCallParser( - tools=sglang_tools, - tool_call_parser=tool_call_parser_name, - **_parser_tokenizer_kwargs(FunctionCallParser, tokenizer), - ) - constraint = _call_with_optional_parallel_tool_calls( - parser.get_structure_constraint, - tool_choice, - parallel_tool_calls=parallel_tool_calls, - ) + return _serialize_tool_constraint(constraint) - if isinstance(constraint, tuple) and len(constraint) == 2: - if constraint[0] == "json_schema": - return {"json": constraint[1]} - if constraint[0] == "structural_tag": - tag_value = constraint[1] - # SGLang returns a Pydantic model (LegacyStructuralTagResponseFormat) - # here. Convert to a plain dict before it hits the RPC layer — - # msgpack/serde_json cannot serialize BaseModel instances. - if hasattr(tag_value, "model_dump"): - tag_value = tag_value.model_dump() - return {"structural_tag": tag_value} - return None +def _serialize_tool_constraint(constraint: Any) -> dict[str, Any] | None: + if not (isinstance(constraint, tuple) and len(constraint) == 2): + return None + if constraint[0] == "json_schema": + return {"json": constraint[1]} + if constraint[0] != "structural_tag": + return None + + tag_value = constraint[1] + # SGLang returns a Pydantic model (LegacyStructuralTagResponseFormat) + # here. Convert to a plain dict before it hits the RPC layer because + # msgpack/serde_json cannot serialize BaseModel instances. + if hasattr(tag_value, "model_dump"): + tag_value = tag_value.model_dump() + return {"structural_tag": tag_value} def build_response_format_guided_decoding( @@ -773,6 +871,9 @@ def preprocess_chat_request( exclude_tools_when_tool_choice_none: bool = True, template_force_reasoning: bool = False, default_thinking_mode: str | None = None, + structural_tag_mode: str = "off", + structural_tag_scope: str = "auto", + structural_tag_schema: str = "auto", ) -> SglangPreprocessResult: """Preprocess a chat request using SGLang tokenizer and parser APIs. @@ -876,21 +977,14 @@ def preprocess_chat_request( raise PreprocessError(str(exc)) from exc prompt_token_ids = _normalize_prompt_token_ids(rendered) - # Build parsers after rendering, so DeepSeek-V4 can use its custom encoder - # while still sharing the existing Dynamo parser/guided-decoding behavior. - tool_call_parser, reasoning_parser = create_parsers( - request, - tool_call_parser_name=tool_call_parser_name, - reasoning_parser_name=effective_reasoning_parser_name, - sglang_tools=sglang_tools, - force_reasoning=force_reasoning, - tokenizer=tokenizer, - ) response_format_guided_decoding = build_response_format_guided_decoding(request) tool_call_guided_decoding = build_tool_call_guided_decoding( request, tool_call_parser_name=tool_call_parser_name, sglang_tools=sglang_tools, + structural_tag_mode=structural_tag_mode, + structural_tag_scope=structural_tag_scope, + structural_tag_schema=structural_tag_schema, tokenizer=tokenizer, ) # This path also never reads the legacy guided_json / guided_regex / @@ -938,6 +1032,17 @@ def preprocess_chat_request( or tool_call_guided_decoding ) + # Match the parser to the installed format, including forced-choice fallback. + tool_call_parser, reasoning_parser = create_parsers( + request, + tool_call_parser_name=tool_call_parser_name, + reasoning_parser_name=effective_reasoning_parser_name, + sglang_tools=sglang_tools, + force_reasoning=force_reasoning, + guided_decoding=guided_decoding, + tokenizer=tokenizer, + ) + return SglangPreprocessResult( prompt_token_ids=prompt_token_ids, tool_call_parser=tool_call_parser, diff --git a/components/src/dynamo/frontend/sglang_processor.py b/components/src/dynamo/frontend/sglang_processor.py index e7177e029f8c..22c0525f4265 100644 --- a/components/src/dynamo/frontend/sglang_processor.py +++ b/components/src/dynamo/frontend/sglang_processor.py @@ -40,6 +40,7 @@ preprocess_chat_request, resolve_skip_special_tokens, ) +from .structural_tag_policy import runtime_structural_tag_options from .thinking import runtime_default_thinking_mode from .utils import ( PreprocessError, @@ -272,6 +273,9 @@ def _map_finish_reason(raw: str | None) -> str | None: _w_exclude_tools_when_tool_choice_none: bool = True _w_template_force_reasoning: bool = False _w_default_thinking_mode: str | None = None +_w_structural_tag_mode: str = "off" +_w_structural_tag_scope: str = "auto" +_w_structural_tag_schema: str = "auto" def _load_chat_template(chat_template: str | None) -> str | None: @@ -323,11 +327,15 @@ def _init_worker( template_force_reasoning: bool = False, chat_template: str | None = None, default_thinking_mode: str | None = None, + structural_tag_mode: str = "off", + structural_tag_scope: str = "auto", + structural_tag_schema: str = "auto", ) -> 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 + global _w_default_thinking_mode, _w_structural_tag_mode + global _w_structural_tag_scope, _w_structural_tag_schema _w_tokenizer = _load_tokenizer(model_path, trust_remote_code) if chat_template is not None: _w_tokenizer.chat_template = chat_template @@ -336,6 +344,9 @@ def _init_worker( _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 + _w_structural_tag_mode = structural_tag_mode + _w_structural_tag_scope = structural_tag_scope + _w_structural_tag_schema = structural_tag_schema def _preprocess_worker( @@ -352,6 +363,9 @@ def _preprocess_worker( 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, + structural_tag_mode=_w_structural_tag_mode, + structural_tag_scope=_w_structural_tag_scope, + structural_tag_schema=_w_structural_tag_schema, ) n = request.get("n", 1) @@ -525,6 +539,9 @@ def __init__( preprocess_workers: int = 0, stream_interval: int = 1, default_thinking_mode: str | None = None, + structural_tag_mode: str = "off", + structural_tag_scope: str = "auto", + structural_tag_schema: str = "auto", ): self.tokenizer = tokenizer # Detect force_reasoning once from the chat template, matching @@ -549,6 +566,9 @@ def __init__( self.debug_perf = debug_perf self.stream_interval = stream_interval self.default_thinking_mode = default_thinking_mode + self.structural_tag_mode = structural_tag_mode + self.structural_tag_scope = structural_tag_scope + self.structural_tag_schema = structural_tag_schema self.preprocess_pool = preprocess_pool if preprocess_pool is not None: self._worker_semaphore: asyncio.Semaphore | None = asyncio.Semaphore( @@ -606,6 +626,9 @@ async def _generator_inner( 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, + structural_tag_mode=self.structural_tag_mode, + structural_tag_scope=self.structural_tag_scope, + structural_tag_schema=self.structural_tag_schema, ) if self.debug_perf: @@ -710,6 +733,9 @@ async def _generator_inner_pool( tool_call_parser_name=self.tool_call_parser_name, reasoning_parser_name=preproc_result.effective_reasoning_parser_name, force_reasoning=preproc_result.force_reasoning, + guided_decoding=preproc_result.dynamo_preproc["sampling_options"][ + "guided_decoding" + ], tokenizer=self.tokenizer, ) @@ -1067,6 +1093,11 @@ async def chat_engine_factory( or _runtime_config_parser_name(mdc, "reasoning_parser") ) default_thinking_mode = runtime_default_thinking_mode(mdc.runtime_config()) + ( + structural_tag_mode, + structural_tag_scope, + structural_tag_schema, + ) = runtime_structural_tag_options(mdc.runtime_config()) if tool_call_parser_name: logger.info("SGLang tool call parser: %s", tool_call_parser_name) @@ -1095,6 +1126,9 @@ async def chat_engine_factory( template_force_reasoning, chat_template, default_thinking_mode, + structural_tag_mode, + structural_tag_scope, + structural_tag_schema, ), ) futures = [ @@ -1131,6 +1165,9 @@ async def chat_engine_factory( preprocess_workers=preprocess_workers, stream_interval=self.stream_interval, default_thinking_mode=default_thinking_mode, + structural_tag_mode=structural_tag_mode, + structural_tag_scope=structural_tag_scope, + structural_tag_schema=structural_tag_schema, ) gen.exclude_tools_when_tool_choice_none = ( self.config.exclude_tools_when_tool_choice_none diff --git a/components/src/dynamo/frontend/structural_tag_policy.py b/components/src/dynamo/frontend/structural_tag_policy.py new file mode 100644 index 000000000000..b54b7499eaca --- /dev/null +++ b/components/src/dynamo/frontend/structural_tag_policy.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any, Literal + +ToolChoiceKind = Literal["none", "auto", "required", "named", "other"] + + +def runtime_structural_tag_options( + runtime_config: Any, +) -> tuple[str, str, str]: + """Read structural-tag policy from a model-card runtime config.""" + if not isinstance(runtime_config, dict): + return "off", "auto", "auto" + return ( + runtime_config.get("structural_tag_mode", "off"), + runtime_config.get("structural_tag_scope", "auto"), + runtime_config.get("structural_tag_schema", "auto"), + ) + + +def should_attempt_structural_tag( + *, + mode: str, + scope: str, + tool_choice_kind: ToolChoiceKind, + has_tools: bool, + any_explicit_strict: bool, + parallel_tool_calls_explicitly_false: bool, +) -> bool: + """Return whether frontend preprocessing should request a structural tag.""" + if mode != "on" or not has_tools or tool_choice_kind == "none": + return False + if tool_choice_kind in {"required", "named"}: + return True + if tool_choice_kind != "auto": + return False + if scope == "always": + return True + return any_explicit_strict or parallel_tool_calls_explicitly_false + + +def effective_tool_strict(request_strict: bool | None, schema_mode: str) -> bool: + """Resolve request strictness for grammar construction. + + In ``auto`` schema mode, omitted ``strict`` is schema-enforced and only an + explicit ``strict: false`` opts out. ``strict`` schema mode overrides that + request-level opt-out. + """ + return schema_mode == "strict" or request_strict is not False diff --git a/components/src/dynamo/frontend/tests/test_pinned_vllm_structural_tag_registry.py b/components/src/dynamo/frontend/tests/test_pinned_vllm_structural_tag_registry.py new file mode 100644 index 000000000000..86897daddd2d --- /dev/null +++ b/components/src/dynamo/frontend/tests/test_pinned_vllm_structural_tag_registry.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Pin the real vLLM structural-tag behavior used by Dynamo's auto tool path.""" + +import pytest + +try: + from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionToolsParam, + ) + from vllm.tool_parsers.structural_tag_registry import get_model_structural_tag +except ImportError: + pytest.skip("requires the vLLM structural-tag registry", allow_module_level=True) + +pytestmark = [ + pytest.mark.unit, + pytest.mark.vllm, + pytest.mark.gpu_0, + pytest.mark.pre_merge, +] + + +def test_pinned_vllm_registry_auto_floor(): + tool = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + "strict": False, + }, + } + non_strict = ChatCompletionToolsParam.model_validate(tool) + assert ( + get_model_structural_tag( + model="kimi_k3", + tools=[non_strict], + tool_choice="auto", + reasoning=False, + ) + is None + ) + + tool["function"]["strict"] = True + strict = ChatCompletionToolsParam.model_validate(tool) + assert ( + get_model_structural_tag( + model="kimi_k3", + tools=[strict], + tool_choice="auto", + reasoning=False, + ) + is not None + ) diff --git a/components/src/dynamo/frontend/tests/test_sglang_checkpoint_templates.py b/components/src/dynamo/frontend/tests/test_sglang_checkpoint_templates.py index 6f930eb58ea9..8c26eae7ac21 100644 --- a/components/src/dynamo/frontend/tests/test_sglang_checkpoint_templates.py +++ b/components/src/dynamo/frontend/tests/test_sglang_checkpoint_templates.py @@ -143,5 +143,7 @@ def constraint(parser, tool_choice): tokenizer=HunyuanTokenizer(), tool_call_parser_name="hunyuan", reasoning_parser_name="hunyuan", + structural_tag_mode="on", + structural_tag_scope="always", ) assert result.guided_decoding == {"structural_tag": {"type": "object"}} 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 b382f8ea118b..f5e9a84340ba 100644 --- a/components/src/dynamo/frontend/tests/test_sglang_processor_unit.py +++ b/components/src/dynamo/frontend/tests/test_sglang_processor_unit.py @@ -16,8 +16,10 @@ import types from concurrent.futures import Future, ThreadPoolExecutor from contextlib import nullcontext +from enum import IntEnum import pytest +import xgrammar as xgr from _routed_engine_fakes import FakeRoutedEngine, FakeRoutedItem from _thinking_parity import RESOLVED_DISABLED, RESOLVED_ENABLED, THINKING_PARITY_CASES from _tool_guidance_parity import ( @@ -28,6 +30,11 @@ tool_choice_value, ) from jinja2.exceptions import TemplateError, UndefinedError +from sglang.srt.constrained.reasoner_grammar_backend import ReasonerGrammarObject +from sglang.srt.constrained.xgrammar_backend import ( + XGrammarGrammar, + XGrammarGrammarBackend, +) from sglang.srt.function_call.function_call_parser import FunctionCallParser from sglang.srt.function_call.json_array_parser import JsonArrayParser from sglang.srt.utils.hf_transformers_utils import get_tokenizer @@ -93,6 +100,20 @@ def tokenizer(): return get_tokenizer(MODEL) +@pytest.fixture(scope="module") +def tool_grammar_compiler(tokenizer): + return xgr.GrammarCompiler( + xgr.TokenizerInfo.from_huggingface(tokenizer), max_threads=1 + ) + + +@pytest.fixture(scope="module") +def tool_grammar_backend(tokenizer): + backend = XGrammarGrammarBackend(tokenizer, vocab_size=len(tokenizer)) + yield backend + backend.executor.shutdown(wait=True) + + @pytest.fixture(scope="module") def byte_fallback_tokenizer(): return get_tokenizer(BYTE_FALLBACK_MODEL) @@ -1281,6 +1302,10 @@ def apply_chat_template(self, messages, **kwargs): sglang_processor_module, "_w_exclude_tools_when_tool_choice_none", True ) monkeypatch.setattr(sglang_processor_module, "_w_template_force_reasoning", False) + # Exercise enabled guidance independently of worker initialization or test order. + monkeypatch.setattr(sglang_processor_module, "_w_structural_tag_mode", "on") + monkeypatch.setattr(sglang_processor_module, "_w_structural_tag_scope", "always") + monkeypatch.setattr(sglang_processor_module, "_w_structural_tag_schema", "auto") request = { "model": "moonshotai/Kimi-K3", @@ -1770,6 +1795,239 @@ def test_structural_tag_builds_guided_decoding(self): ) == {"structural_tag": response_format} +@pytest.mark.core +@pytest.mark.timeout(60) +@pytest.mark.parametrize("use_pool", [False, True], ids=["inline", "pool"]) +@pytest.mark.parametrize( + "tool_choice", + [ + pytest.param("required", id="required"), + pytest.param( + {"type": "function", "function": {"name": "get_weather"}}, id="named" + ), + ], +) +def test_forced_structural_tag_returns_tool_call( + tokenizer, monkeypatch, use_pool, tool_choice +): + """Native forced-tool guidance must be decoded into an API tool call.""" + request = { + "model": MODEL, + "messages": [{"role": "user", "content": "Weather in Paris?"}], + "chat_template_kwargs": {"enable_thinking": False}, + "stream": True, + "tool_choice": tool_choice, + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + } + ], + } + native_call = ( + "\n\n" + "\nParis\n\n" + "\n" + ) + engine = FakeRoutedEngine( + items=[{"token_ids": tokenizer.encode(native_call), "finish_reason": "stop"}] + ) + + if use_pool: + # Exercise the real worker and parser reconstruction without a subprocess. + monkeypatch.setattr(sglang_processor_module, "_w_tokenizer", tokenizer) + monkeypatch.setattr( + sglang_processor_module, "_w_tool_call_parser_name", "qwen3_coder" + ) + monkeypatch.setattr(sglang_processor_module, "_w_reasoning_parser_name", None) + monkeypatch.setattr( + sglang_processor_module, "_w_exclude_tools_when_tool_choice_none", True + ) + monkeypatch.setattr( + sglang_processor_module, "_w_template_force_reasoning", False + ) + monkeypatch.setattr(sglang_processor_module, "_w_default_thinking_mode", None) + monkeypatch.setattr(sglang_processor_module, "_w_structural_tag_mode", "on") + monkeypatch.setattr( + sglang_processor_module, "_w_structural_tag_scope", "always" + ) + monkeypatch.setattr(sglang_processor_module, "_w_structural_tag_schema", "auto") + + with ThreadPoolExecutor(max_workers=1) if use_pool else nullcontext() as pool: + processor = SglangProcessor( + tokenizer=tokenizer, + routed_engine=engine, + tool_call_parser_name="qwen3_coder", + reasoning_parser_name=None, + eos_token_ids=None, + preprocess_pool=pool, + preprocess_workers=1 if use_pool else 0, + structural_tag_mode="on", + structural_tag_scope="always", + ) + + async def collect(): + return [item async for item in processor.generator(request)] + + output = asyncio.run(collect()) + + # Check the actual installed guidance, not just the requested policy. + assert "structural_tag" in engine.requests[0]["sampling_options"]["guided_decoding"] + choices = [choice for item in output for choice in item["data"]["choices"]] + calls = [ + call for choice in choices for call in choice["delta"].get("tool_calls", []) + ] + assert calls, output + assert {call["index"] for call in calls} == {0} + assert [ + call["function"]["name"] for call in calls if call["function"].get("name") + ] == ["get_weather"] + assert json.loads( + "".join(call["function"].get("arguments", "") for call in calls) + ) == {"city": "Paris"} + assert not any(choice["delta"].get("content") for choice in choices) + assert choices[-1]["finish_reason"] == "tool_calls" + + +@pytest.mark.core +@pytest.mark.timeout(60) +@pytest.mark.parametrize("tool_choice", ["auto", "required", "named"]) +@pytest.mark.parametrize("thinking", [False, True], ids=["chat", "thinking"]) +@pytest.mark.parametrize( + "separate_reasoning", [False, True], ids=["combined", "separate"] +) +def test_tool_structural_tag_enforced_after_one_reasoning_boundary( + tokenizer, tool_grammar_compiler, tool_choice, thinking, separate_reasoning +): + """The backend consumes reasoning; the inner grammar must enforce tools next.""" + request = { + "model": MODEL, + "messages": [{"role": "user", "content": "Weather in Paris?"}], + "tools": [parity_tool()], + "tool_choice": tool_choice_value(tool_choice), + "chat_template_kwargs": {"enable_thinking": thinking}, + "separate_reasoning": separate_reasoning, + } + pre = preprocess_chat_request( + request, + tokenizer=tokenizer, + tool_call_parser_name="qwen3_coder", + reasoning_parser_name="qwen3", + structural_tag_mode="on", + structural_tag_scope="always", + ) + assert pre.force_reasoning is thinking + require_reasoning = _guided_output_requires_reasoning( + request, pre.force_reasoning, "qwen3", pre.guided_decoding + ) + assert require_reasoning is thinking + assert pre.guided_decoding is not None + compiled = tool_grammar_compiler.compile_structural_tag( + pre.guided_decoding["structural_tag"] + ) + + def grammar_after_reasoning(): + inner = XGrammarGrammar( + matcher=xgr.GrammarMatcher(compiled), + vocab_size=compiled.tokenizer_info.vocab_size, + ctx=compiled, + override_stop_tokens=None, + ) + grammar = ReasonerGrammarObject( + inner, think_end_ids=tokenizer.encode("", add_special_tokens=False) + ) + grammar.maybe_init_reasoning(require_reasoning) + if thinking: + for token in tokenizer.encode( + "I should check the weather.", add_special_tokens=False + ): + grammar.accept_token(token) + # The wrapper consumes the prefix, including the end marker. + assert inner.accepted_tokens == [] + return grammar + + native_call = ( + "\n\n" + "\nParis\n\n" + "\n" + ) + grammar = grammar_after_reasoning() + for token in tokenizer.encode(native_call, add_special_tokens=False): + grammar.accept_token(token) + grammar.accept_token(tokenizer.eos_token_id) + assert grammar.is_terminated() + + # A second reasoning prefix must not leave an undeclared tool unconstrained. + grammar = grammar_after_reasoning() + unknown_call = native_call.replace("get_weather", "undeclared_tool") + with pytest.raises(ValueError, match="Tokens not accepted"): + for token in tokenizer.encode(unknown_call, add_special_tokens=False): + grammar.accept_token(token) + + +@pytest.mark.core +@pytest.mark.timeout(60) +@pytest.mark.filterwarnings( + "ignore:max_rollback_tokens is deprecated.*:DeprecationWarning" +) +@pytest.mark.parametrize("parser_name", ["qwen25", "qwen3_coder"]) +@pytest.mark.parametrize("tool_choice", ["auto", "required", "named"]) +def test_structural_tag_respects_named_tool_choice( + tool_grammar_backend, parser_name, tool_choice +): + """A named choice excludes other declared tools, including legacy tags.""" + raw_tools = [parity_tool(), parity_tool()] + raw_tools[1]["function"]["name"] = "get_forecast" + request = { + "tools": raw_tools, + "tool_choice": ( + {"type": "function", "function": {"name": "get_forecast"}} + if tool_choice == "named" + else tool_choice + ), + } + guided = build_tool_call_guided_decoding( + request, + tool_call_parser_name=parser_name, + sglang_tools=convert_tools(raw_tools), + structural_tag_mode="on", + structural_tag_scope="always", + ) + assert guided is not None + grammar = tool_grammar_backend.dispatch_structural_tag( + json.dumps(guided["structural_tag"]) + ) + assert isinstance(grammar, XGrammarGrammar) + + def accepts_call(name): + if parser_name == "qwen25": + call = ( + '\n{"name":"' + name + '", "arguments":' + '{"city":"Paris"}}\n' + ) + else: + call = ( + f"\n\n" + "\nParis\n\n" + "\n" + ) + return grammar.copy().matcher.accept_string(call) + + assert accepts_call("get_forecast") + assert accepts_call("get_weather") is (tool_choice != "named") + assert [tool["function"]["name"] for tool in raw_tools] == [ + "get_weather", + "get_forecast", + ] + + class TestBuildToolCallGuidedDecoding: # FRONTEND.3 — guided-decoding setup for tool_choice # Keep SGLang's guidance decisions aligned with the shared backend matrix. @pytest.mark.parametrize( @@ -1798,6 +2056,8 @@ def test_shared_tool_guidance_policy(self, tokenizer, case): tokenizer=tokenizer, tool_call_parser_name="kimi_k2", reasoning_parser_name=None, + structural_tag_mode="on", + structural_tag_scope="always", ) assert ( @@ -1843,17 +2103,16 @@ def test_none_when_tool_choice_none(self): def test_auto_tool_guidance_normalizes_minimax_m3_alias( self, monkeypatch, tokenizer ): - tools = convert_tools( - [ - { - "type": "function", - "function": { - "name": "get_weather", - "parameters": {"type": "object", "properties": {}}, - }, - } - ] - ) + raw_tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + tools = convert_tools(raw_tools) seen = {} # Test double: capture the parser name used for guided decoding setup. @@ -1872,9 +2131,11 @@ def get_structure_constraint(self, tool_choice, **kwargs): ) guided = build_tool_call_guided_decoding( - {"tool_choice": "auto"}, + {"tool_choice": "auto", "tools": raw_tools}, tool_call_parser_name="minimax_m3_nom", sglang_tools=tools, + structural_tag_mode="on", + structural_tag_scope="always", tokenizer=tokenizer, ) @@ -1992,21 +2253,20 @@ def old_get_json_schema_constraint(sglang_tools, tool_choice): def test_auto_tool_choice_supports_older_structure_constraint_signature( self, monkeypatch ): - tools = convert_tools( - [ - { - "type": "function", - "function": { - "name": "get_weather", - "strict": True, - "parameters": { - "type": "object", - "properties": {"city": {"type": "string"}}, - }, + raw_tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": True, + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, }, - } - ] - ) + }, + } + ] + tools = convert_tools(raw_tools) class OldFunctionCallParser: def __init__(self, *, tools, tool_call_parser): @@ -2024,40 +2284,165 @@ def get_structure_constraint(self, tool_choice): ) guided = build_tool_call_guided_decoding( - {"tool_choice": "auto", "parallel_tool_calls": False}, + { + "tool_choice": "auto", + "parallel_tool_calls": False, + "tools": raw_tools, + }, tool_call_parser_name="kimi_k2", sglang_tools=tools, + structural_tag_mode="on", ) assert guided == {"structural_tag": {"type": "object"}} def test_auto_strict_tools_can_build_structural_tag_guidance(self): - tools = convert_tools( - [ - { - "type": "function", - "function": { - "name": "get_weather", - "strict": True, - "parameters": { - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"], - }, + raw_tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "strict": True, + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], }, - } - ] - ) + }, + } + ] + tools = convert_tools(raw_tools) guided = build_tool_call_guided_decoding( - {"tool_choice": "auto"}, + {"tool_choice": "auto", "tools": raw_tools}, tool_call_parser_name="kimi_k2", sglang_tools=tools, + structural_tag_mode="on", + structural_tag_scope="auto", ) assert isinstance(guided, dict) assert "structural_tag" in guided + @pytest.mark.parametrize( + ("request_strict", "include_strict", "schema_mode", "expected_strict"), + [ + (None, False, "auto", True), + (True, True, "auto", True), + (False, True, "auto", False), + (False, True, "strict", True), + ], + ) + def test_structural_tag_guidance_uses_effective_schema_policy( + self, + monkeypatch, + request_strict, + include_strict, + schema_mode, + expected_strict, + ): + function = { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + } + if include_strict: + function["strict"] = request_strict + raw_tools = [{"type": "function", "function": function}] + seen = {} + + class FakeToolStrictLevel(IntEnum): + AUTO = 0 + FUNCTION = 1 + + class CapturingFunctionCallParser: + def __init__(self, *, tools, tool_call_parser): + del tool_call_parser + seen["strict"] = tools[0].function.strict + self.tool_strict_level = FakeToolStrictLevel.AUTO + + def get_structure_constraint( + self, tool_choice, *, parallel_tool_calls=None, thinking_mode=False + ): + del tool_choice, parallel_tool_calls + seen["thinking_mode"] = thinking_mode + seen["strict_level"] = self.tool_strict_level.name + return "structural_tag", {"type": "object"} + + monkeypatch.setattr( + sglang_prepost_module, + "FunctionCallParser", + CapturingFunctionCallParser, + ) + + guided = build_tool_call_guided_decoding( + {"tool_choice": "auto", "tools": raw_tools}, + tool_call_parser_name="kimi_k2", + sglang_tools=convert_tools(raw_tools), + structural_tag_mode="on", + structural_tag_scope="always", + structural_tag_schema=schema_mode, + ) + + assert guided == {"structural_tag": {"type": "object"}} + assert seen == { + "strict": expected_strict, + "thinking_mode": False, + "strict_level": "FUNCTION", + } + assert function.get("strict") is request_strict + + def test_mode_off_does_not_probe_auto_structural_tag(self, monkeypatch): + raw_tools = [parity_tool()] + + class UnexpectedFunctionCallParser: + def __init__(self, **kwargs): + raise AssertionError(f"unexpected structural-tag probe: {kwargs}") + + monkeypatch.setattr( + sglang_prepost_module, + "FunctionCallParser", + UnexpectedFunctionCallParser, + ) + + assert ( + build_tool_call_guided_decoding( + {"tool_choice": "auto", "tools": raw_tools}, + tool_call_parser_name="kimi_k2", + sglang_tools=convert_tools(raw_tools), + structural_tag_mode="off", + structural_tag_scope="always", + ) + is None + ) + + def test_builder_error_uses_required_json_fallback(self, monkeypatch): + raw_tools = [parity_tool()] + + class RaisingFunctionCallParser: + def __init__(self, **kwargs): + del kwargs + raise ValueError("unsupported schema") + + monkeypatch.setattr( + sglang_prepost_module, + "FunctionCallParser", + RaisingFunctionCallParser, + ) + + guided = build_tool_call_guided_decoding( + {"tool_choice": "required", "tools": raw_tools}, + tool_call_parser_name="kimi_k2", + sglang_tools=convert_tools(raw_tools), + structural_tag_mode="on", + structural_tag_scope="always", + ) + + assert guided is not None + assert set(guided) == {"json"} + def test_tool_parser_requires_tools(self): """Tool parser is not created if no tools in request.""" tcp, rp = create_parsers( @@ -2128,7 +2513,7 @@ def test_both_parsers(self): assert rp is not None def test_required_creates_json_array_parser(self): - """tool_choice='required' creates JsonArrayParser, not FunctionCallParser.""" + """Required choice without structural-tag guidance keeps JsonArrayParser.""" request = { "tools": [ { @@ -2148,7 +2533,7 @@ def test_required_creates_json_array_parser(self): assert isinstance(tcp, JsonArrayParser) def test_named_tool_choice_creates_json_array_parser(self): - """Named tool_choice creates JsonArrayParser.""" + """Named choice without structural-tag guidance keeps JsonArrayParser.""" request = { "tools": [ { @@ -3055,9 +3440,15 @@ class FakeTokenizer: exclude_tools_when_tool_choice_none=True, chat_template="custom template", default_thinking_mode="disabled", + structural_tag_mode="on", + structural_tag_scope="always", + structural_tag_schema="strict", ) assert sglang_processor_module._w_tokenizer.chat_template == "custom template" assert sglang_processor_module._w_default_thinking_mode == "disabled" + assert sglang_processor_module._w_structural_tag_mode == "on" + assert sglang_processor_module._w_structural_tag_scope == "always" + assert sglang_processor_module._w_structural_tag_schema == "strict" def test_with_reasoning_parser(self, tokenizer): """Reasoning parser is attached to result.""" diff --git a/components/src/dynamo/frontend/tests/test_structural_tag_policy.py b/components/src/dynamo/frontend/tests/test_structural_tag_policy.py new file mode 100644 index 000000000000..ce93dd3073c6 --- /dev/null +++ b/components/src/dynamo/frontend/tests/test_structural_tag_policy.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from dynamo.frontend.structural_tag_policy import ( + effective_tool_strict, + runtime_structural_tag_options, + should_attempt_structural_tag, +) + +pytestmark = [ + pytest.mark.unit, + pytest.mark.gpu_0, + pytest.mark.pre_merge, +] + + +@pytest.mark.parametrize( + ("mode", "scope", "choice", "strict", "single", "expected"), + [ + ("off", "always", "auto", False, False, False), + ("on", "always", "auto", False, False, True), + ("on", "auto", "auto", True, False, True), + ("on", "auto", "auto", False, True, True), + ("on", "auto", "auto", False, False, False), + ("on", "auto", "required", False, False, True), + ("on", "auto", "named", False, False, True), + ("on", "always", "none", True, True, False), + ], +) +def test_structural_tag_activation_policy( + mode, scope, choice, strict, single, expected +): + assert ( + should_attempt_structural_tag( + mode=mode, + scope=scope, + tool_choice_kind=choice, + has_tools=True, + any_explicit_strict=strict, + parallel_tool_calls_explicitly_false=single, + ) + is expected + ) + + +@pytest.mark.parametrize( + ("request_strict", "schema_mode", "expected"), + [ + (None, "auto", True), + (True, "auto", True), + (False, "auto", False), + (False, "strict", True), + ], +) +def test_effective_tool_strict(request_strict, schema_mode, expected): + assert effective_tool_strict(request_strict, schema_mode) is expected + + +def test_runtime_structural_tag_options_are_conservative_when_absent(): + assert runtime_structural_tag_options(None) == ("off", "auto", "auto") + assert runtime_structural_tag_options({}) == ("off", "auto", "auto") + + +def test_runtime_structural_tag_options_read_model_card_policy(): + assert runtime_structural_tag_options( + { + "structural_tag_mode": "on", + "structural_tag_scope": "always", + "structural_tag_schema": "strict", + } + ) == ("on", "always", "strict") 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 e48b956e55df..0989298a8f92 100644 --- a/components/src/dynamo/frontend/tests/test_vllm_processor_unit.py +++ b/components/src/dynamo/frontend/tests/test_vllm_processor_unit.py @@ -1397,6 +1397,8 @@ class RequestForSampling(SimpleNamespace): max_tokens=1, logprobs=True, top_logprobs=1, + top_k=None, + min_p=None, cache_salt=None, mm_processor_kwargs=None, ), @@ -1662,6 +1664,7 @@ class RequestForSampling(SimpleNamespace): max_tokens=1, cache_salt=None, mm_processor_kwargs=None, + logprobs=None, **{"temperature": None, "top_k": None, "min_p": None, **requested}, ) monkeypatch.setattr( @@ -2698,8 +2701,9 @@ def test_strict_schema_uses_copy(self, tokenizer): assert request.tools[0].function.strict is None assert parser.requests[0].messages is request.messages - # Auto schema mode must preserve an omitted strict flag as unset. - def test_auto_schema_preserves_unset_strict(self, tokenizer): + # In auto schema mode, omitted strict is schema-enforced. Only an explicit + # strict:false opts out. + def test_auto_schema_enforces_omitted_strict(self, tokenizer): parser = _FakeStructuralTagParser() guided = build_tool_call_guided_decoding( self._request(tokenizer), @@ -2709,7 +2713,203 @@ def test_auto_schema_preserves_unset_strict(self, tokenizer): structural_tag_schema="auto", ) - assert guided == {"structural_tag": {"format": {"strict": [None]}}} + assert guided == {"structural_tag": {"format": {"strict": [True]}}} + + def test_auto_schema_preserves_explicit_strict_false(self, tokenizer): + parser = _FakeStructuralTagParser() + request = self._request( + tokenizer, + tools=[ + { + **TOOL_REQUEST["tools"][0], + "function": { + **TOOL_REQUEST["tools"][0]["function"], + "strict": False, + }, + } + ], + ) + + guided = build_tool_call_guided_decoding( + request, + parser, + structural_tag_mode="on", + structural_tag_scope="always", + structural_tag_schema="auto", + ) + + assert guided == {"structural_tag": {"format": {"strict": [False]}}} + + @pytest.mark.parametrize( + "token_suffix", [":6124c78e", ""], ids=["suffixed", "plain"] + ) + @pytest.mark.parametrize("tool_choice_kind", ["auto", "required", "named"]) + @pytest.mark.parametrize("stream_response", [False, True], ids=["batch", "stream"]) + def test_hyv4_structural_tag_matches_checkpoint_tokens( + self, tokenizer, monkeypatch, token_suffix, tool_choice_kind, stream_response + ): + import vllm.envs as vllm_envs + import xgrammar as xgr + from vllm.tool_parsers.hy_v4_tool_parser import HYV4ToolParser + + # Dynamo's explicit policy must work without vLLM's environment gate. + monkeypatch.setattr(vllm_envs, "VLLM_ENFORCE_STRICT_TOOL_CALLING", False) + request = self._request( + tokenizer, + tools=[parity_tool()], + tool_choice=tool_choice_value(tool_choice_kind), + ) + markers = [ + f"<{closing}{name}{token_suffix}>" + for name in ("tool_calls", "tool_call", "arg_key", "arg_value") + for closing in ("", "/") + ] + # Only the vocabulary is needed to detect this checkpoint's suffix. + vocab = {marker: index for index, marker in enumerate(markers)} + parser = HYV4ToolParser( + SimpleNamespace(get_vocab=lambda: vocab, init_kwargs={}), request.tools + ) + assert parser.get_structural_tag(request) is None + + guided = build_tool_call_guided_decoding( + request, + parser, + structural_tag_mode="on", + structural_tag_scope="always", + ) + assert guided is not None + assert set(guided) == {"structural_tag"} + compiler = xgr.GrammarCompiler( + xgr.TokenizerInfo(["x"], vocab_type=xgr.VocabType.RAW), max_threads=1 + ) + compiled = compiler.compile_structural_tag(json.dumps(guided["structural_tag"])) + native_output = ( + f"get_weather" + f"city" + f"Paris" + f"" + ) + assert xgr.GrammarMatcher(compiled).accept_string(native_output) + assert not xgr.GrammarMatcher(compiled).accept_string( + native_output.replace("get_weather", "unknown_tool") + ) + + post = StreamingPostProcessor( + tokenizer=tokenizer, + request_for_sampling=request, + sampling_params=SamplingParams(), + prompt_token_ids=[], + tool_parser=parser, + reasoning_parser_class=None, + chat_template_kwargs={}, + stream_response=stream_response, + ) + choice = post.process_output( + SimpleNamespace( + index=0, + text=native_output, + token_ids=list(vocab.values()), + finish_reason="stop", + logprobs=None, + ) + ) + assert choice is not None + assert choice["finish_reason"] == "tool_calls" + assert not choice["delta"].get("content") + function = choice["delta"]["tool_calls"][0]["function"] + assert function["name"] == "get_weather" + assert json.loads(function["arguments"]) == {"city": "Paris"} + + def test_structural_tag_builder_error_uses_forced_json_fallback(self, tokenizer): + class RaisingParser(_FakeStructuralTagParser): + def get_structural_tag(self, request, *, reasoning=False): + del request, reasoning + raise ValueError("unsupported schema") + + guided = build_tool_call_guided_decoding( + self._request(tokenizer, tool_choice="required"), + RaisingParser(), + structural_tag_mode="on", + structural_tag_scope="always", + ) + + assert guided is not None + assert set(guided) == {"json"} + + @pytest.mark.parametrize("strict, expects_tag", [(True, True), (False, False)]) + def test_declared_model_registry_matches_pinned_vllm_auto_floor( + self, tokenizer, monkeypatch, strict, expects_tag + ): + seen = {} + + class RegistryParser: + structural_tag_model = "qwen_3_coder" + + def get_structural_tag(self, request): + raise AssertionError(f"unexpected parser-level env gate: {request}") + + # vLLM 0.29.0 has no strict_level argument and returns no auto tag + # when every tool is explicitly non-strict. + def fake_get_model_structural_tag(*, model, tools, tool_choice, reasoning): + seen.update( + model=model, + strict=tools[0].function.strict, + tool_choice=tool_choice, + reasoning=reasoning, + ) + return _FakeStructuralTag({"format": {"type": "tag"}}) if strict else None + + monkeypatch.setattr( + prepost_module, + "get_model_structural_tag", + fake_get_model_structural_tag, + ) + + request = self._request( + tokenizer, + tools=[ + { + **TOOL_REQUEST["tools"][0], + "function": { + **TOOL_REQUEST["tools"][0]["function"], + "strict": strict, + }, + } + ], + ) + guided = build_tool_call_guided_decoding( + request, + RegistryParser(), + structural_tag_mode="on", + structural_tag_scope="always", + ) + + assert guided == ( + {"structural_tag": {"format": {"type": "tag"}}} if expects_tag else None + ) + assert seen == { + "model": "qwen_3_coder", + "strict": strict, + "tool_choice": "auto", + "reasoning": False, + } + + def test_missing_registry_uses_parser_method(self, tokenizer, monkeypatch): + class CompatibleParser(_FakeStructuralTagParser): + structural_tag_model = "qwen_3_coder" + + parser = CompatibleParser() + monkeypatch.setattr(prepost_module, "get_model_structural_tag", None) + + guided = build_tool_call_guided_decoding( + self._request(tokenizer), + parser, + structural_tag_mode="on", + structural_tag_scope="always", + ) + + assert guided == {"structural_tag": {"format": {"strict": [True]}}} + assert len(parser.requests) == 1 # Parser-created grammar must survive preprocessing as guided decoding. @pytest.mark.asyncio @@ -3410,22 +3610,6 @@ def test_runtime_config_context_length(vllm_processor_module, runtime_config, ex assert vllm_processor_module._runtime_config_context_length(mdc) == expected -def test_runtime_config_structural_tag_options(vllm_processor_module): - mdc = SimpleNamespace( - runtime_config=lambda: { - "structural_tag_mode": "on", - "structural_tag_scope": "always", - "structural_tag_schema": "strict", - } - ) - - assert vllm_processor_module._runtime_config_structural_tag_options(mdc) == ( - "on", - "always", - "strict", - ) - - # Regression: MistralTokenizer (--tokenizer-mode mistral) has no chat_template # attribute; a direct read used to crash vLLM preprocessing. diff --git a/components/src/dynamo/frontend/vllm_processor.py b/components/src/dynamo/frontend/vllm_processor.py index deb83e92361c..64514d486d1f 100644 --- a/components/src/dynamo/frontend/vllm_processor.py +++ b/components/src/dynamo/frontend/vllm_processor.py @@ -45,6 +45,7 @@ from dynamo.vllm.errors import vllm_client_error_to_http_error from .prepost import StreamingPostProcessor, preprocess_chat_request +from .structural_tag_policy import runtime_structural_tag_options from .thinking import runtime_default_thinking_mode from .utils import ( as_error_envelope, @@ -414,19 +415,6 @@ def _runtime_config_context_length(mdc: ModelDeploymentCard) -> int | None: return context_length -def _runtime_config_structural_tag_options( - mdc: ModelDeploymentCard, -) -> tuple[str, str, str]: - runtime_config = mdc.runtime_config() - if not isinstance(runtime_config, dict): - return "off", "auto", "auto" - return ( - runtime_config.get("structural_tag_mode", "off"), - runtime_config.get("structural_tag_scope", "auto"), - runtime_config.get("structural_tag_schema", "auto"), - ) - - def _ensure_chat_template( tokenizer: Any, local_dir: str, chat_template_flag: str | None ) -> None: @@ -1604,7 +1592,7 @@ async def chat_engine_factory( structural_tag_mode, structural_tag_scope, structural_tag_schema, - ) = _runtime_config_structural_tag_options(mdc) + ) = runtime_structural_tag_options(mdc.runtime_config()) block_size = self.config.kv_cache_block_size or 16 diff --git a/components/src/dynamo/sglang/register.py b/components/src/dynamo/sglang/register.py index eeb05cdcac20..79e15b7aafd6 100644 --- a/components/src/dynamo/sglang/register.py +++ b/components/src/dynamo/sglang/register.py @@ -8,6 +8,7 @@ from typing import Any, List, Optional import sglang as sgl +from sglang.srt.parser.reasoning_parser import ReasoningParser from sglang.srt.server_args import ServerArgs from sglang.srt.speculative.spec_info import SpeculativeAlgorithm @@ -31,6 +32,7 @@ register_model, ) from dynamo.sglang._compat import ( + filter_supported_async_generate_kwargs, sglang_uses_mla_backend, supports_disagg_prefill_cancel_anytime, ) @@ -57,6 +59,44 @@ SGLANG_HICACHE_MOONCAKE_RUNTIME_KEY = "sglang_hicache_mooncake" SPEC_DECODE_RUNTIME_KEY = "spec_decode" +TOOL_CALL_STRUCTURAL_TAG_REASONING_GATE_RUNTIME_KEY = ( + "tool_call_structural_tag_reasoning_gate" +) + + +def publish_sglang_structural_tag_reasoning_policy( + runtime_config: ModelRuntimeConfig, + engine: Optional[sgl.Engine], + server_args: ServerArgs, +) -> None: + """Advertise the native gate that consumes reasoning before guided output.""" + # vLLM also publishes grammar timing through runtime metadata. + # SGLang's exclusion additionally depends on require_reasoning per request. + parser = getattr(server_args, "reasoning_parser", None) + if ( + engine is None + or not parser + or getattr(server_args, "skip_tokenizer_init", False) + ): + return + tokenizer = getattr(engine.tokenizer_manager, "tokenizer", None) + if ( + tokenizer is None + or "require_reasoning" + not in filter_supported_async_generate_kwargs( + engine, {"require_reasoning": True} + ) + ): + return + # Match the scheduler's gate initialization, including disabled gates when + # the reasoning terminator cannot be encoded by the initialized tokenizer. + reasoner = ReasoningParser( + model_type=parser, stream_reasoning=False, tokenizer=tokenizer + ) + if tokenizer.encode(reasoner.detector.think_end_token, add_special_tokens=False): + runtime_config.set_engine_specific( + TOOL_CALL_STRUCTURAL_TAG_REASONING_GATE_RUNTIME_KEY, json.dumps(True) + ) def _supports_engine_generate( @@ -447,6 +487,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 + # Multimodal and diffusion handlers do not forward the per-request gate. + if llm_handler and not getattr(server_args, "dllm_algorithm", None): + publish_sglang_structural_tag_reasoning_policy( + runtime_config, engine, server_args + ) if dynamo_args.dyn_default_thinking_mode is not None: runtime_config.set_engine_specific( "default_thinking_mode", diff --git a/components/src/dynamo/sglang/tests/test_runtime_metadata.py b/components/src/dynamo/sglang/tests/test_runtime_metadata.py index 0d1b94cbc181..110636cb4767 100644 --- a/components/src/dynamo/sglang/tests/test_runtime_metadata.py +++ b/components/src/dynamo/sglang/tests/test_runtime_metadata.py @@ -4,6 +4,7 @@ import asyncio import json from types import SimpleNamespace +from unittest.mock import Mock import pytest @@ -23,6 +24,62 @@ ] +@pytest.mark.parametrize( + "parser, skip_tokenizer_init, has_engine, has_tokenizer, supports_gate, think_end_ids, expected", + [ + ("kimi_k3", False, True, True, True, [163588, 39964, 163589], True), + (None, False, True, True, True, [163588], False), + ("kimi_k3", True, True, True, True, [163588], False), + ("kimi_k3", False, False, True, True, [163588], False), + ("kimi_k3", False, True, False, True, [163588], False), + ("kimi_k3", False, True, True, False, [163588], False), + ("kimi_k3", False, True, True, True, [], False), + ], +) +def test_structural_tag_reasoning_policy_advertises_only_an_available_gate( + parser, + skip_tokenizer_init, + has_engine, + has_tokenizer, + supports_gate, + think_end_ids, + expected, +): + from dynamo.sglang import register + + async def async_generate(*, require_reasoning=False): + pass + + async def legacy_async_generate(*, sampling_params=None): + pass + + tokenizer = Mock() + tokenizer.encode.return_value = think_end_ids + engine = ( + SimpleNamespace( + tokenizer_manager=SimpleNamespace( + tokenizer=tokenizer if has_tokenizer else None + ), + async_generate=async_generate if supports_gate else legacy_async_generate, + ) + if has_engine + else None + ) + server_args = SimpleNamespace( + reasoning_parser=parser, skip_tokenizer_init=skip_tokenizer_init + ) + runtime_config = register.ModelRuntimeConfig() + + register.publish_sglang_structural_tag_reasoning_policy( + runtime_config, engine, server_args + ) + + key = register.TOOL_CALL_STRUCTURAL_TAG_REASONING_GATE_RUNTIME_KEY + assert (key in runtime_config.runtime_data) is expected + if expected: + assert json.loads(runtime_config.runtime_data[key]) is True + + @pytest.mark.parametrize( "server_args, expected", [ @@ -162,6 +219,8 @@ def test_runtime_config_publishes_supported_disagg_capabilities( disaggregation_mode=disaggregation_mode, max_prefill_tokens=None, page_size=16, + reasoning_parser="kimi_k3", + skip_tokenizer_init=False, speculative_algorithm="NONE", speculative_num_steps=None, ) @@ -188,8 +247,16 @@ def test_runtime_config_publishes_supported_disagg_capabilities( ) engine = None if runtime_supported: + + async def async_generate(*, require_reasoning=False): + pass + + tokenizer = Mock() + tokenizer.encode.return_value = [163588, 39964, 163589] engine = SimpleNamespace( + async_generate=async_generate, tokenizer_manager=SimpleNamespace( + tokenizer=tokenizer, context_len=4096, validate_total_tokens=True, num_reserved_tokens=0, @@ -209,6 +276,10 @@ def test_runtime_config_publishes_supported_disagg_capabilities( assert (capability in runtime_config.runtime_data) is capability_expected if capability_expected: assert json.loads(runtime_config.runtime_data[capability]) is True + reasoning_gate = register.TOOL_CALL_STRUCTURAL_TAG_REASONING_GATE_RUNTIME_KEY + assert (reasoning_gate in runtime_config.runtime_data) is ( + runtime_supported and not enable_multimodal + ) assert "Failed to get runtime config" not in caplog.text diff --git a/docs/fern/pages/reference/components/runtime-configuration.mdx b/docs/fern/pages/reference/components/runtime-configuration.mdx index 084e7eae48f7..fb7004ee22a0 100644 --- a/docs/fern/pages/reference/components/runtime-configuration.mdx +++ b/docs/fern/pages/reference/components/runtime-configuration.mdx @@ -296,14 +296,14 @@ address. Environment variable: `DYN_EXCLUDE_TOOLS_WHEN_TOOL_CHOICE_NONE` - - Enable structural tag guided decoding for tool calls. When enabled, configure activation scope and parameter schema strictness with `--dyn-structural-tag-scope` and `--dyn-structural-tag-schema`. + + Enable structural tag guided decoding for tool calls when the configured parser and backend support it. The Rust, Python vLLM, and Python SGLang frontend preprocessing paths use this setting for `auto`, required, and named tool choices. Setting it to `false` disables optional guidance. Rust preserves native tags for Kimi K2 required/named and Kimi K3 named choices; Python vLLM and SGLang respect the opt-out for all tool choices. Native Rust sidecars retain their conservative `off`/`auto` behavior, including this Kimi exception, and are not included in the default change. Environment variable: `DYN_ENABLE_STRUCTURAL_TAG` - - Controls when structural tags are activated. `auto` activates them for required or named `tool_choice`, or when any tool has `strict=true` or `parallel_tool_calls` is false. `always` additionally activates them for `tool_choice=auto` without those conditions. `tool_choice=none` is unaffected by either setting. Only meaningful when `--dyn-enable-structural-tag` is set. + + Controls when structural tags are activated. `always` activates them for supported `auto`, required, and named tool requests. `auto` preserves the legacy conditional behavior: required or named `tool_choice`, or automatic tool choice when any tool has `strict=true` or `parallel_tool_calls` is false. `tool_choice=none` is unaffected by either setting. Applies when structural tags are active. Allowed values: auto always @@ -311,7 +311,7 @@ address. - Controls parameter schema strictness inside structural tags. `auto` applies the real parameter schema unless the tool explicitly sets `strict=false`. Tools with `strict=false` remain syntactically constrained but schema-unconstrained. `strict` applies the real parameter schema to every tool. Only meaningful when `--dyn-enable-structural-tag` is set. + Controls parameter schema strictness inside structural tags. `auto` applies the declared parameter schema unless a tool explicitly sets `strict=false`; an omitted `strict` field is schema-enforced. `strict` applies the declared schema to every tool and overrides `strict=false`. Applies when structural tags are active. Allowed values: auto strict diff --git a/docs/fern/pages/use-cases/tool-calling-and-reasoning/structural-tag.md b/docs/fern/pages/use-cases/tool-calling-and-reasoning/structural-tag.md index c23f91afc6d1..a54b02bde23e 100644 --- a/docs/fern/pages/use-cases/tool-calling-and-reasoning/structural-tag.md +++ b/docs/fern/pages/use-cases/tool-calling-and-reasoning/structural-tag.md @@ -1,5 +1,5 @@ --- -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 title: Structural Tag (Guided Decoding for Tool Calls) subtitle: Constrain model output to valid tool call format using xgrammar structural tags @@ -25,13 +25,22 @@ Benefits: ## Prerequisites -- A backend engine with xgrammar support. +- A backend engine that accepts structural-tag/xgrammar guided decoding. - A Dynamo tool call parser that provides a structural tag config (see [Supported Parsers](#supported-parsers) below). ## Quick Start -Enable structural tags on the **worker** with `--dyn-enable-structural-tag`, alongside the tool-call parser. The Frontend needs no extra flags: +Structural tags are enabled by default. Configure the tool-call parser on the +**worker**; the Frontend needs no extra flags: + +> [!NOTE] +> TensorRT-LLM guided decoding remains **opt-in** pending +> [TensorRT-LLM #19913](https://github.com/NVIDIA/TensorRT-LLM/issues/19913). +> Its `--guided-decoding-backend` default is unset, so default deployments ignore +> structural tags and do not enforce their tool argument schemas. For affected +> deployments using `--guided-decoding-backend xgrammar`, pass +> `--no-dyn-enable-structural-tag` until a supported upgrade resolves the issue. ```yaml apiVersion: nvidia.com/v1beta1 @@ -70,7 +79,6 @@ spec: - Qwen/Qwen3.5-4B - --dyn-tool-call-parser - qwen3_coder - - --dyn-enable-structural-tag ``` Eligible tool-calling requests will now use xgrammar structural tags for guided @@ -80,45 +88,100 @@ decoding. See [Activation Scope](#activation-scope) for the exact policy. | Flag | Values | Default | Description | |---|---|---|---| -| `--dyn-enable-structural-tag` | bool | `false` | Master switch. When disabled, tool calling works the same as without structural tags. | -| `--dyn-structural-tag-scope` | `auto`, `always` | `auto` | Controls when structural tags are activated (see [Activation Scope](#activation-scope)). | +| `--dyn-enable-structural-tag` | bool | `true` | Enables tool-call structural tags in Rust, Python vLLM, and Python SGLang preprocessing. Rust retains native forced-tool tags for Kimi K2 required/named and Kimi K3 named choices when disabled. | +| `--dyn-structural-tag-scope` | `auto`, `always` | `always` | Controls when structural tags are activated (see [Activation Scope](#activation-scope)). | | `--dyn-structural-tag-schema` | `auto`, `strict` | `auto` | Controls parameter schema strictness inside structural tags (see [Schema Modes](#schema-modes)). | +## Preserve Previous Behavior + +To disable optional structural guidance, pass the negated flag to the worker: + +```bash +python3 -m dynamo.vllm ... --no-dyn-enable-structural-tag +python3 -m dynamo.sglang ... --no-dyn-enable-structural-tag +python3 -m dynamo.trtllm ... --no-dyn-enable-structural-tag +``` + +Or set the equivalent environment variable before starting the worker: + +```bash +export DYN_ENABLE_STRUCTURAL_TAG=false +``` + +Rust preprocessing still applies native tags to Kimi K2 `required`/named +choices and Kimi K3 named choices when structural tags are disabled. Kimi K3 +named tags use the new argument-schema enforcement; the opt-out does not +restore main's grammar. A lookahead pattern can still be rejected by XGrammar +0.2.1 or 0.2.7 unless the tool sets `strict: false` under schema mode `auto`. +Global schema mode `strict` overrides that workaround. Kimi K2/K3 automatic +choices and Kimi K3 `required` do not use this exception. Python vLLM and SGLang +preprocessing respect the opt-out for all tool choices. + +To keep structural tags enabled but preserve the previous conditional activation +policy, set the scope to `auto`: + +```bash +export DYN_STRUCTURAL_TAG_SCOPE=auto +``` + +With this scope, required and named tool choices remain eligible. Automatic tool +choice uses structural tags only when a tool sets `strict: true` or the request +sets `parallel_tool_calls` to `false`. + +Setting `strict: false` on a tool relaxes its argument schema. On vLLM +0.30.0, pinned by Dynamo's CUDA image, auto choice with every tool explicitly +non-strict also gets no structural tag from the registry; see +[Schema Modes](#schema-modes). Use the global opt-out above to disable optional +structural guidance. + ## Supported Parsers -Not all parsers support structural tags. Parsers without a structural tag -config fall back to standard behaviour (a warning is logged if structural -tags are enabled but the parser does not support them). +Not all parsers support structural tags. Parsers without a structural-tag +builder fall back to their existing best-effort tool-calling behavior without +rejecting the request. Currently tested and supported: - `qwen3_coder`, `nemotron_nano` - `hermes`, `qwen25` - `deepseek_v3_2`, `deepseek_v4` +- `kimi_k2`, `kimi_k3`, `kimi-k3` +- `inkling` Contributions adding structural tag support for new parsers are welcome. +This list describes Dynamo's Rust parser registry. The Python vLLM and SGLang +frontend processors apply the same mode, scope, and schema policy through the +tool parser supplied by their installed engine version. Parser availability can +therefore differ by backend and engine version. + +> [!NOTE] +> Native Rust sidecars retain their existing conservative `off`/`auto` +> settings, including the native Kimi forced-tool exception described above, +> and are not included in this default change. The default-on policy +> applies when regular vLLM, SGLang, or TensorRT-LLM workers publish the +> deployment runtime configuration consumed by frontend preprocessing. + ## Activation Scope The `--dyn-structural-tag-scope` flag controls when structural tags are used based on the request's `tool_choice`: -### `auto` (default) +### `always` (default) | `tool_choice` | Structural tag? | |---|---| | `required` / `named` | Always | -| `auto` | Only when any tool has `strict: true` or `parallel_tool_calls` is `false` | -| `none` | Exclusion tag only (bans tool call tokens, see [below](#tool_choicenone-and-token-banning)) | +| `auto` | Always attempted; the parser may return no tag | +| `none` | Exclusion tag on the Rust path only | -### `always` +### `auto` (legacy conditional activation) | `tool_choice` | Structural tag? | |---|---| | `required` / `named` | Always | -| `auto` | Always | -| `none` | Exclusion tag only | - +| `auto` | Only when any tool has `strict: true` or `parallel_tool_calls` is `false` | +| `none` | Exclusion tag on the Rust path only (see [below](#tool_choicenone-and-token-banning)) | ## Schema Modes @@ -127,19 +190,45 @@ tool arguments inside the structural tag: ### `auto` (default) -- Tools with `strict: true` or without `strict` — their actual parameter schema is used. -- Tools with `strict: false` — an unconstrained schema is used, allowing the model to generate any valid content in the parser's native format. +- Tools with omitted `strict` or `strict: true` — their declared parameter + schema is used. +- Tools with `strict: false` — argument content is schema-relaxed when the + parser builds a structural tag. ### `strict` - All tools use their actual parameter schema regardless of the `strict` flag. +If a model-native builder cannot safely represent part of a schema, it keeps +the strongest safe tool envelope and relaxes that argument section. If the +builder cannot produce a structural tag at all, Dynamo uses the existing +compatibility path rather than introducing a new request error; automatic tool +choice may therefore remain unconstrained for that parser/schema combination. + +> [!WARNING] +> Valid tool schemas containing constructs unsupported by the backend's +> XGrammar version, such as regex lookahead, can cause request rejection. +> Backend compilation errors do not trigger Dynamo's builder fallback. +> With XGrammar 0.2.1 or 0.2.7 and schema mode `auto`, setting the tool +> function's `strict: false` avoids this lookahead rejection by disabling +> argument-schema enforcement. This workaround is not guaranteed on older +> versions. The deployment opt-out still retains Rust Kimi K3 named-call tags. + +In vLLM 0.30.0, pinned by Dynamo's CUDA image, `tool_choice="auto"` returns no +structural tag when every tool is explicitly `strict: false`. The `always` +activation scope still attempts the tag, but cannot enforce the native tool +envelope for that request. Set schema mode to `strict` to override the opt-out +and let vLLM build the tag. + ## `tool_choice="none"` and Token Banning -When `tool_choice="none"` and structural tags are enabled, Dynamo injects an -exclusion structural tag that bans parser-specific tool-call start tokens (for -example ``) so the model cannot start native tool-call syntax. +On the Rust frontend preprocessing path, when `tool_choice="none"` and +structural tags are enabled, Dynamo injects an exclusion structural tag that +bans parser-specific tool-call start tokens (for example ``) so the +model cannot start native tool-call syntax. The Python vLLM and SGLang frontend +processors continue to handle `none` through their existing prompt and response +shaping; this release does not add token banning to those paths. **Quality trade-off**. If tools remain in the prompt on `none` (often via `--no-exclude-tools-when-tool-choice-none` to keep the chat prefix stable for KV @@ -168,7 +257,8 @@ KV cache reuse. ## Example -To pin the scope and schema, add `--dyn-structural-tag-scope` and `--dyn-structural-tag-schema` to the worker `args:` alongside the parser and master switch: +To pin the enabled state, scope, and schema explicitly, add the following flags +to the worker `args:` alongside the parser: ```yaml - name: SGLangWorker diff --git a/docs/fern/pages/use-cases/tool-calling-and-reasoning/tool-call-parsing.mdx b/docs/fern/pages/use-cases/tool-calling-and-reasoning/tool-call-parsing.mdx index 289b03f206b1..a0a61da9ff49 100644 --- a/docs/fern/pages/use-cases/tool-calling-and-reasoning/tool-call-parsing.mdx +++ b/docs/fern/pages/use-cases/tool-calling-and-reasoning/tool-call-parsing.mdx @@ -350,6 +350,6 @@ The **Upstream name** column shows where the vLLM or SGLang parser name differs - -You can enable **xgrammar structural tags** so guided decoding matches the parser's tool-call format at token granularity. See [Structural Tag (Guided Decoding for Tool Calls)](structural-tag.md). + +For parser and backend combinations that support them, Dynamo enables **xgrammar structural tags** by default so guided decoding matches the parser's tool-call format at token granularity. See [Structural Tag (Guided Decoding for Tool Calls)](structural-tag.md) for the activation policy, compatibility fallbacks, and opt-out settings. diff --git a/docs/fern/translations/zh-CN/pages/use-cases/tool-calling-and-reasoning/tool-call-parsing.mdx b/docs/fern/translations/zh-CN/pages/use-cases/tool-calling-and-reasoning/tool-call-parsing.mdx index 2349e18a83be..6b73ec4fb4e9 100644 --- a/docs/fern/translations/zh-CN/pages/use-cases/tool-calling-and-reasoning/tool-call-parsing.mdx +++ b/docs/fern/translations/zh-CN/pages/use-cases/tool-calling-and-reasoning/tool-call-parsing.mdx @@ -105,6 +105,6 @@ Dynamo 会从模型输出中解析出工具调用,并在响应中以兼容 Ope > 如果工具调用返回结果不正确,请向单个复现请求添加 `"logprobs": true` 并分享响应。有关报告问题时需要捕获和包含的内容,请参阅 > [工具调用故障排查](../../../../../pages/use-cases/tool-calling-and-reasoning/troubleshooting-tool-calls.md)。 -## 可选:结构化标签(structural tags) +## 默认启用结构化标签(structural tags) -你可以启用 **xgrammar 结构化标签**,让引导式解码在 token 粒度上匹配解析器的工具调用格式。参阅 [Structural tag (guided decoding for tool calls)](../../../../../pages/use-cases/tool-calling-and-reasoning/structural-tag.md)。 +对于支持结构化标签的解析器和后端组合,Dynamo 默认启用 **xgrammar 结构化标签**,使引导式解码在 token 粒度上匹配解析器的工具调用格式。有关激活策略、兼容性回退和退出设置,请参阅 [Structural tag (guided decoding for tool calls)](../../../../../pages/use-cases/tool-calling-and-reasoning/structural-tag.md)。 diff --git a/lib/backend-common/src/worker.rs b/lib/backend-common/src/worker.rs index aadc3d8b2df3..4290689f907d 100644 --- a/lib/backend-common/src/worker.rs +++ b/lib/backend-common/src/worker.rs @@ -264,6 +264,9 @@ impl Default for WorkerConfig { metrics_labels: Vec::new(), disaggregation_mode: DisaggregationMode::Aggregated, health_check_payload: None, + // Keep the shared/wire default conservative for mixed-version and + // Rust-sidecar compatibility. Deployment-facing runtime arguments + // explicitly publish the current On/Always defaults. structural_tag_mode: StructuralTagMode::Off, structural_tag_scope: StructuralTagScope::Auto, structural_tag_schema: StructuralTagSchemaMode::Auto, diff --git a/lib/bindings/kvbm/Cargo.lock b/lib/bindings/kvbm/Cargo.lock index 79f0d0d2da39..e40a567e8f74 100644 --- a/lib/bindings/kvbm/Cargo.lock +++ b/lib/bindings/kvbm/Cargo.lock @@ -2295,9 +2295,9 @@ dependencies = [ [[package]] name = "dynamo-parsers" -version = "9.2.6" +version = "9.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedc2cc2376c35d3bc998f6ec326271703cd19a61ef18ad7e1d21dbde3eeb638" +checksum = "a751a31502748c656f69bd1fab01f1d844b6bb629b69e9fbed0b0da9af916b7a" dependencies = [ "aho-corasick", "anyhow", diff --git a/lib/bindings/python/Cargo.lock b/lib/bindings/python/Cargo.lock index edd5ea7f4a07..30e520944bc1 100644 --- a/lib/bindings/python/Cargo.lock +++ b/lib/bindings/python/Cargo.lock @@ -2347,9 +2347,9 @@ dependencies = [ [[package]] name = "dynamo-parsers" -version = "9.2.6" +version = "9.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedc2cc2376c35d3bc998f6ec326271703cd19a61ef18ad7e1d21dbde3eeb638" +checksum = "a751a31502748c656f69bd1fab01f1d844b6bb629b69e9fbed0b0da9af916b7a" dependencies = [ "aho-corasick", "anyhow", diff --git a/lib/bindings/python/Cargo.toml b/lib/bindings/python/Cargo.toml index 870125506767..bea94cea557e 100644 --- a/lib/bindings/python/Cargo.toml +++ b/lib/bindings/python/Cargo.toml @@ -62,7 +62,7 @@ request-trace-s3 = ["dynamo-llm/request-trace-s3"] aisimulate-core = { version = "=0.13.0-dev.202610060000000067", optional = true, features = ["python"] } dynamo-runtime = { path = "../../runtime" } dynamo-mocker = { path = "../../mocker" } -dynamo-parsers = { version = "9.2.6" } +dynamo-parsers = { version = "=9.2.7" } dynamo-backend-common = { path = "../../backend-common" } dynamo-sidecar-common = { path = "../../sidecar/common" } dynamo-sglang-sidecar = { path = "../../sidecar/sglang" } diff --git a/lib/llm/src/http/service.rs b/lib/llm/src/http/service.rs index 3094b5840157..255c1709800b 100644 --- a/lib/llm/src/http/service.rs +++ b/lib/llm/src/http/service.rs @@ -361,7 +361,7 @@ mod tests { assert_eq!( result.guided_tool_constraint, GuidedToolConstraint::StructuralTag, - "kimi_k2 + required must use the intrinsic structural tag, not a reconstructed JSON schema" + "kimi_k2 + required must retain its native structural tag when mode is off" ); } @@ -377,7 +377,7 @@ mod tests { assert_eq!( result.guided_tool_constraint, GuidedToolConstraint::StructuralTag, - "kimi_k2 + a named tool choice must use the intrinsic structural tag, not a reconstructed JSON schema" + "kimi_k2 + a named tool choice must retain its native structural tag when mode is off" ); } @@ -423,11 +423,12 @@ mod tests { ); } - // With the operator default (`structural_tag_mode = Off`), the same non-Kimi - // parser must NOT get a structural tag — confirms the mode gate above is real, - // not a permanently-on regression. + // With the low-level ParsingOptions default (`structural_tag_mode = Off`), + // the same non-Kimi parser must NOT get a structural tag. Regular workers + // explicitly publish the deployment's default-on policy into the model card; + // native sidecars that do not do so retain this conservative default. #[test] - fn operator_default_mode_off_does_not_resolve_structural_tag_for_a_non_kimi_parser() { + fn parsing_options_default_mode_off_does_not_resolve_structural_tag() { let parsing_options = ParsingOptions { tool_call_parser: Some("qwen3_coder".to_string()), ..Default::default() diff --git a/lib/llm/src/local_model/runtime_config.rs b/lib/llm/src/local_model/runtime_config.rs index 7fd56f49bd73..3008f6f58099 100644 --- a/lib/llm/src/local_model/runtime_config.rs +++ b/lib/llm/src/local_model/runtime_config.rs @@ -42,6 +42,11 @@ pub const TOKEN_BUDGET_RUNTIME_KEY: &str = "token_budget"; pub const TOOL_CALL_STRUCTURAL_TAG_EXCLUDES_REASONING_RUNTIME_KEY: &str = "tool_call_structural_tag_excludes_reasoning"; +/// A backend consumes the reasoning prefix only when `require_reasoning` is true +/// on the request. Missing metadata leaves reasoning in the frontend grammar. +pub const TOOL_CALL_STRUCTURAL_TAG_REASONING_GATE_RUNTIME_KEY: &str = + "tool_call_structural_tag_reasoning_gate"; + /// Describes which request-token overflows the frontend may reject early. /// /// The combined limit already accounts for engine-reserved tokens. A false @@ -391,6 +396,8 @@ impl Default for ModelRuntimeConfig { tool_call_arguments_format: ToolCallArgumentsFormat::JsonString, tokenizer_backend: None, tokenizer_fallback_enabled: None, + // Missing fields from older workers remain conservative. Current + // deployment configuration explicitly publishes On/Always. structural_tag_mode: StructuralTagMode::Off, structural_tag_scope: StructuralTagScope::Auto, structural_tag_schema: StructuralTagSchemaMode::Auto, diff --git a/lib/llm/src/preprocessor/structural_tag.rs b/lib/llm/src/preprocessor/structural_tag.rs index c3431be57de0..45bca09450c6 100644 --- a/lib/llm/src/preprocessor/structural_tag.rs +++ b/lib/llm/src/preprocessor/structural_tag.rs @@ -5,6 +5,7 @@ use crate::local_model::runtime_config::{ StructuralTagMode, StructuralTagScope, TOOL_CALL_STRUCTURAL_TAG_EXCLUDES_REASONING_RUNTIME_KEY, + TOOL_CALL_STRUCTURAL_TAG_REASONING_GATE_RUNTIME_KEY, }; use crate::preprocessor::{OpenAIPreprocessor, PreprocessedRequest}; use crate::protocols::openai::tools::{ToolChoiceValidation, validate_tool_choice_against_names}; @@ -46,16 +47,26 @@ fn is_kimi_k2_parser(parser_name: Option<&str>) -> bool { } fn requires_intrinsic_structural_tag(parser_name: Option<&str>, tool_choice: &ToolChoice) -> bool { - // K2 forced calls and K3 named calls cannot use Dynamo's generic JSON-schema - // fallback because both families emit native, marker-delimited formats. - // Treat their structural tags as part of implementing these standard OpenAI - // request shapes, not as an operator opt-in. K3 required remains on its - // intentional prompt-level XTML path. + // Preserve native forced-tool grammars even when optional guidance is off. + // K3 required retains its existing prompt/parser path. (is_kimi_k2_parser(parser_name) && matches!(tool_choice, ToolChoice::Required | ToolChoice::Named(_))) || (is_kimi_k3_parser(parser_name) && matches!(tool_choice, ToolChoice::Named(_))) } +pub(super) fn requires_native_tool_call_format( + parser_name: Option<&str>, + tool_choice: &ToolChoice, +) -> bool { + // The generic forced-tool JSON grammar describes a different wire format + // from Kimi's native marker-delimited calls. This also covers K3 required, + // which uses its prompt/parser path when structural guidance is off. + (is_kimi_k2_parser(parser_name) + && matches!(tool_choice, ToolChoice::Required | ToolChoice::Named(_))) + || (is_kimi_k3_parser(parser_name) + && matches!(tool_choice, ToolChoice::Required | ToolChoice::Named(_))) +} + fn should_skip_tool_call_ban(exclude_tools_when_none: bool, tool_choice: &ToolChoice) -> bool { exclude_tools_when_none && matches!(tool_choice, ToolChoice::None) } @@ -64,9 +75,9 @@ fn should_skip_tool_call_ban(exclude_tools_when_none: bool, tool_choice: &ToolCh /// parser registry can actually build one. /// /// This is the single owner of "is a structural tag applicable and available" — -/// covering model-family/tool-choice intrinsic eligibility, the operator's global -/// `structural_tag_mode`/`structural_tag_scope`, the `tool_choice=None` ban-tag -/// exclusion, and real parser-registry builder availability. A caller can only +/// covering the operator's global `structural_tag_mode`/`structural_tag_scope`, +/// intrinsic Kimi forced-tool grammars, the `tool_choice=None` ban-tag exclusion, and real +/// parser-registry builder availability. A caller can only /// reach `Required` by way of a real, registered /// [`dynamo_parsers::tool_calling::StructuralTagBuilder`] — it cannot ask for the /// eligibility half of this decision without also getting the registry-availability @@ -96,7 +107,6 @@ impl StructuralTagDecision { /// (`http::service::apply_request_tool_call_parsing_options`) must propagate /// this error rather than falling back to `NotApplicable`, or an invalid forced /// choice would silently install a structural tag anyway. -#[allow(clippy::too_many_arguments)] pub(crate) fn structural_tag_decision( parser_name: Option<&str>, tool_choice: &ToolChoice, @@ -106,7 +116,7 @@ pub(crate) fn structural_tag_decision( scope: StructuralTagScope, exclude_tools_when_tool_choice_none: bool, ) -> Result { - // Validate before any mode or family gate. Kimi K3 required requests use a + // Validate before any policy gate. Kimi K3 required requests use a // prompt-level XTML path, but they still need an actual tool to require. validate_forced_tool_choice(tool_choice, tools)?; @@ -124,7 +134,7 @@ pub(crate) fn structural_tag_decision( } let Some(parser_name) = parser_name else { - tracing::warn!( + tracing::debug!( "Structural tag is enabled but --dyn-tool-call-parser is not set; \ structural tags will not be applied" ); @@ -194,23 +204,34 @@ impl OpenAIPreprocessor { parallel_tool_calls, schema_mode: self.runtime_config.structural_tag_schema, starts_in_reasoning: prompt_injected_reasoning - && !self.tool_call_structural_tag_excludes_reasoning(), + && !self.tool_call_structural_tag_excludes_reasoning( + preprocessed_request.require_reasoning, + ), }; Self::apply_tool_call_format(parser_name, builder, &ctx, preprocessed_request) } - fn tool_call_structural_tag_excludes_reasoning(&self) -> bool { - match self - .runtime_config - .get_engine_specific::(TOOL_CALL_STRUCTURAL_TAG_EXCLUDES_REASONING_RUNTIME_KEY) - { + fn tool_call_structural_tag_excludes_reasoning(&self, require_reasoning: bool) -> bool { + // vLLM advertises exclusion for the deployment; SGLang gates it per request. + // Once the backend consumes THINK_CLOSE, this grammar must not expect it again. + // Auto tool choice alone does not activate SGLang's gate. + self.structural_tag_reasoning_metadata( + TOOL_CALL_STRUCTURAL_TAG_EXCLUDES_REASONING_RUNTIME_KEY, + ) || (require_reasoning + && self.structural_tag_reasoning_metadata( + TOOL_CALL_STRUCTURAL_TAG_REASONING_GATE_RUNTIME_KEY, + )) + } + + fn structural_tag_reasoning_metadata(&self, key: &str) -> bool { + match self.runtime_config.get_engine_specific::(key) { Ok(Some(excludes_reasoning)) => excludes_reasoning, Ok(None) => false, Err(error) => { tracing::warn!( %error, - key = TOOL_CALL_STRUCTURAL_TAG_EXCLUDES_REASONING_RUNTIME_KEY, + key, "Ignoring invalid structural-tag reasoning metadata; using the compatibility behavior" ); false @@ -228,7 +249,7 @@ impl OpenAIPreprocessor { .and_then(|tc| tc.structural_tag_builder.as_ref()); if builder.is_none() { - tracing::warn!( + tracing::debug!( parser = parser_name, "Structural tag enabled but parser does not support it; \ falling back to default behaviour" @@ -278,12 +299,12 @@ impl OpenAIPreprocessor { return Ok(false); } Err(e) => { - return Err(DynamoError::builder() - .error_type(ErrorType::Unknown) - .message(format!( - "failed to build structural_tag for parser '{parser_name}': {e}" - )) - .build()); + tracing::warn!( + parser = parser_name, + error = %e, + "Failed to build structural tag; using compatibility fallback" + ); + return Ok(false); } }; @@ -425,78 +446,103 @@ mod tests { } #[test] - fn named_kimi_k3_is_intrinsic_even_when_global_mode_is_off() { - let named = ToolChoice::Named("get_weather".to_string()); - assert!(requires_intrinsic_structural_tag(Some("kimi_k3"), &named)); - assert!(requires_intrinsic_structural_tag(Some("kimi-k3"), &named)); - } - - #[test] - fn forced_kimi_k2_is_intrinsic_even_when_global_mode_is_off() { - assert!(requires_intrinsic_structural_tag( - Some("kimi_k2"), - &ToolChoice::Required - )); - assert!(requires_intrinsic_structural_tag( - Some("kimi_k2"), - &ToolChoice::Named("get_weather".to_string()) - )); - } - - // Durable registry-parity property: every parser/tool_choice combination the - // intrinsic predicate recognizes must have a real, registered - // `StructuralTagBuilder` in the parser registry. If a future edit registers a - // new intrinsic-family alias, or de-registers an existing one, without keeping - // both in lockstep, `structural_tag_decision` would otherwise report `Required` - // with no way to actually build the tag — this is the exact gap the shared - // decision owner exists to close. No global-state mutation, no scratch worktree, - // runs against the real production registry. - #[test] - fn every_intrinsic_parser_choice_has_a_registered_structural_tag_builder() { - let cases: &[(&str, ToolChoice)] = &[ - ("kimi_k2", ToolChoice::Required), - ("kimi_k2", ToolChoice::Named("get_weather".to_string())), - ("kimi_k3", ToolChoice::Named("get_weather".to_string())), - ("kimi-k3", ToolChoice::Named("get_weather".to_string())), - ]; - for (parser, choice) in cases { + fn kimi_k3_reasoning_gate_avoids_duplicate_prefix_per_request() { + let model_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/data/sample-models/mock-llama-3.1-8b-instruct"); + let tools = [ToolDefinition { + name: "get_weather".to_string(), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + })), + strict: None, + }]; + for (gate, require_reasoning, prompt_injected_reasoning, choice, expects_prefix) in [ + (Some(true), true, true, ToolChoice::Required, false), + (None, true, true, ToolChoice::Required, true), + (Some(false), true, true, ToolChoice::Required, true), + (Some(true), false, true, ToolChoice::Auto, true), + (Some(true), true, false, ToolChoice::Required, false), + ( + Some(true), + false, + false, + ToolChoice::Named("get_weather".to_string()), + false, + ), + ] { + let mut mdc = ModelDeploymentCard::load_from_disk(&model_path, None).unwrap(); + mdc.runtime_config.structural_tag_mode = StructuralTagMode::On; + mdc.runtime_config.structural_tag_scope = StructuralTagScope::Always; + mdc.runtime_config.tool_call_parser = Some("kimi_k3".to_string()); + if let Some(gate) = gate { + mdc.runtime_config + .set_engine_specific(TOOL_CALL_STRUCTURAL_TAG_REASONING_GATE_RUNTIME_KEY, gate) + .unwrap(); + } + let preprocessor = OpenAIPreprocessor::new(mdc).unwrap(); + let mut request = preprocessed_request(); + request.require_reasoning = require_reasoning; assert!( - requires_intrinsic_structural_tag(Some(parser), choice), - "test fixture drifted: '{parser}' + {choice:?} is no longer intrinsic" + preprocessor + .apply_tool_choice_structural_tag( + &choice, + &tools, + None, + prompt_injected_reasoning, + &mut request, + ) + .unwrap(), + "gate={gate:?}, require_reasoning={require_reasoning}, choice={choice:?}" ); - assert!( - OpenAIPreprocessor::structural_tag_builder_for_parser(parser).is_some(), - "'{parser}' is intrinsic per the predicate but the parser registry has \ - no structural-tag builder for it — the predicate and the registry have \ - drifted apart" + let tag = request + .sampling_options + .guided_decoding + .unwrap() + .structural_tag + .unwrap(); + let has_prefix = tag["format"]["elements"][0]["end"] == "<|close|>think<|sep|>"; + assert_eq!( + has_prefix, expects_prefix, + "gate={gate:?}, require_reasoning={require_reasoning}, prompt_injected_reasoning={prompt_injected_reasoning}, choice={choice:?}" ); + assert!(tag.to_string().contains("<|open|>tools<|sep|>")); } } #[test] - fn other_choices_and_parsers_still_follow_the_global_mode() { - assert!(!requires_intrinsic_structural_tag( + fn kimi_forced_choices_are_classified_as_native_format_fallbacks() { + let named = ToolChoice::Named("get_weather".to_string()); + assert!(requires_native_tool_call_format( + Some("kimi_k2"), + &ToolChoice::Required + )); + assert!(requires_native_tool_call_format(Some("kimi_k2"), &named)); + assert!(requires_native_tool_call_format( Some("kimi_k3"), &ToolChoice::Required )); - assert!(!requires_intrinsic_structural_tag( + assert!(requires_native_tool_call_format(Some("kimi_k3"), &named)); + assert!(requires_native_tool_call_format(Some("kimi-k3"), &named)); + assert!(!requires_native_tool_call_format( + Some("kimi_k3"), + &ToolChoice::Auto + )); + assert!(!requires_native_tool_call_format( Some("hermes"), &ToolChoice::Named("get_weather".to_string()) )); - assert!(!requires_intrinsic_structural_tag( + assert!(!requires_native_tool_call_format( Some("kimi_k2"), &ToolChoice::Auto )); } #[test] - fn kimi_k2_required_installs_native_tag_when_global_mode_is_off() { + fn kimi_forced_choices_keep_native_tags_when_global_mode_is_off() { let model_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/data/sample-models/mock-llama-3.1-8b-instruct"); - let mut mdc = ModelDeploymentCard::load_from_disk(model_path, None).unwrap(); - mdc.runtime_config.structural_tag_mode = StructuralTagMode::Off; - mdc.runtime_config.tool_call_parser = Some("kimi_k2".to_string()); - let preprocessor = OpenAIPreprocessor::new(mdc).unwrap(); let tools = [ToolDefinition { name: "get_weather".to_string(), parameters: Some(serde_json::json!({ @@ -506,39 +552,48 @@ mod tests { })), strict: None, }]; - let mut request = preprocessed_request(); - - let applied = preprocessor - .apply_tool_choice_structural_tag( - &ToolChoice::Required, - &tools, - None, - false, - &mut request, - ) - .unwrap(); + for (parser, choice, marker) in [ + ( + "kimi_k2", + ToolChoice::Required, + "<|tool_calls_section_begin|>", + ), + ( + "kimi_k2", + ToolChoice::Named("get_weather".to_string()), + "<|tool_calls_section_begin|>", + ), + ( + "kimi_k3", + ToolChoice::Named("get_weather".to_string()), + "<|open|>call", + ), + ( + "kimi-k3", + ToolChoice::Named("get_weather".to_string()), + "<|open|>call", + ), + ] { + let mut mdc = ModelDeploymentCard::load_from_disk(&model_path, None).unwrap(); + mdc.runtime_config.structural_tag_mode = StructuralTagMode::Off; + mdc.runtime_config.tool_call_parser = Some(parser.to_string()); + let preprocessor = OpenAIPreprocessor::new(mdc).unwrap(); + let mut request = preprocessed_request(); + + let applied = preprocessor + .apply_tool_choice_structural_tag(&choice, &tools, None, false, &mut request) + .unwrap(); - assert!(applied); - let format = &request - .sampling_options - .guided_decoding - .as_ref() - .unwrap() - .structural_tag - .as_ref() - .unwrap()["format"]; - assert_eq!(format["type"], "sequence"); - assert_eq!( - format["elements"][0]["value"], - "<|tool_calls_section_begin|>" - ); - assert_eq!(format["elements"][1]["type"], "tags_with_separator"); - assert_eq!(format["elements"][1]["at_least_one"], true); - assert_eq!( - format["elements"][1]["tags"][0]["begin"], - "<|tool_call_begin|>functions.get_weather:" - ); - assert_eq!(format["elements"][2]["value"], "<|tool_calls_section_end|>"); + assert!(applied, "{parser} + {choice:?} must retain its native tag"); + let tag = request + .sampling_options + .guided_decoding + .as_ref() + .and_then(|guided| guided.structural_tag.as_ref()) + .expect("forced Kimi choice must install a native structural tag"); + assert!(tag.to_string().contains(marker)); + assert!(tag.to_string().contains("get_weather")); + } } #[test] @@ -686,6 +741,58 @@ mod tests { assert!(!applied); } + #[test] + fn global_mode_off_is_authoritative_for_kimi_k3_auto() { + let model_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/data/sample-models/mock-llama-3.1-8b-instruct"); + let mut mdc = ModelDeploymentCard::load_from_disk(model_path, None).unwrap(); + mdc.runtime_config.structural_tag_mode = StructuralTagMode::Off; + mdc.runtime_config.structural_tag_scope = StructuralTagScope::Auto; + mdc.runtime_config.tool_call_parser = Some("kimi_k3".to_string()); + let preprocessor = OpenAIPreprocessor::new(mdc).unwrap(); + let tools = [ToolDefinition { + name: "get_weather".to_string(), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"] + })), + // K3 auto must be constrained even when the caller does not opt in + // to OpenAI strict schema enforcement. + strict: None, + }]; + let mut request = preprocessed_request(); + + let applied = preprocessor + .apply_tool_choice_structural_tag(&ToolChoice::Auto, &tools, None, false, &mut request) + .unwrap(); + + assert!(!applied); + assert!(request.sampling_options.guided_decoding.is_none()); + } + + #[test] + fn always_scope_activates_auto_when_strict_is_omitted() { + let tools = [ToolDefinition { + name: "get_weather".to_string(), + parameters: None, + strict: None, + }]; + + assert!(OpenAIPreprocessor::should_apply_tool_call_format( + StructuralTagScope::Always, + &ToolChoice::Auto, + &tools, + None, + )); + assert!(!OpenAIPreprocessor::should_apply_tool_call_format( + StructuralTagScope::Auto, + &ToolChoice::Auto, + &tools, + None, + )); + } + #[test] fn tool_choice_none_skips_ban_only_when_prompt_excludes_tools() { let tools = [ToolDefinition { diff --git a/lib/llm/src/preprocessor/tool_choice.rs b/lib/llm/src/preprocessor/tool_choice.rs index e14ca420239f..403d488dac73 100644 --- a/lib/llm/src/preprocessor/tool_choice.rs +++ b/lib/llm/src/preprocessor/tool_choice.rs @@ -5,6 +5,7 @@ use std::borrow::Cow; +use crate::preprocessor::structural_tag::requires_native_tool_call_format; use crate::preprocessor::{OpenAIPreprocessor, PreprocessedRequest}; use crate::protocols::openai::chat_completions::NvCreateChatCompletionRequest; use crate::protocols::openai::tools::{ @@ -58,11 +59,10 @@ impl OpenAIPreprocessor { /// Apply guided decoding for OpenAI tool-choice requests. /// /// Structural tags are preferred when enabled and supported by the configured - /// tool-call parser. Supported K2 forced requests and named K3 requests - /// intrinsically use their native structural tags because generic JSON cannot - /// represent their tool calls. Other forced choices fall back to the legacy - /// JSON-schema constraint when structural tags are not applied, except K3 - /// required requests, which stay on the prompt-level XTML path. + /// tool-call parser. When disabled or unavailable, ordinary forced choices + /// retain the legacy JSON-schema fallback. Kimi's marker-delimited forced + /// formats cannot be represented by that fallback and remain on their + /// prompt/parser best-effort path instead. pub(super) fn apply_tool_choice_guided_decoding( &self, request: &NvCreateChatCompletionRequest, @@ -81,6 +81,7 @@ impl OpenAIPreprocessor { ); let has_explicit_guided_decoding = has_explicit_guided_decoding(request); let has_response_format_constraint = has_response_format_constraint(request); + let parser_tool_choice = convert_tool_choice(tool_choice); if is_forced_tool_choice && has_explicit_guided_decoding { return Err(invalid_argument(concat!( @@ -106,7 +107,7 @@ impl OpenAIPreprocessor { } if self.apply_tool_choice_structural_tag( - &convert_tool_choice(tool_choice), + &parser_tool_choice, &convert_tools(tools.as_ref()), request.inner.parallel_tool_calls, prompt_injected_reasoning, @@ -115,22 +116,17 @@ impl OpenAIPreprocessor { return Ok(GuidedToolConstraint::StructuralTag); } - let uses_kimi_k3_parser = uses_kimi_k3_parser( - self.tool_call_parser.as_deref(), - self.runtime_config.reasoning_parser.as_deref(), - ); - if is_forced_tool_choice && uses_kimi_k3_parser { - if matches!(tool_choice, ChatCompletionToolChoiceOption::Named(_)) { - return Err(invalid_argument( - "named tool choice for Kimi K3 requires --dyn-tool-call-parser kimi_k3 \ - with XTML structural-tag support", - )); - } - - // K3's prompt-level required instruction produces an XTML `tools` - // channel. Generic JSON guided decoding would constrain the wrong - // wire format and prevent the Rust K3 parser from seeing it. No JSON - // schema is installed, so this is NOT a guided-JSON request. + let requires_native_format = + requires_native_tool_call_format(self.tool_call_parser.as_deref(), &parser_tool_choice) + || requires_native_tool_call_format( + self.runtime_config.reasoning_parser.as_deref(), + &parser_tool_choice, + ); + if is_forced_tool_choice && requires_native_format { + tracing::debug!( + tool_choice = ?parser_tool_choice, + "Structural tag was not applied and the parser uses a native tool-call format; using prompt/parser compatibility fallback" + ); return Ok(GuidedToolConstraint::None); } @@ -287,8 +283,13 @@ pub(crate) fn guided_tool_constraint_with_effective_tools( if has_explicit_guided_decoding(request) { return Ok(GuidedToolConstraint::None); } - // K3 forced requests are served by a prompt-level XTML instruction; no JSON schema. - if uses_kimi_k3_parser(tool_call_parser, reasoning_parser) { + let parser_tool_choice = convert_tool_choice(tool_choice); + // Kimi forced requests use marker-delimited native formats that generic JSON + // guidance cannot represent. Keep this compatibility path aligned with + // request preprocessing for either configured parser slot. + if requires_native_tool_call_format(tool_call_parser, &parser_tool_choice) + || requires_native_tool_call_format(reasoning_parser, &parser_tool_choice) + { return Ok(GuidedToolConstraint::None); } // Validate the forced choice against the actual tools the same way @@ -306,15 +307,6 @@ pub(crate) fn guided_tool_constraint_with_effective_tools( } } -/// True when either configured parser is Kimi K3. -/// -/// K3 forced requests are served by a prompt-level XTML instruction, so they must -/// NOT be reported as guided JSON even though their `tool_choice` is forced. -fn uses_kimi_k3_parser(tool_call_parser: Option<&str>, reasoning_parser: Option<&str>) -> bool { - let is_k3 = |parser: &str| matches!(parser, "kimi_k3" | "kimi-k3"); - tool_call_parser.is_some_and(is_k3) || reasoning_parser.is_some_and(is_k3) -} - /// Map a forced `tool_choice` onto the guided-JSON parser constraint for its grammar. /// /// Only reachable once `get_tool_choice_guidance_from_tools` produced either JSON or @@ -334,7 +326,12 @@ fn installed_json_constraint(tool_choice: &ChatCompletionToolChoiceOption) -> Gu #[cfg(test)] mod tests { + use std::{path::PathBuf, sync::Arc}; + use super::*; + use crate::local_model::runtime_config::StructuralTagMode; + use crate::model_card::ModelDeploymentCard; + use crate::protocols::common::{OutputOptions, SamplingOptions, StopConditions}; use dynamo_protocols::types::{ChatCompletionNamedToolChoice, FunctionName}; use serde_json::{Value, json}; @@ -364,6 +361,26 @@ mod tests { }]) } + fn preprocessor(parser: &str, mode: StructuralTagMode) -> Arc { + let model_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/data/sample-models/mock-llama-3.1-8b-instruct"); + let mut mdc = ModelDeploymentCard::load_from_disk(model_path, None).unwrap(); + mdc.runtime_config.structural_tag_mode = mode; + mdc.runtime_config.tool_call_parser = Some(parser.to_string()); + OpenAIPreprocessor::new(mdc).unwrap() + } + + fn preprocessed_request() -> PreprocessedRequest { + PreprocessedRequest::builder() + .model("test-model".to_string()) + .token_ids(Vec::new()) + .stop_conditions(StopConditions::default()) + .sampling_options(SamplingOptions::default()) + .output_options(OutputOptions::default()) + .build() + .unwrap() + } + #[test] fn tool_call_parsing_requires_tools() { assert!(!OpenAIPreprocessor::tool_call_parsing_enabled(&request( @@ -650,18 +667,35 @@ mod tests { } #[test] - fn kimi_k3_is_detected_from_either_parser_slot() { - assert!(uses_kimi_k3_parser(Some("kimi_k3"), None)); - assert!(uses_kimi_k3_parser(Some("kimi-k3"), None)); - assert!(uses_kimi_k3_parser(None, Some("kimi_k3"))); - assert!(uses_kimi_k3_parser(None, Some("kimi-k3"))); - } + fn native_format_forced_choices_install_no_compatibility_json_constraint() { + let requests = [ + request(json!({ + "tools": tools(), + "tool_choice": "required" + })), + request(json!({ + "tools": tools(), + "tool_choice": { + "type": "function", + "function": {"name": "get_weather"} + } + })), + ]; - #[test] - fn non_k3_parsers_are_not_mistaken_for_k3() { - assert!(!uses_kimi_k3_parser(None, None)); - assert!(!uses_kimi_k3_parser(Some("kimi_k2"), Some("qwen3"))); - assert!(!uses_kimi_k3_parser(Some("qwen3_coder"), None)); + for request in &requests { + for (tool_call_parser, reasoning_parser) in [ + (Some("kimi_k2"), None), + (None, Some("kimi_k2")), + (Some("kimi_k3"), None), + (None, Some("kimi-k3")), + ] { + assert_eq!( + guided_tool_constraint(request, tool_call_parser, reasoning_parser, false,) + .expect("native-format choice is valid"), + GuidedToolConstraint::None, + ); + } + } } #[test] @@ -679,6 +713,136 @@ mod tests { } } + #[test] + fn structural_tag_off_preserves_kimi_k2_required_native_tag() { + let request = request(json!({ + "tools": tools(), + "tool_choice": "required" + })); + let preprocessor = preprocessor("kimi_k2", StructuralTagMode::Off); + let mut common_request = preprocessed_request(); + + let applied = preprocessor + .apply_tool_choice_guided_decoding(&request, &mut common_request, false) + .unwrap(); + + assert_eq!(applied, GuidedToolConstraint::StructuralTag); + assert!( + common_request + .sampling_options + .guided_decoding + .as_ref() + .and_then(|guided| guided.structural_tag.as_ref()) + .is_some() + ); + } + + #[test] + fn structural_tag_off_preserves_kimi_k3_named_native_tag() { + let request = request(json!({ + "tools": tools(), + "tool_choice": { + "type": "function", + "function": {"name": "get_weather"} + } + })); + let preprocessor = preprocessor("kimi_k3", StructuralTagMode::Off); + let mut common_request = preprocessed_request(); + + let applied = preprocessor + .apply_tool_choice_guided_decoding(&request, &mut common_request, false) + .unwrap(); + + assert_eq!(applied, GuidedToolConstraint::StructuralTag); + assert!( + common_request + .sampling_options + .guided_decoding + .as_ref() + .and_then(|guided| guided.structural_tag.as_ref()) + .is_some() + ); + } + + #[test] + fn structural_tag_off_preserves_generic_forced_tool_fallback() { + let request = request(json!({ + "tools": tools(), + "tool_choice": "required" + })); + let preprocessor = preprocessor("hermes", StructuralTagMode::Off); + let mut common_request = preprocessed_request(); + + let applied = preprocessor + .apply_tool_choice_guided_decoding(&request, &mut common_request, false) + .unwrap(); + + assert_eq!(applied, GuidedToolConstraint::GuidedJsonRequired); + assert!( + common_request + .sampling_options + .guided_decoding + .as_ref() + .and_then(|guided| guided.json.as_ref()) + .is_some() + ); + } + + #[test] + fn kimi_k3_builder_error_uses_native_fallback() { + let request = request(json!({ + "tools": [{ + "type": "function", + "function": { + "name": "get_weather", + "strict": true, + "parameters": { + "type": "object", + "properties": { + "value": { + "type": "string", + "$id": "urn:example:scoped", + "$defs": { + "allowed": {"type": "string", "enum": ["safe"]} + }, + "allOf": [{"$ref": "#/$defs/allowed"}] + } + }, + "required": ["value"] + } + } + }], + "tool_choice": "auto" + })); + let preprocessor = preprocessor("kimi_k3", StructuralTagMode::On); + let tools = convert_tools(request.inner.tools.as_deref().unwrap()); + let ctx = dynamo_parsers::tool_calling::ToolCallFormatBuildContext { + tool_choice: &ToolChoice::Auto, + tools: &tools, + parallel_tool_calls: request.inner.parallel_tool_calls, + schema_mode: preprocessor.runtime_config.structural_tag_schema, + starts_in_reasoning: false, + }; + // A scoped string reference cannot be safely rendered as raw XTML. + // Verify an actual builder error, rather than a policy skip or Ok(None). + let error = dynamo_parsers::tool_calling::StructuralTagBuilder::KimiK3 + .build_tool_call_format(&ctx) + .expect_err("scoped string reference must reach the builder-error fallback"); + let message = format!("{error:#}"); + assert!( + message.contains("value") && message.contains("string"), + "{message}" + ); + + let mut common_request = preprocessed_request(); + let applied = preprocessor + .apply_tool_choice_guided_decoding(&request, &mut common_request, false) + .expect("builder errors must allow the request to continue without a structural tag"); + + assert_eq!(applied, GuidedToolConstraint::None); + assert!(common_request.sampling_options.guided_decoding.is_none()); + } + #[test] fn kimi_k3_forced_choices_accept_dynamic_system_tools() { for tool_choice in [ diff --git a/recipes/inkling/README.md b/recipes/inkling/README.md index d669378fa23d..0fc2414f680f 100644 --- a/recipes/inkling/README.md +++ b/recipes/inkling/README.md @@ -342,18 +342,18 @@ curl -s http://localhost:8000/v1/chat/completions \ }' ``` -**Keep both structured-output flags on the worker.** `tool_choice: "required"` and named -`tool_choice` are enforced through a structural-tag grammar that needs both flags the manifests -already set: +**Keep structured output support enabled on the worker.** `tool_choice: "required"` and named +`tool_choice` are enforced through a structural-tag grammar. The manifests set: ``` --dyn-enable-structural-tag --structured-outputs-config '{"enable_in_reasoning": true}' ``` -Drop the second and the grammar is silently inert on a reasoning model — HTTP 200, no error, and -the requested tool is not enforced. Both belong on the worker; the frontend does not accept -`--dyn-enable-structural-tag`. +`--dyn-enable-structural-tag` pins Dynamo's default-on behavior. The +`--structured-outputs-config` setting remains required for this reasoning model; remove it and the +grammar is silently inert — HTTP 200, no error, and the requested tool is not enforced. Both are +worker arguments; the frontend does not accept `--dyn-enable-structural-tag`. **Image and audio input are not supported.** The Dynamo vLLM runtime has no multimodal support for this model yet, so the vLLM GB300 profiles are text-only; use the SGLang B200 profile for image and diff --git a/tests/report_pytest_markers.py b/tests/report_pytest_markers.py index 5f643ea994f8..ef4f5da25c6c 100755 --- a/tests/report_pytest_markers.py +++ b/tests/report_pytest_markers.py @@ -165,6 +165,9 @@ "fsspec.implementations.dirfs", "sglang", "sglang.srt", + "sglang.srt.constrained", + "sglang.srt.constrained.reasoner_grammar_backend", + "sglang.srt.constrained.xgrammar_backend", "sglang.srt.entrypoints", "sglang.srt.entrypoints.openai", "sglang.srt.entrypoints.openai.protocol", @@ -191,6 +194,7 @@ "sglang.srt.disaggregation.utils", "sglang.srt.server_args", "sglang.srt.server_args_config_parser", + "xgrammar", "vllm", "vllm.config", "vllm.distributed",