Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
654e942
feat(llm): integrate structural tags with parsers v2
jellysnack Aug 27, 2026
e1de372
style: fix structural tag integration formatting
jellysnack Aug 27, 2026
71d1f5b
fix(llm): update structural tag integration
jellysnack Sep 10, 2026
007ec17
fix(llm): address structural tag review feedback
jellysnack Sep 10, 2026
12c9aa6
fix(llm): preserve structural tag runtime card compatibility
jellysnack Sep 28, 2026
613931c
test(llm): cover structural tag ban in both parser versions
jellysnack Sep 28, 2026
d4969f5
fix(config): validate XGrammar version for tool argument ordering
jellysnack Sep 29, 2026
e5efc2a
fix(config): normalize boolean structural tag setting
jellysnack Sep 29, 2026
1de30e0
ci: test tool-choice-none structural tag with parsers v2
jellysnack Sep 29, 2026
bceda93
fix(llm): tolerate unknown structural tag fields on the wire
jellysnack Sep 30, 2026
2e6426f
fix(llm): scope structured output conflict validation
jellysnack Sep 30, 2026
5cb8300
fix: align structural tag CI and docs
jellysnack Sep 30, 2026
156a6f4
fix(llm): streamline structural tag preprocessing
jellysnack Oct 2, 2026
0c7cc9d
fix(config): narrow legacy structural tag option types
rmccorm4 Oct 3, 2026
7e820ee
Merge branch 'main' into structural-tags-v2-integration
luckyq Oct 5, 2026
3dde621
Merge branch 'main' into structural-tags-v2-integration
indrajit96 Oct 5, 2026
537d54b
Merge main into structural-tags-v2-integration
indrajit96 Oct 8, 2026
d321de8
FIx precommit
indrajit96 Oct 8, 2026
ba463de
Update V2 crates
indrajit96 Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ dynamo-parsers = { version = "=9.2.7" }
# web: https://crates.io/crates/dynamo-parsers-v2/versions
# sparse index: curl https://index.crates.io/dy/na/dynamo-parsers-v2 (one JSON line per release; read the "vers" field)
# Native count-before-string early streaming still needs a frontend-crates fix.
dynamo-parsers-v2 = { version = "=0.7.17" }
dynamo-parsers-v2 = { version = "=0.7.21" }
dynamo-backend-common = { path = "lib/backend-common", version = "1.6.0" }
dynamo-sidecar-common = { path = "lib/sidecar/common", version = "1.6.0" }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,21 @@ def test_worker_config_accepts_parser_runtime_settings():
)


@pytest.mark.none
def test_structural_tag_bindings_reject_unknown_config_fields():
from dynamo.llm import ModelRuntimeConfig

backend.WorkerConfig(namespace="dynamo", structural_tag={"scope": "always"})
runtime_config = ModelRuntimeConfig()
runtime_config.set_structural_tag({"scope": "always"})

with pytest.raises(ValueError, match="unknown field `scpoe`"):
backend.WorkerConfig(namespace="dynamo", structural_tag={"scpoe": "always"})

with pytest.raises(ValueError, match="unknown field `scpoe`"):
runtime_config.set_structural_tag({"scpoe": "always"})


