pi-free 2.0.9 → 2.0.11

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/README.md CHANGED
@@ -97,6 +97,7 @@ Want to see paid models too? Run the toggle command for your provider:
97
97
  /toggle-crofai # Toggle CrofAI (💳 paid - needs API key with credits)
98
98
  /toggle-codestral # Toggle Codestral (💳 paid - free Experiment plan)
99
99
  /toggle-deepinfra # Toggle DeepInfra (💳 trial credit provider)
100
+ /toggle-together # Toggle Together AI (💳 trial credit provider)
100
101
  /toggle-sambanova # Toggle SambaNova (🔄 freemium)
101
102
  /toggle-llm7 # Toggle LLM7 (✅ free gateway)
102
103
  ```
@@ -412,6 +413,18 @@ AI inference cloud with 100+ open-source models:
412
413
  export DEEPINFRA_TOKEN="..."
413
414
  ```
414
415
 
416
+ ### Together AI ($1 trial credit)
417
+
418
+ Fast inference on 200+ open-source models:
419
+
420
+ - $1 one-time credit on signup (no credit card)
421
+ - 138 chat models (Llama, DeepSeek, Qwen, Mixtral, etc.)
422
+ - 60 RPM, 600 RPD (varies by model)
423
+
424
+ ```bash
425
+ export TOGETHER_AI_API_KEY="..."
426
+ ```
427
+
415
428
  ### SambaNova Cloud (free tier)
416
429
 
417
430
  Fast inference on custom RDU hardware:
@@ -444,10 +457,13 @@ Each provider has toggle commands to switch between free and all models:
444
457
  | `/toggle-huggingface` | Toggle between free/all Hugging Face models (🔧 dynamic) |
445
458
  | `/toggle-codestral` | Toggle Codestral (💳 paid) |
446
459
  | `/toggle-deepinfra` | Toggle DeepInfra (💳 trial credit) |
460
+ | `/toggle-together` | Toggle Together AI (💳 trial credit) |
447
461
  | `/toggle-sambanova` | Toggle SambaNova (🔄 freemium) |
448
462
  | `/toggle-llm7` | Toggle LLM7 (✅ free gateway) |
449
463
  | `/toggle-zenmux` | Toggle ZenMux (💳 paid) |
450
464
  | `/toggle-crofai` | Toggle CrofAI (💳 paid) |
465
+ | `/ollama-cloud-refresh` | Re-fetch Ollama Cloud models live (no restart needed) |
466
+ | `/probe-ollama` | Test Ollama Cloud models for 403 errors (auto-hide) |
451
467
 
452
468
  **The toggle command:**
453
469
 
package/banner.svg CHANGED
@@ -106,10 +106,11 @@
106
106
  <rect x="0" y="0" width="250" height="170" rx="12" fill="url(#card1)" stroke="#7c3aed" stroke-width="0.5" stroke-opacity="0.2" filter="url(#shadow)"/>
107
107
  <rect x="0" y="0" width="250" height="170" rx="12" fill="none" stroke="url(#accent)" stroke-width="0.5" opacity="0.1"/>
108
108
  <text x="20" y="28" font-family="system-ui, sans-serif" font-size="13" font-weight="700" fill="#c4b5fd">Custom Providers</text>
109
- <text x="20" y="55" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">Kilo · Cline</text>
110
- <text x="20" y="78" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">NVIDIA</text>
111
- <text x="20" y="101" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">Ollama Cloud · ZenMux</text>
112
- <text x="20" y="124" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">CrofAI</text>
109
+ <text x="20" y="50" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Kilo · Cline · NVIDIA</text>
110
+ <text x="20" y="70" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Ollama Cloud · ZenMux</text>
111
+ <text x="20" y="90" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">CrofAI · Codestral · LLM7</text>
112
+ <text x="20" y="110" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">DeepInfra · SambaNova</text>
113
+ <text x="20" y="130" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">Together</text>
113
114
  </g>
114
115
 
115
116
  <g transform="translate(970, 55)">
@@ -117,12 +118,13 @@
117
118
  <rect x="0" y="0" width="250" height="170" rx="12" fill="url(#card2)" stroke="#06b6d4" stroke-width="0.5" stroke-opacity="0.2" filter="url(#shadow)"/>
