pi-neuralwatt-provider 1.2.4 → 1.3.1

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
@@ -81,6 +81,17 @@ interface NeuralwattConfig {
81
81
  // When true, hide energy/quota/MCR display if the active model's provider
82
82
  // is not "neuralwatt". Prevents stale display after switching providers.
83
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 };
84
95
  }
85
96
 
86
97
  const CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "extensions", "neuralwatt.json");
@@ -102,6 +113,7 @@ function loadConfig(): NeuralwattConfig {
102
113
  quota: parseDisplayMode(raw.quota, "widget"),
103
114
  mcr: parseDisplayMode(raw.mcr, "widget"),
104
115
  hideOnOtherProvider: typeof raw.hideOnOtherProvider === "boolean" ? raw.hideOnOtherProvider : false,
116
+ modelOverrides: parseModelOverrides(raw.modelOverrides),
105
117
  };
106
118
  } catch {
107
119
  // Config file missing or invalid — populate with defaults so the user can discover it
@@ -115,6 +127,29 @@ function loadConfig(): NeuralwattConfig {
115
127
  }
116
128
  }
117
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
+
118
153
  let config = loadConfig();
119
154
 
120
155
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -147,6 +182,17 @@ interface NeuralwattModel {
147
182
  supportsReasoningEffort?: boolean;
148
183
  requiresAssistantAfterToolResult?: boolean;
149
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>;
150
196
  };
151
197
  vision?: {
152
198
  maxImagesPerRequest?: number;
@@ -177,11 +223,30 @@ function applyPatch(model: NeuralwattModel, patch: Record<string, any>): Neuralw
177
223
  return result;
178
224
  }
179
225
 
180
- /** 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 */
181
245
  function buildModels(
182
246
  base: NeuralwattModel[],
183
247
  custom: NeuralwattModel[],
184
248
  patchList: Record<string, any>,
249
+ overrides: Record<string, ModelOverride> = {},
185
250
  ): NeuralwattModel[] {
186
251
  const modelMap = new Map<string, NeuralwattModel>();
187
252
 
@@ -210,6 +275,17 @@ function buildModels(
210
275
  }
211
276
  }
212
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
+
213
289
  return Array.from(modelMap.values()).map((model) => {
214
290
  const result: any = {
215
291
  id: model.id,
@@ -340,9 +416,20 @@ function mergeWithEmbedded(liveModels: NeuralwattModel[], embeddedModels: Neural
340
416
  const embedded = embeddedMap.get(liveModel.id);
341
417
  seen.add(liveModel.id);
342
418
  if (embedded) {
419
+ // Self-heal: live API pricing is authoritative field-by-field. Prefer the
420
+ // live cost when the API reports it (non-zero); fall back to embedded when
421
+ // the API is silent (0) so curated cacheRead/cacheWrite isn't clobbered and
422
+ // providers whose /models endpoint exposes no pricing keep their curated
423
+ // cost. Curation (reasoning/input/compat/name) still wins via ...embedded.
343
424
  result.push({
344
425
  ...liveModel,
345
426
  ...embedded,
427
+ cost: {
428
+ input: liveModel.cost.input || embedded.cost.input,
429
+ output: liveModel.cost.output || embedded.cost.output,
430
+ cacheRead: liveModel.cost.cacheRead || embedded.cost.cacheRead,
431
+ cacheWrite: liveModel.cost.cacheWrite || embedded.cost.cacheWrite,
432
+ },
346
433
  contextWindow: liveModel.contextWindow || embedded.contextWindow,
347
434
  });
348
435
  } else {
@@ -1190,6 +1277,39 @@ export function streamNeuralwatt(
1190
1277
  const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
1191
1278
  const { reasoning: _reasoning, ...streamOptions } = options ?? {};
1192
1279
 
1280
+ // Preserved thinking (full-history reasoning): when a model opts in via
1281
+ // `compat.chatTemplateKwargs`, inject those chat_template_kwargs into the
1282
+ // request body through pi-ai's onPayload hook. We do this here rather than
1283
+ // via thinkingFormat: "chat-template" because the chat-template branch is
1284
+ // mutually exclusive with the openai `reasoning_effort` path — using
1285
+ // onPayload keeps reasoning_effort (thinking-level control) AND adds the
1286
+ // preserve kwargs. Any caller-supplied onPayload is chained first so it can
1287
+ // inspect/replace the payload; our injection then merges into whatever
1288
+ // chat_template_kwargs the caller (or pi-ai) already set.
1289
+ const userOnPayload = streamOptions.onPayload;
1290
+ const extraKwargs = neuralwattModel.compat?.chatTemplateKwargs;
1291
+ const hasExtraKwargs =
1292
+ !!extraKwargs && typeof extraKwargs === "object" && Object.keys(extraKwargs).length > 0;
1293
+ const onPayload = hasExtraKwargs || userOnPayload
1294
+ ? async (params: any, mdl: any) => {
1295
+ let p = params;
1296
+ if (userOnPayload) {
1297
+ const next = await userOnPayload(p, mdl);
1298
+ if (next !== undefined) p = next;
1299
+ }
1300
+ if (hasExtraKwargs) {
1301
+ p = {
1302
+ ...p,
1303
+ chat_template_kwargs: {
1304
+ ...(p?.chat_template_kwargs ?? {}),
1305
+ ...extraKwargs,
1306
+ },
1307
+ };
1308
+ }
1309
+ return p;
1310
+ }
1311
+ : undefined;
1312
+
1193
1313
  const originalFetch = globalThis.fetch;
1194
1314
 
1195
1315
  globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -1208,6 +1328,7 @@ export function streamNeuralwatt(
1208
1328
  ...streamOptions,
1209
1329
  reasoningEffort,
1210
1330
  apiKey,
1331
+ ...(onPayload ? { onPayload } : {}),
1211
1332
  });
1212
1333
 
1213
1334
  const originalEnd = stream.end.bind(stream);
@@ -1237,7 +1358,7 @@ export function getStaleModels(): NeuralwattModel[] {
1237
1358
  const custom = customModelsData as NeuralwattModel[];
1238
1359
  const patches = patchesData as Record<string, any>;
1239
1360
  const staleBase = loadStaleModels(embedded);
1240
- _staleModelsCache = buildModels(staleBase, custom, patches);
1361
+ _staleModelsCache = buildModels(staleBase, custom, patches, config.modelOverrides);
1241
1362
  }
1242
1363
  return _staleModelsCache;
1243
1364
  }
@@ -1273,6 +1394,10 @@ export default function (pi: ExtensionAPI) {
1273
1394
  config = loadConfig();
1274
1395
  resetSessionState();
1275
1396
  cachedQuota = null;
1397
+ // Bust the stale-models cache so a user-edited neuralwatt.json (e.g. toggled
1398
+ // modelOverrides) takes effect this session instead of serving the
1399
+ // module-load snapshot until the background revalidate swaps it in.
1400
+ _staleModelsCache = null;
1276
1401
  replayEnergyEvents(ctx);
1277
1402
  // Re-register on session_start to guarantee our provider identity
1278
1403
  // (api, streamSimple, headers) wins over any load-time registration
@@ -1294,7 +1419,7 @@ export default function (pi: ExtensionAPI) {
1294
1419
  }
1295
1420
  revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
1296
1421
  if (freshBase && !signal.aborted) {
1297
- pi.registerProvider("neuralwatt", makeProviderConfig(buildModels(freshBase, customModels, patches)));
1422
+ pi.registerProvider("neuralwatt", makeProviderConfig(buildModels(freshBase, customModels, patches, config.modelOverrides)));
1298
1423
  }
1299
1424
  });
1300
1425
  });
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.4",
3
+ "version": "1.3.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pi-neuralwatt-provider",
9
- "version": "1.2.4",
9
+ "version": "1.3.1",
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.4",
3
+ "version": "1.3.1",
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",
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
  },