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/CHANGELOG.md +33 -1
- package/README.md +28 -22
- package/banner.svg +1 -1
- package/config.ts +99 -2
- package/constants.ts +3 -1
- package/index.ts +5 -3
- package/lib/built-in-toggle.ts +91 -58
- package/lib/registry.ts +40 -16
- package/lib/util.ts +13 -12
- package/package.json +2 -2
- package/providers/cline/cline-models.ts +3 -10
- package/providers/crofai/crofai.ts +5 -1
- package/providers/deepinfra/deepinfra.ts +7 -5
- package/providers/dynamic-built-in/index.ts +104 -31
- package/providers/model-fetcher.ts +2 -13
- package/providers/novita/novita.ts +205 -0
- package/providers/nvidia/nvidia.ts +4 -6
- package/providers/sambanova/sambanova.ts +8 -2
- package/providers/together/together.ts +6 -9
- package/providers/zenmux/zenmux.ts +6 -4
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
|
|
488
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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.
|
|
3
|
+
"version": "2.0.12",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "AI model providers for Pi with free model filtering
|
|
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
|
|
78
|
-
let usableModels =
|
|
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
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 ??
|
|
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(
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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:
|
|
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
|
|
392
|
+
// Main Entry
|
|
345
393
|
// =============================================================================
|
|
346
394
|
|
|
347
395
|
/**
|
|
348
396
|
* Kick off model discovery for all configured providers.
|
|
349
|
-
* Runs each fetch concurrently
|
|
350
|
-
*
|
|
397
|
+
* Runs each fetch concurrently so startup waits for the slowest provider,
|
|
398
|
+
* not `n * provider latency`.
|
|
351
399
|
*
|
|
352
|
-
* Pi
|
|
353
|
-
* function
|
|
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 (
|
|
449
|
+
`[dynamic] Kicking off discovery for ${fetchers.length} providers (concurrent)...`,
|
|
375
450
|
);
|
|
376
451
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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 {
|
|
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
|
-
//
|
|
386
|
-
//
|
|
387
|
-
const freeModels = allModels
|
|
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
|
-
|
|
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(
|