Skip to content

Add configurable model storage and external MTP support - #389

Open
orkotr wants to merge 492 commits into
youssofal:mainfrom
orkotr:codex/model-download-location-mtp
Open

Add configurable model storage and external MTP support#389
orkotr wants to merge 492 commits into
youssofal:mainfrom
orkotr:codex/model-download-location-mtp

Conversation

@orkotr

@orkotr orkotr commented Aug 29, 2026

Copy link
Copy Markdown

Summary

  • Allow users to choose and persist the LLM download/storage location.
  • Add the destination picker to the download confirmation flow.
  • Discover installed models recursively and select the active model from Settings.
  • Support matching external MTP heads, including Qwen 3.8 variants, without silently falling back to baseline.

Verification

  • Python contribution suite: 272 passed, 1 skipped.
  • Focused external-MTP tests: 7 passed.
  • Swift suite: 661 passed, 1 skipped.
  • Package build: passed.
  • Fresh virtual-environment smoke test: passed.

Benchmark Evidence

Not applicable: this is a model-management and compatibility change and makes no new product performance claim.

davidtai and others added 30 commits August 8, 2026 21:34
…ough

PR youssofal#245's four call sites pass mtp_batch_finalize_ownership unconditionally;
the dispatcher forwards **kwargs to _run_generation on the solo path, which
rejects the kwarg — every solo chat completion on a non-mtp_batch scheduler
500s (live receipt: ar_batch + solo curl -> TypeError). The mtp_batch branch
receives the whole kwargs dict and still consumes the token.
The nine-rounding-unit bound was calibrated on the PR author's machine. An
M5 Max (Mac17,7, macOS 26 Metal) measures hidden 12.1/128 and logits 9.5/128
on identical code and locked deps while every exact invariant passes (argmax,
row isolation, offsets, commits, same-geometry 0.0) — machine-dependent
compile fusion order. Live gate after widening: three concurrent-vs-solo
greedy completions byte-identical (GREEDY_PARITY_PASS).
The fleet's deep-context collapse was this seam's absence: mlx-lm's batch
step already extracts the finishing row's per-layer cache into
response.prompt_cache, and dropping it forced every follow-up tool round
to re-prefill its whole transcript (live receipt 2026-08-09:
cached_tokens=0 on 23/23 fleet worker rounds, miss ssd_prefix_miss).
_commit_finished_row stores that cache at the pump's finish branch, keyed
on tokens[:-1] (the just-sampled token is not yet in the cache, and the
strictly-shorter key also retires the full-prefix-not-insertable refusal
for these entries). logits/hidden stored as None - the batched restore
path inserts the prefix and prefills the tail, never serving last-position
logits. Vision surrogate ids and sub-512-token prefixes are skipped;
failures are recorded in request_observability and never break generation.

Receipts (M5 Max, scratch 8399, production launch shape, vs 643dade
baseline): staggered 3-lane 20k 3-round wall 122.4s -> 71.2s; restored
rounds run 37-117 tok/s e2e vs 5-8 on miss; deep solo round-trip restores
19,456/19,663 tokens (0.85s vs 7.62s); solo-20k and B3-20k one-shot walls
flat-or-better (11.8/33.1 vs 15.5/35.9); greedy parity byte-identical with
8,177 tokens restored. Remaining known gap: restores are skipped when a
row is admitted mid-decode (congested batch) - the PreparedRow interleave
work; misses keep today's behavior.
Finish-time entries key on prompt + raw generation, and reasoning-history
scoping drops prior thinking from follow-up prompts - so app worker rounds
diverged MID-entry where recurrent A3B entries without interior boundaries
cannot restore (live receipt 2026-08-09: 10 entries banked, every app
round ssd_prefix_miss). At a row's FIRST generation response the cache
covers exactly the prompt (the step that sampled token 1 consumed the
final prompt token), and MLX array immutability makes the extracted
slices a coherent snapshot that put clones before the next decode step.
Keying on the prompt alone makes the entry a strict prefix of EVERY
follow-up round's prompt: restores survive reasoning scoping, and the
terminal recurrent state sits exactly at the restore point - no interior
gdn_boundaries needed. Restored rows skip the re-bank; same fail-safe
contract as the finish commit.

