Skip to content

build: [DO NOT MERGE] CI image build — Inkling container + prefill-router error-status fix - #14060

Closed
sungsooha wants to merge 25 commits into
ai-dynamo:mainfrom
sungsooha:sungsooha/release-1.5.0-inkling-dev.1-container-prefillfix
Closed

sungsooha wants to merge 25 commits into
ai-dynamo:mainfrom
sungsooha:sungsooha/release-1.5.0-inkling-dev.1-container-prefillfix

Conversation

@sungsooha

Copy link
Copy Markdown
Contributor

⛔ DO NOT MERGE — this PR exists only to produce a CI container image

This is #13954's branch plus one commit. It is opened solely so CI builds a
vllm-runtime image carrying both the Inkling container work and an in-flight fix, so that
image can be validated on GB300 and handed to QA. It is not for review and not for merge.

The additional commit

fix(kv-router): preserve the prefill worker's error status through disagg

On a disaggregated deployment a request the prefill worker had correctly rejected with 400
reached the client as a 500. Two independent causes:

  1. The worker's error was attached to PrefillError::PrefillError as a #[source] only, and every
    path out of the prefill router reaches the frontend through to_pyerr, which formats with
    Display and drops the error chain (lib/bindings/python/rust/lib.rs). The frontend saw the bare
    "Prefill router returned error in output" and could not distinguish an invalid argument from an
    engine fault. The two sites that wrap a real error now fold its text into the message, so the
    BackendInvalidArgument: {...} discriminator survives — which the HTTP layer and the Python chat
    processors already key on. match_error_chain is unaffected (it matches error types through the
    chain, never this string), and the sourceless "no output (stream ended)" case is unchanged.
  2. The vLLM chat processor yielded bare {"error": {...}} dicts. The binding only treats a dict as an
    Annotated envelope when it carries _dynamo_annotated (depythonize_annotated); untagged, it is
    parsed as a completion chunk, fails with missing field `id` , and the client gets an opaque 500
    instead of the message. as_error_envelope() tags it. The shared helpers keep their shape, so the
    SGLang processor and the existing tests are untouched.

Evidence