def test_worker_config_preserves_legacy_positional_argument_order():
"""New optional fields must be appended after every existing argument."""
backend.WorkerConfig(
Expand Down Expand Up @@ -247,9 +262,14 @@ def test_python_worker_config_from_runtime_config_copies_parser_settings():
runtime_cfg.dyn_default_thinking_mode = "disabled"
runtime_cfg.exclude_tools_when_tool_choice_none = False
runtime_cfg.enable_local_indexer = False
runtime_cfg.dyn_enable_structural_tag = True
runtime_cfg.dyn_structural_tag_scope = "always"
runtime_cfg.dyn_structural_tag_schema = "strict"
runtime_cfg.structural_tag = {
"scope": "always",
"schema": "strict",
"allow_tool_calls_with_structured_output": True,
"exclude_special_tokens": False,
"reasoning_boundary": "structural_tag",
"tool_arguments_any_order": False,
}
# MagicMock auto-attrs would be rejected as a foreign type by the
# strict coercer; pin them to None.
runtime_cfg.disaggregation_mode = None
Expand All @@ -262,9 +282,7 @@ def test_python_worker_config_from_runtime_config_copies_parser_settings():
assert config.default_thinking_mode == "disabled"
assert config.exclude_tools_when_tool_choice_none is False
assert config.enable_local_indexer is False
assert config.structural_tag_mode == "on"
assert config.structural_tag_scope == "always"
assert config.structural_tag_schema == "strict"
assert config.structural_tag == runtime_cfg.structural_tag


@pytest.mark.unified
Expand All @@ -285,9 +303,7 @@ class _BareRuntime:
assert cfg.use_kv_events is False
assert cfg.custom_jinja_template is None
assert cfg.default_thinking_mode is None
assert cfg.structural_tag_mode == "off"
assert cfg.structural_tag_scope == "auto"
assert cfg.structural_tag_schema == "auto"
assert cfg.structural_tag is None


@pytest.mark.unified
Expand Down
20 changes: 3 additions & 17 deletions components/src/dynamo/common/backend/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,7 @@ class WorkerConfig:
# Operator override; when set, the Rust Worker uses this instead of
# `engine.health_check_payload()`. Populated by `from_runtime_config`.
health_check_payload: Optional[dict] = None
structural_tag_mode: str = "off"
structural_tag_scope: str = "auto"
structural_tag_schema: str = "auto"
structural_tag: Optional[dict[str, object]] = None
# When True, this worker declares an upstream Encode peer in its
# topology `needs`. Meaningful only on AGGREGATED/PREFILL roles;
# the Rust validator rejects DECODE/ENCODE + True with InvalidArgument.
Expand Down Expand Up @@ -185,17 +183,7 @@ def from_runtime_config(
),
"enable_local_indexer": getattr(runtime_cfg, "enable_local_indexer", True),
"enable_kv_routing": getattr(runtime_cfg, "enable_kv_routing", True),
"structural_tag_mode": (
"on"
if getattr(runtime_cfg, "dyn_enable_structural_tag", False)
else "off"
),
"structural_tag_scope": getattr(
runtime_cfg, "dyn_structural_tag_scope", "auto"
),
"structural_tag_schema": getattr(
runtime_cfg, "dyn_structural_tag_schema", "auto"
),
"structural_tag": getattr(runtime_cfg, "structural_tag", None),
"route_to_encoder": getattr(runtime_cfg, "route_to_encoder", False),
}
# Skip the probe when an override is supplied so callers with a foreign
Expand Down Expand Up @@ -277,9 +265,7 @@ async def run(self) -> None:
self.config.disaggregation_mode
),
health_check_payload=self.config.health_check_payload,
structural_tag_mode=self.config.structural_tag_mode,
structural_tag_scope=self.config.structural_tag_scope,
structural_tag_schema=self.config.structural_tag_schema,
structural_tag=self.config.structural_tag,
runtime=runtime_cfg,
route_to_encoder=self.config.route_to_encoder,
media_decoder=self.config.media_decoder,
Expand Down
177 changes: 143 additions & 34 deletions components/src/dynamo/common/configuration/groups/runtime_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,20 @@
import argparse
import logging
import os
from typing import List, Optional
from importlib.metadata import version as distribution_version
from typing import List, Literal, Optional

import msgspec
from packaging.version import Version

from dynamo._core import get_reasoning_parser_names, get_tool_parser_names
from dynamo.common.configuration.arg_group import ArgGroup
from dynamo.common.configuration.config_base import ConfigBase
from dynamo.common.configuration.utils import add_argument, add_negatable_bool_argument
from dynamo.common.configuration.utils import (
add_argument,
add_negatable_bool_argument,
env_or_default,
)
from dynamo.common.utils.namespace import get_worker_namespace
from dynamo.common.utils.output_modalities import OutputModality

Expand All @@ -20,6 +28,30 @@
_fpm_trace_invalid_warning_emitted = False


class StructuralTagConfig(msgspec.Struct, forbid_unknown_fields=True):
scope: Literal["auto", "always"] = "always"
schema: Literal["auto", "strict"] = "auto"
allow_tool_calls_with_structured_output: bool = False
exclude_special_tokens: Optional[bool] = None
reasoning_boundary: Literal["auto", "structural_tag", "backend"] = "auto"
tool_arguments_any_order: bool = False


_STRUCTURAL_TAG_CONFIG_DECODER = msgspec.json.Decoder(type=StructuralTagConfig)


def _parse_structural_tag(value: str) -> StructuralTagConfig | bool:
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "on"}:
return StructuralTagConfig()
if normalized in {"false", "0", "no", "off"}:
return False
try:
return _STRUCTURAL_TAG_CONFIG_DECODER.decode(value)
except msgspec.DecodeError as error:
raise argparse.ArgumentTypeError(str(error)) from error


class DynamoRuntimeConfig(ConfigBase):
"""Configuration for Dynamo runtime (common across all backends)."""

Expand All @@ -40,9 +72,11 @@ 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 = True
dyn_structural_tag_scope: str = "always"
dyn_structural_tag_schema: str = "auto"
dyn_enable_structural_tag: Optional[bool] = None
dyn_structural_tag_scope: Optional[Literal["auto", "always"]] = None
dyn_structural_tag_schema: Optional[Literal["auto", "strict"]] = None
dyn_structural_tag: Optional[StructuralTagConfig | bool] = None
structural_tag: Optional[dict[str, object]] = None
custom_jinja_template: Optional[str] = None
endpoint_types: str
dump_config_to: Optional[str] = None
Expand Down Expand Up @@ -75,6 +109,7 @@ class DynamoRuntimeConfig(ConfigBase):

def validate(self) -> None:
self.namespace = get_worker_namespace(self.namespace)
self.structural_tag = resolve_structural_tag_config(self)