118
119
  <rect x="0" y="0" width="250" height="170" rx="12" fill="none" stroke="url(#accent2)" stroke-width="0.5" opacity="0.1"/>
119
120
  <text x="20" y="28" font-family="system-ui, sans-serif" font-size="13" font-weight="700" fill="#67e8f9">Features</text>
120
- <text x="20" y="52" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ Free model auto-detection</text>
121
- <text x="20" y="72" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ Per-provider toggles</text>
122
- <text x="20" y="92" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ OAuth flows (Kilo, Cline)</text>
123
- <text x="20" y="112" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ Coding Index (CI) scores</text>
124
- <text x="20" y="132" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ Model health probes</text>
125
- <text x="20" y="152" font-family="system-ui, sans-serif" font-size="12" fill="#8b9aaa">✦ 404/403 auto-hide on probes</text>
121
+ <text x="20" y="50" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">✦ Free model auto-detection</text>
122
+ <text x="20" y="68" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">✦ Per-provider toggles</text>
123
+ <text x="20" y="86" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">✦ OAuth flows (Kilo, Cline)</text>
124
+ <text x="20" y="104" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">✦ Coding Index (CI) scores</text>
125
+ <text x="20" y="122" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">✦ Model health probes</text>
126
+ <text x="20" y="140" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">✦ Thinking level maps</text>
127
+ <text x="20" y="158" font-family="system-ui, sans-serif" font-size="11" fill="#8b9aaa">✦ 404/403 auto-hide on probe</text>
126
128
  </g>
127
129
 
128
130
  <!-- Bottom tagline -->
package/config.ts CHANGED
@@ -28,14 +28,10 @@ interface PiFreeConfig {
28
28
  zenmux_api_key?: string;
29
29
  crofai_api_key?: string;
30
30
  codestral_api_key?: string;
31
- mistral_api_key?: string;
32
31
  llm7_api_key?: string;
33
32
  deepinfra_api_key?: string;
34
33
  sambanova_api_key?: string;
35
- groq_api_key?: string;
36
- cerebras_api_key?: string;
37
- xai_api_key?: string;
38
- hf_token?: string;
34
+ together_api_key?: string;
39
35
  kilo_free_only?: boolean;
40
36
  hidden_models?: string[];
41
37
  free_only?: boolean;
@@ -48,6 +44,7 @@ interface PiFreeConfig {
48
44
  llm7_show_paid?: boolean;
49
45
  deepinfra_show_paid?: boolean;
50
46
  sambanova_show_paid?: boolean;
47
+ together_show_paid?: boolean;
51
48
  openrouter_show_paid?: boolean;
52
49
  opencode_show_paid?: boolean;
53
50
  }
@@ -58,14 +55,10 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
58
55
  zenmux_api_key: "",
59
56
  crofai_api_key: "",
60
57
  codestral_api_key: "",
61
- mistral_api_key: "",
62
58
  llm7_api_key: "",
63
59
  deepinfra_api_key: "",
64
60
  sambanova_api_key: "",
65
- groq_api_key: "",
66
- cerebras_api_key: "",
67
- xai_api_key: "",
68
- hf_token: "",
61
+ together_api_key: "",
69
62
 
70
63
  kilo_free_only: false,
71
64
  hidden_models: [],
@@ -79,6 +72,7 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
79
72
  llm7_show_paid: false,
80
73
  deepinfra_show_paid: false,
81
74
  sambanova_show_paid: false,
75
+ together_show_paid: false,
82
76
  openrouter_show_paid: false,
83
77
  opencode_show_paid: false,
84
78
  };
