pi-free 2.0.11 → 2.0.12

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/lib/util.ts CHANGED
@@ -361,7 +361,8 @@ export function mapOpenRouterModel(m: {
361
361
  contextWindow: m.context_length ?? 4096,
362
362
  maxTokens:
363
363
  m.max_completion_tokens ?? m.top_provider?.max_completion_tokens ?? 4096,
364
- };
364
+ _pricingKnown: true,
365
+ } as ProviderModelConfig & { _pricingKnown?: boolean };
365
366
  }
366
367
 
367
368
  // =============================================================================
@@ -484,20 +485,19 @@ export async function fetchOpenAICompatibleModels(
484
485
  (hasVision ? ["text", "image"] : ["text"]);
485
486
 
486
487
  // Use per-model pricing if the API provides it, otherwise use defaults
487
- const inputCost =
488
- (typeof m.pricing?.prompt === "number" ||
488
+ const hasApiPricing = m.pricing !== undefined;
489
+ const apiInput =
490
+ typeof m.pricing?.prompt === "number" ||
489
491
  typeof m.pricing?.prompt === "string"
490
492
  ? Number(m.pricing.prompt)
491
- : undefined) ??
492
- defaults.cost?.input ??
493
- 0;
494
- const outputCost =
495
- (typeof m.pricing?.completion === "number" ||
493
+ : undefined;
494
+ const apiOutput =
495
+ typeof m.pricing?.completion === "number" ||
496
496
  typeof m.pricing?.completion === "string"
497
497
  ? Number(m.pricing.completion)
498
- : undefined) ??
499
- defaults.cost?.output ??
500
- 0;
498
+ : undefined;
499
+ const inputCost = apiInput ?? defaults.cost?.input ?? 0;
500
+ const outputCost = apiOutput ?? defaults.cost?.output ?? 0;
501
501
 
502
502
  return {
503
503
  id: m.id,
@@ -513,7 +513,8 @@ export async function fetchOpenAICompatibleModels(
513
513
  contextWindow,
514
514
  maxTokens,
515
515
  compat: getProxyModelCompat({ id: m.id, name }),
516
- };
516
+ _pricingKnown: hasApiPricing,
517
+ } as PiProviderModelConfig & { _pricingKnown?: boolean };
517
518
  });
518
519
  } catch (error) {
519
520
  logger.error(`[${providerId}] Failed to fetch models:`, {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-free",
3
- "version": "2.0.11",
3
+ "version": "2.0.12",
4
4
  "type": "module",
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.",
5
+ "description": "AI model providers for Pi with free model filtering and dynamic model fetching",
6
6
  "keywords": [
7
7
  "pi-package",
8
8
  "pi-extension",
@@ -9,15 +9,10 @@ import { applyHidden } from "../../config.ts";
9
9
  import {
10
10
  BASE_URL_OPENROUTER,
11
11
  DEFAULT_FETCH_TIMEOUT_MS,
12
- DEFAULT_MIN_SIZE_B,
13
12
  PROVIDER_CLINE,
14
13
  } from "../../constants.ts";
15
14
  import type { ProviderModelConfig } from "../../lib/types.ts";
16
- import {
17
- cleanModelName,
18
- fetchWithRetry,
19
- isUsableModel,
20
- } from "../../lib/util.ts";
15
+ import { cleanModelName, fetchWithRetry } from "../../lib/util.ts";
21
16
 
22
17
  interface OpenRouterRaw {
23
18
  id: string;
@@ -74,10 +69,8 @@ export async function fetchClineModels(
74
69
 
75
70
  const json = (await response.json()) as { data?: OpenRouterRaw[] };
76
71
 
77
- // Filter to usable models (chat-capable, size threshold)
78
- let usableModels = (json.data ?? []).filter((m) =>
79
- isUsableModel(m.id, DEFAULT_MIN_SIZE_B),
80
- );
72
+ // Filter to usable models (chat-capable)
73
+ let usableModels = json.data ?? [];
81
74
 
82
75
  // If freeOnly, filter to free models
83
76
  if (freeOnly) {
@@ -119,7 +119,11 @@ async function fetchCrofaiModels(
119
119
  contextWindow: m.context_length ?? 128_000,
120
120
  maxTokens: m.max_completion_tokens ?? 16_384,
121
121
  compat: getProxyModelCompat({ id: m.id, name }),
122
- };
122
+ _pricingKnown:
123
+ m.pricing?.prompt !== undefined ||
124
+ m.pricing?.completion !== undefined ||
125
+ m.pricing?.cache_prompt !== undefined,
126
+ } as ProviderModelConfig & { _pricingKnown?: boolean };
123
127
  });
124
128
  }
125
129
 
@@ -44,7 +44,7 @@ import {
44
44
  getProxyModelCompat,
45
45
  isLikelyReasoningModel,
46
46
  } from "../../lib/provider-compat.ts";
47
- import { registerWithGlobalToggle } from "../../lib/registry.ts";
47
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
48
48
  import { fetchWithRetry } from "../../lib/util.ts";
49
49
  import { createReRegister, setupProvider } from "../../provider-helper.ts";
50
50
 
@@ -136,7 +136,8 @@ async function fetchDeepinfraModels(
136
136
  contextWindow: meta?.context_length ?? 128_000,
137
137
  maxTokens: meta?.max_tokens ?? 16_384,
138
138
  compat: getProxyModelCompat({ id: m.id, name }),
139
- };
139
+ _pricingKnown: meta?.pricing !== undefined,
140
+ } as ProviderModelConfig & { _pricingKnown?: boolean };
140
141
  });
