pi-lilac-provider 1.2.1 → 1.4.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
@@ -103,13 +103,33 @@ pi --provider lilac --model moonshotai/kimi-k2.6
103
103
 
104
104
  ### Thinking Mode
105
105
 
106
- All Lilac models support chain-of-thought reasoning via `chat_template_kwargs`. Pi uses the `qwen-chat-template` thinking format to send both `thinking` and `enable_thinking` keys, which works across all model families:
107
-
108
- - **Kimi K2.6**: Honors `thinking` key (Moonshot template)
109
- - **GLM 5.1**: Honors `enable_thinking` key (Z.ai template)
110
- - **Gemma 4**: Honors `enable_thinking` key (Google template)
111
-
112
- In pi, reasoning models automatically use the appropriate thinking format. Use Shift+Tab to control thinking level.
106
+ All Lilac models toggle reasoning via `chat_template_kwargs`, but the key each
107
+ model's chat template honors differs per family. The provider uses pi's
108
+ `chat-template` thinkingFormat with per-model `chatTemplateKwargs` (configured in
109
+ `patch.json`) so the right key reaches each template:
110
+
111
+ | Model | Reasoning key | Default |
112
+ |-------|---------------|---------|
113
+ | Kimi K2.6 | `thinking` (bool) | on |
114
+ | GLM 5.1 | `enable_thinking` (bool) | on |
115
+ | GLM 5.2 | `enable_thinking` (bool) + `reasoning_effort` (`max`\|`high`) | on (`max`) |
116
+ | Gemma 4 | `enable_thinking` (bool) | off |
117
+ | MiniMax M2.7 | `thinking` + `enable_thinking` (bool) | on |
118
+ | MiniMax M3 | `thinking_mode` (`disabled`\|`adaptive`\|`enabled`) | adaptive (server) |
119
+
120
+ Kimi K2.6, GLM 5.1, Gemma 4, and MiniMax M2.7 use the forward-compatible form
121
+ that sends **both** `thinking` and `enable_thinking`, so whichever key the
122
+ template honors is set. GLM 5.2 additionally maps pi's thinking levels to
123
+ `reasoning_effort` (`high` = lower-latency, `xhigh` = `max`). MiniMax M3 uses
124
+ the `thinking_mode` enum, exposed as three pi thinking levels: `off` →
125
+ `disabled` (never think), `minimal` → `adaptive` (the model decides), `high` →
126
+ `enabled` (always think). Pi starts at `off` (`disabled`); cycle to `minimal`
127
+ for M3's adaptive "model decides" mode. (The selector/footer show pi's level
128
+ names — `minimal`/`high` — not the `thinking_mode` values; pi has no per-model
129
+ level-relabel hook.)
130
+
131
+ In pi, reasoning models automatically use the appropriate thinking format. Use
132
+ Shift+Tab to control thinking level.
113
133
 
114
134
  ### Vision
115
135
 
@@ -153,7 +173,7 @@ Add to your pi configuration for automatic loading:
153
173
 
154
174
  Lilac's API is OpenAI-compatible with these specifics:
155
175
 
156
- - **`thinkingFormat: "qwen-chat-template"`** — All reasoning models. Lilac uses `chat_template_kwargs` (with `thinking` and `enable_thinking` keys) to toggle reasoning. Pi sends both keys for forward compatibility.
176
+ - **`thinkingFormat: "chat-template"`** — All reasoning models. Lilac's vLLM backend toggles reasoning via `chat_template_kwargs`, but the honored key differs per model family. Per-model `chatTemplateKwargs` in `patch.json` send the right key(s): `thinking`+`enable_thinking` (bool) for Kimi K2.6, GLM 5.1, Gemma 4, and MiniMax M2.7; `enable_thinking` + `reasoning_effort` for GLM 5.2; `thinking_mode` (adaptive|enabled|disabled) for MiniMax M3.
157
177
  - **`maxTokensField: "max_completion_tokens"`** — All models. Lilac supports `max_completion_tokens` (preferred for reasoning models as it includes reasoning tokens).
158
178
  - **`supportsDeveloperRole: true`** — All models. Lilac's vLLM backend maps the developer role to system.
159
179
  - **`supportsStore: false`** — All models. Lilac doesn't support the `store` parameter.
package/index.ts CHANGED
@@ -5,14 +5,28 @@
5
5
  * Base URL: https://api.getlilac.com/v1
6
6
  *
7
7
  * Lilac serves models via a customized fork of vLLM tuned for idle-GPU scheduling