@@ -90,9 +84,21 @@ function ensureConfigFile(): void {
90
84
  try {
91
85
  mkdirSync(PI_DIR, { recursive: true });
92
86
  if (existsSync(CONFIG_PATH)) {
93
- const existing = JSON.parse(
94
- readFileSync(CONFIG_PATH, "utf8"),
95
- ) as PiFreeConfig;
87
+ let existing: PiFreeConfig;
88
+ try {
89
+ existing = JSON.parse(
90
+ readFileSync(CONFIG_PATH, "utf8"),
91
+ ) as PiFreeConfig;
92
+ } catch (_parseErr) {
93
+ // File exists but is corrupt — DO NOT overwrite it.
94
+ // The user needs to fix or delete it manually.
95
+ _logger.error(
96
+ "Config file exists but is corrupt — refusing to overwrite. Fix or delete ~/.pi/free.json.",
97
+ { path: CONFIG_PATH },
98
+ );
99
+ return;
100
+ }
101
+ // Merge with template to add any missing keys, preserving existing values
96
102
  const merged = { ...CONFIG_TEMPLATE, ...existing };
97
103
  if (JSON.stringify(merged) !== JSON.stringify(existing)) {
98
104
  writeFileSync(
@@ -120,7 +126,7 @@ export function loadConfigFile(): PiFreeConfig {
120
126
  try {
121
127
  return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as PiFreeConfig;
122
128
  } catch (err) {
123
- _logger.warn("Could not parse config file — returning empty config", {
129
+ _logger.error("Could not parse config file — returning empty config", {
124
130
  path: CONFIG_PATH,
125
131
  error: err instanceof Error ? err.message : String(err),
126
132
  });
@@ -128,6 +134,18 @@ export function loadConfigFile(): PiFreeConfig {
128
134
  }
129
135
  }
130
136
 
137
+ /**
138
+ * Read the raw config file content without merging with template.
139
+ * Returns the file content as string, or undefined if unreadable.
140
+ */
141
+ function readRawConfigFile(): string | undefined {
142
+ try {
143
+ return readFileSync(CONFIG_PATH, "utf8");
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ }
148
+
131
149
  ensureConfigFile();
132
150
 
133
151
  // Resolve each value: env var takes priority over config file.
@@ -188,6 +206,10 @@ export function getSambanovaShowPaid(): boolean {
188
206
  );
189
207
  }
190
208
 
209
+ export function getTogetherShowPaid(): boolean {
210
+ return resolveBool("TOGETHER_SHOW_PAID", loadConfigFile().together_show_paid);
211
+ }
212
+
191
213
  export function getOllamaShowPaid(): boolean {
192
214
  return resolveBool("OLLAMA_SHOW_PAID", loadConfigFile().ollama_show_paid);
193
215
  }
@@ -247,28 +269,37 @@ export function getSambanovaApiKey(): string | undefined {
247
269
  return resolve("SAMBANOVA_API_KEY", loadConfigFile().sambanova_api_key);
248
270
  }
249
271
 
272
+ export function getTogetherApiKey(): string | undefined {
273
+ return resolve("TOGETHER_AI_API_KEY", loadConfigFile().together_api_key);
274
+ }
275
+
250
276
  export function getOllamaApiKey(): string | undefined {
251
277
  return resolve("OLLAMA_API_KEY", loadConfigFile().ollama_api_key);
252
278
  }
253
279
 
280
+ /** Mistral is pi's built-in provider — key comes from env var only. */
254
281
  export function getMistralApiKey(): string | undefined {
255
- return resolve("MISTRAL_API_KEY", loadConfigFile().mistral_api_key);
282
+ return process.env.MISTRAL_API_KEY;
256
283
  }
257
284
 
285
+ /** Groq is pi's built-in provider — key comes from env var only. */
258
286
  export function getGroqApiKey(): string | undefined {
259
- return resolve("GROQ_API_KEY", loadConfigFile().groq_api_key);
287
+ return process.env.GROQ_API_KEY;
260
288
  }
261
289
 
290
+ /** Cerebras is pi's built-in provider — key comes from env var only. */
262
291
  export function getCerebrasApiKey(): string | undefined {
263
- return resolve("CEREBRAS_API_KEY", loadConfigFile().cerebras_api_key);
292
+ return process.env.CEREBRAS_API_KEY;
264
293
  }
265
294
 
295
+ /** xAI is pi's built-in provider — key comes from env var only. */
266
296
  export function getXaiApiKey(): string | undefined {
267
- return resolve("XAI_API_KEY", loadConfigFile().xai_api_key);
297
+ return process.env.XAI_API_KEY;
268
298
  }
269
299
 
300
+ /** HuggingFace is pi's built-in provider — token comes from env var only. */
270
301
  export function getHfToken(): string | undefined {
271
- return resolve("HF_TOKEN", loadConfigFile().hf_token);
302
+ return process.env.HF_TOKEN;
272
303
  }
273
304
 
274
305
  /**
@@ -315,7 +346,42 @@ export function applyHidden<T extends { id: string }>(
315
346
 
316
347
  export function saveConfig(updates: Partial<PiFreeConfig>): void {
317
348
  try {
318
- const existing = loadConfigFile();
349
+ // Read the raw file content — never use loadConfigFile() here because
350
+ // if the file is unparseable, loadConfigFile() returns {} which would
351
+ // cause us to write a partial config and WIPE all existing keys.
352
+ const raw = readRawConfigFile();
353
+ if (raw === undefined) {
354
+ // File doesn't exist or can't be read — start from template
355
+ const merged = { ...CONFIG_TEMPLATE, ...updates };
356
+ writeFileSync(
357
+ CONFIG_PATH,
358
+ `${JSON.stringify(merged, null, 2)}\n`,
359
+ "utf8",
360
+ );
361
+ _logger.info("Config saved (new file)", {
362
+ path: CONFIG_PATH,
363
+ keys: Object.keys(updates),
364
+ });
365
+ return;
366
+ }
367
+
368
+ let existing: PiFreeConfig;
369
+ try {
370
+ existing = JSON.parse(raw) as PiFreeConfig;
371
+ } catch (parseErr) {
372
+ // File exists but is corrupt. REFUSE to overwrite it with a partial
373
+ // config — that would permanently destroy the user's keys.
374
+ _logger.error(
375
+ "REFUSING to save config — existing file is corrupt. Fix or delete ~/.pi/free.json manually.",
376
+ {
377
+ path: CONFIG_PATH,
378
+ error:
379
+ parseErr instanceof Error ? parseErr.message : String(parseErr),
380
+ },
381
+ );
382
+ return;
383
+ }
384
+
319
385
  const merged = { ...existing, ...updates };
320
386
  writeFileSync(CONFIG_PATH, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
321
387
  _logger.info("Config saved", {
package/constants.ts CHANGED
@@ -21,6 +21,7 @@ export const PROVIDER_CODESTRAL = "codestral";
21
21
  export const PROVIDER_LLM7 = "llm7";
22
22
  export const PROVIDER_DEEPINFRA = "deepinfra";
23
23
  export const PROVIDER_SAMBANOVA = "sambanova";
24
+ export const PROVIDER_TOGETHER = "together";
24
25
 
25
26
  export const ALL_UNIQUE_PROVIDERS = [
26
27
  PROVIDER_KILO,
@@ -36,6 +37,7 @@ export const ALL_UNIQUE_PROVIDERS = [
36
37
  PROVIDER_LLM7,
37
38
  PROVIDER_DEEPINFRA,
38
39
  PROVIDER_SAMBANOVA,
40
+ PROVIDER_TOGETHER,
39
41
  ] as const;
40
42
 
41
43
  // =============================================================================
@@ -56,6 +58,7 @@ export const BASE_URL_CODESTRAL = "https://codestral.mistral.ai/v1";
56
58
  export const BASE_URL_LLM7 = "https://api.llm7.io/v1";
57
59
  export const BASE_URL_DEEPINFRA = "https://api.deepinfra.com/v1/openai";
58
60
  export const BASE_URL_SAMBANOVA = "https://api.sambanova.ai/v1";
61
+ export const BASE_URL_TOGETHER = "https://api.together.xyz/v1";
59
62
 
60
63
  /** Cline fetches free models from OpenRouter */
61
64
  export const BASE_URL_OPENROUTER = "https://openrouter.ai/api/v1";
package/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * - Codestral: Mistral's code-focused model via codestral.mistral.ai (free tier)
14
14
  * - DeepInfra: AI inference cloud ($5 trial credit)
15
15
  * - SambaNova: Fast inference on RDU hardware (free tier, no credit card)
16
+ * - Together: Fast inference on 200+ open-source models ($1 trial credit)
16
17
  * - LLM7: AI gateway (free default/fast selectors)
17
18
  */
18
19
 
@@ -38,6 +39,7 @@ import kilo from "./providers/kilo/kilo.ts";
38
39
  import llm7 from "./providers/llm7/llm7.ts";
39
40
  import deepinfra from "./providers/deepinfra/deepinfra.ts";
40
41
  import sambanova from "./providers/sambanova/sambanova.ts";
42
+ import together from "./providers/together/together.ts";
41
43
  import nvidia from "./providers/nvidia/nvidia.ts";
42
44
  import ollama from "./providers/ollama/ollama.ts";
43
45
  import zenmux from "./providers/zenmux/zenmux.ts";
@@ -207,6 +209,7 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
207
209
  llm7(pi),
208
210
  deepinfra(pi),
209
211
  sambanova(pi),
212
+ together(pi),
210
213
  ]);
211
214
 
212
215
  // Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face)
package/lib/util.ts CHANGED
@@ -384,12 +384,32 @@ export interface OpenAIModelDefaults {
384
384
 
385
385
  /**
386
386
  * Generic model shape returned by OpenAI-compatible /v1/models endpoints.
387
+ *
388
+ * Some providers (SambaNova, DeepInfra) return extended fields beyond
389
+ * the standard OpenAI format. We accept them loosely and use what's
390
+ * available, falling back to defaults otherwise.
387
391
  */
388
392
  export interface OpenAIModelEntry {
389
393
  id: string;
390
394
  object?: string;
391
395
  created?: number;
392
396
  owned_by?: string;
397
+ /** Extended: per-model reasoning capability (some providers expose this) */
398
+ reasoning?: boolean;
399
+ /** Extended: input modalities (some providers expose this) */
400
+ input_modalities?: string[];
401
+ /** Extended: per-model context length (SambaNova, etc.) */
402
+ context_length?: number;
403
+ /** Extended: alternate field name for context length */
404
+ max_context_length?: number;
405
+ /** Extended: alternate field name for context length (snake_case) */
406
+ context_window?: number;
407
+ /** Extended: per-model max completion tokens (SambaNova, etc.) */
408
+ max_completion_tokens?: number;
409
+ /** Extended: alternate field name for max tokens */
410
+ max_tokens?: number;
411
+ /** Extended: per-model pricing (SambaNova, etc.) */
412
+ pricing?: { prompt?: string | number; completion?: string | number };
393
413
  }
394
414
 
395
415
  /**
@@ -426,8 +446,10 @@ export async function fetchOpenAICompatibleModels(
426
446
  throw new Error(`${providerId} API error: ${response.status}`);
427
447
  }
428
448
 
429
- const data = (await response.json()) as { data?: OpenAIModelEntry[] };
430
- const models = data.data ?? [];
449
+ const body = (await response.json()) as
450
+ | OpenAIModelEntry[]
451
+ | { data?: OpenAIModelEntry[] };
452
+ const models = Array.isArray(body) ? body : (body.data ?? []);
431
453
 
432
454
  logger.info(`[${providerId}] Fetched ${models.length} models`);
433
455
 
@@ -435,19 +457,61 @@ export async function fetchOpenAICompatibleModels(
435
457
  .filter((m) => m.id)
436
458
  .map((m): PiProviderModelConfig => {
437
459
  const name = m.id.split("/").pop() || m.id;
460
+
461
+ // Use per-model context length if the API provides it (try multiple field names)
462
+ const contextWindow =
463
+ m.context_length ??
464
+ m.max_context_length ??
465
+ m.context_window ??
466
+ defaults.contextWindow ??
467
+ 128_000;
468
+
469
+ // Use per-model max tokens if the API provides it (try multiple field names)
470
+ const maxTokens =
471
+ m.max_completion_tokens ??
472
+ m.max_tokens ??
473
+ defaults.maxTokens ??
474
+ 4_096;
475
+
476
+ // Use per-model reasoning flag if the API provides it
477
+ const reasoning =
478
+ m.reasoning ?? isLikelyReasoningModel({ id: m.id, name });
479
+
480
+ // Use per-model input_modalities if the API provides it
481
+ const hasVision = m.input_modalities?.includes("image") ?? false;
482
+ const input =
483
+ (defaults.input as PiProviderModelConfig["input"]) ??
484
+ (hasVision ? ["text", "image"] : ["text"]);
485
+
486
+ // Use per-model pricing if the API provides it, otherwise use defaults
487
+ const inputCost =
488
+ (typeof m.pricing?.prompt === "number" ||
489
+ typeof m.pricing?.prompt === "string"
490
+ ? Number(m.pricing.prompt)
491
+ : undefined) ??
492
+ defaults.cost?.input ??
493
+ 0;
494
+ const outputCost =
495
+ (typeof m.pricing?.completion === "number" ||
496
+ typeof m.pricing?.completion === "string"
497
+ ? Number(m.pricing.completion)
498
+ : undefined) ??
499
+ defaults.cost?.output ??
500
+ 0;
501
+
438
502
  return {
439
503
  id: m.id,
440
504
  name,
441
- reasoning: isLikelyReasoningModel({ id: m.id, name }),
442
- input: (defaults.input as PiProviderModelConfig["input"]) ?? ["text"],
505
+ reasoning,
506
+ input,
443
507
  cost: {
444
- input: defaults.cost?.input ?? 0,
445
- output: defaults.cost?.output ?? 0,
508
+ input: inputCost,
509
+ output: outputCost,
446
510
  cacheRead: 0,
447
511
  cacheWrite: 0,
448
512
  },
449
- contextWindow: defaults.contextWindow ?? 128_000,
450
- maxTokens: defaults.maxTokens ?? 4_096,
513
+ contextWindow,
514
+ maxTokens,
451
515
  compat: getProxyModelCompat({ id: m.id, name }),
452
516
  };
453
517
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-free",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
4
4
  "type": "module",
5
5
  "description": "AI model providers for Pi with free model filtering. Shows only $0 cost models by default. Supports Kilo (free OAuth), Cline (free), NVIDIA (freemium), ZenMux, CrofAI, Ollama Cloud, and more.",
6
6
  "keywords": [
@@ -1,30 +1,128 @@
1
1
  /**
2
2
  * CrofAI Provider Extension
3
3
  *
4
- * Provides access to CrofAI API - OpenAI-compatible LLM inference service.
4
+ * Provides access to CrofAI API - OpenAI-compatible LLM inference service
5
+ * hosting DeepSeek, Qwen, and other open-source models.
6
+ *
7
+ * NOTE: CrofAI's /v1/models returns per-model context_length, max_completion_tokens,
8
+ * name, custom_reasoning, and reasoning_effort. Pricing is per-MILLION tokens.
5
9
  *
6
10
  * Setup:
7
11
  * 1. Get API key from https://ai.nahcrof.com
8
12
  * 2. Set CROFAI_API_KEY env var or add to ~/.pi/free.json
9
13
  *
10
- * Responds to global free-only filter.
11
- *
12
14
  * Usage:
13
15
  * pi install git:github.com/apmantza/pi-free
14
16
  * # Set CROFAI_API_KEY env var
15
17
  * # Models appear in /model selector
16
18
  */
17
19
 
18
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import type {
21
+ ExtensionAPI,
22
+ ProviderModelConfig,
23
+ } from "@earendil-works/pi-coding-agent";
19
24
  import { getCrofaiApiKey, getCrofaiShowPaid } from "../../config.ts";
20
- import { BASE_URL_CROFAI, PROVIDER_CROFAI } from "../../constants.ts";
25
+ import {
26
+ BASE_URL_CROFAI,
27
+ DEFAULT_FETCH_TIMEOUT_MS,
28
+ PROVIDER_CROFAI,
29
+ } from "../../constants.ts";
21
30
  import { createLogger } from "../../lib/logger.ts";
31
+ import {
32
+ getProxyModelCompat,
33
+ isLikelyReasoningModel,
34
+ } from "../../lib/provider-compat.ts";
22
35
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
23
- import { fetchOpenAICompatibleModels } from "../../lib/util.ts";
36
+ import { fetchWithRetry } from "../../lib/util.ts";
24
37
  import { createReRegister, setupProvider } from "../../provider-helper.ts";
25
38
 
26
39
  const _logger = createLogger("crofai");
27
40
 
41
+ // =============================================================================
42
+ // Types
43
+ // =============================================================================
44
+
45
+ interface CrofaiModel {
46
+ id: string;
47
+ name?: string;
48
+ context_length?: number;
49
+ max_completion_tokens?: number;
50
+ custom_reasoning?: boolean;
51
+ reasoning_effort?: boolean;
52
+ pricing?: {
53
+ prompt?: string;
54
+ completion?: string;
55
+ cache_prompt?: string;
56
+ };
57
+ }
58
+
59
+ // =============================================================================
60
+ // Fetch
61
+ // =============================================================================
62
+
63
+ function parseCrofaiPrice(priceStr: string | undefined): number {
64
+ if (priceStr === undefined) return 0;
65
+ const num = Number.parseFloat(priceStr);
66
+ if (Number.isNaN(num)) return 0;
67
+ // CrofAI pricing is per-MILLION tokens. Divide to get per-token (Pi convention).
68
+ return num / 1_000_000;
69
+ }
70
+
71
+ async function fetchCrofaiModels(
72
+ apiKey: string,
73
+ ): Promise<ProviderModelConfig[]> {
74
+ const response = await fetchWithRetry(
75
+ `${BASE_URL_CROFAI}/models`,
76
+ {
77
+ headers: {
78
+ Authorization: `Bearer ${apiKey}`,
79
+ "Content-Type": "application/json",
80
+ },
81
+ },
82
+ 3,
83
+ 1000,
84
+ DEFAULT_FETCH_TIMEOUT_MS,
85
+ );
86
+
87
+ if (!response.ok) {
88
+ throw new Error(
89
+ `CrofAI API error: ${response.status} ${response.statusText}`,
90
+ );
91
+ }
92
+
93
+ // CrofAI returns { data: [...] }
94
+ const json = (await response.json()) as {
95
+ data?: CrofaiModel[];
96
+ };
97
+ const models = json.data ?? [];
98
+
99
+ _logger.info(`[crofai] Fetched ${models.length} models`);
100
+
101
+ return models
102
+ .filter((m) => m.id)
103
+ .map((m): ProviderModelConfig => {
104
+ const name = m.name || m.id;
105
+ const reasoning =
106
+ m.custom_reasoning ?? isLikelyReasoningModel({ id: m.id, name });
107
+
108
+ return {
109
+ id: m.id,
110
+ name,
111
+ reasoning,
112
+ input: ["text"],
113
+ cost: {
114
+ input: parseCrofaiPrice(m.pricing?.prompt),
115
+ output: parseCrofaiPrice(m.pricing?.completion),
116
+ cacheRead: parseCrofaiPrice(m.pricing?.cache_prompt),
117
+ cacheWrite: 0,
118
+ },
119
+ contextWindow: m.context_length ?? 128_000,
120
+ maxTokens: m.max_completion_tokens ?? 16_384,
121
+ compat: getProxyModelCompat({ id: m.id, name }),
122
+ };
123
+ });
124
+ }
125
+
28
126
  // =============================================================================
29
127
  // Extension Entry Point
30
128
  // =============================================================================
@@ -39,21 +137,14 @@ export default async function crofaiProvider(pi: ExtensionAPI) {
39
137
  return;
40
138
  }
41
139
 
42
- // Fetch models via shared OpenAI-compatible helper
43
- const allModels = await fetchOpenAICompatibleModels(
44
- "crofai",
45
- BASE_URL_CROFAI,
46
- apiKey,
47
- );
140
+ // Fetch models
141
+ const allModels = await fetchCrofaiModels(apiKey);
48
142
 
49
143
  if (allModels.length === 0) {
50
144
  _logger.warn("[crofai] No models available");
51
145
  return;
52
146
  }
53
147
 
54
- // Use isFreeModel with allModels for proper detection
55
- // CrofAI doesn't expose pricing (all costs are $0), so Route B will be used:
56
- // FREE only if "free" in name
57
148
  const freeModels = allModels.filter((m) =>
58
149
  isFreeModel({ ...m, provider: PROVIDER_CROFAI }, allModels),
59
150
  );