141
142
  }
142
143
 
@@ -163,9 +164,10 @@ export default async function deepinfraProvider(pi: ExtensionAPI) {
163
164
  }
164
165
 
165
166
  // DeepInfra is a trial credit provider — $5 one-time credit, no truly free models.
166
- // All models are marked as paid. When free-only mode is ON, no models are shown.
167
- // Toggle free-only OFF to see all models.
168
- const freeModels: ProviderModelConfig[] = [];
167
+ // Use isFreeModel for consistent detection across all providers.
168
+ const freeModels = allModels.filter((m) =>
169
+ isFreeModel({ ...m, provider: PROVIDER_DEEPINFRA }, allModels),
170
+ );
169
171
  const stored = { free: freeModels, all: allModels };
170
172
 
171
173
  _logger.info(
@@ -5,15 +5,18 @@
5
5
  * standard /models endpoints when the user has configured an API key.
6
6
  *
7
7
  * Uses a single generic fetch function instead of per-provider boilerplate.
8
- * Discovery runs concurrently with 1s timeout per provider, fire-and-forget
9
- * so extension init never blocks. Pi's built-in defaults serve until
10
- * discovery completes and replaces them.
8
+ * Discovery runs concurrently and is awaited by the extension entry point.
9
+ * Pi only flushes provider registrations after async extension startup, so
10
+ * dynamic providers must register before setup returns.
11
11
  *
12
12
  * Providers handled:
13
13
  * - mistral (MISTRAL_API_KEY)
14
14
  * - groq (GROQ_API_KEY)
15
15
  * - cerebras (CEREBRAS_API_KEY)
16
16
  * - xai (XAI_API_KEY)
17
+ * - opencode (OPENCODE_API_KEY from auth.json)
18
+ * - openrouter (OPENROUTER_API_KEY from auth.json)
19
+ * - fastrouter (always discovered, FASTROUTER_API_KEY)
17
20
  * - huggingface (HF_TOKEN - optional, special-cased API shape)
18
21
  *
19
22
  * OpenAI is intentionally skipped per user request.
@@ -25,14 +28,22 @@ import type {
25
28
  } from "@earendil-works/pi-coding-agent";
26
29
  import {
27
30
  getCerebrasApiKey,
31
+ getFastrouterApiKey,
32
+ getFastrouterShowPaid,
28
33
  getGroqApiKey,
29
34
  getHfToken,
30
35
  getMistralApiKey,
36
+ getOpencodeApiKey,
37
+ getOpencodeShowPaid,
38
+ getOpenrouterApiKey,
39
+ getOpenrouterShowPaid,
31
40
  getXaiApiKey,
32
41
  } from "../../config.ts";
42
+ import { DEFAULT_FETCH_TIMEOUT_MS } from "../../constants.ts";
33
43
  import { createLogger } from "../../lib/logger.ts";
34
44
  import { getProxyModelCompat } from "../../lib/provider-compat.ts";
35
45
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
46
+ import { fetchOpenRouterCompatibleModels } from "../model-fetcher.ts";
36
47
  import { createToggleState } from "../../lib/toggle-state.ts";
37
48
  import { enhanceWithCI } from "../../provider-helper.ts";
38
49
 
@@ -68,7 +79,7 @@ async function fetchModelsFromEndpoint(
68
79
 
69
80
  const response = await fetch(url, {
70
81
  headers,
71
- signal: AbortSignal.timeout(opts.timeoutMs ?? 1_000),
82
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
72
83
  });
73
84
 
74
85
  if (!response.ok) {
@@ -101,9 +112,10 @@ async function fetchModelsFromEndpoint(
101
112
  ((m.max_tokens ?? m.max_completion_tokens) as number) ??
102
113
  opts.modelDefaults?.maxTokens ??
103
114
  16_384,
115
+ _pricingKnown: false as boolean | undefined,
104
116
  ...opts.modelDefaults,
105
117
  ...(opts.compat ? { compat: opts.compat } : {}),
106
- } satisfies ProviderModelConfig;
118
+ } satisfies ProviderModelConfig & { _pricingKnown?: boolean };
107
119
  });
