Skip to content

feat: add DeepSeek-V4-Flash support - #676

Merged
waybarrios merged 4 commits into
waybarrios:mainfrom
janhilgard:feat/deepseek-v4-flash
Aug 26, 2026
Merged

waybarrios merged 4 commits into
waybarrios:mainfrom
janhilgard:feat/deepseek-v4-flash

Conversation

@janhilgard

Copy link
Copy Markdown
Collaborator

DeepSeek-V4 ships no Jinja chat_template — its tokenizer_config.json carries only BOS/EOS/pad — so the prompt has to be built programmatically. Today the model would either raise in _apply_chat_template or fall back to naive "role: content" concatenation, and its DSML tool calls would go unparsed. This adds the three pieces needed to serve it.

Prompt encoder

vllm_mlx/utils/deepseek_v4_encoding.py ports the reference encoding_dsv4.py published with the weights:

<|begin▁of▁sentence|>{system}<|User|>{question}<|Assistant|><think>

System content is bare text with no wrapper; roles are delimited solely by <|User|>/<|Assistant|>. The turn closes on <think> in thinking mode or </think> in chat mode, which suppresses reasoning. Tool schemas render into the system message, and tool results fold into the preceding user turn as <tool_result> blocks — V4 has no tool role. reasoning_effort is a text prefix on the whole conversation rather than a token or a sampling parameter, and drop_thinking is forced off when tools are present, because the model needs to see why it made the earlier calls.

vllm_mlx/utils/tokenizer.py installs the encoder by overriding apply_chat_template on the tokenizer when model_type is deepseek_v4. That fixes all three call sites at once — engine/batched.py, engine/simple.py and models/llm.py all reach the template through that one method — without touching any of them. It mirrors what upstream vLLM does in vllm/tokenizers/deepseek_v4.py.

DSML tool parser

V4 emits its own markup rather than JSON:

<|DSML|tool_calls>
<|DSML|invoke name="get_weather">
<|DSML|parameter name="city" string="true">Prague</|DSML|parameter>
<|DSML|parameter name="days" string="false">3</|DSML|parameter>
</|DSML|invoke>
</|DSML|tool_calls>

The string attribute carries the type — "true" is a raw string, "false" is JSON. That is why this is a scanner and not a regex over name=value pairs: a string parameter may legitimately contain quotes, angle brackets or a JSON-looking payload. Registered as deepseek_v4/dsml and wired into AutoToolParser; the existing DeepSeekToolParser handles the V3/R1 <|tool▁calls▁begin|> format and shares nothing with this one.

Reasoning parser

Extends the R1 parser, which already tolerates the missing opening <think> that the encoder's prompt implies. What V4 adds is that a tool call must follow completed reasoning, so an opening <|DSML|tool_calls> terminates the reasoning block even when </think> never arrives. Without that, the entire DSML payload is swallowed as reasoning and the caller sees no tool call at all.

Streaming

Both parsers track how much of the accumulated text they have emitted and withhold a tail that could still grow into a marker. <|DSML|tool_calls> is assembled from several tokens — only the bare |DSML| has an id of its own — so it always straddles delta boundaries. Two failure modes follow from that, and both are covered by tests: detecting completion against the delta rather than the accumulated text loses the calls entirely, and emitting marker fragments as they arrive leaks markup into the user-visible stream and then repeats the whole marker once it is recognised.

Scope

Model loading is deliberately not part of this. vllm-mlx defines no model architectures — models/ only wraps mlx_lm.load() — and deepseek_v4 lands in mlx-lm via ml-explore/mlx-lm#1189, which is open and active. Everything here is text processing and starts working the moment that merges. There is precedent for staging it this way: text_model_from_vlm.py already reports "Cannot import mlx_lm TextModel (need PR #990)".

Continuous batching will likely want its own patch alongside patches/gemma4_mllm.py and patches/glm4v_moe_mllm.py, since V4 has MLA with a compressor, a sparse indexer and mHC. That is best written once the model actually runs.

Testing

tests/test_deepseek_v4_encoding.py asserts against prompts frozen from the reference implementation, which cannot be vendored here. During development the port was also run differentially against that reference across 832 combinations of conversation shape, thinking mode, effort level and tool usage — all identical.

Verified against the real model, DeepSeek-V4-Flash-0731 MXFP4 with 167 GB resident on an M3 Ultra, across 23 scenarios: chat, thinking, every reasoning_effort level, single and parallel tool calls, tool-result round trips and multi-turn history. Prompts were built by this encoder and the completions run back through these parsers — 127/128 checks pass. The one failure is a scenario truncated at the token cap before it emitted </think>, where treating the output as content is the correct behaviour. The model's DSML matched the specification including the string="true|false" type flag, and parallel calls came back intact:

get_weather({"city": "Rome", "days": 1})
get_weather({"city": "Paris", "days": 1})
get_weather({"city": "Madrid", "days": 1})

The three new test files are added to the CI whitelist in .github/workflows/ci.yml, without which they would never run.

@janhilgard
janhilgard force-pushed the feat/deepseek-v4-flash branch 2 times, most recently from f38b87c to 00e489b Compare August 3, 2026 08:53
@janhilgard

Copy link
Copy Markdown
Collaborator Author

Pushed an update after auditing how the pieces behave once they are wired into the server rather than exercised on their own. Two integration bugs came out of it, plus a benchmark.

SUPPORTS_NATIVE_TOOL_FORMAT was wrong. I had it False. That makes extract_multimodal_content flatten role="tool" into "[Tool Result (id)]: ..." and assistant tool_calls into "[Calling tool: name(...)]" before the encoder runs, so a multi-turn tool conversation reached the model as text it was never trained on and the encoder's own <tool_result>/DSML handling never fired. The encoder consumes both natively, so the flag is now True.

Tool call arguments arrive decoded. With native format preserved, api/utils.py json-loads arguments in place. The encoder was loading it again, and json.loads on a mapping raises, so every parameter collapsed into a single bogus arguments entry — the model saw a malformed call in its own history. It now accepts either form.

Both are covered by regression tests. I also added a test that chains the reasoning parser into the tool parser the way the server chains them, since testing them separately misses exactly the handoff where the markup crosses over.

Benchmark (benchmarks/bench_deepseek_v4.py), covering both serving paths:

Prompt encoding      4 messages ->   0.022 ms      130 messages ->   0.335 ms

Single stream, tool-calling turn
   100 tokens -> 0.0061 ms/tok      5000 tokens -> 0.0380 ms/tok

