pi-free 2.0.11 → 2.0.12

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/CHANGELOG.md CHANGED
@@ -5,7 +5,39 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [2.0.11] - 2026-05-08
8
+ ## [Unreleased]
9
+
10
+ ## [2.0.12] - 2026-05-13
11
+
12
+ ### Added
13
+
14
+ - **Novita AI provider** — OpenAI-compatible API at `api.novita.ai/openai/v1` with 100+ open-source models. Non-standard but rich metadata: per-model pricing (`input_token_price_per_m`), context size, max output tokens, reasoning/vision features, and model descriptions. 3 free models, 99 paid.
15
+
16
+ - **FastRouter provider** — OpenRouter-compatible API at `api.fastrouter.ai/api/v1` with 170+ models. Always discovered (no auth needed for model listing). Full pricing, context lengths, and feature metadata. 129 text models (6 free, 123 paid) after filtering image/video. Set `FASTROUTER_API_KEY` for chat completions.
17
+
18
+ - **Dynamic model fetching for OpenCode and OpenRouter** — Pi's built-in providers now get their models fetched dynamically from the API (`opencode.ai/zen/v1/models` and `openrouter.ai/api/v1/models`), same as Mistral, Groq, Cerebras, and xAI. Overwrites Pi's defaults with the full model list. OpenCode uses name-based free detection (API returns no pricing); OpenRouter uses full cost-based detection.
19
+
20
+ - **API key reading from `~/.pi/agent/auth.json`** — `getOpencodeApiKey()` and `getOpenrouterApiKey()` now fall back to Pi's auth.json when the env var isn't set, matching how Pi's built-in providers read their keys.
21
+
22
+ ### Changed
23
+
24
+ - **`_pricingKnown` guard in `isFreeModel`** — Providers can now signal whether pricing data is authoritative. When `_pricingKnown` is explicitly `false` (API returned no pricing), `isFreeModel` falls back to name-only detection (checks for "free" in the model name). This eliminates false positives where missing pricing data was treated as $0 cost. All affected providers (ZenMux, Together, CrofAI, dynamic-built-in, fetchOpenAICompatibleModels, deepinfra, sambanova, novita) now set this flag correctly.
25
+
26
+ - **All providers now use `isFreeModel` consistently** — Together switched from hardcoded `cost===0` check to `isFreeModel`. DeepInfra and SambaNova switched from manual free lists to `isFreeModel` with proper `_pricingKnown` metadata. NVIDIA, Codestral, and Ollama explicitly documented as free-tier providers (`freeModels = allModels`).
27
+
28
+ - **Unified OpenRouter-based providers** — Kilo, OpenRouter, and Cline now share the same `fetchOpenRouterCompatibleModels` / OpenRouter API logic.
29
+
30
+ ### Removed
31
+
32
+ - **`DEFAULT_MIN_SIZE_B` (30B minimum model size filter)** — Removed from `model-fetcher.ts` and `cline-models.ts`. All models are now shown regardless of parameter count. NVIDIA still uses its own 70B threshold (`NVIDIA_MIN_SIZE_B`).
33
+
34
+ ### Fixed
35
+
36
+ - **ZenMux false free classifications** — Models without `pricings` data (DeepSeek Chat V3.1, Kimi K2 0711, Claude 3.7 Sonnet) were incorrectly classified as free because missing pricing defaulted to $0. Fixed to 3 genuinely free models (down from 6 false positives).
37
+
38
+ - **Together AI, CrofAI, dynamic-built-in missing-pricing false positives** — Same `?? 0` pattern across multiple providers could mark unpriced models as free. All now set `_pricingKnown: false` when pricing is absent from the API response.
39
+
40
+ ## [2.0.10] - 2026-05-08
9
41
 
10
42
  ### Fixed
11
43
 
package/README.md CHANGED
@@ -64,6 +64,7 @@ Free models are shown by default — look for the provider prefixes:
64
64
  - `crofai/` — CrofAI OpenAI-compatible API (streaming, reasoning models)
65
65
  - `codestral/` — Codestral via Mistral (free Experiment plan: 2 req/min, 1B tokens/month)
66
66
  - `deepinfra/` — DeepInfra inference cloud ($5 one-time trial credit, no credit card)
67
+ - `novita/` — Novita AI (100+ open-source models, OpenAI-compatible, 3 free models)
67
68
 
68
69
  > **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
70
 
@@ -74,6 +75,9 @@ Free models are shown by default — look for the provider prefixes:
74
75
  - `cerebras/` — Cerebras models (when `CEREBRAS_API_KEY` set)
75
76
  - `xai/` — xAI models (when `XAI_API_KEY` set)
76
77
  - `huggingface/` — Hugging Face models (when `HF_TOKEN` set)
78
+ - `opencode/` — OpenCode models (fetched from opencode.ai/zen/v1, when `OPENCODE_API_KEY` set)
79
+ - `openrouter/` — OpenRouter models (fetched from openrouter.ai, when `OPENROUTER_API_KEY` set)
80
+ - `fastrouter/` — FastRouter models (always discovered, 170+ models, no auth for listing)
77
81
 
78
82
  **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
83
 
@@ -100,6 +104,8 @@ Want to see paid models too? Run the toggle command for your provider:
100
104
  /toggle-together # Toggle Together AI (💳 trial credit provider)
101
105
  /toggle-sambanova # Toggle SambaNova (🔄 freemium)
102
106
  /toggle-llm7 # Toggle LLM7 (✅ free gateway)
