pi-makora-provider 1.7.0 → 1.9.0

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
@@ -21,13 +21,13 @@ _DeepSeek V4, Kimi K2.7 Code, GLM 5.2, Qwen 3.6 for [pi](https://github.com/eare
21
21
  | DeepSeek V4 Flash | `deepseek-ai/DeepSeek-V4-Flash` | Yes | returns `reasoning` field |
22
22
  | DeepSeek V4 Pro | `deepseek-ai/DeepSeek-V4-Pro` | Yes | returns `reasoning` field |
23
23
  | Gemma 4 26B A4B | `google/gemma-4-26B-A4B` | No | |
24
- | GLM 5.2 FP8 | `zai-org/GLM-5.2-FP8` | Yes | `enable_thinking` via `qwen-chat-template`; effort via `reasoning_effort` (only `high`/`max` distinct, per vLLM GLM-5.2 recipe); thinking levels aligned with neuralwatt GLM 5.2; returns `reasoning` field |
25
- | GLM 5.2 NVFP4 | `zai-org/GLM-5.2-NVFP4` | Yes | `enable_thinking` via `qwen-chat-template`; effort via `reasoning_effort` (only `high`/`max` distinct, per vLLM GLM-5.2 recipe); returns `reasoning` field |
26
- | Kimi K2.7 Code | `moonshotai/Kimi-K2.7-Code` | Yes | Reasoning on by default (thinking-only model); `preserve_thinking` via `chatTemplateKwargs` for multi-turn continuity; returns `reasoning` field |
24
+ | GLM 5.2 FP8 | `zai-org/GLM-5.2-FP8` | Yes | off via top-level `reasoning_effort: "none"` (only lever GLM responds to; `enable_thinking`/`thinking`/chat_template_kwargs toggles are ignored); effort `high`/`max` only (minimal/low/medium clamp to `high`); multi-turn continuity via `clear_thinking` (false when thinking on = preserve, true when off = clear; `preserve_thinking` is inert for GLM) injected via onPayload; returns `reasoning` field |
25
+ | GLM 5.2 NVFP4 | `zai-org/GLM-5.2-NVFP4` | Yes | off via top-level `reasoning_effort: "none"` (only lever GLM responds to; `enable_thinking`/`thinking`/chat_template_kwargs toggles are ignored); effort `high`/`max` only (minimal/low/medium clamp to `high`); multi-turn continuity via `clear_thinking` (false when thinking on = preserve, true when off = clear; `preserve_thinking` is inert for GLM) injected via onPayload; returns `reasoning` field |
26
+ | Kimi K2.7 Code | `moonshotai/Kimi-K2.7-Code` | Yes | on/off via namespaced `chat_template_kwargs.thinking` (toggled with thinking switch); `preserve_thinking` (toggled) for multi-turn continuity; onPayload aliases each assistant message's `reasoning` → `reasoning_content` (Kimi's template reads `reasoning_content` but pi-ai sends `reasoning`); returns `reasoning` field. `reasoning_effort` is not emitted (Kimi's template reads namespaced `thinking`, not `reasoning_effort`) |
27
27
  | Llama 3.3 70B FP8 | `amd/Llama-3.3-70B-Instruct-FP8-KV` | No | |
28
28
  | Llama 3.3 70B Instruct | `meta-llama/Llama-3.3-70B-Instruct` | No | |
29
- | Qwen 3.6 27B NVFP4 | `unsloth/Qwen3.6-27B-NVFP4` | Yes | `enable_thinking` via `qwen-chat-template`; `preserve_thinking` via `chatTemplateKwargs` for multi-turn continuity; returns `reasoning` field |
30
- | Qwen 3.6 35B A3B NVFP4 | `unsloth/Qwen3.6-35B-A3B-NVFP4` | Yes | `enable_thinking` via `qwen-chat-template`; `preserve_thinking` via `chatTemplateKwargs` for multi-turn continuity; returns `reasoning` field |
29
+ | Qwen 3.6 27B NVFP4 | `unsloth/Qwen3.6-27B-NVFP4` | Yes | off via top-level `reasoning_effort: "none"`; `preserve_thinking` (toggled with thinking switch) injected via onPayload for multi-turn continuity; returns `reasoning` field |
30
+ | Qwen 3.6 35B A3B NVFP4 | `unsloth/Qwen3.6-35B-A3B-NVFP4` | Yes | off via top-level `reasoning_effort: "none"`; `preserve_thinking` (toggled with thinking switch) injected via onPayload for multi-turn continuity; returns `reasoning` field |
31
31
  <!-- MODELS_TABLE_END -->
32
32
 
33
33
  ## Installation
@@ -160,21 +160,31 @@ Do **not** edit `models.json` directly — it is auto-generated from the API. To
160
160
 
161
161
  ## Death-Loop Guard
162
162
 
163
- GLM 5.2 (NVFP4 / FP8) occasionally degenerates into a repetition loop — an
164
- unbroken run of a single character or token, spaced or not (observed: `!` →
165
- `!!!!...`, `0` → `0000...`, and `0` → `0 0 0 ...`) — that eats the whole
166
- response. This extension ships a guard that watches the streamed assistant
167
- output (both the visible answer and the reasoning trace) and, when it detects a
168
- long run of any one non-whitespace character **or token**, **aborts
169
- the runaway generation and resumes the agentic loop invisibly** — no new user
170
- message is injected, using the
171
- [pi-invisible-continue](https://github.com/monotykamary/pi-invisible-continue)
163
+ GLM 5.2 (NVFP4 / FP8) occasionally degenerates into a repetition loop that
164
+ eats the whole response. Several shapes are possible: an unbroken run of one
165
+ character (`!` → `!!!!...`, `0` → `0000...`), a spaced token loop (`0` →
166
+ `0 0 0 ...`), a short unit repeated under any delimiter (`{}` → `{},{},{},...`),
167
+ and a line/template loop where every line differs at the token level (a log-line
168
+ loop with incrementing timestamps and cycling names). This extension ships a
169
+ guard that watches the streamed assistant output (both the visible answer and
170
+ the reasoning trace) with four detectors — character run, token run,
171
+ trailing-unit run, and normalized-line run — and on a trip it **aborts the
172
+ runaway generation, removes the toxic message from the agent's transcript, and
173
+ resumes the agentic loop invisibly** — no new user message is injected, using
174
+ the [pi-invisible-continue](https://github.com/monotykamary/pi-invisible-continue)
172
175
  pattern (`agent.prompt([])`).
173
176
 
174
- On abort, pi finalizes the in-flight assistant message **with its accumulated
175
- repeated-character content** into the transcript, so the guard drops that
176
- partial message before resuming — otherwise the model would just re-read its
177
- own repeated text and loop again. Recovery retries **indefinitely with exponential backoff (2s→60s,
177
+ Aborting alone isn't enough: the toxic text would stay in the agent's
178
+ persisted transcript and bias later turns (the model re-rolls into ever more
179
+ obscure loops), and any loop that completes without being aborted is committed
180
+ outright. So the guard also **removes the toxic output from the agent itself**:
181
+ a `message_end` handler replaces a degenerate (or aborted) assistant message
182
+ with a clean stub before it is saved to the session file / shown in the TUI,
183
+ and a `context` handler strips degenerate (and stub) assistant messages from
184
+ the per-LLM-call message list so the model never re-sees them. The recovery
185
+ trim also drops the last assistant message from `state.messages` before
186
+ resuming, so the continued `agent.prompt([])` sends a clean context. Recovery
187
+ retries **indefinitely with exponential backoff (2s→60s,
178
188
  2×)**, like [pi-retry](https://github.com/monotykamary/pi-retry) — long-horizon
179
189
  agent work can trip the loop many times in a session, so a retry cap would
180
190
  strand the agent mid-task. The loop exits only on a clean turn, a user abort
@@ -192,6 +202,12 @@ constants at the top of [`death-loop-guard.ts`](./death-loop-guard.ts):
192
202
  | `IGNORED_REPEAT_CHARS` | `space, tab, LF, CR, FF, VT` | Characters whose repetition never trips the guard. |
193
203
  | `TOKEN_REPEAT_THRESHOLD` | `40` | Consecutive identical whitespace-delimited tokens that trip the guard. Catches spaced loops like `0 0 0` that the character run can't see. |
194
204
  | `TOKEN_REPEAT_BUFFER_CHARS` | `1024` | Tail length kept for token-run detection. Must hold ≥ `TOKEN_REPEAT_THRESHOLD` copies of the repeated unit. |
205
+ | `LINE_REPEAT_THRESHOLD` | `100` | Consecutive trailing lines sharing one normalized structure (digits→`#`, identifiers→`A`, whitespace collapsed) that trip the guard. Catches line/template loops (e.g. a log-line loop with incrementing timestamps / cycling names) invisible to the char and token runs. Set high to spare typical structured output (tables, CSVs); degenerate loops produce hundreds-to-thousands of lines. |
206
+ | `LINE_REPEAT_BUFFER_CHARS` | `65536` | Tail length kept for line-run detection. Only the last `LINE_REPEAT_THRESHOLD + 8` complete lines are normalized per check. |
207
+ | `UNIT_REPEAT_THRESHOLD` | `40` | Trailing repeats of a short unit (1–`UNIT_MAX_LENGTH` chars) under ANY delimiter (or none) that trip the guard. The delimiter-agnostic catch-all: catches `{},{},{}` (unit `{},`), `();();();` (unit `();`), and any separator the char/token runs can't see. |
208
+ | `UNIT_MAX_LENGTH` | `16` | Maximum candidate unit length for trailing-unit detection. Repeating units are short; 16 covers `{},`, `();`, `->` with room to spare. |
209
+ | `UNIT_REPEAT_BUFFER_CHARS` | `1024` | Tail length kept for trailing-unit detection. Must hold ≥ `UNIT_REPEAT_THRESHOLD` copies of the longest unit (40 × 16 = 640). |
210
+ | `DEATH_LOOP_STUB_TEXT` | `[Makora death-loop guard: discarded a degenerate repetition loop.]` | Clean replacement swapped in at `message_end` so a death-loop message is removed from the saved transcript. A `context` handler also strips this stub from what the model sees. |
195
211
  | `BACKOFF_BASE_MS` | `2000` | Initial recovery backoff delay. |
196
212
  | `BACKOFF_MAX_MS` | `60000` | Maximum backoff delay (cap). |
197
213
  | `BACKOFF_MULTIPLIER` | `2` | Backoff growth factor per retry. |
@@ -2,16 +2,29 @@
2
2
  * Death-loop guard for Makora reasoning models.
3
3
  *
4
4
  * Some Makora models (notably GLM 5.2 NVFP4 / FP8) occasionally fall into a
5
- * degenerate repetition loop, emitting an unbroken run of a single character
6
- * (observed: '!' -> "!!!!..." and '0' -> "0000...") or a repeated token
7
- * (observed: '0' -> "0 0 0 ...") that consumes the whole response. This guard
8
- * watches the
9
- * streamed assistant output (both the visible answer and the reasoning
10
- * trace); when that run is detected it aborts the runaway generation, drops
11
- * the partial (toxic) assistant message from the transcript, and resumes the
12
- * agentic loop invisibly via agent.prompt([]) — the same pattern
13
- * pi-invisible-continue / pi-retry uses, so no new user message pollutes the
14
- * context.
5
+ * degenerate repetition loop that consumes the whole response. Several shapes
6
+ * are possible: an unbroken run of one character ('!' -> "!!!!...", '0' ->
7
+ * "0000..."), a spaced token loop ('0' -> "0 0 0 ..."), a short unit repeated
8
+ * under any delimiter ('{}' -> "{},{},{},..."), and a line/template loop where
9
+ * every line differs at the token level (a log-line loop with incrementing
10
+ * timestamps and cycling names). This guard watches the streamed assistant
11
+ * output (both the visible answer and the reasoning trace) with four detectors
12
+ * — character run, token run, trailing-unit run, and normalized-line run —
13
+ * and on a trip it (1) aborts the runaway generation, (2) removes the toxic
14
+ * message from the agent itself, and (3) resumes the agentic loop invisibly
15
+ * via agent.prompt([]) (the pi-invisible-continue / pi-retry pattern, so no
16
+ * new user message pollutes the context).
17
+ *
18
+ * Removal, not just suppression: aborting alone leaves the toxic text in the
19
+ * agent's persisted transcript, and any pattern that completes without being
20
+ * aborted is committed outright — both bias later turns (the model re-rolls
21
+ * into ever more obscure loops). So a message_end handler replaces a
22
+ * degenerate/aborted assistant message with a clean stub BEFORE it is saved to
23
+ * the session file / shown in the TUI, and a context handler strips degenerate
24
+ * (and stub) assistant messages from the per-LLM-call message list so the
25
+ * model never re-sees them. The recovery trim also drops the last assistant
26
+ * message from state.messages so the resumed prompt([]) sends a clean
27
+ * continuation context.
15
28
  *
16
29
  * Infinite retries with exponential backoff. Long-horizon agent work can trip
17
30
  * the loop many times in one session; capping retries would strand the agent
@@ -21,12 +34,16 @@
21
34
  * window to intervene; interruptible sleep polls every 100ms so Esc and /new
22
35
  * take effect within 100ms instead of waiting out the full delay.
23
36
  *
24
- * Why trim the aborted message: on abort, pi finalizes the in-flight
25
- * assistant message WITH its accumulated '!!!' content and stopReason
26
- * "aborted" into the transcript. Resuming from that context would re-feed
27
- * the toxic text to the model and likely re-trigger the loop. Dropping the
28
- * last (aborted) assistant message leaves the context ending at the prior
29
- * user/toolResult message — a clean continuation point.
37
+ * Why trim AND scrub the aborted message: on abort, pi finalizes the
38
+ * in-flight assistant message WITH its accumulated toxic content and
39
+ * stopReason "aborted" into the transcript (the Agent pushes it to
40
+ * state.messages before event listeners run). Resuming from that context
41
+ * would re-feed the toxic text to the model and likely re-trigger the loop.
42
+ * The message_end handler swaps that content for a clean stub in the saved
43
+ * transcript; trimAbortedDeathLoop then drops the last assistant message from
44
+ * state.messages so the resumed prompt([]) sends a context ending at the prior
45
+ * user/toolResult — a clean continuation point. The context handler
46
+ * backstops any case that bypasses the recovery path.
30
47
  *
31
48
  * Distinguishing our abort from a user Esc: both call agent.abort(), so both
32
49
  * surface as stopReason "aborted" at turn_end. The handler sets _weAborted
@@ -97,6 +114,47 @@ export const TOKEN_REPEAT_THRESHOLD = 40;
97
114
  * copies of tokens up to ~25 chars. */
98
115
  export const TOKEN_REPEAT_BUFFER_CHARS = 1024;
99
116
 
117
+ /** Trip after this many consecutive trailing lines that share the same
118
+ * normalized structure (digits -> '#', identifiers -> 'A', whitespace
119
+ * collapsed). Catches template/line-level repetition where every line differs
120
+ * at the token level (incrementing timestamps, cycling names) — e.g. a
121
+ * log-line loop — which neither the character nor the token run can see. Set
122
+ * high (100): structural repetition is the most false-positive-prone (markdown
123
+ * tables, CSVs), while degenerate loops produce hundreds-to-thousands of
124
+ * lines, so a high threshold catches them fast while sparing typical structured
125
+ * output. Tunable. */
126
+ export const LINE_REPEAT_THRESHOLD = 100;
127
+
128
+ /** Bounded tail kept for line-run detection. Only the last
129
+ * (LINE_REPEAT_THRESHOLD + 8) complete lines are normalized per check, so cost
130
+ * stays bounded regardless of buffer size; the buffer just needs to hold that
131
+ * many lines. 65536 fits ~100 lines up to ~600 chars. */
132
+ export const LINE_REPEAT_BUFFER_CHARS = 65536;
133
+
134
+ /** Trip after this many trailing repeats of a short unit (1..UNIT_MAX_LENGTH
135
+ * chars) under any delimiter (or none). The delimiter-agnostic catch-all: it
136
+ * catches `{},{},{}` (unit `{},`), `();();();` (unit `();`), and any future
137
+ * separator the char/token runs can't see. 40 mirrors the other thresholds. */
138
+ export const UNIT_REPEAT_THRESHOLD = 40;
139
+
140
+ /** Maximum candidate unit length for trailing-unit detection. Repeating units
141
+ * are short; 16 covers multi-char units like `{},` (3), `();` (3), `->` (2)
142
+ * with room to spare. */
143
+ export const UNIT_MAX_LENGTH = 16;
144
+
145
+ /** Bounded tail kept for trailing-unit detection. Must hold >=
146
+ * UNIT_REPEAT_THRESHOLD copies of the longest unit: 40 * 16 = 640, so 1024
147
+ * gives comfortable margin. */
148
+ export const UNIT_REPEAT_BUFFER_CHARS = 1024;
149
+
150
+ /** Replacement content for a finalized assistant message that was a death
151
+ * loop. The toxic text is removed from the agent's persisted transcript
152
+ * (session file + TUI) by replacing it with this clean stub at message_end,
153
+ * and a context handler strips even this stub from what the model sees, so
154
+ * neither the toxic output nor the stub biases later turns. */
155
+ export const DEATH_LOOP_STUB_TEXT =
156
+ "[Makora death-loop guard: discarded a degenerate repetition loop.]";
157
+
100
158
  /** Exponential backoff for recovery retries. Mirrors pi-retry's defaults:
101
159
  * 2s base, 60s cap, 2× multiplier. Tunable via these constants. */
102
160
  export const BACKOFF_BASE_MS = 2000;
@@ -119,6 +177,8 @@ export interface GuardedMessage {
119
177
  role: string;
120
178
  stopReason?: string;
121
179
  content?: unknown;
180
+ provider?: string;
181
+ model?: string;
122
182
  }
123
183
 
124
184
  export interface GuardedAgent {
@@ -145,11 +205,22 @@ let _trailingRunLen = 0;
145
205
  * identical tokens. Capped to keep memory/cost bounded on long streams. */
146
206
  let _tokenRepeatBuffer = "";
147
207
 
208
+ /** Bounded trailing buffer for line/template repetition detection (e.g. a
209
+ * log-line loop where every line differs at the token level). Only complete
210
+ * lines are inspected, and only the last few are normalized per check. */
211
+ let _lineRepeatBuffer = "";
212
+
213
+ /** Bounded trailing buffer for trailing-unit (delimiter-agnostic) repetition
214
+ * detection (e.g. "{},{},{}"). */
215
+ let _unitRepeatBuffer = "";
216
+
148
217
  /** Reset all per-turn/per-block detection state. */
149
218
  function resetDetection(): void {
150
219
  _trailingRunChar = "";
151
220
  _trailingRunLen = 0;
152
221
  _tokenRepeatBuffer = "";
222
+ _lineRepeatBuffer = "";
223
+ _unitRepeatBuffer = "";
153
224
  }
154
225
 
155
226
  /** Latch: already tripped for the current assistant message. */
@@ -322,6 +393,147 @@ export function isDegenerateTokenRun(
322
393
  return run.count >= threshold && run.token !== "";
323
394
  }
324
395
 
396
+ /** A trailing run of consecutive lines sharing one normalized structure.
397
+ * `template` is "" when `count` === 0. */
398
+ export interface LineRun {
399
+ template: string;
400
+ count: number;
401
+ }
402
+
403
+ /** Normalize a line for structural repetition detection: identifiers -> "A",
404
+ * digit runs -> "#", runs of spaces/tabs collapsed to one, trailing whitespace
405
+ * trimmed. Lines that differ only in filler (timestamps, names, numbers, ids)
406
+ * collapse to the same template, so a log-line loop like
407
+ * "... User logged in: alice" / "... bob" / "... carol" maps to one template. */
408
+ export function normalizeLine(line: string): string {
409
+ return line
410
+ .replace(/[A-Za-z_][A-Za-z0-9_]*/g, "A")
411
+ .replace(/[0-9]+/g, "#")
412
+ .replace(/[ \t]+/g, " ")
413
+ .replace(/[ \t]+$/, "");
414
+ }
415
+
416
+ /** Trailing run of consecutive lines with the same normalized structure in
417
+ * `text`. Only COMPLETE lines (terminated by a newline) are counted — the
418
+ * trailing partial line is ignored so a mid-stream truncation can't break or
419
+ * inflate the run. Only the last `maxScan` complete lines are normalized, so
420
+ * cost is bounded regardless of `text` length. Blank trailing lines are
421
+ * skipped (legitimate blank lines must not trip the guard). */
422
+ export function trailingLineRun(
423
+ text: string,
424
+ maxScan: number = LINE_REPEAT_THRESHOLD + 8,
425
+ ): LineRun {
426
+ if (!text.includes("\n")) return { template: "", count: 0 };
427
+ const parts = text.split("\n");
428
+ parts.pop(); // drop the partial/empty segment after the last newline
429
+ if (parts.length === 0) return { template: "", count: 0 };
430
+ const lines = parts.slice(-maxScan).map(normalizeLine);
431
+ let end = lines.length;
432
+ while (end > 0 && lines[end - 1] === "") end--;
433
+ if (end === 0) return { template: "", count: 0 };
434
+ const template = lines[end - 1];
435
+ let count = 1;
436
+ let i = end - 1;
437
+ while (i > 0 && lines[i - 1] === template) {
438
+ count++;
439
+ i--;
440
+ }
441
+ return { template, count };
442
+ }
443
+
444
+ /** True when a trailing line run is long enough to be a degenerate loop.
445
+ * Blank-line runs never count (template is ""). Exported for unit testing. */
446
+ export function isDegenerateLineRun(
447
+ run: LineRun,
448
+ threshold: number = LINE_REPEAT_THRESHOLD,
449
+ ): boolean {
450
+ return run.count >= threshold && run.template !== "";
451
+ }
452
+
453
+ /** A trailing run of one repeated unit (1..UNIT_MAX_LENGTH chars), under any
454
+ * delimiter (or none). `unit` is "" when `count` === 0. */
455
+ export interface UnitRun {
456
+ unit: string;
457
+ count: number;
458
+ }
459
+
460
+ /** True when every char of `s` is a whitespace char we ignore (mirrors
461
+ * IGNORED_REPEAT_CHARS). Keeps the unit detector from tripping on
462
+ * pure-whitespace units (indentation, blank lines). */
463
+ function isAllWhitespace(s: string): boolean {
464
+ for (let i = 0; i < s.length; i++) {
465
+ if (!IGNORED_REPEAT_CHARS.has(s[i])) return false;
466
+ }
467
+ return s.length > 0;
468
+ }
469
+
470
+ /** Best trailing repetition of a short unit in `text`. For each candidate
471
+ * period p (1..maxLength), takes the unit as the last p chars and counts how
472
+ * many consecutive p-blocks ending at the tail equal it (a leading partial
473
+ * block is ignored). Returns the run with the highest count. Delimiter-
474
+ * agnostic, so it catches a short unit repeated under ANY separator (or none):
475
+ * `!!!!` (unit `!`), `0 0 0` (unit `0 `), `{},{},{}` (unit `{},`), `();();();`
476
+ * (unit `();`), etc.
477
+ *
478
+ * Cost is paid only when repetition is present: on normal text every
479
+ * candidate mismatches its first block, so the whole check is O(maxLength)
480
+ * slices; a real repeat does O(text.length) work — exactly what we want to
481
+ * detect. Operates on a bounded tail buffer. */
482
+ export function trailingUnitRun(
483
+ text: string,
484
+ maxLength: number = UNIT_MAX_LENGTH,
485
+ ): UnitRun {
486
+ const n = text.length;
487
+ if (n < 2) return { unit: "", count: 0 };
488
+ let best: UnitRun = { unit: "", count: 0 };
489
+ const maxP = Math.min(maxLength, n);
490
+ for (let p = 1; p <= maxP; p++) {
491
+ const unit = text.slice(n - p);
492
+ let count = 1;
493
+ let start = n - p;
494
+ while (start - p >= 0 && text.slice(start - p, start) === unit) {
495
+ count++;
496
+ start -= p;
497
+ }
498
+ if (count > best.count) best = { unit, count };
499
+ }
500
+ return best;
501
+ }
502
+
503
+ /** True when a trailing unit run is long enough to be a degenerate loop.
504
+ * Pure-whitespace units never count (e.g. a run of spaces), matching the
505
+ * character detector's whitespace exclusion. Exported for unit testing. */
506
+ export function isDegenerateUnitRun(
507
+ run: UnitRun,
508
+ threshold: number = UNIT_REPEAT_THRESHOLD,
509
+ ): boolean {
510
+ return run.count >= threshold && run.unit !== "" && !isAllWhitespace(run.unit);
511
+ }
512
+
513
+ /** True when a finalized assistant message is a death loop by ANY detector
514
+ * (character run, token run, trailing-unit run, or line/template run). Used by
515
+ * the message_end scrubber, the context filter, and the recovery trim to
516
+ * decide what to remove from the agent. */
517
+ export function isDeathLoopMessage(msg: GuardedMessage): boolean {
518
+ if (msg.role !== "assistant") return false;
519
+ const text = extractText(msg.content);
520
+ if (text.length === 0) return false;
521
+ return (
522
+ isDegenerateRun(messageTrailingRun(msg)) ||
523
+ isDegenerateTokenRun(trailingTokenRun(text)) ||
524
+ isDegenerateUnitRun(trailingUnitRun(text)) ||
525
+ isDegenerateLineRun(trailingLineRun(text))
526
+ );
527
+ }
528
+
529
+ /** True when `msg` was produced by a guarded Makora model (so we only scrub
530
+ * our own models' output, never another provider's). */
531
+ export function isGuardedMessage(msg: GuardedMessage): boolean {
532
+ if (msg.provider !== PROVIDER_ID) return false;
533
+ if (GUARDED_MODEL_IDS.has("*")) return true;
534
+ return msg.model != null && GUARDED_MODEL_IDS.has(msg.model);
535
+ }
536
+
325
537
  /** Exponential backoff delay, capped at maxDelayMs. Mirrors pi-retry. */
326
538
  export function calculateDelay(
327
539
  attempt: number,
@@ -369,9 +581,7 @@ function trimAbortedDeathLoop(agent: GuardedAgent): void {
369
581
  if (
370
582
  last &&
371
583
  last.role === "assistant" &&
372
- (last.stopReason === "aborted" ||
373
- isDegenerateRun(messageTrailingRun(last)) ||
374
- isDegenerateTokenRun(trailingTokenRun(extractText(last.content))))
584
+ (last.stopReason === "aborted" || isDeathLoopMessage(last))
375
585
  ) {
376
586
  agent.state.messages = msgs.slice(0, -1);
377
587
  }
@@ -404,8 +614,8 @@ async function triggerRecovery(): Promise<void> {
404
614
  const delay = calculateDelay(attempt);
405
615
  if (_notifyFn) {
406
616
  _notifyFn(
407
- `Makora death-loop guard: resuming after runaway repeated-character ` +
408
- `output (retry ${attempt}, backoff ${formatDuration(delay)})...`,
617
+ `Makora death-loop guard: resuming after a runaway repetition loop ` +
618
+ `(retry ${attempt}, backoff ${formatDuration(delay)})...`,
409
619
  "warning",
410
620
  );
411
621
  }
@@ -520,7 +730,39 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
520
730
  );
521
731
  const tokenRun = trailingTokenRun(_tokenRepeatBuffer);
522
732
 
523
- if (!isDegenerateRun(run) && !isDegenerateTokenRun(tokenRun)) return;
733
+ // Line/template repetition: a structured loop (e.g. a log-line loop where
734
+ // every line differs at the token level — incrementing timestamps, cycling
735
+ // names) is invisible to both the char and token runs. Track the recent
736
+ // tail and, when a line completes (a newline arrives), count consecutive
737
+ // lines sharing one normalized structure. Recomputed only on newline
738
+ // deltas; the trailing partial line is ignored by trailingLineRun.
739
+ let lineRun: LineRun = { template: "", count: 0 };
740
+ if (ame.delta.includes("\n")) {
741
+ _lineRepeatBuffer = (_lineRepeatBuffer + ame.delta).slice(
742
+ -LINE_REPEAT_BUFFER_CHARS,
743
+ );
744
+ lineRun = trailingLineRun(_lineRepeatBuffer);
745
+ }
746
+
747
+ // Trailing-unit repetition: a short unit repeated under ANY delimiter (or
748
+ // none) — e.g. "{},{},{}" (unit "{},"), "();();();" (unit "();") — is
749
+ // invisible to the char run (the separator resets it) and the token run
750
+ // (whitespace-only splitter). This general detector catches it by finding
751
+ // any short period whose trailing blocks repeat. Runs every delta; cheap
752
+ // on normal text (each candidate mismatches its first block).
753
+ _unitRepeatBuffer = (_unitRepeatBuffer + ame.delta).slice(
754
+ -UNIT_REPEAT_BUFFER_CHARS,
755
+ );
756
+ const unitRun = trailingUnitRun(_unitRepeatBuffer);
757
+
758
+ if (
759
+ !isDegenerateRun(run) &&
760
+ !isDegenerateTokenRun(tokenRun) &&
761
+ !isDegenerateLineRun(lineRun) &&
762
+ !isDegenerateUnitRun(unitRun)
763
+ ) {
764
+ return;
765
+ }
524
766
 
525
767
  // Trip: abort the runaway stream and kick off the recovery loop (once,
526
768
  // mutex-gated). The loop driver handles re-detection on resumed turns.
@@ -529,7 +771,7 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
529
771
  const agent = _agent;
530
772
  if (!agent) {
531
773
  ctx.ui.notify(
532
- "Makora death-loop guard: runaway repeated-character output detected " +
774
+ "Makora death-loop guard: runaway repetition loop detected " +
533
775
  "but the Agent instance was not captured; cannot recover automatically.",
534
776
  "warning",
535
777
  );
@@ -561,4 +803,53 @@ export function registerDeathLoopGuard(pi: ExtensionAPI): void {
561
803
  _notifyFn = (message, level) => ctx.ui.notify(message, level);
562
804
  }
563
805
  });
806
+
807
+ // Remove degenerate output from the agent's persisted transcript. When a
808
+ // finalized assistant message is a death loop (or one we aborted), replace
809
+ // its content with a clean stub BEFORE it is saved to the session file /
810
+ // shown in the TUI. Trimming state.messages only cleans the in-memory
811
+ // continuation context; this cleans the saved record too, so the toxic text
812
+ // can't bias a later /resume. (The Agent pushes the original message to
813
+ // state.messages before this event fires; trimAbortedDeathLoop drops it.)
814
+ pi.on("message_end", (event) => {
815
+ const msg = event.message as GuardedMessage;
816
+ if (msg.role !== "assistant") return;
817
+ if (!isGuardedMessage(msg)) return;
818
+ if (!isDeathLoopMessage(msg) && !_weAborted) return;
819
+ return {
820
+ message: {
821
+ ...event.message,
822
+ content: [{ type: "text", text: DEATH_LOOP_STUB_TEXT }],
823
+ },
824
+ };
825
+ });
826
+
827
+ // Defense-in-depth: before each LLM call, strip any degenerate (or stub)
828
+ // assistant message from the trailing messages so the model never re-sees
829
+ // toxic output — covering user-prompt turns and resumed sessions even when a
830
+ // loop completed without being aborted. The recovery's direct
831
+ // agent.prompt([]) bypasses this event (the state.messages trim covers that
832
+ // path). Only the last ~32 messages are scanned: degenerate output, if
833
+ // present, is recent, and stubs left in context are clean (non-biasing).
834
+ pi.on("context", (event) => {
835
+ const messages = event.messages;
836
+ if (!messages || messages.length === 0) return;
837
+ const scanFrom = Math.max(0, messages.length - 32);
838
+ let changed = false;
839
+ const filtered = messages.filter((m, i) => {
840
+ if (i < scanFrom || m.role !== "assistant") return true;
841
+ const text = extractText((m as GuardedMessage).content);
842
+ const isToxic =
843
+ text.length >= 1000 &&
844
+ isGuardedMessage(m as GuardedMessage) &&
845
+ isDeathLoopMessage(m as GuardedMessage);
846
+ if (isToxic || text === DEATH_LOOP_STUB_TEXT) {
847
+ changed = true;
848
+ return false;
849
+ }
850
+ return true;
851
+ });
852
+ if (!changed) return;
853
+ return { messages: filtered };
854
+ });
564
855
  }
package/index.ts CHANGED
@@ -21,12 +21,14 @@
21
21
  * - Llama 3.3 70B: not a reasoning model.
22
22
  *
23
23
  * A death-loop guard (see ./death-loop-guard.ts) is registered alongside the
24
- * provider. It watches the assistant text stream on the GLM 5.2 family and,
25
- * if the model falls into a degenerate repetition loop — an unbroken run of a
26
- * single character or token, spaced or not (observed: `!!!!`, `0000`, `0 0 0`)
27
- * — it aborts the runaway generation and resumes the agentic loop invisibly
28
- * via agent.prompt([]) (the pi-invisible-continue pattern); no new user
29
- * message is injected.
24
+ * provider. It watches the assistant text stream on the GLM 5.2 family with
25
+ * four detectors — character run, token run, trailing-unit run, and
26
+ * normalized-line run — and, if the model falls into a degenerate repetition
27
+ * loop (observed: `!!!!`, `0000`, `0 0 0`, `{},{},{}`, and a structured
28
+ * log-line loop), it aborts the runaway generation, removes the toxic message
29
+ * from the agent's transcript (so it can't bias later turns), and resumes the
30
+ * agentic loop invisibly via agent.prompt([]) (the pi-invisible-continue
31
+ * pattern); no new user message is injected.
30
32
  *
31
33
  * Developer role is NOT supported by any of the chat templates on Makora's
32
34
  * vLLM deployment (prompts with role: "developer" are silently dropped).
@@ -47,6 +49,9 @@
47
49
  */
48
50
 
49
51
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
52
+ import type { SimpleStreamOptions, AssistantMessageEventStream } from "@earendil-works/pi-ai";
53
+ import { clampThinkingLevel } from "@earendil-works/pi-ai";
54
+ import { streamOpenAICompletions } from "@earendil-works/pi-ai/compat";
50
55
  import modelsData from "./models.json" with { type: "json" };
51
56
  import customModelsData from "./custom-models.json" with { type: "json" };
52
57
  import patchData from "./patch.json" with { type: "json" };
@@ -91,6 +96,13 @@ interface JsonModel {
91
96
  requiresToolResultName?: boolean;
92
97
  requiresAssistantAfterToolResult?: boolean;
93
98
  cacheControlFormat?: "anthropic";
99
+ /** When set, onPayload copies each assistant message's `reasoning` field into
100
+ * this field name before sending. Needed when a model's chat template reads
101
+ * prior reasoning from a different field than the one pi-ai sends it in.
102
+ * e.g. Kimi K2.7's template reads `reasoning_content`, but pi-ai sends
103
+ * `reasoning` (the field the model returns) — without the copy, preserve_thinking
104
+ * can't render the prior trace. */
105
+ assistantReasoningField?: string;
94
106
  /** Extra keys merged into vLLM `chat_template_kwargs` on every request.
95
107
  * Used for preserved-thinking flags like `preserve_thinking` / `clear_thinking`
96
108
  * that some chat templates require for multi-turn reasoning continuity. */
@@ -192,6 +204,177 @@ function buildModels(
192
204
  return Array.from(modelMap.values());
193
205
  }
194
206
 
207
+ // Thinking-off + preserved-thinking via streamSimple
208
+ //
209
+ // Makora's reasoning models need BOTH a thinking on/off switch AND multi-turn
210
+ // reasoning continuity (`preserve_thinking`). pi-ai's built-in `thinkingFormat`
211
+ // branches are mutually exclusive: the `chat-template` branch emits
212
+ // `chat_template_kwargs` (so it can carry `preserve_thinking`) but never a
213
+ // top-level `reasoning_effort`; the OpenAI fallback emits `reasoning_effort`
214
+ // (the lever GLM 5.2 and Qwen 3.6 actually respond to for off) but never
215
+ // `chat_template_kwargs`. No single format emits both.
216
+ //
217
+ // Behavioral E2E (see test-thinking-triggers.ts / test-preserve-deterministic.ts):
218
+ // GLM 5.2 — off ONLY via top-level `reasoning_effort: "none"`;
219
+ // `enable_thinking`/`thinking`/chat_template_kwargs toggles are ignored.
220
+ // Multi-turn continuity uses `clear_thinking: false` (NOT preserve_thinking
221
+ // — that flag is inert for GLM); toggled with the thinking switch
222
+ // (false when on = preserve, true when off = clear).
223
+ // Qwen 3.6 — off via top-level `reasoning_effort: "none"` (or `enable_thinking`).
224
+ // Multi-turn continuity uses `preserve_thinking: true`; reads the
225
+ // `reasoning` field pi-ai sends. Toggled with the thinking switch.
226
+ // Kimi K2.7 — off via namespaced `chat_template_kwargs.thinking: false`.
227
+ // Multi-turn continuity uses `preserve_thinking: true`, BUT the
228
+ // template reads `reasoning_content` while pi-ai sends `reasoning`
229
+ // (Makora doesn't gateway-alias the fields) — so onPayload also
230
+ // copies each assistant message's `reasoning` → `reasoning_content`.
231
+ // preserve_thinking IS functional (deterministic E2E: Qwen 3.6 27B recalls
232
+ // 1.00 with it on, 0.00 off) — it gates whether the prior reasoning trace
233
+ // is rendered into the next turn's prompt.
234
+ //
235
+ // So this provider registers a `streamSimple` wrapper that delegates to pi-ai's
236
+ // `streamOpenAICompletions` (keeping all its streaming/tool-calling/caching) and
237
+ // uses pi-ai's `onPayload` hook — which runs AFTER `buildParams` — to inject the
238
+ // `chat_template_kwargs` that the chosen `thinkingFormat` branch can't reach.
239
+ // `buildParams` still owns `reasoning_effort` (on → mapped effort; off → "none");
240
+ // `onPayload` owns `chat_template_kwargs` (preserve_thinking + Kimi's `thinking`).
241
+ // Per-model config lives in `compat.chatTemplateKwargs` (patch.json), using the
242
+ // same `{ "$var": "thinking.enabled" }` schema pi-ai's chat-template format uses.
243
+
244
+ /** Resolve one `chatTemplateKwargs` value (scalar or `{ $var }`) against the
245
+ * current thinking state. Mirrors pi-ai's `resolveChatTemplateKwargValue` so
246
+ * onPayload-injected values match what the built-in chat-template format would
247
+ * produce — but injected after buildParams so they coexist with reasoning_effort. */
248
+ export function resolveChatTemplateKwarg(
249
+ value: unknown,
250
+ model: JsonModel,
251
+ thinkingOn: boolean,
252
+ reasoningEffort: string | undefined,
253
+ ): unknown {
254
+ // Scalars pass through unchanged (e.g. static `preserve_thinking: true`).
255
+ if (value === null || typeof value !== "object") {
256
+ return value;
257
+ }
258
+ const spec = value as { $var?: string; omitWhenOff?: boolean; invert?: boolean };
259
+ if (spec.omitWhenOff && !thinkingOn) {
260
+ return undefined;
261
+ }
262
+ if (spec.$var === "thinking.enabled") {
263
+ return spec.invert ? !thinkingOn : thinkingOn;
264
+ }
265
+ if (spec.$var === "thinking.effort") {
266
+ const mapped = thinkingOn
267
+ ? model.thinkingLevelMap?.[reasoningEffort as string]
268
+ : model.thinkingLevelMap?.off;
269
+ if (mapped === undefined) return reasoningEffort;
270
+ return typeof mapped === "string" ? mapped : undefined;
271
+ }
272
+ return undefined;
273
+ }
274
+
275
+ /** Custom streamSimple: delegate to pi-ai's OpenAI completions streamer and
276
+ * inject per-model `chat_template_kwargs` (preserve_thinking / clear_thinking +
277
+ * Kimi's thinking toggle) via the onPayload hook, and alias each assistant
278
+ * message's reasoning field when a template reads a different field name.
279
+ * Models with neither chatTemplateKwargs nor assistantReasoningField (DeepSeek,
280
+ * Llama, Gemma) pass through unchanged — onPayload is not registered. */
281
+ export function streamMakora(
282
+ model: any,
283
+ context: any,
284
+ options?: SimpleStreamOptions,
285
+ ): AssistantMessageEventStream {
286
+ const apiKey = options?.apiKey || "";
287
+ if (!apiKey) {
288
+ throw new Error(
289
+ `No API key for Makora. Add it to ~/.pi/agent/auth.json under "makora", ` +
290
+ `set the MAKORA_OPTIMIZE_TOKEN env var, or use --api-key.`,
291
+ );
292
+ }
293
+
294
+ // pi-ai's streamer reads `model.api` to pick the OpenAI completions client. Our
295
+ // provider registers under `api: "makora"` (so pi routes to this streamSimple);
296
+ // override to `openai-completions` here so streamOpenAICompletions uses the
297
+ // standard client. Per-model baseUrl overrides (per-slug endpoints) are kept.
298
+ const makoraModel = { ...model, api: "openai-completions", baseUrl: model.baseUrl || BASE_URL };
299
+
300
+ // pi hands streamSimple providers the raw thinking selection as
301
+ // `options.reasoning` (a ThinkingLevel). The raw streamOpenAICompletions only
302
+ // reads `options.reasoningEffort`, so replicate the clamp+convert pi-ai's own
303
+ // streamSimple wrapper does — otherwise reasoning_effort never reaches the
304
+ // body and thinking levels silently do nothing. "off" → undefined (off).
305
+ const clampedReasoning = options?.reasoning
306
+ ? clampThinkingLevel(makoraModel, options.reasoning)
307
+ : undefined;
308
+ const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
309
+ const thinkingOn = reasoningEffort !== undefined;
310
+ const { reasoning: _reasoning, ...streamOptions } = options ?? {};
311
+
312
+ // Inject chat_template_kwargs after buildParams via onPayload. Any caller-
313
+ // supplied onPayload is chained first so it can inspect/replace the payload;
314
+ // our injection then merges into whatever chat_template_kwargs already exist.
315
+ // We also register onPayload when the model needs its prior-reasoning field
316
+ // aliased (assistantReasoningField) — e.g. Kimi's template reads
317
+ // `reasoning_content` but pi-ai sends `reasoning`; the copy makes the
318
+ // preserved trace visible to the template so preserve_thinking can render it.
319
+ const userOnPayload = (streamOptions as any).onPayload;
320
+ const extraKwargs = (makoraModel as JsonModel).compat?.chatTemplateKwargs;
321
+ const hasExtraKwargs =
322
+ !!extraKwargs && typeof extraKwargs === "object" && Object.keys(extraKwargs).length > 0;
323
+ const assistantReasoningField = (makoraModel as JsonModel).compat?.assistantReasoningField;
324
+ const needsFieldCopy = !!assistantReasoningField;
325
+ const onPayload =
326
+ hasExtraKwargs || needsFieldCopy || userOnPayload
327
+ ? async (params: any, mdl: any) => {
328
+ let p = params;
329
+ if (userOnPayload) {
330
+ const next = await userOnPayload(p, mdl);
331
+ if (next !== undefined) p = next;
332
+ }
333
+ if (needsFieldCopy && thinkingOn) {
334
+ // Only alias the prior reasoning field when thinking is ON — when the
335
+ // user turns thinking off we must NOT carry prior reasoning forward
336
+ // (preserve_thinking is inert for Kimi's field rendering; the field's
337
+ // presence is what gates continuity). Kimi's template ignores the
338
+ // `reasoning` field, so skipping the copy leaves nothing to render.
339
+ const field = assistantReasoningField!;
340
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
341
+ p = {
342
+ ...p,
343
+ messages: msgs.map((m: any) =>
344
+ m && m.role === "assistant" &&
345
+ typeof m.reasoning === "string" && m.reasoning.length > 0 &&
346
+ m[field] === undefined
347
+ ? { ...m, [field]: m.reasoning }
348
+ : m
349
+ ),
350
+ };
351
+ }
352
+ if (hasExtraKwargs) {
353
+ const resolved: Record<string, unknown> = {};
354
+ for (const [k, v] of Object.entries(extraKwargs!)) {
355
+ const r = resolveChatTemplateKwarg(v, makoraModel, thinkingOn, reasoningEffort as string | undefined);
356
+ if (r !== undefined) resolved[k] = r;
357
+ }
358
+ p = {
359
+ ...p,
360
+ chat_template_kwargs: {
361
+ ...(p?.chat_template_kwargs ?? {}),
362
+ ...resolved,
363
+ },
364
+ };
365
+ }
366
+ return p;
367
+ }
368
+ : undefined;
369
+
370
+ return streamOpenAICompletions(makoraModel, context, {
371
+ ...streamOptions,
372
+ reasoningEffort,
373
+ apiKey,
374
+ ...(onPayload ? { onPayload } : {}),
375
+ });
376
+ }
377
+
195
378
  // Extension Entry Point
196
379
 
197
380
  const PROVIDER_ID = "makora";
@@ -206,17 +389,23 @@ const allMakoraModels = buildModels(
206
389
  export default function (pi: ExtensionAPI) {
207
390
  const models = allMakoraModels;
208
391
 
209
- // apiKey resolution order: auth.json ("makora" key) → MAKORA_OPTIMIZE_TOKEN env var
392
+ // apiKey resolution order: auth.json ("makora" key) → MAKORA_OPTIMIZE_TOKEN env var.
393
+ // `api: "makora"` + `streamSimple` routes every Makora model through streamMakora
394
+ // (above), which delegates to pi-ai's OpenAI completions streamer and injects
395
+ // per-model chat_template_kwargs via onPayload.
210
396
  pi.registerProvider(PROVIDER_ID, {
211
397
  name: "Makora",
212
398
  baseUrl: BASE_URL,
213
399
  apiKey: "$MAKORA_OPTIMIZE_TOKEN",
214
- api: "openai-completions",
400
+ api: "makora",
401
+ streamSimple: streamMakora,
215
402
  models,
216
403
  });
217
404
 
218
- // Abort runaway repetition loops — a single character or token, spaced or
219
- // not (e.g. '!!!!', '0000', '0 0 0') — on the GLM 5.2 family and resume the
220
- // agentic loop invisibly (no new user message). See ./death-loop-guard.ts.
405
+ // Abort runaway repetition loops — a single character, a spaced token, a
406
+ // delimiter-joined unit, or a structured line/template loop (e.g. '!!!!',
407
+ // '0000', '0 0 0', '{},{}', a log-line loop) — on the GLM 5.2 family; remove
408
+ // the toxic output from the agent's transcript and resume the agentic loop
409
+ // invisibly (no new user message). See ./death-loop-guard.ts.
221
410
  registerDeathLoopGuard(pi);
222
411
  }
package/models.json CHANGED
@@ -159,7 +159,7 @@
159
159
  "cacheRead": 0,
160
160
  "cacheWrite": 0
161
161
  },
162
- "contextWindow": 1048576,
162
+ "contextWindow": 980000,
163
163
  "maxTokens": 0,
164
164
  "compat": {
165
165
  "supportsDeveloperRole": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-makora-provider",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Makora provider extension for pi - Access DeepSeek V4, GLM 5.2, Kimi K2.7 Code, Llama 3.3, Qwen 3.6, and more through the Makora inference API",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/patch.json CHANGED
@@ -32,31 +32,31 @@
32
32
  },
33
33
  "unsloth/Qwen3.6-27B-NVFP4": {
34
34
  "reasoning": true,
35
- "notes": "`enable_thinking` via `qwen-chat-template`; `preserve_thinking` via `chatTemplateKwargs` for multi-turn continuity; returns `reasoning` field",
35
+ "notes": "off via top-level `reasoning_effort: \"none\"`; `preserve_thinking` (toggled with thinking switch) injected via onPayload for multi-turn continuity; returns `reasoning` field",
36
36
  "thinkingLevelMap": {
37
37
  "minimal": "low",
38
- "xhigh": "high"
38
+ "xhigh": "high",
39
+ "off": "none"
39
40
  },
40
41
  "compat": {
41
- "thinkingFormat": "qwen-chat-template",
42
42
  "supportsReasoningEffort": true,
43
43
  "chatTemplateKwargs": {
44
- "preserve_thinking": true
44
+ "preserve_thinking": { "$var": "thinking.enabled" }
45
45
  }
46
46
  }
47
47
  },
48
48
  "unsloth/Qwen3.6-35B-A3B-NVFP4": {
49
49
  "reasoning": true,
50
- "notes": "`enable_thinking` via `qwen-chat-template`; `preserve_thinking` via `chatTemplateKwargs` for multi-turn continuity; returns `reasoning` field",
50
+ "notes": "off via top-level `reasoning_effort: \"none\"`; `preserve_thinking` (toggled with thinking switch) injected via onPayload for multi-turn continuity; returns `reasoning` field",
51
51
  "thinkingLevelMap": {
52
52
  "minimal": "low",
53
- "xhigh": "high"
53
+ "xhigh": "high",
54
+ "off": "none"
54
55
  },
55
56
  "compat": {
56
- "thinkingFormat": "qwen-chat-template",
57
57
  "supportsReasoningEffort": true,
58
58
  "chatTemplateKwargs": {
59
- "preserve_thinking": true
59
+ "preserve_thinking": { "$var": "thinking.enabled" }
60
60
  }
61
61
  }
62
62
  },
@@ -66,47 +66,54 @@
66
66
  "text",
67
67
  "image"
68
68
  ],
69
- "notes": "Reasoning on by default (thinking-only model); `preserve_thinking` via `chatTemplateKwargs` for multi-turn continuity; returns `reasoning` field",
69
+ "notes": "on/off via namespaced `chat_template_kwargs.thinking` (toggled with thinking switch); `preserve_thinking` (toggled) for multi-turn continuity; onPayload aliases each assistant message's `reasoning` → `reasoning_content` (Kimi's template reads `reasoning_content` but pi-ai sends `reasoning`); returns `reasoning` field. `reasoning_effort` is not emitted (Kimi's template reads namespaced `thinking`, not `reasoning_effort`)",
70
70
  "thinkingLevelMap": {
71
71
  "minimal": "low",
72
72
  "xhigh": "high"
73
73
  },
74
74
  "compat": {
75
- "thinkingFormat": "qwen-chat-template",
76
- "supportsReasoningEffort": true,
75
+ "supportsReasoningEffort": false,
76
+ "assistantReasoningField": "reasoning_content",
77
77
  "chatTemplateKwargs": {
78
- "preserve_thinking": true
78
+ "thinking": { "$var": "thinking.enabled" },
79
+ "preserve_thinking": { "$var": "thinking.enabled" }
79
80
  }
80
81
  }
81
82
  },
82
83
  "zai-org/GLM-5.2-FP8": {
83
84
  "reasoning": true,
84
- "notes": "`enable_thinking` via `qwen-chat-template`; effort via `reasoning_effort` (only `high`/`max` distinct, per vLLM GLM-5.2 recipe); thinking levels aligned with neuralwatt GLM 5.2; returns `reasoning` field",
85
+ "notes": "off via top-level `reasoning_effort: \"none\"` (only lever GLM responds to; `enable_thinking`/`thinking`/chat_template_kwargs toggles are ignored); effort `high`/`max` only (minimal/low/medium clamp to `high`); multi-turn continuity via `clear_thinking` (false when thinking on = preserve, true when off = clear; `preserve_thinking` is inert for GLM) injected via onPayload; returns `reasoning` field",
85
86
  "thinkingLevelMap": {
86
87
  "minimal": null,
87
88
  "low": null,
88
89
  "medium": null,
89
90
  "high": "high",
90
- "xhigh": "max"
91
+ "xhigh": "max",
92
+ "off": "none"
91
93
  },
92
94
  "compat": {
93
- "thinkingFormat": "qwen-chat-template",
94
- "supportsReasoningEffort": true
95
+ "supportsReasoningEffort": true,
96
+ "chatTemplateKwargs": {
97
+ "clear_thinking": { "$var": "thinking.enabled", "invert": true }
98
+ }
95
99
  }
96
100
  },
97
101
  "zai-org/GLM-5.2-NVFP4": {
98
102
  "reasoning": true,
99
- "notes": "`enable_thinking` via `qwen-chat-template`; effort via `reasoning_effort` (only `high`/`max` distinct, per vLLM GLM-5.2 recipe); returns `reasoning` field",
103
+ "notes": "off via top-level `reasoning_effort: \"none\"` (only lever GLM responds to; `enable_thinking`/`thinking`/chat_template_kwargs toggles are ignored); effort `high`/`max` only (minimal/low/medium clamp to `high`); multi-turn continuity via `clear_thinking` (false when thinking on = preserve, true when off = clear; `preserve_thinking` is inert for GLM) injected via onPayload; returns `reasoning` field",
100
104
  "thinkingLevelMap": {
101
105
  "minimal": null,
102
106
  "low": null,
103
107
  "medium": null,
104
108
  "high": "high",
105
- "xhigh": "max"
109
+ "xhigh": "max",
110
+ "off": "none"
106
111
  },
107
112
  "compat": {
108
- "thinkingFormat": "qwen-chat-template",
109
- "supportsReasoningEffort": true
113
+ "supportsReasoningEffort": true,
114
+ "chatTemplateKwargs": {
115
+ "clear_thinking": { "$var": "thinking.enabled", "invert": true }
116
+ }
110
117
  }
111
118
  }
112
119
  }