Receipts (M5 Max, scratch 8399, production shape): divergent-tail
follow-up restores 5,376/5,553 (the exact app failure, fixed); batched-
lane commits proven restorable cross-lane; solo-20k 11.9s and B3-20k
33.7s vs base 15.5/35.9 (flat-or-better); greedy parity byte-identical
with 8,180 restored. Batch-FORMATION restores still never fire - that is
pre-existing ar_batch behavior in every dataset including baseline, and
is the PreparedRow admission scope.
Prints one JSON line per batched-admission bank miss: prompt_len,
bank_entries, miss_reason, session identity, and the bank's
last_prefix_diagnostic. Off unless MTPLX_BANK_RESTORE_DEBUG is set;
fail-safe (wrapped in the existing miss branch, no behavior change).

Receipts it produced (2026-08-09, port-8399 scratch daemon): batched r2
misses show stored_prefix_len 4036 / common_prefix_tokens 4034 — the
tiny-gap path nominates the entry, then boundary-true restore rejects it
because admission-time entries carry no interior gdn_boundaries. See
LOG.md (Aphanes Code v2) for the full seam spec.
# Conflicts:
#	mtplx/a3b_mtp_batch.py
#	mtplx/cli.py
Upstream's 9/128 construction bound is author-machine-calibrated; this M5 Max
(Mac17,7, macOS 26 Metal) measures hidden 12.1/128 with every argmax,
isolation, offset, and same-geometry check exact (2026-08-09 selfcheck
receipt) and the route refuses to install. MTPLX_MTP_BATCH_BF16_LIMIT_UNITS
widens the bound explicitly (floor nine); default behavior and David's
profile tests unchanged (190 pass). Upstream suggestion for youssofal#245.
youssofal and others added 27 commits August 22, 2026 19:13
…tal, default off

New mtplx/verify_crossrow.py: cross-row affine-4 QMV for verify widths
M=2..5, ported from the mlxfast arena E120 family (Layr-Labs
qwen-3.8-mtp-challenge, MIT; chunk-sum lineage morganmcg1 6f1cd66 +13.24%
ranked, tight-launch jungjipdo 8849fad +15.49%, M=2 scarletbright e8f14c4)
onto mx.fast.metal_kernel, parameterized for our g32 trunk (their g64
hardcode) with the g64 variant kept. One simdgroup owns 4 output rows x M
input rows; integer nibble dot + per-group affine correction; optional
per-lane activation chunk-sum table with a fill kernel that replicates the
inline accumulation order exactly — table-on and table-off are bitwise
equal by construction. Launch is tight by construction (ceil(N/8) output
TGs, one input group at M<=5).

Gates (crossrow_check.out, fan-gated): ALL PASS — bitwise table twin,
perturbed-table positive control fires per cell, parity vs stock in the
tail-ULP class (2e-4..3e-3), and the three-way timing at real pack shapes:
gdn.fused M=4 253.7us vs stock 284.8 (+10.9%, ties vk_k 254.0); M=5 281.5
vs stock 322.0 (+12.6% — vk_k has no M=5 lane at all); g64 M=5 +7.4%;
attn/mlp wash; mlp.down stays vk_k's. MEASUREMENTS 19:13 has the table.

Not routed anywhere yet: MTPLX_VK_CROSSROW gate exists but no call site
consults it — integration into the turbo verify patch (GDN family at
M=4/5, in-graph fill) is the next step and carries its own identity/R1b
gates. Unit tests: env gate, eligibility geometry, generated-source
divisors (g32 lid/2, g64 lid/4), kernel cache keys — 4/4.
…/#315c1/youssofal#318 merge ruling)

The founder-ordered merge call, decided by measurement. Evidence:
- n=4 counterbalanced ABBA (2 stack-first + 2 base-first rounds, Ivan
  ladder, fan/die gated): blended mean +2.7%, median rung +2.5%; 0.5k-8k
  rungs +2.3..+9.8, only 16k (−1.8) and 32k (−3.1) negative.
- Dedicated bankless 16k/32k quad (base-stack-stack-base, one session):
  16k −2.9%, 32k −2.7% — third consecutive long-context negative read, so
  blanket ON would ship a pillar regression (flat-or-better law).
- Byte-identity on the final tree: PASS 6/6 (3 prompts x greedy +
  sampled-seed7, identical text and counters) — the greedy-only-refusal
  law's sampled-lane proof.

