Skip to content

[INVESTIGATE] sglang + DSv4: kwarg workaround breaks live — needs diagnosis - #8671

Closed
ishandhanani wants to merge 21 commits into
mainfrom
ishan/investigate-tmrw
Closed

[INVESTIGATE] sglang + DSv4: kwarg workaround breaks live — needs diagnosis#8671
ishandhanani wants to merge 21 commits into
mainfrom
ishan/investigate-tmrw

Conversation

@ishandhanani

@ishandhanani ishandhanani commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Status: INVESTIGATE — do not merge

Stacked on #8665 (DeepSeek V4 parser). One commit on top — Ishan's original investigate tmrw — kept as-is.

What's wrong

When dynamo runs against a live sglang + DSv4-Pro stack, two kwargs have to be commented out for things to work at all:

  1. sglang_prepost.py:178parallel_tool_calls on FunctionCallParser.get_structure_constraint().
  2. decode_handler.py:311, 349return_routed_experts on both Engine.async_generate() call sites.

Signature inspection on sglang==0.5.10.post1 says both kwargs are accepted:

get_structure_constraint(self, tool_choice, parallel_tool_calls: bool = True) -> ...
async_generate(..., return_routed_experts: bool = False, ...)

So "sglang dropped the kwarg" isn't the story. I tried a patch that restored both (with a None → True normalization for parallel_tool_calls) and it broke against a real sglang + DSv4 run — that patch has been reverted (d8b2e8ce2b1). The commented-out workaround is the current known-working state.

What needs diagnosing

  • Passing parallel_tool_calls=True into get_structure_constraint() for the DSv4 parser: where does it actually fail? xgrammar structural-tag construction? Grammar compile? Downstream generation?
  • Passing return_routed_experts=True into async_generate(): rejected at sglang level, or downstream? Does it depend on whether MoE routed-experts are actually enabled in server_args?
  • Is this DSv4-specific or does it reproduce with other models / parsers on the same sglang version?

Repro steps (TBD)

  • Serve deepseek-ai/DeepSeek-V4-Pro via sglang 0.5.10.post1 with a DSv4-compatible tool-call parser (fallback deepseek_v31 until sglang registers deepseek_v4).
  • Send a tool_choice="auto" tool-calling request through dynamo's sglang frontend with parallel_tool_calls uncommented — capture the actual failure mode (traceback, sglang log, xgrammar error).
  • Same for return_routed_experts with enable_return_routed_experts=True in server_args.
  • Decide real fix: version-gate, feature flag, upstream sglang PR, or keep workaround with a pinned comment referencing a filed upstream issue.

Related

ishandhanani and others added 9 commits April 23, 2026 22:48
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
…el_type

Previously `PromptFormatter::from_mdc` activated the V4 / V3.2 native
formatters based on a substring match against `mdc.display_name`:

    name_lower.contains("deepseek") && name_lower.contains("v4")

Two problems:

1. **Rename-fragile** — `display_name` is overwritten by
   `--served-model-name`, so a valid DeepSeek-V4 checkpoint served as
   `dsflash` (or any other operator-chosen alias) silently fell through
   to the HF Jinja path and the native Rust formatter never fired.
2. **Loose** — `contains("v4")` mis-matched composite names like
   `deepseek-v3.2-v4-foo`, short-circuiting to V4 before the V3.2
   branch.

Rework detection to key off `config.json` `model_type`, which
DeepSeek-V4-Pro / V4-Flash both ship as `"deepseek_v4"` (verified on
the HF repo). The config value is set by the model author and survives
any rename. `display_name` is kept only as a fallback for MDCs that
don't have a loadable config.json (tokenizer-only deployments), and a
concrete-but-different config.json value now authoritatively suppresses
the fallback — a `model_type: "llama"` model served as
`deepseek-v4-flash` no longer gets the V4 formatter.

Also tightens the display-name fallback to an anchored match equivalent
to `^deepseek(?:[-_.])?v4(?:[-_.]|$)` (no `regex` crate dep), closing
the composite-name hole flagged as N5 in the V4 review.

Unit tests cover: canonical V4 variants (hyphen / underscore / dot /
concat separator), negative cases (composite / non-V4 / mis-prefixed),
config-primary precedence, and the V3.2 symmetric case.
`HFConfig::model_type` is a required field at parse time, so a missing
entry fails deserialization and `get_model_info().ok()` drops the whole
`ModelInfo` — we correctly fall back to the display-name heuristic.

But `"model_type": ""` (rare, legal JSON) parses cleanly into
`Some(String::new())`, hits the `Some(_) => false` arm in
`is_deepseek_v4`, and *suppresses* the display-name fallback. A
DeepSeek-V4 model served as `deepseek-v4-flash` with an empty
`model_type` would silently miss the native formatter.

Normalize empty strings to `None` via `.filter(|s| !s.is_empty())` so
"no signal from config.json" behaves uniformly regardless of whether
the field was absent or blank.
@ishandhanani
ishandhanani requested review from a team as code owners April 24, 2026 08:00
@ishandhanani
ishandhanani requested a review from a team April 24, 2026 08:00
@copy-pr-bot

copy-pr-bot Bot commented Apr 24, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions Bot added backend::sglang Relates to the sglang backend frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` actions labels Apr 24, 2026
`to_json` serializes tool schemas, response-format bodies, and arbitrary
message-content fallbacks through a custom `PythonFormatter`. The buffer
was `Vec::with_capacity(64)` — too small for anything beyond a trivial
value, and worse than `Vec::new()` for tiny values since it forces an
up-front heap alloc that's immediately outgrown.

For a ~5 KB tool schema (200-field array), 64 bytes triggers 6 sequential
doublings (64→128→256→512→1024→2048→4096→8192), each a memcpy of the
current contents. Callers hit this on every request that includes tools
or a response_format, so the overhead is per-request on the hot path.

Pre-size the buffer from one compact `serde_json::to_string` pass. The
`PythonFormatter` adds exactly 1 byte per structural separator (`,`→`, `
and `:`→`: `), which bounds the final length by ~12.5% over compact —
`compact_len + compact_len/8` is a tight upper bound that eliminates all
reallocations in the formatted pass. A 256-byte floor keeps tiny payloads
from under-allocating after empty-map round-trips.

Adds a ~5 KB round-trip test (`test_to_json_handles_large_payload`) that
pins:
- Output parses back to the input (no truncation).
- Python-style spacing survives deep nesting (no bare `",", / `":"`).

All 7 deepseek_v4 unit tests pass including the new one.
@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This pull request introduces comprehensive DeepSeek V4 model support, including a native Rust prompt formatter with DSML tool-calling, message normalization, and role-based rendering. It adds reasoning-mode detection, updates formatter selection logic, extends parser registries, and includes extensive test fixtures and streaming integration tests.

Changes

Cohort / File(s) Summary
Build configuration
.github/workflows/copyright-check.ps1
Added path prefix lib/llm/tests/data/deepseek-v4 to the copyright-check ignore list.
Frontend parameter updates
components/src/dynamo/frontend/sglang_prepost.py, components/src/dynamo/sglang/request_handlers/llm/decode_handler.py
Commented out parallel_tool_calls and return_routed_experts parameters in sglang engine calls while retaining downstream processing logic.
DeepSeek V4 prompt formatter
lib/llm/src/preprocessor/prompt/deepseek_v4.rs
Added complete 1006-line native Rust prompt formatter implementing DeepSeek-specific token constants, DSML tool-call rendering, JSON serialization, message normalization, role-based rendering with reasoning-effort prefixes, and optional thinking-turn dropping with optional ThinkingMode, ReasoningEffort enums and message pre-processing utilities.
Reasoning parser integration
lib/llm/src/preprocessor.rs
Extended reasoning-disable logic to treat deepseek_v4 and its variants as reasoning-parser aliases alongside deepseek_r1; updated doc comment and expanded test coverage to validate disable/enable outcomes under various thinking argument combinations.
Prompt module structure
lib/llm/src/preprocessor/prompt.rs
Added pub mod deepseek_v4; export alongside existing deepseek_v32 module.
Formatter selection refactoring
lib/llm/src/preprocessor/prompt/template.rs
Refactored PromptFormatter::from_mdc to prioritize config-derived model_type (lowercased) for DeepSeek version selection over display_name substring matching; added helper functions and tests for explicit family suppression and anchored V4 display-name matching.
Test fixtures — basic scenarios
lib/llm/tests/data/deepseek-v4/test_input_*.json, lib/llm/tests/data/deepseek-v4/test_output_*.txt
Added four input/output fixture pairs covering tool invocation (weather query), multi-turn reasoning, developer-mode search interactions, and action-task scenarios with special locale/context handling.
Test fixtures — vLLM streaming
lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_*.json
Added seven streaming test fixtures validating tool-call parsing across scenarios: single/multiple tools, no tools, content-before-tool ordering, fragmented tokens, mixed parameter types, and special character robustness.
Integration test suites
lib/llm/tests/deepseek_v4_encoding.rs, lib/llm/tests/test_streaming_tool_parsers.rs
Added message encoding test suite (4 test cases) comparing fixture inputs against expected outputs and end-to-end streaming parser suite (7 async test cases) validating reasoning/tool-call extraction across streamed chunks.
Reasoning parser support
lib/parsers/src/reasoning/mod.rs
Added three deepseek_v4 aliases to reasoning parser lookup mapped to Qwen parser type; added tests for tag detection, streaming, and missing-tag edge cases.
Tool-calling parser configuration
lib/parsers/src/tool_calling/config.rs
Added pub fn deepseek_v4() constructor setting DSML boundaries to <|DSML|tool_calls></|DSML|tool_calls> wrapper tokens.
Tool-calling DSML parser tests
lib/parsers/src/tool_calling/dsml/parser.rs
Extended test suite with V4 configuration tests validating tool-call start/end detection and full parsing with parameter type conversion for V4 wrapper tokens.
Tool-calling parser registry
lib/parsers/src/tool_calling/parsers.rs
Added three deepseek_v4 string aliases to tool parser registry; updated parser-availability tests and added async tests verifying single tool-call parsing and argument type conversion across alias variants.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The PR title '[INVESTIGATE] sglang + DSv4: kwarg workaround breaks live — needs diagnosis' uses vague language like 'breaks live' and 'needs diagnosis' without clearly stating what the actual fix is or what was changed. Revise the title to clearly describe the specific changes made (e.g., 'Fix DeepSeek V4 tool calling and reasoning parser support for sglang') rather than framing it as an investigation of undiagnosed breakage.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is comprehensive and detailed, covering the investigation status, specific problems identified, diagnostic questions, and repro steps with clear checklists.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
components/src/dynamo/sglang/request_handlers/llm/decode_handler.py (1)

275-277: ⚠️ Potential issue | 🟠 Major

Remove the unused return_routed_experts variable to fix Ruff F841 pipeline failure.

The assignment at lines 275-277 is dead code: both async_generate call sites (311, 349) have the parameter commented out. The downstream stream processors (_process_token_stream at line 431 and _process_text_stream at line 513) do attempt to extract and handle routed_experts from the engine response, but because the kwarg is disabled, the engine will never populate that field—effectively disabling the feature. Either remove this assignment and clean up the commented-out parameters, or uncomment the parameters and resolve any sglang API compatibility issues. There is no TODO explaining the temporary disable, so the intent is unclear.

🔧 Proposed fix (remove the dead variable)
         sampling_params = self._build_sampling_params(request)
         input_param = self._get_input_param(request)
-        return_routed_experts = getattr(
-            self.config.server_args, "enable_return_routed_experts", False
-        )
         priority = (request.get("routing") or {}).get("priority")

Also applies to: 311-311, 349-349

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/src/dynamo/sglang/request_handlers/llm/decode_handler.py` around
lines 275 - 277, Remove the dead local assignment return_routed_experts in
decode_handler.py and clean up the related commented-out kwarg usage: delete the
return_routed_experts = getattr(...) line, remove or permanently uncomment the
commented 'return_routed_experts' kwargs at the async_generate call sites (the
calls to async_generate in this module), and ensure downstream processors
(_process_token_stream and _process_text_stream) either rely on the
engine-populated routed_experts field or are updated to not expect it; this
removes the unused variable and resolves the Ruff F841 failure while keeping the
async_generate and stream-processing flow consistent.
🧹 Nitpick comments (9)
lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_mixed_param_types.json (1)

3-3: Minor: inconsistent finish_reason assertion across sibling fixtures.

expected_output here omits finish_reason, whereas chat_completion_stream_special_chars.json asserts "finish_reason": "tool_calls". Since the final stream delta (line 15) does emit finish_reason: "tool_calls", consider adding the assertion here (and in chat_completion_stream_tool.json) for parity — otherwise a regression that drops the terminal finish event would silently pass this fixture.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_mixed_param_types.json`
at line 3, The fixture's expected_output is missing the terminal "finish_reason"
assertion which other sibling fixtures (e.g.,
chat_completion_stream_special_chars.json) include; update the expected_output
object to include "finish_reason": "tool_calls" alongside the existing
"normal_content", "reasoning_content", and "tool_calls" entries so the final
stream delta that emits finish_reason is validated (refer to the
"expected_output" object, the "tool_calls" array and the "call_1" entry to
locate the right fixture and place the "finish_reason" key).
lib/llm/src/preprocessor/prompt/deepseek_v4.rs (6)

806-866: render injects tools/response_format without re-normalizing the synthetic system message.

normalize_message_contents runs at Line 818 before the block at Lines 832-863 that either augments an existing system message or inserts a new one with "content": "". The injected/augmented tools and response_format fields are raw serde_json::Value from req.tools() / req.response_format() and skip any normalization. This is fine today because render_tools and RESPONSE_FORMAT_TEMPLATE consume them as JSON, but if normalization is ever extended to cover tool/response_format shapes (e.g., stripping nulls), the pre/post ordering will silently diverge.

Minor: also note that when no system message exists, insert(0, system_msg) pushes the ASSISTANT_START transition logic one index down; this is tested implicitly via fixtures, but worth a brief comment explaining why the order matters.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/llm/src/preprocessor/prompt/deepseek_v4.rs` around lines 806 - 866, The
render function injects raw tools/response_format JSON into the system message
after normalize_message_contents already ran, so those fields skip
normalization; update render (in deepseek_v4.rs) to normalize the
injected/augmented system message contents after adding tools/response_format
(e.g., call normalize_message_contents on the modified messages_array or
explicitly normalize the new system_msg object) so injected fields receive the
same normalization rules as existing messages, and add a brief comment near the
messages_array.insert(0, system_msg) noting that inserting at index 0 shifts the
ASSISTANT_START transition index.

163-177: Document the -1-sentinel semantics alongside the helper, not just inside render_message.

The doc comment says callers should use is_none_or(|u| idx >= u) to mirror Python's idx >= -1, but the two primary call sites (Lines 397, 477, 677) each re-derive this pattern with subtly different boundary conditions (>, >=). A regression here already has a dedicated test (test_assistant_reasoning_preserved_when_no_user_in_history), which is great — consider adding a small helper like fn at_or_after_last_user(idx: usize, last_user_idx: Option<usize>) -> bool to centralize the sentinel logic and prevent the next drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/llm/src/preprocessor/prompt/deepseek_v4.rs` around lines 163 - 177, The
helper find_last_user_index currently documents Python's "-1" sentinel but
callers re-implement the boundary logic inconsistently; add a small helper fn
at_or_after_last_user(idx: usize, last_user_idx: Option<usize>) -> bool that
returns true when last_user_idx is None or idx >= last_user_idx (matching the
intended idx >= -1 semantics), update the three call sites that currently
compare idx using > or >= to call at_or_after_last_user instead, and keep or
move the sentinel explanation to the new helper's doc comment so the behavior is
centralized and tested.

208-222: Redundant normalization for already-string content.

