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
@@ -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
+ }
@@ -18,7 +18,12 @@ import type {
18
18
  ExtensionAPI,
19
19
  ProviderModelConfig,
20
20
  } from "@earendil-works/pi-coding-agent";
21
- import { getRoutewayApiKey, getRoutewayShowPaid } from "../../config.ts";
21
+ import {
22
+ getRoutewayApiKey,
23
+ getRoutewayShowPaid,
24
+ loadConfigFile,
25
+ saveConfig,
26
+ } from "../../config.ts";
22
27
  import {
23
28
  BASE_URL_ROUTEWAY,
24
29
  DEFAULT_FETCH_TIMEOUT_MS,
@@ -26,12 +31,19 @@ import {
26
31
  } from "../../constants.ts";
27
32
  import { applyHidden } from "../../config.ts";
28
33
  import { createLogger } from "../../lib/logger.ts";
34
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
29
35
  import {
30
36
  getProxyModelCompat,
31
37
  isLikelyReasoningModel,
32
38
  } from "../../lib/provider-compat.ts";
39
+ import {
40
+ getModelsDueForProbe,
41
+ recordModelProbeResults,
42
+ } from "../../lib/probe-cache.ts";
33
43
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
44
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
34
45
  import { cleanModelName, fetchWithRetry } from "../../lib/util.ts";
46
+ import { fetchWithTimeout } from "../../lib/util.ts";
35
47
  import { createReRegister, setupProvider } from "../../provider-helper.ts";
36
48
 
37
49
  const _logger = createLogger("routeway");
@@ -146,7 +158,13 @@ async function fetchRoutewayModels(
146
158
  const models = (json.data ?? []).filter(isChatModel);
147
159
 
148
160
  _logger.info(`[routeway] Fetched ${models.length} chat models`);
149
- 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);
150
168
  } catch (error) {
151
169
  _logger.error("[routeway] Failed to fetch models", {
152
170
  error: error instanceof Error ? error.message : String(error),
@@ -155,6 +173,125 @@ async function fetchRoutewayModels(
155
173
  }
156
174
  }
157
175
 
176
+ // =============================================================================
177
+ // Probe
178
+ // =============================================================================
179
+
180
+ async function probeRoutewayModel(
181
+ apiKey: string,
182
+ modelId: string,
183
+ ): Promise<"ok" | "broken" | "unknown"> {
184
+ try {
185
+ const response = await fetchWithTimeout(
186
+ `${BASE_URL_ROUTEWAY}/chat/completions`,
187
+ {
188
+ method: "POST",
189
+ headers: {
190
+ Authorization: `Bearer ${apiKey}`,
191
+ "Content-Type": "application/json",
192
+ "User-Agent": "pi-free-providers",
193
+ },
194
+ body: JSON.stringify({
195
+ model: modelId,
196
+ messages: [{ role: "user", content: "hi" }],
197
+ max_tokens: 1,
198
+ }),
199
+ },
200
+ 10000, // 10 second timeout
201
+ );
202
+
203
+ // 5xx = upstream server error (model unavailable)
204
+ if (response.status >= 500) return "broken";
205
+ // 404 = model not found / not provisioned
206
+ if (response.status === 404) return "broken";
207
+ // 429 = rate limited (model works)
208
+ if (response.status === 429) return "ok";
209
+ // 401 = auth issue (model exists, key issue)
210
+ if (response.status === 401) return "ok";
211
+ // 400 = bad request (model exists, param issue)
212
+ if (response.status === 400) return "ok";
213
+ // 200 = success
214
+ if (response.ok) return "ok";
215
+ return "ok";
216
+ } catch {
217
+ return "unknown";
218
+ }
219
+ }
220
+
221
+ async function runRoutewayProbe(
222
+ apiKey: string,
223
+ modelsToTest: ProviderModelConfig[],
224
+ stored: { free: ProviderModelConfig[]; all: ProviderModelConfig[] },
225
+ reRegister: (models: ProviderModelConfig[]) => void,
226
+ options: { useCache?: boolean } = {},
227
+ ): Promise<string[]> {
228
+ const modelIdsToProbe = options.useCache
229
+ ? new Set(
230
+ getModelsDueForProbe(
231
+ PROVIDER_ROUTEWAY,
232
+ modelsToTest.map((m) => m.id),
233
+ ),
234
+ )
235
+ : undefined;
236
+ const probeCandidates = modelIdsToProbe
237
+ ? modelsToTest.filter((m) => modelIdsToProbe.has(m.id))
238
+ : modelsToTest;
239
+
240
+ if (probeCandidates.length === 0) {
241
+ _logger.info("Auto-probe: Routeway probe cache is fresh");
242
+ return [];
243
+ }
244
+
245
+ const broken: string[] = [];
246
+ const cacheableResults: Array<{ modelId: string; status: "ok" | "broken" }> =
247
+ [];
248
+ const batchSize = 5;
249
+
250
+ for (let i = 0; i < probeCandidates.length; i += batchSize) {
251
+ const batch = probeCandidates.slice(i, i + batchSize);
252
+ const results = await Promise.all(
253
+ batch.map(async (m) => {
254
+ const status = await probeRoutewayModel(apiKey, m.id);
255
+ return { id: m.id, status };
256
+ }),
257
+ );
258
+ for (const r of results) {
259
+ if (r.status === "broken") broken.push(r.id);
260
+ if (r.status !== "unknown") {
261
+ cacheableResults.push({ modelId: r.id, status: r.status });
262
+ }
263
+ }
264
+ }
265
+
266
+ await recordModelProbeResults(PROVIDER_ROUTEWAY, cacheableResults);
267
+
268
+ if (broken.length === 0) {
269
+ _logger.info("Auto-probe: all checked Routeway models are routable");
270
+ return [];
271
+ }
272
+
273
+ // Auto-hide broken models in config (provider-scoped)
274
+ const cfg = loadConfigFile();
275
+ const existingHidden = new Set(cfg.hidden_models ?? []);
276
+ for (const id of broken) existingHidden.add(`${PROVIDER_ROUTEWAY}/${id}`);
277
+ saveConfig({ hidden_models: Array.from(existingHidden) });
278
+
279
+ // Re-register so hidden models disappear immediately
280
+ const filtered = await fetchRoutewayModels(apiKey);
281
+ stored.free = filtered;
282
+ stored.all = filtered;
283
+ reRegister(filtered);
284
+
285
+ _logger.info(
286
+ `Auto-probe: found ${broken.length} broken models (auto-hidden)`,
287
+ );
288
+ return broken;
289
+ }
290
+
291
+ // =============================================================================
292
+ // Extension Entry Point
293
+ // =============================================================================
294
+
158
295
  export default async function routewayProvider(pi: ExtensionAPI) {
159
296
  const apiKey = getRoutewayApiKey();
160
297
 
@@ -206,6 +343,55 @@ export default async function routewayProvider(pi: ExtensionAPI) {
206
343
  stored,
207
344
  );
208
345
 
346
+ // ── Lazy auto-probe on first session_start ──────────────────────
347
+ let _autoProbeDone = false;
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
+ });
360
+ });
361
+ }),
362
+ );
363
+
364
+ // ── Probe command: test all registered models for 5xx ─────────────
365
+ pi.registerCommand("probe-routeway", {
366
+ description:
367
+ "Test all Routeway models for server errors and auto-hide broken ones",
368
+ handler: async (_args, ctx) => {
369
+ if (!apiKey) {
370
+ ctx.ui.notify("ROUTEWAY_API_KEY not set", "error");
371
+ return;
372
+ }
373
+
374
+ const modelsToTest = allModels;
375
+ ctx.ui.notify(`Probing ${modelsToTest.length} Routeway models…`, "info");
376
+
377
+ await runRoutewayProbe(apiKey, modelsToTest, stored, reRegister);
378
+
379
+ // Check if any were hidden (re-read config)
380
+ const cfgAfter = loadConfigFile();
381
+ const newHidden = (cfgAfter.hidden_models ?? []).filter((h) =>
382
+ h.startsWith(`${PROVIDER_ROUTEWAY}/`),
383
+ );
384
+ if (newHidden.length > 0) {
385
+ ctx.ui.notify(
386
+ `Found ${newHidden.length} broken models (auto-hidden):\n${newHidden.join("\n")}`,
387
+ "warning",
388
+ );
389
+ } else {
390
+ ctx.ui.notify("All Routeway models are routable ✅", "info");
391
+ }
392
+ },
393
+ });
394
+
209
395
  const showPaid = getRoutewayShowPaid();
210
396
  const initialModels =
211
397
  showPaid && stored.all.length > 0 ? stored.all : freeModels;
@@ -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
  }