Ruling implemented: the three knobs resolve default-ON via
_env_enabled_default_on (opt-out, falsy set symmetric with graphbank) and
are fenced per request by MTPLX_GREEDY_TRIO_MAX_CONTEXT (default 12288
prompt tokens; 0/off = unlimited) — the same context-routing pattern as
MTPLX_COMPILED_VERIFY_MAX_CONTEXT. The fence is decided once per request
at the chain prebind and stamped through a graphbank ContextVar for the
paged-offsets read (batch lane keeps last-set/default = at most the
pre-youssofal#318 serial-sync behavior). Decode-trace receipts carry both the env
resolution and the per-request stamp (greedy_trio_max_context,
trio_context_ok) — the youssofal#314 dead-switch antidote.

Also fixed in the same surface: leg-2b's confidence-width threshold was
silently inert under the (now-default) chain lane — an explicitly set
experimental knob must win over the default path, so the chain eligibility
block now excludes it (dead-switch scar class).

Tests: test_greedy_trio_ports re-pinned (explicit-off baseline arm),
default-resolves-ON pin, fence resolver matrix, fence-disarms-chain live
gate with graphbank stamp assertions — 12/12; fence-adjacent files 84/84.
Full suite split on this tree: cold-tier solo rc=0; main run had ONE
fail, test_bank_shrink_to_bytes_evicts_lru_first — solo and file-solo
green 12/12, no mechanism from this diff (SessionBank untouched), same
suite green under identical serve load earlier today: interference-class
flake, same protocol as the 08-22 cold-tier precedent.
Cause of the long-context cost is UNKNOWN (stack-level measurement only);
knob isolation + 32k flight-recorder diff are the follow-up. Long-context
users lose nothing; short/mid-context users gain the blend.
…red slower)

Three same-process micro sessions (MEASUREMENTS 22:0x-22:1x) at the
production 4-bit g32 shapes put the padded-m6 ksplit lane +3..13% over
stock and the NAX m16 tile worst at every M=5 cell; stock never loses.
The m<=6 branch previously padded M=5 into the m6 template and the m16
tile would catch it otherwise — both now skip m==5 unless
MTPLX_M5_PADDED_LANE=1 opts the old routing back in (A/B escape hatch).
Fall-through is the stock kernel: byte-equal by construction, receipted
via the existing b4_m5 fallback counter.

Honest scope: the shipped runtime caps serve depth at 3 (the D4 serve
gate pair failed with '--depth must be between 1 and 3' — receipts in
m5-pair.log), so M=5 never occurs on the production serial path; the fix
matters only for batch-lane shapes that land on M=5 and for any future
head contract that raises the depth cap. Committed as routing hygiene on
micro receipts; no serve-level gate is possible on the shipped head.
tests/test_nax_verify.py: fall-through + counter + stock byte-equality +
env opt-in — 10/10.
The default-on ~/.mtplx/logs/request-log-<port>.jsonl claimed 'numeric/hash
telemetry only — no prompt or completion content' but every record carried
request_last_user_preview with literal user text, rotating across 4x64MB
generations of durable history.

