Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions .github/codeowners/areas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ areas:
- dynamo.code-workspace
- hatch_build.py
- lib/bindings/kvbm/pyproject.toml
- lib/sidecar/build-with-cache.sh
- lib/sidecar/test_build_cache.py
- pyproject.toml
- rust-toolchain.toml
- scripts/
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@
/container/Dockerfile.test @ai-dynamo/dynamo-ops-codeowners
/container/launch_message/ @ai-dynamo/dynamo-ops-codeowners
/container/Dockerfile.template @ai-dynamo/dynamo-ops-codeowners
/lib/sidecar/build-with-cache.sh @ai-dynamo/dynamo-ops-codeowners
/lib/sidecar/test_build_cache.py @ai-dynamo/dynamo-ops-codeowners

# === docs (@ai-dynamo/dynamo-docs-codeowners) ===
/docs/ @ai-dynamo/dynamo-docs-codeowners
Expand Down
5 changes: 2 additions & 3 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 @@ -80,7 +80,7 @@ dynamo-parsers = { version = "9.2.6" }
# Published on crates.io. To see all available versions:
# 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)
dynamo-parsers-v2 = { version = "0.7.8" }
dynamo-parsers-v2 = { version = "0.7.17" }
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 @@ -91,7 +91,7 @@ Pull, deploy, and install with every artifact pinned to the v1.3.0 release set.
### Frontend