DSML tool parser alone
   100 tokens -> 0.0018 ms/tok      5000 tokens -> 0.0028 ms/tok

Batched decode, 1000 tokens each
     1 concurrent -> 0.0107 ms/tok    16 concurrent -> 0.0107 ms/tok

Batching costs nothing per token — each request holds independent parser state and totals scale linearly to 16 streams. The DSML parser's own cost is flat.

The combined single-stream figure does grow with output length, and it is not this PR's code: BaseThinkingReasoningParser searches the accumulated text for its start and end tags on every delta while the reasoning block is open, which is O(N²) over a generation. Isolating the tool parser shows the difference. It is 0.2% of the decode budget at 50 tok/s so nothing is on fire, but it affects qwen3 and deepseek_r1 equally and belongs in the base class rather than being worked around per model — happy to open a separate PR for it if that is useful.

Rebased onto current main.

@janhilgard janhilgard self-assigned this Aug 3, 2026
@janhilgard
janhilgard requested a review from Thump604 August 3, 2026 09:14
@janhilgard

Copy link
Copy Markdown
Collaborator Author

@Thump604 — review requested.

Two notes for reviewing this one. The DSML tool parser is a scanner rather than a regex because the string="true|false" attribute means a string parameter may legitimately contain quotes, angle brackets or a JSON-looking payload; TestParameterTyping::test_string_value_may_contain_markup_like_text pins that.

The streaming logic is the part most worth a careful look. <|DSML|tool_calls> has no token id of its own — only the bare |DSML| does — so the marker always straddles delta boundaries, and two obvious implementations are both wrong: detecting completion against the delta rather than the accumulated text drops the calls entirely, and emitting marker fragments as they arrive leaks markup to the client and then repeats the whole marker. Both are covered by tests at chunk sizes 1 through 128.

Related: #677 fixes two bugs in BaseThinkingReasoningParser that this parser inherits from. They do not block this PR, but see the note there about merge order.

@janhilgard

Copy link
Copy Markdown
Collaborator Author

Update after validating this against a live model end to end.

Which mlx-lm PR to pair this with: #1192. I tried three that add deepseek_v4 and only that one is usable:

DSML tool calls batched cache
#1189 ✗ ✗
#1195 ✗ ✓
#1192 ✓ ✓

#1189 and #1195 emit <|DSML|tool_c天气> where the marker should be <|DSML|tool_calls>, so tool calls fail while ordinary prose still reads fine — the sort of thing that looks healthy in a smoke test. It is the model implementation, not this PR's parsing or the batching: input token ids are identical across paths, both tokenizers decode the marker correctly on its own, and layer-by-layer hidden-state norms drift from the reference implementation by 1.7% at layer 0 and past 20% from layer 5, once the MoE router starts choosing different experts. #1192 is closed but mergeable, and oMLX ships it, which is how I found it.

Verified against #1192 on DeepSeek-V4-Flash-0731 MXFP4 (283.8 B params, M3 Ultra), through this branch's encoder and parsers:

facts          Paris / 51 / Pacific / No                    4/4
DSML           single, parallel, two tools, integer arg     4/4
determinism    same call three times at temperature 0       1/1

single stream   31 tok/s
aggregate @8    90 tok/s

Over HTTP with --tool-call-parser deepseek_v4 --reasoning-parser deepseek_v4, a full battery passes 11/11: finish_reason=tool_calls, arguments as JSON objects, reasoning split into reasoning_content, no DSML in user-visible content, parallel calls intact, and a tool-result round trip where the model uses the returned values.

Nothing in the diff changed — it is the same encoder and parsers as before, now with the model side pinned down.

@janhilgard
janhilgard force-pushed the feat/deepseek-v4-flash branch 3 times, most recently from 36aea0d to 8181e62 Compare August 3, 2026 17:16
@janhilgard

Copy link
Copy Markdown
Collaborator Author

Update: getting this model actually serving turned up three engine bugs, now fixed in this branch. Flagging them here since they are not really DeepSeek-specific and reviewers may want them split out.

1. Model load and generation ran on different threads

MLX streams exist only in the thread that created them, and an array with pending primitives carries the stream those primitives were built on. SimpleEngine spread that work over three threads (load and streaming on the event loop, _run_blocking_serialized on asyncio.to_thread), and BatchedEngine created its own engine-core thread but loaded the model on the event loop.

Symptoms differed but the cause was one:

  • SimpleEngine returned HTTP 500 on every request with There is no Stream(gpu, N) in current thread.
  • BatchedEngine looked like a scheduler hang — running=1, the step counter climbing into the millions, not one token out. It was not the scheduler: batch_generator.next() raised the same error, engine_core fell back to model-thread stepping, hit it again there, and since that fallback fires only once the loop then spun on the error.

The scattered bind_generation_streams() calls cannot fix this — rebinding a module-level handle changes a global the already-built buffers never consult. I measured the alternatives on a cache built on one thread and evaluated on another: per-worker rebinding failed 3/3, keeping mlx-lm's import-time ThreadLocalStream failed 3/3, pinning passed 0/3. The SimpleEngine half is also up standalone as #679.

Side effect worth knowing: generation no longer blocks the event loop, so genuinely concurrent requests now reach admission control where the default fail_fast rejects them. That was previously masked. VLLM_MLX_SIMPLE_ENGINE_LOCK_ADMISSION=wait restores queueing.

2. The prefix cache could never hit on this architecture

The scheduler stored one entry keyed by prompt + output. Every later query is shorter than such a key, so reuse requires trimming the generated tail — impossible here. RotatingKVCache.is_trimmable() is offset < max_size and sliding_window is 128, so past 128 tokens the ring buffer has physically overwritten the older KV; PoolingCache merges tokens into windows (compress_ratios alternate 4 and 128) and cannot split one. Zero hits were guaranteed by construction, and the code even relied on the supersequence path that memory_cache.py disables for non-trimmable caches.

Fixed by also snapshotting the cache while it still covers exactly the prompt — before the first generated token is appended — and storing that under the prompt tokens. Such an entry is reusable by strict-prefix match with no trimming at all. Two traps: mlx-lm attaches prompt_cache only to the response carrying a finish_reason, so the per-sequence cache is pulled from the live batch via extract_cache(idx); and the snapshot must be a real copy, because both cache types write into their buffers in place.