Redact at the durable sink only: the JSONL line now carries a stable
sha256:<16-hex> digest under the same key (turn correlation survives), while
the in-RAM dashboard ring and flight-recorder trace labels keep the literal
preview for live diagnosis. The flight recorder's terminal event already
copies a curated numeric set, so nothing else content-bearing reaches disk.
MTPLX_REQUEST_LOG_CONTENT=1 opts back into literal previews for local
debugging. Help text and default-path comment updated to match reality.
…vention per sidecar (youssofal#301)

Forge's embedded-head extraction ran every tensor through
sanitize_plain_weight, whose MTP_RMSNORM_ALWAYS_SHIFT_SUFFIXES branch added
+1.0 unconditionally to q_norm, k_norm, and mtp.norm.weight. Correct for
HF-native Qwen3.5/3.8 exports (zero-centered norms), it corrupted exactly
those three tensors when the source already stored absolute gains (MLX-
converted checkpoints like youssofal#301's) — the drafter then mismatches its trunk
and acceptance collapses to 0-2%, making every depth slower than AR.

Measured on the real fleet (shipped 3.8/4B/9B sidecars vs raw HF Qwen3.5-4B
embedded head): the low set separates delta vs absolute at 0.30-0.39 vs
0.87+, q/k at 0.73-0.75 vs 1.73+, but the FINAL norm overlaps across
conventions (raw-delta 4B mean 2.58 vs absolute 3.8 mean 2.25) — it can
never be judged per tensor. So the convention is now decided once per
sidecar (both separable families must agree: max qk < 1.25 AND min low <
0.5, the exact two-signal gate the runtime heal shipped for youssofal#176) and
applied to all seven norms, in one shared shift_delta_mtp_norms():

- forge embedded extraction: sanitize per tensor, then one ensemble shift
- AWQ/compressed-tensors convert: same shift before mtp.safetensors write
- runtime heal (_heal_raw_delta_mtp_norms): collapses to the shared call

Absolute-convention sources now pass through byte-identical. Tests pin both
conventions, both key namespaces, the overlap-final-norm case, and the
missing-family conservative default.
…ng image parts in user canonicalization (youssofal#327)

The agent-transcript canonicalizer (active whenever tools are present)
pooled consecutive user messages by stringifying both sides through
_content_to_text, so an OpenAI image_url part in either message was
silently discarded before the vision extractor ran — DSH's standard preset
(user(image+text) -> user(runtime snapshot)) always answered blind, while
its minimal preset worked. Likely the same family as youssofal#328 (Pi + vision:
Pi sends tools, MTPLX chat does not).

Guard: a message whose structured content carries any non-text part
(image_url, audio, unknown parts) is exempt from tandem-repeat collapse,
duplicate dropping, and consecutive-user merging — it is appended as-is.
Consecutive text-only users keep merging (covered by regression test), and
downstream already renders consecutive user turns (the
skipped_repeated_assistant branch has always emitted them).
…ode and refuses --stock-ar loudly (youssofal#285)

The bench --harness depth-sweep branch accepted --stock-ar,
--generation-mode, --depths, and --seed, then discarded all four
(depths='3', seed=0, compare_ar=False hardcoded) — argparse validated,
exit code was 0, and a result file was written, so four 'different' A/B
configs produced byte-identical MTP-D3 runs and an 'AR baseline' that
drafted 129 tokens.

Now: --depths and --seed thread straight through (defaults unchanged:
'3'/0), --temperature/--top-p/--top-k and explicit --draft-* overrides are
honored over the runtime-contract values, and --generation-mode ar maps to
compare_ar+ar_only (a real target-only AR baseline; depth<1 already fails
loudly in the runner). --stock-ar cannot be delivered by this harness (it
always loads the MTP runtime), so it exits with a pointer to --harness
direct-http instead of pretending. The result profile records the
requested depths and ar_baseline instead of a hardcoded depth: 3.
…identifier fallback ladder (youssofal#329)

The Hardware card guessed the chip from the Mac model identifier with a
table that was off by a generation across the board (mac14->'M3',
mac16->'M3 Ultra', mac13->'M2'), so a Mac Studio M2 Max (Mac14,13) read
'M3'. The server has always sent the true sysctl brand string in the same
payload (machine.chip: 'Apple M2 Max') — the dashboard just never used it.

The badge now prefers the reported chip (Apple prefix stripped); the
identifier ladder remains only as a fallback for a missing brand string
and now maps chip families correctly: Mac13=M1-era Studio, Mac14=M2
(incl. Studio M2 Max/Ultra), Mac15=M3, Mac16=M4, Mac17=M5. Static bundle
rebuilt (tsc -b clean; new logic verified present in the emitted asset).
… replacing the link (youssofal#292)

The terminal-command installer appended its PATH block by rewriting
~/.zshrc with an atomic write — a temp-file rename that replaces a symlink
with a plain regular file. On the very common dotfiles setup (stow,
chezmoi, yadm, hand-rolled repos symlink ~/.zshrc into a git repo) this
silently detached the live file from version control: the repo copy and
the real file diverge with no signal, and the next dotfiles sync clobbers
the divergence.

Resolve the symlink and write to the target, so the PATH line lands inside
the dotfiles repo and the link survives. Regression test pins the link
destination, the appended line in the target, and the preserved content.
…ch; bf16 keeps tuned 512 (youssofal#319-derived)

grzracz reported (PR youssofal#319) that the gdn_capture fused_add_rmsnorm call at
threadgroup_size=512 perturbs prefill above 64 rows. Probed on M5 Max /
MLX 0.32: CONFIRMED for fp16 — max|diff| up to 3.9e-3 vs the unfused
x+r / mx.fast.rms_norm reference once the grid crosses 2^15 threads
(rows > 64 at 512 lanes), data-dependent by seed. bf16 is bit-exact at
512 in every probe, and the default 1024-lane loop is bit-exact for both
dtypes at every probed shape.

fp16 is exactly the M1/M2 model lane (-FP16 siblings), so this was a
silent numerics leak on the hardware least able to absorb it. Fix keeps
the tuned 512 width where it is provably exact (bf16 — mainline path
byte-identical in output and kernel config) and routes fp16 to the exact
default dispatch. No env knob (PR youssofal#319's dial declined in favor of the
dtype guard); exactness contract pinned by tests across seeds.

Follow-up for the gated bench pass: confirm fp16 prefill TPS is flat
under the 1024-lane loop.
…ussofal#330 by @CharliePetch)

Adopted as-is: completes the bit-exact replay envelope (prompt_token_ids
at dispatch + raw sampled output ids at finalize) for the opt-in request
capture. Zero hot-path cost (inside the capture_dir() gate), never-raises
coercion verified on ints/None/garbage; serial, ar_batch, and mtp_batch
finalizers all carry the field. Local pytest: test_request_capture green.
…iftedx)

Adopted as-is: a module_overrides entry with quantize:false now returns
False from the quantize_model predicate so sensitive modules keep their
source precision, instead of being silently 8-bit-quantized by the
fallback params. Typed override tuples, PR's own regression test included;
test_forge_mixed_convert green locally.
…ussofal#273 by @El-Patronum)

Adopted with a comment cleanup: cmark returns raw HTML as literal text, so
<br> in a table cell rendered verbatim and kept the cell one unbreakable
token that overflowed its fixed column into the neighbour. Single
choke-point regex converts <br>/<br/> (case-insensitive) to real newlines
before AttributedString parsing; comparison operators are untouched and
the parse-failure fallback uses the same converted text. swift build +
render tests green.
The May-era agent-rewrite machinery silently mutated client transcripts:
four content compactors truncated tool results (Kilo's active-read path
had no opt-out at all), heuristic text sniffs dropped client assistant
messages and hid declared tools (the Hermes 'direct reply turn: tools
are unavailable' report), the Pi convergence contract told the model to
stop reading files after 14 tool results, and any client hinting 'pi'
had its stream cut after the first complete tool call even though Pi
executes every tool call in a turn (pi-agent-core ships both
executeToolCallsSequential and executeToolCallsParallel).

Community receipts drove the default: tcpdump proof that Pi sends the
full file and the model receives a digest, a 10-run task measurement at
8/10 success with compaction on vs 10/10 off with no latency benefit,
and Hermes skill files silently reduced to fragments.

The new contract:
- Unset (default): passthrough. No content compaction, no steering
  contracts, no heuristic message drops or toolset filtering, no hint
  sniff stream cuts. tool_choice keeps its protocol meaning and the
  tool-format contract still renders where the template needs it.
- MTPLX_AGENT_REWRITES=on restores the full legacy machinery (the test
  suite pins it via the legacy_rewrites fixture).
- MTPLX_AGENT_REWRITES=off is a hard passthrough guarantee that beats
  per-feature opt-ins and resolves tool prompts template-native (a
  backend-required mode still wins: that is protocol, not policy).
- Per-feature MTPLX_*_COMPACT_THRESHOLD_CHARS env limits engage exactly
  one compactor at the chosen limit, for harnesses that want a lower
  cap than their own default.
- New --agent-rewrites {on,off} flag on serve and quickstart.
- Compaction wrapper text no longer names OpenCode on other clients.
- request_observability carries agent_rewrites; goldens regenerated
  (single field drifted: request_pi_convergence_after_tools 14 -> 0).
…oussofal#282)

Both Pi writers (app PiIntegration.sync and CLI mtplx/pi.py) replaced the
whole providers.mtplx block on every launch, silently reverting user
edits inside it: added vision input, a tuned thinkingLevelMap, explicit
maxTokens, renamed models, extra headers (the intensifi report).

New merge contract, identical in both writers:
- MTPLX owns connection identity only: baseUrl, api, apiKey, authHeader
  and the x-mtplx-client header, because ports move between launches.
- Every other key the user edited wins; MTPLX values fill gaps
  recursively. Model entries merge by id; user-added models and fields
  survive a sync untouched.
- Stale MTPLX-owned model entries (our own previous 'mtplx-'-prefixed
  ids) are pruned so switching models does not accumulate dead picker
  rows.
- The request-policy extension now carries an ownership marker: replace
  its content (dropping the marker and the mtplx identifiers) and it is
  yours, MTPLX never rewrites it again. Managed copies keep receiving
  updates; templates stay byte-identical across both writers.

Tests: Python round-trip preserving vision/thinking/cap/name edits plus
extension ownership; Swift mirrors both (654/654 green).
# Conflicts:
#	apps/MTPLXApp/Sources/MTPLXAppCore/Onboarding/RuntimeSetupService.swift
#	mtplx/cli.py
#	mtplx/commands/forge.py
#	mtplx/compressed_tensors.py
#	mtplx/gdn_capture.py
#	mtplx/mtp_patch.py
The bench handler applies the profile env block in-process by design;
the youssofal#285 test file exercised it without isolation, so performance-cold's
MTPLX_DROP_EVENTS leaked into the suite and silenced context-copy block
events two files later (caught by the first full-suite run of the
merged 2.9.2 tree).
The app's serve launcher exported the May-era transcript-rewrite envs
(tool-result 1200-char compactor, read-inspection line caps, force-answer
after 12 tools) on every coding-agent launch. Explicit envs re-arm those
compactors past the engine's MTPLX_AGENT_REWRITES passthrough default, so
app-launched daemons kept rewriting Pi/OpenCode transcripts after the
engine-side cleanup. Receipt: a live Pi session showed 133,794 chars of
tool results compacted at the 1200 threshold.
The receipts page for the project's founding claims, mirroring
mtplx.com/history/: first exact speculative sampling with the native MTP
heads on a Mac (2026-04-27, commit-stamped), shipped as an installable
runtime 2026-05-02, llama.cpp reaching MTP 05-16 and hybrid GDN 08-03,
vllm-metal documenting the cache-vs-speculation gap 08-10, and the oMLX
kernel attribution. Every entry carries a public commit, PR, or changelog
reference so the claims stay checkable. README gains a History section
pointing at both copies.

Prose authored by the founder's editing pass (recovered from
release-stash-20260817); facts verified against MEASUREMENTS.md, the
changelog, and the upstream PRs before landing.
The release gate's app/CLI parity pair caught the other half of the app
fix: mtplx start still exported the read-inspection battery (72/8/120 +
force-answer-after-12) on the shared block and the Pi compaction five
(threshold 1200 + line caps) on the Pi lane. Explicit envs re-arm those
compactors past the engine's passthrough default, so CLI-launched agent
sessions were still being rewritten. Both launchers now export none of
it, and a new parity test pins the full nine-key contract on all three
surfaces.
Version bump, changelog, and release notes for the 2.9.2 patch release:
passthrough-by-default serving endpoints and app launcher (youssofal#282), greedy
trio default-on below a 12288-token context fence (youssofal#313/youssofal#315/youssofal#318), forge
MTP norm convention decided per tensor set (youssofal#301) with a double-shift load
guard (youssofal#306), vision canonicalization and cache-restore fixes (youssofal#327/youssofal#296),
batched repetition stop (youssofal#311), content-free request log (youssofal#326), symlinked
zshrc installer fix (youssofal#292), honest bench flags (youssofal#285), exactness kernel
fixes (youssofal#319/youssofal#320), community ports (PR youssofal#273, youssofal#281, youssofal#330), and experimental
opt-in fusion/crossrow/headcal/marathon levers.
The streaming chat-completions loop cancels its own generation 50ms after
a complete tool call (STREAM_TOOL_CALL_FINISH_GRACE_S), then `continue`s
straight back into the `except Empty` branch whose first check is
`cancel_event.is_set()` — the very event it just set.

The generation thread only observes that event inside `on_tokens`, once
per committed token batch, so the acknowledgement takes as long as the
inter-batch gap. Whenever that gap outlives the 0.25s queue poll, the
loop mistook its own cancel for an external one and ended a healthy
tool-calling turn with "request cancelled via POST /v1/mtplx/cancel
after N streamed tokens" — an endpoint no client had called.

Excluding `early_tool_cancel_used` keeps the loop draining until the
worker acknowledges, which lands on the `kind == "cancelled"` handler
and its `early_tool_cancel_used and streamed_assistant_tool_calls`
terminal frame. A real cancel and a real disconnect are unaffected, and
the stall watchdog still bounds the wait. The completions loop already
guards its own `stop_hit` cancel the same way.
…fal#343)

The worker only observes cancel_event once per committed token batch, so
the repro pumps a complete tool call and then blocks 1.2s before
returning: longer than the 0.05s finish grace plus the 0.25s queue poll.
On the unfixed tree this reproduced the exact reported failure, an SSE
error frame claiming 'request cancelled via POST /v1/mtplx/cancel' on a
turn nobody cancelled. With the guard the stream drains until the worker
acknowledges and ends with the tool_calls terminal frame.
@orkotr
orkotr requested a review from youssofal as a code owner August 29, 2026 07:25
@youssofal
youssofal force-pushed the main branch 2 times, most recently from 2382dfd to 8bc4d88 Compare September 1, 2026 08:07
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.

6 participants