pi-free 2.2.2 → 2.2.4

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/config.ts CHANGED
@@ -1,702 +1,774 @@
1
- /**
2
- * Shared config for pi-free-providers.
3
- *
4
- * Keys and flags are resolved in this order (first wins):
5
- * 1. Environment variable
6
- * 2. ~/.pi/free.json
7
- *
8
- * All exported values are getter functions so that runtime changes
9
- * (e.g. after toggle-{provider}) are visible immediately.
10
- */
11
-
12
- import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
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";
33
- export {
34
- PROVIDER_BAI,
35
- PROVIDER_CLINE,
36
- PROVIDER_FASTROUTER,
37
- PROVIDER_KILO,
38
- PROVIDER_MODAL,
39
- PROVIDER_OPENCODE,
40
- PROVIDER_OPENROUTER,
41
- PROVIDER_QWEN,
42
- PROVIDER_ROUTEWAY,
43
- PROVIDER_TOKENROUTER,
44
- } from "./constants.ts";
45
- import { createLogger } from "./lib/logger.ts";
46
- import { ensureDir, PI_DATA_DIR } from "./lib/paths.ts";
47
-
48
- /**
49
- * JSON.parse reviver that strips prototype-pollution payloads.
50
- */
51
- function safeJsonReviver(_key: string, value: unknown): unknown {
52
- if (_key === "__proto__" || _key === "constructor") {
53
- return undefined;
54
- }
55
- return value;
56
- }
57
-
58
- const _logger = createLogger("config");
59
-
60
- interface PiFreeConfig {
61
- nvidia_api_key?: string;
62
- ollama_api_key?: string;
63
- zenmux_api_key?: string;
64
- crofai_api_key?: string;
65
- codestral_api_key?: string;
66
- llm7_api_key?: string;
67
- deepinfra_api_key?: string;
68
- sambanova_api_key?: string;
69
- together_api_key?: string;
70
- novita_api_key?: string;
71
- routeway_api_key?: string;
72
- fastrouter_api_key?: string;
73
- tokenrouter_api_key?: string;
74
- bai_api_key?: string;
75
- kilo_free_only?: boolean;
76
- hidden_models?: string[];
77
- free_only?: boolean;
78
- kilo_show_paid?: boolean;
79
- ollama_show_paid?: boolean;
80
- cline_show_paid?: boolean;
81
- zenmux_show_paid?: boolean;
82
- crofai_show_paid?: boolean;
83
- codestral_show_paid?: boolean;
84
- llm7_show_paid?: boolean;
85
- deepinfra_show_paid?: boolean;
86
- sambanova_show_paid?: boolean;
87
- together_show_paid?: boolean;
88
- novita_show_paid?: boolean;
89
- routeway_show_paid?: boolean;
90
- fastrouter_show_paid?: boolean;
91
- tokenrouter_show_paid?: boolean;
92
- bai_show_paid?: boolean;
93
- openrouter_show_paid?: boolean;
94
- opencode_show_paid?: boolean;
95
- }
96
-
97
- const CONFIG_TEMPLATE: PiFreeConfig = {
98
- nvidia_api_key: "",
99
- ollama_api_key: "",
100
- zenmux_api_key: "",
101
- crofai_api_key: "",
102
- codestral_api_key: "",
103
- llm7_api_key: "",
104
- deepinfra_api_key: "",
105
- sambanova_api_key: "",
106
- together_api_key: "",
107
- novita_api_key: "",
108
- routeway_api_key: "",
109
- fastrouter_api_key: "",
110
- tokenrouter_api_key: "",
111
- bai_api_key: "",
112
-
113
- kilo_free_only: false,
114
- hidden_models: [],
115
- free_only: true,
116
- kilo_show_paid: false,
117
- ollama_show_paid: false,
118
- cline_show_paid: false,
119
- zenmux_show_paid: false,
120
- crofai_show_paid: false,
121
- codestral_show_paid: false,
122
- llm7_show_paid: false,
123
- deepinfra_show_paid: false,
124
- sambanova_show_paid: false,
125
- together_show_paid: false,
126
- novita_show_paid: false,
127
- routeway_show_paid: false,
128
- fastrouter_show_paid: false,
129
- tokenrouter_show_paid: false,
130
- bai_show_paid: false,
131
- openrouter_show_paid: false,
132
- opencode_show_paid: false,
133
- };
134
-
135
- const CONFIG_PATH = join(PI_DATA_DIR, "free.json");
136
-
137
- function ensureConfigFile(): void {
138
- try {
139
- ensureDir(PI_DATA_DIR);
140
- if (existsSync(CONFIG_PATH)) {
141
- let existing: PiFreeConfig;
142
- try {
143
- existing = JSON.parse(
144
- readFileSync(CONFIG_PATH, "utf8"),
145
- ) as PiFreeConfig;
146
- } catch (_parseErr) {
147
- // File exists but is corrupt — DO NOT overwrite it.
148
- // The user needs to fix or delete it manually.
149
- _logger.error(
150
- "Config file exists but is corrupt — refusing to overwrite. Fix or delete ~/.pi/free.json.",
151
- { path: CONFIG_PATH },
152
- );
153
- return;
154
- }
155
- // Always tighten permissions on startup, even if contents are
156
- // unchanged older installs may have a world-readable file.
157
- restrictConfigFilePermissions();
158
- // Merge with template to add any missing keys, preserving existing values
159
- const merged = { ...CONFIG_TEMPLATE, ...existing };
160
- if (JSON.stringify(merged) !== JSON.stringify(existing)) {
161
- writeFileSync(
162
- CONFIG_PATH,
163
- `${JSON.stringify(merged, null, 2)}\n`,
164
- "utf8",
165
- );
166
- restrictConfigFilePermissions();
167
- }
168
- } else {
169
- writeFileSync(
170
- CONFIG_PATH,
171
- `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}\n`,
172
- "utf8",
173
- );
174
- restrictConfigFilePermissions();
175
- }
176
- } catch (err) {
177
- _logger.warn("Could not create config file", {
178
- path: CONFIG_PATH,
179
- error: err instanceof Error ? err.message : String(err),
180
- });
181
- }
182
- }
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
-
202
- export function loadConfigFile(): PiFreeConfig {
203
- try {
204
- return JSON.parse(
205
- readFileSync(CONFIG_PATH, "utf8"),
206
- safeJsonReviver,
207
- ) as PiFreeConfig;
208
- } catch (err) {
209
- _logger.error("Could not parse config file — returning empty config", {
210
- path: CONFIG_PATH,
211
- error: err instanceof Error ? err.message : String(err),
212
- });
213
- return {};
214
- }
215
- }
216
-
217
- /**
218
- * Read the raw config file content without merging with template.
219
- * Returns the file content as string, or undefined if unreadable.
220
- */
221
- function readRawConfigFile(): string | undefined {
222
- try {
223
- return readFileSync(CONFIG_PATH, "utf8");
224
- } catch {
225
- return undefined;
226
- }
227
- }
228
-
229
- ensureConfigFile();
230
-
231
- // Resolve each value: env var takes priority over config file.
232
- function resolve(envKey: string, fileVal?: string): string | undefined {
233
- return process.env[envKey] || (fileVal?.trim() ? fileVal : undefined);
234
- }
235
-
236
- // Resolve boolean flag: env var takes priority, then config file.
237
- function resolveBool(envKey: string, fileVal?: boolean): boolean {
238
- const envValue = process.env[envKey];
239
- if (envValue === "true") return true;
240
- if (envValue === "false") return false;
241
- return fileVal === true;
242
- }
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
-
294
- // =============================================================================
295
- // Per-provider paid-model flags (getters so toggles reflect immediately)
296
- // =============================================================================
297
-
298
- export function getKiloShowPaid(): boolean {
299
- return resolveBool("KILO_SHOW_PAID", loadConfigFile().kilo_show_paid);
300
- }
301
-
302
- export function getClineShowPaid(): boolean {
303
- return resolveBool("CLINE_SHOW_PAID", loadConfigFile().cline_show_paid);
304
- }
305
-
306
- export function getZenmuxShowPaid(): boolean {
307
- return resolveBool("ZENMUX_SHOW_PAID", loadConfigFile().zenmux_show_paid);
308
- }
309
-
310
- export function getCrofaiShowPaid(): boolean {
311
- return resolveBool("CROFAI_SHOW_PAID", loadConfigFile().crofai_show_paid);
312
- }
313
-
314
- export function getCodestralShowPaid(): boolean {
315
- return resolveBool(
316
- "CODESTRAL_SHOW_PAID",
317
- loadConfigFile().codestral_show_paid,
318
- );
319
- }
320
-
321
- export function getLlm7ShowPaid(): boolean {
322
- return resolveBool("LLM7_SHOW_PAID", loadConfigFile().llm7_show_paid);
323
- }
324
-
325
- export function getDeepinfraShowPaid(): boolean {
326
- return resolveBool(
327
- "DEEPINFRA_SHOW_PAID",
328
- loadConfigFile().deepinfra_show_paid,
329
- );
330
- }
331
-
332
- export function getSambanovaShowPaid(): boolean {
333
- return resolveBool(
334
- "SAMBANOVA_SHOW_PAID",
335
- loadConfigFile().sambanova_show_paid,
336
- );
337
- }
338
-
339
- export function getTogetherShowPaid(): boolean {
340
- return resolveBool("TOGETHER_SHOW_PAID", loadConfigFile().together_show_paid);
341
- }
342
-
343
- export function getNovitaShowPaid(): boolean {
344
- return resolveBool("NOVITA_SHOW_PAID", loadConfigFile().novita_show_paid);
345
- }
346
-
347
- export function getRoutewayShowPaid(): boolean {
348
- return resolveBool("ROUTEWAY_SHOW_PAID", loadConfigFile().routeway_show_paid);
349
- }
350
-
351
- export function getTokenrouterShowPaid(): boolean {
352
- return resolveBool(
353
- "TOKENROUTER_SHOW_PAID",
354
- loadConfigFile().tokenrouter_show_paid,
355
- );
356
- }
357
-
358
- export function getBaiShowPaid(): boolean {
359
- return resolveBool("BAI_SHOW_PAID", loadConfigFile().bai_show_paid);
360
- }
361
-
362
- export function getFastrouterShowPaid(): boolean {
363
- return resolveBool(
364
- "FASTROUTER_SHOW_PAID",
365
- loadConfigFile().fastrouter_show_paid,
366
- );
367
- }
368
-
369
- export function getOllamaShowPaid(): boolean {
370
- return resolveBool("OLLAMA_SHOW_PAID", loadConfigFile().ollama_show_paid);
371
- }
372
-
373
- export function getOpenrouterShowPaid(): boolean {
374
- return resolveBool(
375
- "OPENROUTER_SHOW_PAID",
376
- loadConfigFile().openrouter_show_paid,
377
- );
378
- }
379
-
380
- export function getOpencodeShowPaid(): boolean {
381
- return resolveBool("OPENCODE_SHOW_PAID", loadConfigFile().opencode_show_paid);
382
- }
383
-
384
- export function getProviderShowPaid(providerId: string): boolean {
385
- return resolveShowPaidForProvider(providerId);
386
- }
387
-
388
- // =============================================================================
389
- // Global free-only mode
390
- // =============================================================================
391
-
392
- export function getFreeOnly(): boolean {
393
- return resolveBool("PI_FREE_ONLY", loadConfigFile().free_only);
394
- }
395
-
396
- export function getKiloFreeOnly(): boolean {
397
- return resolveBool("PI_FREE_KILO_FREE_ONLY", loadConfigFile().kilo_free_only);
398
- }
399
-
400
- // =============================================================================
401
- // API Keys (getters so runtime config changes are visible)
402
- // =============================================================================
403
-
404
- export function getNvidiaApiKey(): string | undefined {
405
- return resolve("NVIDIA_API_KEY", loadConfigFile().nvidia_api_key);
406
- }
407
-
408
- export function getZenmuxApiKey(): string | undefined {
409
- return resolve("ZENMUX_API_KEY", loadConfigFile().zenmux_api_key);
410
- }
411
-
412
- export function getCrofaiApiKey(): string | undefined {
413
- return resolve("CROFAI_API_KEY", loadConfigFile().crofai_api_key);
414
- }
415
-
416
- export function getCodestralApiKey(): string | undefined {
417
- return resolve("CODESTRAL_API_KEY", loadConfigFile().codestral_api_key);
418
- }
419
-
420
- export function getLlm7ApiKey(): string | undefined {
421
- return resolve("LLM7_API_KEY", loadConfigFile().llm7_api_key);
422
- }
423
-
424
- export function getDeepinfraApiKey(): string | undefined {
425
- return resolve("DEEPINFRA_TOKEN", loadConfigFile().deepinfra_api_key);
426
- }
427
-
428
- export function getSambanovaApiKey(): string | undefined {
429
- return resolve("SAMBANOVA_API_KEY", loadConfigFile().sambanova_api_key);
430
- }
431
-
432
- export function getTogetherApiKey(): string | undefined {
433
- return resolve("TOGETHER_AI_API_KEY", loadConfigFile().together_api_key);
434
- }
435
-
436
- export function getNovitaApiKey(): string | undefined {
437
- return resolve("NOVITA_API_KEY", loadConfigFile().novita_api_key);
438
- }
439
-
440
- export function getRoutewayApiKey(): string | undefined {
441
- return resolve("ROUTEWAY_API_KEY", loadConfigFile().routeway_api_key);
442
- }
443
-
444
- export function getFastrouterApiKey(): string | undefined {
445
- return resolve("FASTROUTER_API_KEY", loadConfigFile().fastrouter_api_key);
446
- }
447
-
448
- export function getTokenrouterApiKey(): string | undefined {
449
- return resolve("TOKENROUTER_API_KEY", loadConfigFile().tokenrouter_api_key);
450
- }
451
-
452
- export function getBaiApiKey(): string | undefined {
453
- return resolve("BAI_API_KEY", loadConfigFile().bai_api_key);
454
- }
455
-
456
- export function getOllamaApiKey(): string | undefined {
457
- return resolve("OLLAMA_API_KEY", loadConfigFile().ollama_api_key);
458
- }
459
-
460
- /** Mistral is pi's built-in provider — key comes from env var only. */
461
- export function getMistralApiKey(): string | undefined {
462
- return process.env.MISTRAL_API_KEY;
463
- }
464
-
465
- /** Groq is pi's built-in provider — key comes from env var only. */
466
- export function getGroqApiKey(): string | undefined {
467
- return process.env.GROQ_API_KEY;
468
- }
469
-
470
- /** Cerebras is pi's built-in provider — key comes from env var only. */
471
- export function getCerebrasApiKey(): string | undefined {
472
- return process.env.CEREBRAS_API_KEY;
473
- }
474
-
475
- /** xAI is pi's built-in provider — key comes from env var only. */
476
- export function getXaiApiKey(): string | undefined {
477
- return process.env.XAI_API_KEY;
478
- }
479
-
480
- /** HuggingFace is pi's built-in provider — token comes from env var only. */
481
- export function getHfToken(): string | undefined {
482
- return process.env.HF_TOKEN;
483
- }
484
-
485
- /**
486
- * Read an API key from ~/.pi/agent/auth.json.
487
- * Pi stores built-in provider keys there (opencode, openrouter, etc.).
488
- * Falls back to env var if auth.json is missing or key not found.
489
- */
490
- function readAuthJsonKey(
491
- providerId: string,
492
- envVar: string,
493
- ): string | undefined {
494
- // Check env var first (fast path)
495
- const envVal = process.env[envVar];
496
- if (envVal) return envVal;
497
-
498
- // Check auth.json
499
- try {
500
- const authPath = join(PI_DATA_DIR, "agent", "auth.json");
501
- if (!existsSync(authPath)) return undefined;
502
- const raw = readFileSync(authPath, "utf8");
503
- const auth = JSON.parse(raw, safeJsonReviver) as Record<
504
- string,
505
- { type?: string; key?: string }
506
- >;
507
- const entry = auth[providerId];
508
- if (entry?.key?.trim()) return entry.key;
509
- } catch {
510
- // auth.json missing or corrupt — silently skip
511
- }
512
- return undefined;
513
- }
514
-
515
- /**
516
- * OpenRouter key pi's built-in provider reads from ~/.pi/agent/auth.json.
517
- * pi-free checks env var first, then auth.json.
518
- */
519
- export function getOpenrouterApiKey(): string | undefined {
520
- return readAuthJsonKey("openrouter", "OPENROUTER_API_KEY");
521
- }
522
-
523
- /** OpenCode key — pi's built-in provider. Read from env or auth.json. */
524
- export function getOpencodeApiKey(): string | undefined {
525
- return readAuthJsonKey("opencode", "OPENCODE_API_KEY");
526
- }
527
-
528
- // =============================================================================
529
- // Hidden models (re-reads config on every call)
530
- // =============================================================================
531
-
532
- /**
533
- * Apply hidden models filter with provider scoping.
534
- * Hidden models can be specified as:
535
- * - "model-id" (global, applies to all providers - deprecated)
536
- * - "provider/model-id" (provider-specific, preferred)
537
- */
538
- export function applyHidden<T extends { id: string }>(
539
- models: T[],
540
- providerId?: string,
541
- ): T[] {
542
- const hidden = new Set(loadConfigFile().hidden_models ?? []);
543
- if (hidden.size === 0) return models;
544
-
545
- return models.filter((m) => {
546
- // Check provider-scoped ID (preferred format: "provider/model-id")
547
- if (providerId && hidden.has(`${providerId}/${m.id}`)) {
548
- return false;
549
- }
550
- // Check global ID (legacy format, still supported for backward compat)
551
- if (hidden.has(m.id)) {
552
- return false;
553
- }
554
- return true;
555
- });
556
- }
557
-
558
- // =============================================================================
559
- // Persistence
560
- // =============================================================================
561
-
562
- export function saveConfig(updates: Partial<PiFreeConfig>): void {
563
- try {
564
- // Read the raw file content — never use loadConfigFile() here because
565
- // if the file is unparseable, loadConfigFile() returns {} which would
566
- // cause us to write a partial config and WIPE all existing keys.
567
- const raw = readRawConfigFile();
568
- if (raw === undefined) {
569
- // File doesn't exist or can't be read — start from template
570
- const merged = { ...CONFIG_TEMPLATE, ...updates };
571
- writeFileSync(
572
- CONFIG_PATH,
573
- `${JSON.stringify(merged, null, 2)}\n`,
574
- "utf8",
575
- );
576
- _logger.info("Config saved (new file)", {
577
- path: CONFIG_PATH,
578
- keys: Object.keys(updates),
579
- });
580
- return;
581
- }
582
-
583
- let existing: PiFreeConfig;
584
- try {
585
- existing = JSON.parse(raw, safeJsonReviver) as PiFreeConfig;
586
- } catch (parseErr) {
587
- // File exists but is corrupt. REFUSE to overwrite it with a partial
588
- // configthat would permanently destroy the user's keys.
589
- _logger.error(
590
- "REFUSING to save config — existing file is corrupt. Fix or delete ~/.pi/free.json manually.",
591
- {
592
- path: CONFIG_PATH,
593
- error:
594
- parseErr instanceof Error ? parseErr.message : String(parseErr),
595
- },
596
- );
597
- return;
598
- }
599
-
600
- const merged = { ...existing, ...updates };
601
- writeFileSync(CONFIG_PATH, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
602
- _logger.info("Config saved", {
603
- path: CONFIG_PATH,
604
- keys: Object.keys(updates),
605
- });
606
- } catch (err) {
607
- _logger.error("Failed to save config", {
608
- path: CONFIG_PATH,
609
- error: err instanceof Error ? err.message : String(err),
610
- });
611
- }
612
- }
613
-
614
- /**
615
- * Serialise all config RMW operations to prevent concurrent updates
616
- * from clobbering each other (e.g. two provider probes finishing at the
617
- * same time both writing hidden_models and losing the other's update).
618
- */
619
- class ConfigLock {
620
- private promise: Promise<void> = Promise.resolve();
621
-
622
- async acquire(): Promise<() => void> {
623
- let release: () => void;
624
- const newPromise = new Promise<void>((resolve) => {
625
- release = resolve;
626
- });
627
- const previous = this.promise;
628
- this.promise = previous.then(() => newPromise);
629
- await previous;
630
- return release!;
631
- }
632
- }
633
-
634
- const _configLock = new ConfigLock();
635
-
636
- /**
637
- * Atomically read-modify-write the config file. The updater function
638
- * receives the current parsed config and returns the partial updates to
639
- * merge. Concurrent calls are serialised by an internal lock.
640
- *
641
- * If the config file is corrupt, the updater is NOT called and the file
642
- * is left untouched (matches saveConfig's safety behaviour).
643
- */
644
- export async function updateConfig(
645
- updater: (current: PiFreeConfig) => Partial<PiFreeConfig>,
646
- ): Promise<void> {
647
- const release = await _configLock.acquire();
648
- try {
649
- const raw = readRawConfigFile();
650
- if (raw === undefined) {
651
- // File doesn't exist — start from template, apply updater once
652
- const updated = updater({ ...CONFIG_TEMPLATE });
653
- const merged = { ...CONFIG_TEMPLATE, ...updated };
654
- writeFileSync(
655
- CONFIG_PATH,
656
- `${JSON.stringify(merged, null, 2)}\n`,
657
- "utf8",
658
- );
659
- _logger.info("Config updated (new file)", {
660
- path: CONFIG_PATH,
661
- keys: Object.keys(updated),
662
- });
663
- return;
664
- }
665
-
666
- let existing: PiFreeConfig;
667
- try {
668
- existing = JSON.parse(raw, safeJsonReviver) as PiFreeConfig;
669
- } catch (parseErr) {
670
- _logger.error(
671
- "REFUSING to update config — existing file is corrupt. Fix or delete ~/.pi/free.json manually.",
672
- {
673
- path: CONFIG_PATH,
674
- error:
675
- parseErr instanceof Error ? parseErr.message : String(parseErr),
676
- },
677
- );
678
- return;
679
- }
680
-
681
- const updated = updater(existing);
682
- const merged = { ...existing, ...updated };
683
- writeFileSync(CONFIG_PATH, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
684
- _logger.info("Config updated", {
685
- path: CONFIG_PATH,
686
- keys: Object.keys(updated),
687
- });
688
- } catch (err) {
689
- _logger.error("Failed to update config", {
690
- path: CONFIG_PATH,
691
- error: err instanceof Error ? err.message : String(err),
692
- });
693
- } finally {
694
- release();
695
- }
696
- }
697
-
698
- export function getConfig(): PiFreeConfig {
699
- return loadConfigFile();
700
- }
701
-
702
- // =============================================================================
1
+ /**
2
+ * Shared config for pi-free-providers.
3
+ *
4
+ * Keys and flags are resolved in this order (first wins):
5
+ * 1. Environment variable
6
+ * 2. ~/.pi/free.json
7
+ *
8
+ * All exported values are getter functions so that runtime changes
9
+ * (e.g. after toggle-{provider}) are visible immediately.
10
+ */
11
+
12
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
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_OPENMODEL,
22
+ PROVIDER_OPENROUTER,
23
+ PROVIDER_ROUTEWAY,
24
+ PROVIDER_TOKENROUTER,
25
+ PROVIDER_ZENMUX,
26
+ PROVIDER_CROFAI,
27
+ PROVIDER_CODESTRAL,
28
+ PROVIDER_LLM7,
29
+ PROVIDER_DEEPINFRA,
30
+ PROVIDER_SAMBANOVA,
31
+ PROVIDER_TOGETHER,
32
+ PROVIDER_NOVITA,
33
+ } from "./constants.ts";
34
+ export {
35
+ PROVIDER_BAI,
36
+ PROVIDER_CLINE,
37
+ PROVIDER_FASTROUTER,
38
+ PROVIDER_KILO,
39
+ PROVIDER_MODAL,
40
+ PROVIDER_OPENCODE,
41
+ PROVIDER_OPENROUTER,
42
+ PROVIDER_QWEN,
43
+ PROVIDER_ROUTEWAY,
44
+ PROVIDER_TOKENROUTER,
45
+ } from "./constants.ts";
46
+ import { createLogger } from "./lib/logger.ts";
47
+ import { ensureDir, PI_DATA_DIR } from "./lib/paths.ts";
48
+
49
+ /**
50
+ * JSON.parse reviver that strips prototype-pollution payloads.
51
+ */
52
+ function safeJsonReviver(_key: string, value: unknown): unknown {
53
+ if (_key === "__proto__" || _key === "constructor") {
54
+ return undefined;
55
+ }
56
+ return value;
57
+ }
58
+
59
+ const _logger = createLogger("config");
60
+
61
+ interface PiFreeConfig {
62
+ nvidia_api_key?: string;
63
+ ollama_api_key?: string;
64
+ zenmux_api_key?: string;
65
+ crofai_api_key?: string;
66
+ codestral_api_key?: string;
67
+ llm7_api_key?: string;
68
+ deepinfra_api_key?: string;
69
+ sambanova_api_key?: string;
70
+ together_api_key?: string;
71
+ novita_api_key?: string;
72
+ routeway_api_key?: string;
73
+ fastrouter_api_key?: string;
74
+ tokenrouter_api_key?: string;
75
+ bai_api_key?: string;
76
+ openmodel_api_key?: string;
77
+ kilo_api_key?: string;
78
+ cline_api_key?: string;
79
+ kilo_free_only?: boolean;
80
+ hidden_models?: string[];
81
+ free_only?: boolean;
82
+ kilo_show_paid?: boolean;
83
+ ollama_show_paid?: boolean;
84
+ cline_show_paid?: boolean;
85
+ zenmux_show_paid?: boolean;
86
+ crofai_show_paid?: boolean;
87
+ codestral_show_paid?: boolean;
88
+ llm7_show_paid?: boolean;
89
+ deepinfra_show_paid?: boolean;
90
+ sambanova_show_paid?: boolean;
91
+ together_show_paid?: boolean;
92
+ novita_show_paid?: boolean;
93
+ routeway_show_paid?: boolean;
94
+ fastrouter_show_paid?: boolean;
95
+ tokenrouter_show_paid?: boolean;
96
+ bai_show_paid?: boolean;
97
+ openmodel_show_paid?: boolean;
98
+ openrouter_show_paid?: boolean;
99
+ opencode_show_paid?: boolean;
100
+ }
101
+
102
+ const CONFIG_TEMPLATE: PiFreeConfig = {
103
+ nvidia_api_key: "",
104
+ ollama_api_key: "",
105
+ zenmux_api_key: "",
106
+ crofai_api_key: "",
107
+ codestral_api_key: "",
108
+ llm7_api_key: "",
109
+ deepinfra_api_key: "",
110
+ sambanova_api_key: "",
111
+ together_api_key: "",
112
+ novita_api_key: "",
113
+ routeway_api_key: "",
114
+ fastrouter_api_key: "",
115
+ tokenrouter_api_key: "",
116
+ bai_api_key: "",
117
+ openmodel_api_key: "",
118
+ kilo_api_key: "",
119
+ cline_api_key: "",
120
+
121
+ kilo_free_only: false,
122
+ hidden_models: [],
123
+ free_only: true,
124
+ kilo_show_paid: false,
125
+ ollama_show_paid: false,
126
+ cline_show_paid: false,
127
+ zenmux_show_paid: false,
128
+ crofai_show_paid: false,
129
+ codestral_show_paid: false,
130
+ llm7_show_paid: false,
131
+ deepinfra_show_paid: false,
132
+ sambanova_show_paid: false,
133
+ together_show_paid: false,
134
+ novita_show_paid: false,
135
+ routeway_show_paid: false,
136
+ fastrouter_show_paid: false,
137
+ tokenrouter_show_paid: false,
138
+ bai_show_paid: false,
139
+ openmodel_show_paid: false,
140
+ openrouter_show_paid: false,
141
+ opencode_show_paid: false,
142
+ };
143
+
144
+ const CONFIG_PATH = join(PI_DATA_DIR, "free.json");
145
+
146
+ function ensureConfigFile(): void {
147
+ try {
148
+ ensureDir(PI_DATA_DIR);
149
+ if (existsSync(CONFIG_PATH)) {
150
+ let existing: PiFreeConfig;
151
+ try {
152
+ existing = JSON.parse(
153
+ readFileSync(CONFIG_PATH, "utf8"),
154
+ ) as PiFreeConfig;
155
+ } catch (_parseErr) {
156
+ // File exists but is corrupt DO NOT overwrite it.
157
+ // The user needs to fix or delete it manually.
158
+ _logger.error(
159
+ "Config file exists but is corrupt — refusing to overwrite. Fix or delete ~/.pi/free.json.",
160
+ { path: CONFIG_PATH },
161
+ );
162
+ return;
163
+ }
164
+ // Always tighten permissions on startup, even if contents are
165
+ // unchanged — older installs may have a world-readable file.
166
+ restrictConfigFilePermissions();
167
+ // Merge with template to add any missing keys, preserving existing values
168
+ const merged = { ...CONFIG_TEMPLATE, ...existing };
169
+ if (JSON.stringify(merged) !== JSON.stringify(existing)) {
170
+ writeFileSync(
171
+ CONFIG_PATH,
172
+ `${JSON.stringify(merged, null, 2)}\n`,
173
+ "utf8",
174
+ );
175
+ restrictConfigFilePermissions();
176
+ }
177
+ } else {
178
+ writeFileSync(
179
+ CONFIG_PATH,
180
+ `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}\n`,
181
+ "utf8",
182
+ );
183
+ restrictConfigFilePermissions();
184
+ }
185
+ } catch (err) {
186
+ _logger.warn("Could not create config file", {
187
+ path: CONFIG_PATH,
188
+ error: err instanceof Error ? err.message : String(err),
189
+ });
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Restrict `~/.pi/free.json` to owner read/write (0600). The file may
195
+ * contain API keys for paid providers, so it must never be world-readable.
196
+ * Best-effort: if chmod is not supported on the platform/filesystem,
197
+ * log a warning and continue (the keys are still safe inside the user's
198
+ * home directory).
199
+ */
200
+ function restrictConfigFilePermissions(): void {
201
+ try {
202
+ chmodSync(CONFIG_PATH, 0o600);
203
+ } catch (err) {
204
+ _logger.warn("Could not restrict config file permissions to 0600", {
205
+ path: CONFIG_PATH,
206
+ error: err instanceof Error ? err.message : String(err),
207
+ });
208
+ }
209
+ }
210
+
211
+ export function loadConfigFile(): PiFreeConfig {
212
+ try {
213
+ return JSON.parse(
214
+ readFileSync(CONFIG_PATH, "utf8"),
215
+ safeJsonReviver,
216
+ ) as PiFreeConfig;
217
+ } catch (err) {
218
+ _logger.error("Could not parse config file returning empty config", {
219
+ path: CONFIG_PATH,
220
+ error: err instanceof Error ? err.message : String(err),
221
+ });
222
+ return {};
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Read the raw config file content without merging with template.
228
+ * Returns the file content as string, or undefined if unreadable.
229
+ */
230
+ function readRawConfigFile(): string | undefined {
231
+ try {
232
+ return readFileSync(CONFIG_PATH, "utf8");
233
+ } catch {
234
+ return undefined;
235
+ }
236
+ }
237
+
238
+ ensureConfigFile();
239
+
240
+ // Resolve each value: env var takes priority over config file.
241
+ function resolve(envKey: string, fileVal?: string): string | undefined {
242
+ return process.env[envKey] || (fileVal?.trim() ? fileVal : undefined);
243
+ }
244
+
245
+ // Resolve boolean flag: env var takes priority, then config file.
246
+ function resolveBool(envKey: string, fileVal?: boolean): boolean {
247
+ const envValue = process.env[envKey];
248
+ if (envValue === "true") return true;
249
+ if (envValue === "false") return false;
250
+ return fileVal === true;
251
+ }
252
+
253
+ // =============================================================================
254
+ // Per-provider metadata table
255
+ // Adding a new provider only requires a single entry here plus the
256
+ // corresponding field in the PiFreeConfig interface and CONFIG_TEMPLATE.
257
+ // Each entry pairs the provider ID with its env-var prefix (used for both
258
+ // the API key and show_paid flag) and the typed key on PiFreeConfig.
259
+ // =============================================================================
260
+
261
+ interface ProviderMeta {
262
+ id: string;
263
+ /** Env var prefix, e.g. "KILO" => KILO_SHOW_PAID and KILO_API_KEY */
264
+ prefix: string;
265
+ /** Typed accessor returning the show_paid value from PiFreeConfig */
266
+ showPaidKey: keyof PiFreeConfig;
267
+ }
268
+
269
+ const PROVIDER_META: readonly ProviderMeta[] = [
270
+ { id: PROVIDER_KILO, prefix: "KILO", showPaidKey: "kilo_show_paid" },
271
+ { id: PROVIDER_CLINE, prefix: "CLINE", showPaidKey: "cline_show_paid" },
272
+ { id: PROVIDER_ZENMUX, prefix: "ZENMUX", showPaidKey: "zenmux_show_paid" },
273
+ { id: PROVIDER_CROFAI, prefix: "CROFAI", showPaidKey: "crofai_show_paid" },
274
+ {
275
+ id: PROVIDER_CODESTRAL,
276
+ prefix: "CODESTRAL",
277
+ showPaidKey: "codestral_show_paid",
278
+ },
279
+ { id: PROVIDER_LLM7, prefix: "LLM7", showPaidKey: "llm7_show_paid" },
280
+ {
281
+ id: PROVIDER_DEEPINFRA,
282
+ prefix: "DEEPINFRA",
283
+ showPaidKey: "deepinfra_show_paid",
284
+ },
285
+ {
286
+ id: PROVIDER_SAMBANOVA,
287
+ prefix: "SAMBANOVA",
288
+ showPaidKey: "sambanova_show_paid",
289
+ },
290
+ {
291
+ id: PROVIDER_TOGETHER,
292
+ prefix: "TOGETHER",
293
+ showPaidKey: "together_show_paid",
294
+ },
295
+ { id: PROVIDER_NOVITA, prefix: "NOVITA", showPaidKey: "novita_show_paid" },
296
+ {
297
+ id: PROVIDER_ROUTEWAY,
298
+ prefix: "ROUTEWAY",
299
+ showPaidKey: "routeway_show_paid",
300
+ },
301
+ {
302
+ id: PROVIDER_TOKENROUTER,
303
+ prefix: "TOKENROUTER",
304
+ showPaidKey: "tokenrouter_show_paid",
305
+ },
306
+ { id: PROVIDER_BAI, prefix: "BAI", showPaidKey: "bai_show_paid" },
307
+ {
308
+ id: PROVIDER_OPENMODEL,
309
+ prefix: "OPENMODEL",
310
+ showPaidKey: "openmodel_show_paid",
311
+ },
312
+ {
313
+ id: PROVIDER_FASTROUTER,
314
+ prefix: "FASTROUTER",
315
+ showPaidKey: "fastrouter_show_paid",
316
+ },
317
+ { id: PROVIDER_OLLAMA, prefix: "OLLAMA", showPaidKey: "ollama_show_paid" },
318
+ {
319
+ id: PROVIDER_OPENROUTER,
320
+ prefix: "OPENROUTER",
321
+ showPaidKey: "openrouter_show_paid",
322
+ },
323
+ {
324
+ id: PROVIDER_OPENCODE,
325
+ prefix: "OPENCODE",
326
+ showPaidKey: "opencode_show_paid",
327
+ },
328
+ ];
329
+
330
+ const PROVIDER_META_BY_ID = new Map(PROVIDER_META.map((m) => [m.id, m]));
331
+
332
+ /**
333
+ * Generic show_paid resolver backed by PROVIDER_META. Returns false
334
+ * for unknown provider IDs (matches the previous switch default).
335
+ */
336
+ function resolveShowPaidForProvider(providerId: string): boolean {
337
+ const meta = PROVIDER_META_BY_ID.get(providerId);
338
+ if (!meta) return false;
339
+ const cfg = loadConfigFile();
340
+ const fileVal = cfg[meta.showPaidKey];
341
+ return resolveBool(
342
+ `${meta.prefix}_SHOW_PAID`,
343
+ fileVal as boolean | undefined,
344
+ );
345
+ }
346
+
347
+ // =============================================================================
348
+ // Per-provider paid-model flags (getters so toggles reflect immediately)
349
+ // =============================================================================
350
+
351
+ export function getKiloShowPaid(): boolean {
352
+ return resolveBool("KILO_SHOW_PAID", loadConfigFile().kilo_show_paid);
353
+ }
354
+
355
+ export function getClineShowPaid(): boolean {
356
+ return resolveBool("CLINE_SHOW_PAID", loadConfigFile().cline_show_paid);
357
+ }
358
+
359
+ export function getZenmuxShowPaid(): boolean {
360
+ return resolveBool("ZENMUX_SHOW_PAID", loadConfigFile().zenmux_show_paid);
361
+ }
362
+
363
+ export function getCrofaiShowPaid(): boolean {
364
+ return resolveBool("CROFAI_SHOW_PAID", loadConfigFile().crofai_show_paid);
365
+ }
366
+
367
+ export function getCodestralShowPaid(): boolean {
368
+ return resolveBool(
369
+ "CODESTRAL_SHOW_PAID",
370
+ loadConfigFile().codestral_show_paid,
371
+ );
372
+ }
373
+
374
+ export function getLlm7ShowPaid(): boolean {
375
+ return resolveBool("LLM7_SHOW_PAID", loadConfigFile().llm7_show_paid);
376
+ }
377
+
378
+ export function getDeepinfraShowPaid(): boolean {
379
+ return resolveBool(
380
+ "DEEPINFRA_SHOW_PAID",
381
+ loadConfigFile().deepinfra_show_paid,
382
+ );
383
+ }
384
+
385
+ export function getSambanovaShowPaid(): boolean {
386
+ return resolveBool(
387
+ "SAMBANOVA_SHOW_PAID",
388
+ loadConfigFile().sambanova_show_paid,
389
+ );
390
+ }
391
+
392
+ export function getTogetherShowPaid(): boolean {
393
+ return resolveBool("TOGETHER_SHOW_PAID", loadConfigFile().together_show_paid);
394
+ }
395
+
396
+ export function getNovitaShowPaid(): boolean {
397
+ return resolveBool("NOVITA_SHOW_PAID", loadConfigFile().novita_show_paid);
398
+ }
399
+
400
+ export function getRoutewayShowPaid(): boolean {
401
+ return resolveBool("ROUTEWAY_SHOW_PAID", loadConfigFile().routeway_show_paid);
402
+ }
403
+
404
+ export function getTokenrouterShowPaid(): boolean {
405
+ return resolveBool(
406
+ "TOKENROUTER_SHOW_PAID",
407
+ loadConfigFile().tokenrouter_show_paid,
408
+ );
409
+ }
410
+
411
+ export function getBaiShowPaid(): boolean {
412
+ return resolveBool("BAI_SHOW_PAID", loadConfigFile().bai_show_paid);
413
+ }
414
+
415
+ export function getOpenmodelShowPaid(): boolean {
416
+ return resolveBool(
417
+ "OPENMODEL_SHOW_PAID",
418
+ loadConfigFile().openmodel_show_paid,
419
+ );
420
+ }
421
+
422
+ export function getFastrouterShowPaid(): boolean {
423
+ return resolveBool(
424
+ "FASTROUTER_SHOW_PAID",
425
+ loadConfigFile().fastrouter_show_paid,
426
+ );
427
+ }
428
+
429
+ export function getOllamaShowPaid(): boolean {
430
+ return resolveBool("OLLAMA_SHOW_PAID", loadConfigFile().ollama_show_paid);
431
+ }
432
+
433
+ export function getOpenrouterShowPaid(): boolean {
434
+ return resolveBool(
435
+ "OPENROUTER_SHOW_PAID",
436
+ loadConfigFile().openrouter_show_paid,
437
+ );
438
+ }
439
+
440
+ export function getOpencodeShowPaid(): boolean {
441
+ return resolveBool("OPENCODE_SHOW_PAID", loadConfigFile().opencode_show_paid);
442
+ }
443
+
444
+ export function getProviderShowPaid(providerId: string): boolean {
445
+ return resolveShowPaidForProvider(providerId);
446
+ }
447
+
448
+ // =============================================================================
449
+ // Global free-only mode
450
+ // =============================================================================
451
+
452
+ export function getFreeOnly(): boolean {
453
+ return resolveBool("PI_FREE_ONLY", loadConfigFile().free_only);
454
+ }
455
+
456
+ export function getKiloFreeOnly(): boolean {
457
+ return resolveBool("PI_FREE_KILO_FREE_ONLY", loadConfigFile().kilo_free_only);
458
+ }
459
+
460
+ // =============================================================================
461
+ // API Keys (getters so runtime config changes are visible)
462
+ // =============================================================================
463
+
464
+ export function getNvidiaApiKey(): string | undefined {
465
+ return resolve("NVIDIA_API_KEY", loadConfigFile().nvidia_api_key);
466
+ }
467
+
468
+ export function getZenmuxApiKey(): string | undefined {
469
+ return resolve("ZENMUX_API_KEY", loadConfigFile().zenmux_api_key);
470
+ }
471
+
472
+ export function getCrofaiApiKey(): string | undefined {
473
+ return resolve("CROFAI_API_KEY", loadConfigFile().crofai_api_key);
474
+ }
475
+
476
+ export function getCodestralApiKey(): string | undefined {
477
+ return resolve("CODESTRAL_API_KEY", loadConfigFile().codestral_api_key);
478
+ }
479
+
480
+ export function getLlm7ApiKey(): string | undefined {
481
+ return resolve("LLM7_API_KEY", loadConfigFile().llm7_api_key);
482
+ }
483
+
484
+ export function getDeepinfraApiKey(): string | undefined {
485
+ return resolve("DEEPINFRA_TOKEN", loadConfigFile().deepinfra_api_key);
486
+ }
487
+
488
+ export function getSambanovaApiKey(): string | undefined {
489
+ return resolve("SAMBANOVA_API_KEY", loadConfigFile().sambanova_api_key);
490
+ }
491
+
492
+ export function getTogetherApiKey(): string | undefined {
493
+ return resolve("TOGETHER_AI_API_KEY", loadConfigFile().together_api_key);
494
+ }
495
+
496
+ export function getNovitaApiKey(): string | undefined {
497
+ return resolve("NOVITA_API_KEY", loadConfigFile().novita_api_key);
498
+ }
499
+
500
+ export function getRoutewayApiKey(): string | undefined {
501
+ return resolve("ROUTEWAY_API_KEY", loadConfigFile().routeway_api_key);
502
+ }
503
+
504
+ export function getFastrouterApiKey(): string | undefined {
505
+ return resolve("FASTROUTER_API_KEY", loadConfigFile().fastrouter_api_key);
506
+ }
507
+
508
+ export function getTokenrouterApiKey(): string | undefined {
509
+ return resolve("TOKENROUTER_API_KEY", loadConfigFile().tokenrouter_api_key);
510
+ }
511
+
512
+ export function getBaiApiKey(): string | undefined {
513
+ return resolve("BAI_API_KEY", loadConfigFile().bai_api_key);
514
+ }
515
+
516
+ export function getOpenmodelApiKey(): string | undefined {
517
+ return resolve("OPENMODEL_API_KEY", loadConfigFile().openmodel_api_key);
518
+ }
519
+
520
+ export function getKiloApiKey(): string | undefined {
521
+ return resolve("KILO_API_KEY", loadConfigFile().kilo_api_key);
522
+ }
523
+
524
+ export function getClineApiKey(): string | undefined {
525
+ return resolve("CLINE_API_KEY", loadConfigFile().cline_api_key);
526
+ }
527
+
528
+ export function getOllamaApiKey(): string | undefined {
529
+ return resolve("OLLAMA_API_KEY", loadConfigFile().ollama_api_key);
530
+ }
531
+
532
+ /** Mistral is pi's built-in provider — key comes from env var only. */
533
+ export function getMistralApiKey(): string | undefined {
534
+ return process.env.MISTRAL_API_KEY;
535
+ }
536
+
537
+ /** Groq is pi's built-in provider — key comes from env var only. */
538
+ export function getGroqApiKey(): string | undefined {
539
+ return process.env.GROQ_API_KEY;
540
+ }
541
+
542
+ /** Cerebras is pi's built-in provider — key comes from env var only. */
543
+ export function getCerebrasApiKey(): string | undefined {
544
+ return process.env.CEREBRAS_API_KEY;
545
+ }
546
+
547
+ /** xAI is pi's built-in provider — key comes from env var only. */
548
+ export function getXaiApiKey(): string | undefined {
549
+ return process.env.XAI_API_KEY;
550
+ }
551
+
552
+ /** HuggingFace is pi's built-in provider — token comes from env var only. */
553
+ export function getHfToken(): string | undefined {
554
+ return process.env.HF_TOKEN;
555
+ }
556
+
557
+ /**
558
+ * Read an API key from ~/.pi/agent/auth.json.
559
+ * Pi stores built-in provider keys there (opencode, openrouter, etc.).
560
+ * Falls back to env var if auth.json is missing or key not found.
561
+ */
562
+ function readAuthJsonKey(
563
+ providerId: string,
564
+ envVar: string,
565
+ ): string | undefined {
566
+ // Check env var first (fast path)
567
+ const envVal = process.env[envVar];
568
+ if (envVal) return envVal;
569
+
570
+ // Check auth.json
571
+ try {
572
+ const authPath = join(PI_DATA_DIR, "agent", "auth.json");
573
+ if (!existsSync(authPath)) return undefined;
574
+ const raw = readFileSync(authPath, "utf8");
575
+ const auth = JSON.parse(raw, safeJsonReviver) as Record<
576
+ string,
577
+ { type?: string; key?: string }
578
+ >;
579
+ const entry = auth[providerId];
580
+ if (entry?.key?.trim()) return entry.key;
581
+ } catch {
582
+ // auth.json missing or corrupt — silently skip
583
+ }
584
+ return undefined;
585
+ }
586
+
587
+ /**
588
+ * OpenRouter key pi's built-in provider reads from ~/.pi/agent/auth.json.
589
+ * pi-free checks env var first, then auth.json.
590
+ */
591
+ export function getOpenrouterApiKey(): string | undefined {
592
+ return readAuthJsonKey("openrouter", "OPENROUTER_API_KEY");
593
+ }
594
+
595
+ /** OpenCode key — pi's built-in provider. Read from env or auth.json. */
596
+ export function getOpencodeApiKey(): string | undefined {
597
+ return readAuthJsonKey("opencode", "OPENCODE_API_KEY");
598
+ }
599
+
600
+ // =============================================================================
601
+ // Hidden models (re-reads config on every call)
602
+ // =============================================================================
603
+
604
+ /**
605
+ * Apply hidden models filter with provider scoping.
606
+ * Hidden models can be specified as:
607
+ * - "model-id" (global, applies to all providers - deprecated)
608
+ * - "provider/model-id" (provider-specific, preferred)
609
+ */
610
+ export function applyHidden<T extends { id: string }>(
611
+ models: T[],
612
+ providerId?: string,
613
+ ): T[] {
614
+ const hidden = new Set(loadConfigFile().hidden_models ?? []);
615
+ if (hidden.size === 0) return models;
616
+
617
+ return models.filter((m) => {
618
+ // Check provider-scoped ID (preferred format: "provider/model-id")
619
+ if (providerId && hidden.has(`${providerId}/${m.id}`)) {
620
+ return false;
621
+ }
622
+ // Check global ID (legacy format, still supported for backward compat)
623
+ if (hidden.has(m.id)) {
624
+ return false;
625
+ }
626
+ return true;
627
+ });
628
+ }
629
+
630
+ // =============================================================================
631
+ // Persistence
632
+ // =============================================================================
633
+
634
+ export function saveConfig(updates: Partial<PiFreeConfig>): void {
635
+ try {
636
+ // Read the raw file content — never use loadConfigFile() here because
637
+ // if the file is unparseable, loadConfigFile() returns {} which would
638
+ // cause us to write a partial config and WIPE all existing keys.
639
+ const raw = readRawConfigFile();
640
+ if (raw === undefined) {
641
+ // File doesn't exist or can't be read start from template
642
+ const merged = { ...CONFIG_TEMPLATE, ...updates };
643
+ writeFileSync(
644
+ CONFIG_PATH,
645
+ `${JSON.stringify(merged, null, 2)}\n`,
646
+ "utf8",
647
+ );
648
+ _logger.info("Config saved (new file)", {
649
+ path: CONFIG_PATH,
650
+ keys: Object.keys(updates),
651
+ });
652
+ return;
653
+ }
654
+
655
+ let existing: PiFreeConfig;
656
+ try {
657
+ existing = JSON.parse(raw, safeJsonReviver) as PiFreeConfig;
658
+ } catch (parseErr) {
659
+ // File exists but is corrupt. REFUSE to overwrite it with a partial
660
+ // config — that would permanently destroy the user's keys.
661
+ _logger.error(
662
+ "REFUSING to save config — existing file is corrupt. Fix or delete ~/.pi/free.json manually.",
663
+ {
664
+ path: CONFIG_PATH,
665
+ error:
666
+ parseErr instanceof Error ? parseErr.message : String(parseErr),
667
+ },
668
+ );
669
+ return;
670
+ }
671
+
672
+ const merged = { ...existing, ...updates };
673
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
674
+ _logger.info("Config saved", {
675
+ path: CONFIG_PATH,
676
+ keys: Object.keys(updates),
677
+ });
678
+ } catch (err) {
679
+ _logger.error("Failed to save config", {
680
+ path: CONFIG_PATH,
681
+ error: err instanceof Error ? err.message : String(err),
682
+ });
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Serialise all config RMW operations to prevent concurrent updates
688
+ * from clobbering each other (e.g. two provider probes finishing at the
689
+ * same time both writing hidden_models and losing the other's update).
690
+ */
691
+ class ConfigLock {
692
+ private promise: Promise<void> = Promise.resolve();
693
+
694
+ async acquire(): Promise<() => void> {
695
+ let release: () => void;
696
+ const newPromise = new Promise<void>((resolve) => {
697
+ release = resolve;
698
+ });
699
+ const previous = this.promise;
700
+ this.promise = previous.then(() => newPromise);
701
+ await previous;
702
+ return release!;
703
+ }
704
+ }
705
+
706
+ const _configLock = new ConfigLock();
707
+
708
+ /**
709
+ * Atomically read-modify-write the config file. The updater function
710
+ * receives the current parsed config and returns the partial updates to
711
+ * merge. Concurrent calls are serialised by an internal lock.
712
+ *
713
+ * If the config file is corrupt, the updater is NOT called and the file
714
+ * is left untouched (matches saveConfig's safety behaviour).
715
+ */
716
+ export async function updateConfig(
717
+ updater: (current: PiFreeConfig) => Partial<PiFreeConfig>,
718
+ ): Promise<void> {
719
+ const release = await _configLock.acquire();
720
+ try {
721
+ const raw = readRawConfigFile();
722
+ if (raw === undefined) {
723
+ // File doesn't exist — start from template, apply updater once
724
+ const updated = updater({ ...CONFIG_TEMPLATE });
725
+ const merged = { ...CONFIG_TEMPLATE, ...updated };
726
+ writeFileSync(
727
+ CONFIG_PATH,
728
+ `${JSON.stringify(merged, null, 2)}\n`,
729
+ "utf8",
730
+ );
731
+ _logger.info("Config updated (new file)", {
732
+ path: CONFIG_PATH,
733
+ keys: Object.keys(updated),
734
+ });
735
+ return;
736
+ }
737
+
738
+ let existing: PiFreeConfig;
739
+ try {
740
+ existing = JSON.parse(raw, safeJsonReviver) as PiFreeConfig;
741
+ } catch (parseErr) {
742
+ _logger.error(
743
+ "REFUSING to update config — existing file is corrupt. Fix or delete ~/.pi/free.json manually.",
744
+ {
745
+ path: CONFIG_PATH,
746
+ error:
747
+ parseErr instanceof Error ? parseErr.message : String(parseErr),
748
+ },
749
+ );
750
+ return;
751
+ }
752
+
753
+ const updated = updater(existing);
754
+ const merged = { ...existing, ...updated };
755
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
756
+ _logger.info("Config updated", {
757
+ path: CONFIG_PATH,
758
+ keys: Object.keys(updated),
759
+ });
760
+ } catch (err) {
761
+ _logger.error("Failed to update config", {
762
+ path: CONFIG_PATH,
763
+ error: err instanceof Error ? err.message : String(err),
764
+ });
765
+ } finally {
766
+ release();
767
+ }
768
+ }
769
+
770
+ export function getConfig(): PiFreeConfig {
771
+ return loadConfigFile();
772
+ }
773
+
774
+ // =============================================================================