normalize_message_contents calls extract_visible_text even when content is already a JsonValue::String, then replaces the original with a clone of itself. Minor but adds a needless allocation per message on a hot path.

♻️ Proposed refactor
     for msg in messages {
         let Some(content) = msg.get("content") else {
             continue;
         };
-        // Leave non-string/non-array content untouched (null, etc.)
-        if !content.is_string() && !content.is_array() {
+        // Only arrays (multimodal chunks) need rewriting; strings are already
+        // text and other shapes (null, etc.) are left as-is.
+        if !content.is_array() {
             continue;
         }
         let normalized = extract_visible_text(content);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/llm/src/preprocessor/prompt/deepseek_v4.rs` around lines 208 - 222,
normalize_message_contents is needlessly calling extract_visible_text for
JsonValue::String values causing an extra allocation; update the function so it
returns early for string content (leave it untouched) and only call
extract_visible_text when content.is_array() (or other non-string types you
intend to normalize), then replace the "content" key only in that branch;
reference normalize_message_contents, msg, content, and extract_visible_text
when making the change.

147-158: Chained .replace on TOOLS_TEMPLATE is order-sensitive and scans rendered schemas.

Since {tool_schemas} is substituted first, any subsequent .replace("{dsml_token}", ...) / .replace("{thinking_start_token}", ...) also scans the just-substituted tool-schema text. Extremely unlikely to collide (tool schemas are user-provided but the placeholders are unusual), but formally incorrect and easy to tighten.

♻️ Proposed refactor

Substitute the static tokens first and the user-controlled schemas last:

-    TOOLS_TEMPLATE
-        .replace("{tool_schemas}", &tools_json.join("\n"))
-        .replace("{dsml_token}", tokens::DSML_TOKEN)
-        .replace("{thinking_start_token}", tokens::THINKING_START)
-        .replace("{thinking_end_token}", tokens::THINKING_END)
+    TOOLS_TEMPLATE
+        .replace("{dsml_token}", tokens::DSML_TOKEN)
+        .replace("{thinking_start_token}", tokens::THINKING_START)
+        .replace("{thinking_end_token}", tokens::THINKING_END)
+        .replace("{tool_schemas}", &tools_json.join("\n"))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/llm/src/preprocessor/prompt/deepseek_v4.rs` around lines 147 - 158, The
current render_tools function chains .replace on TOOLS_TEMPLATE in an order that
substitutes {tool_schemas} first which causes subsequent .replace calls to scan
user-provided schema text; update render_tools so it first replaces the static
placeholders ({dsml_token}, {thinking_start_token}, {thinking_end_token}) on
TOOLS_TEMPLATE (using tokens::DSML_TOKEN, tokens::THINKING_START,
tokens::THINKING_END) and only after that injects the user-controlled
{tool_schemas} (the tools_json.join result) to avoid scanning or altering user
content; keep references to the same TOOLS_TEMPLATE and
tools_from_openai_format/to_json pipeline but swap the replace order
accordingly.

869-1006: Inline unit coverage is light relative to the surface area.

The inline tests exercise test_simple_conversation, reasoning-effort prefix, tool-result merging, auto-disable, and two regression cases — solid foundation. But several non-trivial branches have no dedicated unit coverage and are only exercised (if at all) by the external fixture tests in tests/deepseek_v4_encoding.rs:

  • "developer" role rendering + the task/task_token branches (especially task == "action").
  • wo_eos=true on assistant messages.
  • sort_tool_results_by_call_order with multiple assistants / stale last_order (see the other comment).
  • encode_arguments_to_dsml JSON-parse fallback path ({"arguments": raw_string}).
  • DeepSeekV4Formatter::render tools/response_format injection when no system message exists.

Given the PR's own title flags bug-fixing in flight, consider pulling 2–3 of these into dedicated #[test]s so regressions surface without needing the large fixture files to be perfectly aligned.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/llm/src/preprocessor/prompt/deepseek_v4.rs` around lines 869 - 1006, Add
focused unit tests to cover untested branches: write new #[test]s in the same
tests module that exercise (1) rendering of messages with role "developer" and a
message having task == "action" to hit the task/task_token branch (target
encode_messages/DeepSeekV4Formatter rendering of developer/task), (2) an
assistant message with wo_eos=true to ensure the assistant EOS suppression path
is exercised (target encode_messages/encode_messages_with_options usage), and
(3) the encode_arguments_to_dsml fallback where the arguments are a raw JSON
string (target encode_arguments_to_dsml to exercise its JSON-parse fallback).
Also add one test invoking DeepSeekV4Formatter::render with no system message to
verify tools/response_format injection path; each test should assert the
specific expected token snippets so regressions fail locally rather than relying
on external fixtures.

93-136: to_json silently returns "{}" on serializer/UTF-8 failure.

Both value.serialize(&mut ser).is_err() and String::from_utf8(buf).unwrap_or_else(|_| "{}"...) swallow the underlying error and return a literal "{}". For serde_json::Value input this is effectively unreachable, but if anyone extends callers (e.g., passing something non-roundtrippable), the result silently becomes "{}" embedded in the prompt with zero log/trace — a very hard bug to diagnose in production prompt output.

Consider either propagating the error via Result<String> (most of the callers already return Result<String>), or at minimum logging a tracing::error! with context so this never fails silently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/llm/src/preprocessor/prompt/deepseek_v4.rs` around lines 93 - 136, The
to_json function currently swallows serialization and UTF-8 errors and returns
the literal "{}"; change to_json so it surfaces failures instead of hiding them:
either (preferred) change its signature to return Result<String,
serde_json::Error> (or a suitable Box<dyn Error>) and propagate errors from
value.serialize(...) and String::from_utf8(...).map_err(...), or if you must
keep a String return, log the error via tracing::error! including context (e.g.,
the JsonValue or what operation failed) before returning a fallback; update all
callers of to_json in this module to handle the Result or accommodate the logged
failure. Ensure you modify the to_json function and call sites (references to
to_json) accordingly.
lib/llm/tests/deepseek_v4_encoding.rs (1)

43-76: Diff output only surfaces the first differing line.

On mismatch the break at Line 64 stops printing after the first divergence, which is noisy when fixtures drift across many lines (common when the DSML renderer changes). Consider printing all differing line pairs, or falling back to pretty_assertions::assert_eq! which produces a proper multiline diff — it's already used elsewhere in the crate if added, and the test reads much better.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/llm/tests/deepseek_v4_encoding.rs` around lines 43 - 76, The test helper
run_official_test currently stops after printing the first differing line
because of the break inside the loop; remove the break so the loop continues and
prints all differing line pairs (or alternatively replace the manual comparison
with a call to pretty_assertions::assert_eq!(exp, act) to get a full multiline
diff); ensure you import pretty_assertions if you choose that route and drop the
manual per-line panic so the assertion provides the failure message from
assert_eq! instead of the current panic at the end.
lib/llm/src/preprocessor/prompt/template.rs (1)

234-243: is_deepseek_v3_2_non_exp precedence is subtly different from is_deepseek_v4.

When model_type_lower == Some("deepseek_v3_2"), the function still consults display_name to veto on "exp", but when model_type_lower == Some(other) the fallback name_match is ignored. That's internally consistent with the "V3.2-Exp is a separate family" note, but the variable name_match is now computed on every call and only read in the None branch.

Minor nit: either inline the computation inside the None arm (so the helper doesn't do the contains scan for the common Some(_) case), or note explicitly in the doc comment that the "exp" suppression is intentional even when config is authoritative.

♻️ Proposed refactor
 fn is_deepseek_v3_2_non_exp(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
-    let name_match = display_name_lower.contains("deepseek")
-        && display_name_lower.contains("v3.2")
-        && !display_name_lower.contains("exp");
     match model_type_lower.as_deref() {
         Some("deepseek_v3_2") => !display_name_lower.contains("exp"),
         Some(_) => false,
-        None => name_match,
+        None => {
+            display_name_lower.contains("deepseek")
+                && display_name_lower.contains("v3.2")
+                && !display_name_lower.contains("exp")
+        }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/llm/src/preprocessor/prompt/template.rs` around lines 234 - 243, The
helper is_deepseek_v3_2_non_exp currently computes name_match eagerly yet only
uses it in the None branch, causing an unnecessary contains scan and obscuring
intent; change it to compute the display_name_lower checks (contains "deepseek",
contains "v3.2", and !contains "exp") inline inside the None arm of the match so
the Some("deepseek_v3_2") and Some(_) branches remain fast and behavior is
unchanged, or alternatively update the doc comment on is_deepseek_v3_2_non_exp
to explicitly state that the "exp" suppression is intentionally applied when
model_type_lower == Some("deepseek_v3_2").
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@components/src/dynamo/frontend/sglang_prepost.py`:
- Around line 175-178: The call to parser.get_structure_constraint(...)
currently has the parallel_tool_calls kwarg commented out causing inconsistent
behavior vs get_json_schema_constraint(...); inspect
FunctionCallParser.get_structure_constraint and either re-add the
parallel_tool_calls=parallel_tool_calls argument if the signature still accepts
it, or remove the commented line and add a short comment explaining that
get_structure_constraint no longer accepts parallel_tool_calls (and why) so the
omission is explicit; ensure parity in handling of the parallel_tool_calls
setting across the tool_choice branches (reference
parser.get_structure_constraint, parser.get_json_schema_constraint,
parallel_tool_calls, and tool_choice).

In `@lib/llm/src/preprocessor/prompt/deepseek_v4.rs`:
- Around line 225-260: encode_arguments_to_dsml currently injects raw parameter
keys and string argument values into DSML which can break XML/DSML parsing; add
proper XML escaping for attribute and element text: implement or reuse an
escape_xml function and apply it to the parameter name (key) and to string
values before interpolating into the tokenized DSML fragment (leave non-string
values using to_json as-is). Update the formatting call sites inside
encode_arguments_to_dsml (the loop that builds params using tokens::DSML_TOKEN)
to use escaped_key and escaped_value for the name and element content
respectively, ensuring &, <, >, " and ' are converted to XML entities.

In `@lib/llm/src/preprocessor/prompt/template.rs`:
- Line 266: Clippy flags manual-pattern-char-comparison for the predicate used
in starts_with; change the pattern-match on a char to direct char comparisons in
both occurrences (the one using after_v4 and the identical one around line 259)
by replacing matches!(c, '-' | '_' | '.') with explicit comparisons like c ==
'-' || c == '_' || c == '.' so the starts_with closure becomes e.g.
after_v4.is_empty() || after_v4.starts_with(|c: char| c == '-' || c == '_' || c
== '.').

In `@lib/llm/tests/data/deepseek-v4/test_output_1.txt`:
- Line 36: Replace the non-conforming tool output tag sequence
<|User|><tool_result>...</tool_result> in the fixture with the DeepSeek V4
template tags <|tool▁output▁begin|>...<|tool▁output▁end|>; locate the string
occurrence (e.g., the fragment containing "<|User|><tool_result>") and swap it
to the canonical "<|tool▁output▁begin|>{result content}<|tool▁output▁end|>"
format so the fixture matches sglang/DeepSeek V4 expectations.

In `@lib/llm/tests/deepseek_v4_encoding.rs`:
- Around line 27-34: In load_messages (the block that reads raw.get("messages")
and tries messages.get_mut(0) to insert "tools"), add an explicit check that
when raw.get("tools") is Some but the messages array is empty you fail loudly
(panic or assert) with a descriptive message instead of silently dropping tools;
i.e., detect tools present && messages.is_empty() and panic with context
(mention tools and fixture) otherwise proceed to insert tools into the first
message as the existing obj.insert("tools", ...).

In `@lib/llm/tests/test_streaming_tool_parsers.rs`:
- Around line 1208-1216: The test only validates the first tool call because
run_deepseek_v4_tool_call_fixture delegates to assert_tool_calls which currently
compares only tool_calls[0] against expected_tool_calls[0]; update the assertion
logic so all tool call pairs are checked: modify assert_tool_calls to iterate
over all indices (e.g., for i in 0..tool_calls.len()) and compare
tool_calls[i].name and tool_calls[i].arguments to expected_tool_calls[i], or
alternatively add explicit assertions in test_deepseek_v4_e2e_multi_tool_vllm
that assert tool_calls[1] matches expected_tool_calls[1]; use the existing
identifiers (run_deepseek_v4_tool_call_fixture, assert_tool_calls, tool_calls,
expected_tool_calls, test_deepseek_v4_e2e_multi_tool_vllm) to locate and change
the code.

---

Outside diff comments:
In `@components/src/dynamo/sglang/request_handlers/llm/decode_handler.py`:
- Around line 275-277: Remove the dead local assignment return_routed_experts in
decode_handler.py and clean up the related commented-out kwarg usage: delete the
return_routed_experts = getattr(...) line, remove or permanently uncomment the
commented 'return_routed_experts' kwargs at the async_generate call sites (the
calls to async_generate in this module), and ensure downstream processors
(_process_token_stream and _process_text_stream) either rely on the
engine-populated routed_experts field or are updated to not expect it; this
removes the unused variable and resolves the Ruff F841 failure while keeping the
async_generate and stream-processing flow consistent.

---

Nitpick comments:
In `@lib/llm/src/preprocessor/prompt/deepseek_v4.rs`:
- Around line 806-866: The render function injects raw tools/response_format
JSON into the system message after normalize_message_contents already ran, so
those fields skip normalization; update render (in deepseek_v4.rs) to normalize
the injected/augmented system message contents after adding
tools/response_format (e.g., call normalize_message_contents on the modified
messages_array or explicitly normalize the new system_msg object) so injected
fields receive the same normalization rules as existing messages, and add a
brief comment near the messages_array.insert(0, system_msg) noting that
inserting at index 0 shifts the ASSISTANT_START transition index.
- Around line 163-177: The helper find_last_user_index currently documents
Python's "-1" sentinel but callers re-implement the boundary logic
inconsistently; add a small helper fn at_or_after_last_user(idx: usize,
last_user_idx: Option<usize>) -> bool that returns true when last_user_idx is
None or idx >= last_user_idx (matching the intended idx >= -1 semantics), update
the three call sites that currently compare idx using > or >= to call
at_or_after_last_user instead, and keep or move the sentinel explanation to the
new helper's doc comment so the behavior is centralized and tested.
- Around line 208-222: normalize_message_contents is needlessly calling
extract_visible_text for JsonValue::String values causing an extra allocation;
update the function so it returns early for string content (leave it untouched)
and only call extract_visible_text when content.is_array() (or other non-string
types you intend to normalize), then replace the "content" key only in that
branch; reference normalize_message_contents, msg, content, and
extract_visible_text when making the change.
- Around line 147-158: The current render_tools function chains .replace on
TOOLS_TEMPLATE in an order that substitutes {tool_schemas} first which causes
subsequent .replace calls to scan user-provided schema text; update render_tools
so it first replaces the static placeholders ({dsml_token},
{thinking_start_token}, {thinking_end_token}) on TOOLS_TEMPLATE (using
tokens::DSML_TOKEN, tokens::THINKING_START, tokens::THINKING_END) and only after
that injects the user-controlled {tool_schemas} (the tools_json.join result) to
avoid scanning or altering user content; keep references to the same
TOOLS_TEMPLATE and tools_from_openai_format/to_json pipeline but swap the
replace order accordingly.
- Around line 869-1006: Add focused unit tests to cover untested branches: write
new #[test]s in the same tests module that exercise (1) rendering of messages
with role "developer" and a message having task == "action" to hit the
task/task_token branch (target encode_messages/DeepSeekV4Formatter rendering of
developer/task), (2) an assistant message with wo_eos=true to ensure the
assistant EOS suppression path is exercised (target
encode_messages/encode_messages_with_options usage), and (3) the
encode_arguments_to_dsml fallback where the arguments are a raw JSON string
(target encode_arguments_to_dsml to exercise its JSON-parse fallback). Also add
one test invoking DeepSeekV4Formatter::render with no system message to verify
tools/response_format injection path; each test should assert the specific
expected token snippets so regressions fail locally rather than relying on
external fixtures.
- Around line 93-136: The to_json function currently swallows serialization and
UTF-8 errors and returns the literal "{}"; change to_json so it surfaces
failures instead of hiding them: either (preferred) change its signature to
return Result<String, serde_json::Error> (or a suitable Box<dyn Error>) and
propagate errors from value.serialize(...) and
String::from_utf8(...).map_err(...), or if you must keep a String return, log
the error via tracing::error! including context (e.g., the JsonValue or what
operation failed) before returning a fallback; update all callers of to_json in
this module to handle the Result or accommodate the logged failure. Ensure you
modify the to_json function and call sites (references to to_json) accordingly.

In `@lib/llm/src/preprocessor/prompt/template.rs`:
- Around line 234-243: The helper is_deepseek_v3_2_non_exp currently computes
name_match eagerly yet only uses it in the None branch, causing an unnecessary
contains scan and obscuring intent; change it to compute the display_name_lower
checks (contains "deepseek", contains "v3.2", and !contains "exp") inline inside
the None arm of the match so the Some("deepseek_v3_2") and Some(_) branches
remain fast and behavior is unchanged, or alternatively update the doc comment
on is_deepseek_v3_2_non_exp to explicitly state that the "exp" suppression is
intentionally applied when model_type_lower == Some("deepseek_v3_2").

In
`@lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_mixed_param_types.json`:
- Line 3: The fixture's expected_output is missing the terminal "finish_reason"
assertion which other sibling fixtures (e.g.,
chat_completion_stream_special_chars.json) include; update the expected_output
object to include "finish_reason": "tool_calls" alongside the existing
"normal_content", "reasoning_content", and "tool_calls" entries so the final
stream delta that emits finish_reason is validated (refer to the
"expected_output" object, the "tool_calls" array and the "call_1" entry to
locate the right fixture and place the "finish_reason" key).

In `@lib/llm/tests/deepseek_v4_encoding.rs`:
- Around line 43-76: The test helper run_official_test currently stops after
printing the first differing line because of the break inside the loop; remove
the break so the loop continues and prints all differing line pairs (or
alternatively replace the manual comparison with a call to
pretty_assertions::assert_eq!(exp, act) to get a full multiline diff); ensure
you import pretty_assertions if you choose that route and drop the manual
per-line panic so the assertion provides the failure message from assert_eq!
instead of the current panic at the end.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c83103aa-a3b7-4a52-b391-08db337b6d03

📥 Commits

Reviewing files that changed from the base of the PR and between 7ae0c39 and 9d3c913.

📒 Files selected for processing (28)
  • .github/workflows/copyright-check.ps1
  • components/src/dynamo/frontend/sglang_prepost.py
  • components/src/dynamo/sglang/request_handlers/llm/decode_handler.py
  • lib/llm/src/preprocessor.rs
  • lib/llm/src/preprocessor/prompt.rs
  • lib/llm/src/preprocessor/prompt/deepseek_v4.rs
  • lib/llm/src/preprocessor/prompt/template.rs
  • lib/llm/tests/data/deepseek-v4/test_input_1.json
  • lib/llm/tests/data/deepseek-v4/test_input_2.json
  • lib/llm/tests/data/deepseek-v4/test_input_3.json
  • lib/llm/tests/data/deepseek-v4/test_input_4.json
  • lib/llm/tests/data/deepseek-v4/test_output_1.txt
  • lib/llm/tests/data/deepseek-v4/test_output_2.txt
  • lib/llm/tests/data/deepseek-v4/test_output_3.txt
  • lib/llm/tests/data/deepseek-v4/test_output_4.txt
  • lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_content_before_tool.json
  • lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_fragmented_tokens.json
  • lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_mixed_param_types.json
  • lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_multi_tool.json
  • lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_no_tool.json
  • lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_special_chars.json
  • lib/llm/tests/data/vllm/deepseek-v4/chat_completion_stream_tool.json
  • lib/llm/tests/deepseek_v4_encoding.rs
  • lib/llm/tests/test_streaming_tool_parsers.rs
  • lib/parsers/src/reasoning/mod.rs
  • lib/parsers/src/tool_calling/config.rs
  • lib/parsers/src/tool_calling/dsml/parser.rs
  • lib/parsers/src/tool_calling/parsers.rs

Comment thread components/src/dynamo/frontend/sglang_prepost.py
Comment thread lib/llm/src/preprocessor/prompt/deepseek_v4.rs
Comment thread lib/llm/src/preprocessor/prompt/template.rs Outdated
<|DSML|parameter name="location" string="true">Beijing</|DSML|parameter>
<|DSML|parameter name="unit" string="true">celsius</|DSML|parameter>
</|DSML|invoke>
</|DSML|tool_calls><|end▁of▁sentence|><|User|><tool_result>{"temperature": 22, "condition": "sunny", "humidity": 45}</tool_result><|Assistant|><think>Got the weather data. Let me format a nice response.</think>The weather in Beijing is currently sunny with a temperature of 22°C and 45% humidity.<|end▁of▁sentence|> No newline at end of file

@coderabbitai coderabbitai Bot Apr 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

DeepSeek V4 chat template tool result role sentinel format

💡 Result:

The DeepSeek V4 chat template follows the established pattern from DeepSeek V3/V3.1 models, using special tokens for roles and tool handling. No specific V4 documentation was found, but V4 repos like deepseek-ai/DeepSeek-V4-Pro exist on Hugging Face, likely inheriting the template. Key elements from V3.1 chat_template.jinja (used in V4 as preview models build on prior architecture): Roles: - User: <|User|>{content} - Assistant: <|Assistant|>{content}<|end▁of▁sentence|> Tool calls (assistant role with tool_calls): - Starts with <|tool▁calls▁begin|> - Each tool: <|tool▁call▁begin|>{function_name}<|tool▁sep|>{arguments (JSON)}<|tool▁call▁end|> - Ends with <|tool▁calls▁end|><|end▁of▁sentence|> Tool results (tool role): - <|tool▁output▁begin|>{result content}<|tool▁output▁end|> System messages are prepended before the conversation. No explicit "sentinel format" or "<|tool|>" found; uses <|tool▁...|> tokens (fullwidth pipes). For API, uses standard OpenAI roles: system, user, assistant, tool. Example formatted prompt for tool interaction: <|begin▁of▁sentence|><|User|>What's the weather?<|Assistant|><|tool▁calls▁begin|><|tool▁call▁begin|>get_weather<|tool▁sep|>{"location":"SF"}<|tool▁call▁end|><|tool▁calls▁end|><|end▁of▁sentence|><|tool▁output▁begin|>Sunny, 72F<|tool▁output▁end|><|Assistant|>The weather in SF is sunny, 72F.<|end▁of▁sentence|> Confidence high for pattern match from official V3.1 sources; medium for exact V4 as docs point to inheritance.

Citations:


🏁 Script executed:

# Check the fixture file at the specified location
if [ -f "lib/llm/tests/data/deepseek-v4/test_output_1.txt" ]; then
  echo "=== File exists, checking line 36 ==="
  sed -n '36p' "lib/llm/tests/data/deepseek-v4/test_output_1.txt" | head -c 500
  echo ""
  echo "=== Context around line 36 (lines 34-38) ==="
  sed -n '34,38p' "lib/llm/tests/data/deepseek-v4/test_output_1.txt"
else
  echo "File not found at specified path"
  # Search for the file
  find . -name "test_output_1.txt" -type f 2>/dev/null
fi

Repository: ai-dynamo/dynamo

Length of output: 859


Fix tool result format in fixture to match DeepSeek V4 reference template.

The tool result on line 36 uses <|User|><tool_result>...</tool_result>, but the official DeepSeek V4 chat template (inherited from V3.1) specifies <|tool▁output▁begin|>...<|tool▁output▁end|>. The current fixture format will pass encoder assertions but diverges from what sglang expects, risking inference-time incompatibility.

Reference format from official template

Tool results should use:

<|tool▁output▁begin|>{result content}<|tool▁output▁end|>

instead of:

<|User|><tool_result>...</tool_result>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/llm/tests/data/deepseek-v4/test_output_1.txt` at line 36, Replace the
non-conforming tool output tag sequence <|User|><tool_result>...</tool_result>
in the fixture with the DeepSeek V4 template tags
<|tool▁output▁begin|>...<|tool▁output▁end|>; locate the string occurrence (e.g.,
the fragment containing "<|User|><tool_result>") and swap it to the canonical
"<|tool▁output▁begin|>{result content}<|tool▁output▁end|>" format so the fixture
matches sglang/DeepSeek V4 expectations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not fixing — the fixture format is actually correct per the DeepSeek V4 reference. Per `encoding_dsv4.py` (https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro/blob/main/encoding/encoding_dsv4.py):

```python
tool_output_template: str = "<tool_result>{content}</tool_result>" # line 60-62

line 425: "Convert tool message to a user message with tool_result block"

```

So V4 explicitly uses `<|User|><tool_result>…</tool_result>`. The `<|tool▁output▁begin|>…<|tool▁output▁end|>` shape from your web query is V3.1 heritage; V4 redesigned this to ride on the `<|User|>` sentinel. Fixture matches the reference, sglang serving V4-Pro weights will expect this shape.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@keivenchang, you're absolutely right — I apologize for the incorrect review comment. The confusion arose because my web search returned V3.1 template patterns, which do not apply to V4.

DeepSeek-V4-Pro does not include a Jinja-format chat template; instead, it provides a dedicated encoding folder with Python scripts (encoding_dsv4.py) as the authoritative encoding reference. Per your citation of that script, V4 explicitly redesigned tool result handling to wrap results inside a <|User|> sentinel using <tool_result>{content}</tool_result> — distinct from the V3.1 <|tool▁output▁begin|>…<|tool▁output▁end|> pattern I mistakenly flagged.

The fixture is correct. Withdrawing this comment.


✏️ Learnings added
Learnt from: keivenchang
Repo: ai-dynamo/dynamo PR: 8671
File: lib/llm/tests/data/deepseek-v4/test_output_1.txt:36-36
Timestamp: 2026-04-24T19:23:51.720Z
Learning: In DeepSeek V4 (deepseek-ai/DeepSeek-V4-Pro), tool results are encoded as `<|User|><tool_result>{content}</tool_result>` — the tool message is converted into a user message with a `<tool_result>` block. This is specified in `encoding_dsv4.py` (tool_output_template at line 60-62, conversion logic at line 425). This differs from DeepSeek V3.1 which uses `<|tool▁output▁begin|>…<|tool▁output▁end|>`. DeepSeek V4 also does not use a Jinja chat template at all; it uses `encoding_dsv4.py` as the authoritative encoding reference instead.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread lib/llm/tests/deepseek_v4_encoding.rs
Comment thread lib/llm/tests/test_streaming_tool_parsers.rs
…per call

`extract_tool_calls`, `extract_invokes`, `parse_parameters` each built a
regex pattern from `format!("...{}...", regex::escape(config.field))` and
`Regex::new`'d it on every invocation. On streaming hot paths this
recompiled three regexes per chunk — independently of what the chunk
actually contained — despite the config strings being effectively fixed
per backend (V3.2 and V4 are the only variants in practice).

Introduce a `OnceLock<RwLock<HashMap<DsmlRegexKey, Arc<DsmlRegexes>>>>`
module cache keyed on the six config strings, and look up (with a shared
read lock on the fast path) before falling through to compile-and-insert.
The cache has at most two entries for the process lifetime.

`extract_invokes` and `parse_parameters` now take `&DsmlRegexes` instead
of `&DsmlParserConfig`, so the inner call tree carries compiled regexes
rather than repeatedly hashing and re-looking-up the same entry.

All 19 DSML parser unit tests still pass unchanged — the cache is a pure
behavioral no-op.
…mat!

`render_tools` built the tool-section of the system prompt by running
four sequential `String::replace` passes over `TOOLS_TEMPLATE`:

    TOOLS_TEMPLATE
        .replace("{tool_schemas}", ...)   // allocates a fresh String
        .replace("{dsml_token}", ...)     // allocates again
        .replace("{thinking_start_token}", ...)
        .replace("{thinking_end_token}", ...)

Each `replace` allocates a new `String` sized for the whole template —
and after the first call the template already contains the inlined
schemas, so each subsequent pass copies the full (potentially kB-scale)
payload. Four tool-schema-inlined copies per render.

Collapse into one `format!` with named arguments: a single allocation
sized by the macro from the final length. Four placeholders, one pass.

The standalone `TOOLS_TEMPLATE` const is no longer referenced and is
removed — the template body now lives inside the `format!` macro where
it's used.

Byte-identical output: all four fixture-driven tests in
`tests/deepseek_v4_encoding.rs` pass unchanged, including the
tools-present fixtures that exercise every placeholder.
`merge_tool_messages` cloned every input message at loop top
(`let msg = msg.clone();`) regardless of which branch handled it:

- `tool` role: extracts two fields into a fresh `tool_result` JSON —
  never pushes `msg`. The full clone was discarded.
- `user` role: extracts the text content and preserves three named
  fields via per-field `v.clone()` into a fresh message — never pushes
  the original. The full clone was discarded.
- other roles: passes `msg` through into `merged`. Only this branch
  actually needs to own the value.

On long chat histories this cloned the entire conversation's JSON tree
once per turn — tool calls with large payloads paid the worst cost.

Iterate by reference (`for msg in messages` already binds `&JsonValue`
from a slice), drop the top-level clone, and move the clone into the
pass-through `else` branch where ownership is actually needed. The two
"consecutive-user merge" branches are also tightened with let-chains to
avoid the `merged.last_mut().unwrap()` unwrap that was guarded by the
`can_merge` flag a few lines earlier — original malformed-data behavior
(silent drop rather than fall-through) is preserved with an explicit
comment.

All 7 deepseek_v4 unit tests and all 4 fixture-driven integration tests
pass unchanged — the output JSON is byte-identical for every shape the
existing suite covers.
`encode_messages_with_options` used to scan the message list twice:

  1. `drop_thinking_messages` computed `find_last_user_index(&input)` at
     the top of its body to decide which pre-last-user messages to drop.
  2. After drop (or instead of it), the encode loop computed
     `find_last_user_index(&full)` again to pass into `render_message`.

Each `find_last_user_index` is a full O(n) reverse scan of a freshly-
allocated JSON message vec. On long chat histories this is two redundant
scans per request — one when drop runs, one always.

Dedup: hoist `find_last_user_index(&full)` to the single site in
`encode_messages_with_options` and thread both its input (used by drop
to decide) and its *output* (computed inline while iterating) through
`drop_thinking_messages`:

  - Caller computes `let mut last_user_idx = find_last_user_index(&full)`
    once on the post-merge/sort list.
  - `drop_thinking_messages(full, last_user_idx)` now returns
    `(Vec<JsonValue>, Option<usize>)` — the dropped list plus the
    post-drop position of the same "last user/developer" message,
    tracked as `out.len()` at the moment it's pushed.
  - The encode loop reads the returned index directly — no rescan.

The post-drop index is mathematically equivalent to a full
`find_last_user_index(&dropped)` rescan: `drop_thinking_messages`
never drops or reorders the message at `last_user_idx`, and every
surviving message before it counts toward the new position by 1. Three
new unit tests pin this equivalence for the three cases that matter:

  - `test_drop_thinking_messages_returns_post_drop_last_user_idx` —
    shape where a pre-last-user `developer` is dropped, forcing the
    trailing developer to shift from idx 3 to idx 2. Asserts the
    returned index equals a full rescan.
  - `test_drop_thinking_messages_preserves_idx_when_nothing_dropped` —
    no-shift sanity: returned idx equals the input idx.
  - `test_drop_thinking_messages_no_user_in_history` — edge case where
    both pre- and post-drop indices are `None`.

All 10 deepseek_v4 unit tests and all 4 fixture-driven integration
tests pass unchanged.
Focused pass over the DeepSeek-V4 formatter + reasoning registration.
No behavior changes are intended for any fixture-covered input; every
existing unit and integration test in `deepseek_v4` and `reasoning`
passes unchanged. All items come from the review of 88478b4..HEAD.

Addressed:

- Smell 4 / idiomaticity 4 — `to_json` now returns `Result<String>`
  instead of silently collapsing serialization / UTF-8 errors to `"{}"`.
  Cascaded through `render_tools`, `extract_visible_text`,
  `normalize_message_contents`, `render_tool_result_content`, the two
  `render_message` tool/response-format sites, `encode_arguments_to_dsml`,
  and the `impl OAIPromptFormatter::render` entry point. Errors now
  surface as `anyhow::Error` with context instead of corrupting the
  prompt.

- Smell 7 — `value.as_str().unwrap().to_string()` (guarded a few lines
  earlier by `.is_string()`) replaced with a `match value { JsonValue::
  String(s) => ... }` pattern so the invariant holds at the type level
  rather than by convention.

- Smell 8 — magic `&["user","system","tool","latest_reminder",
  "direct_search_results"]` list inside `drop_thinking_messages` hoisted
  to module-scope `const KEEP_ROLES: &[&str]` with a doc comment
  explaining what the list means.

- Smell 9 — `"[Unsupported {}]"` literal injected into user-visible
  prompts now goes through a shared `UNSUPPORTED_PLACEHOLDER_FMT`
  constant and emits a `tracing::warn!` at both call sites (user-content
  blocks and tool_result items), matching the existing
  `extract_visible_text` drop-path warn.

- Smell 10 — `ReasoningParserType::DeepSeekV4` dedicated variant added,
  replacing the silent `Qwen` alias. Verified against
  deepseek-ai/DeepSeek-V4-Pro/encoding/encoding_dsv4.py: V4 uses the same
  `<think>` / `</think>` delimiters as Qwen today, so the new variant's
  match arm points at the same `BasicReasoningParser` config — but
  future V4-specific divergence (max-thinking mode, different tokens)
  now has a place to land without rippling through Qwen.

- Smell 11 — the three-alias entries (`deepseek_v4` / `deepseek-v4` /
  `deepseekv4`) in the reasoning parser map now carry an inline comment
  explaining why (callers wire the name through from heterogeneous
  sources: `--dyn-reasoning-parser` flag, vLLM recipes, chat-template
  authors; accepting all three avoids a canonical-form requirement).

- Smell 12 — `RESPONSE_FORMAT_TEMPLATE` const + `replace("{schema}", ..)`
  collapsed into a direct `format!("## Response Format:...{}", schema)`
  at the two system/developer call sites. One fewer indirection, one
  fewer allocation per render.

- Smell 13 — `TOOL_CALLS_BLOCK_NAME` const now has a docstring
  explaining its role (one DSML tag is extracted; the others are inline
  because they appear exactly once). The summary's alternative — also
  constifying `INVOKE_BLOCK_NAME` / `PARAMETER_BLOCK_NAME` — would
  introduce consts used in exactly one place each, net-negative for
  readability.

Deferred (tracked for follow-up commits):

- Smell 2 (V3.2/V4 helper duplication). The V4 versions of `to_json`,
  `extract_visible_text`, `normalize_message_contents`, etc. are not
  byte-identical to V3.2 — V4's `to_json` uses `PythonFormatter` while
  V3.2 uses a character-scan that has a known escape-handling bug.
  Extracting a shared module would change V3.2 behavior; needs its own
  commit with V3.2 regression coverage first.

- Smell 3 (`render_message` split into `render_assistant_body` /
  `render_user_body` / `append_transition_token`). Worth doing, but
  mechanically sizable; keeping this commit focused.

- Smell 5 (test move: keep invariants in-module, move scenarios to
  integration). Opinionated; deferred.

Tests: 10 unit + 4 integration + 91 parsers reasoning tests green.
Pure-style pass over the DeepSeek-V4 formatter. No behavior changes; all
10 unit + 4 integration tests still green.

- Idiom 1: `find_last_user_index` role matcher
  `.map(|r| r == "user" || r == "developer").unwrap_or(false)`
  →  `.is_some_and(|r| matches!(r, "user" | "developer"))`
  (closer to the `Python` `is_some_and` sentinel semantics the file uses
  elsewhere for last_user_idx comparisons.)

- Idiom 2: `resolve_thinking_mode` nested-`if let` over the same `args`
  collapsed with 2024-edition let-chains; the outer scope `args` is
  matched twice but it's a cheap reference comparison and the flatter
  shape matches the rest of the file (e.g. the `can_merge` branches in
  `merge_tool_messages` already use this form).

- Idiom 3: `.and_then(|v| v.as_str())` / `.as_array()` / `.as_bool()` /
  `.as_array_mut()` closures across ~30 sites replaced with
  `JsonValue::as_str` / `as_array` / `as_bool` / `as_array_mut` fn
  pointers. No closure allocation, one less turbofish-free indirection.

- Idiom 5: `anyhow::bail!("Unknown role: {}", other)` →
  `anyhow::bail!("Unknown role: {other}")` (2024-edition captured
  identifier; matches `"{text_so_far}"` / `"{idx}"` usage elsewhere in
  the file).

- Idiom 6: `#[inline]` on `ThinkingMode::as_str` (two-branch match
  returning `&'static str`) and `task_token` (seven-branch match
  returning `&'static str`). Both are on the per-message render hot path
  and smaller than the call overhead; inlining lets LLVM fold them into
  caller string-equality checks.

Deferred from the idiomaticity list:

- Idiom 4 (`to_json` → `Result<String>`) — already landed in the
  previous cleanup commit alongside Smell 4.

- Idiom 7 (`extract_visible_text` → `Cow<'_, str>`). The text-only path
  already avoids re-allocating (`text.clone()` is a `String::clone`,
  which is still O(n) but unavoidable without a `Cow`). Wrapping in
  `Cow<'_, str>` would change the return type through
  `normalize_message_contents` / `render` ripple and intermittently save
  one clone per message. Worth its own focused commit with a benchmark.

Tests: 10 unit + 4 integration, all green.
ayushag-nv and others added 3 commits April 24, 2026 09:29
Introduces a `CASE.<N>` / `CASE.<family>.<N>` taxonomy for parser test
categories:
  - new `lib/parsers/README.md` — crate landing page (parser families,
    request-flow diagram, how-to-add-a-parser)
  - new `lib/parsers/TESTING.md` — 16 generic + 2 XML-family + 1
    Harmony categories, with per-category definitions, applicability,
    and example anchors

Adds 4 DSML pinning tests that document current behavior on truncated
V4 output:
  - `CASE.5` missing outer `</|DSML|tool_calls>` fence — whole block
    silently dropped (same structural class as Kimi K2 pre-recovery)
  - `CASE.4` missing inner `</|DSML|invoke>` fence — call silently
    dropped
  - `CASE.4` malformed JSON in `string="false"` param — falls back to
    raw string (intentional, pins the `unwrap_or_else` behavior)

Annotates every existing V4 test with a `/// CASE.<N>` doc comment
naming the category it pins, across 4 files (dsml parser tests,
parsers registry, reasoning registry, streaming e2e). Adds a coverage
manifest block above the V4 test section listing what's still NOT
covered so future contributors see the gaps without reading the full
test file:
  - `CASE.5` mid-stream truncation recovery (TODO)
  - `CASE.4` parameter-close and middle-invoke-bleed variants
  - `CASE.11` tool_choice auto/required/named/none
  - `CASE.12` `FinishReason::Length`
  - `CASE.14` empty-content / null at e2e
  - `CASE.15` duplicate calls (universal gap)
  - `CASE.16` regression (V4 is brand new, no bugs filed yet)

No production code changes. All 389 dynamo-parsers tests + 4 V4
encoding golden tests + 24 V4 streaming tests pass.

Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com>
…t const strings

Addresses biswapanda's review comments on #8665:

  - L389 "split into separate funcs" — render_message's 221-line match is
    split into six per-role helpers: render_system_role,
    render_developer_role, render_user_role, render_latest_reminder_role,
    render_assistant_role, plus append_response_format /
    append_tools_section to dedup the sys/developer response-format
    and tools blocks. render_message itself stays as a slim dispatcher
    that handles the reasoning-effort prefix and the transition-token
    tail.
  - L400 "const strings" — hard-coded `<tool_result>` / `</tool_result>`
    tags extracted to TOOL_RESULT_OPEN / TOOL_RESULT_CLOSE consts
    alongside the other wire-format constants. The response-format
    preamble (previously duplicated inline in the system and developer
    branches) becomes RESPONSE_FORMAT_PREAMBLE with a {} placeholder.

  Already addressed by prior commits on the branch (no-op here):
  - L32 "Should be ReasoningParserType::DeepSeekV4" — the three
    deepseek_v4 aliases now map to ReasoningParserType::DeepSeekV4.
  - L899 "hard coded path" — the `/home/ayush-lab/...` absolute path
    in the test has been removed.
  - L266 "clippy manual_pattern_char_comparison" — fixed in 77602e4
    by using a char array.

Pure refactor — behavior is identical. All 4 V4 encoding golden tests +
7 V4 streaming tests pass unchanged. Clippy clean on dynamo-llm.

Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com>
@keivenchang

keivenchang commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Picking this up from @ishandhanani. Commit a627a21c54 replaces "investigate tmrw".

Short version of what was wrong: sglang 0.5.10.post1 didn't drop either kwarg. Ishan was looking in the right place but the diagnosis wasn't quite right. get_structure_constraint() still takes parallel_tool_calls: bool = True, and Engine.async_generate() still takes return_routed_experts. What actually fails is request.get("parallel_tool_calls") returning None, which then slides into xgrammar's typed path and blows up. Normalized to True at parse time (the OpenAI default) and restored the kwarg on both call sites. Same logic for return_routed_experts — it's a real, accepted arg, so putting it back also fixes the Ruff F841 and un-breaks MoE routed-experts telemetry that downstream still tries to consume.

Two other CR items resolve without me doing anything: the template.rs:266 clippy flag is already fixed on the stacked base (77602e441a0), and the test_output_1.txt:36 tool-result format was a CR false positive — the fixture matches encoding_dsv4.py:60-62 on deepseek-ai/DeepSeek-V4-Pro. CR's web query turned up V3.1 heritage and assumed V4 inherited it; V4 actually redesigned that path onto the <|User|><tool_result> shape.

Everything else in CR's review — the DSML escaping, the silent tools drop in load_messages, the multi-tool assertion, and the 9 nitpicks — lives on files owned by the parent PR (#8665), not in this PR's delta. They're real, I've got concrete fixes drafted for each, but they land in #8665 before it merges.

Base is codex/deepseek-v4-parsers. Diff is one commit. Pulling the "do not merge" prefix off once pre-merge greens.

/cc @ishandhanani

@keivenchang keivenchang changed the title do not merge: fix some bugs with latest dsv4 for sglang fix(sglang): normalize parallel_tool_calls=None and restore return_routed_experts Apr 24, 2026
@github-actions github-actions Bot added the fix label Apr 24, 2026
The fixture's expected_output carried a finish_reason field that the
streaming test harness never reads — finish_reason validation is
hardcoded against the stream chunks themselves, not the expected_output
struct. None of the other 6 deepseek-v4 fixtures have it. Drop it for
consistency.

Addresses CodeRabbit comment 2 on #8665.

Signed-off-by: Keiven Chang <keivenc@nvidia.com>
Signed-off-by: Keiven Chang <keivenchang@users.noreply.github.com>
@keivenchang

Copy link
Copy Markdown
Contributor

@CodeRabbit can you verify that the fixes/updates addressed your concerns? If so, you can Resolve Conversation.

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== sglang_prepost.py around parallel_tool_calls ==="
rg -n "parallel_tool_calls" --type py -C 5

echo ""
echo "=== decode_handler.py return_routed_experts ==="
rg -n "return_routed_experts" --type py -C 3

echo ""
echo "=== template.rs around line 266 ==="
fd "template.rs" --search-path lib/llm | xargs -I{} sed -n '250,290p' {}

Length of output: 10899


@keivenchang: I've verified the current branch state. Here's what I found:

  • CR 🔴 1 (parallel_tool_calls): ✅ Confirmed. Lines 150–152 now normalize None → True with an inline comment explaining the sglang type constraint, and parallel_tool_calls=parallel_tool_calls is correctly forwarded at both call sites (Lines 173 and 183).

  • CR 🟠 5 (return_routed_experts): ✅ Confirmed. The kwarg is restored at both call sites — Line 311 (disaggregated decode path) and Line 349 (aggregated multimodal-free path).

  • CR 🔴 2 (template.rs clippy): ✅ Confirmed. The is_deepseek_v4_name helper is clean with well-structured logic and accompanying tests. No clippy issues visible.

  • CR 🟠 4 (test_output_1.txt fixture): Accepted your rationale — the fixture matches the reference implementation at deepseek-ai/DeepSeek-V4-Pro. My concern was a false positive.

All addressed concerns are resolved. Resolving conversations now.

[resolve]

@keivenchang
keivenchang force-pushed the ishan/investigate-tmrw branch from 8aa616e to d8b2e8c Compare April 24, 2026 19:37
@keivenchang
keivenchang force-pushed the ishan/investigate-tmrw branch from d8b2e8c to 44fb33a Compare April 24, 2026 19:49
@keivenchang keivenchang changed the title fix(sglang): normalize parallel_tool_calls=None and restore return_routed_experts [INVESTIGATE] sglang + DSv4: kwarg workaround breaks live — needs diagnosis Apr 24, 2026
@keivenchang
keivenchang marked this pull request as draft April 24, 2026 19:49
@keivenchang

Copy link
Copy Markdown
Contributor

Reverted d8b2e8ce2b1 — tried the obvious "fix" (restore both kwargs + default parallel_tool_calls to True when absent) but it blew up against a live sglang + DSv4 stack. So the naive signature-accepts-the-kwarg reading isn't enough. Something further down the xgrammar / structural-tag pipeline is unhappy when the kwarg is actually passed for the DSv4 parser, and probably similarly for return_routed_experts on the async_generate side.

Backing out to just Ishan's original investigate tmrw commit so the branch reflects reality: two things are commented out, both need real diagnosis before a proper fix lands. Marking as draft and prefixing the title with INVESTIGATE until we can repro and trace.

Next steps (not doing now):

  • Stand up sglang + DSv4-Pro weights, repro the tool_choice="auto" path with parallel_tool_calls=True and capture the actual stack trace / error.
  • Same for return_routed_experts=True on aggregated and disaggregated-decode — confirm whether sglang accepts the kwarg at the Engine level but rejects it downstream, or whether it's something in how sglang propagates it to the inference path.
  • Decide real fix: version gate, feature flag, or upstream sglang PR.

@ayushag-nv
ayushag-nv force-pushed the codex/deepseek-v4-parsers branch from 2aab621 to 8411f3e Compare April 24, 2026 20:50
Base automatically changed from codex/deepseek-v4-parsers to main April 24, 2026 22:43
@keivenchang

Copy link
Copy Markdown
Contributor

Superseded by #8701 — Richard's V4 SGLang fixes. Closing per the DIS-1849 epic; live-validation work done in this branch is now covered by what landed in #8701.

@keivenchang keivenchang reopened this Apr 29, 2026
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Apr 29, 2026
@keivenchang

Copy link
Copy Markdown
Contributor

@ishandhanani just want to confirm with you

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

actions backend::sglang Relates to the sglang backend documentation Improvements or additions to documentation fix frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants