pi-free 2.0.12 → 2.0.14
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 +640 -608
- package/README.md +7 -23
- package/config.ts +15 -0
- package/constants.ts +3 -0
- package/index.ts +135 -0
- package/lib/built-in-toggle.ts +34 -7
- package/lib/probe-cache.ts +86 -0
- package/lib/registry.ts +25 -3
- package/lib/telemetry.ts +328 -0
- package/lib/util.ts +10 -1
- package/package.json +1 -1
- package/provider-failover/benchmark-lookup.ts +94 -8
- package/provider-failover/benchmarks-chunk-0.ts +599 -890
- package/provider-failover/benchmarks-chunk-1.ts +655 -924
- package/provider-failover/benchmarks-chunk-2.ts +675 -966
- package/provider-failover/benchmarks-chunk-3.ts +676 -967
- package/provider-failover/benchmarks-chunk-4.ts +704 -954
- package/provider-failover/benchmarks-chunk-5.ts +1301 -0
- package/provider-failover/hardcoded-benchmarks.ts +9 -3
- package/providers/cline/cline-models.ts +196 -68
- package/providers/dynamic-built-in/index.ts +27 -4
- package/providers/kilo/kilo.ts +2 -2
- package/providers/model-fetcher.ts +3 -1
- package/providers/nvidia/nvidia.ts +47 -15
- package/providers/ollama/ollama.ts +103 -46
- package/providers/opencode-session.ts +376 -11
- package/providers/qwen/qwen.ts +2 -2
- package/providers/routeway/routeway.ts +213 -0
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
|
|
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)
|
|
@@ -65,6 +64,7 @@ Free models are shown by default — look for the provider prefixes:
|
|
|
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)
|
|
67
66
|
- `novita/` — Novita AI (100+ open-source models, OpenAI-compatible, 3 free models)
|
|
67
|
+
- `routeway/` — Routeway AI gateway (OpenAI-compatible, `:free` models)
|
|
68
68
|
|
|
69
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.
|
|
70
70
|
|
|
@@ -75,7 +75,6 @@ Free models are shown by default — look for the provider prefixes:
|
|
|
75
75
|
- `cerebras/` — Cerebras models (when `CEREBRAS_API_KEY` set)
|
|
76
76
|
- `xai/` — xAI models (when `XAI_API_KEY` set)
|
|
77
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
78
|
- `openrouter/` — OpenRouter models (fetched from openrouter.ai, when `OPENROUTER_API_KEY` set)
|
|
80
79
|
- `fastrouter/` — FastRouter models (always discovered, 170+ models, no auth for listing)
|
|
81
80
|
|
|
@@ -86,7 +85,6 @@ Free models are shown by default — look for the provider prefixes:
|
|
|
86
85
|
Want to see paid models too? Run the toggle command for your provider:
|
|
87
86
|
|
|
88
87
|
```
|
|
89
|
-
/toggle-opencode # Toggle OpenCode (✅ offers free models)
|
|
90
88
|
/toggle-kilo # Toggle Kilo (✅ offers free models)
|
|
91
89
|
/toggle-openrouter # Toggle OpenRouter (✅ offers free models)
|
|
92
90
|
/toggle-cline # Toggle Cline (✅ offers free models)
|
|
@@ -105,6 +103,7 @@ Want to see paid models too? Run the toggle command for your provider:
|
|
|
105
103
|
/toggle-sambanova # Toggle SambaNova (🔄 freemium)
|
|
106
104
|
/toggle-llm7 # Toggle LLM7 (✅ free gateway)
|
|
107
105
|
/toggle-novita # Toggle Novita AI (💳 paid — 3 free models)
|
|
106
|
+
/toggle-routeway # Toggle Routeway AI (💳 paid — has :free models)
|
|
108
107
|
/toggle-fastrouter # Toggle FastRouter (🔧 dynamic — always discovered)
|
|
109
108
|
```
|
|
110
109
|
|
|
@@ -114,10 +113,6 @@ Want to see paid models too? Run the toggle command for your provider:
|
|
|
114
113
|
- **🔧 Dynamic providers** show all fetched models by default — the toggle filters the list when you have an API key configured
|
|
115
114
|
- **Freemium providers** show all models by default; you manage your usage limits via their dashboards
|
|
116
115
|
|
|
117
|
-
You'll see a notification like: `opencode: showing free models` or `opencode: showing all models`
|
|
118
|
-
|
|
119
|
-
**Note:** Built-in provider toggles such as OpenCode and OpenRouter update in the current session — no restart needed.
|
|
120
|
-
|
|
121
116
|
### 4. Add API keys for more providers (optional)
|
|
122
117
|
|
|
123
118
|
Some providers require a free account or API key.
|
|
@@ -139,7 +134,8 @@ Add your API keys to this file:
|
|
|
139
134
|
"sambanova_api_key": "...",
|
|
140
135
|
"llm7_api_key": "...",
|
|
141
136
|
"zenmux_api_key": "...",
|
|
142
|
-
"crofai_api_key": "..."
|
|
137
|
+
"crofai_api_key": "...",
|
|
138
|
+
"routeway_api_key": "sk-..."
|
|
143
139
|
}
|
|
144
140
|
```
|
|
145
141
|
|
|
@@ -204,7 +200,7 @@ Providers have different pricing models. pi-free handles them all:
|
|
|
204
200
|
|
|
205
201
|
**Provider types:**
|
|
206
202
|
|
|
207
|
-
- ✅ **Free providers** (
|
|
203
|
+
- ✅ **Free providers** (Kilo, Cline) — Toggle between free-only vs paid models
|
|
208
204
|
- 🔄 **Freemium** (NVIDIA, Ollama) — Free tier with limits, toggle shows all
|
|
209
205
|
- 🔧 **Dynamic API** (Mistral, Groq, Cerebras, xAI) — Fetched when API key configured, toggle filters the list
|
|
210
206
|
|
|
@@ -219,17 +215,6 @@ Authentication is handled automatically:
|
|
|
219
215
|
|
|
220
216
|
## Using Free Models (No Setup Required)
|
|
221
217
|
|
|
222
|
-
### OpenCode
|
|
223
|
-
|
|
224
|
-
Works immediately with zero setup:
|
|
225
|
-
|
|
226
|
-
1. Press `Ctrl+L`
|
|
227
|
-
2. Search for `opencode/`
|
|
228
|
-
3. Pick any model (e.g., `opencode/big-pickle`)
|
|
229
|
-
4. Start chatting
|
|
230
|
-
|
|
231
|
-
No account, no API key, no OAuth. Run `/toggle-opencode` to switch between free and paid OpenCode models.
|
|
232
|
-
|
|
233
218
|
### Kilo (free models, more after login)
|
|
234
219
|
|
|
235
220
|
Kilo shows free models immediately. To unlock all models, authenticate with Kilo's free OAuth:
|
|
@@ -450,7 +435,6 @@ Each provider has toggle commands to switch between free and all models:
|
|
|
450
435
|
|
|
451
436
|
| Command | Action |
|
|
452
437
|
| ----------------------- | -------------------------------------------------------- |
|
|
453
|
-
| `/toggle-opencode` | Toggle between free/all OpenCode models |
|
|
454
438
|
| `/toggle-kilo` | Toggle between free/all Kilo models |
|
|
455
439
|
| `/toggle-openrouter` | Toggle between free/all OpenRouter models |
|
|
456
440
|
| `/toggle-cline` | Toggle between free/all Cline models |
|
|
@@ -477,7 +461,7 @@ Each provider has toggle commands to switch between free and all models:
|
|
|
477
461
|
- **For 🔄 freemium providers**: Shows all models by default; toggle switches between filtered and full list
|
|
478
462
|
- **For 🔧 dynamic API providers**: Filters the model list when you have an API key configured
|
|
479
463
|
- **Persists your preference** to `~/.pi/free.json` for next startup
|
|
480
|
-
|
|
464
|
+
|
|
481
465
|
|
|
482
466
|
### Probe Commands (Health Check)
|
|
483
467
|
|
package/config.ts
CHANGED
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
PROVIDER_MODAL,
|
|
18
18
|
PROVIDER_NVIDIA,
|
|
19
19
|
PROVIDER_QWEN,
|
|
20
|
+
PROVIDER_ROUTEWAY,
|
|
20
21
|
} from "./constants.ts";
|
|
21
22
|
import { createLogger } from "./lib/logger.ts";
|
|
22
23
|
|
|
@@ -33,6 +34,7 @@ interface PiFreeConfig {
|
|
|
33
34
|
sambanova_api_key?: string;
|
|
34
35
|
together_api_key?: string;
|
|
35
36
|
novita_api_key?: string;
|
|
37
|
+
routeway_api_key?: string;
|
|
36
38
|
fastrouter_api_key?: string;
|
|
37
39
|
kilo_free_only?: boolean;
|
|
38
40
|
hidden_models?: string[];
|
|
@@ -48,6 +50,7 @@ interface PiFreeConfig {
|
|
|
48
50
|
sambanova_show_paid?: boolean;
|
|
49
51
|
together_show_paid?: boolean;
|
|
50
52
|
novita_show_paid?: boolean;
|
|
53
|
+
routeway_show_paid?: boolean;
|
|
51
54
|
fastrouter_show_paid?: boolean;
|
|
52
55
|
openrouter_show_paid?: boolean;
|
|
53
56
|
opencode_show_paid?: boolean;
|
|
@@ -64,6 +67,7 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
|
|
|
64
67
|
sambanova_api_key: "",
|
|
65
68
|
together_api_key: "",
|
|
66
69
|
novita_api_key: "",
|
|
70
|
+
routeway_api_key: "",
|
|
67
71
|
fastrouter_api_key: "",
|
|
68
72
|
|
|
69
73
|
kilo_free_only: false,
|
|
@@ -80,6 +84,7 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
|
|
|
80
84
|
sambanova_show_paid: false,
|
|
81
85
|
together_show_paid: false,
|
|
82
86
|
novita_show_paid: false,
|
|
87
|
+
routeway_show_paid: false,
|
|
83
88
|
fastrouter_show_paid: false,
|
|
84
89
|
openrouter_show_paid: false,
|
|
85
90
|
opencode_show_paid: false,
|
|
@@ -222,6 +227,10 @@ export function getNovitaShowPaid(): boolean {
|
|
|
222
227
|
return resolveBool("NOVITA_SHOW_PAID", loadConfigFile().novita_show_paid);
|
|
223
228
|
}
|
|
224
229
|
|
|
230
|
+
export function getRoutewayShowPaid(): boolean {
|
|
231
|
+
return resolveBool("ROUTEWAY_SHOW_PAID", loadConfigFile().routeway_show_paid);
|
|
232
|
+
}
|
|
233
|
+
|
|
225
234
|
export function getFastrouterShowPaid(): boolean {
|
|
226
235
|
return resolveBool(
|
|
227
236
|
"FASTROUTER_SHOW_PAID",
|
|
@@ -266,6 +275,8 @@ export function getProviderShowPaid(providerId: string): boolean {
|
|
|
266
275
|
return getTogetherShowPaid();
|
|
267
276
|
case "novita":
|
|
268
277
|
return getNovitaShowPaid();
|
|
278
|
+
case "routeway":
|
|
279
|
+
return getRoutewayShowPaid();
|
|
269
280
|
case "fastrouter":
|
|
270
281
|
return getFastrouterShowPaid();
|
|
271
282
|
case "ollama-cloud":
|
|
@@ -331,6 +342,10 @@ export function getNovitaApiKey(): string | undefined {
|
|
|
331
342
|
return resolve("NOVITA_API_KEY", loadConfigFile().novita_api_key);
|
|
332
343
|
}
|
|
333
344
|
|
|
345
|
+
export function getRoutewayApiKey(): string | undefined {
|
|
346
|
+
return resolve("ROUTEWAY_API_KEY", loadConfigFile().routeway_api_key);
|
|
347
|
+
}
|
|
348
|
+
|
|
334
349
|
export function getFastrouterApiKey(): string | undefined {
|
|
335
350
|
return resolve("FASTROUTER_API_KEY", loadConfigFile().fastrouter_api_key);
|
|
336
351
|
}
|
package/constants.ts
CHANGED
|
@@ -23,6 +23,7 @@ export const PROVIDER_DEEPINFRA = "deepinfra";
|
|
|
23
23
|
export const PROVIDER_SAMBANOVA = "sambanova";
|
|
24
24
|
export const PROVIDER_TOGETHER = "together";
|
|
25
25
|
export const PROVIDER_NOVITA = "novita";
|
|
26
|
+
export const PROVIDER_ROUTEWAY = "routeway";
|
|
26
27
|
|
|
27
28
|
export const ALL_UNIQUE_PROVIDERS = [
|
|
28
29
|
PROVIDER_KILO,
|
|
@@ -40,6 +41,7 @@ export const ALL_UNIQUE_PROVIDERS = [
|
|
|
40
41
|
PROVIDER_SAMBANOVA,
|
|
41
42
|
PROVIDER_TOGETHER,
|
|
42
43
|
PROVIDER_NOVITA,
|
|
44
|
+
PROVIDER_ROUTEWAY,
|
|
43
45
|
] as const;
|
|
44
46
|
|
|
45
47
|
// =============================================================================
|
|
@@ -62,6 +64,7 @@ export const BASE_URL_DEEPINFRA = "https://api.deepinfra.com/v1/openai";
|
|
|
62
64
|
export const BASE_URL_SAMBANOVA = "https://api.sambanova.ai/v1";
|
|
63
65
|
export const BASE_URL_TOGETHER = "https://api.together.xyz/v1";
|
|
64
66
|
export const BASE_URL_NOVITA = "https://api.novita.ai/openai/v1";
|
|
67
|
+
export const BASE_URL_ROUTEWAY = "https://api.routeway.ai/v1";
|
|
65
68
|
|
|
66
69
|
/** Cline fetches free models from OpenRouter */
|
|
67
70
|
export const BASE_URL_OPENROUTER = "https://openrouter.ai/api/v1";
|
package/index.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* - DeepInfra: AI inference cloud ($5 trial credit)
|
|
15
15
|
* - SambaNova: Fast inference on RDU hardware (free tier, no credit card)
|
|
16
16
|
* - Together: Fast inference on 200+ open-source models ($1 trial credit)
|
|
17
|
+
* - Routeway: OpenAI-compatible gateway with free `:free` models
|
|
17
18
|
* - LLM7: AI gateway (free default/fast selectors)
|
|
18
19
|
*/
|
|
19
20
|
|
|
@@ -24,6 +25,13 @@ import {
|
|
|
24
25
|
processQuotaResponse,
|
|
25
26
|
formatQuotaStatus,
|
|
26
27
|
} from "./lib/quota-monitor.ts";
|
|
28
|
+
import {
|
|
29
|
+
startModelCall,
|
|
30
|
+
recordModelCall,
|
|
31
|
+
getAllTelemetry,
|
|
32
|
+
getTelemetryPath,
|
|
33
|
+
clearTelemetry,
|
|
34
|
+
} from "./lib/telemetry.ts";
|
|
27
35
|
import {
|
|
28
36
|
applyGlobalFilter,
|
|
29
37
|
getGlobalFreeOnly,
|
|
@@ -41,6 +49,7 @@ import deepinfra from "./providers/deepinfra/deepinfra.ts";
|
|
|
41
49
|
import sambanova from "./providers/sambanova/sambanova.ts";
|
|
42
50
|
import together from "./providers/together/together.ts";
|
|
43
51
|
import novita from "./providers/novita/novita.ts";
|
|
52
|
+
import routeway from "./providers/routeway/routeway.ts";
|
|
44
53
|
import nvidia from "./providers/nvidia/nvidia.ts";
|
|
45
54
|
import ollama from "./providers/ollama/ollama.ts";
|
|
46
55
|
import zenmux from "./providers/zenmux/zenmux.ts";
|
|
@@ -145,6 +154,64 @@ function setupGlobalCommands(pi: ExtensionAPI) {
|
|
|
145
154
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
146
155
|
},
|
|
147
156
|
});
|
|
157
|
+
|
|
158
|
+
// /telemetry — Show model telemetry data
|
|
159
|
+
pi.registerCommand("free-telemetry", {
|
|
160
|
+
description:
|
|
161
|
+
"Show real-world performance data for free models (tokens/s, latency, success rate)",
|
|
162
|
+
handler: async (_args, ctx) => {
|
|
163
|
+
const allTelemetry = getAllTelemetry();
|
|
164
|
+
const entries = Object.entries(allTelemetry);
|
|
165
|
+
|
|
166
|
+
if (entries.length === 0) {
|
|
167
|
+
ctx.ui.notify(
|
|
168
|
+
"No telemetry data yet. Use some free models first!",
|
|
169
|
+
"info",
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Sort by total calls descending
|
|
175
|
+
entries.sort((a, b) => b[1].totalCalls - a[1].totalCalls);
|
|
176
|
+
|
|
177
|
+
const lines = ["📊 Model Telemetry:", ""];
|
|
178
|
+
lines.push(
|
|
179
|
+
`${`Model`.padEnd(40)} ${`Calls`.padEnd(6)} ${`OK%`.padEnd(6)} ${`Lat`.padEnd(7)} ${`tok/s`.padEnd(7)} ${`Cost`}`,
|
|
180
|
+
);
|
|
181
|
+
lines.push(`─`.repeat(75));
|
|
182
|
+
|
|
183
|
+
for (const [key, t] of entries.slice(0, 20)) {
|
|
184
|
+
const name = key.length > 38 ? key.slice(0, 35) + "..." : key;
|
|
185
|
+
const calls = String(t.totalCalls).padStart(5);
|
|
186
|
+
const ok = `${t.successRate}%`.padStart(5);
|
|
187
|
+
const lat =
|
|
188
|
+
t.avgLatencyMs > 0
|
|
189
|
+
? `${t.avgLatencyMs}ms`.padStart(6)
|
|
190
|
+
: "—".padStart(6);
|
|
191
|
+
const tps =
|
|
192
|
+
t.avgTokensPerSecond > 0
|
|
193
|
+
? `${t.avgTokensPerSecond}`.padStart(6)
|
|
194
|
+
: "—".padStart(6);
|
|
195
|
+
const cost =
|
|
196
|
+
t.totalCost > 0
|
|
197
|
+
? `$${t.totalCost.toFixed(4)}`.padStart(8)
|
|
198
|
+
: "free".padStart(8);
|
|
199
|
+
lines.push(`${name.padEnd(40)} ${calls} ${ok} ${lat} ${tps} ${cost}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
lines.push("", `File: ${getTelemetryPath()}`);
|
|
203
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// /clear-free-telemetry — Clear all telemetry data
|
|
208
|
+
pi.registerCommand("clear-free-telemetry", {
|
|
209
|
+
description: "Clear all model telemetry data",
|
|
210
|
+
handler: async (_args, ctx) => {
|
|
211
|
+
clearTelemetry();
|
|
212
|
+
ctx.ui.notify("Telemetry data cleared", "info");
|
|
213
|
+
},
|
|
214
|
+
});
|
|
148
215
|
}
|
|
149
216
|
|
|
150
217
|
// =============================================================================
|
|
@@ -183,6 +250,70 @@ function setupQuotaMonitoring(pi: ExtensionAPI) {
|
|
|
183
250
|
});
|
|
184
251
|
}
|
|
185
252
|
|
|
253
|
+
// =============================================================================
|
|
254
|
+
// Model Telemetry
|
|
255
|
+
// =============================================================================
|
|
256
|
+
|
|
257
|
+
function setupTelemetry(pi: ExtensionAPI) {
|
|
258
|
+
// Only track telemetry for FREE models (uses same isFreeModel logic as model filtering)
|
|
259
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
260
|
+
(pi as any).on("before_agent_start", (_event: any, ctx: any) => {
|
|
261
|
+
if (!ctx.model) return;
|
|
262
|
+
if (!isFreeModel(ctx.model as any)) return;
|
|
263
|
+
const provider = ctx.model?.provider;
|
|
264
|
+
const model = ctx.model?.id;
|
|
265
|
+
if (provider && model) {
|
|
266
|
+
startModelCall(provider, model);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Record telemetry when a turn completes
|
|
271
|
+
pi.on("turn_end", (event, ctx) => {
|
|
272
|
+
if (!ctx.model) return;
|
|
273
|
+
if (!isFreeModel(ctx.model as any)) return;
|
|
274
|
+
|
|
275
|
+
const msg = (
|
|
276
|
+
event as {
|
|
277
|
+
message?: {
|
|
278
|
+
role?: string;
|
|
279
|
+
model?: string;
|
|
280
|
+
usage?: {
|
|
281
|
+
input?: number;
|
|
282
|
+
output?: number;
|
|
283
|
+
totalTokens?: number;
|
|
284
|
+
cost?: { total?: number };
|
|
285
|
+
};
|
|
286
|
+
stopReason?: string;
|
|
287
|
+
errorMessage?: string;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
).message;
|
|
291
|
+
|
|
292
|
+
if (msg?.role !== "assistant") return;
|
|
293
|
+
|
|
294
|
+
const provider = ctx.model?.provider;
|
|
295
|
+
const model = msg.model || ctx.model?.id;
|
|
296
|
+
if (!provider || !model) return;
|
|
297
|
+
|
|
298
|
+
const usage = msg.usage;
|
|
299
|
+
const inputTokens = usage?.input ?? 0;
|
|
300
|
+
const outputTokens = usage?.output ?? 0;
|
|
301
|
+
const totalTokens = usage?.totalTokens ?? inputTokens + outputTokens;
|
|
302
|
+
const cost = usage?.cost?.total ?? 0;
|
|
303
|
+
const isError = msg.stopReason === "error" || !!msg.errorMessage;
|
|
304
|
+
|
|
305
|
+
recordModelCall(
|
|
306
|
+
provider,
|
|
307
|
+
model,
|
|
308
|
+
{ input: inputTokens, output: outputTokens, totalTokens },
|
|
309
|
+
cost,
|
|
310
|
+
!isError,
|
|
311
|
+
msg.stopReason,
|
|
312
|
+
msg.errorMessage,
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
186
317
|
// =============================================================================
|
|
187
318
|
// Main Entry Point
|
|
188
319
|
// =============================================================================
|
|
@@ -197,6 +328,9 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
|
|
|
197
328
|
// Setup quota monitoring (passive, no extra API calls)
|
|
198
329
|
setupQuotaMonitoring(pi);
|
|
199
330
|
|
|
331
|
+
// Setup model telemetry (tracks real-world performance)
|
|
332
|
+
setupTelemetry(pi);
|
|
333
|
+
|
|
200
334
|
// Load all unique providers
|
|
201
335
|
// Each provider will register itself with the global toggle system
|
|
202
336
|
await Promise.allSettled([
|
|
@@ -212,6 +346,7 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
|
|
|
212
346
|
sambanova(pi),
|
|
213
347
|
together(pi),
|
|
214
348
|
novita(pi),
|
|
349
|
+
routeway(pi),
|
|
215
350
|
]);
|
|
216
351
|
|
|
217
352
|
// Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face,
|
package/lib/built-in-toggle.ts
CHANGED
|
@@ -24,9 +24,18 @@ import {
|
|
|
24
24
|
registerWithGlobalToggle,
|
|
25
25
|
} from "./registry.ts";
|
|
26
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";
|
|
27
33
|
|
|
28
34
|
const _logger = createLogger("built-in-toggle");
|
|
29
35
|
|
|
36
|
+
// OpenCode requires per-request ids; see createOpenCodeStreamSimple().
|
|
37
|
+
const _opencodeSession = createOpenCodeSessionTracker();
|
|
38
|
+
|
|
30
39
|
// =============================================================================
|
|
31
40
|
// Configuration
|
|
32
41
|
// =============================================================================
|
|
@@ -38,6 +47,7 @@ interface BuiltInToggleConfig {
|
|
|
38
47
|
|
|
39
48
|
const BUILT_IN_TOGGLE_PROVIDERS: BuiltInToggleConfig[] = [
|
|
40
49
|
{ id: "opencode", getShowPaid: getOpencodeShowPaid },
|
|
50
|
+
{ id: "opencode-go", getShowPaid: getOpencodeShowPaid },
|
|
41
51
|
{ id: "openrouter", getShowPaid: getOpenrouterShowPaid },
|
|
42
52
|
];
|
|
43
53
|
|
|
@@ -113,7 +123,9 @@ function tryCaptureProvider(
|
|
|
113
123
|
);
|
|
114
124
|
if (providerModels.length === 0) return undefined;
|
|
115
125
|
|
|
116
|
-
const allModels = providerModels.map(
|
|
126
|
+
const allModels = providerModels.map((m: Model<Api>) =>
|
|
127
|
+
modelToProviderConfig(m, config.id),
|
|
128
|
+
);
|
|
117
129
|
const freeModels = allModels.filter((m: ProviderModelConfig) =>
|
|
118
130
|
isFreeModel({ ...m, provider: config.id }, allModels),
|
|
119
131
|
);
|
|
@@ -126,7 +138,10 @@ function tryCaptureProvider(
|
|
|
126
138
|
pi.registerProvider(config.id, {
|
|
127
139
|
baseUrl,
|
|
128
140
|
apiKey: apiKeyEnv,
|
|
129
|
-
api,
|
|
141
|
+
api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : api,
|
|
142
|
+
...(isOpenCodeProvider(config.id)
|
|
143
|
+
? { streamSimple: createOpenCodeStreamSimple(_opencodeSession) }
|
|
144
|
+
: {}),
|
|
130
145
|
models,
|
|
131
146
|
});
|
|
132
147
|
};
|
|
@@ -196,8 +211,11 @@ function registerToggleCommand(
|
|
|
196
211
|
// Helpers
|
|
197
212
|
// =============================================================================
|
|
198
213
|
|
|
199
|
-
function modelToProviderConfig(
|
|
200
|
-
|
|
214
|
+
function modelToProviderConfig(
|
|
215
|
+
m: Model<Api>,
|
|
216
|
+
providerId?: string,
|
|
217
|
+
): ProviderModelConfig {
|
|
218
|
+
const base: ProviderModelConfig = {
|
|
201
219
|
id: m.id,
|
|
202
220
|
name: m.name,
|
|
203
221
|
api: m.api,
|
|
@@ -209,6 +227,14 @@ function modelToProviderConfig(m: Model<Api>): ProviderModelConfig {
|
|
|
209
227
|
headers: m.headers,
|
|
210
228
|
compat: (m as any).compat,
|
|
211
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;
|
|
212
238
|
}
|
|
213
239
|
|
|
214
240
|
// =============================================================================
|
|
@@ -252,8 +278,9 @@ function setupStatusBar(
|
|
|
252
278
|
|
|
253
279
|
function getApiKeyEnvForProvider(providerId: string): string {
|
|
254
280
|
const envMap: Record<string, string> = {
|
|
255
|
-
opencode: "OPENCODE_API_KEY",
|
|
256
|
-
|
|
281
|
+
opencode: "$OPENCODE_API_KEY",
|
|
282
|
+
"opencode-go": "$OPENCODE_API_KEY",
|
|
283
|
+
openrouter: "$OPENROUTER_API_KEY",
|
|
257
284
|
};
|
|
258
|
-
return envMap[providerId] ||
|
|
285
|
+
return envMap[providerId] || `$${providerId.toUpperCase()}_API_KEY`;
|
|
259
286
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider model probe cache.
|
|
3
|
+
*
|
|
4
|
+
* Stores the last successful accessibility probe per provider/model so
|
|
5
|
+
* background cleanup can avoid spending quota on the same checks every session.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { createJSONStore } from "./json-persistence.ts";
|
|
11
|
+
import { createLogger } from "./logger.ts";
|
|
12
|
+
|
|
13
|
+
const _logger = createLogger("probe-cache");
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_PROBE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export type ProbeStatus = "ok" | "broken";
|
|
18
|
+
|
|
19
|
+
export interface ModelProbeResult {
|
|
20
|
+
modelId: string;
|
|
21
|
+
status: ProbeStatus;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ModelProbeEntry {
|
|
25
|
+
lastProbedAt: string;
|
|
26
|
+
status: ProbeStatus;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ProviderProbeCache {
|
|
30
|
+
provider: string;
|
|
31
|
+
models: Record<string, ModelProbeEntry>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ProbeCacheData {
|
|
35
|
+
providers: Record<string, ProviderProbeCache>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CACHE_FILE = join(homedir(), ".pi", "probe-cache.json");
|
|
39
|
+
const _cache = createJSONStore<ProbeCacheData>(CACHE_FILE, { providers: {} });
|
|
40
|
+
|
|
41
|
+
export function getModelsDueForProbe(
|
|
42
|
+
providerId: string,
|
|
43
|
+
modelIds: string[],
|
|
44
|
+
ttlMs = DEFAULT_PROBE_TTL_MS,
|
|
45
|
+
): string[] {
|
|
46
|
+
const provider = _cache.load().providers[providerId];
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
|
|
49
|
+
return modelIds.filter((modelId) => {
|
|
50
|
+
const entry = provider?.models[modelId];
|
|
51
|
+
if (!entry) return true;
|
|
52
|
+
|
|
53
|
+
// Broken models are normally hidden immediately. If a user later unhides one,
|
|
54
|
+
// re-check it instead of letting a stale broken cache suppress cleanup.
|
|
55
|
+
if (entry.status === "broken") return true;
|
|
56
|
+
|
|
57
|
+
const lastProbedAt = Date.parse(entry.lastProbedAt);
|
|
58
|
+
if (!Number.isFinite(lastProbedAt)) return true;
|
|
59
|
+
|
|
60
|
+
return now - lastProbedAt >= ttlMs;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function recordModelProbeResults(
|
|
65
|
+
providerId: string,
|
|
66
|
+
results: ModelProbeResult[],
|
|
67
|
+
): void {
|
|
68
|
+
if (results.length === 0) return;
|
|
69
|
+
|
|
70
|
+
const data = _cache.load();
|
|
71
|
+
const provider = (data.providers[providerId] ??= {
|
|
72
|
+
provider: providerId,
|
|
73
|
+
models: {},
|
|
74
|
+
});
|
|
75
|
+
const lastProbedAt = new Date().toISOString();
|
|
76
|
+
|
|
77
|
+
for (const result of results) {
|
|
78
|
+
provider.models[result.modelId] = {
|
|
79
|
+
lastProbedAt,
|
|
80
|
+
status: result.status,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_cache.save(data);
|
|
85
|
+
_logger.debug(`Recorded ${results.length} probe results for ${providerId}`);
|
|
86
|
+
}
|
package/lib/registry.ts
CHANGED
|
@@ -82,7 +82,12 @@ 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 & {
|
|
85
|
+
model: ProviderModelConfig & {
|
|
86
|
+
provider?: string;
|
|
87
|
+
_pricingKnown?: boolean;
|
|
88
|
+
_freeKnown?: boolean;
|
|
89
|
+
_isFree?: boolean;
|
|
90
|
+
},
|
|
86
91
|
allModels?: ProviderModelConfig[],
|
|
87
92
|
): boolean {
|
|
88
93
|
return isFreeModelInternal(model, allModels);
|
|
@@ -90,9 +95,21 @@ export function isFreeModel(
|
|
|
90
95
|
|
|
91
96
|
// Internal implementation to work around TypeScript filter callback issues
|
|
92
97
|
function isFreeModelInternal(
|
|
93
|
-
model: ProviderModelConfig & {
|
|
98
|
+
model: ProviderModelConfig & {
|
|
99
|
+
provider?: string;
|
|
100
|
+
_pricingKnown?: boolean;
|
|
101
|
+
_freeKnown?: boolean;
|
|
102
|
+
_isFree?: boolean;
|
|
103
|
+
},
|
|
94
104
|
allModels: ProviderModelConfig[] | undefined,
|
|
95
105
|
): boolean {
|
|
106
|
+
// Some gateways expose an authoritative free/paid flag. Prefer it over
|
|
107
|
+
// pricing because a few non-chat or preview models can report zero token
|
|
108
|
+
// prices while still not being offered as free chat models.
|
|
109
|
+
if (model._freeKnown === true) {
|
|
110
|
+
return model._isFree === true;
|
|
111
|
+
}
|
|
112
|
+
|
|
96
113
|
// Determine if pricing is exposed
|
|
97
114
|
let pricingExposed: boolean;
|
|
98
115
|
|
|
@@ -213,7 +230,12 @@ export function applyGlobalFilter(
|
|
|
213
230
|
|
|
214
231
|
for (const [providerId, entry] of providerRegistry) {
|
|
215
232
|
try {
|
|
216
|
-
applyFilterToProvider(
|
|
233
|
+
applyFilterToProvider(
|
|
234
|
+
providerId,
|
|
235
|
+
entry,
|
|
236
|
+
freeOnly,
|
|
237
|
+
options.force === true,
|
|
238
|
+
);
|
|
217
239
|
} catch (err) {
|
|
218
240
|
_logger.error(
|
|
219
241
|
`[pi-free] Failed to apply filter to ${providerId}`,
|