pi-free 2.2.4 → 2.2.6

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/banner.svg +1 -1
  4. package/config.ts +58 -29
  5. package/constants.ts +1 -4
  6. package/index.ts +30 -24
  7. package/lib/built-in-toggle.ts +185 -18
  8. package/lib/provider-cache.ts +22 -0
  9. package/package.json +74 -74
  10. package/provider-failover/benchmark-lookup.ts +4 -1
  11. package/provider-failover/benchmarks-chunk-0.ts +570 -570
  12. package/provider-failover/benchmarks-chunk-1.ts +676 -676
  13. package/provider-failover/benchmarks-chunk-2.ts +673 -673
  14. package/provider-failover/benchmarks-chunk-3.ts +680 -680
  15. package/provider-failover/benchmarks-chunk-4.ts +683 -683
  16. package/provider-failover/benchmarks-chunk-5.ts +816 -474
  17. package/provider-helper.ts +64 -0
  18. package/providers/bai/bai.ts +8 -2
  19. package/providers/crofai/crofai.ts +8 -2
  20. package/providers/deepinfra/deepinfra.ts +8 -2
  21. package/providers/dynamic-built-in/index.ts +36 -26
  22. package/providers/kilo/kilo.ts +41 -22
  23. package/providers/novita/novita.ts +8 -2
  24. package/providers/openmodel/openmodel.ts +8 -2
  25. package/providers/qoder/models.ts +63 -175
  26. package/providers/qoder/qoder.ts +49 -84
  27. package/providers/qoder/stream.ts +182 -274
  28. package/providers/qoder/transform.ts +5 -2
  29. package/providers/routeway/routeway.ts +8 -2
  30. package/providers/sambanova/sambanova.ts +12 -6
  31. package/providers/together/together.ts +8 -2
  32. package/providers/tokenrouter/tokenrouter.ts +8 -2
  33. package/providers/zenmux/zenmux.ts +8 -2
  34. package/providers/codestral/codestral.ts +0 -128
  35. package/providers/qoder/encoding.ts +0 -48
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.6] - 2026-07-09
11
+
12
+ ### Changed
13
+
14
+ - **Faster startup (~30x)** — warm-cache load dropped from ~2.1s to ~70ms: providers now serve from a 1-hour disk cache and fetch live only on cold/stale cache. Extends the cache-first pattern (already used by Cline) to kilo, fastrouter, and all OpenAI-compatible fetchers (tokenrouter, zenmux, crofai, deepinfra, sambanova, together, novita, routeway, bai, openmodel); the dynamic built-in phase runs concurrently with the static providers.
15
+ - **Coding-Index debug logging is now opt-in** — `~/.pi/modelmatch.log` previously received one synchronous `appendFileSync` per model per match attempt at startup. Now off by default; set `PI_FREE_BENCHMARK_DEBUG=1` to re-enable.
16
+ - **Config reads are memoized** — `~/.pi/free.json` is parsed once and reused while its mtime is unchanged.
17
+
18
+ ### Fixed
19
+
20
+ - **Cache-poisoning guard** — a transient partial API response (a 200 returning a near-empty list) can no longer overwrite a healthy cached model list; fetches returning < 50% of the cached count keep the existing cache.
21
+
22
+ ### Removed
23
+
24
+ - **Codestral provider** — removed the Codestral provider (`codestral.mistral.ai`). Codestral's free tier is no longer available; use `MISTRAL_API_KEY` with pi's built-in dynamic Mistral provider instead.
25
+
26
+ ## [2.2.5] - 2026-06-28
27
+
28
+ ### Fixed
29
+
30
+ - **Qoder api2-v2 migration** — Move Qoder to the OpenAI-compatible `https://api2-v2.qoder.sh/model/v1/chat/completions` endpoint, replacing the legacy proxy-style request path. Streaming and non-streaming responses are normalized through the shared stream wrapper, stale legacy model cache entries are ignored, and Qoder now uses the standard basic/all toggle state instead of bespoke toggle logic. The provider avoids logging request bodies or credentials, clamps `max_tokens` to a valid positive value, propagates SSE error payloads, and guards against unbounded SSE buffers. Live validation confirmed the `lite` model works with saved Qoder auth; other account-gated models returned provider quota errors rather than protocol failures ([#285](https://github.com/apmantza/pi-free/pull/285)).
31
+
32
+ - **OpenCode built-in toggle before registry load** — `/toggle-opencode` and `/toggle-opencode-go` now fall back to direct `/models` discovery with saved OpenCode credentials when Pi's built-in model registry has not populated yet, instead of warning `models not loaded yet. Start a session first.` OpenCode-discovered models preserve the dynamic stream wrapper and are run through `isFreeModel` with the full model set so only `*-free` models are marked free; non-free models no longer appear as free in the picker just because OpenCode's model-list endpoint omits pricing ([#286](https://github.com/apmantza/pi-free/pull/286), [#287](https://github.com/apmantza/pi-free/pull/287)).
33
+
10
34
  ## [2.2.4] - 2026-06-27
11
35
 
12
36
  ### Added
package/README.md CHANGED
@@ -74,7 +74,7 @@ First run creates `~/.pi/free.json` automatically. Add keys there or use environ
74
74
  | Category | Providers |
75
75
  |---|---|
76
76
  | ✅ **Free** | Kilo, Cline, OpenRouter, OpenCode, LLM7, OpenModel, TokenRouter (1 free) |
77
- | 🔄 **Freemium** | Ollama Cloud, SambaNova, Codestral, AgentRouter |
77
+ | 🔄 **Freemium** | Ollama Cloud, SambaNova, AgentRouter |
78
78
  | 💳 **Paid** | ZenMux, CrofAI, DeepInfra, Together, Novita, Routeway, b.ai |
79
79
  | 🔧 **Dynamic** | Mistral, Groq, Cerebras, xAI, Hugging Face, FastRouter |
80
80
 
package/banner.svg CHANGED
@@ -95,7 +95,7 @@
95
95
  <text x="20" y="46" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Kilo · Cline · OpenCode</text>
96
96
  <text x="20" y="66" font-family="system-ui, sans-serif" font-size="11" fill="#a78bfa">OpenModel · AgentRouter</text>
97
97
  <text x="20" y="86" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Ollama Cloud · SambaNova</text>
98
- <text x="20" y="106" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Codestral · LLM7 · Novita</text>
98
+ <text x="20" y="106" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">LLM7 · Novita</text>
99
99
  <text x="20" y="126" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">ZenMux · CrofAI · DeepInfra</text>
100
100
  <text x="20" y="146" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Together · TokenRouter · FastRouter</text>
101
101
  <text x="20" y="166" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">+ dynamic (Mistral, Groq, …)</text>
package/config.ts CHANGED
@@ -9,7 +9,13 @@
9
9
  * (e.g. after toggle-{provider}) are visible immediately.
10
10
  */
11
11
 
12
- import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import {
13
+ chmodSync,
14
+ existsSync,
15
+ readFileSync,
16
+ statSync,
17
+ writeFileSync,
18
+ } from "node:fs";
13
19
  import { join } from "node:path";
14
20
  import {
15
21
  PROVIDER_BAI,
@@ -20,11 +26,11 @@ import {
20
26
  PROVIDER_OPENCODE,
21
27
  PROVIDER_OPENMODEL,
22
28
  PROVIDER_OPENROUTER,
29
+ PROVIDER_QODER,
23
30
  PROVIDER_ROUTEWAY,
24
31
  PROVIDER_TOKENROUTER,
25
32
  PROVIDER_ZENMUX,
26
33
  PROVIDER_CROFAI,
27
- PROVIDER_CODESTRAL,
28
34
  PROVIDER_LLM7,
29
35
  PROVIDER_DEEPINFRA,
30
36
  PROVIDER_SAMBANOVA,
@@ -39,6 +45,7 @@ export {
39
45
  PROVIDER_MODAL,
40
46
  PROVIDER_OPENCODE,
41
47
  PROVIDER_OPENROUTER,
48
+ PROVIDER_QODER,
42
49
  PROVIDER_QWEN,
43
50
  PROVIDER_ROUTEWAY,
44
51
  PROVIDER_TOKENROUTER,
@@ -63,7 +70,6 @@ interface PiFreeConfig {
63
70
  ollama_api_key?: string;
64
71
  zenmux_api_key?: string;
65
72
  crofai_api_key?: string;
66
- codestral_api_key?: string;
67
73
  llm7_api_key?: string;
68
74
  deepinfra_api_key?: string;
69
75
  sambanova_api_key?: string;
@@ -84,7 +90,6 @@ interface PiFreeConfig {
84
90
  cline_show_paid?: boolean;
85
91
  zenmux_show_paid?: boolean;
86
92
  crofai_show_paid?: boolean;
87
- codestral_show_paid?: boolean;
88
93
  llm7_show_paid?: boolean;
89
94
  deepinfra_show_paid?: boolean;
90
95
  sambanova_show_paid?: boolean;
@@ -97,6 +102,7 @@ interface PiFreeConfig {
97
102
  openmodel_show_paid?: boolean;
98
103
  openrouter_show_paid?: boolean;
99
104
  opencode_show_paid?: boolean;
105
+ qoder_show_paid?: boolean;
100
106
  }
101
107
 
102
108
  const CONFIG_TEMPLATE: PiFreeConfig = {
@@ -104,7 +110,6 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
104
110
  ollama_api_key: "",
105
111
  zenmux_api_key: "",
106
112
  crofai_api_key: "",
107
- codestral_api_key: "",
108
113
  llm7_api_key: "",
109
114
  deepinfra_api_key: "",
110
115
  sambanova_api_key: "",
@@ -126,7 +131,6 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
126
131
  cline_show_paid: false,
127
132
  zenmux_show_paid: false,
128
133
  crofai_show_paid: false,
129
- codestral_show_paid: false,
130
134
  llm7_show_paid: false,
131
135
  deepinfra_show_paid: false,
132
136
  sambanova_show_paid: false,
@@ -139,10 +143,19 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
139
143
  openmodel_show_paid: false,
140
144
  openrouter_show_paid: false,
141
145
  opencode_show_paid: false,
146
+ qoder_show_paid: false,
142
147
  };
143
148
 
144
149
  const CONFIG_PATH = join(PI_DATA_DIR, "free.json");
145
150
 
151
+ // Memoize the parsed config: re-read+parse only when the file's mtime changes.
152
+ // loadConfigFile() is called on every config getter (dozens of times at
153
+ // startup); this avoids repeated readFileSync + JSON.parse of the same file.
154
+ // Every write path uses writeFileSync (which bumps mtime), so the mtime check
155
+ // auto-invalidates after saveConfig()/updateConfig()/ensureConfigFile().
156
+ let cachedConfig: PiFreeConfig | null = null;
157
+ let cachedConfigMtime = -1;
158
+
146
159
  function ensureConfigFile(): void {
147
160
  try {
148
161
  ensureDir(PI_DATA_DIR);
@@ -209,17 +222,44 @@ function restrictConfigFilePermissions(): void {
209
222
  }
210
223
 
211
224
  export function loadConfigFile(): PiFreeConfig {
225
+ // Return the memoized parse when the file hasn't changed on disk.
226
+ // Use a single stat result for both the hit check and the cached mtime to
227
+ // reduce the TOCTOU window between the check and the read.
228
+ let mtime: number | undefined;
229
+ try {
230
+ mtime = statSync(CONFIG_PATH).mtimeMs;
231
+ if (cachedConfig !== null && mtime === cachedConfigMtime) {
232
+ // Return a frozen copy so callers cannot mutate the shared cache.
233
+ return Object.freeze(cachedConfig) as PiFreeConfig;
234
+ }
235
+ } catch {
236
+ // stat failed (e.g. file removed) — fall through to read+parse below
237
+ cachedConfig = null;
238
+ }
239
+
212
240
  try {
213
- return JSON.parse(
241
+ const parsed = JSON.parse(
214
242
  readFileSync(CONFIG_PATH, "utf8"),
215
243
  safeJsonReviver,
216
244
  ) as PiFreeConfig;
245
+ cachedConfig = parsed;
246
+ cachedConfigMtime = mtime ?? -1;
247
+ return Object.freeze(parsed) as PiFreeConfig;
217
248
  } catch (err) {
218
- _logger.error("Could not parse config file — returning empty config", {
219
- path: CONFIG_PATH,
220
- error: err instanceof Error ? err.message : String(err),
221
- });
222
- return {};
249
+ cachedConfig = null;
250
+ cachedConfigMtime = -1;
251
+ if (err instanceof SyntaxError) {
252
+ _logger.error("Config file is corrupt (invalid JSON) — returning empty config", {
253
+ path: CONFIG_PATH,
254
+ error: err.message,
255
+ });
256
+ } else {
257
+ _logger.error("Could not read config file — returning empty config", {
258
+ path: CONFIG_PATH,
259
+ error: err instanceof Error ? err.message : String(err),
260
+ });
261
+ }
262
+ return Object.freeze({}) as PiFreeConfig;
223
263
  }
224
264
  }
225
265
 
@@ -230,7 +270,11 @@ export function loadConfigFile(): PiFreeConfig {
230
270
  function readRawConfigFile(): string | undefined {
231
271
  try {
232
272
  return readFileSync(CONFIG_PATH, "utf8");
233
- } catch {
273
+ } catch (err) {
274
+ _logger.warn("Could not read config file", {
275
+ path: CONFIG_PATH,
276
+ error: err instanceof Error ? err.message : String(err),
277
+ });
234
278
  return undefined;
235
279
  }
236
280
  }
@@ -271,11 +315,6 @@ const PROVIDER_META: readonly ProviderMeta[] = [
271
315
  { id: PROVIDER_CLINE, prefix: "CLINE", showPaidKey: "cline_show_paid" },
272
316
  { id: PROVIDER_ZENMUX, prefix: "ZENMUX", showPaidKey: "zenmux_show_paid" },
273
317
  { id: PROVIDER_CROFAI, prefix: "CROFAI", showPaidKey: "crofai_show_paid" },
274
- {
275
- id: PROVIDER_CODESTRAL,
276
- prefix: "CODESTRAL",
277
- showPaidKey: "codestral_show_paid",
278
- },
279
318
  { id: PROVIDER_LLM7, prefix: "LLM7", showPaidKey: "llm7_show_paid" },
280
319
  {
281
320
  id: PROVIDER_DEEPINFRA,
@@ -325,6 +364,7 @@ const PROVIDER_META: readonly ProviderMeta[] = [
325
364
  prefix: "OPENCODE",
326
365
  showPaidKey: "opencode_show_paid",
327
366
  },
367
+ { id: PROVIDER_QODER, prefix: "QODER", showPaidKey: "qoder_show_paid" },
328
368
  ];
329
369
 
330
370
  const PROVIDER_META_BY_ID = new Map(PROVIDER_META.map((m) => [m.id, m]));
@@ -364,13 +404,6 @@ export function getCrofaiShowPaid(): boolean {
364
404
  return resolveBool("CROFAI_SHOW_PAID", loadConfigFile().crofai_show_paid);
365
405
  }
366
406
 
367
- export function getCodestralShowPaid(): boolean {
368
- return resolveBool(
369
- "CODESTRAL_SHOW_PAID",
370
- loadConfigFile().codestral_show_paid,
371
- );
372
- }
373
-
374
407
  export function getLlm7ShowPaid(): boolean {
375
408
  return resolveBool("LLM7_SHOW_PAID", loadConfigFile().llm7_show_paid);
376
409
  }
@@ -473,10 +506,6 @@ export function getCrofaiApiKey(): string | undefined {
473
506
  return resolve("CROFAI_API_KEY", loadConfigFile().crofai_api_key);
474
507
  }
475
508
 
476
- export function getCodestralApiKey(): string | undefined {
477
- return resolve("CODESTRAL_API_KEY", loadConfigFile().codestral_api_key);
478
- }
479
-
480
509
  export function getLlm7ApiKey(): string | undefined {
481
510
  return resolve("LLM7_API_KEY", loadConfigFile().llm7_api_key);
482
511
  }
package/constants.ts CHANGED
@@ -16,7 +16,6 @@ export const PROVIDER_QWEN = "qwen";
16
16
  export const PROVIDER_MODAL = "modal";
17
17
  export const PROVIDER_ZENMUX = "zenmux";
18
18
  export const PROVIDER_CROFAI = "crofai";
19
- export const PROVIDER_CODESTRAL = "codestral";
20
19
  export const PROVIDER_LLM7 = "llm7";
21
20
  export const PROVIDER_DEEPINFRA = "deepinfra";
22
21
  export const PROVIDER_SAMBANOVA = "sambanova";
@@ -42,7 +41,6 @@ export const ALL_UNIQUE_PROVIDERS = [
42
41
  PROVIDER_OLLAMA,
43
42
  PROVIDER_ZENMUX,
44
43
  PROVIDER_CROFAI,
45
- PROVIDER_CODESTRAL,
46
44
  PROVIDER_LLM7,
47
45
  PROVIDER_DEEPINFRA,
48
46
  PROVIDER_SAMBANOVA,
@@ -68,7 +66,6 @@ export const BASE_URL_QWEN =
68
66
  "https://dashscope.aliyuncs.com/compatible-mode/v1";
69
67
  export const BASE_URL_ZENMUX = "https://zenmux.ai/api/v1";
70
68
  export const BASE_URL_CROFAI = "https://crof.ai/v1";
71
- export const BASE_URL_CODESTRAL = "https://codestral.mistral.ai/v1";
72
69
  export const BASE_URL_LLM7 = "https://api.llm7.io/v1";
73
70
  export const BASE_URL_DEEPINFRA = "https://api.deepinfra.com/v1/openai";
74
71
  export const BASE_URL_SAMBANOVA = "https://api.sambanova.ai/v1";
@@ -83,7 +80,7 @@ export const BASE_URL_BAI = "https://api.b.ai/v1";
83
80
  * NOT include `/v1`. See {@link PROVIDER_OPENMODEL}.
84
81
  */
85
82
  export const BASE_URL_OPENMODEL = "https://api.openmodel.ai";
86
- export const BASE_URL_QODER = "https://api3.qoder.sh";
83
+ export const BASE_URL_QODER = "https://api2-v2.qoder.sh";
87
84
 
88
85
  /** Cline fetches free models from OpenRouter */
89
86
  export const BASE_URL_OPENROUTER = "https://openrouter.ai/api/v1";
package/index.ts CHANGED
@@ -31,7 +31,6 @@ import {
31
31
  } from "./lib/registry.ts";
32
32
  // Import unique provider extensions (only providers NOT built into pi)
33
33
  import cline from "./providers/cline/cline.ts";
34
- import codestral from "./providers/codestral/codestral.ts";
35
34
  import crofai from "./providers/crofai/crofai.ts";
36
35
  import kilo from "./providers/kilo/kilo.ts";
37
36
  import llm7 from "./providers/llm7/llm7.ts";
@@ -61,7 +60,6 @@ const UNIQUE_PROVIDERS: ReadonlyArray<(pi: ExtensionAPI) => Promise<void>> = [
61
60
  cline,
62
61
  zenmux,
63
62
  crofai,
64
- codestral,
65
63
  llm7,
66
64
  deepinfra,
67
65
  sambanova,
@@ -386,28 +384,36 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
386
384
  // Setup model telemetry (tracks real-world performance)
387
385
  setupTelemetry(pi);
388
386
 
389
- // Load all unique providers
390
- // Each provider will register itself with the global toggle system
391
- await Promise.allSettled(UNIQUE_PROVIDERS.map((setup) => setup(pi)));
392
-
393
- // Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face,
394
- // OpenRouter/OpenCode from Pi auth, and FastRouter public model discovery)
395
- try {
396
- const { setupDynamicBuiltInProviders } = await import(
397
- "./providers/dynamic-built-in/index.ts"
398
- );
399
- await setupDynamicBuiltInProviders(pi);
400
- } catch (err) {
401
- // Dynamic providers are a best-effort enhancement if the import
402
- // or init fails (e.g. upstream API change), continue with the
403
- // already-registered static providers rather than failing the whole
404
- // extension load. Log full error (message + stack) to the structured
405
- // log so the user can investigate, but never block startup.
406
- _logger.error("[pi-free] Dynamic built-in providers failed to load", {
407
- error: err instanceof Error ? err.message : String(err),
408
- stack: err instanceof Error ? err.stack : undefined,
409
- });
410
- }
387
+ // Load all unique providers + dynamic built-in providers CONCURRENTLY.
388
+ // Running the dynamic phase (e.g. FastRouter) in parallel with the static
389
+ // providers avoids serializing two independent network windows at startup.
390
+ // Each provider registers itself with the global toggle system.
391
+ const dynamicSetup = (async () => {
392
+ try {
393
+ const { setupDynamicBuiltInProviders } = await import(
394
+ "./providers/dynamic-built-in/index.ts"
395
+ );
396
+ await setupDynamicBuiltInProviders(pi);
397
+ } catch (err) {
398
+ // Dynamic providers are a best-effort enhancement — if the import
399
+ // or init fails (e.g. upstream API change), continue with the
400
+ // already-registered static providers rather than failing the whole
401
+ // extension load. Log full error (message + stack) to the structured
402
+ // log so the user can investigate, but never block startup.
403
+ _logger.error(
404
+ "[pi-free] Dynamic built-in providers failed to load",
405
+ {
406
+ error: err instanceof Error ? err.message : String(err),
407
+ stack: err instanceof Error ? err.stack : undefined,
408
+ },
409
+ );
410
+ }
411
+ })();
412
+
413
+ await Promise.allSettled([
414
+ ...UNIQUE_PROVIDERS.map((setup) => setup(pi)),
415
+ dynamicSetup,
416
+ ]);
411
417
 
412
418
  // Setup toggles for pi's built-in providers (e.g., OpenCode)
413
419
  setupBuiltInProviderToggles(pi);
@@ -16,7 +16,12 @@ import type {
16
16
  ExtensionAPI,
17
17
  ProviderModelConfig,
18
18
  } from "@earendil-works/pi-coding-agent";
19
- import { getOpencodeShowPaid, getOpenrouterShowPaid } from "../config.ts";
19
+ import {
20
+ getOpencodeApiKey,
21
+ getOpencodeShowPaid,
22
+ getOpenrouterApiKey,
23
+ getOpenrouterShowPaid,
24
+ } from "../config.ts";
20
25
  import { createLogger } from "./logger.ts";
21
26
  import {
22
27
  getProviderRegistry,
@@ -25,6 +30,7 @@ import {
25
30
  } from "./registry.ts";
26
31
  import { wrapSessionStartHandler } from "./session-start-metrics.ts";
27
32
  import { createToggleState } from "./toggle-state.ts";
33
+ import { fetchWithTimeout } from "./util.ts";
28
34
  import {
29
35
  OPENCODE_DYNAMIC_API,
30
36
  createOpenCodeSessionTracker,
@@ -53,12 +59,33 @@ function getOpenCodeSession() {
53
59
  interface BuiltInToggleConfig {
54
60
  id: string;
55
61
  getShowPaid: () => boolean;
62
+ baseUrl: string;
63
+ api: Api;
64
+ getApiKey: () => string | undefined;
56
65
  }
57
66
 
58
67
  const BUILT_IN_TOGGLE_PROVIDERS: BuiltInToggleConfig[] = [
59
- { id: "opencode", getShowPaid: getOpencodeShowPaid },
60
- { id: "opencode-go", getShowPaid: getOpencodeShowPaid },
61
- { id: "openrouter", getShowPaid: getOpenrouterShowPaid },
68
+ {
69
+ id: "opencode",
70
+ getShowPaid: getOpencodeShowPaid,
71
+ baseUrl: "https://opencode.ai/zen/v1",
72
+ api: OPENCODE_DYNAMIC_API,
73
+ getApiKey: getOpencodeApiKey,
74
+ },
75
+ {
76
+ id: "opencode-go",
77
+ getShowPaid: getOpencodeShowPaid,
78
+ baseUrl: "https://opencode.ai/zen/go/v1",
79
+ api: OPENCODE_DYNAMIC_API,
80
+ getApiKey: getOpencodeApiKey,
81
+ },
82
+ {
83
+ id: "openrouter",
84
+ getShowPaid: getOpenrouterShowPaid,
85
+ baseUrl: "https://openrouter.ai/api/v1",
86
+ api: "openai-completions",
87
+ getApiKey: getOpenrouterApiKey,
88
+ },
62
89
  ];
63
90
 
64
91
  // =============================================================================
@@ -74,6 +101,24 @@ interface BuiltInProviderState {
74
101
  const providerStates = new Map<string, BuiltInProviderState>();
75
102
  let commandsRegistered = false;
76
103
 
104
+ const ZERO_COST = Object.freeze({
105
+ input: 0,
106
+ output: 0,
107
+ cacheRead: 0,
108
+ cacheWrite: 0,
109
+ });
110
+
111
+ // OpenCode's /models endpoint does not expose real pricing. Use a tiny
112
+ // non-zero sentinel for known non-free models so the model picker does not
113
+ // label every OpenCode model as free. Authoritative free detection still uses
114
+ // _freeKnown/_isFree, not the sentinel amount.
115
+ const UNKNOWN_PAID_COST = Object.freeze({
116
+ input: 0.000001,
117
+ output: 0.000001,
118
+ cacheRead: 0,
119
+ cacheWrite: 0,
120
+ });
121
+
77
122
  // =============================================================================
78
123
  // Setup
79
124
  // =============================================================================
@@ -138,18 +183,87 @@ function tryCaptureProvider(
138
183
  const allModels = providerModels.map((m: Model<Api>) =>
139
184
  modelToProviderConfig(m, config.id),
140
185
  );
186
+
187
+ return createProviderState(pi, config, {
188
+ allModels,
189
+ baseUrl: providerModels[0].baseUrl,
190
+ api: providerModels[0].api,
191
+ apiKey: getApiKeyEnvForProvider(config.id),
192
+ source: "captured",
193
+ });
194
+ }
195
+
196
+ async function tryDiscoverProvider(
197
+ pi: ExtensionAPI,
198
+ config: BuiltInToggleConfig,
199
+ ): Promise<BuiltInProviderState | undefined> {
200
+ const apiKey = config.getApiKey();
201
+ if (!apiKey) return undefined;
202
+
203
+ try {
204
+ const response = await fetchWithTimeout(
205
+ `${config.baseUrl}/models`,
206
+ {
207
+ headers: {
208
+ Accept: "application/json",
209
+ Authorization: `Bearer ${apiKey}`,
210
+ },
211
+ },
212
+ 30_000,
213
+ );
214
+
215
+ if (!response.ok) {
216
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
217
+ }
218
+
219
+ const body = (await response.json()) as
220
+ | Array<Record<string, unknown>>
221
+ | { data?: Array<Record<string, unknown>> };
222
+ const rawModels = Array.isArray(body) ? body : (body.data ?? []);
223
+ const mappedModels = rawModels
224
+ .map((m) => rawModelToProviderConfig(m, config))
225
+ .filter((m): m is ProviderModelConfig & { _pricingKnown?: boolean } =>
226
+ m !== undefined,
227
+ );
228
+ const allModels = applyAuthoritativeFreeFlags(mappedModels, config.id);
229
+
230
+ if (allModels.length === 0) return undefined;
231
+
232
+ return createProviderState(pi, config, {
233
+ allModels,
234
+ baseUrl: config.baseUrl,
235
+ api: config.api,
236
+ apiKey,
237
+ source: "discovered",
238
+ });
239
+ } catch (err) {
240
+ _logger.warn(`[built-in-toggle] ${config.id}: on-demand discovery failed`, {
241
+ error: err instanceof Error ? err.message : String(err),
242
+ });
243
+ return undefined;
244
+ }
245
+ }
246
+
247
+ function createProviderState(
248
+ pi: ExtensionAPI,
249
+ config: BuiltInToggleConfig,
250
+ options: {
251
+ allModels: ProviderModelConfig[];
252
+ baseUrl: string;
253
+ api: Api;
254
+ apiKey: string;
255
+ source: "captured" | "discovered";
256
+ },
257
+ ): BuiltInProviderState {
258
+ const { allModels, baseUrl, api, apiKey, source } = options;
141
259
  const freeModels = allModels.filter((m: ProviderModelConfig) =>
142
260
  isFreeModel({ ...m, provider: config.id }, allModels),
143
261
  );
144
262
 
145
- const baseUrl = providerModels[0].baseUrl;
146
- const api = providerModels[0].api;
147
- const apiKeyEnv = getApiKeyEnvForProvider(config.id);
148
-
149
263
  const reRegister = (models: ProviderModelConfig[]) => {
150
264
  pi.registerProvider(config.id, {
151
265
  baseUrl,
152
- apiKey: apiKeyEnv,
266
+ apiKey,
153
267
  api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : api,
154
268
  ...(isOpenCodeProvider(config.id)
155
269
  ? { streamSimple: createOpenCodeStreamSimple(getOpenCodeSession()) }
@@ -171,7 +285,7 @@ function tryCaptureProvider(
171
285
  registerWithGlobalToggle(config.id, stored, reRegister, true);
172
286
 
173
287
  _logger.info(
174
- `[built-in-toggle] ${config.id}: captured ${allModels.length} models (${freeModels.length} free)`,
288
+ `[built-in-toggle] ${config.id}: ${source} ${allModels.length} models (${freeModels.length} free)`,
175
289
  );
176
290
 
177
291
  return state;
@@ -191,15 +305,21 @@ function registerToggleCommand(
191
305
  handler: async (_args, ctx) => {
192
306
  let state = providerStates.get(config.id);
193
307
  if (!state) {
194
- // Models may have loaded after session_start — try on-demand capture
308
+ // Models may have loaded after session_start — try on-demand capture.
195
309
  state = tryCaptureProvider(pi, config, ctx);
196
- if (!state) {
197
- ctx.ui.notify(
198
- `${config.id}: models not loaded yet. Start a session first.`,
199
- "warning",
200
- );
201
- return;
202
- }
310
+ }
311
+ if (!state) {
312
+ // If Pi has not exposed built-in models yet, fetch the provider's
313
+ // /models endpoint directly so /toggle-opencode works before the
314
+ // first chat session has populated the model registry.
315
+ state = await tryDiscoverProvider(pi, config);
316
+ }
317
+ if (!state) {
318
+ ctx.ui.notify(
319
+ `${config.id}: models not loaded yet and on-demand discovery failed. Check your API key, then try again.`,
320
+ "warning",
321
+ );
322
+ return;
203
323
  }
204
324
 
205
325
  const applied = state.toggleState.toggle(state.reRegister);
@@ -249,6 +369,53 @@ function modelToProviderConfig(
249
369
  return base;
250
370
  }
251
371
 
372
+ function rawModelToProviderConfig(
373
+ m: Record<string, unknown>,
374
+ config: BuiltInToggleConfig,
375
+ ): (ProviderModelConfig & { _pricingKnown?: boolean }) | undefined {
376
+ const id = String(m.id ?? "").trim();
377
+ if (!id) return undefined;
378
+ const inputModalities = Array.isArray(m.input_modalities)
379
+ ? m.input_modalities
380
+ : undefined;
381
+ const supportsImage = inputModalities?.includes("image") === true;
382
+ return {
383
+ id,
384
+ name: String(m.name ?? m.model ?? id),
385
+ api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : config.api,
386
+ reasoning: Boolean(m.reasoning ?? false),
387
+ input: supportsImage ? ["text", "image"] : ["text"],
388
+ cost: ZERO_COST,
389
+ contextWindow:
390
+ ((m.context_length ??
391
+ m.max_context_length ??
392
+ m.context_window) as number) ?? 128_000,
393
+ maxTokens: ((m.max_tokens ?? m.max_completion_tokens) as number) ?? 16_384,
394
+ _pricingKnown: false,
395
+ };
396
+ }
397
+
398
+ function applyAuthoritativeFreeFlags(
399
+ models: Array<ProviderModelConfig & { _pricingKnown?: boolean }>,
400
+ providerId: string,
401
+ ): Array<
402
+ ProviderModelConfig & {
403
+ _pricingKnown?: boolean;
404
+ _freeKnown?: boolean;
405
+ _isFree?: boolean;
406
+ }
407
+ > {
408
+ return models.map((model) => {
409
+ const isFree = isFreeModel({ ...model, provider: providerId }, models);
410
+ return {
411
+ ...model,
412
+ cost: isFree ? ZERO_COST : UNKNOWN_PAID_COST,
413
+ _freeKnown: true,
414
+ _isFree: isFree,
415
+ };
416
+ });
417
+ }
418
+
252
419
  function getApiKeyEnvForProvider(providerId: string): string {
253
420
  const envMap: Record<string, string> = {
254
421
  opencode: "$OPENCODE_API_KEY",
@@ -128,6 +128,28 @@ export async function saveProviderCache(
128
128
  _logger.debug(`Saved ${models.length} models to cache for ${providerId}`);
129
129
  }
130
130
 
131
+ /**
132
+ * Persist models to the cache, but refuse to overwrite a healthy existing
133
+ * entry with a drastically smaller list (a transient partial/error response).
134
+ * Also never caches an empty list. Returns true when the cache was written.
135
+ */
136
+ export async function saveProviderCacheGuarded(
137
+ providerId: string,
138
+ models: ProviderModelConfig[],
139
+ ): Promise<boolean> {
140
+ if (!models || models.length === 0) return false;
141
+ const existing = getProviderCacheEntry(providerId);
142
+ const existingCount = existing?.models.length ?? 0;
143
+ if (existingCount > 0 && models.length < existingCount * 0.5) {
144
+ _logger.debug(
145
+ `Not caching ${providerId}: ${models.length} models vs ${existingCount} cached (likely transient shrink)`,
146
+ );
147
+ return false;
148
+ }
149
+ await saveProviderCache(providerId, models);
150
+ return true;
151
+ }
152
+
131
153
  /**
132
154
  * Clear cached models for a provider.
133
155
  */