- **GPT-OSS Harmony Parser Fixes:** Corrected the Harmony parser to preserve analysis text as normal content when reasoning parsing is unconfigured ([#9729](https://github.com/ai-dynamo/dynamo/pull/9729)), enabled GPT-OSS tool calling from Codex by retaining the `<|call|>` terminator and resolving Jinja `.items()` schema conflicts ([#9778](https://github.com/ai-dynamo/dynamo/pull/9778)), corrected thinking behavior ([#9844](https://github.com/ai-dynamo/dynamo/pull/9844)), handled bare Harmony stream markers starting directly at the commentary channel ([#9897](https://github.com/ai-dynamo/dynamo/pull/9897)), and routed Harmony reasoning, visible text, and tool-call handoff into the correct Dynamo fields by channel and recipient ([#10054](https://github.com/ai-dynamo/dynamo/pull/10054)). Preserved the directed commentary tool-call handoff to the downstream Harmony tool parser ([#10111](https://github.com/ai-dynamo/dynamo/pull/10111)), and recovered tool calls emitted on the analysis channel using the recipient as the definitive signal ([#10366](https://github.com/ai-dynamo/dynamo/pull/10366)).
- **Experimental Parsers V2 Routing:** Routed Qwen3-Coder and DeepSeek-V4 tool calls through the dynamo-parsers-v2 streaming parser behind a new `DYN_ENABLE_EXPERIMENTAL_PARSERS_V2` flag (off by default); the v2 parser owns incremental tool-call emission and drops parameter values truncated at EOF ([#10853](https://github.com/ai-dynamo/dynamo/pull/10853)). Added reasoning-parser parity cases for the "1 open + 2 close" pattern and fixed BasicReasoningParser and Gemma4ReasoningParser to keep stray close markers out of normal_text ([#10250](https://github.com/ai-dynamo/dynamo/pull/10250)).
- **Experimental Parsers V2 Routing:** Routed Qwen3-Coder and DeepSeek-V4 tool calls through the dynamo-parsers-v2 streaming parser behind an experimental V2 routing setting (off by default); the v2 parser owns incremental tool-call emission and drops parameter values truncated at EOF ([#10853](https://github.com/ai-dynamo/dynamo/pull/10853)). Added reasoning-parser parity cases for the "1 open + 2 close" pattern and fixed BasicReasoningParser and Gemma4ReasoningParser to keep stray close markers out of normal_text ([#10250](https://github.com/ai-dynamo/dynamo/pull/10250)).
- **Structural Tag Tool Calls:** Added automatic xgrammar structural tag generation for tool-calling requests, constraining decoding in each model's native tool-call format to enforce `required`/`named` tool choice, strict function schemas, `parallel_tool_calls=false`, and `tool_choice="none"` token bans. Disabled by default, configurable via `--dyn-enable-structural-tag`, supporting the hermes, qwen3_coder, deepseek_v3_2, and deepseek_v4 parsers ([#9711](https://github.com/ai-dynamo/dynamo/pull/9711)).
- **Skip Special Tokens Warning:** Added a one-time diagnostic warning in the OpenAIPreprocessor when a request forces `skip_special_tokens=true` while a special-token-dependent parser is active, a combination that silently strips parser markers and leaks tool-call or reasoning markup into content ([#10225](https://github.com/ai-dynamo/dynamo/pull/10225)).
- **Realtime API Endpoint Build-Out:** Wired the `/v1/realtime` WebSocket endpoint through ModelManager with a first-class `ModelType::Realtime` endpoint kind and bidirectional PushRouter transport ([#9308](https://github.com/ai-dynamo/dynamo/pull/9308)), and replaced the placeholder chat-completion shape with OpenAI Realtime API event types, exchanging `RealtimeClientEvent` and `RealtimeServerEvent` frames and synthesizing a spec-compliant `session.created` event on connect ([#9205](https://github.com/ai-dynamo/dynamo/pull/9205)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,46 @@ from the `bytes` arrays and compare it against `message.content` and
`message.tool_calls` to decide whether the issue is in the model output,
the parser configuration, or the parser logic.

## Parser Generation Routing
Comment thread
keivenchang marked this conversation as resolved.
Resolved

To select a parser generation, set `DYN_PARSER_VERSION=1` or `DYN_PARSER_VERSION=2`. Leave it unset or use `auto` to keep the existing family defaults. The aliases `v1` and `v2` are not accepted.

| `DYN_PARSER_VERSION` | Selection |
| --- | --- |
| Unset or `auto` | Existing family defaults |
| `1` | V1 |
| `2` | V2 |

Unsupported values stop startup. Explicit selections validate the configured tool and reasoning parsers and reject combinations that the selected generation cannot support.

| Configured family | Unset or `auto` selection |
| --- | --- |
| Muse Glimmer | V2 |
| DeepSeek V4.1 | V2 when both `tool_call_parser` and `reasoning_parser` are `deepseek_v41` |
| DeepSeek V4 | Original V1 route |
| Gemma 4 | Original V1 route |
| GLM 4.7 | Original V1 route; its reasoning name may be `glm45` |
| Kimi K2 / K2.5 | Original V1 route; configure the matching tool and reasoning names |
| Kimi K3 | Original V1 route |
| Qwen3 Coder | Original V1 route; pair `qwen3_coder` with `qwen3` |
| Other parser names | Existing family-specific route |

Version `2` uses a compatible unified parser for streaming and batch when either the tool-call parser or reasoning parser identifies a supported family. When both are configured, they must resolve to the same family. The DeepSeek V4.1 pair uses V2 by default for streaming and batch. With unset settings or `auto`, Muse streaming requests with named/required tool choices or structural tags retain their existing V1 handling; Muse batch responses use V2. Set `DYN_PARSER_VERSION=2` to require V2 for supported request modes. Tool-only and reasoning-only V1 configurations keep their existing parsers under the original family defaults.

Explicit V1 and V2 apply the same generation selection to streaming and batch. Streaming passes output chunks to the selected parser as they arrive; partial tool-call deltas depend on the family and output shape. Batch parsing waits for the complete response before constructing `message.content`, `message.reasoning_content`, and `message.tool_calls`. `tool_choice: none` suppresses tool calls while the selected content decoder removes recognized native markup from visible content.

Forced tool choices can install a guided JSON constraint, and structural-tag requests keep that constraint when the configured family has a compatible unified parser. A request mode that requires the V1 tool-call jail cannot silently fall back when explicit V2 is selected; Dynamo rejects that unsupported parser configuration before serving requests.

On the default Kimi K3 route, a prompt ending with the XTML reasoning opener `<|open|>think<|sep|>` starts parsing inside the reasoning channel; whitespace between the markers is ignored. This also applies when `DYN_PARSER_VERSION` is unset.

## Known limitations and follow-up

This revision pins the latest published `dynamo-parsers-v2` crate, version 0.7.17. It includes the merged quoted-control and streaming fixes from [frontend-crates #326](https://github.com/ai-dynamo/frontend-crates/pull/326), along with the Kimi tool-ID and call-boundary fixes from [frontend-crates #332](https://github.com/ai-dynamo/frontend-crates/pull/332). The five Dynamo regression tests that were ignored for 0.7.13 are enabled and pass against 0.7.17. The targeted `cargo test --locked -p dynamo-llm quoted_ -- --nocapture` run passed 13 unit tests, one aggregator test, and two integration tests. The every-split Muse structured-response case also passes in `cargo test --locked -p dynamo-llm response_format_explicit_reasoning_without_prompt_prefill -- --nocapture`. These checks do not cover every parser path.

A separate quoted native tool-call opener case can still cut off the rest of an answer under V2, in streaming and batch requests. The exact regression remains follow-up work tracked with related native Qwen3 and Kimi streaming changes in [Dynamo #15577](https://github.com/ai-dynamo/dynamo/pull/15577). With generic `<think>` prompt prefill, splitting `thought</think>` after `thought<` can also leave the remaining tool JSON in reasoning; this behavior is present on the existing V1 route as well. Both cases still need qualification before they can be claimed as fixed.

For configurations supported by V1, select `DYN_PARSER_VERSION=1` to use the legacy parser.

## See also

- [Tool Call Parsing (Dynamo)](tool-call-parsing.mdx) -- Dynamo-native parser names and
Expand Down
5 changes: 2 additions & 3 deletions lib/bindings/kvbm/Cargo.lock

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

5 changes: 2 additions & 3 deletions lib/bindings/python/Cargo.lock

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

6 changes: 2 additions & 4 deletions lib/bindings/python/rust/parsers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ use dynamo_parsers::reasoning::get_available_reasoning_parsers;
use dynamo_parsers::tool_calling::parsers::get_available_tool_parsers;
use pyo3::prelude::*;

/// Append the muse unified family names, skipping any already present. fc's v1
/// registries dropped muse, so it lives only in dynamo's `UNIFIED_FAMILIES`;
/// `unified_family` routes to the unified pass when EITHER the tool-call or the
/// reasoning parser is a muse name, so both name lists must accept muse.
/// Expose unified families and aliases in both selector lists, including families
/// absent from the V1 registry. Model validation checks the selected generation.
fn with_unified_families(mut names: Vec<&'static str>) -> Vec<&'static str> {
for &name in unified_family_names() {
if !names.contains(&name) {
Expand Down
94 changes: 70 additions & 24 deletions lib/llm/src/discovery/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ impl ModelWatcher {
card.download_config(self.local_model_path.as_deref())
.await?;

validate_card_parser_version(card)?;

validate_policy_worker_role(card, &self.plugins)?;

// Prepare without exact video routing unless the cohort agreed on a contract.
Expand All @@ -433,7 +435,6 @@ impl ModelWatcher {
exact video routing disabled for this group"
);
}

// Use per-worker-set router config if the worker provided one in its MDC,
// otherwise fall back to the frontend-level global config. Policy selections
// are process-local, so preserve them when the MDC supplies the base config.
Expand Down Expand Up @@ -1343,6 +1344,21 @@ fn validate_card_shape(card: &ModelDeploymentCard) -> anyhow::Result<()> {
Ok(())
}

fn validate_card_parser_version(card: &ModelDeploymentCard) -> anyhow::Result<()> {
if should_validate_parser_version(card) {
crate::protocols::openai::chat_completions::tool_parser_v2::validate_parser_version(
card.runtime_config.tool_call_parser.as_deref(),
card.runtime_config.reasoning_parser.as_deref(),
)?;
}
Ok(())
}

fn should_validate_parser_version(card: &ModelDeploymentCard) -> bool {
card.model_type.supports_chat()
&& effective_worker_type(card.worker_type, card.model_type) != WorkerType::Prefill
}

fn effective_router_config<'a>(
worker_config: Option<&'a RouterConfig>,
frontend_config: &'a RouterConfig,
Expand Down Expand Up @@ -1735,8 +1751,7 @@ mod tests {
if std::env::var("DYNAMO_ALIAS_TEST").as_deref() != Ok(test_name) {
let output = tokio::time::timeout(
Duration::from_secs(30),
tokio::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", test_name, "--nocapture"])
tokio::process::Command::from(crate::test_utils::isolated_command(test_name))
.env("DYNAMO_ALIAS_TEST", test_name)
.env("DYN_TCP_RPC_HOST", "127.0.0.1")
.env("DYN_TCP_RPC_PORT", "0")
Expand All @@ -1748,19 +1763,7 @@ mod tests {
.await
.expect("classify subprocess must finish within its deadline")
.expect("classify subprocess must start");
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout
.lines()
.any(|line| line.starts_with("test result: ok. 1 passed; 0 failed;")),
"classify subprocess must run exactly one passing test: {stdout}"
);
crate::test_utils::assert_isolated_success(&output);
return;
}

Expand Down Expand Up @@ -2094,8 +2097,7 @@ mod tests {
// more than libtest's default 2 MiB stack, like the prefill routing tests.
let output = tokio::time::timeout(
Duration::from_secs(30),
tokio::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", test_name, "--nocapture"])
tokio::process::Command::from(crate::test_utils::isolated_command(test_name))
.env("DYNAMO_CLASSIFIER_CATALOG_TEST", test_name)
.env("RUST_MIN_STACK", (4 * 1024 * 1024).to_string())
.env("DYN_TCP_RPC_HOST", "127.0.0.1")
Expand All @@ -2108,12 +2110,7 @@ mod tests {
.await
.expect("classifier subprocess timed out")
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
crate::test_utils::assert_isolated_success(&output);
return;
}
use dynamo_kv_router::scheduling::{
Expand Down Expand Up @@ -3023,6 +3020,55 @@ request_classifier:
assert!(validate_policy_worker_role(&card, &custom).is_ok());
}

#[test]
fn parser_validation_skips_non_chat_and_prefill_workers() {
if crate::test_utils::run_isolated(
concat!(
module_path!(),
"::parser_validation_skips_non_chat_and_prefill_workers"
),
&[("DYN_PARSER_VERSION", "2")],
) {
return;
}

let mut card = ModelDeploymentCard::with_name_only("model");
card.runtime_config.tool_call_parser = Some("hermes".to_string());
card.model_type = ModelType::Embedding;
assert!(validate_card_parser_version(&card).is_ok());
card.model_type = ModelType::Chat;
card.worker_type = Some(WorkerType::Prefill);
assert!(validate_card_parser_version(&card).is_ok());
for role in [None, Some(WorkerType::Decode), Some(WorkerType::Encode)] {
card.worker_type = role;
assert!(validate_card_parser_version(&card).is_err());
}
card.runtime_config.tool_call_parser = Some("qwen3_coder".to_string());
card.runtime_config.reasoning_parser = Some("qwen3".to_string());
assert!(validate_card_parser_version(&card).is_ok());
}

#[test]
fn parser_version_validation_applies_only_to_chat_surfaces() {
let mut card = ModelDeploymentCard::with_name_only("model");
card.runtime_config.tool_call_parser = Some("hermes".to_string());

card.model_type = ModelType::Embedding;
assert!(!should_validate_parser_version(&card));

card.model_type = ModelType::Chat;
assert!(should_validate_parser_version(&card));

card.worker_type = Some(WorkerType::Encode);
assert!(should_validate_parser_version(&card));

card.worker_type = Some(WorkerType::Prefill);
assert!(!should_validate_parser_version(&card));

card.worker_type = Some(WorkerType::Decode);
assert!(should_validate_parser_version(&card));
}

#[test]
fn worker_router_config_preserves_frontend_policy_selections() {
let mut frontend = RouterConfig::default();
Expand Down
7 changes: 7 additions & 0 deletions lib/llm/src/discovery/worker_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,13 @@ impl WorkerSet {
/// Build ParsingOptions from this WorkerSet's card configuration.
pub fn parsing_options(&self) -> crate::protocols::openai::ParsingOptions {
crate::protocols::openai::ParsingOptions {
default_thinking_mode: self
.card
.runtime_config
.runtime_data
.get(crate::preprocessor::DEFAULT_THINKING_MODE_RUNTIME_KEY)
.and_then(serde_json::Value::as_str)
.map(str::to_string),
structural_tag_mode: self.card.runtime_config.structural_tag_mode,
structural_tag_scope: self.card.runtime_config.structural_tag_scope,
exclude_tools_when_tool_choice_none: self
Expand Down
Loading
Loading