8
- * and shared warm endpoints. All models use chat_template_kwargs to toggle reasoning:
8
+ * and shared warm endpoints. Reasoning is toggled via chat_template_kwargs, but
9
+ * the key each model's chat template honors differs per family, so per-model
10
+ * chatTemplateKwargs are configured in patch.json:
9
11
  *
10
- * - Kimi K2.6: reasoning ON by default, honors `thinking` key
11
- * - GLM 5.1: reasoning ON by default, honors `enable_thinking` key
12
- * - Gemma 4: reasoning OFF by default, honors `enable_thinking` key
12
+ * - Kimi K2.6: honors `thinking` (bool); `enable_thinking` ignored. ON by default.
13
+ * - GLM 5.1: honors `enable_thinking` (bool). ON by default.
14
+ * - GLM 5.2: honors `enable_thinking` (bool) + `reasoning_effort` (max|high). ON by default.
15
+ * - Gemma 4: honors `enable_thinking` (bool). OFF by default.
16
+ * - MiniMax M2.7: forward-compatible `thinking` + `enable_thinking` (bool).
17
+ * - MiniMax M3: honors `thinking_mode` (disabled|adaptive|enabled); bool keys ignored.
13
18
  *
14
- * The forward-compatible approach is to send both `thinking` and `enable_thinking`
15
- * in chat_template_kwargs — pi's `qwen-chat-template` thinkingFormat does this.
19
+ * We use pi's `chat-template` thinkingFormat (NOT `qwen-chat-template`, which
20
+ * sends only `enable_thinking` + `preserve_thinking` and is ignored by Kimi and
21
+ * MiniMax M3). The forward-compatible form sends BOTH `thinking` and
22
+ * `enable_thinking` so whichever key the template honors is set:
23
+ * { chat_template_kwargs: { thinking: <bool>, enable_thinking: <bool> } }
24
+ * GLM 5.2 adds `reasoning_effort` (high = lower-latency, xhigh = max) via a
25
+ * thinkingLevelMap. MiniMax M3 maps to the `thinking_mode` enum as three pi
26
+ * thinking levels — off→disabled, minimal→adaptive (model decides), high→enabled
27
+ * — so adaptive is selectable via pi's Shift+Tab cycle (off→minimal→high). Pi
28
+ * shows the pi level names (minimal/high) in the selector/footer, not the
29
+ * thinking_mode values; there's no per-model level-relabel hook.
16
30
  *
17
31
  * Key API notes:
18
32
  * - Uses `max_completion_tokens` (preferred for reasoning models)
@@ -99,6 +113,17 @@ type ThinkingLevelMap = {
99
113
  xhigh?: string | null;
100
114
  };
101
115
 