108
120
  }
109
121
 
@@ -123,7 +135,7 @@ async function fetchHuggingFaceModels(
123
135
 
124
136
  const response = await fetch(
125
137
  "https://api-inference.huggingface.co/models?pipeline_tag=text-generation&limit=50",
126
- { headers, signal: AbortSignal.timeout(1_000) },
138
+ { headers, signal: AbortSignal.timeout(DEFAULT_FETCH_TIMEOUT_MS) },
127
139
  );
128
140
 
129
141
  if (!response.ok) {
@@ -159,11 +171,16 @@ interface DynamicProviderDef {
159
171
  getApiKey: () => string | undefined;
160
172
  baseUrl: string;
161
173
  api: "openai-completions" | "mistral-conversations" | "anthropic-messages";
162
- defaultShowPaid: boolean;
174
+ defaultShowPaid: boolean | (() => boolean);
163
175
  /** Optional per-provider compat overrides (e.g., DeepSeek proxy). */
164
176
  compat?: ProviderModelConfig["compat"];
165
177
  /** Per-model field defaults when the API doesn't expose them. */
166
178
  modelDefaults?: Partial<ProviderModelConfig>;
179
+ /**
180
+ * Custom model fetcher (e.g., OpenRouter uses its own pricing-aware fetcher).
181
+ * When not provided, fetchModelsFromEndpoint is used (no pricing, _pricingKnown=false).
182
+ */
183
+ fetchModels?: (apiKey: string) => Promise<ProviderModelConfig[]>;
167
184
  }
168
185
 
169
186
  const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
@@ -196,6 +213,28 @@ const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
196
213
  api: "openai-completions",
197
214
  defaultShowPaid: false,
198
215
  },
216
+ {
217
+ providerId: "opencode",
218
+ getApiKey: getOpencodeApiKey,
219
+ baseUrl: "https://opencode.ai/zen/v1",
220
+ api: "openai-completions",
221
+ defaultShowPaid: getOpencodeShowPaid,
222
+ // OpenCode API returns no pricing — _pricingKnown=false, name-based detection
223
+ },
224
+ {
225
+ providerId: "openrouter",
226
+ getApiKey: getOpenrouterApiKey,
227
+ baseUrl: "https://openrouter.ai/api/v1",
228
+ api: "openai-completions",
229
+ defaultShowPaid: getOpenrouterShowPaid,
230
+ // OpenRouter returns full pricing — use its dedicated fetcher
231
+ fetchModels: (apiKey) =>
232
+ fetchOpenRouterCompatibleModels({
233
+ baseUrl: "https://openrouter.ai/api/v1",
234
+ apiKey,
235
+ freeOnly: false,
236
+ }),
237
+ },
199
238
  ];
200
239
 
201
240
  // =============================================================================
@@ -210,22 +249,27 @@ async function discoverAndRegister(
210
249
  let allModels: ProviderModelConfig[];
211
250
 
212
251
  try {
213
- allModels = await fetchModelsFromEndpoint({
214
- baseUrl: config.baseUrl,
215
- apiKey,
216
- compat: config.compat,
217
- modelDefaults: config.modelDefaults,
218
- timeoutMs: 1_000,
219
- });
252
+ if (config.fetchModels) {
253
+ allModels = await config.fetchModels(apiKey);
254
+ } else {
255
+ allModels = await fetchModelsFromEndpoint({
256
+ baseUrl: config.baseUrl,
257
+ apiKey,
258
+ compat: config.compat,
259
+ modelDefaults: config.modelDefaults,
260
+ timeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
261
+ });
262
+ }
220
263
 
221
264
  // Apply DeepSeek proxy compat to matching models
222
265
  allModels = allModels.map((m) => ({
223
266
  ...m,
224
267
  compat: getProxyModelCompat(m) ?? m.compat,
225
268
  }));
226
- } catch {
269
+ } catch (error) {
227
270
  _logger.info(
228
271
  `[dynamic] ${config.providerId}: discovery failed, Pi keeps its defaults`,
272
+ { error: error instanceof Error ? error.message : String(error) },
229
273
  );
230
274
  return;
231
275
  }
@@ -248,9 +292,10 @@ async function discoverAndRegisterHF(
248
292
  let allModels: ProviderModelConfig[];
249
293
  try {
250
294
  allModels = await fetchHuggingFaceModels(apiKey);
251
- } catch {
295
+ } catch (error) {
252
296
  _logger.info(
253
297
  "[dynamic] huggingface: discovery failed, Pi keeps its defaults",
298
+ { error: error instanceof Error ? error.message : String(error) },
254
299
  );
255
300
  return;
256
301
  }
@@ -289,7 +334,10 @@ async function registerProvider(
289
334
  // Toggle state
290
335
  const toggleState = createToggleState({
291
336
  providerId: config.providerId,
292
- initialShowPaid: config.defaultShowPaid,
337
+ initialShowPaid:
338
+ typeof config.defaultShowPaid === "function"
339
+ ? config.defaultShowPaid()
340
+ : config.defaultShowPaid,
293
341
  initialModels: { free: freeModels, all: allModels },
294
342
  });
295
343
 
@@ -341,16 +389,18 @@ async function registerProvider(
341
389
  }
342
390
 
343
391
  // =============================================================================
344
- // Main Entry — Fire-and-Forget
392
+ // Main Entry
345
393
  // =============================================================================
346
394
 
347
395
  /**
348
396
  * Kick off model discovery for all configured providers.
349
- * Runs each fetch concurrently with a 1s timeout so the worst-case
350
- * wall time is ~1s, not `n * 1s`. Extension init never blocks.
397
+ * Runs each fetch concurrently so startup waits for the slowest provider,
398
+ * not `n * provider latency`.
351
399
  *
352
- * Pi's built-in defaults serve until discovery completes and this
353
- * function replaces them via pi.registerProvider().
400
+ * Pi flushes provider registrations after async extension startup completes,
401
+ * so this function must await discovery before returning. Otherwise late
402
+ * pi.registerProvider() calls may not be visible to startup flows such as
403
+ * `pi --list-models` or the initial model picker.
354
404
  */
355
405
  export async function setupDynamicBuiltInProviders(
356
406
  pi: ExtensionAPI,
@@ -368,18 +418,41 @@ export async function setupDynamicBuiltInProviders(
368
418
  fetchers.push(discoverAndRegisterHF(pi, hfKey));
369
419
  }
370
420
 
421
+ // FastRouter: always discovered (model listing needs no auth), but Pi
422
+ // requires a non-empty apiKey/env-var name when replacing a provider's models.
423
+ // Use the real configured key when present; otherwise register with the env
424
+ // var name so startup does not fail for users who have not configured it yet.
425
+ const fastrouterApiKey = getFastrouterApiKey();
426
+ fetchers.push(
427
+ discoverAndRegister(
428
+ pi,
429
+ {
430
+ providerId: "fastrouter",
431
+ getApiKey: getFastrouterApiKey,
432
+ baseUrl: "https://api.fastrouter.ai/api/v1",
433
+ api: "openai-completions",
434
+ defaultShowPaid: getFastrouterShowPaid,
435
+ fetchModels: () =>
436
+ fetchOpenRouterCompatibleModels({
437
+ baseUrl: "https://api.fastrouter.ai/api/v1",
438
+ apiKey: fastrouterApiKey,
439
+ freeOnly: false,
440
+ }),
441
+ },
442
+ fastrouterApiKey ?? "FASTROUTER_API_KEY",
443
+ ),
444
+ );
445
+
371
446
  if (fetchers.length === 0) return;
372
447
 
373
448
  _logger.info(
374
- `[dynamic] Kicking off discovery for ${fetchers.length} providers (1s timeout each, concurrent)...`,
449
+ `[dynamic] Kicking off discovery for ${fetchers.length} providers (concurrent)...`,
375
450
  );
376
451
 
377
- // Fire-and-forget: log results, never block init
378
- void Promise.allSettled(fetchers).then((results) => {
379
- const succeeded = results.filter((r) => r.status === "fulfilled").length;
380
- const failed = results.filter((r) => r.status === "rejected").length;
381
- _logger.info(
382
- `[dynamic] Discovery complete: ${succeeded} succeeded, ${failed} failed/rejected`,
383
- );
384
- });
452
+ const results = await Promise.allSettled(fetchers);
453
+ const succeeded = results.filter((r) => r.status === "fulfilled").length;
454
+ const failed = results.filter((r) => r.status === "rejected").length;
455
+ _logger.info(
456
+ `[dynamic] Discovery complete: ${succeeded} succeeded, ${failed} failed/rejected`,
457
+ );
385
458
  }
@@ -3,17 +3,9 @@
3
3
  * Consolidates duplicate logic from openrouter.ts and kilo-models.ts
4
4
  */
5
5
 
6
- import {
7
- DEFAULT_FETCH_TIMEOUT_MS,
8
- DEFAULT_MIN_SIZE_B,
9
- URL_MODELS_DEV,
10
- } from "../constants.ts";
6
+ import { DEFAULT_FETCH_TIMEOUT_MS, URL_MODELS_DEV } from "../constants.ts";
11
7
  import type { ModelsDevModel, ProviderModelConfig } from "../lib/types.ts";
12
- import {
13
- fetchWithRetry,
14
- isUsableModel,
15
- mapOpenRouterModel,
16
- } from "../lib/util.ts";
8
+ import { fetchWithRetry, mapOpenRouterModel } from "../lib/util.ts";
17
9
 
18
10
  interface OpenRouterCompatibleModel {
19
11
  id: string;
@@ -113,9 +105,6 @@ export async function fetchOpenRouterCompatibleModels(
113
105
  if (prompt !== 0 || completion !== 0) return false;
114
106
  }
115
107
 
116
- // Filter unusable and too-small models
117
- if (!isUsableModel(m.id, DEFAULT_MIN_SIZE_B)) return false;
118
-
119
108
  return true;
120
109
  })
121
110
  .map(mapOpenRouterModel);
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Novita AI Provider Extension
3
+ *
4
+ * Novita AI deploys 100+ open-source models with an OpenAI-compatible API.
5
+ * Known for competitive pricing, globally distributed GPU infrastructure,
6
+ * and support for chat, vision, and Anthropic-compatible endpoints.
7
+ *
8
+ * API: https://api.novita.ai/openai/v1
9
+ * Models: /v1/models returns non-standard pricing fields (input_token_price_per_m,
10
+ * output_token_price_per_m) plus rich metadata (context_size, max_output_tokens,
11
+ * features for reasoning, input_modalities for vision).
12
+ *
13
+ * Setup:
14
+ * 1. Sign up at https://novita.ai
15
+ * 2. Get API key from dashboard
16
+ * 3. Set NOVITA_API_KEY env var or add to ~/.pi/free.json
17
+ *
18
+ * Usage:
19
+ * pi install git:github.com/apmantza/pi-free
20
+ * # Set NOVITA_API_KEY env var
21
+ * # Models appear in /model selector
22
+ */
23
+
24
+ import type {
25
+ ExtensionAPI,
26
+ ProviderModelConfig,
27
+ } from "@earendil-works/pi-coding-agent";
28
+ import { getNovitaApiKey, getNovitaShowPaid } from "../../config.ts";
29
+ import {
30
+ BASE_URL_NOVITA,
31
+ DEFAULT_FETCH_TIMEOUT_MS,
32
+ PROVIDER_NOVITA,
33
+ } from "../../constants.ts";
34
+ import { createLogger } from "../../lib/logger.ts";
35
+ import {
36
+ getProxyModelCompat,
37
+ isLikelyReasoningModel,
38
+ } from "../../lib/provider-compat.ts";
39
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
40
+ import { fetchWithRetry } from "../../lib/util.ts";
41
+ import { createReRegister, setupProvider } from "../../provider-helper.ts";
42
+
43
+ const _logger = createLogger("novita");
44
+
45
+ // =============================================================================
46
+ // Types
47
+ // =============================================================================
48
+
49
+ interface NovitaModel {
50
+ id: string;
51
+ display_name?: string;
52
+ description?: string;
53
+ input_token_price_per_m?: number;
54
+ output_token_price_per_m?: number;
55
+ context_size?: number;
56
+ max_output_tokens?: number;
57
+ features?: string[];
58
+ input_modalities?: string[];
59
+ output_modalities?: string[];
60
+ model_type?: string;
61
+ endpoints?: string[];
62
+ status?: number;
63
+ }
64
+
65
+ // =============================================================================
66
+ // Fetch
67
+ // =============================================================================
68
+
69
+ async function fetchNovitaModels(
70
+ apiKey: string,
71
+ ): Promise<ProviderModelConfig[]> {
72
+ _logger.info("[novita] Fetching models from Novita API...");
73
+
74
+ try {
75
+ const response = await fetchWithRetry(
76
+ `${BASE_URL_NOVITA}/models`,
77
+ {
78
+ headers: {
79
+ Authorization: `Bearer ${apiKey}`,
80
+ "Content-Type": "application/json",
81
+ },
82
+ },
83
+ 3,
84
+ 1000,
85
+ DEFAULT_FETCH_TIMEOUT_MS,
86
+ );
87
+
88
+ if (!response.ok) {
89
+ throw new Error(`Novita API error: ${response.status}`);
90
+ }
91
+
92
+ const json = (await response.json()) as { data?: NovitaModel[] };
93
+ const models = (json.data ?? []).filter(
94
+ (m) => m.status === 1 && m.model_type === "chat",
95
+ );
96
+
97
+ _logger.info(`[novita] Fetched ${models.length} models`);
98
+
99
+ return models.map((m): ProviderModelConfig => {
100
+ const name = m.display_name || m.id.split("/").pop() || m.id;
101
+ const reasoning =
102
+ (m.features ?? []).includes("reasoning") ||
103
+ isLikelyReasoningModel({ id: m.id, name });
104
+ const hasVision = m.input_modalities?.includes("image") ?? false;
105
+
106
+ // Novita pricing is per-MILLION tokens. Divide for per-token (Pi convention).
107
+ const inputCost = (m.input_token_price_per_m ?? 0) / 1_000_000;
108
+ const outputCost = (m.output_token_price_per_m ?? 0) / 1_000_000;
109
+ const hasPricing =
110
+ m.input_token_price_per_m !== undefined ||
111
+ m.output_token_price_per_m !== undefined;
112
+
113
+ return {
114
+ id: m.id,
115
+ name,
116
+ reasoning,
117
+ input: hasVision ? ["text", "image"] : ["text"],
118
+ cost: {
119
+ input: inputCost,
120
+ output: outputCost,
121
+ cacheRead: 0,
122
+ cacheWrite: 0,
123
+ },
124
+ contextWindow: m.context_size ?? 128_000,
125
+ maxTokens: m.max_output_tokens ?? 16_384,
126
+ compat: getProxyModelCompat({ id: m.id, name }),
127
+ _pricingKnown: hasPricing,
128
+ } as ProviderModelConfig & { _pricingKnown?: boolean };
129
+ });
130
+ } catch (error) {
131
+ _logger.error("[novita] Failed to fetch models:", {
132
+ error: error instanceof Error ? error.message : String(error),
133
+ });
134
+ return [];
135
+ }
136
+ }
137
+
138
+ // =============================================================================
139
+ // Extension Entry Point
140
+ // =============================================================================
141
+
142
+ export default async function novitaProvider(pi: ExtensionAPI) {
143
+ const apiKey = getNovitaApiKey();
144
+
145
+ if (!apiKey) {
146
+ _logger.info(
147
+ "[novita] Skipping — NOVITA_API_KEY not set. Sign up at https://novita.ai/",
148
+ );
149
+ return;
150
+ }
151
+
152
+ // Fetch models
153
+ const allModels = await fetchNovitaModels(apiKey);
154
+
155
+ if (allModels.length === 0) {
156
+ _logger.warn("[novita] No chat models available");
157
+ return;
158
+ }
159
+
160
+ // Use isFreeModel with allModels for proper detection
161
+ // Novita returns pricing for all models → _pricingKnown=true → Route A OR logic
162
+ const freeModels = allModels.filter((m) =>
163
+ isFreeModel({ ...m, provider: PROVIDER_NOVITA }, allModels),
164
+ );
165
+
166
+ const stored = { free: freeModels, all: allModels };
167
+
168
+ _logger.info(
169
+ `[novita] Registered ${allModels.length} models (${freeModels.length} free)`,
170
+ );
171
+
172
+ // Create re-register function
173
+ const reRegister = createReRegister(pi, {
174
+ providerId: PROVIDER_NOVITA,
175
+ baseUrl: BASE_URL_NOVITA,
176
+ apiKey,
177
+ });
178
+
179
+ // Register with global toggle
180
+ registerWithGlobalToggle(PROVIDER_NOVITA, stored, reRegister, true);
181
+
182
+ // Setup provider with toggle command
183
+ setupProvider(
184
+ pi,
185
+ {
186
+ providerId: PROVIDER_NOVITA,
187
+ initialShowPaid: getNovitaShowPaid(),
188
+ tosUrl: "https://novita.ai/terms",
189
+ reRegister: (models, _stored) => {
190
+ if (_stored) {
191
+ stored.free = _stored.free;
192
+ stored.all = _stored.all;
193
+ }
194
+ reRegister(models);
195
+ },
196
+ },
197
+ stored,
198
+ );
199
+
200
+ // Initial registration — respect persisted toggle state
201
+ const showPaid = getNovitaShowPaid();
202
+ const initialModels =
203
+ showPaid && stored.all.length > 0 ? stored.all : freeModels;
204
+ reRegister(initialModels);
205
+ }
@@ -31,7 +31,7 @@ import {
31
31
  URL_MODELS_DEV,
32
32
  } from "../../constants.ts";
33
33
  import { createLogger } from "../../lib/logger.ts";
34
- import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
34
+ import { registerWithGlobalToggle } from "../../lib/registry.ts";
35
35
  import type { ModelsDevModel, ModelsDevProvider } from "../../lib/types.ts";
36
36
  import {
37
37
  fetchWithRetry,
@@ -382,11 +382,9 @@ export default async function nvidiaProvider(pi: ExtensionAPI) {
382
382
  return;
383
383
  }
384
384
 
385
- // Store both sets for global toggle using consistent isFreeModel helper
386
- // NVIDIA uses Route B (name-based): only models with "free" in name are marked free
387
- const freeModels = allModels.filter((m) =>
388
- isFreeModel({ ...m, provider: PROVIDER_NVIDIA }),
389
- );
385
+ // All NVIDIA NIM models are accessible via free credits (no payment method required).
386
+ // Same approach as Codestral/Ollama: all models shown as free-tier.
387
+ const freeModels = allModels;
390
388
  const stored = { free: freeModels, all: allModels };
391
389
 
392
390
  // Create re-register function
@@ -31,7 +31,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
31
  import { getSambanovaApiKey, getSambanovaShowPaid } from "../../config.ts";
32
32
  import { BASE_URL_SAMBANOVA, PROVIDER_SAMBANOVA } from "../../constants.ts";
33
33
  import { createLogger } from "../../lib/logger.ts";
34
- import { registerWithGlobalToggle } from "../../lib/registry.ts";
34
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
35
35
  import { fetchOpenAICompatibleModels } from "../../lib/util.ts";
36
36
  import { createReRegister, setupProvider } from "../../provider-helper.ts";
37
37
 
@@ -66,7 +66,13 @@ export default async function sambanovaProvider(pi: ExtensionAPI) {
66
66
 
67
67
  // All SambaNova models are free-tier (no payment method required).
68
68
  // Rate limits are lower on free tier but all models are accessible.
69
- const freeModels = allModels;
69
+ // Override _pricingKnown so isFreeModel trusts the zero costs.
70
+ for (const m of allModels) {
71
+ (m as unknown as { _pricingKnown?: boolean })._pricingKnown = true;
72
+ }
73
+ const freeModels = allModels.filter((m) =>
74
+ isFreeModel({ ...m, provider: PROVIDER_SAMBANOVA }, allModels),
75
+ );
70
76
  const stored = { free: freeModels, all: allModels };
71
77
 
72
78
  _logger.info(