pi-neuralwatt-provider 1.2.3 → 1.3.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
@@ -38,8 +38,6 @@ _Kimi, GLM, Qwen, DeepSeek — with real-time ⚡ energy/cost per session for [p
38
38
  | GLM-5.2 (short, fast) | 200K | ❌ | ❌ | $1.45 | $4.50 |
39
39
  | GLM-5.2 (short, flex) | 200K | ❌ | ✅ | $1.45 | $4.50 |
40
40
  | GLM-5.2 (short) | 200K | ❌ | ✅ | $1.45 | $4.50 |
41
- | Kimi K2.5 | 262K | ✅ | ✅ | $0.52 | $2.59 |
42
- | Kimi K2.5 Fast | 262K | ✅ | ❌ | $0.52 | $2.59 |
43
41
  | Kimi K2.6 | 262K | ✅ | ✅ | $0.69 | $3.22 |
44
42
  | Kimi K2.6 (flex) | 262K | ✅ | ✅ | $0.69 | $3.22 |
45
43
  | Kimi K2.6 Fast | 262K | ✅ | ❌ | $0.69 | $3.22 |
@@ -137,12 +135,19 @@ pi
137
135
 
138
136
  ### Compat Settings
139
137
 
140
- Neuralwatt's API now provides compatibility and capability metadata (pricing, reasoning, vision, developer_role, reasoning_effort, max_images) directly in the `/v1/models` response. The `update-models.js` script reads these and writes them into `models.json`. Only genuinely incorrect API data needs a manual override in `patch.json`.
138
+ Neuralwatt's API provides compatibility and capability metadata (pricing, reasoning, vision, `developer_role`, `reasoning_effort`, `max_images`) directly in the `/v1/models` response. The `update-models.js` script reads these and writes them into `models.json`. Only genuinely incorrect API data needs a manual override in `patch.json`.
141
139
 
142
- Currently configured compat settings (all sourced from the API):
140
+ Currently configured compat settings:
143
141
 
144
142
  - **`supportsDeveloperRole: false`** — All models. vLLM doesn't support the `developer` role; pi sends system prompts as `system` messages instead.
145
- - **`supportsReasoningEffort: true`** — GPT-OSS. Sends `reasoning_effort` parameter (maps to pi's `/reasoning` command levels).
143
+ - **`supportsReasoningEffort: true`** — GLM-5.2. Sends the `reasoning_effort` parameter (maps pi's `/reasoning` levels onto GLM-5.2's native `high`/`max`/`minimal` via `thinkingLevelMap`).
144
+ - **`requiresReasoningContentOnAssistantMessages: true`** — Kimi K2.6/K2.7 reasoning variants. Pi-ai replays the model's prior-turn `reasoning` field on every assistant message so the model can continue its chain-of-thought across turns. All Neuralwatt reasoning models get this Layer-A replay automatically (the gateway aliases `reasoning` ↔ `reasoning_content`); this flag adds an empty `reasoning_content` scaffold for turns with no thinking block.
145
+ - **`chatTemplateKwargs`** — Raw `chat_template_kwargs` merged into every request via pi-ai's `onPayload` hook, mirroring vLLM's request field of the same name. Used to opt reasoning models into **full-history reasoning preservation** (vLLM's Jinja templates otherwise trim older assistant reasoning in alternating chat). The flags are template-level and family-specific — NOT a generic boolean:
146
+ - **Kimi K2.6 / K2.7** → `{ "preserve_thinking": true }` — keeps the full reasoning history across turns (doc-backed; behavioral E2E: 0/6 → 6/6 recall).
147
+ - **GLM-5.2 family** → `{ "clear_thinking": false }` — stops the template clearing older reasoning (functional: 1/4 → 4/4 recall, confirmed family-wide).
148
+ - **GLM-5.1 / Qwen3.x / non-reasoning `-fast`** — no kwarg (their templates expose no flag; they rely on Layer-A replay only).
149
+
150
+ These are injected alongside `reasoning_effort` (NOT via `thinkingFormat: "chat-template"`, which would displace the OpenAI `reasoning_effort` path) so thinking-level control and full-history preservation coexist.
146
151
 
147
152
  ### Custom Stream Handler
148
153
 
@@ -180,6 +185,8 @@ For reasoning models, control thinking depth:
180
185
 
181
186
  Values: `none`, `low`, `medium`, `high`
182
187
 
188
+ Full-history reasoning preservation is **on by default** for Kimi K2.6/K2.7 and the GLM-5.2 family (see [Compat Settings](#compat-settings)). Override it per-model via [Model Overrides](#model-overrides).
189
+
183
190
  ## Display Configuration
184
191
 
185
192
  Energy and quota are independently configurable. Create `~/.pi/agent/extensions/neuralwatt.json`:
@@ -213,6 +220,29 @@ The file is auto-populated with defaults on first run.
213
220
  }
214
221
  ```
215
222
 
223
+ ### Model Overrides
224
+
225
+ `modelOverrides` lets you override compat flags and other model properties per model id, **on top of** `patch.json` + `custom-models.json`, without editing the extension. Keyed by model id; `compat` and `thinkingLevelMap` are deep-merged (toggle one flag without redeclaring the rest), scalars are replaced. Applied at session start, so edits take effect on the next `pi` session.
226
+
227
+ ```jsonc
228
+ {
229
+ "energy": "widget",
230
+ "quota": "widget",
231
+ "mcr": "widget",
232
+ "hideOnOtherProvider": false,
233
+ "modelOverrides": {
234
+ // Disable full-history reasoning for kimi-k2.6 (e.g. to save tokens):
235
+ "kimi-k2.6": { "compat": { "chatTemplateKwargs": { "preserve_thinking": false } } },
236
+ // Override a single thinking level without redeclaring the map:
237
+ "glm-5.2": { "thinkingLevelMap": { "high": "max" }, "compat": { "chatTemplateKwargs": { "clear_thinking": true } } },
238
+ // Force a smaller image cap:
239
+ "kimi-k2.7-code": { "vision": { "maxImagesPerRequest": 4 } }
240
+ }
241
+ }
242
+ ```
243
+
244
+ The full set of overridable fields matches the model schema (`compat`, `thinkingLevelMap`, `vision`, `cost`, `contextWindow`, `maxTokens`, `reasoning`, `input`). See [Compat Settings](#compat-settings) for the catalog of compat flags and what `chatTemplateKwargs` values mean per family.
245
+
216
246
  ## Energy Reporting
217
247
 
218
248
  Neuralwatt provides real-time energy consumption data with every API response. This extension captures it and displays a running total in a dedicated status widget between the editor and the pi footer:
package/index.ts CHANGED
@@ -58,7 +58,8 @@
58
58
  */
59
59
 
60
60
  import type { SimpleStreamOptions } from "@earendil-works/pi-ai";
61
- import { clampThinkingLevel, streamOpenAICompletions } from "@earendil-works/pi-ai";
61
+ import { clampThinkingLevel } from "@earendil-works/pi-ai";
62
+ import { streamOpenAICompletions } from "@earendil-works/pi-ai/compat";
62
63
  import type { AssistantMessageEventStream } from "@earendil-works/pi-ai";
63
64
  import type { ExtensionAPI, ModelRegistry } from "@earendil-works/pi-coding-agent";
64
65
  import modelsData from "./models.json" with { type: "json" };
@@ -80,6 +81,17 @@ interface NeuralwattConfig {
80
81
  // When true, hide energy/quota/MCR display if the active model's provider
81
82
  // is not "neuralwatt". Prevents stale display after switching providers.
82
83
  hideOnOtherProvider: boolean;
84
+ // Per-model overrides applied ON TOP of patch.json + custom-models.json, keyed
85
+ // by model id. Lets a user override compat flags (e.g. toggle
86
+ // chat_template_kwargs) without editing the extension. Deep-merges `compat`
87
+ // and `thinkingLevelMap`; replaces scalars. See README "Model Overrides".
88
+ modelOverrides?: Record<string, ModelOverride>;
89
+ }
90
+
91
+ interface ModelOverride {
92
+ thinkingLevelMap?: Record<string, string | null>;
93
+ compat?: Record<string, any>;
94
+ vision?: { maxImagesPerRequest?: number };
83
95
  }
84
96
 
85
97
  const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "extensions", "neuralwatt.json");
@@ -101,6 +113,7 @@ function loadConfig(): NeuralwattConfig {
101
113
  quota: parseDisplayMode(raw.quota, "widget"),
102
114
  mcr: parseDisplayMode(raw.mcr, "widget"),
103
115
  hideOnOtherProvider: typeof raw.hideOnOtherProvider === "boolean" ? raw.hideOnOtherProvider : false,
116
+ modelOverrides: parseModelOverrides(raw.modelOverrides),
104
117
  };
105
118
  } catch {
106
119
  // Config file missing or invalid — populate with defaults so the user can discover it
@@ -114,6 +127,29 @@ function loadConfig(): NeuralwattConfig {
114
127
  }
115
128
  }
116
129
 
130
+ // Validate user-supplied modelOverrides from the config file. Non-object / non-string
131
+ // ids are dropped silently so a malformed file doesn't crash model registration.
132
+ function parseModelOverrides(raw: unknown): Record<string, ModelOverride> | undefined {
133
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
134
+ const result: Record<string, ModelOverride> = {};
135
+ for (const [id, override] of Object.entries(raw as Record<string, unknown>)) {
136
+ if (typeof id !== "string" || !override || typeof override !== "object" || Array.isArray(override)) continue;
137
+ const o = override as Record<string, unknown>;
138
+ const parsed: ModelOverride = {};
139
+ if (o.thinkingLevelMap && typeof o.thinkingLevelMap === "object") {
140
+ const m: Record<string, string | null> = {};
141
+ for (const [k, v] of Object.entries(o.thinkingLevelMap as Record<string, unknown>)) {
142
+ if (v === null || typeof v === "string") m[k] = v;
143
+ }
144
+ if (Object.keys(m).length > 0) parsed.thinkingLevelMap = m;
145
+ }
146
+ if (o.compat && typeof o.compat === "object") parsed.compat = o.compat as Record<string, any>;
147
+ if (o.vision && typeof o.vision === "object") parsed.vision = o.vision as { maxImagesPerRequest?: number };
148
+ if (Object.keys(parsed).length > 0) result[id] = parsed;
149
+ }
150
+ return Object.keys(result).length > 0 ? result : undefined;
151
+ }
152
+
117
153
  let config = loadConfig();
118
154
 
119
155
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -146,6 +182,17 @@ interface NeuralwattModel {
146
182
  supportsReasoningEffort?: boolean;
147
183
  requiresAssistantAfterToolResult?: boolean;
148
184
  requiresReasoningContentOnAssistantMessages?: boolean;
185
+ // Raw chat_template_kwargs merged into every chat-completions request via
186
+ // pi-ai's onPayload hook. Mirrors vLLM's request field of the same name; the
187
+ // values are template-level flags the model's Jinja chat template reads, so
188
+ // they're model-family-specific (NOT a generic boolean). Injected alongside
189
+ // reasoning_effort (not via thinkingFormat: "chat-template" — that branch is
190
+ // mutually exclusive with the openai reasoning_effort path). Behavioral E2E:
191
+ // Kimi K2.6/K2.7 → { "preserve_thinking": true } (template keeps full reasoning history across turns)
192
+ // GLM-5.x family → { "clear_thinking": false } (template stops clearing older assistant reasoning)
193
+ // GLM-5.1 / Qwen3.x expose no family-wide flag; Layer-A replay (the `reasoning`
194
+ // field, aliased reasoning <-> reasoning_content by the gateway) still applies to all.
195
+ chatTemplateKwargs?: Record<string, string | number | boolean | null>;
149
196
  };
150
197
  vision?: {
151
198
  maxImagesPerRequest?: number;
@@ -176,11 +223,30 @@ function applyPatch(model: NeuralwattModel, patch: Record<string, any>): Neuralw
176
223
  return result;
177
224
  }
178
225
 
179
- /** Full pipeline: base → patch → custom → result */
226
+ // Apply a user-supplied modelOverride (from neuralwatt.json) on top of a built
227
+ // model. Same deep-merge semantics as applyPatch for compat/vision/cost, plus
228
+ // thinkingLevelMap (so a user can override a single thinking level without
229
+ // redeclaring the whole map). Scalars are replaced. No reasoning-cleanup
230
+ // (unlike applyPatch) — the user's override is authoritative.
231
+ function applyModelOverride(model: NeuralwattModel, override: ModelOverride): NeuralwattModel {
232
+ const result = { ...model };
233
+ const NESTED_KEYS = new Set(["compat", "vision", "cost", "thinkingLevelMap"]);
234
+ for (const [key, value] of Object.entries(override)) {
235
+ if (NESTED_KEYS.has(key) && typeof value === "object" && value !== null && typeof (result as any)[key] === "object") {
236
+ (result as any)[key] = { ...(result as any)[key], ...value };
237
+ } else {
238
+ (result as any)[key] = value;
239
+ }
240
+ }
241
+ return result;
242
+ }
243
+
244
+ /** Full pipeline: base → patch → custom → user modelOverrides → result */
180
245
  function buildModels(
181
246
  base: NeuralwattModel[],
182
247
  custom: NeuralwattModel[],
183
248
  patchList: Record<string, any>,
249
+ overrides: Record<string, ModelOverride> = {},
184
250
  ): NeuralwattModel[] {
185
251
  const modelMap = new Map<string, NeuralwattModel>();
186
252
 
@@ -209,6 +275,17 @@ function buildModels(
209
275
  }
210
276
  }
211
277
 
278
+ // User-supplied modelOverrides (from ~/.pi/agent/extensions/neuralwatt.json)
279
+ // applied LAST so they win over patch.json + custom-models.json. Deep-merges
280
+ // compat / thinkingLevelMap / vision so a user can toggle a single flag
281
+ // (e.g. chatTemplateKwargs.preserve_thinking) without redeclaring the rest.
282
+ for (const [id, override] of Object.entries(overrides)) {
283
+ const existing = modelMap.get(id);
284
+ if (existing) {
285
+ modelMap.set(id, applyModelOverride(existing, override));
286
+ }
287
+ }
288
+
212
289
  return Array.from(modelMap.values()).map((model) => {
213
290
  const result: any = {
214
291
  id: model.id,
@@ -1189,6 +1266,39 @@ export function streamNeuralwatt(
1189
1266
  const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
1190
1267
  const { reasoning: _reasoning, ...streamOptions } = options ?? {};
1191
1268
 
1269
+ // Preserved thinking (full-history reasoning): when a model opts in via
1270
+ // `compat.chatTemplateKwargs`, inject those chat_template_kwargs into the
1271
+ // request body through pi-ai's onPayload hook. We do this here rather than
1272
+ // via thinkingFormat: "chat-template" because the chat-template branch is
1273
+ // mutually exclusive with the openai `reasoning_effort` path — using
1274
+ // onPayload keeps reasoning_effort (thinking-level control) AND adds the
1275
+ // preserve kwargs. Any caller-supplied onPayload is chained first so it can
1276
+ // inspect/replace the payload; our injection then merges into whatever
1277
+ // chat_template_kwargs the caller (or pi-ai) already set.
1278
+ const userOnPayload = streamOptions.onPayload;
1279
+ const extraKwargs = neuralwattModel.compat?.chatTemplateKwargs;
1280
+ const hasExtraKwargs =
1281
+ !!extraKwargs && typeof extraKwargs === "object" && Object.keys(extraKwargs).length > 0;
1282
+ const onPayload = hasExtraKwargs || userOnPayload
1283
+ ? async (params: any, mdl: any) => {
1284
+ let p = params;
1285
+ if (userOnPayload) {
1286
+ const next = await userOnPayload(p, mdl);
1287
+ if (next !== undefined) p = next;
1288
+ }
1289
+ if (hasExtraKwargs) {
1290
+ p = {
1291
+ ...p,
1292
+ chat_template_kwargs: {
1293
+ ...(p?.chat_template_kwargs ?? {}),
1294
+ ...extraKwargs,
1295
+ },
1296
+ };
1297
+ }
1298
+ return p;
1299
+ }
1300
+ : undefined;
1301
+
1192
1302
  const originalFetch = globalThis.fetch;
1193
1303
 
1194
1304
  globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -1207,6 +1317,7 @@ export function streamNeuralwatt(
1207
1317
  ...streamOptions,
1208
1318
  reasoningEffort,
1209
1319
  apiKey,
1320
+ ...(onPayload ? { onPayload } : {}),
1210
1321
  });
1211
1322
 
1212
1323
  const originalEnd = stream.end.bind(stream);
@@ -1236,7 +1347,7 @@ export function getStaleModels(): NeuralwattModel[] {
1236
1347
  const custom = customModelsData as NeuralwattModel[];
1237
1348
  const patches = patchesData as Record<string, any>;
1238
1349
  const staleBase = loadStaleModels(embedded);
1239
- _staleModelsCache = buildModels(staleBase, custom, patches);
1350
+ _staleModelsCache = buildModels(staleBase, custom, patches, config.modelOverrides);
1240
1351
  }
1241
1352
  return _staleModelsCache;
1242
1353
  }
@@ -1272,6 +1383,10 @@ export default function (pi: ExtensionAPI) {
1272
1383
  config = loadConfig();
1273
1384
  resetSessionState();
1274
1385
  cachedQuota = null;
1386
+ // Bust the stale-models cache so a user-edited neuralwatt.json (e.g. toggled
1387
+ // modelOverrides) takes effect this session instead of serving the
1388
+ // module-load snapshot until the background revalidate swaps it in.
1389
+ _staleModelsCache = null;
1275
1390
  replayEnergyEvents(ctx);
1276
1391
  // Re-register on session_start to guarantee our provider identity
1277
1392
  // (api, streamSimple, headers) wins over any load-time registration
@@ -1293,7 +1408,7 @@ export default function (pi: ExtensionAPI) {
1293
1408
  }
1294
1409
  revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
1295
1410
  if (freshBase && !signal.aborted) {
1296
- pi.registerProvider("neuralwatt", makeProviderConfig(buildModels(freshBase, customModels, patches)));
1411
+ pi.registerProvider("neuralwatt", makeProviderConfig(buildModels(freshBase, customModels, patches, config.modelOverrides)));
1297
1412
  }
1298
1413
  });
1299
1414
  });
package/models.json CHANGED
@@ -139,52 +139,6 @@
139
139
  "supportsReasoningEffort": true
140
140
  }
141
141
  },
142
- {
143
- "id": "moonshotai/Kimi-K2.5",
144
- "name": "Kimi K2.5",
145
- "reasoning": true,
146
- "input": [
147
- "text",
148
- "image"
149
- ],
150
- "cost": {
151
- "input": 0.52,
152
- "output": 2.59,
153
- "cacheRead": 0.13,
154
- "cacheWrite": 0
155
- },
156
- "contextWindow": 262128,
157
- "maxTokens": 262128,
158
- "compat": {
159
- "supportsDeveloperRole": false
160
- },
161
- "vision": {
162
- "maxImagesPerRequest": 20
163
- }
164
- },
165
- {
166
- "id": "kimi-k2.5-fast",
167
- "name": "Kimi K2.5 Fast",
168
- "reasoning": false,
169
- "input": [
170
- "text",
171
- "image"
172
- ],
173
- "cost": {
174
- "input": 0.52,
175
- "output": 2.59,
176
- "cacheRead": 0.13,
177
- "cacheWrite": 0
178
- },
179
- "contextWindow": 262128,
180
- "maxTokens": 262128,
181
- "compat": {
182
- "supportsDeveloperRole": false
183
- },
184
- "vision": {
185
- "maxImagesPerRequest": 20
186
- }
187
- },
188
142
  {
189
143
  "id": "kimi-k2.6",
190
144
  "name": "Kimi K2.6",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-neuralwatt-provider",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pi-neuralwatt-provider",
9
- "version": "1.2.3",
9
+ "version": "1.2.4",
10
10
  "license": "MIT",
11
11
  "devDependencies": {
12
12
  "knip": "6.14.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-neuralwatt-provider",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Neuralwatt provider extension for pi - Access Kimi, GLM, Qwen, GPT-OSS, Devstral, and MiniMax models through the Neuralwatt API",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -45,5 +45,6 @@
45
45
  "./index.ts",
46
46
  "./neuralwatt-mcr.ts"
47
47
  ]
48
- }
48
+ },
49
+ "packageManager": "pnpm@11.6.0"
49
50
  }
package/patch.json CHANGED
@@ -7,6 +7,11 @@
7
7
  "medium": null,
8
8
  "high": "high",
9
9
  "xhigh": "max"
10
+ },
11
+ "compat": {
12
+ "chatTemplateKwargs": {
13
+ "clear_thinking": false
14
+ }
10
15
  }
11
16
  },
12
17
  "glm-5.2-fast": {
@@ -22,6 +27,11 @@
22
27
  "medium": null,
23
28
  "high": "high",
24
29
  "xhigh": "max"
30
+ },
31
+ "compat": {
32
+ "chatTemplateKwargs": {
33
+ "clear_thinking": false
34
+ }
25
35
  }
26
36
  },
27
37
  "glm-5.2-short": {
@@ -32,24 +42,18 @@
32
42
  "medium": null,
33
43
  "high": "high",
34
44
  "xhigh": "max"
35
- }
36
- },
37
- "moonshotai/Kimi-K2.5": {
38
- "thinkingLevelMap": {
39
- "minimal": null,
40
- "low": "low",
41
- "medium": "medium",
42
- "high": "high",
43
- "xhigh": null
44
45
  },
45
46
  "compat": {
46
- "maxTokensField": "max_tokens",
47
- "requiresReasoningContentOnAssistantMessages": true
47
+ "chatTemplateKwargs": {
48
+ "clear_thinking": false
49
+ }
48
50
  }
49
51
  },
50
- "kimi-k2.5-fast": {
52
+ "glm-5.2-short-flex": {
51
53
  "compat": {
52
- "maxTokensField": "max_tokens"
54
+ "chatTemplateKwargs": {
55
+ "clear_thinking": false
56
+ }
53
57
  }
54
58
  },
55
59
  "kimi-k2.6": {
@@ -62,7 +66,10 @@
62
66
  },
63
67
  "compat": {
64
68
  "maxTokensField": "max_tokens",
65
- "requiresReasoningContentOnAssistantMessages": true
69
+ "requiresReasoningContentOnAssistantMessages": true,
70
+ "chatTemplateKwargs": {
71
+ "preserve_thinking": true
72
+ }
66
73
  }
67
74
  },
68
75
  "kimi-k2.6-fast": {
@@ -70,6 +77,14 @@
70
77
  "maxTokensField": "max_tokens"
71
78
  }
72
79
  },
80
+ "kimi-k2.6-flex": {
81
+ "compat": {
82
+ "requiresReasoningContentOnAssistantMessages": true,
83
+ "chatTemplateKwargs": {
84
+ "preserve_thinking": true
85
+ }
86
+ }
87
+ },
73
88
  "kimi-k2.7-code": {
74
89
  "thinkingLevelMap": {
75
90
  "minimal": null,
@@ -80,7 +95,26 @@
80
95
  },
81
96
  "compat": {
82
97
  "maxTokensField": "max_tokens",
83
- "requiresReasoningContentOnAssistantMessages": true
98
+ "requiresReasoningContentOnAssistantMessages": true,
99
+ "chatTemplateKwargs": {
100
+ "preserve_thinking": true
101
+ }
102
+ }
103
+ },
104
+ "kimi-k2.7-code-flex": {
105
+ "compat": {
106
+ "requiresReasoningContentOnAssistantMessages": true,
107
+ "chatTemplateKwargs": {
108
+ "preserve_thinking": true
109
+ }
110
+ }
111
+ },
112
+ "neuralwatt/kimi-k2.6-long": {
113
+ "compat": {
114
+ "requiresReasoningContentOnAssistantMessages": true,
115
+ "chatTemplateKwargs": {
116
+ "preserve_thinking": true
117
+ }
84
118
  }
85
119
  },
86
120
  "qwen3.5-397b-fast": {
@@ -8,6 +8,7 @@
8
8
  export interface SimpleStreamOptions {
9
9
  apiKey?: string;
10
10
  reasoning?: string;
11
+ onPayload?: (params: any, model: any) => any | Promise<any>;
11
12
  }
12
13
 
13
14
  export interface AssistantMessageEventStream {
@@ -129,3 +129,205 @@ describe("GLM-5.2 family patch.json thinkingLevelMap", () => {
129
129
  expect(patches["glm-5.2"]?.thinkingLevelMap?.off).toBe("minimal");
130
130
  });
131
131
  });
132
+
133
+ describe("chatTemplateKwargs onPayload injection", () => {
134
+ let originalFetch: typeof globalThis.fetch;
135
+
136
+ beforeEach(() => {
137
+ originalFetch = globalThis.fetch;
138
+ __resetStreamCalls();
139
+ __setClamp((_m, level) => level);
140
+ });
141
+
142
+ afterEach(() => {
143
+ globalThis.fetch = originalFetch;
144
+ });
145
+
146
+ // Kimi K2.6 model shaped as buildModels() produces it after patch.json:
147
+ // chatTemplateKwargs: { preserve_thinking: true } is set on the compat block.
148
+ const kimi26 = {
149
+ id: "kimi-k2.6",
150
+ provider: "neuralwatt",
151
+ reasoning: true,
152
+ input: ["text", "image"],
153
+ thinkingLevelMap: { minimal: null, low: "low", medium: "medium", high: "high", xhigh: null },
154
+ compat: {
155
+ supportsDeveloperRole: false,
156
+ maxTokensField: "max_tokens",
157
+ requiresReasoningContentOnAssistantMessages: true,
158
+ chatTemplateKwargs: { preserve_thinking: true },
159
+ },
160
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
161
+ contextWindow: 262144,
162
+ maxTokens: 262144,
163
+ };
164
+
165
+ const ctx = { messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] };
166
+
167
+ it("registers an onPayload hook when model.compat.chatTemplateKwargs is set", () => {
168
+ const stream = streamNeuralwatt(kimi26, ctx, { apiKey: "sk-test", reasoning: "high" } as any);
169
+ stream.end();
170
+
171
+ expect(__streamCalls).toHaveLength(1);
172
+ expect(typeof __streamCalls[0].options.onPayload).toBe("function");
173
+ });
174
+
175
+ it("injects chat_template_kwargs.preserve_thinking = true into the payload", async () => {
176
+ const stream = streamNeuralwatt(kimi26, ctx, { apiKey: "sk-test", reasoning: "high" } as any);
177
+ stream.end();
178
+
179
+ const onPayload = __streamCalls[0].options.onPayload;
180
+ const original = { model: "kimi-k2.6", messages: [], reasoning_effort: "high" };
181
+ const result = await onPayload(original, kimi26);
182
+
183
+ expect(result).toEqual({
184
+ ...original,
185
+ chat_template_kwargs: { preserve_thinking: true },
186
+ });
187
+ // reasoning_effort is preserved (onPayload doesn't displace it)
188
+ expect(result.reasoning_effort).toBe("high");
189
+ });
190
+
191
+ it("injects chat_template_kwargs.clear_thinking = false for GLM-5.2 family", async () => {
192
+ // GLM-5.x reasoning variants opt into full-history via clear_thinking: false
193
+ // (behavioral E2E: 1/4 → 4/4 recall). The kwarg is template-level and family-
194
+ // specific; the onPayload path injects whatever chatTemplateKwargs lists.
195
+ const glm52Model = {
196
+ ...glm52,
197
+ id: "glm-5.2",
198
+ compat: { supportsDeveloperRole: false, chatTemplateKwargs: { clear_thinking: false } },
199
+ };
200
+ const stream = streamNeuralwatt(glm52Model, ctx, { apiKey: "sk-test", reasoning: "high" } as any);
201
+ stream.end();
202
+
203
+ const onPayload = __streamCalls[0].options.onPayload;
204
+ const result = await onPayload({ model: "glm-5.2", reasoning_effort: "high" }, glm52Model);
205
+ expect(result.chat_template_kwargs).toEqual({ clear_thinking: false });
206
+ expect(result.reasoning_effort).toBe("high");
207
+ });
208
+
209
+ it("merges into pre-existing chat_template_kwargs instead of clobbering", async () => {
210
+ const stream = streamNeuralwatt(kimi26, ctx, { apiKey: "sk-test", reasoning: "high" } as any);
211
+ stream.end();
212
+
213
+ const onPayload = __streamCalls[0].options.onPayload;
214
+ const original = { model: "kimi-k2.6", chat_template_kwargs: { enable_thinking: true } };
215
+ const result = await onPayload(original, kimi26);
216
+
217
+ expect(result.chat_template_kwargs).toEqual({ enable_thinking: true, preserve_thinking: true });
218
+ });
219
+
220
+ it("does NOT register onPayload when chatTemplateKwargs is absent (e.g. Qwen / non-reasoning)", () => {
221
+ // Qwen3.x and Kimi -fast (non-reasoning) have no full-history kwarg (not
222
+ // exposed by their chat template / nothing to preserve). They rely on the
223
+ // intrinsic Layer-A replay (pi-ai replays the `reasoning` field; the gateway
224
+ // aliases reasoning <-> reasoning_content). glm52 here has no chatTemplateKwargs,
225
+ // standing in for such a model.
226
+ const stream = streamNeuralwatt(glm52, ctx, { apiKey: "sk-test", reasoning: "high" } as any);
227
+ stream.end();
228
+
229
+ expect(__streamCalls[0].options.onPayload).toBeUndefined();
230
+ });
231
+
232
+ it("chains a caller-supplied onPayload first, then injects preserve_thinking", async () => {
233
+ const userPayload = vi.fn((p: any) => ({ ...p, reason: "user-saw-it", model: p.model }));
234
+ const stream = streamNeuralwatt(kimi26, ctx, {
235
+ apiKey: "sk-test",
236
+ reasoning: "high",
237
+ onPayload: userPayload,
238
+ } as any);
239
+ stream.end();
240
+
241
+ const onPayload = __streamCalls[0].options.onPayload;
242
+ const result = await onPayload({ model: "kimi-k2.6" }, kimi26);
243
+
244
+ expect(userPayload).toHaveBeenCalledTimes(1);
245
+ expect(result.reason).toBe("user-saw-it");
246
+ expect(result.chat_template_kwargs).toEqual({ preserve_thinking: true });
247
+ });
248
+ });
249
+
250
+ describe("patch.json chatTemplateKwargs enablement (behavioral E2E-verified)", () => {
251
+ const patches = patchesData as Record<string, any>;
252
+
253
+ // Kimi K2.6/K2.7 reasoning variants: preserve_thinking: true
254
+ // (doc-backed; behavioral E2E: 0/6 → 6/6 recall)
255
+ const kimi = ["kimi-k2.6", "kimi-k2.6-flex", "neuralwatt/kimi-k2.6-long", "kimi-k2.7-code", "kimi-k2.7-code-flex"];
256
+ for (const id of kimi) {
257
+ it(`${id} opts into full-history via preserve_thinking: true`, () => {
258
+ expect(patches[id]?.compat?.chatTemplateKwargs).toEqual({ preserve_thinking: true });
259
+ // Layer-A empty-scaffold stays on alongside it
260
+ expect(patches[id]?.compat?.requiresReasoningContentOnAssistantMessages).toBe(true);
261
+ });
262
+ }
263
+
264
+ // GLM-5.2 reasoning variants: clear_thinking: false
265
+ // (NOT in the docs' full-history table, which lists clear_thinking only for
266
+ // GLM-5.1; but behavioral E2E proved it functional on GLM-5.2: 1/4 → 4/4 recall,
267
+ // confirmed family-wide on base/short/flex). Non-reasoning -fast variants excluded.
268
+ const glm = ["glm-5.2", "glm-5.2-flex", "glm-5.2-short", "glm-5.2-short-flex"];
269
+ for (const id of glm) {
270
+ it(`${id} opts into full-history via clear_thinking: false`, () => {
271
+ expect(patches[id]?.compat?.chatTemplateKwargs).toEqual({ clear_thinking: false });
272
+ });
273
+ }
274
+
275
+ // Models with no full-history kwarg: non-reasoning -fast variants (nothing to
276
+ // preserve) and Qwen (chat template exposes no flag).
277
+ const none = ["kimi-k2.6-fast", "glm-5.2-fast", "qwen3.5-397b-fast", "qwen3.6-35b-fast"];
278
+ for (const id of none) {
279
+ it(`${id} sets NO chatTemplateKwargs (non-reasoning / not exposed)`, () => {
280
+ expect(patches[id]?.compat?.chatTemplateKwargs).toBeUndefined();
281
+ });
282
+ }
283
+ });
284
+
285
+ describe("modelOverrides (user config) applied on top of patch", () => {
286
+ // Mirrors buildModels(base, custom, patch, overrides) + applyModelOverride.
287
+ // Defined inline so the test stays self-contained without importing the
288
+ // (private) buildModels; it validates the override semantics the feature
289
+ // promises: deep-merge compat, replace scalars, win over patch.json.
290
+ function applyModelOverride(model: any, override: any): any {
291
+ const result = { ...model };
292
+ const NESTED = new Set(["compat", "vision", "cost", "thinkingLevelMap"]);
293
+ for (const [k, v] of Object.entries(override)) {
294
+ if (NESTED.has(k) && typeof v === "object" && v !== null && typeof result[k] === "object") {
295
+ result[k] = { ...result[k], ...v };
296
+ } else {
297
+ result[k] = v;
298
+ }
299
+ }
300
+ return result;
301
+ }
302
+
303
+ const base = {
304
+ id: "kimi-k2.6",
305
+ provider: "neuralwatt",
306
+ reasoning: true,
307
+ compat: { supportsDeveloperRole: false, chatTemplateKwargs: { preserve_thinking: true } },
308
+ thinkingLevelMap: { low: "low", high: "high" },
309
+ };
310
+
311
+ it("override wins over patch.json for a compat flag it sets", () => {
312
+ // User disables preserve_thinking via override → must win over patch.json's true.
313
+ const out = applyModelOverride(base, { compat: { chatTemplateKwargs: { preserve_thinking: false } } });
314
+ expect(out.compat.chatTemplateKwargs.preserve_thinking).toBe(false);
315
+ });
316
+
317
+ it("deep-merges compat so non-overridden flags survive", () => {
318
+ // User toggles only chatTemplateKwargs; supportsDeveloperRole must survive.
319
+ const out = applyModelOverride(base, { compat: { chatTemplateKwargs: {} } });
320
+ expect(out.compat.supportsDeveloperRole).toBe(false);
321
+ expect(out.compat.chatTemplateKwargs).toEqual({});
322
+ });
323
+
324
+ it("deep-merges thinkingLevelMap so a single level can be overridden", () => {
325
+ const out = applyModelOverride(base, { thinkingLevelMap: { high: null } });
326
+ expect(out.thinkingLevelMap).toEqual({ low: "low", high: null });
327
+ });
328
+
329
+ it("replace-semantics for scalar fields (e.g. reasoning)", () => {
330
+ const out = applyModelOverride(base, { reasoning: false });
331
+ expect(out.reasoning).toBe(false);
332
+ });
333
+ });
package/vitest.config.ts CHANGED
@@ -10,6 +10,10 @@ export default defineConfig({
10
10
  },
11
11
  resolve: {
12
12
  alias: {
13
+ // index.ts imports streamOpenAICompletions from the `/compat` subpath;
14
+ // the exact-match `@earendil-works/pi-ai` alias below doesn't cover it,
15
+ // so without this entry every test that imports ../index fails to load.
16
+ "@earendil-works/pi-ai/compat": path.resolve(__dirname, "tests/__mocks__/pi-ai.ts"),
13
17
  "@earendil-works/pi-ai": path.resolve(__dirname, "tests/__mocks__/pi-ai.ts"),
14
18
  "@earendil-works/pi-coding-agent": path.resolve(__dirname, "tests/__mocks__/pi-coding-agent.ts"),
15
19
  },