Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,12 @@ Currently tested and supported:

- `qwen3_coder`, `nemotron_nano`
- `hermes`, `qwen25`
- `deepseek_v3_2`, `deepseek_v4`
- `deepseek_v3_2`, `deepseek_v4`, `deepseek_v41`
- `kimi_k2`, `kimi_k3`, `kimi-k3`
- `inkling`
- `glm47` with parsers v2

The parsers-v2 builders for `qwen3_coder`, `deepseek_v4`, and `glm47` require
The parsers-v2 builders for `qwen3_coder`, `deepseek_v4`, `deepseek_v41`, and `glm47` require
a compatible V2 parser route in the worker and frontend processes. Unset `DYN_PARSER_VERSION` and `auto` select V2 when the configured tool and reasoning parsers resolve to a compatible family; explicit `DYN_PARSER_VERSION=2` requires V2.

Contributions adding structural tag support for new parsers are welcome.
Expand All @@ -204,6 +204,22 @@ 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.

### Checking Grammar Availability

Output-parser availability does not guarantee structural-tag support. Query the registered grammar builders separately:

```python
from dynamo._core import get_structural_tag_parser_names, get_tool_parser_names

assert "deepseek_v41" in get_tool_parser_names()
assert "deepseek_v41" in get_structural_tag_parser_names()
assert "gemma4" not in get_structural_tag_parser_names()
```

`get_structural_tag_parser_names()` returns a sorted, deduplicated inventory across the V1 and V2 registries. It does not select a parser generation, validate a tool/reasoning parser pair, or qualify the backend's installed XGrammar version. Check those deployment requirements separately.

On a compatible V2 route, `deepseek_v41` uses its V4.1 framing and `deepseek_v4_1_xml` argument style; `deepseek_v4` retains `deepseek_xml`. With structural tags enabled, the registered V4.1 builder supplies guidance by default. Set `--dyn-structural-tag false` to disable optional guidance.

## Activation Scope

The `scope` field controls when structural tags are used
Expand Down
23 changes: 22 additions & 1 deletion lib/bindings/python/rust/parsers.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use dynamo_llm::protocols::openai::chat_completions::tool_parser_v2::unified_family_names;
use dynamo_llm::protocols::openai::chat_completions::tool_parser_v2::{
structural_tag_family_names, unified_family_names,
};
use dynamo_parsers::reasoning::get_available_reasoning_parsers;
use dynamo_parsers::tool_calling::parsers::get_available_tool_parsers;
use pyo3::prelude::*;
Expand Down Expand Up @@ -29,17 +31,35 @@ pub fn get_reasoning_parser_names() -> Vec<&'static str> {
with_unified_families(get_available_reasoning_parsers())
}

/// Get selectable families with a registered grammar in either parser registry.
/// The configured parser generation is validated separately at model startup.
#[pyfunction]
pub fn get_structural_tag_parser_names() -> Vec<&'static str> {
structural_tag_family_names()
}

/// Add parsers module functions to the Python module
pub fn add_to_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(get_tool_parser_names, m)?)?;
m.add_function(wrap_pyfunction!(get_reasoning_parser_names, m)?)?;
m.add_function(wrap_pyfunction!(get_structural_tag_parser_names, m)?)?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn grammar_capabilities_are_exposed_separately_from_output_parsers() {
assert_eq!(
get_structural_tag_parser_names(),
structural_tag_family_names()
);
assert!(get_structural_tag_parser_names().contains(&"deepseek_v4"));
assert!(get_tool_parser_names().contains(&"deepseek_v41"));
}

#[test]
fn unified_families_are_selectable_in_both_lists() {
for parser in ["muse_glimmer", "muse_spark", "muse_spark_open"] {
Expand All @@ -52,5 +72,6 @@ mod tests {
"{parser} must be a selectable reasoning parser name"
);
}

}
}
8 changes: 8 additions & 0 deletions lib/bindings/python/src/dynamo/_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ def get_reasoning_parser_names() -> list[str]:
"""Get list of available reasoning parser names."""
...

def get_structural_tag_parser_names() -> list[str]:
Comment thread
keivenchang marked this conversation as resolved.
Resolved
"""Selectable families with registered grammar builders across parser generations.

A selectable output parser alone does not imply guided-decoding support.
Parser-version and model compatibility are validated separately.
"""
...

def run_kv_indexer(args: List[str]) -> None:
"""Run the KV indexer with the given arguments."""
...
Expand Down
43 changes: 43 additions & 0 deletions lib/bindings/python/tests/test_parser_capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import pytest

from dynamo._core import get_structural_tag_parser_names, get_tool_parser_names
from dynamo._core.backend import EngineConfig

pytestmark = [
pytest.mark.gpu_0,
pytest.mark.parallel,
pytest.mark.pre_merge,
pytest.mark.unit,
pytest.mark.timeout(30),
]


def test_grammar_inventory_is_sorted_unique_and_selectable():
Comment thread
keivenchang marked this conversation as resolved.
Resolved
names = get_structural_tag_parser_names()
assert names == sorted(set(names))
assert set(names) <= set(get_tool_parser_names())


