pi-free 2.0.11 → 2.0.13

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
@@ -20,7 +20,7 @@ When you install pi-free, it:
20
20
 
21
21
  3. **Filters to show only free models by default** for providers that expose pricing — You see only the models that cost $0 to use. Paid models are hidden until you explicitly toggle them on.
22
22
 
23
- 4. **Provides per-provider toggle commands** — Run `/toggle-{provider}` (e.g., `/toggle-kilo`, `/toggle-opencode`) to switch between free-only mode and showing all models including paid ones. Changes apply immediately and your preference is saved for the next Pi restart.
23
+ 4. **Provides per-provider toggle commands** — Run `/toggle-{provider}` (e.g., `/toggle-kilo`) to switch between free-only mode and showing all models including paid ones. Changes apply immediately and your preference is saved for the next Pi restart.
24
24
 
25
25
  5. **Handles authentication for you** — OAuth flows (Kilo, Cline) open your browser automatically; API keys are read from `~/.pi/free.json` or environment variables
26
26
 
@@ -46,7 +46,6 @@ Free models are shown by default — look for the provider prefixes:
46
46
 
47
47
  **✅ Free Models (no payment required):**
48
48
 
49
- - `opencode/` — OpenCode models (no setup required; toggle with `/toggle-opencode`)
50
49
  - `kilo/` — Kilo models (free models available immediately, more after `/login kilo`)
51
50
  - `openrouter/` — OpenRouter models (free account required)
52
51
  - `cline/` — Cline models (run `/login cline` to use)
@@ -64,6 +63,7 @@ Free models are shown by default — look for the provider prefixes:
64
63
  - `crofai/` — CrofAI OpenAI-compatible API (streaming, reasoning models)
65
64
  - `codestral/` — Codestral via Mistral (free Experiment plan: 2 req/min, 1B tokens/month)
66
65
  - `deepinfra/` — DeepInfra inference cloud ($5 one-time trial credit, no credit card)
66
+ - `novita/` — Novita AI (100+ open-source models, OpenAI-compatible, 3 free models)
67
67
 
68
68
  > **Note:** Paid providers may occasionally offer free models or promotional credits. The `isFreeModel` helper automatically detects free models based on provider pricing data or model names containing "free". For providers that don't expose pricing (like CrofAI), only models with "free" in their names are marked as free.
69
69
 
@@ -74,6 +74,8 @@ Free models are shown by default — look for the provider prefixes:
74
74
  - `cerebras/` — Cerebras models (when `CEREBRAS_API_KEY` set)
75
75
  - `xai/` — xAI models (when `XAI_API_KEY` set)
76
76
  - `huggingface/` — Hugging Face models (when `HF_TOKEN` set)
77
+ - `openrouter/` — OpenRouter models (fetched from openrouter.ai, when `OPENROUTER_API_KEY` set)
78
+ - `fastrouter/` — FastRouter models (always discovered, 170+ models, no auth for listing)
77
79
 
78
80
  **Note:** Fireworks is now a [built-in Pi provider](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#0681---2026-04-22) — no extension needed. Set `FIREWORKS_API_KEY` to use it directly.
79
81
 
@@ -82,7 +84,6 @@ Free models are shown by default — look for the provider prefixes:
82
84
  Want to see paid models too? Run the toggle command for your provider:
83
85
 
84
86
  ```
85
- /toggle-opencode # Toggle OpenCode (✅ offers free models)
86
87
  /toggle-kilo # Toggle Kilo (✅ offers free models)
87
88
  /toggle-openrouter # Toggle OpenRouter (✅ offers free models)
88
89
  /toggle-cline # Toggle Cline (✅ offers free models)
@@ -100,6 +101,8 @@ Want to see paid models too? Run the toggle command for your provider:
100
101
  /toggle-together # Toggle Together AI (💳 trial credit provider)
101
102
  /toggle-sambanova # Toggle SambaNova (🔄 freemium)
102
103
  /toggle-llm7 # Toggle LLM7 (✅ free gateway)
104
+ /toggle-novita # Toggle Novita AI (💳 paid — 3 free models)
105
+ /toggle-fastrouter # Toggle FastRouter (🔧 dynamic — always discovered)
103
106
  ```
104
107
 
105
108
  **Notes:**
@@ -108,10 +111,6 @@ Want to see paid models too? Run the toggle command for your provider:
108
111
  - **🔧 Dynamic providers** show all fetched models by default — the toggle filters the list when you have an API key configured
109
112
  - **Freemium providers** show all models by default; you manage your usage limits via their dashboards
110
113
 
111
- You'll see a notification like: `opencode: showing free models` or `opencode: showing all models`
112
-
113
- **Note:** Built-in provider toggles such as OpenCode and OpenRouter update in the current session — no restart needed.
114
-
115
114
  ### 4. Add API keys for more providers (optional)
116
115
 
117
116
  Some providers require a free account or API key.
@@ -198,7 +197,7 @@ Providers have different pricing models. pi-free handles them all:
198
197
 
199
198
  **Provider types:**
200
199
 
201
- - ✅ **Free providers** (OpenCode, Kilo, Cline) — Toggle between free-only vs paid models
200
+ - ✅ **Free providers** (Kilo, Cline) — Toggle between free-only vs paid models
202
201
  - 🔄 **Freemium** (NVIDIA, Ollama) — Free tier with limits, toggle shows all
203
202
  - 🔧 **Dynamic API** (Mistral, Groq, Cerebras, xAI) — Fetched when API key configured, toggle filters the list
204
203
 
@@ -213,17 +212,6 @@ Authentication is handled automatically:
213
212
 
214
213
  ## Using Free Models (No Setup Required)
215
214
 
216
- ### OpenCode
217
-
218
- Works immediately with zero setup:
219
-
220
- 1. Press `Ctrl+L`
221
- 2. Search for `opencode/`
222
- 3. Pick any model (e.g., `opencode/big-pickle`)
223
- 4. Start chatting
224
-
225
- No account, no API key, no OAuth. Run `/toggle-opencode` to switch between free and paid OpenCode models.
226
-
227
215
  ### Kilo (free models, more after login)
228
216
 
229
217
  Kilo shows free models immediately. To unlock all models, authenticate with Kilo's free OAuth:
@@ -442,28 +430,27 @@ export SAMBANOVA_API_KEY="..."
442
430
 
443
431
  Each provider has toggle commands to switch between free and all models:
444
432
 
445
- | Command | Action |
446
- | --------------------- | -------------------------------------------------------- |
447
- | `/toggle-opencode` | Toggle between free/all OpenCode models |
448
- | `/toggle-kilo` | Toggle between free/all Kilo models |
449
- | `/toggle-openrouter` | Toggle between free/all OpenRouter models |
450
- | `/toggle-cline` | Toggle between free/all Cline models |
451
- | `/toggle-nvidia` | Toggle between free/all NVIDIA models |
452
- | `/toggle-ollama` | Toggle between free/all Ollama Cloud models |
453
- | `/toggle-mistral` | Toggle between free/all Mistral models (🔧 dynamic) |
454
- | `/toggle-groq` | Toggle between free/all Groq models (🔧 dynamic) |
455
- | `/toggle-cerebras` | Toggle between free/all Cerebras models (🔧 dynamic) |
456
- | `/toggle-xai` | Toggle between free/all xAI models (🔧 dynamic) |
457
- | `/toggle-huggingface` | Toggle between free/all Hugging Face models (🔧 dynamic) |
458
- | `/toggle-codestral` | Toggle Codestral (💳 paid) |
459
- | `/toggle-deepinfra` | Toggle DeepInfra (💳 trial credit) |
460
- | `/toggle-together` | Toggle Together AI (💳 trial credit) |
461
- | `/toggle-sambanova` | Toggle SambaNova (🔄 freemium) |
462
- | `/toggle-llm7` | Toggle LLM7 ( free gateway) |
463
- | `/toggle-zenmux` | Toggle ZenMux (💳 paid) |
464
- | `/toggle-crofai` | Toggle CrofAI (💳 paid) |
465
- | `/ollama-cloud-refresh` | Re-fetch Ollama Cloud models live (no restart needed) |
466
- | `/probe-ollama` | Test Ollama Cloud models for 403 errors (auto-hide) |
433
+ | Command | Action |
434
+ | ----------------------- | -------------------------------------------------------- |
435
+ | `/toggle-kilo` | Toggle between free/all Kilo models |
436
+ | `/toggle-openrouter` | Toggle between free/all OpenRouter models |
437
+ | `/toggle-cline` | Toggle between free/all Cline models |
438
+ | `/toggle-nvidia` | Toggle between free/all NVIDIA models |
439
+ | `/toggle-ollama` | Toggle between free/all Ollama Cloud models |
440
+ | `/toggle-mistral` | Toggle between free/all Mistral models (🔧 dynamic) |
441
+ | `/toggle-groq` | Toggle between free/all Groq models (🔧 dynamic) |
442
+ | `/toggle-cerebras` | Toggle between free/all Cerebras models (🔧 dynamic) |
443
+ | `/toggle-xai` | Toggle between free/all xAI models (🔧 dynamic) |
444
+ | `/toggle-huggingface` | Toggle between free/all Hugging Face models (🔧 dynamic) |
445
+ | `/toggle-codestral` | Toggle Codestral (💳 paid) |
446
+ | `/toggle-deepinfra` | Toggle DeepInfra (💳 trial credit) |
447
+ | `/toggle-together` | Toggle Together AI (💳 trial credit) |
448
+ | `/toggle-sambanova` | Toggle SambaNova (🔄 freemium) |
449
+ | `/toggle-llm7` | Toggle LLM7 ( free gateway) |
450
+ | `/toggle-zenmux` | Toggle ZenMux (💳 paid) |
451
+ | `/toggle-crofai` | Toggle CrofAI (💳 paid) |
452
+ | `/ollama-cloud-refresh` | Re-fetch Ollama Cloud models live (no restart needed) |
453
+ | `/probe-ollama` | Test Ollama Cloud models for 403 errors (auto-hide) |
467
454
 
468
455
  **The toggle command:**
469
456
 
@@ -471,7 +458,7 @@ Each provider has toggle commands to switch between free and all models:
471
458
  - **For 🔄 freemium providers**: Shows all models by default; toggle switches between filtered and full list
472
459
  - **For 🔧 dynamic API providers**: Filters the model list when you have an API key configured
473
460
  - **Persists your preference** to `~/.pi/free.json` for next startup
474
- - Shows a notification: "opencode: showing free models" or "opencode: showing all models"
461
+
475
462
 
476
463
  ### Probe Commands (Health Check)
477
464
 
package/banner.svg CHANGED
@@ -110,7 +110,7 @@
110
110
  <text x="20" y="70" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Ollama Cloud · ZenMux</text>
111
111
  <text x="20" y="90" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">CrofAI · Codestral · LLM7</text>
112
112
  <text x="20" y="110" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">DeepInfra · SambaNova</text>
113
- <text x="20" y="130" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Together</text>
113
+ <text x="20" y="130" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Together · Novita · FastRouter</text>
114
114
  </g>
115
115
 
116
116
  <g transform="translate(970, 55)">
package/config.ts CHANGED
@@ -32,6 +32,8 @@ interface PiFreeConfig {
32
32
  deepinfra_api_key?: string;
33
33
  sambanova_api_key?: string;
34
34
  together_api_key?: string;
35
+ novita_api_key?: string;
36
+ fastrouter_api_key?: string;
35
37
  kilo_free_only?: boolean;
36
38
  hidden_models?: string[];
37
39
  free_only?: boolean;
@@ -45,6 +47,8 @@ interface PiFreeConfig {
45
47
  deepinfra_show_paid?: boolean;
46
48
  sambanova_show_paid?: boolean;
47
49
  together_show_paid?: boolean;
50
+ novita_show_paid?: boolean;
51
+ fastrouter_show_paid?: boolean;
48
52
  openrouter_show_paid?: boolean;
49
53
  opencode_show_paid?: boolean;
50
54
  }
@@ -59,6 +63,8 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
59
63
  deepinfra_api_key: "",
60
64
  sambanova_api_key: "",
61
65
  together_api_key: "",
66
+ novita_api_key: "",
67
+ fastrouter_api_key: "",
62
68
 
63
69
  kilo_free_only: false,
64
70
  hidden_models: [],
@@ -73,6 +79,8 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
73
79
  deepinfra_show_paid: false,
74
80
  sambanova_show_paid: false,
75
81
  together_show_paid: false,
82
+ novita_show_paid: false,
83
+ fastrouter_show_paid: false,
76
84
  openrouter_show_paid: false,
77
85
  opencode_show_paid: false,
78
86
  };
@@ -210,6 +218,17 @@ export function getTogetherShowPaid(): boolean {
210
218
  return resolveBool("TOGETHER_SHOW_PAID", loadConfigFile().together_show_paid);
211
219
  }
212
220
 
221
+ export function getNovitaShowPaid(): boolean {
222
+ return resolveBool("NOVITA_SHOW_PAID", loadConfigFile().novita_show_paid);
223
+ }
224
+
225
+ export function getFastrouterShowPaid(): boolean {
226
+ return resolveBool(
227
+ "FASTROUTER_SHOW_PAID",
228
+ loadConfigFile().fastrouter_show_paid,
229
+ );
230
+ }
231
+
213
232
  export function getOllamaShowPaid(): boolean {
214
233
  return resolveBool("OLLAMA_SHOW_PAID", loadConfigFile().ollama_show_paid);
215
234
  }
@@ -225,6 +244,41 @@ export function getOpencodeShowPaid(): boolean {
225
244
  return resolveBool("OPENCODE_SHOW_PAID", loadConfigFile().opencode_show_paid);
226
245
  }
227
246
 
247
+ export function getProviderShowPaid(providerId: string): boolean {
248
+ switch (providerId) {
249
+ case "kilo":
250
+ return getKiloShowPaid();
251
+ case "cline":
252
+ return getClineShowPaid();
253
+ case "zenmux":
254
+ return getZenmuxShowPaid();
255
+ case "crofai":
256
+ return getCrofaiShowPaid();
257
+ case "codestral":
258
+ return getCodestralShowPaid();
259
+ case "llm7":
260
+ return getLlm7ShowPaid();
261
+ case "deepinfra":
262
+ return getDeepinfraShowPaid();
263
+ case "sambanova":
264
+ return getSambanovaShowPaid();
265
+ case "together":
266
+ return getTogetherShowPaid();
267
+ case "novita":
268
+ return getNovitaShowPaid();
269
+ case "fastrouter":
270
+ return getFastrouterShowPaid();
271
+ case "ollama-cloud":
272
+ return getOllamaShowPaid();
273
+ case "openrouter":
274
+ return getOpenrouterShowPaid();
275
+ case "opencode":
276
+ return getOpencodeShowPaid();
277
+ default:
278
+ return false;
279
+ }
280
+ }
281
+
228
282
  // =============================================================================
229
283
  // Global free-only mode
230
284
  // =============================================================================
@@ -273,6 +327,14 @@ export function getTogetherApiKey(): string | undefined {
273
327
  return resolve("TOGETHER_AI_API_KEY", loadConfigFile().together_api_key);
274
328
  }
275
329
 
330
+ export function getNovitaApiKey(): string | undefined {
331
+ return resolve("NOVITA_API_KEY", loadConfigFile().novita_api_key);
332
+ }
333
+
334
+ export function getFastrouterApiKey(): string | undefined {
335
+ return resolve("FASTROUTER_API_KEY", loadConfigFile().fastrouter_api_key);
336
+ }
337
+
276
338
  export function getOllamaApiKey(): string | undefined {
277
339
  return resolve("OLLAMA_API_KEY", loadConfigFile().ollama_api_key);
278
340
  }
@@ -302,12 +364,47 @@ export function getHfToken(): string | undefined {
302
364
  return process.env.HF_TOKEN;
303
365
  }
304
366
 
367
+ /**
368
+ * Read an API key from ~/.pi/agent/auth.json.
369
+ * Pi stores built-in provider keys there (opencode, openrouter, etc.).
370
+ * Falls back to env var if auth.json is missing or key not found.
371
+ */
372
+ function readAuthJsonKey(
373
+ providerId: string,
374
+ envVar: string,
375
+ ): string | undefined {
376
+ // Check env var first (fast path)
377
+ const envVal = process.env[envVar];
378
+ if (envVal) return envVal;
379
+
380
+ // Check auth.json
381
+ try {
382
+ const authPath = join(PI_DIR, "agent", "auth.json");
383
+ if (!existsSync(authPath)) return undefined;
384
+ const raw = readFileSync(authPath, "utf8");
385
+ const auth = JSON.parse(raw) as Record<
386
+ string,
387
+ { type?: string; key?: string }
388
+ >;
389
+ const entry = auth[providerId];
390
+ if (entry?.key?.trim()) return entry.key;
391
+ } catch {
392
+ // auth.json missing or corrupt — silently skip
393
+ }
394
+ return undefined;
395
+ }
396
+
305
397
  /**
306
398
  * OpenRouter key — pi's built-in provider reads from ~/.pi/agent/auth.json.
307
- * pi-free only checks the env var to avoid stale keys from free.json.
399
+ * pi-free checks env var first, then auth.json.
308
400
  */
309
401
  export function getOpenrouterApiKey(): string | undefined {
310
- return process.env.OPENROUTER_API_KEY;
402
+ return readAuthJsonKey("openrouter", "OPENROUTER_API_KEY");
403
+ }
404
+
405
+ /** OpenCode key — pi's built-in provider. Read from env or auth.json. */
406
+ export function getOpencodeApiKey(): string | undefined {
407
+ return readAuthJsonKey("opencode", "OPENCODE_API_KEY");
311
408
  }
312
409
 
313
410
  // =============================================================================
package/constants.ts CHANGED
@@ -22,6 +22,7 @@ export const PROVIDER_LLM7 = "llm7";
22
22
  export const PROVIDER_DEEPINFRA = "deepinfra";
23
23
  export const PROVIDER_SAMBANOVA = "sambanova";
24
24
  export const PROVIDER_TOGETHER = "together";
25
+ export const PROVIDER_NOVITA = "novita";
25
26
 
26
27
  export const ALL_UNIQUE_PROVIDERS = [
27
28
  PROVIDER_KILO,
@@ -38,6 +39,7 @@ export const ALL_UNIQUE_PROVIDERS = [
38
39
  PROVIDER_DEEPINFRA,
39
40
  PROVIDER_SAMBANOVA,
40
41
  PROVIDER_TOGETHER,
42
+ PROVIDER_NOVITA,
41
43
  ] as const;
42
44
 
43
45
  // =============================================================================
@@ -59,6 +61,7 @@ export const BASE_URL_LLM7 = "https://api.llm7.io/v1";
59
61
  export const BASE_URL_DEEPINFRA = "https://api.deepinfra.com/v1/openai";
60
62
  export const BASE_URL_SAMBANOVA = "https://api.sambanova.ai/v1";
61
63
  export const BASE_URL_TOGETHER = "https://api.together.xyz/v1";
64
+ export const BASE_URL_NOVITA = "https://api.novita.ai/openai/v1";
62
65
 
63
66
  /** Cline fetches free models from OpenRouter */
64
67
  export const BASE_URL_OPENROUTER = "https://openrouter.ai/api/v1";
@@ -84,7 +87,6 @@ export const CLINE_AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
84
87
  // =============================================================================
85
88
 
86
89
  export const NVIDIA_MIN_SIZE_B = 70; // Minimum model size for NVIDIA NIM
87
- export const DEFAULT_MIN_SIZE_B = 30; // Default minimum model size for filtering
88
90
 
89
91
  // =============================================================================
90
92
  // Timeouts (milliseconds)
package/index.ts CHANGED
@@ -40,6 +40,7 @@ import llm7 from "./providers/llm7/llm7.ts";
40
40
  import deepinfra from "./providers/deepinfra/deepinfra.ts";
41
41
  import sambanova from "./providers/sambanova/sambanova.ts";
42
42
  import together from "./providers/together/together.ts";
43
+ import novita from "./providers/novita/novita.ts";
43
44
  import nvidia from "./providers/nvidia/nvidia.ts";
44
45
  import ollama from "./providers/ollama/ollama.ts";
45
46
  import zenmux from "./providers/zenmux/zenmux.ts";
@@ -57,7 +58,7 @@ function setupGlobalCommands(pi: ExtensionAPI) {
57
58
  handler: async (_args, ctx) => {
58
59
  const current = getGlobalFreeOnly();
59
60
  const next = !current;
60
- applyGlobalFilter(pi, next);
61
+ applyGlobalFilter(pi, next, { force: true });
61
62
 
62
63
  const registry = getProviderRegistry();
63
64
  const providerCount = registry.size;
@@ -210,10 +211,11 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
210
211
  deepinfra(pi),
211
212
  sambanova(pi),
212
213
  together(pi),
214
+ novita(pi),
213
215
  ]);
214
216
 
215
- // Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face)
216
- // These only activate if the user has configured API keys (OpenRouter works without key too)
217
+ // Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face,
218
+ // OpenRouter/OpenCode from Pi auth, and FastRouter public model discovery)
217
219
  const { setupDynamicBuiltInProviders } = await import(
218
220
  "./providers/dynamic-built-in/index.ts"
219
221
  );
@@ -18,11 +18,24 @@ import type {
18
18
  } from "@earendil-works/pi-coding-agent";
19
19
  import { getOpencodeShowPaid, getOpenrouterShowPaid } from "../config.ts";
20
20
  import { createLogger } from "./logger.ts";
21
- import { isFreeModel, registerWithGlobalToggle } from "./registry.ts";
21
+ import {
22
+ getProviderRegistry,
23
+ isFreeModel,
24
+ registerWithGlobalToggle,
25
+ } from "./registry.ts";
22
26
  import { createToggleState } from "./toggle-state.ts";
27
+ import {
28
+ OPENCODE_DYNAMIC_API,
29
+ createOpenCodeSessionTracker,
30
+ createOpenCodeStreamSimple,
31
+ isOpenCodeProvider,
32
+ } from "../providers/opencode-session.ts";
23
33
 
24
34
  const _logger = createLogger("built-in-toggle");
25
35
 
36
+ // OpenCode requires per-request ids; see createOpenCodeStreamSimple().
37
+ const _opencodeSession = createOpenCodeSessionTracker();
38
+
26
39
  // =============================================================================
27
40
  // Configuration
28
41
  // =============================================================================
@@ -34,6 +47,7 @@ interface BuiltInToggleConfig {
34
47
 
35
48
  const BUILT_IN_TOGGLE_PROVIDERS: BuiltInToggleConfig[] = [
36
49
  { id: "opencode", getShowPaid: getOpencodeShowPaid },
50
+ { id: "opencode-go", getShowPaid: getOpencodeShowPaid },
37
51
  { id: "openrouter", getShowPaid: getOpenrouterShowPaid },
38
52
  ];
39
53
 
@@ -55,68 +69,38 @@ let commandsRegistered = false;
55
69
  // =============================================================================
56
70
 
57
71
  export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
72
+ const activeConfigs = BUILT_IN_TOGGLE_PROVIDERS.filter(
73
+ (config) => !getProviderRegistry().has(config.id),
74
+ );
75
+
76
+ if (activeConfigs.length === 0) {
77
+ _logger.info(
78
+ "[built-in-toggle] OpenCode/OpenRouter already registered dynamically; skipping fallback capture",
79
+ );
80
+ return;
81
+ }
82
+
58
83
  // Register toggle commands once (available even before models load)
59
84
  if (!commandsRegistered) {
60
- for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
85
+ for (const config of activeConfigs) {
61
86
  registerToggleCommand(pi, config);
62
87
  }
63
- setupStatusBar(pi);
88
+ setupStatusBar(pi, activeConfigs);
64
89
  commandsRegistered = true;
65
90
  }
66
91
 
67
92
  // Capture built-in models on session start and apply initial filter
68
93
  pi.on("session_start", async (_event, ctx) => {
69
- const available = ctx.modelRegistry.getAvailable();
70
-
71
- for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
94
+ for (const config of activeConfigs) {
72
95
  if (providerStates.has(config.id)) {
73
- // Already captured this session — skip to avoid re-registering
96
+ // Already captured — skip to avoid re-registering
74
97
  continue;
75
98
  }
76
99
 
77
- const providerModels = available.filter(
78
- (m: Model<Api>) => m.provider === config.id,
79
- );
80
- if (providerModels.length === 0) continue;
81
-
82
- const allModels = providerModels.map(modelToProviderConfig);
83
- const freeModels = allModels.filter((m) =>
84
- isFreeModel({ ...m, provider: config.id }, allModels),
85
- );
86
-
87
- const baseUrl = providerModels[0].baseUrl;
88
- const api = providerModels[0].api;
89
- const apiKeyEnv = getApiKeyEnvForProvider(config.id);
90
-
91
- const reRegister = (models: ProviderModelConfig[]) => {
92
- pi.registerProvider(config.id, {
93
- baseUrl,
94
- apiKey: apiKeyEnv,
95
- api,
96
- models,
97
- });
98
- };
99
-
100
- const stored = { free: freeModels, all: allModels };
101
- const toggleState = createToggleState<ProviderModelConfig>({
102
- providerId: config.id,
103
- initialShowPaid: config.getShowPaid(),
104
- initialModels: stored,
105
- });
106
-
107
- providerStates.set(config.id, {
108
- stored,
109
- reRegister,
110
- toggleState,
111
- });
112
-
113
- registerWithGlobalToggle(config.id, stored, reRegister, true);
100
+ const state = tryCaptureProvider(pi, config, ctx);
101
+ if (!state) continue;
114
102
 
115
- _logger.info(
116
- `[built-in-toggle] ${config.id}: captured ${allModels.length} models (${freeModels.length} free)`,
117
- );
118
-
119
- const applied = toggleState.applyCurrent(reRegister);
103
+ const applied = state.toggleState.applyCurrent(state.reRegister);
120
104
  _logger.info(
121
105
  `[built-in-toggle] ${config.id}: applied ${applied.mode} mode with ${applied.models.length} models`,
122
106
  );
@@ -124,6 +108,63 @@ export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
124
108
  });
125
109
  }
126
110
 
111
+ // =============================================================================
112
+ // On-demand model capture (called by toggle command when state is missing)
113
+ // =============================================================================
114
+
115
+ function tryCaptureProvider(
116
+ pi: ExtensionAPI,
117
+ config: BuiltInToggleConfig,
118
+ ctx: any,
119
+ ): BuiltInProviderState | undefined {
120
+ const available = ctx.modelRegistry.getAvailable();
121
+ const providerModels = available.filter(
122
+ (m: Model<Api>) => m.provider === config.id,
123
+ );
124
+ if (providerModels.length === 0) return undefined;
125
+
126
+ const allModels = providerModels.map((m: Model<Api>) =>
127
+ modelToProviderConfig(m, config.id),
128
+ );
129
+ const freeModels = allModels.filter((m: ProviderModelConfig) =>
130
+ isFreeModel({ ...m, provider: config.id }, allModels),
131
+ );
132
+
133
+ const baseUrl = providerModels[0].baseUrl;
134
+ const api = providerModels[0].api;
135
+ const apiKeyEnv = getApiKeyEnvForProvider(config.id);
136
+
137
+ const reRegister = (models: ProviderModelConfig[]) => {
138
+ pi.registerProvider(config.id, {
139
+ baseUrl,
140
+ apiKey: apiKeyEnv,
141
+ api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : api,
142
+ ...(isOpenCodeProvider(config.id)
143
+ ? { streamSimple: createOpenCodeStreamSimple(_opencodeSession) }
144
+ : {}),
145
+ models,
146
+ });
147
+ };
148
+
149
+ const stored = { free: freeModels, all: allModels };
150
+ const toggleState = createToggleState<ProviderModelConfig>({
151
+ providerId: config.id,
152
+ initialShowPaid: config.getShowPaid(),
153
+ initialModels: stored,
154
+ });
155
+
156
+ const state: BuiltInProviderState = { stored, reRegister, toggleState };
157
+ providerStates.set(config.id, state);
158
+
159
+ registerWithGlobalToggle(config.id, stored, reRegister, true);
160
+
161
+ _logger.info(
162
+ `[built-in-toggle] ${config.id}: captured ${allModels.length} models (${freeModels.length} free)`,
163
+ );
164
+
165
+ return state;
166
+ }
167
+
127
168
  // =============================================================================
128
169
  // Per-provider toggle command
129
170
  // =============================================================================
@@ -136,13 +177,17 @@ function registerToggleCommand(
136
177
  pi.registerCommand(commandName, {
137
178
  description: `Toggle free/paid ${config.id} models`,
138
179
  handler: async (_args, ctx) => {
139
- const state = providerStates.get(config.id);
180
+ let state = providerStates.get(config.id);
140
181
  if (!state) {
141
- ctx.ui.notify(
142
- `${config.id}: models not loaded yet. Start a session first.`,
143
- "warning",
144
- );
145
- return;
182
+ // Models may have loaded after session_start — try on-demand capture
183
+ state = tryCaptureProvider(pi, config, ctx);
184
+ if (!state) {
185
+ ctx.ui.notify(
186
+ `${config.id}: models not loaded yet. Start a session first.`,
187
+ "warning",
188
+ );
189
+ return;
190
+ }
146
191
  }
147
192
 
148
193
  const applied = state.toggleState.toggle(state.reRegister);
@@ -166,8 +211,11 @@ function registerToggleCommand(
166
211
  // Helpers
167
212
  // =============================================================================
168
213
 
169
- function modelToProviderConfig(m: Model<Api>): ProviderModelConfig {
170
- return {
214
+ function modelToProviderConfig(
215
+ m: Model<Api>,
216
+ providerId?: string,
217
+ ): ProviderModelConfig {
218
+ const base: ProviderModelConfig = {
171
219
  id: m.id,
172
220
  name: m.name,
173
221
  api: m.api,
@@ -179,18 +227,29 @@ function modelToProviderConfig(m: Model<Api>): ProviderModelConfig {
179
227
  headers: m.headers,
180
228
  compat: (m as any).compat,
181
229
  };
230
+
231
+ // Use a custom OpenCode API wrapper so per-request headers are regenerated
232
+ // for every LLM call instead of being frozen at registration time.
233
+ if (providerId && isOpenCodeProvider(providerId)) {
234
+ base.api = OPENCODE_DYNAMIC_API;
235
+ }
236
+
237
+ return base;
182
238
  }
183
239
 
184
240
  // =============================================================================
185
241
  // Status bar for provider selection
186
242
  // =============================================================================
187
243
 
188
- function setupStatusBar(pi: ExtensionAPI): void {
244
+ function setupStatusBar(
245
+ pi: ExtensionAPI,
246
+ configs: BuiltInToggleConfig[],
247
+ ): void {
189
248
  pi.on("model_select", (_event, ctx) => {
190
249
  const selected = _event.model?.provider;
191
250
 
192
- // Clear status for all built-in toggle providers
193
- for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
251
+ // Clear status for all fallback-captured built-in providers
252
+ for (const config of configs) {
194
253
  if (selected !== config.id) {
195
254
  ctx.ui.setStatus(`${config.id}-status`, undefined);
196
255
  }
@@ -220,6 +279,7 @@ function setupStatusBar(pi: ExtensionAPI): void {
220
279
  function getApiKeyEnvForProvider(providerId: string): string {
221
280
  const envMap: Record<string, string> = {
222
281
  opencode: "OPENCODE_API_KEY",
282
+ "opencode-go": "OPENCODE_API_KEY",
223
283
  openrouter: "OPENROUTER_API_KEY",
224
284
  };
225
285
  return envMap[providerId] || `${providerId.toUpperCase()}_API_KEY`;