pi-free 2.0.14 → 2.1.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +64 -78
  3. package/banner.svg +21 -36
  4. package/config.ts +123 -9
  5. package/constants.ts +3 -9
  6. package/index.ts +14 -15
  7. package/lib/built-in-toggle.ts +29 -16
  8. package/lib/json-persistence.ts +90 -22
  9. package/lib/logger.ts +21 -12
  10. package/lib/model-detection.ts +2 -12
  11. package/lib/model-enhancer.ts +11 -2
  12. package/lib/model-metadata.ts +387 -0
  13. package/lib/open-browser.ts +74 -24
  14. package/lib/paths.ts +90 -0
  15. package/lib/probe-cache.ts +19 -19
  16. package/lib/provider-cache.ts +74 -28
  17. package/lib/provider-compat.ts +58 -9
  18. package/lib/provider-probe.ts +188 -0
  19. package/lib/registry.ts +1 -5
  20. package/lib/session-start-metrics.ts +46 -0
  21. package/lib/telemetry.ts +115 -86
  22. package/lib/types.ts +22 -2
  23. package/lib/util.ts +80 -21
  24. package/package.json +7 -2
  25. package/provider-failover/benchmark-lookup.ts +17 -5
  26. package/provider-helper.ts +11 -2
  27. package/providers/cline/cline-models.ts +12 -2
  28. package/providers/cline/cline-xml-bridge.ts +974 -0
  29. package/providers/cline/cline.ts +67 -176
  30. package/providers/crofai/crofai.ts +6 -1
  31. package/providers/deepinfra/deepinfra.ts +69 -2
  32. package/providers/dynamic-built-in/index.ts +237 -2
  33. package/providers/kilo/kilo-models.ts +3 -1
  34. package/providers/kilo/kilo.ts +268 -41
  35. package/providers/model-fetcher.ts +18 -55
  36. package/providers/novita/novita.ts +69 -2
  37. package/providers/ollama/ollama.ts +48 -24
  38. package/providers/opencode-session.ts +67 -2
  39. package/providers/routeway/routeway.ts +188 -2
  40. package/providers/sambanova/sambanova.ts +67 -1
  41. package/providers/together/together.ts +69 -2
  42. package/providers/tokenrouter/tokenrouter.ts +378 -0
  43. package/providers/zenmux/zenmux.ts +6 -1
  44. package/scripts/check-extensions.mjs +32 -16
  45. package/providers/nvidia/nvidia.ts +0 -504
@@ -42,8 +42,16 @@ import {
42
42
  } from "../../config.ts";
43
43
  import { DEFAULT_FETCH_TIMEOUT_MS } from "../../constants.ts";
44
44
  import { createLogger } from "../../lib/logger.ts";
45
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
45
46
  import { getProxyModelCompat } from "../../lib/provider-compat.ts";
47
+ import {
48
+ getModelsDueForProbe,
49
+ recordModelProbeResults,
50
+ } from "../../lib/probe-cache.ts";
46
51
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
52
+ import { updateConfig } from "../../config.ts";
53
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
54
+ import { fetchWithTimeout } from "../../lib/util.ts";
47
55
  import { fetchOpenRouterCompatibleModels } from "../model-fetcher.ts";
48
56
  import { createToggleState } from "../../lib/toggle-state.ts";
49
57
  import { enhanceWithCI } from "../../provider-helper.ts";