# The Rust FPM sink reads this setting from the process environment.
# Canonicalize the resolved CLI/env value before the runtime or backend
Expand Down Expand Up @@ -289,40 +324,38 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
help="Exclude tool definitions from the chat template when tool_choice='none'. "
"Prevents models from generating raw XML tool calls in the content field.",
)
add_negatable_bool_argument(
g,
flag_name="--dyn-enable-structural-tag",
env_var="DYN_ENABLE_STRUCTURAL_TAG",
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.",
g.add_argument(
"--dyn-enable-structural-tag",
action=argparse.BooleanOptionalAction,
default=env_or_default("DYN_ENABLE_STRUCTURAL_TAG", None, value_type=bool),
help=argparse.SUPPRESS,
)
add_argument(
g,
flag_name="--dyn-structural-tag-scope",
env_var="DYN_STRUCTURAL_TAG_SCOPE",
default="always",
g.add_argument(
"--dyn-structural-tag-scope",
default=env_or_default("DYN_STRUCTURAL_TAG_SCOPE", None, value_type=str),
choices=["auto", "always"],
help="Controls when structural tags are activated. "
"'auto': for required/named tool_choice, and if any tool has strict=true "
"or parallel_tool_calls is false. "
"'always': also for auto without those conditions. "
"tool_choice none is unaffected by auto vs always.",
help=argparse.SUPPRESS,
)
g.add_argument(
"--dyn-structural-tag-schema",
default=env_or_default("DYN_STRUCTURAL_TAG_SCHEMA", None, value_type=str),
choices=["auto", "strict"],
help=argparse.SUPPRESS,
)
add_argument(
g,
flag_name="--dyn-structural-tag-schema",
env_var="DYN_STRUCTURAL_TAG_SCHEMA",
default="auto",
choices=["auto", "strict"],
help="Controls parameter schema strictness inside structural tags. "
"'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.",
flag_name="--dyn-structural-tag",
env_var="DYN_STRUCTURAL_TAG",
default=None,
arg_type=_parse_structural_tag,
nargs="?",
const="true",
help="Configure default-on structural tag guided decoding with a JSON object, "
"or pass false to disable optional guidance. Rust retains native tags for "
"Kimi K2 required/named and Kimi K3 named choices; Python vLLM and SGLang "
"respect the opt-out. Schema auto enforces strict omitted or true; schema "
"strict enforces every tool, overriding strict=false. Unsupported schema "
"constructs can cause backend grammar compilation errors.",
)
add_argument(
g,
Expand Down Expand Up @@ -529,3 +562,79 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
default=None,
help="Path to PEM private key for the NATS client certificate (mTLS).",
)


def _validate_xgrammar_any_order_support() -> None:
xgrammar_version = Version(distribution_version("xgrammar"))
if xgrammar_version < Version("0.2.3"):
raise ValueError(
"tool_arguments_any_order requires XGrammar >= 0.2.3; "
f"found {xgrammar_version}"
)


def resolve_structural_tag_config(
runtime_config: DynamoRuntimeConfig,
) -> Optional[dict[str, object]]:
"""Normalize the public setting and hidden legacy flags."""

structural_tag_setting = runtime_config.dyn_structural_tag
if structural_tag_setting is True:
structural_tag_setting = StructuralTagConfig()
legacy_enable = runtime_config.dyn_enable_structural_tag
legacy_scope = runtime_config.dyn_structural_tag_scope
legacy_schema = runtime_config.dyn_structural_tag_schema

legacy_options = []
if legacy_enable is not None:
legacy_options.append(
"--dyn-enable-structural-tag"
if legacy_enable
else "--no-dyn-enable-structural-tag"
)
if legacy_scope is not None:
legacy_options.append("--dyn-structural-tag-scope")
if legacy_schema is not None:
legacy_options.append("--dyn-structural-tag-schema")
if legacy_options:
logger.warning(
"%s deprecated; use --dyn-structural-tag instead",
", ".join(legacy_options),
)

if structural_tag_setting is not None:
enabled = structural_tag_setting is not False
conflicting_options = []
if legacy_enable is not None and legacy_enable != enabled:
conflicting_options.append(
"--dyn-enable-structural-tag"
if legacy_enable
else "--no-dyn-enable-structural-tag"
)
if legacy_scope is not None:
conflicting_options.append("--dyn-structural-tag-scope")
if legacy_schema is not None:
conflicting_options.append("--dyn-structural-tag-schema")
if conflicting_options:
raise ValueError(
"--dyn-structural-tag cannot be combined with legacy option(s): "
+ ", ".join(conflicting_options)
)
if not enabled:
return None
if (
isinstance(structural_tag_setting, StructuralTagConfig)
and structural_tag_setting.tool_arguments_any_order
):
_validate_xgrammar_any_order_support()
return msgspec.to_builtins(structural_tag_setting)
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved

if legacy_enable is False:
return None

return msgspec.to_builtins(
StructuralTagConfig(
scope=legacy_scope if legacy_scope is not None else "always",
schema=legacy_schema if legacy_schema is not None else "auto",
)
)
Loading
Loading