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 +34 -18
- package/death-loop-guard.ts +314 -23
- package/index.ts +200 -11
- package/models.json +1 -1
- package/package.json +1 -1
- package/patch.json +27 -20
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 |
|
|
25
|
-
| GLM 5.2 NVFP4 | `zai-org/GLM-5.2-NVFP4` | Yes |
|
|
26
|
-
| Kimi K2.7 Code | `moonshotai/Kimi-K2.7-Code` | Yes |
|
|
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 |
|
|
30
|
-
| Qwen 3.6 35B A3B NVFP4 | `unsloth/Qwen3.6-35B-A3B-NVFP4` | Yes |
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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. |
|
package/death-loop-guard.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
25
|
-
* assistant message WITH its accumulated
|
|
26
|
-
* "aborted" into the transcript
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
408
|
-
`
|
|
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
|
-
|
|
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
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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: "
|
|
400
|
+
api: "makora",
|
|
401
|
+
streamSimple: streamMakora,
|
|
215
402
|
models,
|
|
216
403
|
});
|
|
217
404
|
|
|
218
|
-
// Abort runaway repetition loops — a single character
|
|
219
|
-
//
|
|
220
|
-
//
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-makora-provider",
|
|
3
|
-
"version": "1.
|
|
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": "
|
|
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":
|
|
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": "
|
|
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":
|
|
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": "
|
|
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
|
-
"
|
|
76
|
-
"
|
|
75
|
+
"supportsReasoningEffort": false,
|
|
76
|
+
"assistantReasoningField": "reasoning_content",
|
|
77
77
|
"chatTemplateKwargs": {
|
|
78
|
-
"
|
|
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": "
|
|
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
|
-
"
|
|
94
|
-
"
|
|
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": "
|
|
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
|
-
"
|
|
109
|
-
"
|
|
113
|
+
"supportsReasoningEffort": true,
|
|
114
|
+
"chatTemplateKwargs": {
|
|
115
|
+
"clear_thinking": { "$var": "thinking.enabled", "invert": true }
|
|
116
|
+
}
|
|
110
117
|
}
|
|
111
118
|
}
|
|
112
119
|
}
|