Exact matches are deliberately skipped. The scheduler re-feeds prompt[-1:] on those, which duplicates that token in the KV cache when the entry covers the whole key — measured as the same prompt returning a different answer.

3. --chunked-prefill-tokens was silently dropped

It was only applied by monkey-patching BatchGenerator._process_prompts / active_batch, neither of which exists on current mlx-lm, so the scheduler logged "Chunked prefill disabled" and prefilled in one go. The warning was also wrong about the consequence: mlx-lm chunks the prompt natively — PromptProcessingBatch consumes at most prefill_step_size per step — so the budget just needed mapping onto that. Only the patch's extra (mid-prefill cache saves) is genuinely unavailable.

Verification

DeepSeek-V4-Flash-0731, 283.8B MXFP4, M3 Ultra:

cache correctness, 5 scenarios × 2 turns 10/10 character-identical cold vs warm
non-stream suite 30/30, 0 stream errors
streaming suite 18/18
tool calls 6/6 non-stream, 3/3 streaming
concurrency 4/4, 54.3 tok/s aggregate vs ~31 single-stream
long shared prefix 4.19s → 1.09s
chunked prefill, 8413-token prefill + short request 1s later 1024/chunk: short 27.3s · 256/chunk: short 12.1s
repo suite 2412 passed

@janhilgard
janhilgard force-pushed the feat/deepseek-v4-flash branch from 8181e62 to acaad47 Compare August 3, 2026 19:20
@janhilgard

Copy link
Copy Markdown
Collaborator Author

Follow-up: the post-prefill snapshot I added above had a memory bug that only showed under a real agentic client, now fixed.

It was stored with evict_prefixes=False, but in an agentic loop each turn's prompt extends the previous one, so every turn added another full-length KV copy rather than replacing the entry it supersedes — 45 entries of a ~46k-token cache. Each copy is hundreds of arrays (43 layers, CacheList of three caches each), and Metal runs out of buffers long before the cache's byte budget is reached, so the memory accounting never noticed:

RuntimeError: [metal::malloc] Resource limit (499000) exceeded.
[generation_error_recovery] aborted 1 running requests, batch generator closed

The failure lands inside the model during a generation step, the request is aborted, and the client just sees the connection stall.

Two changes:

  • store the snapshot with evict_prefixes=True, so a conversation keeps one entry instead of one per turn
  • skip the completion-time prompt + output entry when can_trim_prompt_cache() is false. Such an entry can never be reused — every later query is shorter than its key, so reuse needs a trim — making it pure memory pressure on exactly the architectures that can least afford it

Verified with an 8-turn tool-calling conversation over a ~33k-token context:

before after
cache entries 45 and climbing 1
Metal errors 7 0
cache hits — 7/8, 84 tokens prefilled instead of 33k
tool calls parsed — 8/8
steady-state turn latency — 3.7–5.2s

Worth noting for reviewers: the byte-based limit in memory_cache.py is not sufficient on its own, because buffer count is the binding resource for caches split across many small per-layer arrays.

@janhilgard
janhilgard force-pushed the feat/deepseek-v4-flash branch 3 times, most recently from 21bf1d5 to 12dcc4e Compare August 3, 2026 21:12

@Thump604 Thump604 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The DeepSeek encoder/parser work is valuable, but the current branch is no longer reviewable or safe to merge as one feature: 26 files / +3,089 lines now combine DeepSeek prompt+DSML support with SimpleEngine and BatchedEngine thread ownership, prefix-cache snapshot/accounting policy, and chunked-prefill behavior. The embedded SimpleEngine lifecycle is also the earlier implementation superseded by #679's stop/drain corrections, and #648 already carries the narrow chunked-prefill API update.

Please split this into independently reviewable changes:

  1. DeepSeek V4 encoder, DSML tool/reasoning parsers, registration/CLI, goldens, and their focused tests.
  2. SimpleEngine ownership via #679; remove the duplicate implementation here.
  3. A separate BatchedEngine owner-thread/error-handling PR with its own reproduction and lifecycle tests.
  4. Separate prefix-cache changes, with explicit coverage for non-trimmable/rotating caches and memory ownership.
  5. Reuse/rebase onto #648 for chunked prefill rather than carrying another copy.

Then rebase the DeepSeek slice after #677 and rerun the combined streaming/tool replay. Finally, update the dependency story: the PR currently identifies mlx-lm #1192 as the usable implementation, but #1192 is closed and unmerged, so the feature needs an installable upstream version/active dependency plus an end-to-end reproduction before merge. CI being green on this combined branch does not close those scope and dependency blockers.

@janhilgard
janhilgard force-pushed the feat/deepseek-v4-flash branch from 12dcc4e to c37e588 Compare August 8, 2026 08:42
@janhilgard

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main — the branch is mergeable again.

One conflict, in server.py: #562 added the gpt-oss harmony markers to _STREAMING_TOOL_MARKERS while this branch added the DSML one. Independent additions to the same tuple, so I kept both. Repo suite at the new head: 2443 passed, and I checked the gate directly rather than trusting the merge — <|channel|>commentary, <|call|>, |DSML|, plus a partial |DSML|tool_c delta all still trip it, 6/6.

That is only the mechanical blocker. On the substance of your review:

The split is fair and I am not going to argue it. The branch grew engine changes because that was the only way to get the model serving at all, but you are right that "DeepSeek encoder + DSML parsers" and "engine thread ownership + prefix-cache policy + chunked prefill" are four different review problems wearing one hat, and the SimpleEngine copy here is genuinely superseded by #679's stop/drain work. I will restructure along your lines: reuse #679 for SimpleEngine, rebase chunked prefill onto your #648 rather than carrying a second copy, and take BatchedEngine's owner thread and the prefix-cache snapshot out as their own PRs with their own reproductions. The DeepSeek slice then rebases after #677 and gets the combined streaming/tool replay re-run.

The dependency blocker is real and I do not have an answer for it yet. I re-checked upstream today: no mlx-lm PR adding deepseek_v4 has merged. #1189, #1201, #1067 and #1337 are open, #1192, #1193 and #1190 are closed, and released mlx-lm (0.31.3) has no models/deepseek_v4.py. So the honest position is that the DeepSeek slice cannot merge on an installable dependency today, whatever its own quality. I would rather it sit behind that than ship something whose only working model implementation is a closed PR people have to apply by hand.

Given that, my preference is to land the parts that stand on their own — they are useful independently of whether DeepSeek ever arrives — and hold the DeepSeek slice until upstream settles. If you would rather I keep it open as a tracking branch or close it until then, say which and I will do that; I will not close it on my own.