@pytest.mark.parametrize(
"family", ["hermes", "deepseek_v4", "deepseek_v41", "glm45", "kimi_k25"]
)
def test_registered_grammar_is_available(family):
assert family in get_structural_tag_parser_names()


def test_selectable_output_parser_does_not_imply_grammar_availability():
assert "gemma4" in get_tool_parser_names()
assert "gemma4" not in get_structural_tag_parser_names()
assert "no_such_parser" not in get_structural_tag_parser_names()


@pytest.mark.parametrize("value", [512, 0.5, True, {"limits": [-3, 0, 2**63]}])
def test_parser_dependency_preserves_runtime_metadata_numbers(value):
Comment thread
keivenchang marked this conversation as resolved.
Resolved
# Parser dependency features also affect serializers used by runtime metadata.
config = EngineConfig(model="test", runtime_data={"value": value})
actual = config.runtime_data["value"]
assert type(actual) is type(value)
assert actual == value
48 changes: 48 additions & 0 deletions lib/llm/src/preprocessor/structural_tag/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,52 @@ mod tests {
assert!(supports_family(alias), "unsupported alias: {alias}");
}
}
#[test]
fn deepseek_dialects_keep_their_framing_and_argument_styles() {
let tools = [ToolDefinition {
name: "get_weather".into(),
parameters: Some(serde_json::json!({
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
})),
strict: Some(true),
}];
let choice = ToolChoice::Named("get_weather".into());
let request = StructuralTagBuildRequest {
tool_choice: &choice,
tools: &tools,
parallel_tool_calls: Some(false),
schema_mode: dynamo_parsers::tool_calling::StructuralTagSchemaMode::Strict,
exclude_special_tokens: None,
reasoning_boundary: super::super::ResolvedReasoningBoundary::StructuralTag,
tool_arguments_any_order: false,
starts_in_reasoning: false,
structured_output_schema: None,
};
for (family, marker, style, other_marker) in [
(
"deepseek_v4",
"<|DSML|tool_calls>",
"deepseek_xml",
"<|DSML| calls>",
),
(
"deepseek_v41",
"<|DSML| calls>",
"deepseek_v4_1_xml",
"<|DSML|tool_calls>",
),
] {
let tag = StructuralTagBuilder::for_parser(family)
.unwrap()
.build(&request)
.unwrap()
.unwrap();
let serialized = serde_json::to_string(&tag).unwrap();
assert!(serialized.contains(marker), "{family}: {serialized}");
assert!(serialized.contains(style), "{family}: {serialized}");
assert!(!serialized.contains(other_marker), "{family}: {serialized}");
}
}
}
37 changes: 37 additions & 0 deletions lib/llm/src/protocols/openai/chat_completions/tool_parser_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,28 @@ pub fn unified_family_names() -> &'static [&'static str] {
&super::unified_parser::FAMILY_NAMES
}

/// Selectable families with a registered guided-decoding grammar.
///
/// Output parser registration alone does not imply grammar support. This query
/// covers both parser registries; deployment parser-version validation remains
/// separate from this capability inventory.
pub fn structural_tag_family_names() -> Vec<&'static str> {
Comment thread
keivenchang marked this conversation as resolved.
Resolved
let legacy = dynamo_parsers::tool_calling::parsers::get_tool_parser_map();
let mut names = dynamo_parsers::tool_calling::parsers::get_available_tool_parsers();
names.extend_from_slice(unified_family_names());
names.retain(|family| {
legacy
.get(*family)
.is_some_and(|config| config.structural_tag_builder.is_some())
|| super::unified_parser::canonical_family(family)
.and_then(dynamo_parsers_v2::structural_tag_builder_for_family)
.is_some()
});
names.sort_unstable();
names.dedup();
names
}

/// Resolve the configured family through the shared generation and pair policy.
#[cfg(test)]
pub(crate) fn unified_family(
Expand Down Expand Up @@ -991,6 +1013,21 @@ mod tests {
const MUSE_TOOL: &str = "<|start|>assistant to=get_weather<|message|><atem:invoke name=\"get_weather\"><atem:parameter name=\"location\">Paris</atem:parameter></atem:invoke><|eom|>";
const MUSE_ANSWER: &str = "<|start|>assistant to=user<|message|>It's 18C.<|eot|>";

#[test]
fn grammar_capability_inventory_is_sorted_and_selectable() {
let names = structural_tag_family_names();
for family in ["deepseek_v4", "deepseek_v41", "glm45", "kimi_k25"] {
assert!(names.contains(&family), "missing grammar: {family}");
}
assert!(names.windows(2).all(|pair| pair[0] < pair[1]));
let mut selectable = dynamo_parsers::tool_calling::parsers::get_available_tool_parsers();
selectable.extend_from_slice(unified_family_names());
assert!(names.iter().all(|family| selectable.contains(family)));
assert!(selectable.contains(&"gemma4"));
assert!(!names.contains(&"gemma4"));
assert!(!names.contains(&"no_such_parser"));
}

#[test]
fn deepseek_v4_engine_aliases_select_the_v2_family() {
for alias in ["deepseek_v4", "deepseek-v4", "deepseekv4"] {
Expand Down
Loading