107
+ /toggle-novita # Toggle Novita AI (💳 paid — 3 free models)
108
+ /toggle-fastrouter # Toggle FastRouter (🔧 dynamic — always discovered)
103
109
  ```
104
110
 
105
111
  **Notes:**
@@ -442,28 +448,28 @@ export SAMBANOVA_API_KEY="..."
442
448
 
443
449
  Each provider has toggle commands to switch between free and all models:
444
450
 
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) |
451
+ | Command | Action |
452
+ | ----------------------- | -------------------------------------------------------- |
453
+ | `/toggle-opencode` | Toggle between free/all OpenCode models |
454
+ | `/toggle-kilo` | Toggle between free/all Kilo models |
455
+ | `/toggle-openrouter` | Toggle between free/all OpenRouter models |
456
+ | `/toggle-cline` | Toggle between free/all Cline models |
457
+ | `/toggle-nvidia` | Toggle between free/all NVIDIA models |
458
+ | `/toggle-ollama` | Toggle between free/all Ollama Cloud models |
459
+ | `/toggle-mistral` | Toggle between free/all Mistral models (🔧 dynamic) |
460
+ | `/toggle-groq` | Toggle between free/all Groq models (🔧 dynamic) |
461
+ | `/toggle-cerebras` | Toggle between free/all Cerebras models (🔧 dynamic) |
462
+ | `/toggle-xai` | Toggle between free/all xAI models (🔧 dynamic) |
463
+ | `/toggle-huggingface` | Toggle between free/all Hugging Face models (🔧 dynamic) |
464
+ | `/toggle-codestral` | Toggle Codestral (💳 paid) |
465
+ | `/toggle-deepinfra` | Toggle DeepInfra (💳 trial credit) |
466
+ | `/toggle-together` | Toggle Together AI (💳 trial credit) |
467
+ | `/toggle-sambanova` | Toggle SambaNova (🔄 freemium) |
468
+ | `/toggle-llm7` | Toggle LLM7 (✅ free gateway) |
469
+ | `/toggle-zenmux` | Toggle ZenMux (💳 paid) |
470
+ | `/toggle-crofai` | Toggle CrofAI (💳 paid) |
471
+ | `/ollama-cloud-refresh` | Re-fetch Ollama Cloud models live (no restart needed) |
472
+ | `/probe-ollama` | Test Ollama Cloud models for 403 errors (auto-hide) |
467
473
 
468
474
  **The toggle command:**
469
475
 
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,7 +18,11 @@ 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";
23
27
 
24
28
  const _logger = createLogger("built-in-toggle");
@@ -55,68 +59,38 @@ let commandsRegistered = false;
55
59
  // =============================================================================
56
60
 
57
61
  export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
62
+ const activeConfigs = BUILT_IN_TOGGLE_PROVIDERS.filter(
63
+ (config) => !getProviderRegistry().has(config.id),
64
+ );
65
+
66
+ if (activeConfigs.length === 0) {
67
+ _logger.info(
68
+ "[built-in-toggle] OpenCode/OpenRouter already registered dynamically; skipping fallback capture",
69
+ );
70
+ return;
71
+ }
72
+
58
73
  // Register toggle commands once (available even before models load)
59
74
  if (!commandsRegistered) {
60
- for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
75
+ for (const config of activeConfigs) {
61
76
  registerToggleCommand(pi, config);
62
77
  }
63
- setupStatusBar(pi);
78
+ setupStatusBar(pi, activeConfigs);
64
79
  commandsRegistered = true;
65
80
  }
66
81
 
67
82
  // Capture built-in models on session start and apply initial filter
68
83
  pi.on("session_start", async (_event, ctx) => {
69
- const available = ctx.modelRegistry.getAvailable();
70
-
71
- for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
84
+ for (const config of activeConfigs) {
72
85
  if (providerStates.has(config.id)) {
73
- // Already captured this session — skip to avoid re-registering
86
+ // Already captured — skip to avoid re-registering
74
87
  continue;
75
88
  }
76
89
 
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);
90
+ const state = tryCaptureProvider(pi, config, ctx);
91
+ if (!state) continue;
114
92
 
115
- _logger.info(
116
- `[built-in-toggle] ${config.id}: captured ${allModels.length} models (${freeModels.length} free)`,
117
- );
118
-
119
- const applied = toggleState.applyCurrent(reRegister);
93
+ const applied = state.toggleState.applyCurrent(state.reRegister);
120
94
  _logger.info(
121
95
  `[built-in-toggle] ${config.id}: applied ${applied.mode} mode with ${applied.models.length} models`,
122
96
  );
@@ -124,6 +98,58 @@ export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
124
98
  });
125
99
  }
126
100
 
101
+ // =============================================================================
102
+ // On-demand model capture (called by toggle command when state is missing)
103
+ // =============================================================================
104
+
105
+ function tryCaptureProvider(
106
+ pi: ExtensionAPI,
107
+ config: BuiltInToggleConfig,
108
+ ctx: any,
109
+ ): BuiltInProviderState | undefined {
110
+ const available = ctx.modelRegistry.getAvailable();
111
+ const providerModels = available.filter(
112
+ (m: Model<Api>) => m.provider === config.id,
113
+ );
114
+ if (providerModels.length === 0) return undefined;
115
+
116
+ const allModels = providerModels.map(modelToProviderConfig);
117
+ const freeModels = allModels.filter((m: ProviderModelConfig) =>
118
+ isFreeModel({ ...m, provider: config.id }, allModels),
119
+ );
120
+
121
+ const baseUrl = providerModels[0].baseUrl;
122
+ const api = providerModels[0].api;
123
+ const apiKeyEnv = getApiKeyEnvForProvider(config.id);
124
+
125
+ const reRegister = (models: ProviderModelConfig[]) => {
126
+ pi.registerProvider(config.id, {
127
+ baseUrl,
128
+ apiKey: apiKeyEnv,
129
+ api,
130
+ models,
131
+ });
132
+ };
133
+
134
+ const stored = { free: freeModels, all: allModels };
135
+ const toggleState = createToggleState<ProviderModelConfig>({
136
+ providerId: config.id,
137
+ initialShowPaid: config.getShowPaid(),
138
+ initialModels: stored,
139
+ });
140
+
141
+ const state: BuiltInProviderState = { stored, reRegister, toggleState };
142
+ providerStates.set(config.id, state);
143
+
144
+ registerWithGlobalToggle(config.id, stored, reRegister, true);
145
+
146
+ _logger.info(
147
+ `[built-in-toggle] ${config.id}: captured ${allModels.length} models (${freeModels.length} free)`,
148
+ );
149
+
150
+ return state;
151
+ }
152
+
127
153
  // =============================================================================
128
154
  // Per-provider toggle command
129
155
  // =============================================================================
@@ -136,13 +162,17 @@ function registerToggleCommand(
136
162
  pi.registerCommand(commandName, {
137
163
  description: `Toggle free/paid ${config.id} models`,
138
164
  handler: async (_args, ctx) => {
139
- const state = providerStates.get(config.id);
165
+ let state = providerStates.get(config.id);
140
166
  if (!state) {
141
- ctx.ui.notify(
142
- `${config.id}: models not loaded yet. Start a session first.`,
143
- "warning",
144
- );
145
- return;
167
+ // Models may have loaded after session_start — try on-demand capture
168
+ state = tryCaptureProvider(pi, config, ctx);
169
+ if (!state) {
170
+ ctx.ui.notify(
171
+ `${config.id}: models not loaded yet. Start a session first.`,
172
+ "warning",
173
+ );
174
+ return;
175
+ }
146
176
  }
147
177
 
148
178
  const applied = state.toggleState.toggle(state.reRegister);
@@ -185,12 +215,15 @@ function modelToProviderConfig(m: Model<Api>): ProviderModelConfig {
185
215
  // Status bar for provider selection
186
216
  // =============================================================================
187
217
 
188
- function setupStatusBar(pi: ExtensionAPI): void {
218
+ function setupStatusBar(
219
+ pi: ExtensionAPI,
220
+ configs: BuiltInToggleConfig[],
221
+ ): void {
189
222
  pi.on("model_select", (_event, ctx) => {
190
223
  const selected = _event.model?.provider;
191
224
 
192
- // Clear status for all built-in toggle providers
193
- for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
225
+ // Clear status for all fallback-captured built-in providers
226
+ for (const config of configs) {
194
227
  if (selected !== config.id) {
195
228
  ctx.ui.setStatus(`${config.id}-status`, undefined);
196
229
  }
package/lib/registry.ts CHANGED
@@ -9,7 +9,7 @@ import type {
9
9
  ExtensionAPI,
10
10
  ProviderModelConfig,
11
11
  } from "@earendil-works/pi-coding-agent";
12
- import { getFreeOnly, saveConfig } from "../config.ts";
12
+ import { getFreeOnly, getProviderShowPaid, saveConfig } from "../config.ts";
13
13
  import { createLogger } from "./logger.ts";
14
14
 
15
15
  const _logger = createLogger("pi-free");
@@ -82,7 +82,7 @@ function detectPricingExposed(allModels: ProviderModelConfig[]): boolean {
82
82
  * @returns true if the model is definitively free per the provider's API
83
83
  */
84
84
  export function isFreeModel(
85
- model: ProviderModelConfig & { provider?: string },
85
+ model: ProviderModelConfig & { provider?: string; _pricingKnown?: boolean },
86
86
  allModels?: ProviderModelConfig[],
87
87
  ): boolean {
88
88
  return isFreeModelInternal(model, allModels);
@@ -90,7 +90,7 @@ export function isFreeModel(
90
90
 
91
91
  // Internal implementation to work around TypeScript filter callback issues
92
92
  function isFreeModelInternal(
93
- model: ProviderModelConfig & { provider?: string },
93
+ model: ProviderModelConfig & { provider?: string; _pricingKnown?: boolean },
94
94
  allModels: ProviderModelConfig[] | undefined,
95
95
  ): boolean {
96
96
  // Determine if pricing is exposed
@@ -106,12 +106,20 @@ function isFreeModelInternal(
106
106
  pricingExposed = true;
107
107
  }
108
108
 
109
- // Route A: Pricing-exposed providers - use OR logic
110
- // Model is free if EITHER cost is zero OR name contains "free"
109
+ // Route A: Pricing-exposed providers
110
+ // Model is free if EITHER cost is zero OR name contains "free".
111
+ // BUT: when _pricingKnown is explicitly false (API returned no pricing data),
112
+ // cost values are untrustworthy defaults — fall back to name-only detection.
111
113
  if (pricingExposed) {
112
114
  const isZeroCost =
113
115
  (model.cost?.input ?? 0) === 0 && (model.cost?.output ?? 0) === 0;
114
116
  const hasFreeInName = model.name.toLowerCase().includes("free");
117
+
118
+ // Pricing missing for this specific model — only trust name-based signal
119
+ if (model._pricingKnown === false) {
120
+ return hasFreeInName;
121
+ }
122
+
115
123
  return isZeroCost || hasFreeInName;
116
124
  }
117
125
 
@@ -156,12 +164,32 @@ export function getProviderRegistry(): ReadonlyMap<string, ProviderEntry> {
156
164
  // Global filter application
157
165
  // =============================================================================
158
166
 
167
+ function showAllForProvider(providerId: string, entry: ProviderEntry): void {
168
+ const allModels =
169
+ entry.stored.all.length > 0 ? entry.stored.all : entry.stored.free;
170
+ if (allModels.length > 0) {
171
+ entry.reRegister(allModels);
172
+ _logger.info(
173
+ `[pi-free] ${providerId}: showing all ${allModels.length} models`,
174
+ );
175
+ }
176
+ }
177
+
159
178
  function applyFilterToProvider(
160
179
  providerId: string,
161
180
  entry: ProviderEntry,
162
181
  freeOnly: boolean,
182
+ force: boolean,
163
183
  ): void {
164
184
  if (freeOnly) {
185
+ if (!force && getProviderShowPaid(providerId)) {
186
+ showAllForProvider(providerId, entry);
187
+ _logger.info(
188
+ `[pi-free] ${providerId}: preserved persisted all-models toggle`,
189
+ );
190
+ return;
191
+ }
192
+
165
193
  if (entry.stored.free.length > 0) {
166
194
  entry.reRegister(entry.stored.free);
167
195
  _logger.info(
@@ -171,25 +199,21 @@ function applyFilterToProvider(
171
199
  _logger.warn(`[pi-free] ${providerId}: no free models available`);
172
200
  }
173
201
  } else {
174
- // Show all models (paid + free)
175
- const allModels =
176
- entry.stored.all.length > 0 ? entry.stored.all : entry.stored.free;
177
- if (allModels.length > 0) {
178
- entry.reRegister(allModels);
179
- _logger.info(
180
- `[pi-free] ${providerId}: showing all ${allModels.length} models`,
181
- );
182
- }
202
+ showAllForProvider(providerId, entry);
183
203
  }
184
204
  }
185
205
 
186
- export function applyGlobalFilter(_pi: ExtensionAPI, freeOnly: boolean): void {
206
+ export function applyGlobalFilter(
207
+ _pi: ExtensionAPI,
208
+ freeOnly: boolean,
209
+ options: { force?: boolean } = {},
210
+ ): void {
187
211
  globalFreeOnly = freeOnly;
188
212
  saveConfig({ free_only: freeOnly });
189
213
 
190
214
  for (const [providerId, entry] of providerRegistry) {
191
215
  try {
192
- applyFilterToProvider(providerId, entry, freeOnly);
216
+ applyFilterToProvider(providerId, entry, freeOnly, options.force === true);
193
217
  } catch (err) {
194
218
  _logger.error(
195
219
  `[pi-free] Failed to apply filter to ${providerId}`,