pi-free 2.0.14 → 2.1.0
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 +90 -0
- package/README.md +64 -78
- package/banner.svg +21 -36
- package/config.ts +123 -9
- package/constants.ts +3 -9
- package/index.ts +14 -15
- package/lib/built-in-toggle.ts +29 -16
- package/lib/json-persistence.ts +90 -22
- package/lib/logger.ts +21 -12
- package/lib/model-detection.ts +2 -12
- package/lib/model-enhancer.ts +11 -2
- package/lib/model-metadata.ts +387 -0
- package/lib/open-browser.ts +74 -24
- package/lib/paths.ts +90 -0
- package/lib/probe-cache.ts +19 -19
- package/lib/provider-cache.ts +74 -28
- package/lib/provider-compat.ts +58 -9
- package/lib/provider-probe.ts +188 -0
- package/lib/registry.ts +1 -5
- package/lib/session-start-metrics.ts +46 -0
- package/lib/telemetry.ts +115 -86
- package/lib/types.ts +22 -2
- package/lib/util.ts +80 -21
- package/package.json +7 -2
- package/provider-failover/benchmark-lookup.ts +17 -5
- package/provider-helper.ts +11 -2
- package/providers/cline/cline-models.ts +12 -2
- package/providers/cline/cline-xml-bridge.ts +974 -0
- package/providers/cline/cline.ts +67 -176
- package/providers/crofai/crofai.ts +6 -1
- package/providers/deepinfra/deepinfra.ts +69 -2
- package/providers/dynamic-built-in/index.ts +237 -2
- package/providers/kilo/kilo-models.ts +3 -1
- package/providers/kilo/kilo.ts +268 -41
- package/providers/model-fetcher.ts +18 -55
- package/providers/novita/novita.ts +69 -2
- package/providers/ollama/ollama.ts +48 -24
- package/providers/opencode-session.ts +67 -2
- package/providers/routeway/routeway.ts +188 -2
- package/providers/sambanova/sambanova.ts +67 -1
- package/providers/together/together.ts +69 -2
- package/providers/tokenrouter/tokenrouter.ts +378 -0
- package/providers/zenmux/zenmux.ts +6 -1
- package/scripts/check-extensions.mjs +32 -16
- package/providers/nvidia/nvidia.ts +0 -504
package/config.ts
CHANGED
|
@@ -9,17 +9,28 @@
|
|
|
9
9
|
* (e.g. after toggle-{provider}) are visible immediately.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync,
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
export {
|
|
15
15
|
PROVIDER_CLINE,
|
|
16
16
|
PROVIDER_KILO,
|
|
17
17
|
PROVIDER_MODAL,
|
|
18
|
-
PROVIDER_NVIDIA,
|
|
19
18
|
PROVIDER_QWEN,
|
|
20
19
|
PROVIDER_ROUTEWAY,
|
|
20
|
+
PROVIDER_TOKENROUTER,
|
|
21
21
|
} from "./constants.ts";
|
|
22
22
|
import { createLogger } from "./lib/logger.ts";
|
|
23
|
+
import { ensureDir, PI_DATA_DIR } from "./lib/paths.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* JSON.parse reviver that strips prototype-pollution payloads.
|
|
27
|
+
*/
|
|
28
|
+
function safeJsonReviver(_key: string, value: unknown): unknown {
|
|
29
|
+
if (_key === "__proto__" || _key === "constructor") {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
23
34
|
|
|
24
35
|
const _logger = createLogger("config");
|
|
25
36
|
|
|
@@ -36,6 +47,7 @@ interface PiFreeConfig {
|
|
|
36
47
|
novita_api_key?: string;
|
|
37
48
|
routeway_api_key?: string;
|
|
38
49
|
fastrouter_api_key?: string;
|
|
50
|
+
tokenrouter_api_key?: string;
|
|
39
51
|
kilo_free_only?: boolean;
|
|
40
52
|
hidden_models?: string[];
|
|
41
53
|
free_only?: boolean;
|
|
@@ -52,6 +64,7 @@ interface PiFreeConfig {
|
|
|
52
64
|
novita_show_paid?: boolean;
|
|
53
65
|
routeway_show_paid?: boolean;
|
|
54
66
|
fastrouter_show_paid?: boolean;
|
|
67
|
+
tokenrouter_show_paid?: boolean;
|
|
55
68
|
openrouter_show_paid?: boolean;
|
|
56
69
|
opencode_show_paid?: boolean;
|
|
57
70
|
}
|
|
@@ -69,6 +82,7 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
|
|
|
69
82
|
novita_api_key: "",
|
|
70
83
|
routeway_api_key: "",
|
|
71
84
|
fastrouter_api_key: "",
|
|
85
|
+
tokenrouter_api_key: "",
|
|
72
86
|
|
|
73
87
|
kilo_free_only: false,
|
|
74
88
|
hidden_models: [],
|
|
@@ -86,16 +100,16 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
|
|
|
86
100
|
novita_show_paid: false,
|
|
87
101
|
routeway_show_paid: false,
|
|
88
102
|
fastrouter_show_paid: false,
|
|
103
|
+
tokenrouter_show_paid: false,
|
|
89
104
|
openrouter_show_paid: false,
|
|
90
105
|
opencode_show_paid: false,
|
|
91
106
|
};
|
|
92
107
|
|
|
93
|
-
const
|
|
94
|
-
const CONFIG_PATH = join(PI_DIR, "free.json");
|
|
108
|
+
const CONFIG_PATH = join(PI_DATA_DIR, "free.json");
|
|
95
109
|
|
|
96
110
|
function ensureConfigFile(): void {
|
|
97
111
|
try {
|
|
98
|
-
|
|
112
|
+
ensureDir(PI_DATA_DIR);
|
|
99
113
|
if (existsSync(CONFIG_PATH)) {
|
|
100
114
|
let existing: PiFreeConfig;
|
|
101
115
|
try {
|
|
@@ -137,7 +151,10 @@ function ensureConfigFile(): void {
|
|
|
137
151
|
|
|
138
152
|
export function loadConfigFile(): PiFreeConfig {
|
|
139
153
|
try {
|
|
140
|
-
return JSON.parse(
|
|
154
|
+
return JSON.parse(
|
|
155
|
+
readFileSync(CONFIG_PATH, "utf8"),
|
|
156
|
+
safeJsonReviver,
|
|
157
|
+
) as PiFreeConfig;
|
|
141
158
|
} catch (err) {
|
|
142
159
|
_logger.error("Could not parse config file — returning empty config", {
|
|
143
160
|
path: CONFIG_PATH,
|
|
@@ -231,6 +248,13 @@ export function getRoutewayShowPaid(): boolean {
|
|
|
231
248
|
return resolveBool("ROUTEWAY_SHOW_PAID", loadConfigFile().routeway_show_paid);
|
|
232
249
|
}
|
|
233
250
|
|
|
251
|
+
export function getTokenrouterShowPaid(): boolean {
|
|
252
|
+
return resolveBool(
|
|
253
|
+
"TOKENROUTER_SHOW_PAID",
|
|
254
|
+
loadConfigFile().tokenrouter_show_paid,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
234
258
|
export function getFastrouterShowPaid(): boolean {
|
|
235
259
|
return resolveBool(
|
|
236
260
|
"FASTROUTER_SHOW_PAID",
|
|
@@ -277,6 +301,8 @@ export function getProviderShowPaid(providerId: string): boolean {
|
|
|
277
301
|
return getNovitaShowPaid();
|
|
278
302
|
case "routeway":
|
|
279
303
|
return getRoutewayShowPaid();
|
|
304
|
+
case "tokenrouter":
|
|
305
|
+
return getTokenrouterShowPaid();
|
|
280
306
|
case "fastrouter":
|
|
281
307
|
return getFastrouterShowPaid();
|
|
282
308
|
case "ollama-cloud":
|
|
@@ -350,6 +376,10 @@ export function getFastrouterApiKey(): string | undefined {
|
|
|
350
376
|
return resolve("FASTROUTER_API_KEY", loadConfigFile().fastrouter_api_key);
|
|
351
377
|
}
|
|
352
378
|
|
|
379
|
+
export function getTokenrouterApiKey(): string | undefined {
|
|
380
|
+
return resolve("TOKENROUTER_API_KEY", loadConfigFile().tokenrouter_api_key);
|
|
381
|
+
}
|
|
382
|
+
|
|
353
383
|
export function getOllamaApiKey(): string | undefined {
|
|
354
384
|
return resolve("OLLAMA_API_KEY", loadConfigFile().ollama_api_key);
|
|
355
385
|
}
|
|
@@ -394,10 +424,10 @@ function readAuthJsonKey(
|
|
|
394
424
|
|
|
395
425
|
// Check auth.json
|
|
396
426
|
try {
|
|
397
|
-
const authPath = join(
|
|
427
|
+
const authPath = join(PI_DATA_DIR, "agent", "auth.json");
|
|
398
428
|
if (!existsSync(authPath)) return undefined;
|
|
399
429
|
const raw = readFileSync(authPath, "utf8");
|
|
400
|
-
const auth = JSON.parse(raw) as Record<
|
|
430
|
+
const auth = JSON.parse(raw, safeJsonReviver) as Record<
|
|
401
431
|
string,
|
|
402
432
|
{ type?: string; key?: string }
|
|
403
433
|
>;
|
|
@@ -479,7 +509,7 @@ export function saveConfig(updates: Partial<PiFreeConfig>): void {
|
|
|
479
509
|
|
|
480
510
|
let existing: PiFreeConfig;
|
|
481
511
|
try {
|
|
482
|
-
existing = JSON.parse(raw) as PiFreeConfig;
|
|
512
|
+
existing = JSON.parse(raw, safeJsonReviver) as PiFreeConfig;
|
|
483
513
|
} catch (parseErr) {
|
|
484
514
|
// File exists but is corrupt. REFUSE to overwrite it with a partial
|
|
485
515
|
// config — that would permanently destroy the user's keys.
|
|
@@ -508,6 +538,90 @@ export function saveConfig(updates: Partial<PiFreeConfig>): void {
|
|
|
508
538
|
}
|
|
509
539
|
}
|
|
510
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Serialise all config RMW operations to prevent concurrent updates
|
|
543
|
+
* from clobbering each other (e.g. two provider probes finishing at the
|
|
544
|
+
* same time both writing hidden_models and losing the other's update).
|
|
545
|
+
*/
|
|
546
|
+
class ConfigLock {
|
|
547
|
+
private promise: Promise<void> = Promise.resolve();
|
|
548
|
+
|
|
549
|
+
async acquire(): Promise<() => void> {
|
|
550
|
+
let release: () => void;
|
|
551
|
+
const newPromise = new Promise<void>((resolve) => {
|
|
552
|
+
release = resolve;
|
|
553
|
+
});
|
|
554
|
+
const previous = this.promise;
|
|
555
|
+
this.promise = previous.then(() => newPromise);
|
|
556
|
+
await previous;
|
|
557
|
+
return release!;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const _configLock = new ConfigLock();
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Atomically read-modify-write the config file. The updater function
|
|
565
|
+
* receives the current parsed config and returns the partial updates to
|
|
566
|
+
* merge. Concurrent calls are serialised by an internal lock.
|
|
567
|
+
*
|
|
568
|
+
* If the config file is corrupt, the updater is NOT called and the file
|
|
569
|
+
* is left untouched (matches saveConfig's safety behaviour).
|
|
570
|
+
*/
|
|
571
|
+
export async function updateConfig(
|
|
572
|
+
updater: (current: PiFreeConfig) => Partial<PiFreeConfig>,
|
|
573
|
+
): Promise<void> {
|
|
574
|
+
const release = await _configLock.acquire();
|
|
575
|
+
try {
|
|
576
|
+
const raw = readRawConfigFile();
|
|
577
|
+
if (raw === undefined) {
|
|
578
|
+
// File doesn't exist — start from template, apply updater once
|
|
579
|
+
const updated = updater({ ...CONFIG_TEMPLATE });
|
|
580
|
+
const merged = { ...CONFIG_TEMPLATE, ...updated };
|
|
581
|
+
writeFileSync(
|
|
582
|
+
CONFIG_PATH,
|
|
583
|
+
`${JSON.stringify(merged, null, 2)}\n`,
|
|
584
|
+
"utf8",
|
|
585
|
+
);
|
|
586
|
+
_logger.info("Config updated (new file)", {
|
|
587
|
+
path: CONFIG_PATH,
|
|
588
|
+
keys: Object.keys(updated),
|
|
589
|
+
});
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let existing: PiFreeConfig;
|
|
594
|
+
try {
|
|
595
|
+
existing = JSON.parse(raw, safeJsonReviver) as PiFreeConfig;
|
|
596
|
+
} catch (parseErr) {
|
|
597
|
+
_logger.error(
|
|
598
|
+
"REFUSING to update config — existing file is corrupt. Fix or delete ~/.pi/free.json manually.",
|
|
599
|
+
{
|
|
600
|
+
path: CONFIG_PATH,
|
|
601
|
+
error:
|
|
602
|
+
parseErr instanceof Error ? parseErr.message : String(parseErr),
|
|
603
|
+
},
|
|
604
|
+
);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const updated = updater(existing);
|
|
609
|
+
const merged = { ...existing, ...updated };
|
|
610
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
611
|
+
_logger.info("Config updated", {
|
|
612
|
+
path: CONFIG_PATH,
|
|
613
|
+
keys: Object.keys(updated),
|
|
614
|
+
});
|
|
615
|
+
} catch (err) {
|
|
616
|
+
_logger.error("Failed to update config", {
|
|
617
|
+
path: CONFIG_PATH,
|
|
618
|
+
error: err instanceof Error ? err.message : String(err),
|
|
619
|
+
});
|
|
620
|
+
} finally {
|
|
621
|
+
release();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
511
625
|
export function getConfig(): PiFreeConfig {
|
|
512
626
|
return loadConfigFile();
|
|
513
627
|
}
|
package/constants.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
|
|
10
10
|
export const PROVIDER_KILO = "kilo";
|
|
11
11
|
export const PROVIDER_CLINE = "cline";
|
|
12
|
-
export const PROVIDER_NVIDIA = "nvidia";
|
|
13
12
|
export const PROVIDER_CLOUDFLARE = "cloudflare";
|
|
14
13
|
export const PROVIDER_OLLAMA = "ollama-cloud";
|
|
15
14
|
/** @deprecated Qwen provider is deprecated. The 1,000 req/day free tier is no longer available. */
|
|
@@ -24,11 +23,11 @@ export const PROVIDER_SAMBANOVA = "sambanova";
|
|
|
24
23
|
export const PROVIDER_TOGETHER = "together";
|
|
25
24
|
export const PROVIDER_NOVITA = "novita";
|
|
26
25
|
export const PROVIDER_ROUTEWAY = "routeway";
|
|
26
|
+
export const PROVIDER_TOKENROUTER = "tokenrouter";
|
|
27
27
|
|
|
28
28
|
export const ALL_UNIQUE_PROVIDERS = [
|
|
29
29
|
PROVIDER_KILO,
|
|
30
30
|
PROVIDER_CLINE,
|
|
31
|
-
PROVIDER_NVIDIA,
|
|
32
31
|
/** @deprecated Qwen free tier no longer available */
|
|
33
32
|
PROVIDER_QWEN,
|
|
34
33
|
PROVIDER_MODAL,
|
|
@@ -42,6 +41,7 @@ export const ALL_UNIQUE_PROVIDERS = [
|
|
|
42
41
|
PROVIDER_TOGETHER,
|
|
43
42
|
PROVIDER_NOVITA,
|
|
44
43
|
PROVIDER_ROUTEWAY,
|
|
44
|
+
PROVIDER_TOKENROUTER,
|
|
45
45
|
] as const;
|
|
46
46
|
|
|
47
47
|
// =============================================================================
|
|
@@ -49,7 +49,6 @@ export const ALL_UNIQUE_PROVIDERS = [
|
|
|
49
49
|
// =============================================================================
|
|
50
50
|
|
|
51
51
|
export const BASE_URL_KILO = "https://api.kilo.ai/api/gateway";
|
|
52
|
-
export const BASE_URL_NVIDIA = "https://integrate.api.nvidia.com/v1";
|
|
53
52
|
export const BASE_URL_CLOUDFLARE = "https://api.cloudflare.com/client/v4";
|
|
54
53
|
export const BASE_URL_OLLAMA = "https://ollama.com/v1"; // OpenAI-compatible API endpoint
|
|
55
54
|
export const BASE_URL_CLINE = "https://api.cline.bot/api/v1";
|
|
@@ -65,6 +64,7 @@ export const BASE_URL_SAMBANOVA = "https://api.sambanova.ai/v1";
|
|
|
65
64
|
export const BASE_URL_TOGETHER = "https://api.together.xyz/v1";
|
|
66
65
|
export const BASE_URL_NOVITA = "https://api.novita.ai/openai/v1";
|
|
67
66
|
export const BASE_URL_ROUTEWAY = "https://api.routeway.ai/v1";
|
|
67
|
+
export const BASE_URL_TOKENROUTER = "https://api.tokenrouter.com/v1";
|
|
68
68
|
|
|
69
69
|
/** Cline fetches free models from OpenRouter */
|
|
70
70
|
export const BASE_URL_OPENROUTER = "https://openrouter.ai/api/v1";
|
|
@@ -85,12 +85,6 @@ export const URL_MODAL_TOS = "https://modal.com/terms";
|
|
|
85
85
|
|
|
86
86
|
export const CLINE_AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
87
87
|
|
|
88
|
-
// =============================================================================
|
|
89
|
-
// Configuration thresholds
|
|
90
|
-
// =============================================================================
|
|
91
|
-
|
|
92
|
-
export const NVIDIA_MIN_SIZE_B = 70; // Minimum model size for NVIDIA NIM
|
|
93
|
-
|
|
94
88
|
// =============================================================================
|
|
95
89
|
// Timeouts (milliseconds)
|
|
96
90
|
// =============================================================================
|
package/index.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
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
17
|
* - Routeway: OpenAI-compatible gateway with free `:free` models
|
|
18
|
+
* - TokenRouter: OpenAI-compatible gateway routing to 90+ models
|
|
18
19
|
* - LLM7: AI gateway (free default/fast selectors)
|
|
19
20
|
*/
|
|
20
21
|
|
|
@@ -50,7 +51,7 @@ import sambanova from "./providers/sambanova/sambanova.ts";
|
|
|
50
51
|
import together from "./providers/together/together.ts";
|
|
51
52
|
import novita from "./providers/novita/novita.ts";
|
|
52
53
|
import routeway from "./providers/routeway/routeway.ts";
|
|
53
|
-
import
|
|
54
|
+
import tokenRouter from "./providers/tokenrouter/tokenrouter.ts";
|
|
54
55
|
import ollama from "./providers/ollama/ollama.ts";
|
|
55
56
|
import zenmux from "./providers/zenmux/zenmux.ts";
|
|
56
57
|
|
|
@@ -67,7 +68,7 @@ function setupGlobalCommands(pi: ExtensionAPI) {
|
|
|
67
68
|
handler: async (_args, ctx) => {
|
|
68
69
|
const current = getGlobalFreeOnly();
|
|
69
70
|
const next = !current;
|
|
70
|
-
applyGlobalFilter(
|
|
71
|
+
applyGlobalFilter(next, { force: true });
|
|
71
72
|
|
|
72
73
|
const registry = getProviderRegistry();
|
|
73
74
|
const providerCount = registry.size;
|
|
@@ -111,11 +112,7 @@ function setupGlobalCommands(pi: ExtensionAPI) {
|
|
|
111
112
|
"cerebras",
|
|
112
113
|
]);
|
|
113
114
|
// Freemium providers - all models share a free tier quota
|
|
114
|
-
const freemiumProviders = new Set([
|
|
115
|
-
"nvidia",
|
|
116
|
-
"sambanova",
|
|
117
|
-
"ollama-cloud",
|
|
118
|
-
]);
|
|
115
|
+
const freemiumProviders = new Set(["sambanova", "ollama-cloud"]);
|
|
119
116
|
// Trial credit providers - one-time credits, otherwise paid
|
|
120
117
|
const trialCreditProviders = new Set(["deepinfra"]);
|
|
121
118
|
|
|
@@ -208,7 +205,7 @@ function setupGlobalCommands(pi: ExtensionAPI) {
|
|
|
208
205
|
pi.registerCommand("clear-free-telemetry", {
|
|
209
206
|
description: "Clear all model telemetry data",
|
|
210
207
|
handler: async (_args, ctx) => {
|
|
211
|
-
clearTelemetry();
|
|
208
|
+
await clearTelemetry();
|
|
212
209
|
ctx.ui.notify("Telemetry data cleared", "info");
|
|
213
210
|
},
|
|
214
211
|
});
|
|
@@ -268,7 +265,7 @@ function setupTelemetry(pi: ExtensionAPI) {
|
|
|
268
265
|
});
|
|
269
266
|
|
|
270
267
|
// Record telemetry when a turn completes
|
|
271
|
-
pi.on("turn_end", (event, ctx) => {
|
|
268
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
272
269
|
if (!ctx.model) return;
|
|
273
270
|
if (!isFreeModel(ctx.model as any)) return;
|
|
274
271
|
|
|
@@ -302,14 +299,16 @@ function setupTelemetry(pi: ExtensionAPI) {
|
|
|
302
299
|
const cost = usage?.cost?.total ?? 0;
|
|
303
300
|
const isError = msg.stopReason === "error" || !!msg.errorMessage;
|
|
304
301
|
|
|
305
|
-
recordModelCall(
|
|
302
|
+
await recordModelCall(
|
|
306
303
|
provider,
|
|
307
304
|
model,
|
|
308
305
|
{ input: inputTokens, output: outputTokens, totalTokens },
|
|
309
306
|
cost,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
307
|
+
{
|
|
308
|
+
success: !isError,
|
|
309
|
+
stopReason: msg.stopReason,
|
|
310
|
+
errorMessage: msg.errorMessage,
|
|
311
|
+
},
|
|
313
312
|
);
|
|
314
313
|
});
|
|
315
314
|
}
|
|
@@ -334,7 +333,6 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
|
|
|
334
333
|
// Load all unique providers
|
|
335
334
|
// Each provider will register itself with the global toggle system
|
|
336
335
|
await Promise.allSettled([
|
|
337
|
-
nvidia(pi),
|
|
338
336
|
kilo(pi),
|
|
339
337
|
ollama(pi),
|
|
340
338
|
cline(pi),
|
|
@@ -347,6 +345,7 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
|
|
|
347
345
|
together(pi),
|
|
348
346
|
novita(pi),
|
|
349
347
|
routeway(pi),
|
|
348
|
+
tokenRouter(pi),
|
|
350
349
|
]);
|
|
351
350
|
|
|
352
351
|
// Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face,
|
|
@@ -362,7 +361,7 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
|
|
|
362
361
|
// Apply initial global filter if free-only mode is enabled
|
|
363
362
|
if (globalFreeOnly) {
|
|
364
363
|
_logger.info("[pi-free] Applying initial free-only filter");
|
|
365
|
-
applyGlobalFilter(
|
|
364
|
+
applyGlobalFilter(true);
|
|
366
365
|
}
|
|
367
366
|
|
|
368
367
|
const registry = getProviderRegistry();
|
package/lib/built-in-toggle.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
isFreeModel,
|
|
24
24
|
registerWithGlobalToggle,
|
|
25
25
|
} from "./registry.ts";
|
|
26
|
+
import { wrapSessionStartHandler } from "./session-start-metrics.ts";
|
|
26
27
|
import { createToggleState } from "./toggle-state.ts";
|
|
27
28
|
import {
|
|
28
29
|
OPENCODE_DYNAMIC_API,
|
|
@@ -34,7 +35,16 @@ import {
|
|
|
34
35
|
const _logger = createLogger("built-in-toggle");
|
|
35
36
|
|
|
36
37
|
// OpenCode requires per-request ids; see createOpenCodeStreamSimple().
|
|
37
|
-
|
|
38
|
+
// Lazy-initialised because the OpenCode dynamic fetcher in
|
|
39
|
+
// providers/dynamic-built-in/ usually wins the race for `opencode`,
|
|
40
|
+
// leaving this fallback capture unused — no point allocating the
|
|
41
|
+
// session tracker on every module import.
|
|
42
|
+
let _opencodeSession: ReturnType<typeof createOpenCodeSessionTracker> | null =
|
|
43
|
+
null;
|
|
44
|
+
function getOpenCodeSession() {
|
|
45
|
+
if (!_opencodeSession) _opencodeSession = createOpenCodeSessionTracker();
|
|
46
|
+
return _opencodeSession;
|
|
47
|
+
}
|
|
38
48
|
|
|
39
49
|
// =============================================================================
|
|
40
50
|
// Configuration
|
|
@@ -90,22 +100,25 @@ export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
|
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
// Capture built-in models on session start and apply initial filter
|
|
93
|
-
pi.on(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
pi.on(
|
|
104
|
+
"session_start",
|
|
105
|
+
wrapSessionStartHandler("built-in-toggle", async (_event, ctx) => {
|
|
106
|
+
for (const config of activeConfigs) {
|
|
107
|
+
if (providerStates.has(config.id)) {
|
|
108
|
+
// Already captured — skip to avoid re-registering
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
99
111
|
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
const state = tryCaptureProvider(pi, config, ctx);
|
|
113
|
+
if (!state) continue;
|
|
102
114
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
115
|
+
const applied = state.toggleState.applyCurrent(state.reRegister);
|
|
116
|
+
_logger.info(
|
|
117
|
+
`[built-in-toggle] ${config.id}: applied ${applied.mode} mode with ${applied.models.length} models`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
// =============================================================================
|
|
@@ -140,7 +153,7 @@ function tryCaptureProvider(
|
|
|
140
153
|
apiKey: apiKeyEnv,
|
|
141
154
|
api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : api,
|
|
142
155
|
...(isOpenCodeProvider(config.id)
|
|
143
|
-
? { streamSimple: createOpenCodeStreamSimple(
|
|
156
|
+
? { streamSimple: createOpenCodeStreamSimple(getOpenCodeSession()) }
|
|
144
157
|
: {}),
|
|
145
158
|
models,
|
|
146
159
|
});
|
package/lib/json-persistence.ts
CHANGED
|
@@ -1,17 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared JSON persistence utilities.
|
|
3
|
-
*
|
|
3
|
+
* Consolidated file I/O patterns from usage-store.ts and free-tier-limits.ts
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync,
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { dirname } from "node:path";
|
|
8
8
|
import { createLogger } from "./logger.ts";
|
|
9
|
+
import { ensureDir } from "./paths.ts";
|
|
9
10
|
|
|
10
11
|
const _logger = createLogger("json-persistence");
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* JSON.parse reviver that strips prototype-pollution payloads.
|
|
15
|
+
* Filters out `__proto__` and `constructor` keys at every level of the
|
|
16
|
+
* parsed object, preventing attackers from polluting Object.prototype
|
|
17
|
+
* through crafted config/cache files.
|
|
18
|
+
*/
|
|
19
|
+
function safeJsonReviver(_key: string, value: unknown): unknown {
|
|
20
|
+
if (_key === "__proto__" || _key === "constructor") {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
export interface JSONStore<T> {
|
|
13
27
|
load(): T;
|
|
14
28
|
save(data: T): void;
|
|
29
|
+
update(updater: (data: T) => T): Promise<T>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class Lock {
|
|
33
|
+
private promise: Promise<void> = Promise.resolve();
|
|
34
|
+
|
|
35
|
+
async acquire(): Promise<() => void> {
|
|
36
|
+
let release: () => void;
|
|
37
|
+
const newPromise = new Promise<void>((resolve) => {
|
|
38
|
+
release = resolve;
|
|
39
|
+
});
|
|
40
|
+
const previous = this.promise;
|
|
41
|
+
this.promise = previous.then(() => newPromise);
|
|
42
|
+
await previous;
|
|
43
|
+
return release!;
|
|
44
|
+
}
|
|
15
45
|
}
|
|
16
46
|
|
|
17
47
|
/**
|
|
@@ -22,16 +52,23 @@ export function createJSONStore<T extends object>(
|
|
|
22
52
|
defaultValue: T,
|
|
23
53
|
): JSONStore<T> {
|
|
24
54
|
let cached: T | null = null;
|
|
55
|
+
const lock = new Lock();
|
|
25
56
|
|
|
26
57
|
function load(): T {
|
|
27
58
|
if (cached) return cached;
|
|
28
59
|
try {
|
|
29
60
|
if (existsSync(filepath)) {
|
|
30
|
-
cached = JSON.parse(
|
|
61
|
+
cached = JSON.parse(
|
|
62
|
+
readFileSync(filepath, "utf-8"),
|
|
63
|
+
safeJsonReviver,
|
|
64
|
+
) as T;
|
|
31
65
|
return cached;
|
|
32
66
|
}
|
|
33
67
|
} catch (err) {
|
|
34
|
-
_logger.warn("Failed to load JSON store, using default", {
|
|
68
|
+
_logger.warn("Failed to load JSON store, using default", {
|
|
69
|
+
filepath,
|
|
70
|
+
error: err,
|
|
71
|
+
});
|
|
35
72
|
}
|
|
36
73
|
cached = defaultValue;
|
|
37
74
|
return cached;
|
|
@@ -40,62 +77,93 @@ export function createJSONStore<T extends object>(
|
|
|
40
77
|
function save(data: T): void {
|
|
41
78
|
cached = data;
|
|
42
79
|
try {
|
|
43
|
-
|
|
44
|
-
if (!existsSync(dir)) {
|
|
45
|
-
mkdirSync(dir, { recursive: true });
|
|
46
|
-
}
|
|
80
|
+
ensureDir(dirname(filepath));
|
|
47
81
|
writeFileSync(filepath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
48
82
|
} catch (err) {
|
|
49
83
|
_logger.warn("Failed to save JSON store", { filepath, error: err });
|
|
50
84
|
}
|
|
51
85
|
}
|
|
52
86
|
|
|
53
|
-
|
|
87
|
+
async function update(updater: (data: T) => T): Promise<T> {
|
|
88
|
+
const release = await lock.acquire();
|
|
89
|
+
try {
|
|
90
|
+
const data = load();
|
|
91
|
+
const updated = updater(data);
|
|
92
|
+
save(updated);
|
|
93
|
+
return updated;
|
|
94
|
+
} finally {
|
|
95
|
+
release();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { load, save, update };
|
|
54
100
|
}
|
|
55
101
|
|
|
56
102
|
/**
|
|
57
103
|
* Create a JSONL (newline-delimited JSON) store for append-only logs.
|
|
104
|
+
*
|
|
105
|
+
* `append` and `clear` are async and serialised by an internal lock to
|
|
106
|
+
* prevent interleaved writes (e.g. `clear` truncating the file while
|
|
107
|
+
* `append` is mid-write).
|
|
58
108
|
*/
|
|
59
109
|
export function createJSONLStore<T extends object>(
|
|
60
110
|
filepath: string,
|
|
61
111
|
): {
|
|
62
112
|
load(): T[];
|
|
63
|
-
append(entry: T): void
|
|
64
|
-
clear(): void
|
|
113
|
+
append(entry: T): Promise<void>;
|
|
114
|
+
clear(): Promise<void>;
|
|
65
115
|
} {
|
|
116
|
+
const lock = new Lock();
|
|
117
|
+
|
|
66
118
|
function load(): T[] {
|
|
67
119
|
try {
|
|
68
120
|
if (existsSync(filepath)) {
|
|
69
121
|
const content = readFileSync(filepath, "utf-8");
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
122
|
+
const lines = content.split("\n").filter((line) => line.trim());
|
|
123
|
+
const entries: T[] = [];
|
|
124
|
+
for (const [index, line] of lines.entries()) {
|
|
125
|
+
try {
|
|
126
|
+
entries.push(JSON.parse(line, safeJsonReviver) as T);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
_logger.warn("Malformed JSONL line skipped", {
|
|
129
|
+
filepath,
|
|
130
|
+
line: index + 1,
|
|
131
|
+
error: err,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return entries;
|
|
74
136
|
}
|
|
75
137
|
} catch (err) {
|
|
76
|
-
_logger.warn("Failed to load JSONL store, using empty array", {
|
|
138
|
+
_logger.warn("Failed to load JSONL store, using empty array", {
|
|
139
|
+
filepath,
|
|
140
|
+
error: err,
|
|
141
|
+
});
|
|
77
142
|
}
|
|
78
143
|
return [];
|
|
79
144
|
}
|
|
80
145
|
|
|
81
|
-
function append(entry: T): void {
|
|
146
|
+
async function append(entry: T): Promise<void> {
|
|
147
|
+
const release = await lock.acquire();
|
|
82
148
|
try {
|
|
83
|
-
|
|
84
|
-
if (!existsSync(dir)) {
|
|
85
|
-
mkdirSync(dir, { recursive: true });
|
|
86
|
-
}
|
|
149
|
+
ensureDir(dirname(filepath));
|
|
87
150
|
const line = JSON.stringify(entry);
|
|
88
151
|
writeFileSync(filepath, `${line}\n`, { flag: "a", encoding: "utf-8" });
|
|
89
152
|
} catch (err) {
|
|
90
153
|
_logger.warn("Failed to append to JSONL store", { filepath, error: err });
|
|
154
|
+
} finally {
|
|
155
|
+
release();
|
|
91
156
|
}
|
|
92
157
|
}
|
|
93
158
|
|
|
94
|
-
function clear(): void {
|
|
159
|
+
async function clear(): Promise<void> {
|
|
160
|
+
const release = await lock.acquire();
|
|
95
161
|
try {
|
|
96
162
|
writeFileSync(filepath, "", "utf-8");
|
|
97
163
|
} catch (err) {
|
|
98
164
|
_logger.warn("Failed to clear JSONL store", { filepath, error: err });
|
|
165
|
+
} finally {
|
|
166
|
+
release();
|
|
99
167
|
}
|
|
100
168
|
}
|
|
101
169
|
|