Reproduced on Inkling-NVFP4 / GB300 1P1D disagg: min_p and two malformed json_schema payloads
returned 500 3/3, while logprobs — rejected frontend-side, so path-independent — correctly returned
400. Aggregated was unaffected, which is why the earlier fix (#13980, now merged) passed on AGG and
missed this. Follow-up to NVBug 6678456.

yifjiang and others added 25 commits August 25, 2026 17:28
…rocessing

vLLM reasoning parsers expose adjust_request() so they can declare the sampling
params they need. Parsers that split thinking from the answer on text markers
require `skip_special_tokens=False`, otherwise the markers are stripped before
the parser ever sees them and the split silently does not happen.

`_prepare_request` calls the TOOL parser's adjust_request but there is no
equivalent for the reasoning parser -- it does not even receive
`reasoning_parser_class`, since the parser is constructed later, in
StreamingPostProcessor, long after the sampling request is finalised.

Net effect: `reasoning_content` comes back empty unless the CLIENT sends
`skip_special_tokens: false`. Ordinary OpenAI-compatible clients do not, so on
such models the reasoning channel silently does nothing for normal traffic. The
tool path is additionally gated on `tool_choice != "none"`, so a plain
tools-free chat request gets no adjust_request call from either parser.

Mirror the tool-parser path: construct the reasoning parser during request
preparation and let it adjust the request.

The gating matters and is deliberately identical to StreamingPostProcessor's own
parser construction (`reasoning_parser_class and not thinking_disabled`, where
`thinking_disabled = chat_template_kwargs.get("enable_thinking") is False`). If
the sampling params were adjusted for a parser that then ends up None, the
model's control markers would reach the client as visible text. The call is
therefore placed after chat_template_kwargs is resolved.

This is a no-op for parsers that do not need it: the base
ReasoningParser.adjust_request is `return request`.

Two files are required. prepost.py alone is inert -- vllm_processor.py must pass
reasoning_parser_class into preprocess_chat_request, otherwise the new parameter
defaults to None and the gate never fires.

Adds unit coverage for the wiring and, importantly, for the gate: the parser must
NOT be constructed or consulted when enable_thinking is False.

Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses review feedback on ai-dynamo#12541: adjust_request() can change more than
skip_special_tokens.

A reasoning parser that rewrites the caller's constraint -- converting
response_format into the structural tag its model actually speaks -- had that
rewrite dropped. parser_guided_decoding was gated on `tool_parser is not None`,
and build_tool_call_guided_decoding returns early when the request has no tools,
so the rewrite never reached guided_decoding. The request carried the structural
tag while the engine was handed the pre-adjustment JSON schema captured before
_prepare_request ran.

Recompute the parser guidance when either parser is active, and let a rewrite
that only the reasoning parser can have made (no tool parser in the request, so
it is attributable) take precedence over the caller's pre-adjustment copy.

Scoped deliberately: when a tool parser is also active the change is not
attributable to either parser, and parser-vs-assistant precedence is already
settled there -- the caller's constraint wins, pinned by
test_assistant_guidance_takes_precedence_over_auto_tool_guidance and the
response-format-precedence row of TOOL_GUIDANCE_PARITY_CASES. Revisiting that
policy is a separate change.

Also single-source the reasoning-parser gate as _reasoning_parser_enabled(), a
TypeGuard now shared by _prepare_request, preprocess_chat_request and
StreamingPostProcessor. The three must agree: if they drift, the sampling params
get adjusted for a parser that then does not exist and the model's control
markers reach the client as visible text.

Tests: four cases in TestReasoningParserGuidanceForwarding covering the rewrite
with no tool parser, a pass-through parser, no parser, and the thinking-disabled
gate. The first fails without this change with the reported symptom
({"json": {"type": "object"}} instead of the structural tag).

The reasoning-parser stubs move to a per-test fixture so no mutable registry is
shared at module scope, per .ai/pytest-guidelines.md (CodeRabbit RUF012, Devin).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqtTc1QsXejXiEdpmLZ4Wz
Removes the tradeoff in the previous commit rather than settling it.

Both parsers rewrite the same request inside _prepare_request -- tool first,
reasoning second -- and the code diffed the guidance across both calls at once.
That single diff cannot say which parser moved it, so attribution was inferred
from `tool_parser is None`. The inference is what forced a choice: with both
parsers active a reasoning rewrite was unattributable, so it lost to the
caller's copy, which is the bug this PR exists to fix.

Take the reading between the two calls and attribution stops being a guess:

    client -> after_tool_parser   changed by the tool parser
    after_tool_parser -> final    changed by the reasoning parser

Each rewrite then gets its own precedence, which is what was wanted all along.
A reasoning rewrite wins whether or not a tool parser is active, closing the
gap. A tool rewrite still loses to the caller's explicit constraint, so the
pinned policy is untouched. When both rewrote it there is nothing to arbitrate:
the reasoning parser ran last, on the already-tool-adjusted request, so the
final value IS the composition -- forwarding it is forwarding what the request
holds, which is the invariant the report was about.

_prepare_request takes an optional write-only guidance_snapshots dict rather
than a sixth return value: the return is unpacked at 22 call sites across the
two test files and widening it would have churned all of them.

The snapshot deliberately uses request_for_sampling.structured_outputs, the same
accessor as adjusted_structured_guidance, since it is only ever compared against
that value; extract_structured_outputs() is a different view and would have made
the diff spurious.

Deletes reasoning_only_guided_decoding and its scoping rationale.

Validated against the previous commit, same container, prepost.py swapped
underneath:
  test_vllm_processor_unit.py   before 11 failed / 117 passed
                                after  10 failed / 118 passed
  regressions: none; the only delta is the new gap test
  tests/frontend/test_prepost.py  15 passed both
The gap test fails on the previous commit with the exact symptom from the
report -- {'json': {'type': 'object'}} instead of the structural tag -- and the
three pinned cases pass under both.

Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
…snapshot

Addresses three review threads on ai-dynamo#12541.

[P1] model_config. ReasoningParser.__init__ reads model_config out of **kwargs,
and BaseCohereCommandReasoningParser.adjust_request returns the request untouched
when it is absent (`if model_architecture is None: return request`). Constructing
without it made this PR's whole adjust_request() call a no-op for the only shipped
parser that overrides it. Verified against real vLLM: same parser, same request,
model_config absent -> response_format kept and structured_outputs None; present
-> structural tag produced and response_format cleared.

The config is already reachable as self.input_processor.model_config (InputProcessor
sets it from vllm_config), so no constructor change is needed. Threaded to all three
construction sites -- _prepare_request, StreamingPostProcessor, and
_build_reasoning_parser_metadata. The third does not read it, but constructing one
of them differently is exactly the drift that hid this. NOT added to parser_kwargs:
that dict goes on the wire to the worker and ModelConfig is not serializable.

[P2] Snapshot cost. The guidance snapshot moved inside the
_reasoning_parser_enabled() gate, since its only reader sits behind the same test
and short-circuits. The deepcopy is gone: adjust_request() replaces
request.structured_outputs with a fresh object rather than mutating it, so a plain
reference still denotes the pre-adjustment value -- and aliasing lets the caller's
`!=` short-circuit on identity instead of walking the tree. Measured on a 1.49 MB
schema: 56.081 ms -> 0.0001 ms for the copy, plus 0.7155 ms -> 0.0001 ms on the
comparison it feeds. Tool-only and no-parser requests now pay neither.

[P2] Boundary test. test_generator_inner_forwards_reasoning_parser_and_model_config
drives the real _generator_inner with a spy and asserts both wiring arguments
arrive. Deleting either line fails only this test; before it, deleting
reasoning_parser_class left all 143 green. Traded out the overlapping
test_no_reasoning_parser_keeps_the_caller_constraint per the review.

Also adds _ModelConfigGatedReasoningParser, a fake that mirrors Cohere's actual
contract (no model_config => no rewrite). The existing fakes ignored model_config
entirely, which is why a no-op passed them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqtTc1QsXejXiEdpmLZ4Wz
Signed-off-by: Ryan McCormick <rmccormick@nvidia.com>
…r path

Squashed from ai-dynamo#13947 for this release branch.

dynamo ai-dynamo#12181 added reasoning-token usage only to the Rust OpenAIPreprocessor.
`--dyn-chat-processor vllm` selects the Python path, which bypasses it, and the
worker's _build_completion_usage() emits no `completion_tokens_details` key at
all -- so `reasoning_tokens` was ABSENT from usage (NVBug 6678449b).

The count is accumulated where the reasoning parser CLASSIFIES its output
(StreamingPostProcessor.reasoning_token_total), not where the response is
projected. Deriving it from the emitted choices loses it whenever a projection
drops or defers the reasoning: include_reasoning=false suppresses the delta, and
the non-streaming tool path buffers every chunk and releases on a zero-token
terminal delta.

Measured on Inkling-NVFP4 / GB300 with vLLM 0.28.0, reported vs an independent
tokenization of the returned reasoning_content, 3 runs each:

  non-streaming tool call   before 0.03/0.03/0.02   after 1.00/1.00/1.00
  plain reasoning request   before 1.04/1.03/1.02   after 1.00/1.03/1.04

Before, the tool case reported a constant 7 whatever the real count was.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
Bumps the vLLM runtime base to v0.28.0-ubuntu2404 and layers the Inkling
structural-tag patch onto it, following the Nemotron-3 Ultra pattern (ai-dynamo#10234).

Why 0.28.0: Inkling's tool parser and the structural-tag registry the patch
extends only exist from that release. The tag is a multi-arch manifest list
(linux/arm64 + linux/amd64), so GB300 aarch64 is covered. xpu and cpu stay at
0.27.1 -- untested at 0.28.0, and Inkling does not use them.

Why the patch: vLLM ships an Inkling tool parser but registers no structural tag
for it, so Dynamo finds no grammar to install for a tool request and silently
degrades to tool_choice="auto" -- HTTP 200, no warning, and a forced tool choice
is simply not honoured. Upstream as vllm-project/vllm#54120; the .patch here is
cut against the exact v0.28.0 tree and applies with `patch --forward -p1` clean.

validate_inkling_runtime.py asserts the POSTCONDITIONS rather than trusting
patch's exit code: 'inkling' registered in both the builtin set and the builder
registry, structural_tag_model == "inkling", and supports_required_and_named
equal to the value AbstractToolParser.__init_subclass__ derives from
VLLM_ENFORCE_STRICT_TOOL_CALLING (setting it by hand is redundant and silently
overwritten). Verified against the real patched runtime, and verified to FAIL
when the tag is unregistered or the vLLM version differs.

The build-time version test is load-bearing: `patch --forward` would otherwise
skip or fuzz a hunk on a different base and leave a half-patched image that
still builds successfully.

NOTE: the image alone is not sufficient. The worker must also run with
--dyn-enable-structural-tag AND
--structured-outputs-config '{"enable_in_reasoning": true}'; without the second,
vLLM never fills the grammar bitmask for a reasoning model and the grammar is
inert. That belongs in the recipe, not here.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
The pre-commit shebang hook requires an executable bit for a file that declares
one. This validator is invoked as `python3 <file>` from the Dockerfile, exactly
like its sibling validate_torch_compile_smoke.py, which carries no shebang and
mode 644. Match the sibling rather than making the file executable.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
…g parser

ai-dynamo#12541 threads model_config into the reasoning-parser constructor, so the fake
parser in the reasoning-accounting tests needs to accept it. Test-only; the
production path already passes it.

Caught by running the suite on the merged tree -- the cherry-pick auto-merged
without conflict, which is not the same as being correct.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
…uired

The previous note claimed Inkling's tool parser and structural-tag registry only
exist from 0.28.0. That is wrong -- both files, and every symbol the Inkling
structural-tag patch touches, are present in 0.27.1, and the patch applies there
cleanly. The bump is required for a different reason.

The Inkling NVIDIA path moved substantially after 0.27.1, and the MTP support
this recipe runs on is part of it:

  * models/inkling/nvidia/mtp.py -- InklingMTP gains
    SupportsMultiModalEmbeddings, from "Restore multimodal draft capability
    detection" (vllm#50417), which also reworks v1/spec_decode/llm_base_proposer
  * models/inkling/nvidia/ops/fa4_rel_attention.py (+400) and attention.py --
    FA4 warmup migration (vllm#49315)
  * model.py / moe.py / ops/lamport.py -- shared-expert partial addition fused
    into the Lamport collective (vllm#50697)
  * configs.py -- HF-config compat for Inkling (vllm#51850)

Three Inkling parser fixes land in the same range (vllm#49876, #50528, #51391),
which is the behaviour the frontend commits on this branch interact with.

Separately, every Inkling number we have -- p0, IFEval/GPQA-D, and the moontrace
throughput ladder -- was measured on 0.28.0, so shipping 0.27.1 would hand QA an
engine combination that has never been validated for this model.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
… marker

Refreshes the vendored patch from vllm-project/vllm#54120, which now begins the
tool/text tags at `<|content_invoke_tool_json|>` / `<|content_text|>` instead of
at `<|message_model|>`.

Inkling's chat template prefills the role marker into the generation prompt (it
ends `...<|end_message|><|message_model|>`), so generation resumes inside the
model message and never re-emits it. The tags are `tags_with_separator` with
`at_least_one=true`, so they anchor rather than trigger -- the old grammar
therefore required a SECOND role marker. Compiling the tag with xgrammar shows
the inversion, for forced/required/auto alike:

                        suffix-only   with duplicate marker
    before                 rejected              accepted
    after                  accepted              rejected

It appeared to work only because the parser absorbed the duplicate, while
manufacturing exactly the unconsumed-control-marker residue ai-dynamo#12510 strips.

Validated on Inkling-NVFP4 / GB300, vLLM 0.28.0, MTP 8, 2-replica AGG, applied
over the shipped image via ConfigMap: forced tool choice honoured 5/5 against a
prompt matching the other tool; required = 1 call; parallel_tool_calls true/false
= 2/1; no control-marker residue in content -- all in both streaming and
non-streaming. Full p0 blocking count unchanged at 2 (the unrelated
reasoning_budget_zero vLLM limitation).

Note the probe suite could NOT have caught this: control_marker_residue passed
before the fix too, because the parser was absorbing the duplicate. The
grammar-level acceptance test is what exposed it.

Reported by dynamo-review-agent on ai-dynamo#13954.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
`vllm-runtime / Test cuda13.0` failed on both amd64 and arm64:

    RuntimeWarning: vLLM and vLLM-Omni appear to have mismatched major/minor
    versions:
     --> vLLM-Omni version 0.27.0rc1
     --> vLLM version 0.28.0

vllm-omni asserts matching major/minor against the installed vLLM at import, so
`vllm_omni_ref` is coupled to the runtime image tag and has to move with it.
vllm-omni 0.28.0rc1 is published on PyPI (py3-none-any, not yanked).

Adds a comment recording the coupling so the next bump does not repeat it.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
`vllm_omni_ref` was global while only the cuda13.0 stanza moved to vLLM 0.28.0,
so the xpu and cpu images -- still on vLLM 0.27.1 -- would have installed
vLLM-Omni 0.28.0rc1 and hit the very major/minor mismatch the previous commit
pinned away from. Confirmed by rendering: before this change all three devices
emitted `ARG VLLM_OMNI_REF=v0.28.0rc1` while xpu/cpu kept
`RUNTIME_IMAGE_TAG=v0.27.1`, and install_vllm_omni.sh runs on every device.

Move the ref into each device stanza next to the runtime_image_tag it must
track, and read it in args.Dockerfile from the selected device context:

    cuda13.0  vLLM v0.28.0-ubuntu2404   omni v0.28.0rc1
    xpu       vLLM v0.27.1              omni v0.27.0rc1
    cpu       vLLM v0.27.1              omni v0.27.0rc1

Chosen over bumping xpu/cpu to vLLM 0.28.0 (their 0.28.0 base images do exist)
because those targets are untested here and Inkling does not use them; this
keeps their behaviour byte-identical to the base branch.

The ARG sits inside `{% if framework == "vllm" %}`, so sglang/dynamo/trtllm
never evaluate it -- verified by rendering each. (trtllm/cuda13.0 is an invalid
combination on the base branch too; trtllm renders on cuda13.1, which is what CI
builds.)

Reported by dynamo-review-agent on ai-dynamo#13954.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
…review fixes

Two problems, both found while checking the branch against what was actually
validated rather than waiting for another CI round.

1. transformers_version was global at 5.14.1, but the VALIDATED Inkling image
   runs transformers 5.15.1. Shipping the pin would hand QA a transformers we
   never measured -- which also undercuts the argument for carrying the existing
   p0/accuracy/throughput numbers forward to the release container.

   The pin's own removal condition is met on cuda: it exists because vLLM 0.27.1
   lacks the Transformers 5.15 heterogeneous Gemma 4 support, and vllm#49797
   landed that and IS in 0.28.0 (verified: in v0.28.0, not in v0.27.1). xpu/cpu
   are still on vLLM 0.27.1 and still need 5.14.1, so -- exactly as with
   vllm_omni_ref -- the value has to be per-device, not global:

       cuda13.0  vLLM v0.28.0-ubuntu2404  omni v0.28.0rc1  transformers 5.15.1
       xpu       vLLM v0.27.1             omni v0.27.0rc1  transformers 5.14.1
       cpu       vLLM v0.27.1             omni v0.27.0rc1  transformers 5.14.1

   TRANSFORMERS_VERSION is a hard `transformers==${...}` install plus a
   build-time equality assertion, so an empty value is not an option; each
   stanza carries a concrete version.

2. The frontend files had drifted from ai-dynamo#13947, which picked up two CodeRabbit
   fixes after the squash landed here: a negative backend `reasoning_tokens`
   survived the falsiness check (-1 is truthy) and `total` used a defensive
   getattr on an attribute its own constructor guarantees. Forward-ported both,
   with the parametrised regression test; the annotator block is now identical
   between the two PRs.

Rendered all three vllm devices plus sglang / dynamo / trtllm(13.1) to confirm
the per-device lookup does not disturb other frameworks. Accounting suite 13/13.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
…ing patch

Syncs both of our unmerged fixes to their reviewed state and re-validates the
container on real hardware.

frontend (ai-dynamo#13947, P1 review): reasoning tokens are now counted by
the pinned vLLM parser's own `count_reasoning_tokens` over a per-choice ledger of
the ORIGINAL generated ids, appended before any branch in process_output. The
per-chunk classifier and the terminal `tokenizer.encode(saved_reasoning)`
retokenisation are both removed -- the latter drifted because detokenisation is
not injective (generated id 77150 decodes to '10' and re-encodes to two tokens).

vLLM patch (vllm-project/vllm#54120): regenerated against the v0.28.0 tree, now
also accepting `<|content_model_end_sampling|>`. The Inkling parser treats it as
a co-equal terminator -- `is_reasoning_end` returns True on it and
`count_reasoning_tokens` closes a block on either -- so permitting only
`<|end_message|>` masked a valid natural stop. Applies clean with the
Dockerfile's own `patch --batch --forward -p1`.

Validated on Inkling-NVFP4 / GB300, vLLM 0.28.0, MTP 8, on BOTH topologies with
the full patch set overlaid on the shipped image. p0: 13/15 with 2 blocking on
each, and the two blockers are the same unpatchable vLLM limitation
(reasoning_budget_zero, NVBug 6678449a):

  2-replica AGG  structural_tag_grammar pass · reasoning ratio 1.00 · kv_routing pass
  1P1D disagg    structural_tag_grammar pass · reasoning ratio 1.00

p0 was extended to cover these changes, because it structurally could not before:
the tag can be wrong while every wire-level probe passes (the parser absorbs the
malformed output -- control_marker_residue passed before BOTH tag defects), and
reasoning_tokens can be non-zero but the wrong order (the pre-fix code reported a
constant 7 against 324 real tokens).

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
…onstraint

The multi-arch build failed at the vLLM-Omni install on both arches:

    Because vllm-omni>=0.28.0rc1 depends on transformers>=5.10.1,<5.15 and
    transformers==5.15.1, we can conclude that vllm-omni>=0.28.0rc1 cannot be
    used.

vllm-omni 0.28.0rc1 declares a hard upper bound of <5.15, so pinning 5.15.1
makes the image unbuildable. Reverted to 5.14.1, which satisfies omni 0.28.0rc1
on cuda and omni 0.27.0rc1 on xpu/cpu.

The earlier bump was justified with bad evidence: "the validated image runs
transformers 5.15.1". That image is the thin pip-layer dev build, which does NOT
install vllm-omni at all -- so it could never have exercised this combination.
A running image only demonstrates the combination it actually contains.

vllm#49797 (Gemma 4 heterogeneous config) being in 0.28.0 makes 5.15 PERMISSIBLE
for vLLM, which needs only >=5.5.3 -- but not required. Omni is what binds here.
Revisit when an omni release relaxes the bound.

Adds check_pin_compatibility.py, which resolves the pins in each device stanza
against their PyPI metadata and fails on a conflict. It reproduces this exact
error in under a second instead of ~15 minutes into a multi-arch build, and it
is verified in both directions: the fixed context passes, and reintroducing
5.15.1 reproduces the failure. Note it only compares pins against EACH OTHER, so
a conflict with an unpinned transitive dependency still needs a real build.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
Dropping the preflight script. The review correctly found it fails OPEN -- a
PyPI fetch error was printed and ignored, so an unreachable index would exit 0
and falsely validate. A gate that passes when it cannot check is worse than no
gate.

That is a symptom rather than the reason to remove it. The script only compared
pinned packages against EACH OTHER, so it never covered transitive dependencies;
the container build already catches this class and everything metadata cannot
see. It was added reacting to my own mistake, and it costs reviewers a second
source of truth about dependency resolution that can drift from what uv does.

What prevents recurrence is the comment kept in container/context.yaml: it
records that vLLM-Omni, not vLLM, is what binds transformers below 5.15, and
that the thin dev image carrying 5.15.1 does not install omni at all -- so it was
never evidence that the combination resolves. That sits at the point of edit,
which is where the mistake was made.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
…fusers breaks

Two independent CI failures, neither in the shipped code paths.

1. test_engine_core_struct_contract. vLLM 0.28.0 appends `session_id` to
   EngineCoreRequest, after `abort_immediately`. The observed tuple differs from
   the nearest allowed variant by that one field. Added session_id variants
   (plain and vllm-omni), mirroring how the test already handles
   abort_immediately and model_intermediate_buffer.

   No production change: the test docstring claims vllm_processor.py reads
   EngineCoreRequest positionally, but it does not -- it uses attribute access
   (vllm_preproc.mm_features) and builds EngineCoreOutput by keyword
   (EngineCoreOutput(**output_kwargs)). A trailing append is therefore
   compatible, which is what the model_intermediate_buffer comment already says.

2. test_vllm_omni.py errored 4x and test_realtime_omni_bridge failed with "Main
   server process exited with code 1", both from:

     RuntimeError: Failed to import diffusers.pipelines.pipeline_utils ...
     cannot import name 'get_cached_repo_tree' from 'huggingface_hub'

   The omni guard caught (ImportError, OSError, NotImplementedError). diffusers
   imports its pipelines lazily and re-raises ANY underlying failure as
   RuntimeError, so a broken transitive dependency never reaches that tuple and
   the module ERRORS instead of skipping. Added RuntimeError.

   test_realtime_omni_bridge had no omni guard at all; its worker
   (realtime_omni_mock_worker.py) imports vllm_omni and dynamo.vllm.omni at
   module scope, so the same failure killed the worker and surfaced as an opaque
   server-exit. It now probes those imports and skips.

   These tests could not run at all before vllm-omni became installable on this
   branch, so they were silently skipping rather than passing. Making omni
   installable is what exposed the guard gap.

Measured, not inferred: the base image ships huggingface_hub 1.28.0, which DOES
export get_cached_repo_tree (verified in the wheel), and the build never
installs, moves or downgrades hub. So this is a diffusers-0.40.0-vs-hub-1.28.0
incompatibility, not a pin conflict introduced here -- worth reporting upstream
separately. Skipping restores the behaviour the suite already intended.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
The request-side assertion now passes; this is the output-side one behind it.
vLLM 0.28.0 appends `mm_cache_miss_hashes` and `new_sampling_mask` after
num_nans_in_logits -- after vLLM's own fields, before anything vllm-omni adds.

No production change. vllm_processor.py builds EngineCoreOutput BY KEYWORD from
four required kwargs and feature-detects optional fields through
__struct_fields__, so appended fields take their msgspec defaults. That is not
just an argument from the code: this exact path served ~7000 requests on vLLM
0.28.0 during validation (p0 plus a full 3541-request moontrace replay on both
2-replica AGG and 1P1D) with no error, so the defaults are demonstrably
acceptable for detokenization.

Rather than enumerate another four hand-written tuples, the variants are now
generated as a product of (base | cached-token) x (with | without the 0.28.0
extras) x (with | without the omni extras) -- 8 variants from the same three
building blocks the test already defined. Verified the CI-observed tuple is
accepted and all four previously-valid shapes still are.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
The previous commit replaced the hand-written omni output tuples with a generated
product, leaving omni_output_fields and omni_cached_token_output_fields assigned
but unused -- ruff F841, which I pushed without reading the pre-commit result.
They are referenced nowhere else; the generated variants cover the same shapes.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
`pytest.skip(..., allow_module_level=True)` removes the module from
COLLECTION, not just from running. The CI step
`-m "(pre_merge and vllm and gpu_1) and not profiled_vram_gib"` has
exactly one match in the repo -- `test_omni_serve_deployment[omni_audio]`
in tests/serve/test_vllm_omni.py -- so once that module stopped being
collected, the step selected nothing and pytest exited 5 ("no tests ran"),
which the runner reports as a job failure.

The test was already marked `pytest.mark.skip`, so before this it was
collected and reported as skipped and the step exited 0. Guard the probe
import into a flag and skip via `pytest.mark.skipif` instead: same intent,
but the items stay collected and are reported as skipped.

Verified in the runtime container with the omni import blocked, running
that exact marker expression against tests/serve/test_vllm_omni.py:
module-level skip -> "1 skipped", exit 5; skipif -> "1 skipped, 5
deselected", exit 0.

tests/frontend/test_realtime_omni_bridge.py carried the same pattern and
gets the same treatment, so it cannot empty a selection later.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
…rocessor path

Two request-rejection paths in the Python chat processor
(`--dyn-chat-processor vllm`) surfaced as HTTP 500 with no usable
explanation. The Rust OpenAIPreprocessor handles both correctly; the
Python path does not go through it.

1. A worker-side rejection -- an unsupported sampling parameter, an
   unparseable grammar -- crosses the Rust boundary as a plain exception
   whose text is `BackendInvalidArgument: {"message":...,"code":400}`.
   The generic `except Exception` turned that into an internal error,
   discarding both the status and the reason.
   `backend_invalid_argument_to_http_error()` recovers them, mirroring
   `extract_backend_error_if_present` in
   lib/llm/src/http/service/openai.rs.

2. `logprobs`/`top_logprobs` are not implemented on this path (see the
   existing TODO), but the code warned and continued anyway, emitting a
   response with an empty logprobs struct that the HTTP layer cannot
   deserialize ("invalid length 0, expected struct ChatChoiceLogprobs
   with 2 elements"). It now rejects up front.

The rejection is 400 rather than the 501 used for other unimplemented
fields (`previous_response_id`, `background`, `prompt`): those are raised
inside the Rust HTTP service, whereas a status asserted by a Python
engine is triaged by SanitizedError::for_backend_status, which forwards a
4xx verbatim but replaces the body of any 5xx. A 501 raised here reaches
the client as an opaque 500 with the reason lost -- observed, not assumed.

Measured on Inkling-NVFP4 / GB300 (2-replica AGG, vLLM 0.28.0): min_p,
malformed json_schema, logprobs and top_logprobs all go 500 -> 400
carrying the backend's own message; the other seven probe cases are
unchanged.

test_generator_preserves_zero_top_logprobs pinned the warn-and-continue
behaviour, so it is repointed at the rejection and renamed. It still
guards the subtlety it always did: `logprobs=True` with `top_logprobs=0`
projects to 0, so a truthiness guard would let exactly that request
through and re-create the 500 -- the guard tests `is not None`.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
(cherry picked from commit 894ee68)
…sagg

On a disaggregated deployment a request the prefill worker had correctly
rejected with 400 reached the client as a 500. The worker's error was
attached to `PrefillError::PrefillError` as a `#[source]` only, and every
path out of the prefill router reaches the frontend through `to_pyerr`,
which formats with `Display` and drops the error chain
(`lib/bindings/python/rust/lib.rs`). The frontend therefore saw the bare
"Prefill router returned error in output" and could not tell an invalid
argument from an engine fault.

Fold the worker's text into the message at the two sites that wrap a real
error, so the `BackendInvalidArgument: {...}` discriminator survives the
Display-only boundary. The HTTP layer and the Python chat processors
already key on it, so no other change is needed for the status to reach
the client. `match_error_chain` is unaffected -- it matches error TYPES
through the chain, never this string -- and the sourceless
"no output (stream ended)" case is left alone.

Also stop yielding untagged error dicts from the vLLM chat processor. The
binding only treats a dict as an `Annotated` envelope when it carries
`_dynamo_annotated` (`depythonize_annotated`); a bare `{"error": {...}}`
is parsed as a completion chunk, fails with "missing field `id`", and the
client gets an opaque 500 in place of the message -- so the reason never
arrives even when the frontend has one. `as_error_envelope()` tags it;
the shared helpers keep their shape, so the SGLang processor and the
existing tests are untouched.

Measured on Inkling-NVFP4 / GB300 1P1D disagg, where min_p and two
malformed json_schema payloads returned 500 3/3 while logprobs (rejected
frontend-side, so path-independent) correctly returned 400.

Signed-off-by: Sungsoo Ha <sungsooh@nvidia.com>
@sungsooha
sungsooha temporarily deployed to external_collaborator August 31, 2026 17:49 — with GitHub Actions Inactive
@sungsooha
sungsooha temporarily deployed to external_collaborator August 31, 2026 17:49 — with GitHub Actions Inactive
@github-actions

Copy link
Copy Markdown
Contributor

👋 Hi sungsooha! Thank you for contributing to ai-dynamo/dynamo.

Just a reminder: The NVIDIA Test Github Validation CI runs an essential subset of the testing framework to quickly catch errors.Your PR reviewers may elect to test the changes comprehensively before approving your changes.

🚀

@github-actions github-actions Bot added the external-contribution Pull request is from an external contributor label Aug 31, 2026
@github-actions github-actions Bot added backend::vllm Relates to the vllm backend frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` router Relates to routing, KV-aware routing, etc. container labels Aug 31, 2026
@sungsooha sungsooha changed the title [DO NOT MERGE] CI image build — Inkling container + prefill-router error-status fix build: [DO NOT MERGE] CI image build — Inkling container + prefill-router error-status fix Aug 31, 2026
@github-actions github-actions Bot added the build label Aug 31, 2026
@sungsooha sungsooha closed this Aug 31, 2026

This branch was previously deployed

1 inactive deployment
external_collaborator — 36201cfd Deployed Aug 31, 2026 by sungsooha via pr_reminder #12042
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend::vllm Relates to the vllm backend build container external-contribution Pull request is from an external contributor frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` router Relates to routing, KV-aware routing, etc. size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants