pi-free 2.0.13 → 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 +12 -0
- package/README.md +4 -1
- package/config.ts +15 -0
- package/constants.ts +3 -0
- package/index.ts +135 -0
- package/lib/built-in-toggle.ts +4 -4
- 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 +1 -1
- 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 +398 -371
- package/providers/qwen/qwen.ts +2 -2
- package/providers/routeway/routeway.ts +213 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.0.14] - 2026-06-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Routeway provider** — OpenAI-compatible gateway (`api.routeway.ai/v1`) with 219 models, 16 free (`:free` suffix). Set `ROUTEWAY_API_KEY` or add `routeway_api_key` to `~/.pi/free.json`. Toggle with `/toggle-routeway` ([#209](https://github.com/apmantza/pi-free/pull/209)).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Cline free model merging** — Free-to-try models (e.g. `qwen3.7-plus`) from Cline's recommended list now appear in the free model picker even when absent from the main catalog ([#209](https://github.com/apmantza/pi-free/pull/209)).
|
|
19
|
+
|
|
20
|
+
- **`_pricingKnown` / `_freeKnown` authoritatve flag** — Providers can now signal whether pricing data is authoritative via `_pricingKnown`. When `false`, `isFreeModel` falls back to name-based detection. Kilo's `isFree` API flag now flows through as `_freeKnown` ([#209](https://github.com/apmantza/pi-free/pull/209)).
|
|
21
|
+
|
|
10
22
|
## [2.0.13] - 2026-05-21
|
|
11
23
|
|
|
12
24
|
### Added
|
package/README.md
CHANGED
|
@@ -64,6 +64,7 @@ Free models are shown by default — look for the provider prefixes:
|
|
|
64
64
|
- `codestral/` — Codestral via Mistral (free Experiment plan: 2 req/min, 1B tokens/month)
|
|
65
65
|
- `deepinfra/` — DeepInfra inference cloud ($5 one-time trial credit, no credit card)
|
|
66
66
|
- `novita/` — Novita AI (100+ open-source models, OpenAI-compatible, 3 free models)
|
|
67
|
+
- `routeway/` — Routeway AI gateway (OpenAI-compatible, `: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
|
|
|
@@ -102,6 +103,7 @@ Want to see paid models too? Run the toggle command for your provider:
|
|
|
102
103
|
/toggle-sambanova # Toggle SambaNova (🔄 freemium)
|
|
103
104
|
/toggle-llm7 # Toggle LLM7 (✅ free gateway)
|
|
104
105
|
/toggle-novita # Toggle Novita AI (💳 paid — 3 free models)
|
|
106
|
+
/toggle-routeway # Toggle Routeway AI (💳 paid — has :free models)
|
|
105
107
|
/toggle-fastrouter # Toggle FastRouter (🔧 dynamic — always discovered)
|
|
106
108
|
```
|
|
107
109
|
|
|
@@ -132,7 +134,8 @@ Add your API keys to this file:
|
|
|
132
134
|
"sambanova_api_key": "...",
|
|
133
135
|
"llm7_api_key": "...",
|
|
134
136
|
"zenmux_api_key": "...",
|
|
135
|
-
"crofai_api_key": "..."
|
|
137
|
+
"crofai_api_key": "...",
|
|
138
|
+
"routeway_api_key": "sk-..."
|
|
136
139
|
}
|
|
137
140
|
```
|
|
138
141
|
|
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
|
@@ -278,9 +278,9 @@ function setupStatusBar(
|
|
|
278
278
|
|
|
279
279
|
function getApiKeyEnvForProvider(providerId: string): string {
|
|
280
280
|
const envMap: Record<string, string> = {
|
|
281
|
-
opencode: "OPENCODE_API_KEY",
|
|
282
|
-
"opencode-go": "OPENCODE_API_KEY",
|
|
283
|
-
openrouter: "OPENROUTER_API_KEY",
|
|
281
|
+
opencode: "$OPENCODE_API_KEY",
|
|
282
|
+
"opencode-go": "$OPENCODE_API_KEY",
|
|
283
|
+
openrouter: "$OPENROUTER_API_KEY",
|
|
284
284
|
};
|
|
285
|
-
return envMap[providerId] ||
|
|
285
|
+
return envMap[providerId] || `$${providerId.toUpperCase()}_API_KEY`;
|
|
286
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}`,
|