116
+ // A chat_template_kwargs value, mirroring pi-ai's ChatTemplateKwargSchema. Scalar
117
+ // values are passed through verbatim; { $var } values are resolved by pi-ai from
118
+ // the turn's thinking state ("thinking.enabled" → bool, "thinking.effort" → the
119
+ // mapped effort string). omitWhenOff drops the key entirely when thinking is off.
120
+ type ChatTemplateKwargValue =
121
+ | string
122
+ | number
123
+ | boolean
124
+ | null
125
+ | { $var: "thinking.enabled" | "thinking.effort"; omitWhenOff?: boolean };
126
+
102
127
  interface JsonModel {
103
128
  id: string;
104
129
  name: string;
@@ -117,7 +142,19 @@ interface JsonModel {
117
142
  supportsDeveloperRole?: boolean;
118
143
  supportsStore?: boolean;
119
144
  maxTokensField?: "max_completion_tokens" | "max_tokens";
120
- thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template";
145
+ thinkingFormat?:
146
+ | "openai"
147
+ | "openrouter"
148
+ | "together"
149
+ | "deepseek"
150
+ | "zai"
151
+ | "qwen"
152
+ | "chat-template"
153
+ | "qwen-chat-template"
154
+ | "string-thinking"
155
+ | "ant-ling";
156
+ chatTemplateKwargs?: Record<string, ChatTemplateKwargValue>;
157
+ zaiToolStream?: boolean;
121
158
  supportsReasoningEffort?: boolean;
122
159
  };
123
160
  discount?: JsonDiscount;
@@ -252,14 +289,21 @@ function transformApiModel(apiModel: any): JsonModel | null {
252
289
  maxTokens: apiModel.top_provider?.max_completion_tokens || apiModel.context_length || 131072,
253
290
  };
254
291
 
255
- // All Lilac models use chat_template_kwargs for reasoning toggle
292
+ // All Lilac models toggle reasoning via chat_template_kwargs, but the key each
293
+ // model's chat template honors differs per family. Default newly discovered
294
+ // models to the forward-compatible both-keys form (works across all current
295
+ // Lilac templates); per-model overrides in patch.json refine this — e.g. GLM
296
+ // 5.2 adds reasoning_effort, MiniMax M3 uses the thinking_mode enum.
256
297
  if (features.includes("reasoning")) {
257
298
  model.compat = {
258
299
  supportsDeveloperRole: true,
259
300
  supportsStore: false,
260
301
  maxTokensField: "max_completion_tokens",
261
- thinkingFormat: "qwen-chat-template",
262
- supportsReasoningEffort: true,
302
+ thinkingFormat: "chat-template",
303
+ chatTemplateKwargs: {
304
+ thinking: { $var: "thinking.enabled" },
305
+ enable_thinking: { $var: "thinking.enabled" },
306
+ },
263
307
  };
264
308
  } else {
265
309
  model.compat = {
@@ -538,7 +582,19 @@ let cachedApiKey: string | undefined;
538
582
  let revalidateAbort: AbortController | null = null;
539
583
  let latestDiscounts: Map<string, JsonDiscount> | null = null;
540
584
  let lastDiscountFetchTime = 0;
541
- const STATUS_CACHE_TTL_MS = 30000;
585
+ // Turn-initiated /status fetches are throttled to once per TTL window so a burst
586
+ // of messages doesn't hammer the endpoint. Background polling (see
587
+ // STATUS_POLL_INTERVAL_MS) and turn fetches both stamp lastDiscountFetchTime, so
588
+ // they cooperate: a poll that just ran lets the next turn skip its own fetch
589
+ // within the TTL.
590
+ const STATUS_CACHE_TTL_MS = 60000;
591
+ // Lilac refreshes discounts ~every 10 minutes (per their docs: "Discounts refresh
592
+ // approximately every 10 minutes and are locked in when a request starts"). Poll
593
+ // on that cadence during idle so a long-idle session still catches supply/sub
594
+ // changes without waiting for the user to send a message — turn fetches alone
595
+ // only refresh on a user message and are TTL-throttled to 1/min.
596
+ const STATUS_POLL_INTERVAL_MS = 10 * 60 * 1000;
597
+ let pollInterval: ReturnType<typeof setInterval> | null = null;
542
598
  // List-price (patch-applied, pre-discount) models, cached until the base set
543
599
  // changes. Reset in cacheModels() so the next getListModels() rebuilds from the
544
600
  // refreshed disk cache / embedded set.
@@ -599,11 +655,49 @@ export default function (pi: ExtensionAPI) {
599
655
  }
600
656
  }
601
657
 
658
+ /**
659
+ * Background /status poll, fired every STATUS_POLL_INTERVAL_MS (10 min) from
660
+ * session_start to cover idle sessions. Mirrors the discount half of
661
+ * before_provider_request, but without an in-flight turn model to mutate: it
662
+ * only refreshes latestDiscounts, re-registers (so the next turn's models carry
663
+ * the new price), and re-paints the footer from the LIVE model. Passes the
664
+ * session AbortSignal so the fetch dies on session_shutdown or a subsequent
665
+ * session_start; bails on a missing API key or an aborted signal.
666
+ */
667
+ function pollStatusDiscounts(ctx: any, signal: AbortSignal): void {
668
+ if (signal.aborted || !cachedApiKey) return;
669
+ fetchStatusDiscounts(cachedApiKey, signal).then(discounts => {
670
+ if (signal.aborted || !discounts) return;
671
+ lastDiscountFetchTime = Date.now();
672
+ if (!discountsChanged(latestDiscounts, discounts)) {
673
+ syncStatus(ctx);
674
+ return;
675
+ }
676
+ cacheDiscounts(discounts);
677
+ latestDiscounts = discounts;
678
+ const freshList = getListModels();
679
+ pi.registerProvider("lilac", {
680
+ baseUrl: BASE_URL,
681
+ apiKey: "$LILAC_API_KEY",
682
+ api: "openai-completions",
683
+ models: applyDiscounts(freshList, discounts),
684
+ });
685
+ syncStatus(ctx);
686
+ }).catch(() => { /* network errors are non-fatal */ });
687
+ }
688
+
602
689
  pi.on("session_start", async (_event, ctx) => {
603
690
  revalidateAbort?.abort();
604
691
  revalidateAbort = new AbortController();
605
692
  const signal = revalidateAbort.signal;
606
693
 
694
+ // Tear down any poll interval left from a prior session before starting a
695
+ // fresh one (defensive; session_shutdown normally handles this).
696
+ if (pollInterval) {
697
+ clearInterval(pollInterval);
698
+ pollInterval = null;
699
+ }
700
+
607
701
  // Replay persisted discount state from session JSONL (synchronous, zero-latency)
608
702
  replayDiscountEvents(ctx);
609
703
 
@@ -653,6 +747,15 @@ export default function (pi: ExtensionAPI) {
653
747
  syncStatus(ctx);
654
748
  }).catch(() => { /* network errors are non-fatal */ });
655
749
  });
750
+
751
+ // Background poll for idle sessions: Lilac refreshes discounts ~every 10
752
+ // minutes, so poll on that cadence to catch supply/sub changes while the
753
+ // user is idle (turn fetches only run when a message is sent). The callback
754
+ // bails on a missing API key or an aborted/shut-down session. Cleared in
755
+ // session_shutdown and at the top of the next session_start.
756
+ pollInterval = setInterval(() => pollStatusDiscounts(ctx, signal), STATUS_POLL_INTERVAL_MS);
757
+ // Don't keep the process alive solely for discount polling.
758
+ pollInterval.unref?.();
656
759
  });
