pi-free 2.2.0 → 2.2.2
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 +31 -0
- package/config.ts +97 -39
- package/constants.ts +5 -0
- package/index.ts +69 -30
- package/package.json +1 -1
- package/providers/bai/bai.ts +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.2.2] - 2026-06-19
|
|
11
|
+
|
|
12
|
+
### Refactored
|
|
13
|
+
|
|
14
|
+
- `config.ts`: Introduced `PROVIDER_META` table that pairs each
|
|
15
|
+
provider's ID, env-var prefix, and typed config key. `getProviderShowPaid`
|
|
16
|
+
now delegates to a generic `resolveShowPaidForProvider` resolver
|
|
17
|
+
instead of a 17-case switch. New `PROVIDER_OPENROUTER`, `PROVIDER_OPENCODE`,
|
|
18
|
+
and `PROVIDER_FASTROUTER` constants added to `constants.ts` and used
|
|
19
|
+
in the table (DRYKISS DRY/Architecture findings).
|
|
20
|
+
- `index.ts`: Wrap dynamic built-in provider import in `try/catch`
|
|
21
|
+
with full error+stack logging to both `~/.pi/free.log` and stderr
|
|
22
|
+
(DRYKISS Resilience finding).
|
|
23
|
+
- `providers/bai/bai.ts`: Improve startup-failure message to point users
|
|
24
|
+
at `~/.pi/free.log` and their API key (DRYKISS Resilience finding).
|
|
25
|
+
|
|
26
|
+
## [2.2.1] - 2026-06-19
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
|
|
30
|
+
- `~/.pi/free.json` (which contains API keys for paid providers) is now
|
|
31
|
+
written and re-tightened with mode `0600` (owner read/write only) on
|
|
32
|
+
every startup and on every write. Previously the file was world-readable
|
|
33
|
+
on Unix. No-op on Windows. Closes DRYKISS finding.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Wrap quota monitoring and telemetry event handlers in `try/catch` so a
|
|
38
|
+
failed status-bar update or telemetry write can never break the agent
|
|
39
|
+
loop (DRYKISS resilience findings).
|
|
40
|
+
|
|
10
41
|
## [2.2.0] - 2026-06-19
|
|
11
42
|
|
|
12
43
|
### Added
|
package/config.ts
CHANGED
|
@@ -9,13 +9,35 @@
|
|
|
9
9
|
* (e.g. after toggle-{provider}) are visible immediately.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
PROVIDER_BAI,
|
|
16
|
+
PROVIDER_CLINE,
|
|
17
|
+
PROVIDER_FASTROUTER,
|
|
18
|
+
PROVIDER_KILO,
|
|
19
|
+
PROVIDER_OLLAMA,
|
|
20
|
+
PROVIDER_OPENCODE,
|
|
21
|
+
PROVIDER_OPENROUTER,
|
|
22
|
+
PROVIDER_ROUTEWAY,
|
|
23
|
+
PROVIDER_TOKENROUTER,
|
|
24
|
+
PROVIDER_ZENMUX,
|
|
25
|
+
PROVIDER_CROFAI,
|
|
26
|
+
PROVIDER_CODESTRAL,
|
|
27
|
+
PROVIDER_LLM7,
|
|
28
|
+
PROVIDER_DEEPINFRA,
|
|
29
|
+
PROVIDER_SAMBANOVA,
|
|
30
|
+
PROVIDER_TOGETHER,
|
|
31
|
+
PROVIDER_NOVITA,
|
|
32
|
+
} from "./constants.ts";
|
|
14
33
|
export {
|
|
15
34
|
PROVIDER_BAI,
|
|
16
35
|
PROVIDER_CLINE,
|
|
36
|
+
PROVIDER_FASTROUTER,
|
|
17
37
|
PROVIDER_KILO,
|
|
18
38
|
PROVIDER_MODAL,
|
|
39
|
+
PROVIDER_OPENCODE,
|
|
40
|
+
PROVIDER_OPENROUTER,
|
|
19
41
|
PROVIDER_QWEN,
|
|
20
42
|
PROVIDER_ROUTEWAY,
|
|
21
43
|
PROVIDER_TOKENROUTER,
|
|
@@ -130,6 +152,9 @@ function ensureConfigFile(): void {
|
|
|
130
152
|
);
|
|
131
153
|
return;
|
|
132
154
|
}
|
|
155
|
+
// Always tighten permissions on startup, even if contents are
|
|
156
|
+
// unchanged — older installs may have a world-readable file.
|
|
157
|
+
restrictConfigFilePermissions();
|
|
133
158
|
// Merge with template to add any missing keys, preserving existing values
|
|
134
159
|
const merged = { ...CONFIG_TEMPLATE, ...existing };
|
|
135
160
|
if (JSON.stringify(merged) !== JSON.stringify(existing)) {
|
|
@@ -138,6 +163,7 @@ function ensureConfigFile(): void {
|
|
|
138
163
|
`${JSON.stringify(merged, null, 2)}\n`,
|
|
139
164
|
"utf8",
|
|
140
165
|
);
|
|
166
|
+
restrictConfigFilePermissions();
|
|
141
167
|
}
|
|
142
168
|
} else {
|
|
143
169
|
writeFileSync(
|
|
@@ -145,6 +171,7 @@ function ensureConfigFile(): void {
|
|
|
145
171
|
`${JSON.stringify(CONFIG_TEMPLATE, null, 2)}\n`,
|
|
146
172
|
"utf8",
|
|
147
173
|
);
|
|
174
|
+
restrictConfigFilePermissions();
|
|
148
175
|
}
|
|
149
176
|
} catch (err) {
|
|
150
177
|
_logger.warn("Could not create config file", {
|
|
@@ -154,6 +181,24 @@ function ensureConfigFile(): void {
|
|
|
154
181
|
}
|
|
155
182
|
}
|
|
156
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Restrict `~/.pi/free.json` to owner read/write (0600). The file may
|
|
186
|
+
* contain API keys for paid providers, so it must never be world-readable.
|
|
187
|
+
* Best-effort: if chmod is not supported on the platform/filesystem,
|
|
188
|
+
* log a warning and continue (the keys are still safe inside the user's
|
|
189
|
+
* home directory).
|
|
190
|
+
*/
|
|
191
|
+
function restrictConfigFilePermissions(): void {
|
|
192
|
+
try {
|
|
193
|
+
chmodSync(CONFIG_PATH, 0o600);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
_logger.warn("Could not restrict config file permissions to 0600", {
|
|
196
|
+
path: CONFIG_PATH,
|
|
197
|
+
error: err instanceof Error ? err.message : String(err),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
157
202
|
export function loadConfigFile(): PiFreeConfig {
|
|
158
203
|
try {
|
|
159
204
|
return JSON.parse(
|
|
@@ -196,6 +241,56 @@ function resolveBool(envKey: string, fileVal?: boolean): boolean {
|
|
|
196
241
|
return fileVal === true;
|
|
197
242
|
}
|
|
198
243
|
|
|
244
|
+
// =============================================================================
|
|
245
|
+
// Per-provider metadata table
|
|
246
|
+
// Adding a new provider only requires a single entry here plus the
|
|
247
|
+
// corresponding field in the PiFreeConfig interface and CONFIG_TEMPLATE.
|
|
248
|
+
// Each entry pairs the provider ID with its env-var prefix (used for both
|
|
249
|
+
// the API key and show_paid flag) and the typed key on PiFreeConfig.
|
|
250
|
+
// =============================================================================
|
|
251
|
+
|
|
252
|
+
interface ProviderMeta {
|
|
253
|
+
id: string;
|
|
254
|
+
/** Env var prefix, e.g. "KILO" => KILO_SHOW_PAID and KILO_API_KEY */
|
|
255
|
+
prefix: string;
|
|
256
|
+
/** Typed accessor returning the show_paid value from PiFreeConfig */
|
|
257
|
+
showPaidKey: keyof PiFreeConfig;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const PROVIDER_META: readonly ProviderMeta[] = [
|
|
261
|
+
{ id: PROVIDER_KILO, prefix: "KILO", showPaidKey: "kilo_show_paid" },
|
|
262
|
+
{ id: PROVIDER_CLINE, prefix: "CLINE", showPaidKey: "cline_show_paid" },
|
|
263
|
+
{ id: PROVIDER_ZENMUX, prefix: "ZENMUX", showPaidKey: "zenmux_show_paid" },
|
|
264
|
+
{ id: PROVIDER_CROFAI, prefix: "CROFAI", showPaidKey: "crofai_show_paid" },
|
|
265
|
+
{ id: PROVIDER_CODESTRAL, prefix: "CODESTRAL", showPaidKey: "codestral_show_paid" },
|
|
266
|
+
{ id: PROVIDER_LLM7, prefix: "LLM7", showPaidKey: "llm7_show_paid" },
|
|
267
|
+
{ id: PROVIDER_DEEPINFRA, prefix: "DEEPINFRA", showPaidKey: "deepinfra_show_paid" },
|
|
268
|
+
{ id: PROVIDER_SAMBANOVA, prefix: "SAMBANOVA", showPaidKey: "sambanova_show_paid" },
|
|
269
|
+
{ id: PROVIDER_TOGETHER, prefix: "TOGETHER", showPaidKey: "together_show_paid" },
|
|
270
|
+
{ id: PROVIDER_NOVITA, prefix: "NOVITA", showPaidKey: "novita_show_paid" },
|
|
271
|
+
{ id: PROVIDER_ROUTEWAY, prefix: "ROUTEWAY", showPaidKey: "routeway_show_paid" },
|
|
272
|
+
{ id: PROVIDER_TOKENROUTER, prefix: "TOKENROUTER", showPaidKey: "tokenrouter_show_paid" },
|
|
273
|
+
{ id: PROVIDER_BAI, prefix: "BAI", showPaidKey: "bai_show_paid" },
|
|
274
|
+
{ id: PROVIDER_FASTROUTER, prefix: "FASTROUTER", showPaidKey: "fastrouter_show_paid" },
|
|
275
|
+
{ id: PROVIDER_OLLAMA, prefix: "OLLAMA", showPaidKey: "ollama_show_paid" },
|
|
276
|
+
{ id: PROVIDER_OPENROUTER, prefix: "OPENROUTER", showPaidKey: "openrouter_show_paid" },
|
|
277
|
+
{ id: PROVIDER_OPENCODE, prefix: "OPENCODE", showPaidKey: "opencode_show_paid" },
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
const PROVIDER_META_BY_ID = new Map(PROVIDER_META.map((m) => [m.id, m]));
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Generic show_paid resolver backed by PROVIDER_META. Returns false
|
|
284
|
+
* for unknown provider IDs (matches the previous switch default).
|
|
285
|
+
*/
|
|
286
|
+
function resolveShowPaidForProvider(providerId: string): boolean {
|
|
287
|
+
const meta = PROVIDER_META_BY_ID.get(providerId);
|
|
288
|
+
if (!meta) return false;
|
|
289
|
+
const cfg = loadConfigFile();
|
|
290
|
+
const fileVal = cfg[meta.showPaidKey];
|
|
291
|
+
return resolveBool(`${meta.prefix}_SHOW_PAID`, fileVal as boolean | undefined);
|
|
292
|
+
}
|
|
293
|
+
|
|
199
294
|
// =============================================================================
|
|
200
295
|
// Per-provider paid-model flags (getters so toggles reflect immediately)
|
|
201
296
|
// =============================================================================
|
|
@@ -287,44 +382,7 @@ export function getOpencodeShowPaid(): boolean {
|
|
|
287
382
|
}
|
|
288
383
|
|
|
289
384
|
export function getProviderShowPaid(providerId: string): boolean {
|
|
290
|
-
|
|
291
|
-
case "kilo":
|
|
292
|
-
return getKiloShowPaid();
|
|
293
|
-
case "cline":
|
|
294
|
-
return getClineShowPaid();
|
|
295
|
-
case "zenmux":
|
|
296
|
-
return getZenmuxShowPaid();
|
|
297
|
-
case "crofai":
|
|
298
|
-
return getCrofaiShowPaid();
|
|
299
|
-
case "codestral":
|
|
300
|
-
return getCodestralShowPaid();
|
|
301
|
-
case "llm7":
|
|
302
|
-
return getLlm7ShowPaid();
|
|
303
|
-
case "deepinfra":
|
|
304
|
-
return getDeepinfraShowPaid();
|
|
305
|
-
case "sambanova":
|
|
306
|
-
return getSambanovaShowPaid();
|
|
307
|
-
case "together":
|
|
308
|
-
return getTogetherShowPaid();
|
|
309
|
-
case "novita":
|
|
310
|
-
return getNovitaShowPaid();
|
|
311
|
-
case "routeway":
|
|
312
|
-
return getRoutewayShowPaid();
|
|
313
|
-
case "tokenrouter":
|
|
314
|
-
return getTokenrouterShowPaid();
|
|
315
|
-
case "bai":
|
|
316
|
-
return getBaiShowPaid();
|
|
317
|
-
case "fastrouter":
|
|
318
|
-
return getFastrouterShowPaid();
|
|
319
|
-
case "ollama-cloud":
|
|
320
|
-
return getOllamaShowPaid();
|
|
321
|
-
case "openrouter":
|
|
322
|
-
return getOpenrouterShowPaid();
|
|
323
|
-
case "opencode":
|
|
324
|
-
return getOpencodeShowPaid();
|
|
325
|
-
default:
|
|
326
|
-
return false;
|
|
327
|
-
}
|
|
385
|
+
return resolveShowPaidForProvider(providerId);
|
|
328
386
|
}
|
|
329
387
|
|
|
330
388
|
// =============================================================================
|
package/constants.ts
CHANGED
|
@@ -26,6 +26,11 @@ export const PROVIDER_ROUTEWAY = "routeway";
|
|
|
26
26
|
export const PROVIDER_TOKENROUTER = "tokenrouter";
|
|
27
27
|
export const PROVIDER_BAI = "bai";
|
|
28
28
|
|
|
29
|
+
// Built-in pi providers that pi-free wraps with toggles
|
|
30
|
+
export const PROVIDER_OPENROUTER = "openrouter";
|
|
31
|
+
export const PROVIDER_OPENCODE = "opencode";
|
|
32
|
+
export const PROVIDER_FASTROUTER = "fastrouter";
|
|
33
|
+
|
|
29
34
|
export const ALL_UNIQUE_PROVIDERS = [
|
|
30
35
|
PROVIDER_KILO,
|
|
31
36
|
PROVIDER_CLINE,
|
package/index.ts
CHANGED
|
@@ -222,29 +222,42 @@ function setupQuotaMonitoring(pi: ExtensionAPI) {
|
|
|
222
222
|
(pi as any).on(
|
|
223
223
|
"after_provider_response",
|
|
224
224
|
(event: { status: number; headers: Record<string, string> }, ctx: any) => {
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
try {
|
|
226
|
+
const providerId = ctx.model?.provider;
|
|
227
|
+
if (!providerId) return;
|
|
227
228
|
|
|
228
|
-
|
|
229
|
+
processQuotaResponse(providerId, event.headers);
|
|
229
230
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
// Update status bar with quota for the active provider
|
|
232
|
+
const status = formatQuotaStatus(providerId);
|
|
233
|
+
if (status) {
|
|
234
|
+
ctx.ui.setStatus("quota", status);
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// Quota monitoring is best-effort — never break the agent flow
|
|
238
|
+
_logger.warn("quota monitoring failed", {
|
|
239
|
+
error: err instanceof Error ? err.message : String(err),
|
|
240
|
+
});
|
|
234
241
|
}
|
|
235
242
|
},
|
|
236
243
|
);
|
|
237
244
|
|
|
238
245
|
// Clear quota status when switching away from a provider
|
|
239
246
|
pi.on("model_select", (_event, ctx) => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
247
|
+
try {
|
|
248
|
+
const providerId = ctx.model?.provider;
|
|
249
|
+
if (!providerId) {
|
|
250
|
+
ctx.ui.setStatus("quota", undefined);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// Show cached quota on provider switch (if still fresh)
|
|
254
|
+
const status = formatQuotaStatus(providerId);
|
|
255
|
+
ctx.ui.setStatus("quota", status);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
_logger.warn("quota status update failed", {
|
|
258
|
+
error: err instanceof Error ? err.message : String(err),
|
|
259
|
+
});
|
|
244
260
|
}
|
|
245
|
-
// Show cached quota on provider switch (if still fresh)
|
|
246
|
-
const status = formatQuotaStatus(providerId);
|
|
247
|
-
ctx.ui.setStatus("quota", status);
|
|
248
261
|
});
|
|
249
262
|
}
|
|
250
263
|
|
|
@@ -261,7 +274,14 @@ function setupTelemetry(pi: ExtensionAPI) {
|
|
|
261
274
|
const provider = ctx.model?.provider;
|
|
262
275
|
const model = ctx.model?.id;
|
|
263
276
|
if (provider && model) {
|
|
264
|
-
|
|
277
|
+
try {
|
|
278
|
+
startModelCall(provider, model);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
// Telemetry is best-effort — never break the agent flow
|
|
281
|
+
_logger.warn("telemetry startModelCall failed", {
|
|
282
|
+
error: err instanceof Error ? err.message : String(err),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
265
285
|
}
|
|
266
286
|
});
|
|
267
287
|
|
|
@@ -300,17 +320,24 @@ function setupTelemetry(pi: ExtensionAPI) {
|
|
|
300
320
|
const cost = usage?.cost?.total ?? 0;
|
|
301
321
|
const isError = msg.stopReason === "error" || !!msg.errorMessage;
|
|
302
322
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
323
|
+
try {
|
|
324
|
+
await recordModelCall(
|
|
325
|
+
provider,
|
|
326
|
+
model,
|
|
327
|
+
{ input: inputTokens, output: outputTokens, totalTokens },
|
|
328
|
+
cost,
|
|
329
|
+
{
|
|
330
|
+
success: !isError,
|
|
331
|
+
stopReason: msg.stopReason,
|
|
332
|
+
errorMessage: msg.errorMessage,
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
// Telemetry is best-effort — never break the agent flow
|
|
337
|
+
_logger.warn("telemetry recordModelCall failed", {
|
|
338
|
+
error: err instanceof Error ? err.message : String(err),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
314
341
|
});
|
|
315
342
|
}
|
|
316
343
|
|
|
@@ -352,10 +379,22 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
|
|
|
352
379
|
|
|
353
380
|
// Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face,
|
|
354
381
|
// OpenRouter/OpenCode from Pi auth, and FastRouter public model discovery)
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
382
|
+
try {
|
|
383
|
+
const { setupDynamicBuiltInProviders } = await import(
|
|
384
|
+
"./providers/dynamic-built-in/index.ts"
|
|
385
|
+
);
|
|
386
|
+
await setupDynamicBuiltInProviders(pi);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
// Dynamic providers are a best-effort enhancement — if the import
|
|
389
|
+
// or init fails (e.g. upstream API change), continue with the
|
|
390
|
+
// already-registered static providers rather than failing the whole
|
|
391
|
+
// extension load. Log full error (message + stack) to the structured
|
|
392
|
+
// log so the user can investigate, but never block startup.
|
|
393
|
+
_logger.error("[pi-free] Dynamic built-in providers failed to load", {
|
|
394
|
+
error: err instanceof Error ? err.message : String(err),
|
|
395
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
359
398
|
|
|
360
399
|
// Setup toggles for pi's built-in providers (e.g., OpenCode)
|
|
361
400
|
setupBuiltInProviderToggles(pi);
|
package/package.json
CHANGED
package/providers/bai/bai.ts
CHANGED
|
@@ -184,7 +184,12 @@ export default async function baiProvider(pi: ExtensionAPI) {
|
|
|
184
184
|
const allModels = await fetchBaiModels(apiKey);
|
|
185
185
|
|
|
186
186
|
if (allModels.length === 0) {
|
|
187
|
-
|
|
187
|
+
// Either the API failed (already logged inside fetchBaiModels) or
|
|
188
|
+
// the API returned zero text chat models. We can't tell the user
|
|
189
|
+
// which, but we can give a hint to check the log / their key.
|
|
190
|
+
_logger.warn(
|
|
191
|
+
"[bai] No text chat models available — verify BAI_API_KEY is valid and see ~/.pi/free.log for details",
|
|
192
|
+
);
|
|
188
193
|
return;
|
|
189
194
|
}
|
|
190
195
|
|