pi-free 2.0.15 → 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 +74 -0
  2. package/README.md +64 -79
  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 +53 -37
  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 +7 -1
  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 +25 -17
  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 -510
@@ -36,9 +36,12 @@ import {
36
36
  } from "../../constants.ts";
37
37
  import { createLogger } from "../../lib/logger.ts";
38
38
  import {
39
+ DEFAULT_PROVIDER_CACHE_TTL_MS,
40
+ isProviderCacheFresh,
39
41
  loadProviderCache,
40
42
  saveProviderCache,
41
43
  } from "../../lib/provider-cache.ts";
44
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
42
45
  import {
43
46
  getModelsDueForProbe,
44
47
  recordModelProbeResults,
@@ -427,7 +430,7 @@ async function runOllamaProbe(
427
430
  }
428
431
  }
429
432
 
430
- recordModelProbeResults(PROVIDER_OLLAMA, cacheableResults);
433
+ await recordModelProbeResults(PROVIDER_OLLAMA, cacheableResults);
431
434
 
432
435
  if (notFound.length === 0) {
433
436
  _logger.info("Auto-probe: all checked Ollama models are accessible");
@@ -445,7 +448,7 @@ async function runOllamaProbe(
445
448
  // Re-fetch and re-register so hidden models disappear immediately
446
449
  try {
447
450
  const fresh = await fetchAllModels(apiKey);
448
- saveProviderCache(PROVIDER_OLLAMA, fresh);
451
+ await saveProviderCache(PROVIDER_OLLAMA, fresh);
449
452
  applyModels(fresh);
450
453
  } catch {
451
454
  // If refresh fails, keep current models. The next refresh/probe will retry.
@@ -517,14 +520,14 @@ export default async function ollamaProvider(pi: ExtensionAPI) {
517
520
  _logger.info(
518
521
  `[ollama-cloud] Registered ${initialModels.length} models` +
519
522
  (fromCache ? " (from cache)" : " (fallback)") +
520
- ", fetching fresh in background...",
523
+ ", refresh scheduled on session start...",
521
524
  );
522
525
 
523
526
  // ── Background refresh ─────────────────────────────────────────
524
527
  async function refreshModels(): Promise<ProviderModelConfig[]> {
525
528
  try {
526
529
  const freshModels = await fetchAllModels(apiKey!);
527
- saveProviderCache(PROVIDER_OLLAMA, freshModels);
530
+ await saveProviderCache(PROVIDER_OLLAMA, freshModels);
528
531
  return freshModels;
529
532
  } catch (error) {
530
533
  _logger.error("[ollama-cloud] Background refresh failed", {
@@ -543,7 +546,7 @@ export default async function ollamaProvider(pi: ExtensionAPI) {
543
546
  ctx.ui.notify("Refreshing Ollama Cloud models…", "info");
544
547
  try {
545
548
  const fresh = await fetchAllModels(apiKey!);
546
- saveProviderCache(PROVIDER_OLLAMA, fresh);
549
+ await saveProviderCache(PROVIDER_OLLAMA, fresh);
547
550
  applyModelList(fresh);
548
551
  ctx.ui.notify(
549
552
  `Registered ${fresh.length} Ollama Cloud models (refresh complete)`,
@@ -601,30 +604,51 @@ export default async function ollamaProvider(pi: ExtensionAPI) {
601
604
  ctx.ui.setStatus(`${PROVIDER_OLLAMA}-status`, `ollama: ${count} models`);
602
605
  });
603
606
 
607
+ const runProbeInBackground = (models: ProviderModelConfig[]) => {
608
+ runOllamaProbe(apiKey, models, applyModelList, { useCache: true }).catch(
609
+ (error) => {
610
+ _logger.warn("Auto-probe failed", {
611
+ error: error instanceof Error ? error.message : String(error),
612
+ });
613
+ },
614
+ );
615
+ };
616
+
604
617
  // ── Background refresh on session_start ─────────────────────────
605
- let bgRefreshed = false;
618
+ let refreshInFlight: Promise<void> | undefined;
606
619
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
607
- pi.on("session_start" as any, async (_event: any, ctx: any) => {
608
- if (bgRefreshed) {
609
- return;
610
- }
611
- bgRefreshed = true;
620
+ pi.on(
621
+ "session_start" as any,
622
+ wrapSessionStartHandler("ollama-cloud", (_event: any, ctx: any) => {
623
+ if (refreshInFlight) return Promise.resolve();
624
+
625
+ if (
626
+ isProviderCacheFresh(PROVIDER_OLLAMA, DEFAULT_PROVIDER_CACHE_TTL_MS)
627
+ ) {
628
+ _logger.info(
629
+ "session_start: Ollama Cloud cache is fresh; skipping refresh",
630
+ );
631
+ runProbeInBackground(allModels);
632
+ return Promise.resolve();
633
+ }
612
634
 
613
- try {
614
- const fresh = await refreshModels();
615
- applyModelList(fresh);
616
- ctx.ui.notify(`Ollama Cloud: ${fresh.length} models ready`, "info");
617
- runOllamaProbe(apiKey, fresh, applyModelList, { useCache: true }).catch(
618
- (error) => {
619
- _logger.warn("Auto-probe failed", {
635
+ refreshInFlight = refreshModels()
636
+ .then((fresh) => {
637
+ applyModelList(fresh);
638
+ ctx.ui.notify(`Ollama Cloud: ${fresh.length} models ready`, "info");
639
+ runProbeInBackground(fresh);
640
+ })
641
+ .catch((error) => {
642
+ _logger.warn("Background refresh failed", {
620
643
  error: error instanceof Error ? error.message : String(error),
621
644
  });
622
- },
623
- );
624
- } catch {
625
- // Already logged in refreshModels()
626
- }
627
- });
645
+ })
646
+ .finally(() => {
647
+ refreshInFlight = undefined;
648
+ });
649
+ return Promise.resolve();
650
+ }),
651
+ );
628
652
  }
629
653
 
630
654
  // =============================================================================
@@ -352,6 +352,18 @@ export function createOpenCodeStreamSimple(
352
352
  const headers = createOpenCodeHeaders(tracker, options?.headers);
353
353
  const stream = new DeferredAssistantMessageEventStream();
354
354
 
355
+ // Sanitize context messages for Anthropic/OpenAI compatibility.
356
+ // OpenCode proxies to Anthropic which strictly enforces alternating
357
+ // user/assistant turns. This fixes consecutive assistant messages,
358
+ // leading assistant messages, and trailing assistant messages.
359
+ const sanitizedMessages = sanitizeMessagesForOpenCode(
360
+ context.messages as unknown[],
361
+ );
362
+ const sanitizedContext: Context = {
363
+ ...context,
364
+ messages: sanitizedMessages as Context["messages"],
365
+ };
366
+
355
367
  void (async () => {
356
368
  try {
357
369
  if (isAnthropicOpenCodeEndpoint(model)) {
@@ -364,7 +376,7 @@ export function createOpenCodeStreamSimple(
364
376
  ...model,
365
377
  api: "anthropic-messages",
366
378
  } as Model<"anthropic-messages">,
367
- context,
379
+ sanitizedContext,
368
380
  { ...options, headers },
369
381
  ),
370
382
  );
@@ -382,7 +394,7 @@ export function createOpenCodeStreamSimple(
382
394
  ...model,
383
395
  api: "openai-completions",
384
396
  } as Model<"openai-completions">,
385
- context,
397
+ sanitizedContext,
386
398
  { ...options, headers },
387
399
  ),
388
400
  );
@@ -396,3 +408,56 @@ export function createOpenCodeStreamSimple(
396
408
  return stream as unknown as AssistantMessageEventStream;
397
409
  };
398
410
  }
411
+
412
+ /**
413
+ * Sanitize message history for OpenCode's backends.
414
+ *
415
+ * OpenCode proxies to Anthropic and OpenAI. Anthropic strictly enforces
416
+ * alternating user/assistant turns and rejects:
417
+ * - consecutive assistant messages
418
+ * - conversations that start with assistant
419
+ * - conversations that end with assistant
420
+ *
421
+ * This sanitizer fixes all three issues with minimal placeholder messages.
422
+ */
423
+ export function sanitizeMessagesForOpenCode(messages: unknown[]): unknown[] {
424
+ if (!Array.isArray(messages)) return messages;
425
+
426
+ const sanitized: unknown[] = [];
427
+ let hasNonSystem = false;
428
+
429
+ for (const raw of messages) {
430
+ if (!raw || typeof raw !== "object") continue;
431
+ const msg = raw as { role?: string; content?: unknown };
432
+ const role = msg.role;
433
+ if (!role) continue;
434
+
435
+ if (role === "system") {
436
+ sanitized.push(raw);
437
+ continue;
438
+ }
439
+
440
+ // Skip leading assistant messages before any user/tool message
441
+ if (role === "assistant" && !hasNonSystem) continue;
442
+
443
+ hasNonSystem = true;
444
+
445
+ // Insert placeholder user message between consecutive assistant messages
446
+ const last = sanitized[sanitized.length - 1] as
447
+ | { role?: string }
448
+ | undefined;
449
+ if (role === "assistant" && last?.role === "assistant") {
450
+ sanitized.push({ role: "user", content: " " });
451
+ }
452
+
453
+ sanitized.push(raw);
454
+ }
455
+
456
+ // Ensure conversation ends with a user message
457
+ const last = sanitized[sanitized.length - 1] as { role?: string } | undefined;
458
+ if (last?.role === "assistant") {
459
+ sanitized.push({ role: "user", content: " " });
460
+ }
461
+
462
+ return sanitized;
463
+ }
@@ -31,6 +31,7 @@ import {
31
31
  } from "../../constants.ts";
32
32
  import { applyHidden } from "../../config.ts";
33
33
  import { createLogger } from "../../lib/logger.ts";
34
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
34
35
  import {
35
36
  getProxyModelCompat,
36
37
  isLikelyReasoningModel,
@@ -40,6 +41,7 @@ import {
40
41
  recordModelProbeResults,
41
42
  } from "../../lib/probe-cache.ts";
42
43
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
44
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
43
45
  import { cleanModelName, fetchWithRetry } from "../../lib/util.ts";
44
46
  import { fetchWithTimeout } from "../../lib/util.ts";
45
47
  import { createReRegister, setupProvider } from "../../provider-helper.ts";
@@ -156,7 +158,13 @@ async function fetchRoutewayModels(
156
158
  const models = (json.data ?? []).filter(isChatModel);
157
159
 
158
160
  _logger.info(`[routeway] Fetched ${models.length} chat models`);
159
- return applyHidden(models.map(mapRoutewayModel), PROVIDER_ROUTEWAY);
161
+ const enriched = await safeEnrichModelsWithModelsDev(
162
+ models.map(mapRoutewayModel),
163
+ {
164
+ providerId: PROVIDER_ROUTEWAY,
165
+ },
166
+ );
167
+ return applyHidden(enriched, PROVIDER_ROUTEWAY);
160
168
  } catch (error) {
161
169
  _logger.error("[routeway] Failed to fetch models", {
162
170
  error: error instanceof Error ? error.message : String(error),
@@ -255,7 +263,7 @@ async function runRoutewayProbe(
255
263
  }
256
264
  }
257
265
 
258
- recordModelProbeResults(PROVIDER_ROUTEWAY, cacheableResults);
266
+ await recordModelProbeResults(PROVIDER_ROUTEWAY, cacheableResults);
259
267
 
260
268
  if (broken.length === 0) {
261
269
  _logger.info("Auto-probe: all checked Routeway models are routable");
@@ -337,18 +345,21 @@ export default async function routewayProvider(pi: ExtensionAPI) {
337
345
 
338
346
  // ── Lazy auto-probe on first session_start ──────────────────────
339
347
  let _autoProbeDone = false;
340
- pi.on("session_start", async () => {
341
- if (_autoProbeDone || !apiKey) return;
342
- _autoProbeDone = true;
343
- _logger.info("Starting lazy auto-probe of Routeway models...");
344
- runRoutewayProbe(apiKey, allModels, stored, reRegister, {
345
- useCache: true,
346
- }).catch((err) => {
347
- _logger.warn("Auto-probe failed", {
348
- error: err instanceof Error ? err.message : String(err),
348
+ pi.on(
349
+ "session_start",
350
+ wrapSessionStartHandler("routeway", async () => {
351
+ if (_autoProbeDone || !apiKey) return;
352
+ _autoProbeDone = true;
353
+ _logger.info("Starting lazy auto-probe of Routeway models...");
354
+ runRoutewayProbe(apiKey, allModels, stored, reRegister, {
355
+ useCache: true,
356
+ }).catch((err) => {
357
+ _logger.warn("Auto-probe failed", {
358
+ error: err instanceof Error ? err.message : String(err),
359
+ });
349
360
  });
350
- });
351
- });
361
+ }),
362
+ );
352
363
 
353
364
  // ── Probe command: test all registered models for 5xx ─────────────
354
365
  pi.registerCommand("probe-routeway", {
@@ -361,10 +372,7 @@ export default async function routewayProvider(pi: ExtensionAPI) {
361
372
  }
362
373
 
363
374
  const modelsToTest = allModels;
364
- ctx.ui.notify(
365
- `Probing ${modelsToTest.length} Routeway models…`,
366
- "info",
367
- );
375
+ ctx.ui.notify(`Probing ${modelsToTest.length} Routeway models…`, "info");
368
376
 
369
377
  await runRoutewayProbe(apiKey, modelsToTest, stored, reRegister);
370
378
 
@@ -31,8 +31,13 @@ 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 { createProviderProbe } from "../../lib/provider-probe.ts";
34
35
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
35
- import { fetchOpenAICompatibleModels } from "../../lib/util.ts";
36
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
37
+ import {
38
+ fetchOpenAICompatibleModels,
39
+ fetchWithTimeout,
40
+ } from "../../lib/util.ts";
36
41
  import { createReRegister, setupProvider } from "../../provider-helper.ts";
37
42
 
38
43
  const _logger = createLogger("sambanova");
@@ -112,4 +117,65 @@ export default async function sambanovaProvider(pi: ExtensionAPI) {
112
117
  const initialModels =
113
118
  showPaid && stored.all.length > 0 ? stored.all : freeModels;
114
119
  reRegister(initialModels);
120
+
121
+ // ── Probe support ──────────────────────────────────────────────
122
+ const probe = createProviderProbe({
123
+ providerId: PROVIDER_SAMBANOVA,
124
+ probeModel: async (_apiKey: string, modelId: string) => {
125
+ try {
126
+ const response = await fetchWithTimeout(
127
+ `${BASE_URL_SAMBANOVA}/chat/completions`,
128
+ {
129
+ method: "POST",
130
+ headers: {
131
+ Authorization: `Bearer ${apiKey}`,
132
+ "Content-Type": "application/json",
133
+ "User-Agent": "pi-free-providers",
134
+ },
135
+ body: JSON.stringify({
136
+ model: modelId,
137
+ messages: [{ role: "user", content: "hi" }],
138
+ max_tokens: 1,
139
+ }),
140
+ },
141
+ 10_000,
142
+ );
143
+ // SambaNova may return 404 for preview/unavailable models
144
+ if (response.status === 404 || response.status >= 500) return "broken";
145
+ if (response.status === 429) return "ok";
146
+ if (response.ok) return "ok";
147
+ return "ok";
148
+ } catch {
149
+ return "unknown";
150
+ }
151
+ },
152
+ });
153
+
154
+ // Probe command
155
+ pi.registerCommand(`probe-${PROVIDER_SAMBANOVA}`, {
156
+ description: "Test all SambaNova models for availability",
157
+ handler: async (_args, ctx) => {
158
+ ctx.ui.notify(`Probing ${allModels.length} SambaNova models…`, "info");
159
+ const broken = await probe.run(apiKey, allModels, {
160
+ onBroken: (ids) => {
161
+ ctx.ui.notify(
162
+ `Found ${ids.length} broken models (auto-hidden):\n${ids.join("\n")}`,
163
+ "warning",
164
+ );
165
+ },
166
+ });
167
+ if (broken.length === 0) {
168
+ ctx.ui.notify("All SambaNova models are accessible ✅", "info");
169
+ }
170
+ },
171
+ });
172
+
173
+ // Lazy auto-probe on first session_start
174
+ pi.on(
175
+ "session_start",
176
+ wrapSessionStartHandler(
177
+ `${PROVIDER_SAMBANOVA}-auto-probe`,
178
+ probe.autoProbeHandler(apiKey, freeModels),
179
+ ),
180
+ );
115
181
  }
@@ -41,12 +41,15 @@ import {
41
41
  PROVIDER_TOGETHER,
42
42
  } from "../../constants.ts";
43
43
  import { createLogger } from "../../lib/logger.ts";
44
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
44
45
  import {
45
46
  getProxyModelCompat,
46
47
  isLikelyReasoningModel,
47
48
  } from "../../lib/provider-compat.ts";
49
+ import { createProviderProbe } from "../../lib/provider-probe.ts";
48
50
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
49
- import { fetchWithRetry } from "../../lib/util.ts";
51
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
52
+ import { fetchWithRetry, fetchWithTimeout } from "../../lib/util.ts";
50
53
  import { createReRegister, setupProvider } from "../../provider-helper.ts";
51
54
 
52
55
  const _logger = createLogger("together");
@@ -98,7 +101,7 @@ async function fetchTogetherModels(
98
101
 
99
102
  _logger.info(`[together] Fetched ${models.length} models`);
100
103
 
101
- return models
104
+ const mapped = models
102
105
  .filter((m) => m.type === "chat" && m.id && !m.id.includes("embed"))
103
106
  .map((m): ProviderModelConfig => {
104
107
  const name = m.display_name || m.id.split("/").pop() || m.id;
@@ -126,6 +129,10 @@ async function fetchTogetherModels(
126
129
  _pricingKnown: m.pricing !== undefined,
127
130
  } as ProviderModelConfig & { _pricingKnown?: boolean };
128
131
  });
132
+
133
+ return await safeEnrichModelsWithModelsDev(mapped, {
134
+ providerId: PROVIDER_TOGETHER,
135
+ });
129
136
  }
130
137
 
131
138
  // =============================================================================
@@ -191,4 +198,64 @@ export default async function togetherProvider(pi: ExtensionAPI) {
191
198
 
192
199
  // Initial registration — show all models (trial credit provider)
193
200
  reRegister(stored.all);
201
+
202
+ // ── Probe support ──────────────────────────────────────────────
203
+ const probe = createProviderProbe({
204
+ providerId: PROVIDER_TOGETHER,
205
+ probeModel: async (_apiKey: string, modelId: string) => {
206
+ try {
207
+ const response = await fetchWithTimeout(
208
+ `${BASE_URL_TOGETHER}/chat/completions`,
209
+ {
210
+ method: "POST",
211
+ headers: {
212
+ Authorization: `Bearer ${apiKey}`,
213
+ "Content-Type": "application/json",
214
+ "User-Agent": "pi-free-providers",
215
+ },
216
+ body: JSON.stringify({
217
+ model: modelId,
218
+ messages: [{ role: "user", content: "hi" }],
219
+ max_tokens: 1,
220
+ }),
221
+ },
222
+ 10_000,
223
+ );
224
+ if (response.status === 404 || response.status >= 500) return "broken";
225
+ if (response.status === 429) return "ok";
226
+ if (response.ok) return "ok";
227
+ return "ok";
228
+ } catch {
229
+ return "unknown";
230
+ }
231
+ },
232
+ });
233
+
234
+ // Probe command
235
+ pi.registerCommand(`probe-${PROVIDER_TOGETHER}`, {
236
+ description: "Test all Together AI models for availability",
237
+ handler: async (_args, ctx) => {
238
+ ctx.ui.notify(`Probing ${allModels.length} Together AI models…`, "info");
239
+ const broken = await probe.run(apiKey, allModels, {
240
+ onBroken: (ids) => {
241
+ ctx.ui.notify(
242
+ `Found ${ids.length} broken models (auto-hidden):\n${ids.join("\n")}`,
243
+ "warning",
244
+ );
245
+ },
246
+ });
247
+ if (broken.length === 0) {
248
+ ctx.ui.notify("All Together AI models are accessible ✅", "info");
249
+ }
250
+ },
251
+ });
252
+
253
+ // Lazy auto-probe on first session_start
254
+ pi.on(
255
+ "session_start",
256
+ wrapSessionStartHandler(
257
+ `${PROVIDER_TOGETHER}-auto-probe`,
258
+ probe.autoProbeHandler(apiKey, freeModels),
259
+ ),
260
+ );
194
261
  }