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 +33 -1
- package/README.md +28 -22
- package/banner.svg +1 -1
- package/config.ts +99 -2
- package/constants.ts +3 -1
- package/index.ts +5 -3
- package/lib/built-in-toggle.ts +91 -58
- package/lib/registry.ts +40 -16
- package/lib/util.ts +13 -12
- package/package.json +2 -2
- package/providers/cline/cline-models.ts +3 -10
- package/providers/crofai/crofai.ts +5 -1
- package/providers/deepinfra/deepinfra.ts +7 -5
- package/providers/dynamic-built-in/index.ts +104 -31
- package/providers/model-fetcher.ts +2 -13
- package/providers/novita/novita.ts +205 -0
- package/providers/nvidia/nvidia.ts +4 -6
- package/providers/sambanova/sambanova.ts +8 -2
- package/providers/together/together.ts +6 -9
- package/providers/zenmux/zenmux.ts +6 -4
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
|
-
## [
|
|
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
|
|
446
|
-
|
|
|
447
|
-
| `/toggle-opencode`
|
|
448
|
-
| `/toggle-kilo`
|
|
449
|
-
| `/toggle-openrouter`
|
|
450
|
-
| `/toggle-cline`
|
|
451
|
-
| `/toggle-nvidia`
|
|
452
|
-
| `/toggle-ollama`
|
|
453
|
-
| `/toggle-mistral`
|
|
454
|
-
| `/toggle-groq`
|
|
455
|
-
| `/toggle-cerebras`
|
|
456
|
-
| `/toggle-xai`
|
|
457
|
-
| `/toggle-huggingface`
|
|
458
|
-
| `/toggle-codestral`
|
|
459
|
-
| `/toggle-deepinfra`
|
|
460
|
-
| `/toggle-together`
|
|
461
|
-
| `/toggle-sambanova`
|
|
462
|
-
| `/toggle-llm7`
|
|
463
|
-
| `/toggle-zenmux`
|
|
464
|
-
| `/toggle-crofai`
|
|
465
|
-
| `/ollama-cloud-refresh` | Re-fetch Ollama Cloud models live (no restart needed)
|
|
466
|
-
| `/probe-ollama`
|
|
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
|
|
399
|
+
* pi-free checks env var first, then auth.json.
|
|
308
400
|
*/
|
|
309
401
|
export function getOpenrouterApiKey(): string | undefined {
|
|
310
|
-
return
|
|
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
|
-
//
|
|
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
|
);
|
package/lib/built-in-toggle.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
86
|
+
// Already captured — skip to avoid re-registering
|
|
74
87
|
continue;
|
|
75
88
|
}
|
|
76
89
|
|
|
77
|
-
const
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
let state = providerStates.get(config.id);
|
|
140
166
|
if (!state) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
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
|
|
193
|
-
for (const config of
|
|
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
|
|
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
|
-
|
|
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(
|
|
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}`,
|