@@ -56,6 +64,8 @@ import {
56
64
 
57
65
  const _logger = createLogger("dynamic-built-in");
58
66
 
67
+ const OPENCODE_PROBE_TIMEOUT_MS = 15_000;
68
+
59
69
  // OpenCode headers must be regenerated for every LLM request.
60
70
  const _opencodeSession = createOpenCodeSessionTracker();
61
71
 
@@ -64,6 +74,7 @@ const _opencodeSession = createOpenCodeSessionTracker();
64
74
  // =============================================================================
65
75
 
66
76
  interface FetchModelsOptions {
77
+ providerId: string;
67
78
  baseUrl: string;
68
79
  apiKey: string;
69
80
  compat?: ProviderModelConfig["compat"];
@@ -103,7 +114,7 @@ async function fetchModelsFromEndpoint(
103
114
  ? body
104
115
  : (body.data ?? []);
105
116
 
106
- return rawModels.map((m) => {
117
+ const models = rawModels.map((m) => {
107
118
  const id = String(m.id ?? "");
108
119
  const inputModalities = m.input_modalities as string[] | undefined;
109
120
  return {
@@ -115,7 +126,9 @@ async function fetchModelsFromEndpoint(
115
126
  : (["text"] as const),
116
127
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
117
128
  contextWindow:
118
- ((m.max_context_length ?? m.context_window) as number) ??
129
+ ((m.context_length ??
130
+ m.max_context_length ??
131
+ m.context_window) as number) ??
119
132
  opts.modelDefaults?.contextWindow ??
120
133
  128_000,
121
134
  maxTokens:
@@ -127,6 +140,10 @@ async function fetchModelsFromEndpoint(
127
140
  ...(opts.compat ? { compat: opts.compat } : {}),
128
141
  } satisfies ProviderModelConfig & { _pricingKnown?: boolean };
129
142
  });
143
+
144
+ return await safeEnrichModelsWithModelsDev(models, {
145
+ providerId: opts.providerId,
146
+ });
130
147
  }
131
148
 
132
149
  // =============================================================================
@@ -191,6 +208,16 @@ interface DynamicProviderDef {
191
208
  * When not provided, fetchModelsFromEndpoint is used (no pricing, _pricingKnown=false).
192
209
  */
193
210
  fetchModels?: (apiKey: string) => Promise<ProviderModelConfig[]>;
211
+ /**
212
+ * Optional probe support for providers whose free model status expires.
213
+ */
214
+ probe?: {
215
+ run: (
216
+ apiKey: string,
217
+ models: ProviderModelConfig[],
218
+ options?: { useCache?: boolean },
219
+ ) => Promise<string[]>;
220
+ };
194
221
  }
195
222
 
196
223
  const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
@@ -230,6 +257,15 @@ const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
230
257
  api: OPENCODE_DYNAMIC_API,
231
258
  defaultShowPaid: getOpencodeShowPaid,
232
259
  // OpenCode API returns no pricing — _pricingKnown=false, name-based detection
260
+ probe: {
261
+ run: (apiKey, models) =>
262
+ runOpenCodeProbe(
263
+ "opencode",
264
+ apiKey,
265
+ "https://opencode.ai/zen/v1",
266
+ models,
267
+ ),
268
+ },
233
269
  },
234
270
  {
235
271
  providerId: "opencode-go",
@@ -238,6 +274,15 @@ const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
238
274
  api: OPENCODE_DYNAMIC_API,
239
275
  defaultShowPaid: getOpencodeShowPaid,
240
276
  // OpenCode Go uses the same OPENCODE_API_KEY and per-request headers
277
+ probe: {
278
+ run: (apiKey, models) =>
279
+ runOpenCodeProbe(
280
+ "opencode-go",
281
+ apiKey,
282
+ "https://opencode.ai/zen/go/v1",
283
+ models,
284
+ ),
285
+ },
241
286
  },
242
287
  {
243
288
  providerId: "openrouter",
@@ -248,6 +293,7 @@ const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
248
293
  // OpenRouter returns full pricing — use its dedicated fetcher
249
294
  fetchModels: (apiKey) =>
250
295
  fetchOpenRouterCompatibleModels({
296
+ providerId: "openrouter",
251
297
  baseUrl: "https://openrouter.ai/api/v1",
252
298
  apiKey,
253
299
  freeOnly: false,
@@ -271,6 +317,7 @@ async function discoverAndRegister(
271
317
  allModels = await config.fetchModels(apiKey);
272
318
  } else {
273
319
  allModels = await fetchModelsFromEndpoint({
320
+ providerId: config.providerId,
274
321
  baseUrl: config.baseUrl,
275
322
  apiKey,
276
323
  compat: config.compat,
@@ -297,6 +344,107 @@ async function discoverAndRegister(
297
344
  await registerProvider(pi, config, allModels, apiKey);
298
345
  }
299
346
 
347
+ // =============================================================================
348
+ // OpenCode Probe
349
+ // =============================================================================
350
+
351
+ /**
352
+ * Probe a single OpenCode model with a minimal chat request.
353
+ *
354
+ * OpenCode expired free promotions return 401 with a body like:
355
+ * { error: { message: "Free promotion has ended" } }
356
+ *
357
+ * We treat 401 and 403 as "broken" for free models, since those codes mean
358
+ * the model is no longer accessible under the current credentials. 404 is
359
+ * also broken. 429 means the model is reachable but rate-limited (ok).
360
+ */
361
+ async function probeOpenCodeModel(
362
+ apiKey: string,
363
+ baseUrl: string,
364
+ modelId: string,
365
+ ): Promise<"ok" | "broken" | "unknown"> {
366
+ try {
367
+ const response = await fetchWithTimeout(
368
+ `${baseUrl}/chat/completions`,
369
+ {
370
+ method: "POST",
371
+ headers: {
372
+ Authorization: `Bearer ${apiKey}`,
373
+ "Content-Type": "application/json",
374
+ "User-Agent": "opencode/1.15.5",
375
+ "x-opencode-client": "cli",
376
+ },
377
+ body: JSON.stringify({
378
+ model: modelId,
379
+ messages: [{ role: "user", content: "hi" }],
380
+ max_tokens: 1,
381
+ }),
382
+ },
383
+ OPENCODE_PROBE_TIMEOUT_MS,
384
+ );
385
+
386
+ if (response.status === 401 || response.status === 403) return "broken";
387
+ if (response.status === 404) return "broken";
388
+ if (response.status === 429) return "ok";
389
+ if (response.ok) return "ok";
390
+ return "ok";
391
+ } catch {
392
+ return "unknown";
393
+ }
394
+ }
395
+
396
+ async function runOpenCodeProbe(
397
+ providerId: string,
398
+ apiKey: string,
399
+ baseUrl: string,
400
+ models: ProviderModelConfig[],
401
+ options: { useCache?: boolean } = {},
402
+ ): Promise<string[]> {
403
+ const freeModels = models.filter((m) =>
404
+ isFreeModel({ ...m, provider: providerId }, models),
405
+ );
406
+ const modelIdsToProbe = options.useCache
407
+ ? new Set(
408
+ getModelsDueForProbe(
409
+ providerId,
410
+ freeModels.map((m) => m.id),
411
+ ),
412
+ )
413
+ : undefined;
414
+ const probeCandidates = modelIdsToProbe
415
+ ? freeModels.filter((m) => modelIdsToProbe.has(m.id))
416
+ : freeModels;
417
+
418
+ if (probeCandidates.length === 0) {
419
+ _logger.info(`Auto-probe: ${providerId} probe cache is fresh`);
420
+ return [];
421
+ }
422
+
423
+ const broken: string[] = [];
424
+ const cacheableResults: Array<{ modelId: string; status: "ok" | "broken" }> =
425
+ [];
426
+ const batchSize = 5;
427
+
428
+ for (let i = 0; i < probeCandidates.length; i += batchSize) {
429
+ const batch = probeCandidates.slice(i, i + batchSize);
430
+ const results = await Promise.all(
431
+ batch.map(async (m) => {
432
+ const status = await probeOpenCodeModel(apiKey, baseUrl, m.id);
433
+ return { id: m.id, status };
434
+ }),
435
+ );
436
+ for (const r of results) {
437
+ if (r.status === "broken") broken.push(r.id);
438
+ if (r.status !== "unknown") {
439
+ cacheableResults.push({ modelId: r.id, status: r.status });
440
+ }
441
+ }
442
+ }
443
+
444
+ await recordModelProbeResults(providerId, cacheableResults);
445
+ return broken;
446
+ }
447
+
300
448
  async function discoverAndRegisterHF(
301
449
  pi: ExtensionAPI,
302
450
  apiKey: string,
@@ -354,6 +502,35 @@ async function registerProvider(
354
502
  });
355
503
  };
356
504
 
505
+ const stored: { free: ProviderModelConfig[]; all: ProviderModelConfig[] } = {
506
+ free: freeModels,
507
+ all: allModels,
508
+ };
509
+
510
+ /**
511
+ * Hide broken free models in config and update the active model list.
512
+ */
513
+ async function hideBrokenModels(brokenIds: string[]): Promise<void> {
514
+ if (brokenIds.length === 0) return;
515
+
516
+ await updateConfig((cfg) => {
517
+ const existingHidden = new Set(cfg.hidden_models ?? []);
518
+ for (const id of brokenIds) {
519
+ existingHidden.add(`${config.providerId}/${id}`);
520
+ }
521
+ return { hidden_models: Array.from(existingHidden) };
522
+ });
523
+
524
+ stored.all = stored.all.filter((m) => !brokenIds.includes(m.id));
525
+ stored.free = stored.free.filter((m) => !brokenIds.includes(m.id));
526
+ toggleState.setModels(stored);
527
+ toggleState.applyCurrent(reRegister);
528
+
529
+ _logger.info(
530
+ `[dynamic] ${config.providerId}: hidden ${brokenIds.length} broken models`,
531
+ );
532
+ }
533
+
357
534
  // Toggle state
358
535
  const toggleState = createToggleState({
359
536
  providerId: config.providerId,
@@ -408,6 +585,63 @@ async function registerProvider(
408
585
 
409
586
  // Register models (this swaps in our discovered models over Pi's defaults)
410
587
  toggleState.applyCurrent(reRegister);
588
+
589
+ // ── Probe command for providers whose free model status expires ─────
590
+ if (config.probe) {
591
+ pi.registerCommand(`probe-${config.providerId}`, {
592
+ description: `Test ${config.providerId} free models for expired promotions`,
593
+ handler: async (_args, ctx) => {
594
+ const modelsToTest =
595
+ toggleState.getCurrentMode() === "all" ? stored.all : stored.free;
596
+ ctx.ui.notify(
597
+ `Probing ${modelsToTest.length} ${config.providerId} models…`,
598
+ "info",
599
+ );
600
+
601
+ const broken = await config.probe!.run(apiKey, modelsToTest, {
602
+ useCache: false,
603
+ });
604
+
605
+ if (broken.length === 0) {
606
+ ctx.ui.notify(
607
+ `All ${config.providerId} models are accessible ✅`,
608
+ "info",
609
+ );
610
+ return;
611
+ }
612
+
613
+ ctx.ui.notify(
614
+ `Found ${broken.length} expired free models:\n${broken.join("\n")}`,
615
+ "warning",
616
+ );
617
+ await hideBrokenModels(broken);
618
+ },
619
+ });
620
+
621
+ // ── Lazy auto-probe on first session_start ───────────────────────
622
+ let _autoProbeDone = false;
623
+ pi.on(
624
+ "session_start",
625
+ wrapSessionStartHandler(`${config.providerId}-auto-probe`, async () => {
626
+ if (_autoProbeDone) return;
627
+ _autoProbeDone = true;
628
+ _logger.info(
629
+ `Starting lazy auto-probe of ${config.providerId} free models...`,
630
+ );
631
+ try {
632
+ const broken = await config.probe!.run(apiKey, stored.free, {
633
+ useCache: true,
634
+ });
635
+ await hideBrokenModels(broken);
636
+ } catch (err) {
637
+ _logger.warn("Auto-probe failed", {
638
+ error: err instanceof Error ? err.message : String(err),
639
+ });
640
+ }
641
+ }),
642
+ );
643
+ }
644
+
411
645
  _logger.info(`[dynamic] ${config.providerId}: registered`);
412
646
  }
413
647
 
@@ -457,6 +691,7 @@ export async function setupDynamicBuiltInProviders(
457
691
  defaultShowPaid: getFastrouterShowPaid,
458
692
  fetchModels: () =>
459
693
  fetchOpenRouterCompatibleModels({
694
+ providerId: "fastrouter",
460
695
  baseUrl: "https://api.fastrouter.ai/api/v1",
461
696
  apiKey: fastrouterApiKey,
462
697
  freeOnly: false,
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { applyHidden } from "../../config.ts";
6
6
  import { PROVIDER_KILO } from "../../constants.ts";
7
+ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
7
8
  import { fetchOpenRouterCompatibleModels } from "../model-fetcher.ts";
8
9
 
9
10
  const KILO_API_BASE = process.env.KILO_API_URL || "https://api.kilo.ai";
@@ -16,8 +17,9 @@ export const KILO_GATEWAY_BASE = `${KILO_API_BASE}/api/gateway`;
16
17
  export async function fetchKiloModels(options?: {
17
18
  token?: string;
18
19
  freeOnly?: boolean;
19
- }): Promise<ReturnType<typeof fetchOpenRouterCompatibleModels>> {
20
+ }): Promise<ProviderModelConfig[]> {
20
21
  const models = await fetchOpenRouterCompatibleModels({
22
+ providerId: PROVIDER_KILO,
21
23
  baseUrl: KILO_GATEWAY_BASE,
22
24
  apiKey: options?.token,
23
25
  freeOnly: options?.freeOnly,