mlx-bun 0.0.12 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -15
- package/docs/design/colibri-glm52-port.md +7 -0
- package/docs/planning/release-notes-v0.0.13.md +77 -0
- package/docs/reference/features-matrix.md +1 -0
- package/docs/reference/server-api.md +24 -12
- package/docs/reference/server-config.md +1 -1
- package/package.json +1 -1
- package/src/chat-template.ts +34 -1
- package/src/config.ts +10 -0
- package/src/server.ts +109 -12
package/README.md
CHANGED
|
@@ -225,7 +225,7 @@ On the site: [The lab](https://mlx-bun.dev/about/lab/).
|
|
|
225
225
|
|
|
226
226
|
Scope is deliberate: a few model families held to explicit oracle contracts,
|
|
227
227
|
rather than dozens held to none. Currently MiniCPM5, the Gemma-4 OptiQ quants,
|
|
228
|
-
|
|
228
|
+
the Qwen 3.x family, and the direct-container GLM-5.2 path:
|
|
229
229
|
|
|
230
230
|
| Model | Download | Fits on | Vision | Notes |
|
|
231
231
|
|---|---|---|---|---|
|
|
@@ -234,14 +234,13 @@ Qwen3.5-4B, and the direct-container GLM-5.2 path:
|
|
|
234
234
|
| [`mlx-community/gemma-4-e4b-it-OptiQ-4bit`](https://huggingface.co/mlx-community/gemma-4-e4b-it-OptiQ-4bit) | 7.0 GB | 16 GB | ✓ | **Recommended starter** (16 GB+); ~56 tok/s |
|
|
235
235
|
| [`mlx-community/gemma-4-12B-it-OptiQ-4bit`](https://huggingface.co/mlx-community/gemma-4-12B-it-OptiQ-4bit) | 8.4 GB | 16 GB | ✓ | Vision + tool calling, both verified end-to-end |
|
|
236
236
|
| [`mlx-community/gemma-4-26B-A4B-it-OptiQ-4bit`](https://huggingface.co/mlx-community/gemma-4-26B-A4B-it-OptiQ-4bit) | 18 GB | 24 GB | — | MoE (top-8 of 128 experts); ~54 tok/s |
|
|
237
|
+
| [`mlx-community/Qwen3.8-27B-OptiQ-4bit`](https://huggingface.co/mlx-community/Qwen3.8-27B-OptiQ-4bit) | 20.4 GB | 32 GB (24 GB with `--memory-budget`) | text-only for now | Current-gen Qwen; bf16-KV bit-exact parity (vs mlx-lm); thinking with `reasoning_effort` (xhigh/medium/low) + `preserve_thinking` + tool calling; MTP speculation, vision + video in bring-up |
|
|
237
238
|
| [`mateogrgic/GLM-5.2-colibri-int4-with-int8-mtp`](https://huggingface.co/mateogrgic/GLM-5.2-colibri-int4-with-int8-mtp) | ~384 GB / 357 GiB | 32 GB + fast disk | — | Direct streamed Colibri container; true top-8, native MTP, bounded 25 GiB process plan; warm single-stream performance is disk-bound and below the aspirational 2 tok/s target |
|
|
238
239
|
|
|
239
240
|
Not sure what fits your machine? `bun src/cli.ts fit <model> --ctx 8192`
|
|
240
|
-
gives a deterministic answer (see below).
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
disk: [docs/reference/models.md](./docs/reference/models.md); on the site:
|
|
244
|
-
[Choosing a model](https://mlx-bun.dev/getting-started/models/).
|
|
241
|
+
gives a deterministic answer (see below). Downloading, cache layout, and
|
|
242
|
+
reclaiming disk: [docs/reference/models.md](./docs/reference/models.md);
|
|
243
|
+
on the site: [Choosing a model](https://mlx-bun.dev/getting-started/models/).
|
|
245
244
|
|
|
246
245
|
### GLM-5.2: 32 GB quickstart
|
|
247
246
|
|
|
@@ -342,8 +341,10 @@ agent CLIs like pi/OpenClaw via their provider config.
|
|
|
342
341
|
`temperature`, `top_p`, `top_k`, `max_tokens`, `seed`,
|
|
343
342
|
`repetition_penalty`, `stop` (string or array, matched on decoded
|
|
344
343
|
text with streaming hold-back), `reasoning_effort` (thinking on/off for
|
|
345
|
-
Qwen3.5 / MiniCPM5 — `none` disables, any level enables
|
|
346
|
-
|
|
344
|
+
Qwen3.5/3.8 / MiniCPM5 — `none` disables, any level enables; on Qwen3.8
|
|
345
|
+
the level also sets reasoning depth: `low`/`medium`/`xhigh`), a Qwen3.8
|
|
346
|
+
`chat_template_kwargs.preserve_thinking` toggle (keep think blocks from
|
|
347
|
+
history; default on), and an `hlg` tone-curve sampling override;
|
|
347
348
|
omitted sampling fields default to the model's own
|
|
348
349
|
`generation_config.json` recipe; usage includes `cached_tokens`.
|
|
349
350
|
Full schemas in
|
|
@@ -415,9 +416,12 @@ agent CLIs like pi/OpenClaw via their provider config.
|
|
|
415
416
|
targets, not small fast models. Serial lane only — with `--batch N` a
|
|
416
417
|
mounted draft routes every request serial, like mlx_lm.server.
|
|
417
418
|
- **Memory admission control** — `--memory-budget <GB>` refuses to load
|
|
418
|
-
a model that can't serve within the budget
|
|
419
|
-
`prompt + max_tokens` exceed the
|
|
420
|
-
(`type: "memory_admission"`) *before* generating.
|
|
419
|
+
a model that can't serve within the budget. Generic budgeted serving rejects
|
|
420
|
+
requests whose `prompt + max_tokens` exceed the safe context with a 400
|
|
421
|
+
(`type: "memory_admission"`) *before* generating. GLM-5.2 has a fixed,
|
|
422
|
+
pre-planned context instead: broad client `max_tokens` values are capped to
|
|
423
|
+
the remaining context, and only a prompt that leaves no generation slot is
|
|
424
|
+
rejected. The GPU
|
|
421
425
|
out-of-memory crash it prevents is uncatchable by design — rejection
|
|
422
426
|
up front is the only defense. Ceiling visible at `GET /stats`.
|
|
423
427
|
- **Anthropic Messages API** (`POST /v1/messages`, on by default) —
|
|
@@ -571,7 +575,7 @@ Pre-alpha, moving fast. See [PLAN.md](./PLAN.md) for phases, exit
|
|
|
571
575
|
criteria, measured numbers, and the findings log.
|
|
572
576
|
|
|
573
577
|
**Complete** — load path; bit-exact model port (MiniCPM5, Qwen3.5-4B,
|
|
574
|
-
e4b per-layer-input, 12B dense, 26B MoE); sampling + serving (tools, vision,
|
|
578
|
+
Qwen3.8-27B, e4b per-layer-input, 12B dense, 26B MoE); sampling + serving (tools, vision,
|
|
575
579
|
prompt cache); registry / fit / KV persistence; quantized +
|
|
576
580
|
mixed-precision KV serving (rotating-cache KV-quant, Phase 9) with fused
|
|
577
581
|
quantized prefill (Phase 10); LoRA hot-swap with per-request selection;
|
|
@@ -586,9 +590,11 @@ head-to-head benchmark harness; the decode-gap root-cause fix
|
|
|
586
590
|
(commit 4625fe5); the embeddable single-binary build (signed +
|
|
587
591
|
notarized — Homebrew, direct-download, and npm/bunx).
|
|
588
592
|
|
|
589
|
-
**In progress** — `Qwen3.
|
|
590
|
-
|
|
591
|
-
|
|
593
|
+
**In progress** — `Qwen3.8-27B` full support (Phase 14 retarget): text
|
|
594
|
+
parity is bit-exact and thinking controls (`reasoning_effort` depths,
|
|
595
|
+
`preserve_thinking`) + tool calling are wired; native MTP speculation
|
|
596
|
+
(the Qwen-trained head as a `--draft-model`), vision, and video input
|
|
597
|
+
are in bring-up.
|
|
592
598
|
|
|
593
599
|
**Experimental** — opt-in, default-off, still being hardened: transparent
|
|
594
600
|
expert offload for MoE models (`serve --expert-offload`, Phase 20:
|
|
@@ -1425,6 +1425,13 @@ advertised false, not emulated.
|
|
|
1425
1425
|
`native-v0.2.0` GitHub release. Both assets are uploaded; anonymous HTTP
|
|
1426
1426
|
resolution and an empty-cache default-URL download/checksum/extraction
|
|
1427
1427
|
passed with all five required files. G8 is closed.
|
|
1428
|
+
- [x] Published mlx-bun v0.0.12 itself: the signed/notarized GitHub release
|
|
1429
|
+
and stable asset, npm package, and Homebrew tap are live and independently
|
|
1430
|
+
verified. Both 80,420,638-byte binary assets carry SHA-256
|
|
1431
|
+
`64c4d697faba65789c2af7c1344ee39024f8a03bd6839d2c8df4ec7dce872a74`;
|
|
1432
|
+
npm reports shasum `0dcc5c200fa81dbea1f8be854e21a0efbbdebbfa`.
|
|
1433
|
+
The final full gate passed 1,936 tests / 28,378 assertions with 71 skips
|
|
1434
|
+
and zero failures.
|
|
1428
1435
|
- Update README plus `docs/reference/{models,memory,cli,server-config,
|
|
1429
1436
|
server-api,library-api,features-matrix}.md` in the same feature changes that
|
|
1430
1437
|
add the corresponding flags, fields, routes, defaults, and limitations.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# v0.0.13 release notes
|
|
2
|
+
|
|
3
|
+
Everything since v0.0.12. One theme: **Qwen 3.8**. The current-generation
|
|
4
|
+
Qwen (released August 2026) is brought up on the published OptiQ
|
|
5
|
+
artifact at the repo's standard bar — bit-exact logit parity with
|
|
6
|
+
mlx-lm — with its thinking controls served faithfully. Vision, video,
|
|
7
|
+
and native Qwen MTP remain separately gated follow-ups.
|
|
8
|
+
|
|
9
|
+
## Qwen3.8-27B (text, bit-exact)
|
|
10
|
+
|
|
11
|
+
- `mlx-community/Qwen3.8-27B-OptiQ-4bit` serves on the existing
|
|
12
|
+
qwen3_5 hybrid graph (gated DeltaNet + gated attention) — the
|
|
13
|
+
architecture audit found byte-identical geometry to the verified
|
|
14
|
+
port, and parity confirmed it: **every logit at every prompt
|
|
15
|
+
position plus 32 greedy decode steps, bit-exact vs stock mlx-lm**
|
|
16
|
+
(the parity harness grew a full prefill-grid golden and
|
|
17
|
+
manifest-driven step counts in the process).
|
|
18
|
+
- The config's new `output_gate_type: "swish"` field is inert in every
|
|
19
|
+
implementation (transformers ground truth included) — a load-time
|
|
20
|
+
guard now fails loudly if a future checkpoint carries a value we
|
|
21
|
+
haven't verified.
|
|
22
|
+
- Fit reality: the artifact is 20.35 GB of shards. 32 GB machines
|
|
23
|
+
serve it comfortably; 24 GB machines need `--memory-budget` and
|
|
24
|
+
should prefer the uniform 4-bit conversion until the planned
|
|
25
|
+
~4 bpw TurboQuant×weights artifact lands (PLAN 14z).
|
|
26
|
+
|
|
27
|
+
## Thinking controls (Qwen 3.8's serving surface)
|
|
28
|
+
|
|
29
|
+
- Thinking is on by default and streams as OpenAI `reasoning` deltas;
|
|
30
|
+
`chat_template_kwargs.enable_thinking: false` selects instruct mode.
|
|
31
|
+
- `reasoning_effort` is now a real depth control on templates that read
|
|
32
|
+
it: OpenAI levels map onto Qwen's `low`/`medium`/`xhigh` (with
|
|
33
|
+
`xhigh` accepted as a first-class request value), and the variable is
|
|
34
|
+
only passed to templates that declare it — the Qwen 3.8 template
|
|
35
|
+
raises on unknown level names, so the server never forwards unmapped
|
|
36
|
+
strings.
|
|
37
|
+
- `preserve_thinking` (new in Qwen 3.8, default on) is forwarded:
|
|
38
|
+
history keeps its think blocks, which also improves prompt-cache
|
|
39
|
+
reuse in agent loops.
|
|
40
|
+
- Tool calling: Qwen 3.8's XML call format
|
|
41
|
+
(`<tool_call><function=name><parameter=…>`) parses through the
|
|
42
|
+
existing Qwen-style parser into OpenAI `tool_calls`, multiline
|
|
43
|
+
parameters included.
|
|
44
|
+
|
|
45
|
+
## Qwen native MTP is deliberately not shipped yet
|
|
46
|
+
|
|
47
|
+
- Qwen 3.8 ships a multi-step MTP head trained alongside the model;
|
|
48
|
+
mlx-community publishes it split out
|
|
49
|
+
(`mlx-community/Qwen3.8-27B-MTP-bf16`). Review found that the target's
|
|
50
|
+
48 recurrent DeltaNet caches cannot roll back rejected verify rows,
|
|
51
|
+
while the shared speculative loop requires exactly that operation.
|
|
52
|
+
The initial implementation therefore never reached a draft round.
|
|
53
|
+
- MTP artifacts are detected and refused at startup with the exact reason,
|
|
54
|
+
before the 20 GB target is opened. Shipping a switch that silently disables
|
|
55
|
+
itself—or corrupts recurrent state when forced—would be worse than a clear
|
|
56
|
+
deferral. The follow-up needs recurrent-state snapshot/restore or a verify
|
|
57
|
+
schedule that never advances state beyond the accepted prefix.
|
|
58
|
+
|
|
59
|
+
## GLM-5.2 client compatibility
|
|
60
|
+
|
|
61
|
+
- Fixed-context GLM serving now treats request `max_tokens` as an upper bound:
|
|
62
|
+
when a client sends a family-wide value larger than the planned remaining
|
|
63
|
+
context, the server caps it instead of rejecting an otherwise valid first
|
|
64
|
+
prompt. The reported 2,788-prompt + 8,192-cap failure now admits a 1,308-token
|
|
65
|
+
completion ceiling within the 4,096-token plan. Prompts that consume the
|
|
66
|
+
whole context remain rejected before generation.
|
|
67
|
+
|
|
68
|
+
## Also in this release
|
|
69
|
+
|
|
70
|
+
- The parity harness's step-0 check now covers the full prefill logit
|
|
71
|
+
grid for all Qwen targets — masking/position bugs can no longer hide
|
|
72
|
+
behind a last-position comparison.
|
|
73
|
+
- A GPU command-buffer failure mode on memory-saturated machines was
|
|
74
|
+
root-caused (Metal completion-thread exception, uncatchable by
|
|
75
|
+
design) and documented in PLAN 14g, along with the finding that some
|
|
76
|
+
mlx-c paths surface C++ errors as process panics instead of
|
|
77
|
+
catchable errors (hardening item).
|
|
@@ -69,6 +69,7 @@ parity). **Tiers:** L1 = bit-exact vs mlx-lm · L2 = bit-exact vs mlx-optiq
|
|
|
69
69
|
| MiniCPM5 (cpm5) | ✅ L1/L2 | ✅ | the starter model |
|
|
70
70
|
| Gemma 4 (1B/e4b/12B/26B, + vision e4b/12B, + audio e4b) | ✅ L1/L2 | ✅ | sliding+full interleaved; MoE 26B |
|
|
71
71
|
| Qwen3.5 (gated-DeltaNet hybrid) | ✅ L1/L2 | ✅ (SSM path) | `MLX_BUN_BATCH_SSM=0` reverts |
|
|
72
|
+
| Qwen3.8-27B (same qwen3_5 graph) | ✅ L1 (no L2 oracle) | ✅ (SSM path) | thinking depths + preserve_thinking; MTP/vision/video in bring-up |
|
|
72
73
|
| GLM-5.2 / Colibri (pre-release port gate) | ✅ chat/text, Messages, Responses, SSE, tools, grammar, logprobs, library/CLI | ✅ compressed MLA/DSA scheduler | native MTP defaults to serial+spec; `--mtp off` enables ordinary batching; artifact-aware CLI/API/status `fit` and live main/MTP expert-tier telemetry; embeddings, vision/audio, adapters, and training are explicitly unsupported; per-row batched MTP is post-release |
|
|
73
74
|
| DiffusionGemma-26B (non-autoregressive) | ✅ (own engine) | — serial always | first bit-exact non-AR port |
|
|
74
75
|
| Tier-0 universal (llama/qwen2/qwen3/olmo2/…, 11 archs) | ✅ L1 | ✅ plain full-attention archs² | gemma2-family/sliding universal → serial |
|
|
@@ -50,14 +50,25 @@ Request body (OpenAI chat schema; unknown fields ignored):
|
|
|
50
50
|
"tools": [ /* OpenAI function tools */ ],
|
|
51
51
|
"tool_choice": "auto", // "none" disables tools
|
|
52
52
|
"chat_template_kwargs": { // forwarded to the chat template
|
|
53
|
-
"enable_thinking": false
|
|
53
|
+
"enable_thinking": false, // MiniCPM5 / Qwen3.5/3.8 / GLM-5.2: <think>
|
|
54
|
+
// channel on/off (Qwen3.8 default: ON)
|
|
55
|
+
"preserve_thinking": true // Qwen3.8: keep think blocks from earlier
|
|
56
|
+
// assistant turns in the rendered prompt
|
|
57
|
+
// (template default true — better prompt-
|
|
58
|
+
// cache reuse in agent loops). Ignored by
|
|
59
|
+
// templates that don't read it.
|
|
54
60
|
},
|
|
55
|
-
"reasoning_effort": "medium", // "none"|"minimal"|"low"|"medium"|"high"
|
|
56
|
-
// gates enable_thinking on Qwen3.5/
|
|
57
|
-
// "none" → off, any other level →
|
|
58
|
-
// consulted when
|
|
59
|
-
// .enable_thinking is not explicitly
|
|
60
|
-
// an explicit enable_thinking always
|
|
61
|
+
"reasoning_effort": "medium", // "none"|"minimal"|"low"|"medium"|"high"|"xhigh"
|
|
62
|
+
// gates enable_thinking on Qwen3.5/3.8/
|
|
63
|
+
// MiniCPM5: "none" → off, any other level →
|
|
64
|
+
// on. Only consulted when chat_template_
|
|
65
|
+
// kwargs.enable_thinking is not explicitly
|
|
66
|
+
// set — an explicit enable_thinking always
|
|
67
|
+
// wins. On templates with a reasoning-depth
|
|
68
|
+
// variable (Qwen3.8) the level also maps
|
|
69
|
+
// into the template: minimal/low → low,
|
|
70
|
+
// medium → medium, high/xhigh → xhigh
|
|
71
|
+
// (Qwen3.8's default depth is xhigh).
|
|
61
72
|
"hlg": { // HLG tone-curve sampling (per request).
|
|
62
73
|
"enabled": true, // merged over --hlg-sampling server defaults.
|
|
63
74
|
"width": 4, // logit-width of the tone plateau
|
|
@@ -333,11 +344,12 @@ All errors are `{ "error": { "message": …, ... } }`.
|
|
|
333
344
|
`top_logprobs must be of type int` / `at least 0` / `at most 11`;
|
|
334
345
|
`top_logprobs: -1` is the accepted "unset" sentinel).
|
|
335
346
|
- `400` with `"type": "memory_admission"`, `"code":
|
|
336
|
-
"context_over_budget"` —
|
|
337
|
-
|
|
338
|
-
the
|
|
339
|
-
|
|
340
|
-
|
|
347
|
+
"context_over_budget"` — the request cannot fit the memory budget's max
|
|
348
|
+
safe context. Generic budgeted serving rejects when `prompt + max_tokens`
|
|
349
|
+
exceeds the ceiling. GLM-5.2 instead caps a broad `max_tokens` upper bound
|
|
350
|
+
to the space remaining in its fixed planned context and returns this error
|
|
351
|
+
only when the prompt itself leaves no generation slot. The ceiling is visible
|
|
352
|
+
at `/stats`.
|
|
341
353
|
|
|
342
354
|
## POST /v1/completions (raw text completion)
|
|
343
355
|
|
|
@@ -21,7 +21,7 @@ the design rationale is in
|
|
|
21
21
|
| --- | --- | --- | --- |
|
|
22
22
|
| `--host` | addr | `127.0.0.1` | Interface to bind. Loopback-only by default (mlx_lm.server parity); pass `--host 0.0.0.0` to expose the server on your network. |
|
|
23
23
|
| `--port` | n | `8080` | Listen port. A pre-flight probe refuses to start if the port is already serving. |
|
|
24
|
-
| `--memory-budget` | GB | machine RAM × 0.75; GLM-5.2: min(25 GiB, physical RAM) | Admission ceiling.
|
|
24
|
+
| `--memory-budget` | GB | machine RAM × 0.75; GLM-5.2: min(25 GiB, physical RAM) | Admission ceiling. Generic budgeted serving rejects requests whose `prompt + max_tokens` exceed the safe context with **400** (`type: memory_admission`) instead of risking an uncatchable GPU OOM. GLM-5.2 runs its exact process equation before opening resident weights or expert slabs and caps MLX to the plan's allocator reserve; because its context is fixed up front, a broad client `max_tokens` upper bound is capped to the remaining planned context, while a prompt that leaves no generation slot is still rejected. **Decimal GB (×10⁹).** |
|
|
25
25
|
| `--prompt-cache` | GB | `8` | RAM prefix-KV cache (byte-capped LRU; `0` disables). **PREFIX SHARING (2026-07-05): serves are NON-CONSUMING** — a hit hands out zero-copy clones and leaves the donor entry intact, so N agents (or a brand-new session) sharing a system prompt all reuse ONE prefill without destroying each other's conversation entries (the old consume-and-trim take cannibalized the donor). Extended entries supersede their prefix-ancestors (when trimmable), so a conversation stays one entry. Works on BOTH lanes and tiers over the SSD store (Layer 0). |
|
|
26
26
|
| `--isolate` | (bool) | off | Runtime isolation ([runtime-isolation.md](../design/runtime-isolation.md)): the inference engine runs as a CHILD process on a unix socket; this process stays a pure UI/API reverse proxy — instant under any GPU load, survives engine crashes (auto-respawn, in-flight requests get a clean 502). Measured cost ≈0 (−0.4% tok/s, +2 ms TTFT, per-token SSE granularity preserved). `/ws/chat` is not proxied yet (501). `/engine` reports child pid/restarts. |
|
|
27
27
|
| `--model-pool` | n | `1` | With `--isolate`: max RESIDENT model engines. Requests naming another model (**exact `/v1/models` id** — fuzzy strings keep mlx-lm's ignored-field semantics) spawn/route to that model's own engine child (SPAWN-OVERLAP: the new model loads while the old one keeps serving; nobody's stream is interrupted). Over the cap, the LRU engine drains, **demotes its prompt cache to the SSD tier**, and exits — switching back respawns it with state restored from disk. Measured (M1 Max, cpm5⇄qwen0.8b, pool 1): switch 1.5 s, switch-back 1.2 s with `cached_tokens` 103/104 — the conversation survived the eviction. The pool drives this eviction via `POST /admin/drain` — unix-socket-only (never exposed on the public TCP listener) — which quiesces the gateway and demotes the whole prompt cache to the SSD tier before eviction, responding `{ drained: true, demotions: N }`. |
|
package/package.json
CHANGED
package/src/chat-template.ts
CHANGED
|
@@ -40,6 +40,14 @@ export interface RenderOptions {
|
|
|
40
40
|
addGenerationPrompt?: boolean;
|
|
41
41
|
tools?: ToolDefinition[] | null;
|
|
42
42
|
enableThinking?: boolean;
|
|
43
|
+
/** Template-level reasoning depth (Qwen3.8: xhigh|medium|low — the template
|
|
44
|
+
* RAISES on other values, so callers must map OpenAI levels first and only
|
|
45
|
+
* set this for templates that read it; see ChatTemplate.readsReasoningEffort). */
|
|
46
|
+
reasoningEffort?: "xhigh" | "medium" | "low";
|
|
47
|
+
/** Keep think blocks from historical assistant turns in the rendered prompt
|
|
48
|
+
* (Qwen3.8 `preserve_thinking`, template default true — better prompt-cache
|
|
49
|
+
* reuse and agent-trace continuity). Only passed when the template reads it. */
|
|
50
|
+
preserveThinking?: boolean;
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
type ChatRenderer = (
|
|
@@ -240,6 +248,14 @@ export class ChatTemplate {
|
|
|
240
248
|
readonly #renderer: ChatRenderer | null;
|
|
241
249
|
readonly #bosToken: string | null;
|
|
242
250
|
readonly #eosToken: string | null;
|
|
251
|
+
/** True when the template reads `reasoning_effort` (Qwen3.8's xhigh/medium/
|
|
252
|
+
* low depth control). Callers must not pass the variable otherwise — and
|
|
253
|
+
* must never pass unmapped OpenAI level names; this template family raises
|
|
254
|
+
* on values outside its supported set. */
|
|
255
|
+
readonly readsReasoningEffort: boolean;
|
|
256
|
+
/** True when the template reads `preserve_thinking` (Qwen3.8: keep think
|
|
257
|
+
* blocks from historical turns; template default true). */
|
|
258
|
+
readonly readsPreserveThinking: boolean;
|
|
243
259
|
/** Which reasoning format the template's `enable_thinking` channel uses, or
|
|
244
260
|
* null if the model has no switchable reasoning:
|
|
245
261
|
* - "think-tag": `<think>…</think>` text markers (Qwen3.5, MiniCPM5) — split
|
|
@@ -267,6 +283,8 @@ export class ChatTemplate {
|
|
|
267
283
|
this.#bosToken = bosToken;
|
|
268
284
|
this.#eosToken = eosToken;
|
|
269
285
|
const gatesThinking = source?.includes("enable_thinking") ?? false;
|
|
286
|
+
this.readsReasoningEffort = source?.includes("reasoning_effort") ?? false;
|
|
287
|
+
this.readsPreserveThinking = source?.includes("preserve_thinking") ?? false;
|
|
270
288
|
// `forceNoThinking` suppresses the switchable channel even when the template
|
|
271
289
|
// carries the gemma markers. DiffusionGemma ships the shared gemma-family
|
|
272
290
|
// template (with the `<|channel>thought…<channel|>` reasoning channel), but
|
|
@@ -334,7 +352,13 @@ export class ChatTemplate {
|
|
|
334
352
|
|
|
335
353
|
render(messages: ChatMessage[], options: RenderOptions = {}): string {
|
|
336
354
|
if (this.#renderer) return this.#renderer(messages, options);
|
|
337
|
-
const {
|
|
355
|
+
const {
|
|
356
|
+
addGenerationPrompt = true,
|
|
357
|
+
tools = null,
|
|
358
|
+
enableThinking,
|
|
359
|
+
reasoningEffort,
|
|
360
|
+
preserveThinking,
|
|
361
|
+
} = options;
|
|
338
362
|
return this.#template!.render({
|
|
339
363
|
messages,
|
|
340
364
|
add_generation_prompt: addGenerationPrompt,
|
|
@@ -342,6 +366,15 @@ export class ChatTemplate {
|
|
|
342
366
|
// `value['type'] | upper` (Gemma) never see UndefinedValue.
|
|
343
367
|
tools: normalizeToolSchemas(tools),
|
|
344
368
|
...(enableThinking !== undefined ? { enable_thinking: enableThinking } : {}),
|
|
369
|
+
// Only set for templates that read them: reasoning_effort values are
|
|
370
|
+
// template-validated (Qwen3.8 raises on unknown levels), and an unread
|
|
371
|
+
// variable in other templates is dead context at best.
|
|
372
|
+
...(reasoningEffort !== undefined && this.readsReasoningEffort
|
|
373
|
+
? { reasoning_effort: reasoningEffort }
|
|
374
|
+
: {}),
|
|
375
|
+
...(preserveThinking !== undefined && this.readsPreserveThinking
|
|
376
|
+
? { preserve_thinking: preserveThinking }
|
|
377
|
+
: {}),
|
|
345
378
|
bos_token: this.#bosToken,
|
|
346
379
|
eos_token: this.#eosToken,
|
|
347
380
|
});
|
package/src/config.ts
CHANGED
|
@@ -253,6 +253,16 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
|
|
|
253
253
|
// partial_rotary_factor}), not the gemma per-attention-type map — and
|
|
254
254
|
// type "default" means plain partial nn.RoPE (mrope_section ignored for text).
|
|
255
255
|
const qwenRope = (t.rope_parameters ?? {}) as Record<string, any>;
|
|
256
|
+
// Qwen3.8 adds output_gate_type ("swish"), which every implementation
|
|
257
|
+
// (transformers ground truth, mlx-lm pinned + main) currently ignores —
|
|
258
|
+
// the attention output gate is hardcoded o_proj(out·σ(gate)). Only accept
|
|
259
|
+
// values verified to mean that; an unknown value must fail at load, not
|
|
260
|
+
// silently mis-gate a future checkpoint where the field starts mattering.
|
|
261
|
+
if (isQwen35 && t.output_gate_type != null && t.output_gate_type !== "swish")
|
|
262
|
+
throw new Error(
|
|
263
|
+
`qwen3_5: unverified output_gate_type "${t.output_gate_type}" (only "swish" ` +
|
|
264
|
+
`is verified to mean the standard sigmoid output gate)`,
|
|
265
|
+
);
|
|
256
266
|
|
|
257
267
|
const text: TextConfig = {
|
|
258
268
|
hiddenSize: glm52?.hiddenSize ?? t.hidden_size,
|
package/src/server.ts
CHANGED
|
@@ -293,7 +293,7 @@ export interface GenSamplingDefaults {
|
|
|
293
293
|
repetitionPenalty?: number;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
export type DraftKind = "dspark" | "deepspec" | "assistant" | "two-model" | "ngram";
|
|
296
|
+
export type DraftKind = "dspark" | "deepspec" | "assistant" | "two-model" | "ngram" | "mtp";
|
|
297
297
|
|
|
298
298
|
/** Detect the draft artifact's kind so the right provider is loaded. All
|
|
299
299
|
* providers share ONE serve loop (src/spec/serve-loop.ts). Exported for the
|
|
@@ -311,12 +311,22 @@ export async function detectDraftKind(dir: string): Promise<DraftKind> {
|
|
|
311
311
|
// dspark.json, plain HF config stamped Gemma4DSparkModel.
|
|
312
312
|
if (cfg.architectures?.[0] === "Gemma4DSparkModel") return "deepspec";
|
|
313
313
|
if (String(cfg.model_type ?? "").includes("assistant")) return "assistant";
|
|
314
|
+
// Detect Qwen's companion head so loadContext can refuse it precisely.
|
|
315
|
+
// The target's 48 recurrent DeltaNet caches cannot yet roll back rejected
|
|
316
|
+
// verification rows, so treating this as a generic two-model draft would
|
|
317
|
+
// fail later and obscure the actual unsupported composition.
|
|
318
|
+
if (String(cfg.model_type ?? "").endsWith("_mtp")) return "mtp";
|
|
314
319
|
} catch {
|
|
315
320
|
// no/unreadable config → fall through to a full second model
|
|
316
321
|
}
|
|
317
322
|
return "two-model";
|
|
318
323
|
}
|
|
319
324
|
|
|
325
|
+
export const QWEN_MTP_UNSUPPORTED_REASON =
|
|
326
|
+
"Qwen native MTP is not supported yet: its target has recurrent DeltaNet " +
|
|
327
|
+
"caches that cannot roll back rejected verification tokens. Serve Qwen3.8 " +
|
|
328
|
+
"without --draft-model; MTP remains a gated follow-up.";
|
|
329
|
+
|
|
320
330
|
async function loadGenSamplingDefaults(modelDir: string): Promise<GenSamplingDefaults> {
|
|
321
331
|
const file = Bun.file(`${modelDir}/generation_config.json`);
|
|
322
332
|
if (!(await file.exists())) return {};
|
|
@@ -341,6 +351,15 @@ export async function loadContext(
|
|
|
341
351
|
const config = await loadModelConfig(modelDir);
|
|
342
352
|
const glm = isGlm52Config(config);
|
|
343
353
|
const externalDraft = opts.draftModelDir !== undefined || opts.draftKind !== undefined;
|
|
354
|
+
// Refuse the known-impossible composition before opening the 20 GB target
|
|
355
|
+
// or the companion head. The shared speculative loop requires target-cache
|
|
356
|
+
// rollback after a partial reject; Qwen's DeltaNet state is recurrent and
|
|
357
|
+
// deliberately non-trimmable. Silently degrading would make --draft-model
|
|
358
|
+
// a lie, while forcing it would corrupt the next target step.
|
|
359
|
+
const resolvedDraftKind = opts.draftModelDir
|
|
360
|
+
? opts.draftKind ?? await detectDraftKind(opts.draftModelDir)
|
|
361
|
+
: opts.draftKind;
|
|
362
|
+
if (resolvedDraftKind === "mtp") throw new Error(QWEN_MTP_UNSUPPORTED_REASON);
|
|
344
363
|
if (glm && externalDraft && opts.glm?.enableMtp === true)
|
|
345
364
|
throw new Error("native GLM MTP and --draft-model/--draft-kind are mutually exclusive");
|
|
346
365
|
let weights: Weights | null = null;
|
|
@@ -412,7 +431,7 @@ export async function loadContext(
|
|
|
412
431
|
};
|
|
413
432
|
} else if (opts.draftModelDir) {
|
|
414
433
|
const dir = opts.draftModelDir;
|
|
415
|
-
const kind =
|
|
434
|
+
const kind = resolvedDraftKind!;
|
|
416
435
|
let provider: import("./spec/source").DraftProvider;
|
|
417
436
|
let numDraftTokens = Math.max(1, opts.numDraftTokens ?? 3);
|
|
418
437
|
if (kind === "dspark") {
|
|
@@ -663,15 +682,21 @@ interface ChatRequest {
|
|
|
663
682
|
tools?: ToolDefinition[];
|
|
664
683
|
tool_choice?: "auto" | "none" | { type: string; function?: { name: string } };
|
|
665
684
|
/** Forwarded to HF chat templates, matching optiq serve. MiniCPM5 uses
|
|
666
|
-
* enable_thinking to select direct answers vs the <think> channel.
|
|
685
|
+
* enable_thinking to select direct answers vs the <think> channel; Qwen3.8
|
|
686
|
+
* adds preserve_thinking (keep think blocks from history; template default
|
|
687
|
+
* true — better prompt-cache reuse in agent loops). */
|
|
667
688
|
chat_template_kwargs?: {
|
|
668
689
|
enable_thinking?: boolean;
|
|
690
|
+
preserve_thinking?: boolean;
|
|
669
691
|
[key: string]: unknown;
|
|
670
692
|
};
|
|
671
693
|
/** OpenAI reasoning control. For models with a switchable <think> channel
|
|
672
694
|
* (Qwen3.5/MiniCPM5) it gates enable_thinking: "none" → off, any level → on.
|
|
673
|
-
* This is what Pi sends when the provider advertises reasoning.
|
|
674
|
-
|
|
695
|
+
* This is what Pi sends when the provider advertises reasoning. For
|
|
696
|
+
* templates that read a reasoning-depth variable (Qwen3.8: xhigh/medium/
|
|
697
|
+
* low), the level ALSO maps into the template via qwenReasoningEffort —
|
|
698
|
+
* "xhigh" accepted as a first-class value (Qwen3.8's default/deepest). */
|
|
699
|
+
reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
675
700
|
/** Mounted LoRA adapter selection: "id", "a+b" (stacked), or "none". */
|
|
676
701
|
adapter?: string;
|
|
677
702
|
/** HLG tone-curve sampling override (per request). Snake_case wire fields,
|
|
@@ -708,6 +733,36 @@ type TextCompletionRequest = Omit<ChatRequest, "messages" | "tools" | "tool_choi
|
|
|
708
733
|
prompt?: unknown;
|
|
709
734
|
};
|
|
710
735
|
|
|
736
|
+
export interface ContextAdmissionDecision {
|
|
737
|
+
/** Effective completion ceiling after admission. */
|
|
738
|
+
maxTokens: number;
|
|
739
|
+
/** True when a fixed-context runtime reduced the client's upper bound. */
|
|
740
|
+
clamped: boolean;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Resolve a request's completion upper bound against the admitted context.
|
|
744
|
+
*
|
|
745
|
+
* Generic memory-budget serving preserves the established fail-fast contract:
|
|
746
|
+
* an oversized reservation is rejected. A fixed-context runtime such as GLM
|
|
747
|
+
* may instead clamp the client's upper bound to the remaining planned context.
|
|
748
|
+
* `max_tokens` is a ceiling, not a promise that every token will be emitted;
|
|
749
|
+
* clamping keeps clients that send a broad family-wide default usable without
|
|
750
|
+
* weakening the prompt/context OOM guard. A prompt that leaves no generation
|
|
751
|
+
* slot is always rejected. */
|
|
752
|
+
export function admitRequestContext(
|
|
753
|
+
promptTokens: number,
|
|
754
|
+
requestedMaxTokens: number,
|
|
755
|
+
maxSafeContext: number,
|
|
756
|
+
clampToFit: boolean,
|
|
757
|
+
): ContextAdmissionDecision | null {
|
|
758
|
+
const available = maxSafeContext - promptTokens;
|
|
759
|
+
if (available < 1) return null;
|
|
760
|
+
if (requestedMaxTokens <= available)
|
|
761
|
+
return { maxTokens: requestedMaxTokens, clamped: false };
|
|
762
|
+
if (!clampToFit) return null;
|
|
763
|
+
return { maxTokens: available, clamped: true };
|
|
764
|
+
}
|
|
765
|
+
|
|
711
766
|
/** Per-field default HLG knobs when enabling without specifying them. */
|
|
712
767
|
const HLG_DEFAULTS = { width: 4, shoulder: 4, toe: 6, pivotOffset: 6 } as const;
|
|
713
768
|
|
|
@@ -2162,10 +2217,36 @@ export function createServer(
|
|
|
2162
2217
|
return { controller: r.controller, degradeHint: r.degradeHint };
|
|
2163
2218
|
};
|
|
2164
2219
|
|
|
2220
|
+
// Map OpenAI reasoning_effort levels onto the Qwen3.8 template's supported
|
|
2221
|
+
// set (xhigh|medium|low — the template raises on anything else). "none"
|
|
2222
|
+
// means thinking off (handled by resolveEnableThinking), so no depth is
|
|
2223
|
+
// passed. Only consumed for templates with readsReasoningEffort.
|
|
2224
|
+
const qwenReasoningEffort = (
|
|
2225
|
+
effort: ChatRequest["reasoning_effort"],
|
|
2226
|
+
): "xhigh" | "medium" | "low" | undefined => {
|
|
2227
|
+
switch (effort) {
|
|
2228
|
+
case "minimal":
|
|
2229
|
+
case "low":
|
|
2230
|
+
return "low";
|
|
2231
|
+
case "medium":
|
|
2232
|
+
return "medium";
|
|
2233
|
+
case "high":
|
|
2234
|
+
case "xhigh":
|
|
2235
|
+
return "xhigh";
|
|
2236
|
+
default:
|
|
2237
|
+
return undefined; // "none" / unset → template default (xhigh)
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
|
|
2165
2241
|
const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
|
|
2166
2242
|
// enableThinking resolution (and its precedence) lives in
|
|
2167
2243
|
// resolveEnableThinking so template rendering and sampling stay in sync.
|
|
2168
|
-
return {
|
|
2244
|
+
return {
|
|
2245
|
+
tools,
|
|
2246
|
+
enableThinking: resolveEnableThinking(req),
|
|
2247
|
+
reasoningEffort: qwenReasoningEffort(req.reasoning_effort),
|
|
2248
|
+
preserveThinking: req.chat_template_kwargs?.preserve_thinking,
|
|
2249
|
+
};
|
|
2169
2250
|
};
|
|
2170
2251
|
|
|
2171
2252
|
const promptIdsFor = (
|
|
@@ -2200,7 +2281,7 @@ export function createServer(
|
|
|
2200
2281
|
trimmed: number[],
|
|
2201
2282
|
): number => {
|
|
2202
2283
|
const opts = templateOptionsFor(req, tools);
|
|
2203
|
-
const mode = `${opts.enableThinking}|${!!tools?.length}`;
|
|
2284
|
+
const mode = `${opts.enableThinking}|${!!tools?.length}|${opts.reasoningEffort ?? ""}|${opts.preserveThinking ?? ""}`;
|
|
2204
2285
|
const primer = primerLenByMode.get(mode);
|
|
2205
2286
|
if (primer !== undefined) return Math.max(0, trimmed.length - primer);
|
|
2206
2287
|
let stableLen = trimmed.length;
|
|
@@ -3277,15 +3358,22 @@ export function createServer(
|
|
|
3277
3358
|
// Admission: reject what cannot finish within the memory budget
|
|
3278
3359
|
// (the GPU OOM it would otherwise hit is uncatchable and kills
|
|
3279
3360
|
// the process — Phase 6 finding).
|
|
3280
|
-
const
|
|
3281
|
-
|
|
3361
|
+
const requestedMaxTokens = options.maxTokens ?? 1024;
|
|
3362
|
+
const requiredCtx = promptIds.length + requestedMaxTokens;
|
|
3363
|
+
const contextDecision = admitRequestContext(
|
|
3364
|
+
promptIds.length,
|
|
3365
|
+
requestedMaxTokens,
|
|
3366
|
+
admission.maxSafeContext,
|
|
3367
|
+
ctx.glmMemoryPlan !== null,
|
|
3368
|
+
);
|
|
3369
|
+
if (!contextDecision) {
|
|
3282
3370
|
disposeRejected();
|
|
3283
3371
|
return Response.json(
|
|
3284
3372
|
{
|
|
3285
3373
|
error: {
|
|
3286
3374
|
message:
|
|
3287
3375
|
`request needs ${requiredCtx} tokens of context ` +
|
|
3288
|
-
`(prompt ${promptIds.length} + max_tokens ${
|
|
3376
|
+
`(prompt ${promptIds.length} + max_tokens ${requestedMaxTokens}) but the ` +
|
|
3289
3377
|
`memory budget caps safe context at ${admission.maxSafeContext} — ` +
|
|
3290
3378
|
`shorten the prompt or lower max_tokens`,
|
|
3291
3379
|
type: "memory_admission",
|
|
@@ -3295,6 +3383,7 @@ export function createServer(
|
|
|
3295
3383
|
{ status: 400 },
|
|
3296
3384
|
);
|
|
3297
3385
|
}
|
|
3386
|
+
options.maxTokens = contextDecision.maxTokens;
|
|
3298
3387
|
try {
|
|
3299
3388
|
// A request's explicit `adapter` (incl. "none") wins over the
|
|
3300
3389
|
// startup default from `serve --adapter <dir>`.
|
|
@@ -3632,8 +3721,15 @@ export function createServer(
|
|
|
3632
3721
|
// completion: with no template an EOS may never come.
|
|
3633
3722
|
options.maxTokens = body.max_completion_tokens ?? body.max_tokens ??
|
|
3634
3723
|
defaultGeneratedTokens ?? 512;
|
|
3635
|
-
const
|
|
3636
|
-
|
|
3724
|
+
const requestedMaxTokens = options.maxTokens;
|
|
3725
|
+
const requiredCtx = promptIds.length + requestedMaxTokens;
|
|
3726
|
+
const contextDecision = admitRequestContext(
|
|
3727
|
+
promptIds.length,
|
|
3728
|
+
requestedMaxTokens,
|
|
3729
|
+
admission.maxSafeContext,
|
|
3730
|
+
ctx.glmMemoryPlan !== null,
|
|
3731
|
+
);
|
|
3732
|
+
if (!contextDecision) {
|
|
3637
3733
|
// Pre-run reject: the gateway only takes grammar disposal ownership
|
|
3638
3734
|
// once run — free the WASM matcher here (chat lane: disposeRejected).
|
|
3639
3735
|
textGrammarCtrl?.dispose();
|
|
@@ -3652,6 +3748,7 @@ export function createServer(
|
|
|
3652
3748
|
{ status: 400 },
|
|
3653
3749
|
);
|
|
3654
3750
|
}
|
|
3751
|
+
options.maxTokens = contextDecision.maxTokens;
|
|
3655
3752
|
try {
|
|
3656
3753
|
const adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
|
|
3657
3754
|
if (adapterIds.length) options.adapters = adapterIds;
|