657
760
 
658
761
  pi.on("turn_end", async (_event, ctx) => {
@@ -776,6 +879,10 @@ export default function (pi: ExtensionAPI) {
776
879
 
777
880
  pi.on("session_shutdown", () => {
778
881
  revalidateAbort?.abort();
882
+ if (pollInterval) {
883
+ clearInterval(pollInterval);
884
+ pollInterval = null;
885
+ }
779
886
  });
780
887
  }
781
888
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lilac-provider",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Lilac provider extension for pi - Access Kimi K2.6, GLM 5.1, and Gemma 4 models through Lilac's OpenAI-compatible API on idle GPUs",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/patch.json CHANGED
@@ -7,11 +7,14 @@
7
7
  "cacheRead": 0.20
8
8
  },
9
9
  "compat": {
10
- "thinkingFormat": "qwen-chat-template",
10
+ "thinkingFormat": "chat-template",
11
+ "chatTemplateKwargs": {
12
+ "thinking": { "$var": "thinking.enabled" },
13
+ "enable_thinking": { "$var": "thinking.enabled" }
14
+ },
11
15
  "maxTokensField": "max_completion_tokens",
12
16
  "supportsDeveloperRole": false,
13
- "supportsStore": false,
14
- "supportsReasoningEffort": true
17
+ "supportsStore": false
15
18
  }
16
19
  },
17
20
  "zai-org/glm-5.1": {
@@ -23,20 +26,29 @@
23
26
  "cacheRead": 0.27
24
27
  },
25
28
  "compat": {
26
- "thinkingFormat": "qwen-chat-template",
29
+ "thinkingFormat": "chat-template",
30
+ "chatTemplateKwargs": {
31
+ "thinking": { "$var": "thinking.enabled" },
32
+ "enable_thinking": { "$var": "thinking.enabled" }
33
+ },
27
34
  "maxTokensField": "max_completion_tokens",
28
35
  "supportsDeveloperRole": false,
29
36
  "supportsStore": false,
30
- "zaiToolStream": true,
31
- "supportsReasoningEffort": true
37
+ "zaiToolStream": true
32
38
  }
33
39
  },
34
40
  "zai-org/glm-5.2": {
41
+ "compat": {
42
+ "thinkingFormat": "chat-template",
43
+ "chatTemplateKwargs": {
44
+ "enable_thinking": { "$var": "thinking.enabled" },
45
+ "reasoning_effort": { "$var": "thinking.effort", "omitWhenOff": true }
46
+ }
47
+ },
35
48
  "thinkingLevelMap": {
36
- "off": "minimal",
37
49
  "minimal": null,
38
- "low": null,
39
- "medium": null,
50
+ "low": "high",
51
+ "medium": "high",
40
52
  "high": "high",
41
53
  "xhigh": "max"
42
54
  }
@@ -49,16 +61,40 @@
49
61
  "cacheRead": 0
50
62
  },