@janhilgard

Copy link
Copy Markdown
Collaborator Author

Split done, along the lines you set out. This PR is now the DeepSeek slice alone: 14 files, +2333 instead of 26 files / +3089.

your item where it went
1. DeepSeek encoder, DSML parsers, registration/CLI, goldens, tests this PR, nothing else left in it
2. SimpleEngine ownership via #679 removed here — #679 is the only copy
3. BatchedEngine owner thread, own repro + lifecycle tests #684
4. Prefix cache, explicit non-trimmable/rotating + memory-ownership coverage #683
5. Reuse #648 for chunked prefill dropped here entirely

Notes on three of them.

#684 is stacked on #679, because it needs run_blocking_startup_work(executor=...) from it. Its own content is 64 lines across batched.py and engine_core.py; the diff cleans up once #679 merges. Say the word if you would rather I carry the twelve-line base.py change in #684 instead and keep the two independent — I went with stacking specifically to avoid the duplicate implementation you objected to here.

On #648: I dropped my chunked-prefill change rather than rebasing onto yours, because yours is strictly better. Mine mapped the budget onto prefill_step_size at construction and left the legacy path behind; _configure_chunked_prefill dispatches between the legacy and native APIs and sets batch_gen.prefill_step_size = budget on the native one. Same effect on current mlx-lm, and it keeps working on older layouts. Nothing of mine was worth carrying forward.

#683 and #684 both got the coverage you asked for, and writing #684's tests caught me repeating the exact mistake waybarrios flagged on #679: my first version replicated the engine's load call in the test body, so reverting start() to the unpinned pool still passed. Both PRs now drive the real entry points, and I mutation-tested each — reverting the behaviour while keeping the API fails a specific test in every case.

Merge order for this PR: it depends on nothing, but #677 changes the reasoning base class DeepSeekV4ReasoningParser inherits from, so I checked it merged with #677 rather than only alongside it — 307 targeted tests and 2479 repo-wide, clean. On this branch alone: 2439 passed.

The dependency blocker stands unchanged, and I am not going to pretend otherwise: no mlx-lm PR adding deepseek_v4 has merged, and released mlx-lm 0.31.3 has no models/deepseek_v4.py. So this slice still cannot merge on an installable dependency, however clean it is now. #683 and #684 do not have that problem — they are about the engine and are useful regardless of whether DeepSeek ever arrives — which is another reason splitting was the right call.

@Thump604 Thump604 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The split is good. This is reviewable now, and the engine, cache, and chunked-prefill scope is gone. Two blockers remain. The only model implementation validated end to end is closed and unmerged mlx-lm #1192, while released mlx-lm and current main still have no DeepSeek V4 model. Please update the PR body and rerun end to end once there is an installable dependency. Also, buffered streaming drops text before a tool call when the text and complete DSML block arrive in one delta; the parser returns only tool_calls. Please cover that case and preserve both. Keeping changes requested until those are fixed.

@Thump604

Thump604 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Keep it open as the tracking branch. The split was the right move, and the standalone engine/cache work can move independently. This one should stay held until there is an installable DeepSeek V4 dependency and the buffered text-before-tool-call case is fixed.

janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 8, 2026
MLX streams exist only in the thread that created them, and `BatchGenerator`
captures `generation_stream` into `self._stream` when it is built. So the
thread that loads the model has to be the thread that drives `scheduler.step`.

The two batched paths step on different threads, so they load on different
threads:

- AsyncEngineCore steps on a worker. `BatchedEngine` loaded inline on the
  event loop (issue waybarrios#407), so the two never matched. The symptom does not look
  like a stream problem: `batch_generator.next()` raises "There is no
  Stream(gpu, N) in current thread", `EngineCore` falls back to stepping on the
  model thread, hits the same error there, and — because that fallback fires
  only once — then spins on the error. What an operator sees is `running=1`,
  the step counter climbing into the millions, and not one token emitted. It
  reads as a scheduler hang, which is how I first misdiagnosed it.
- MLLM never reaches AsyncEngineCore. `_start_mllm` drives MLLMScheduler,
  whose `_process_loop` calls `step()` on the event loop with no executor hop,
  so an MLLM model has to be built there — as it was before this change.

`_model_load_executor()` states that policy in one place, and `start()` and
ResidencyManager both read it. `EngineCore` steps on a supplied worker and does
not shut it down, since that thread owns the loaded model and outlives the
engine loop; without one it still creates and retires its own, unchanged.

Stacked on waybarrios#679 for `run_blocking_startup_work(executor=...)`. It cleans up by
itself once waybarrios#679 lands. Split out of waybarrios#676 on review.

Verified on DeepSeek-V4-Flash-0731 (283.8B MXFP4, M3 Ultra), which could not
emit a single token through the batched path before this: 4/4 concurrent
requests, 54.3 tok/s aggregate against ~31 single-stream.

`tests/test_batched_engine_owner_thread.py` drives the engine's real `start()`
rather than replicating its load call, and covers both paths: MLLM load and
MLLMScheduler stepping land on the event loop, non-MLLM load and EngineCore
stepping land on the worker, and ResidencyManager honours the same split.
Confirmed by mutation — pinning MLLM to the worker, having lifecycle ignore the
policy, dropping `executor=` from the load, and shutting down a caller-supplied
worker are each caught by a distinct test. Repo suite: 2282 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 8, 2026
The scheduler stored one prefix-cache entry per request, keyed by
``prompt + output``. Every later query is *shorter* than such a key, so reuse
requires trimming the generated tail off first. Models with sliding-window or
pooled KV cannot do that: ``RotatingKVCache.is_trimmable()`` is
``offset < max_size``, so once the ring buffer wraps the older KV is physically
overwritten, and a pooling cache merges tokens into windows it cannot split.

Zero hits were guaranteed by construction on those architectures, and the
entries were not free: each holds a full-length KV copy, and in an agentic loop
where every turn extends the previous prompt they accumulate. Measured 45
entries of a ~46k-token cache. Metal runs out of *buffers* long before the
byte budget notices:

    RuntimeError: [metal::malloc] Resource limit (499000) exceeded.

Two changes:

- Snapshot the cache once it covers the prompt and store that. Such an entry is
  reusable by strict prefix match with no trimming at all.
- Skip the completion-time ``prompt + output`` entry when the cache is not
  trimmable, since it can never be reused.

Four things the implementation has to get right:

- mlx-lm attaches ``prompt_cache`` only to the response carrying a
  ``finish_reason``, so mid-generation it is None. The per-sequence cache is
  pulled from the live batch via ``extract_cache(idx)``.
- The snapshot must be a real copy. Both cache types write into their buffers
  in place, so a snapshot that aliases them is rewritten by the generation it
  is supposed to predate.
- It is stored with ``evict_prefixes=True``, or each turn adds another
  full-length copy rather than replacing the entry it supersedes.
- The key must name exactly the tokens the snapshot holds. The snapshot is
  taken while processing the response carrying the first generated token, and
  the batch has already fed that token through the cache — measured
  ``prompt_len=5, cache_offset=6`` on a real scheduler run. Storing that under
  ``prompt_token_ids`` left every warm reuse one token ahead of its key.
  Trimming the overshoot off is not available here, so the key is extended
  instead; the extra token is the first token of the reply, which the next
  turn's prompt also contains, so the entry still matches by strict prefix.
  A key that cannot be named exactly means no entry at all.

The snapshot destination mirrors the live cache objects rather than calling
``make_prompt_cache``. A plain ``KVCache`` destination cannot take a
``RotatingKVCache``'s state or meta_state; the assignment raised, the broad
handler logged a warning, and nothing was stored on precisely the
configurations this feature targets. Deriving the destination from
``config.max_kv_size`` instead is also wrong, which measurement showed:
``_create_batch_generator`` does not pass ``max_kv_size`` to
``BatchGenerator``, so with it configured the live layers were still plain
``KVCache``.

Exact matches are deliberately skipped rather than used. The scheduler re-feeds
``prompt[-1:]`` on those, which duplicates that token in the KV cache when the
entry covers the whole key — measured as the same prompt returning a different
answer.

``memory_cache.py`` could not measure these caches either: ``CacheList.state``
is nested, and the two-way unpack raised a ValueError that was swallowed, so
every such entry was accounted as 0 bytes and the byte-based LRU never evicted.

Verified on DeepSeek-V4-Flash-0731 (283.8B MXFP4, M3 Ultra), an 8-turn
tool-calling conversation over a ~33k-token context: cache entries 45 -> 1,
Metal buffer errors 7 -> 0, 7/8 hits with 84 tokens prefilled instead of 33k,
long shared prefix 4.19s -> 1.09s. Re-checked end to end on Qwen3-0.6B through
the real scheduler after the alignment fix: identical greedy output cold and
warm, key length equal to cache coverage, and a prefix hit on the extended
second-turn prompt.

Split out of waybarrios#676 on review. Repo suite: 2310 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 8, 2026
The scheduler stored one prefix-cache entry per request, keyed by
``prompt + output``. Every later query is *shorter* than such a key, so reuse
requires trimming the generated tail off first. Models with sliding-window or
pooled KV cannot do that: ``RotatingKVCache.is_trimmable()`` is
``offset < max_size``, so once the ring buffer wraps the older KV is physically
overwritten, and a pooling cache merges tokens into windows it cannot split.

Zero hits were guaranteed by construction on those architectures, and the
entries were not free: each holds a full-length KV copy, and in an agentic loop
where every turn extends the previous prompt they accumulate. Measured 45
entries of a ~46k-token cache. Metal runs out of *buffers* long before the
byte budget notices:

    RuntimeError: [metal::malloc] Resource limit (499000) exceeded.

Two changes:

- Snapshot the cache once it covers the prompt and store that. Such an entry is
  reusable by strict prefix match with no trimming at all.
- Skip the completion-time ``prompt + output`` entry when the cache is not
  trimmable, since it can never be reused.

Four things the implementation has to get right:

- mlx-lm attaches ``prompt_cache`` only to the response carrying a
  ``finish_reason``, so mid-generation it is None. The per-sequence cache is
  pulled from the live batch via ``extract_cache(idx)``.
- The snapshot must be a real copy. Both cache types write into their buffers
  in place, so a snapshot that aliases them is rewritten by the generation it
  is supposed to predate.
- It is stored with ``evict_prefixes=True``, or each turn adds another
  full-length copy rather than replacing the entry it supersedes.
- The key must name exactly the tokens the snapshot holds. The snapshot is
  taken while processing the response carrying the first generated token, and
  the batch has already fed that token through the cache — measured
  ``prompt_len=5, cache_offset=6`` on a real scheduler run. Storing that under
  ``prompt_token_ids`` left every warm reuse one token ahead of its key.
  Trimming the overshoot off is not available here, so the key is extended
  instead; the extra token is the first token of the reply, which the next
  turn's prompt also contains, so the entry still matches by strict prefix.
  A key that cannot be named exactly means no entry at all.

The snapshot destination mirrors the live cache objects rather than calling
``make_prompt_cache``. A plain ``KVCache`` destination cannot take a
``RotatingKVCache``'s state or meta_state; the assignment raised, the broad
handler logged a warning, and nothing was stored on precisely the
configurations this feature targets. Deriving the destination from
``config.max_kv_size`` instead is also wrong, which measurement showed:
``_create_batch_generator`` does not pass ``max_kv_size`` to
``BatchGenerator``, so with it configured the live layers were still plain
``KVCache``.

Exact matches are deliberately skipped rather than used. The scheduler re-feeds
``prompt[-1:]`` on those, which duplicates that token in the KV cache when the
entry covers the whole key — measured as the same prompt returning a different
answer.

``memory_cache.py`` could not measure these caches either: ``CacheList.state``
is nested, and the two-way unpack raised a ValueError that was swallowed, so
every such entry was accounted as 0 bytes and the byte-based LRU never evicted.

Verified on DeepSeek-V4-Flash-0731 (283.8B MXFP4, M3 Ultra), an 8-turn
tool-calling conversation over a ~33k-token context: cache entries 45 -> 1,
Metal buffer errors 7 -> 0, 7/8 hits with 84 tokens prefilled instead of 33k,
long shared prefix 4.19s -> 1.09s. Re-checked end to end on Qwen3-0.6B through
the real scheduler after the alignment fix: identical greedy output cold and
warm, key length equal to cache coverage, and a prefix hit on the extended
second-turn prompt.

Split out of waybarrios#676 on review. Repo suite: 2310 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DeepSeek-V4 has no Jinja chat template, so without a prompt encoder the prompt
is built by plain concatenation and the model sees a format it was never
trained on. It also emits tool calls in its own DSML markup rather than JSON.
This adds the encoder and both parsers, plus registration and CLI wiring.

The DSML tool parser is a scanner rather than a regex, because the
`string="true|false"` attribute means a string parameter may legitimately
contain quotes, angle brackets or a JSON-looking payload;
`TestParameterTyping::test_string_value_may_contain_markup_like_text` pins that.

The streaming path is the subtle part. `<|DSML|tool_calls>` has no token id of
its own — only the bare `|DSML|` does — so the marker always straddles delta
boundaries, and two obvious implementations are both wrong: detecting
completion against the delta rather than the accumulated text drops the calls
entirely, and emitting marker fragments as they arrive leaks markup to the
client and then repeats the whole marker. Both are covered at chunk sizes 1
through 128.

Two integration bugs that only appear once this is wired into the server:

- `SUPPORTS_NATIVE_TOOL_FORMAT` must be True. With False,
  `extract_multimodal_content` flattens `role="tool"` into
  `"[Tool Result (id)]: ..."` and assistant `tool_calls` into
  `"[Calling tool: name(...)]"` *before* the encoder runs, so a multi-turn tool
  conversation reaches the model as prose and the encoder's own
  `<tool_result>`/DSML handling never fires.
- With native format preserved, `api/utils.py` json-loads `arguments` in place.
  The encoder loaded it again, and `json.loads` on a mapping raises, so every
  parameter collapsed into one bogus `arguments` entry — the model saw a
  malformed call in its own history. It now accepts either form.

Benchmark (`benchmarks/bench_deepseek_v4.py`), both serving paths:

    Prompt encoding    4 messages -> 0.022 ms     130 messages -> 0.335 ms
    Single stream      100 tok -> 0.0061 ms/tok   5000 tok -> 0.0380 ms/tok
    DSML parser alone  100 tok -> 0.0018 ms/tok   5000 tok -> 0.0028 ms/tok
    Batched decode     1 concurrent -> 0.0107     16 concurrent -> 0.0107 ms/tok

Batching costs nothing per token; each request holds independent parser state.

Verified end to end on DeepSeek-V4-Flash-0731 MXFP4 (283.8B, M3 Ultra): 11/11
over HTTP with `--tool-call-parser deepseek_v4 --reasoning-parser deepseek_v4`
— `finish_reason=tool_calls`, arguments as JSON objects, reasoning split into
`reasoning_content`, no DSML in user-visible content, parallel calls intact,
and a tool-result round trip where the model uses the returned values.

Scope note: this was previously one branch carrying engine changes as well.
Those are now waybarrios#679 (SimpleEngine ownership), waybarrios#684 (BatchedEngine owner thread)
and waybarrios#683 (prefix cache on non-trimmable KV), and chunked prefill is dropped in
favour of waybarrios#648. This PR is the DeepSeek slice alone.

Merge order: this depends on nothing, but waybarrios#677 changes the reasoning base class
this parser inherits from. Checked merged with waybarrios#677 rather than only alongside
it: 307 targeted tests and 2479 repo-wide, clean.

Repo suite on this branch alone: 2439 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@janhilgard

Copy link
Copy Markdown
Collaborator Author

Understood on keeping it open as the tracking branch — that matches how I read the dependency situation too.

Fixed the second blocker. Reproduced it first:

text + DSML in ONE delta  ->  text=''             calls=1     <- text lost
same content, 4-char chunks -> text='Checking.\n\n' calls=1
non-streaming reference     -> content='Checking.'

So the response depended on nothing but how the model's output happened to be chunked, which is the worst shape for a bug like this to have — token-level streaming hides it, and it only surfaces when a whole response lands at once.

The cause was a deliberate choice of mine, and the comment in the code even argued for it: "only a whole response arriving as a single delta would force the choice, and there the calls win." That was wrong. The text is user-visible assistant content and dropping it silently is data loss, not a tie-break.

It also could not be fixed in the parser alone. The premise I had built on was real — server.py did suppress = result is None or "tool_calls" in result, so a delta carrying both had its content discarded — and the same assumption appeared in three places, spelled three different ways, behind all 19 tool parsers. That half is #690, against main, since nothing about it is DeepSeek-specific: any parser that buffers across a block hits it whenever the block arrives whole.

Here, the parser now flushes the buffered head with the calls when the block opens and closes in the same delta, and ahead of them otherwise. New TestTextAndCallsInOneDelta asserts the single-delta case keeps both, that the result is identical at chunk sizes 1, 3, 4, 17, 64 and 4096, and that the head is never emitted twice. Mutation-checked — removing the head from the formatted result fails seven of them.

Repo suite on this branch: 2447 passed.

That leaves only the dependency blocker, which is unchanged: still no merged mlx-lm PR adding deepseek_v4, and released mlx-lm has no models/deepseek_v4.py. I will rerun the end-to-end battery and update the PR body when there is something installable to run it against.

@janhilgard
janhilgard force-pushed the feat/deepseek-v4-flash branch from 583c5cb to 961b402 Compare August 8, 2026 14:10
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 13, 2026
The scheduler stored one prefix-cache entry per request, keyed by
``prompt + output``. Every later query is *shorter* than such a key, so reuse
requires trimming the generated tail off first. Models with sliding-window or
pooled KV cannot do that: ``RotatingKVCache.is_trimmable()`` is
``offset < max_size``, so once the ring buffer wraps the older KV is physically
overwritten, and a pooling cache merges tokens into windows it cannot split.

Zero hits were guaranteed by construction on those architectures, and the
entries were not free: each holds a full-length KV copy, and in an agentic loop
where every turn extends the previous prompt they accumulate. Measured 45
entries of a ~46k-token cache. Metal runs out of *buffers* long before the
byte budget notices:

    RuntimeError: [metal::malloc] Resource limit (499000) exceeded.

Two changes:

- Snapshot the cache once it covers the prompt and store that. Such an entry is
  reusable by strict prefix match with no trimming at all.
- Skip the completion-time ``prompt + output`` entry when the cache is not
  trimmable, since it can never be reused.

Four things the implementation has to get right:

- mlx-lm attaches ``prompt_cache`` only to the response carrying a
  ``finish_reason``, so mid-generation it is None. The per-sequence cache is
  pulled from the live batch via ``extract_cache(idx)``.
- The snapshot must be a real copy. Both cache types write into their buffers
  in place, so a snapshot that aliases them is rewritten by the generation it
  is supposed to predate.
- It is stored with ``evict_prefixes=True``, or each turn adds another
  full-length copy rather than replacing the entry it supersedes.
- The key must name exactly the tokens the snapshot holds. The snapshot is
  taken while processing the response carrying the first generated token, and
  the batch has already fed that token through the cache — measured
  ``prompt_len=5, cache_offset=6`` on a real scheduler run. Storing that under
  ``prompt_token_ids`` left every warm reuse one token ahead of its key.
  Trimming the overshoot off is not available here, so the key is extended
  instead; the extra token is the first token of the reply, which the next
  turn's prompt also contains, so the entry still matches by strict prefix.
  A key that cannot be named exactly means no entry at all.

The snapshot destination mirrors the live cache objects rather than calling
``make_prompt_cache``. A plain ``KVCache`` destination cannot take a
``RotatingKVCache``'s state or meta_state; the assignment raised, the broad
handler logged a warning, and nothing was stored on precisely the
configurations this feature targets. Deriving the destination from
``config.max_kv_size`` instead is also wrong, which measurement showed:
``_create_batch_generator`` does not pass ``max_kv_size`` to
``BatchGenerator``, so with it configured the live layers were still plain
``KVCache``.

Exact matches are deliberately skipped rather than used. The scheduler re-feeds
``prompt[-1:]`` on those, which duplicates that token in the KV cache when the
entry covers the whole key — measured as the same prompt returning a different
answer.

``memory_cache.py`` could not measure these caches either: ``CacheList.state``
is nested, and the two-way unpack raised a ValueError that was swallowed, so
every such entry was accounted as 0 bytes and the byte-based LRU never evicted.

Verified on DeepSeek-V4-Flash-0731 (283.8B MXFP4, M3 Ultra), an 8-turn
tool-calling conversation over a ~33k-token context: cache entries 45 -> 1,
Metal buffer errors 7 -> 0, 7/8 hits with 84 tokens prefilled instead of 33k,
long shared prefix 4.19s -> 1.09s. Re-checked end to end on Qwen3-0.6B through
the real scheduler after the alignment fix: identical greedy output cold and
warm, key length equal to cache coverage, and a prefix hit on the extended
second-turn prompt.

Split out of waybarrios#676 on review. Repo suite: 2310 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
waybarrios pushed a commit that referenced this pull request Aug 13, 2026
…es (#683)

The scheduler stored one prefix-cache entry per request, keyed by
``prompt + output``. Every later query is *shorter* than such a key, so reuse
requires trimming the generated tail off first. Models with sliding-window or
pooled KV cannot do that: ``RotatingKVCache.is_trimmable()`` is
``offset < max_size``, so once the ring buffer wraps the older KV is physically
overwritten, and a pooling cache merges tokens into windows it cannot split.

Zero hits were guaranteed by construction on those architectures, and the
entries were not free: each holds a full-length KV copy, and in an agentic loop
where every turn extends the previous prompt they accumulate. Measured 45
entries of a ~46k-token cache. Metal runs out of *buffers* long before the
byte budget notices:

    RuntimeError: [metal::malloc] Resource limit (499000) exceeded.

Two changes:

- Snapshot the cache once it covers the prompt and store that. Such an entry is
  reusable by strict prefix match with no trimming at all.
- Skip the completion-time ``prompt + output`` entry when the cache is not
  trimmable, since it can never be reused.

Four things the implementation has to get right:

- mlx-lm attaches ``prompt_cache`` only to the response carrying a
  ``finish_reason``, so mid-generation it is None. The per-sequence cache is
  pulled from the live batch via ``extract_cache(idx)``.
- The snapshot must be a real copy. Both cache types write into their buffers
  in place, so a snapshot that aliases them is rewritten by the generation it
  is supposed to predate.
- It is stored with ``evict_prefixes=True``, or each turn adds another
  full-length copy rather than replacing the entry it supersedes.
- The key must name exactly the tokens the snapshot holds. The snapshot is
  taken while processing the response carrying the first generated token, and
  the batch has already fed that token through the cache — measured
  ``prompt_len=5, cache_offset=6`` on a real scheduler run. Storing that under
  ``prompt_token_ids`` left every warm reuse one token ahead of its key.
  Trimming the overshoot off is not available here, so the key is extended
  instead; the extra token is the first token of the reply, which the next
  turn's prompt also contains, so the entry still matches by strict prefix.
  A key that cannot be named exactly means no entry at all.

The snapshot destination mirrors the live cache objects rather than calling
``make_prompt_cache``. A plain ``KVCache`` destination cannot take a
``RotatingKVCache``'s state or meta_state; the assignment raised, the broad
handler logged a warning, and nothing was stored on precisely the
configurations this feature targets. Deriving the destination from
``config.max_kv_size`` instead is also wrong, which measurement showed:
``_create_batch_generator`` does not pass ``max_kv_size`` to
``BatchGenerator``, so with it configured the live layers were still plain
``KVCache``.

Exact matches are deliberately skipped rather than used. The scheduler re-feeds
``prompt[-1:]`` on those, which duplicates that token in the KV cache when the
entry covers the whole key — measured as the same prompt returning a different
answer.

``memory_cache.py`` could not measure these caches either: ``CacheList.state``
is nested, and the two-way unpack raised a ValueError that was swallowed, so
every such entry was accounted as 0 bytes and the byte-based LRU never evicted.

Verified on DeepSeek-V4-Flash-0731 (283.8B MXFP4, M3 Ultra), an 8-turn
tool-calling conversation over a ~33k-token context: cache entries 45 -> 1,
Metal buffer errors 7 -> 0, 7/8 hits with 84 tokens prefilled instead of 33k,
long shared prefix 4.19s -> 1.09s. Re-checked end to end on Qwen3-0.6B through
the real scheduler after the alignment fix: identical greedy output cold and
warm, key length equal to cache coverage, and a prefix hit on the extended
second-turn prompt.

Split out of #676 on review. Repo suite: 2310 passed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 13, 2026
The scheduler stored one prefix-cache entry per request, keyed by
``prompt + output``. Every later query is *shorter* than such a key, so reuse
requires trimming the generated tail off first. Models with sliding-window or
pooled KV cannot do that: ``RotatingKVCache.is_trimmable()`` is
``offset < max_size``, so once the ring buffer wraps the older KV is physically
overwritten, and a pooling cache merges tokens into windows it cannot split.

Zero hits were guaranteed by construction on those architectures, and the
entries were not free: each holds a full-length KV copy, and in an agentic loop
where every turn extends the previous prompt they accumulate. Measured 45
entries of a ~46k-token cache. Metal runs out of *buffers* long before the
byte budget notices:

    RuntimeError: [metal::malloc] Resource limit (499000) exceeded.

Two changes:

- Snapshot the cache once it covers the prompt and store that. Such an entry is
  reusable by strict prefix match with no trimming at all.
- Skip the completion-time ``prompt + output`` entry when the cache is not
  trimmable, since it can never be reused.

Four things the implementation has to get right:

- mlx-lm attaches ``prompt_cache`` only to the response carrying a
  ``finish_reason``, so mid-generation it is None. The per-sequence cache is
  pulled from the live batch via ``extract_cache(idx)``.
- The snapshot must be a real copy. Both cache types write into their buffers
  in place, so a snapshot that aliases them is rewritten by the generation it
  is supposed to predate.
- It is stored with ``evict_prefixes=True``, or each turn adds another
  full-length copy rather than replacing the entry it supersedes.
- The key must name exactly the tokens the snapshot holds. The snapshot is
  taken while processing the response carrying the first generated token, and
  the batch has already fed that token through the cache — measured
  ``prompt_len=5, cache_offset=6`` on a real scheduler run. Storing that under
  ``prompt_token_ids`` left every warm reuse one token ahead of its key.
  Trimming the overshoot off is not available here, so the key is extended
  instead; the extra token is the first token of the reply, which the next
  turn's prompt also contains, so the entry still matches by strict prefix.
  A key that cannot be named exactly means no entry at all.

The snapshot destination mirrors the live cache objects rather than calling
``make_prompt_cache``. A plain ``KVCache`` destination cannot take a
``RotatingKVCache``'s state or meta_state; the assignment raised, the broad
handler logged a warning, and nothing was stored on precisely the
configurations this feature targets. Deriving the destination from
``config.max_kv_size`` instead is also wrong, which measurement showed:
``_create_batch_generator`` does not pass ``max_kv_size`` to
``BatchGenerator``, so with it configured the live layers were still plain
``KVCache``.

Exact matches are deliberately skipped rather than used. The scheduler re-feeds
``prompt[-1:]`` on those, which duplicates that token in the KV cache when the
entry covers the whole key — measured as the same prompt returning a different
answer.

``memory_cache.py`` could not measure these caches either: ``CacheList.state``
is nested, and the two-way unpack raised a ValueError that was swallowed, so
every such entry was accounted as 0 bytes and the byte-based LRU never evicted.

Verified on DeepSeek-V4-Flash-0731 (283.8B MXFP4, M3 Ultra), an 8-turn
tool-calling conversation over a ~33k-token context: cache entries 45 -> 1,
Metal buffer errors 7 -> 0, 7/8 hits with 84 tokens prefilled instead of 33k,
long shared prefix 4.19s -> 1.09s. Re-checked end to end on Qwen3-0.6B through
the real scheduler after the alignment fix: identical greedy output cold and
warm, key length equal to cache coverage, and a prefix hit on the extended
second-turn prompt.

Split out of waybarrios#676 on review. Repo suite: 2310 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
janhilgard added a commit to janhilgard/vllm-mlx that referenced this pull request Aug 16, 2026
MLX streams exist only in the thread that created them, and `BatchGenerator`
captures `generation_stream` into `self._stream` when it is built. So the
thread that loads the model has to be the thread that drives `scheduler.step`.

The two batched paths step on different threads, so they load on different
threads:

- AsyncEngineCore steps on a worker. `BatchedEngine` loaded inline on the
  event loop (issue waybarrios#407), so the two never matched. The symptom does not look
  like a stream problem: `batch_generator.next()` raises "There is no
  Stream(gpu, N) in current thread", `EngineCore` falls back to stepping on the
  model thread, hits the same error there, and — because that fallback fires
  only once — then spins on the error. What an operator sees is `running=1`,
  the step counter climbing into the millions, and not one token emitted. It
  reads as a scheduler hang, which is how I first misdiagnosed it.
- MLLM never reaches AsyncEngineCore. `_start_mllm` drives MLLMScheduler,
  whose `_process_loop` calls `step()` on the event loop with no executor hop,
  so an MLLM model has to be built there — as it was before this change.

`_model_load_executor()` states that policy in one place, and `start()` and
ResidencyManager both read it. `EngineCore` steps on a supplied worker and does
not shut it down, since that thread owns the loaded model and outlives the
engine loop; without one it still creates and retires its own, unchanged.

Stacked on waybarrios#679 for `run_blocking_startup_work(executor=...)`. It cleans up by
itself once waybarrios#679 lands. Split out of waybarrios#676 on review.

Verified on DeepSeek-V4-Flash-0731 (283.8B MXFP4, M3 Ultra), which could not
emit a single token through the batched path before this: 4/4 concurrent
requests, 54.3 tok/s aggregate against ~31 single-stream.

`tests/test_batched_engine_owner_thread.py` drives the engine's real `start()`
rather than replicating its load call, and covers both paths: MLLM load and
MLLMScheduler stepping land on the event loop, non-MLLM load and EngineCore
stepping land on the worker, and ResidencyManager honours the same split.
Confirmed by mutation — pinning MLLM to the worker, having lifecycle ignore the
policy, dropping `executor=` from the load, and shutting down a caller-supplied
worker are each caught by a distinct test. Repo suite: 2282 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@waybarrios

waybarrios commented Aug 26, 2026 •

Copy link
Copy Markdown
Owner

Pushed follow-up patch f19475d addressing the review feedback:

  • V4 and auto parsers now receive every delta through a request-local lifecycle.
  • Responses routes reasoning content through tool parsing before emitting text.
  • EOS finalization preserves partial markers, truncated DSML, and terminal metadata.
  • Reasoning-effort mapping now distinguishes the preview and official 0731 profiles.
  • Added the requested Chat, Responses, one-character streaming, truncation, and effort regressions.
  • Added the V4 test modules to Linux CI and documented the parser contract.

Local verification: 502 compatibility tests and 172 dedicated V4 tests passed. CI is running.

@waybarrios

Copy link
Copy Markdown
Owner

Pushed 295fda3 to fix the Apple CI failure. Empty final deltas now safely flush truncated DSML in Chat and Anthropic streams, with regressions for both Anthropic paths. CI is rerunning.

@waybarrios
waybarrios merged commit 0a6d749 into waybarrios:main Aug 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants