pi-neuralwatt-provider 1.2.4 → 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
@@ -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,
@@ -1190,6 +1266,39 @@ export function streamNeuralwatt(
1190
1266
  const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
1191
1267
  const { reasoning: _reasoning, ...streamOptions } = options ?? {};
1192
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
+
1193
1302
  const originalFetch = globalThis.fetch;
1194
1303
 
1195
1304
  globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -1208,6 +1317,7 @@ export function streamNeuralwatt(
1208
1317
  ...streamOptions,
1209
1318
  reasoningEffort,
1210
1319
  apiKey,
1320
+ ...(onPayload ? { onPayload } : {}),
1211
1321
  });
1212
1322
 
1213
1323
  const originalEnd = stream.end.bind(stream);
@@ -1237,7 +1347,7 @@ export function getStaleModels(): NeuralwattModel[] {
1237
1347
  const custom = customModelsData as NeuralwattModel[];
1238
1348
  const patches = patchesData as Record<string, any>;
1239
1349
  const staleBase = loadStaleModels(embedded);
1240
- _staleModelsCache = buildModels(staleBase, custom, patches);
1350
+ _staleModelsCache = buildModels(staleBase, custom, patches, config.modelOverrides);
1241
1351
  }
1242
1352
  return _staleModelsCache;
1243
1353
  }
@@ -1273,6 +1383,10 @@ export default function (pi: ExtensionAPI) {
1273
1383
  config = loadConfig();
1274
1384
  resetSessionState();
1275
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;
1276
1390
  replayEnergyEvents(ctx);
1277
1391
  // Re-register on session_start to guarantee our provider identity
1278
1392
  // (api, streamSimple, headers) wins over any load-time registration
@@ -1294,7 +1408,7 @@ export default function (pi: ExtensionAPI) {
1294
1408
  }
1295
1409
  revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
1296
1410
  if (freshBase && !signal.aborted) {
1297
- pi.registerProvider("neuralwatt", makeProviderConfig(buildModels(freshBase, customModels, patches)));
1411
+ pi.registerProvider("neuralwatt", makeProviderConfig(buildModels(freshBase, customModels, patches, config.modelOverrides)));
1298
1412
  }
1299
1413
  });
1300
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,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-neuralwatt-provider",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
package/package.json CHANGED
@@ -1,9 +1,22 @@
1
1
  {
2
2
  "name": "pi-neuralwatt-provider",
3
- "version": "1.2.4",
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",
7
+ "scripts": {
8
+ "clean": "echo 'nothing to clean'",
9
+ "build": "echo 'nothing to build'",
10
+ "check": "echo 'nothing to check'",
11
+ "lint:dead": "knip --no-gitignore",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest",
14
+ "probe:image-limit": "node scripts/test-image-limit.mjs",
15
+ "probe:image-limit-stream": "node scripts/test-image-limit-stream.mjs",
16
+ "probe:vision-models": "node scripts/probe-vision-models.mjs",
17
+ "update-models": "node scripts/update-models.js",
18
+ "sync-mcr": "curl -sL https://raw.githubusercontent.com/neuralwatt/neuralwatt-tools/refs/heads/main/plugins/pi/extensions/neuralwatt-mcr.ts -o chad-mcr-upstream.ts"
19
+ },
7
20
  "keywords": [
8
21
  "pi",
9
22
  "extension",
@@ -33,17 +46,5 @@
33
46
  "./neuralwatt-mcr.ts"
34
47
  ]
35
48
  },
36
- "scripts": {
37
- "clean": "echo 'nothing to clean'",
38
- "build": "echo 'nothing to build'",
39
- "check": "echo 'nothing to check'",
40
- "lint:dead": "knip --no-gitignore",
41
- "test": "vitest run",
42
- "test:watch": "vitest",
43
- "probe:image-limit": "node scripts/test-image-limit.mjs",
44
- "probe:image-limit-stream": "node scripts/test-image-limit-stream.mjs",
45
- "probe:vision-models": "node scripts/probe-vision-models.mjs",
46
- "update-models": "node scripts/update-models.js",
47
- "sync-mcr": "curl -sL https://raw.githubusercontent.com/neuralwatt/neuralwatt-tools/refs/heads/main/plugins/pi/extensions/neuralwatt-mcr.ts -o chad-mcr-upstream.ts"
48
- }
49
- }
49
+ "packageManager": "pnpm@11.6.0"
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": {
File without changes
File without changes
File without changes
@@ -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
  },