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 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
- Qwen3.5-4B, and the direct-container GLM-5.2 path:
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). The larger
241
- `Qwen3.6-27B-OptiQ-4bit` is still in bring-up — parity and serving polish
242
- remain (see [PLAN.md](./PLAN.md)). Downloading, cache layout, and reclaiming
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), and an `hlg`
346
- tone-curve sampling override;
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 and rejects requests whose
419
- `prompt + max_tokens` exceed the budget's max safe context with a 400
420
- (`type: "memory_admission"`) *before* generating. The GPU
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.6-27B` bring-up (Phase 14f): same architecture
590
- as the verified 4B (untied + larger geometry); parity and serving polish
591
- remain. MTP speculation and Qwen3-VL vision deferred.
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 // MiniCPM5 / Qwen3.5 / GLM-5.2: think channel
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/MiniCPM5:
57
- // "none" → off, any other level → on. Only
58
- // consulted when chat_template_kwargs
59
- // .enable_thinking is not explicitly set —
60
- // an explicit enable_thinking always wins.
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"` — `prompt + max_tokens` exceeds the memory
337
- budget's max safe context (only when serving with `--memory-budget`;
338
- the GPU OOM this prevents would kill the process, so it is refused
339
- up front). Lower `max_tokens` or shorten the prompt; the ceiling is
340
- visible at `/stats`.
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. Requests whose `prompt + max_tokens` exceed the budget's max safe context are **rejected with 400** (`type: memory_admission`) instead of risking an uncatchable GPU OOM. GLM-5.2 runs its header-only exact process equation before opening resident weights or expert slabs, and caps MLX to that plan's allocator reserve. **Decimal GB (×10⁹).** |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlx-bun",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Native MLX inference for Bun on Apple Silicon — local LLM server (OpenAI/Anthropic-compatible) and TypeScript library. No Python.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 { addGenerationPrompt = true, tools = null, enableThinking } = options;
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 = opts.draftKind ?? (await detectDraftKind(dir));
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
- reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high";
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 { tools, enableThinking: resolveEnableThinking(req) };
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 requiredCtx = promptIds.length + (options.maxTokens ?? 1024);
3281
- if (requiredCtx > admission.maxSafeContext) {
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 ${options.maxTokens}) but the ` +
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 requiredCtx = promptIds.length + options.maxTokens;
3636
- if (requiredCtx > admission.maxSafeContext) {
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;