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 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
- switch (providerId) {
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
- const providerId = ctx.model?.provider;
226
- if (!providerId) return;
225
+ try {
226
+ const providerId = ctx.model?.provider;
227
+ if (!providerId) return;
227
228
 
228
- processQuotaResponse(providerId, event.headers);
229
+ processQuotaResponse(providerId, event.headers);
229
230
 
230
- // Update status bar with quota for the active provider
231
- const status = formatQuotaStatus(providerId);
232
- if (status) {
233
- ctx.ui.setStatus("quota", status);
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
- const providerId = ctx.model?.provider;
241
- if (!providerId) {
242
- ctx.ui.setStatus("quota", undefined);
243
- return;
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
- startModelCall(provider, model);
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
- await recordModelCall(
304
- provider,
305
- model,
306
- { input: inputTokens, output: outputTokens, totalTokens },
307
- cost,
308
- {
309
- success: !isError,
310
- stopReason: msg.stopReason,
311
- errorMessage: msg.errorMessage,
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
- const { setupDynamicBuiltInProviders } = await import(
356
- "./providers/dynamic-built-in/index.ts"
357
- );
358
- await setupDynamicBuiltInProviders(pi);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-free",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "type": "module",
5
5
  "description": "AI model providers for Pi with free model filtering and dynamic model fetching",
6
6
  "keywords": [
@@ -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
- _logger.warn("[bai] No text chat models available");
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