51
63
  "compat": {
52
- "thinkingFormat": "qwen-chat-template",
64
+ "thinkingFormat": "chat-template",
65
+ "chatTemplateKwargs": {
66
+ "thinking": { "$var": "thinking.enabled" },
67
+ "enable_thinking": { "$var": "thinking.enabled" }
68
+ },
53
69
  "maxTokensField": "max_completion_tokens",
54
70
  "supportsDeveloperRole": true,
55
- "supportsStore": false,
56
- "supportsReasoningEffort": true
71
+ "supportsStore": false
57
72
  }
58
73
  },
59
74
  "minimaxai/minimax-m2.7": {
60
75
  "compat": {
76
+ "thinkingFormat": "chat-template",
77
+ "chatTemplateKwargs": {
78
+ "thinking": { "$var": "thinking.enabled" },
79
+ "enable_thinking": { "$var": "thinking.enabled" }
80
+ },
61
81
  "supportsDeveloperRole": false
62
82
  }
83
+ },
84
+ "minimaxai/minimax-m3": {
85
+ "compat": {
86
+ "thinkingFormat": "chat-template",
87
+ "chatTemplateKwargs": {
88
+ "thinking_mode": { "$var": "thinking.effort" }
89
+ }
90
+ },
91
+ "thinkingLevelMap": {
92
+ "off": "disabled",
93
+ "minimal": "adaptive",
94
+ "low": null,
95
+ "medium": null,
96
+ "high": "enabled",
97
+ "xhigh": null
98
+ }
63
99
  }
64
100
  }
@@ -11,12 +11,14 @@
11
11
  * 5. session_start replays persisted discount events and sets footer status.
12
12
  * 6. model_select sets/clears footer status for lilac/non-lilac models.
13
13
  * 7. turn_end appends discount entry to session JSONL.
14
- * 8. before_provider_request refreshes discounts with a 30s cache.
14
+ * 8. before_provider_request refreshes discounts with a 60s cache.
15
15
  * 9. formatDiscountStatus returns fallbacks when data is missing.
16
16
  * 10. applyDiscountInPlace mutates the in-flight model's cost in place,
17
17
  * recomputed from list price so re-applied discounts never compound.
18
18
  * 11. before_provider_request mutates the bound (in-flight) model object so the
19
19
  * current turn's cost calc sees the discount in real time.
20
+ * 12. session_start schedules a 10-minute background /status poll to cover idle
21
+ * sessions; session_shutdown clears it.
20
22
  */
21
23
 
22
24
  import type { ExtensionAPI, ModelRegistry } from "@earendil-works/pi-coding-agent";
@@ -596,15 +598,16 @@ assert(
596
598
 
597
599
  console.log("\n--- Test 15: before_provider_request clears status after switch ---");
598
600
 
599
- // Regression: every 30s (when the discount TTL expires) before_provider_request
601
+ // Regression: every 60s (when the discount TTL expires) before_provider_request
600
602
  // awaits a fresh /status fetch. If the user switches to a non-lilac model during
601
603
  // that await, the post-await paint must clear (live model) instead of re-painting
602
604
  // the stale captured in-flight lilac model. Cost mutation still targets the
603
605
  // captured in-flight model (Tests 12-13); only the DISPLAY follows the live model.
604
606
 
605
- // Force the 30s TTL to look expired so the fetch path executes.
607
+ // Force the 60s TTL to look expired so the fetch path executes. Offset 120s for
608
+ // a clear 2x margin over the 60s TTL (the original used 60s over a 30s TTL).
606
609
  const realDateNow = Date.now;
607
- Date.now = () => realDateNow.call(Date) + 60000;
610
+ Date.now = () => realDateNow.call(Date) + 120000;
608
611
  try {
609
612
  globalThis.fetch = mockFetch({
610
613
  "/status": {
@@ -697,6 +700,86 @@ assert(
697
700
  "deferred session_start paints the NEW lilac model's discount (glm), not the stale kimi capture",
698
701
  );
699
702
 
703
+ // ─── Test 17: session_start schedules a 10-min idle poll; shutdown clears it ─
704
+
705
+ console.log("\n--- Test 17: session_start schedules idle poll; session_shutdown clears it ---");
706
+
707
+ // Lilac refreshes discounts ~every 10 minutes. session_start must schedule a
708
+ // background /status poll at that cadence to cover idle sessions (turn fetches
709
+ // only run when the user sends a message), and session_shutdown must clear it so
710
+ // it neither leaks nor keeps the process alive. Wrap the global timer APIs to
711
+ // capture the scheduled delay + handle, then confirm shutdown clears it.
712
+
713
+ const realSetInterval = globalThis.setInterval.bind(globalThis);
714
+ const realClearInterval = globalThis.clearInterval.bind(globalThis);
715
+ let scheduledDelay: number | null = null;
716
+ let scheduledHandle: ReturnType<typeof setInterval> | null = null;
717
+ const clearedHandles = new Set<ReturnType<typeof setInterval>>();
718
+
719
+ globalThis.setInterval = ((fn: (...args: any[]) => void, delay?: number, ...rest: any[]) => {
720
+ scheduledDelay = delay ?? null;
721
+ const h = realSetInterval(fn, delay as any, ...rest);
722
+ scheduledHandle = h;
723
+ return h;
724
+ }) as any;
725
+ globalThis.clearInterval = ((handle: ReturnType<typeof setInterval>) => {
726
+ clearedHandles.add(handle);
727
+ return realClearInterval(handle);
728
+ }) as any;
729
+
730
+ try {
731
+ // Benign fetch mock so the session_start fire-and-forget /models + /status
732
+ // fetch doesn't hit the network. (The poll itself never fires — 10 min — so
733
+ // only the startup fetch needs mocking here.)
734
+ globalThis.fetch = mockFetch({
735
+ "/models": { body: { data: [] } },
736
+ "/status": {
737
+ body: {
738
+ models: [
739
+ {
740
+ id: "moonshotai/kimi-k2.6",
741
+ current_subscription_supply_state: "healthy",
742
+ current_subscription_discount_percent: 25,
743
+ current_subscription_credit_multiplier: "0.75",
744
+ },
745
+ ],
746
+ },
747
+ },
748
+ }) as any;
749
+
750
+ const pollCtx: any = {
751
+ modelRegistry: mockRegistry,
752
+ ui: mockUi,
753
+ model: { id: "moonshotai/kimi-k2.6", provider: "lilac" },
754
+ sessionManager: { getBranch: () => [] },
755
+ };
756
+
757
+ for (const handler of handlers.get("session_start") || []) {
758
+ await handler({}, pollCtx);
759
+ }
760
+
761
+ assert(
762
+ scheduledDelay === 10 * 60 * 1000,
763
+ "session_start schedules a 10-minute (600000ms) /status poll for idle sessions",
764
+ );
765
+ assert(scheduledHandle !== null, "poll interval handle was captured");
766
+ const capturedHandle = scheduledHandle;
767
+
768
+ for (const handler of handlers.get("session_shutdown") || []) {
769
+ await handler({}, pollCtx);
770
+ }
771
+
772
+ assert(
773
+ capturedHandle !== null && clearedHandles.has(capturedHandle),
774
+ "session_shutdown clears the scheduled poll interval (no leak / process-hang)",
775
+ );
776
+ } finally {
777
+ // Restore globals; clear any interval this test scheduled before it can fire.
778
+ if (scheduledHandle) realClearInterval(scheduledHandle);
779
+ globalThis.setInterval = realSetInterval as any;
780
+ globalThis.clearInterval = realClearInterval as any;
781
+ }
782
+
700
783
  // ─── Cleanup ──────────────────────────────────────────────────────────────────
701
784
 
702
785
  globalThis.fetch = originalFetch;
@@ -147,7 +147,11 @@ function transformApiModel(apiModel, existingModelsMap) {
147
147
  maxTokens: apiModel.top_provider?.max_completion_tokens || apiModel.context_length || 131072,
148
148
  };
149
149
 
150
- // Add compat — all Lilac models use chat_template_kwargs for reasoning toggle
150
+ // Add compat — all Lilac models toggle reasoning via chat_template_kwargs, but
151
+ // the honored key differs per model family. Default newly discovered models to
152
+ // the forward-compatible both-keys form (works across all current Lilac
153
+ // templates); refine per-model in patch.json (e.g. GLM 5.2 adds reasoning_effort,
154
+ // MiniMax M3 uses the thinking_mode enum).
151
155
  const compat = {
152
156
  supportsDeveloperRole: true,
153
157
  supportsStore: false,
@@ -155,7 +159,11 @@ function transformApiModel(apiModel, existingModelsMap) {
155
159
  };
156
160
 
157
161
  if (hasReasoning) {
158
- compat.thinkingFormat = 'qwen-chat-template';
162
+ compat.thinkingFormat = 'chat-template';
163
+ compat.chatTemplateKwargs = {
164
+ thinking: { $var: 'thinking.enabled' },
165
+ enable_thinking: { $var: 'thinking.enabled' },
166
+ };
159
167
  }
160
168
 
161
169
  model.compat = compat;