pi-free 2.2.7 → 2.2.8

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.
@@ -1,718 +1,718 @@
1
- /**
2
- * Dynamic Built-in Provider Fetcher
3
- *
4
- * Fetches models dynamically from Pi's built-in providers via their
5
- * standard /models endpoints when the user has configured an API key.
6
- *
7
- * Uses a single generic fetch function instead of per-provider boilerplate.
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
- *
12
- * Providers handled:
13
- * - mistral (MISTRAL_API_KEY)
14
- * - groq (GROQ_API_KEY)
15
- * - cerebras (CEREBRAS_API_KEY)
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)
20
- * - huggingface (HF_TOKEN - optional, special-cased API shape)
21
- *
22
- * OpenAI is intentionally skipped per user request.
23
- */
24
-
25
- import type { Api } from "@earendil-works/pi-ai";
26
- import type {
27
- ExtensionAPI,
28
- ProviderModelConfig,
29
- } from "@earendil-works/pi-coding-agent";
30
- import {
31
- getCerebrasApiKey,
32
- getFastrouterApiKey,
33
- getFastrouterShowPaid,
34
- getGroqApiKey,
35
- getHfToken,
36
- getMistralApiKey,
37
- getOpencodeApiKey,
38
- getOpencodeShowPaid,
39
- getOpenrouterApiKey,
40
- getOpenrouterShowPaid,
41
- getXaiApiKey,
42
- } from "../../config.ts";
43
- import { DEFAULT_FETCH_TIMEOUT_MS } from "../../constants.ts";
44
- import { createLogger } from "../../lib/logger.ts";
45
- import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
46
- import { getProxyModelCompat } from "../../lib/provider-compat.ts";
47
- import {
48
- areAllModelsFresh,
49
- getModelsDueForProbe,
50
- recordModelProbeResults,
51
- } from "../../lib/probe-cache.ts";
52
- import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
53
- import { updateConfig } from "../../config.ts";
54
- import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
55
- import { fetchWithTimeout } from "../../lib/util.ts";
56
- import { fetchOpenRouterCompatibleModels } from "../model-fetcher.ts";
57
- import { createToggleState } from "../../lib/toggle-state.ts";
58
- import { enhanceWithCI, loadCachedOrFetchModels } from "../../provider-helper.ts";
59
- import {
60
- OPENCODE_DYNAMIC_API,
61
- createOpenCodeSessionTracker,
62
- createOpenCodeStreamSimple,
63
- isOpenCodeProvider,
64
- } from "../opencode-session.ts";
65
-
66
- const _logger = createLogger("dynamic-built-in");
67
-
68
- const OPENCODE_PROBE_TIMEOUT_MS = 15_000;
69
-
70
- // OpenCode headers must be regenerated for every LLM request.
71
- const _opencodeSession = createOpenCodeSessionTracker();
72
-
73
- // =============================================================================
74
- // Generic Model Fetcher
75
- // =============================================================================
76
-
77
- interface FetchModelsOptions {
78
- providerId: string;
79
- baseUrl: string;
80
- apiKey: string;
81
- compat?: ProviderModelConfig["compat"];
82
- modelDefaults?: Partial<ProviderModelConfig>;
83
- timeoutMs?: number;
84
- }
85
-
86
- /**
87
- * Fetch models from any standard {baseUrl}/models endpoint.
88
- * Handles both OpenAI-style { object: "list", data: [...] } and plain arrays.
89
- * Uses AbortSignal.timeout for non-retry, fail-fast behaviour.
90
- */
91
- async function fetchModelsFromEndpoint(
92
- opts: FetchModelsOptions,
93
- ): Promise<ProviderModelConfig[]> {
94
- let cleanBase = opts.baseUrl;
95
- while (cleanBase.endsWith("/")) cleanBase = cleanBase.slice(0, -1);
96
- const url = `${cleanBase}/models`;
97
- const headers: Record<string, string> = {
98
- Accept: "application/json",
99
- Authorization: `Bearer ${opts.apiKey}`,
100
- };
101
-
102
- const response = await fetch(url, {
103
- headers,
104
- signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
105
- });
106
-
107
- if (!response.ok) {
108
- throw new Error(`HTTP ${response.status} ${response.statusText}`);
109
- }
110
-
111
- const body = (await response.json()) as
112
- | Array<Record<string, unknown>>
113
- | { data?: Array<Record<string, unknown>> };
114
- const rawModels: Array<Record<string, unknown>> = Array.isArray(body)
115
- ? body
116
- : (body.data ?? []);
117
-
118
- const models = rawModels.map((m) => {
119
- const id = String(m.id ?? "");
120
- const inputModalities = m.input_modalities as string[] | undefined;
121
- return {
122
- id,
123
- name: (m.name as string) ?? (m.model as string) ?? id,
124
- reasoning: !!(m.reasoning ?? false),
125
- input: inputModalities?.includes("image")
126
- ? (["text", "image"] as const)
127
- : (["text"] as const),
128
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
129
- contextWindow:
130
- ((m.context_length ??
131
- m.max_context_length ??
132
- m.context_window) as number) ??
133
- opts.modelDefaults?.contextWindow ??
134
- 128_000,
135
- maxTokens:
136
- ((m.max_tokens ?? m.max_completion_tokens) as number) ??
137
- opts.modelDefaults?.maxTokens ??
138
- 16_384,
139
- _pricingKnown: false as boolean | undefined,
140
- ...opts.modelDefaults,
141
- ...(opts.compat ? { compat: opts.compat } : {}),
142
- } satisfies ProviderModelConfig & { _pricingKnown?: boolean };
143
- });
144
-
145
- return await safeEnrichModelsWithModelsDev(models, {
146
- providerId: opts.providerId,
147
- });
148
- }
149
-
150
- // =============================================================================
151
- // Hugging Face (special-cased: non-standard API shape)
152
- // =============================================================================
153
-
154
- async function fetchHuggingFaceModels(
155
- apiKey?: string,
156
- ): Promise<ProviderModelConfig[]> {
157
- const headers: Record<string, string> = {
158
- "Content-Type": "application/json",
159
- };
160
- if (apiKey) {
161
- headers.Authorization = `Bearer ${apiKey}`;
162
- }
163
-
164
- const response = await fetch(
165
- "https://api-inference.huggingface.co/models?pipeline_tag=text-generation&limit=50",
166
- { headers, signal: AbortSignal.timeout(DEFAULT_FETCH_TIMEOUT_MS) },
167
- );
168
-
169
- if (!response.ok) {
170
- throw new Error(`Hugging Face API error: ${response.status}`);
171
- }
172
-
173
- const body = (await response.json()) as Array<{
174
- id: string;
175
- modelId?: string;
176
- }>;
177
-
178
- const models = Array.isArray(body) ? body.slice(0, 50) : [];
179
- return models.map((m): ProviderModelConfig => {
180
- const id = m.id || m.modelId || "unknown";
181
- return {
182
- id,
183
- name: id.split("/").pop() || "Unknown",
184
- reasoning: false,
185
- input: ["text"],
186
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
187
- contextWindow: 4096,
188
- maxTokens: 2048,
189
- };
190
- });
191
- }
192
-
193
- // =============================================================================
194
- // Provider Definitions
195
- // =============================================================================
196
-
197
- interface DynamicProviderDef {
198
- providerId: string;
199
- getApiKey: () => string | undefined;
200
- baseUrl: string;
201
- api: Api;
202
- defaultShowPaid: boolean | (() => boolean);
203
- /** Optional per-provider compat overrides (e.g., DeepSeek proxy). */
204
- compat?: ProviderModelConfig["compat"];
205
- /** Per-model field defaults when the API doesn't expose them. */
206
- modelDefaults?: Partial<ProviderModelConfig>;
207
- /**
208
- * Custom model fetcher (e.g., OpenRouter uses its own pricing-aware fetcher).
209
- * When not provided, fetchModelsFromEndpoint is used (no pricing, _pricingKnown=false).
210
- */
211
- fetchModels?: (apiKey: string) => Promise<ProviderModelConfig[]>;
212
- /**
213
- * Optional probe support for providers whose free model status expires.
214
- */
215
- probe?: {
216
- run: (
217
- apiKey: string,
218
- models: ProviderModelConfig[],
219
- options?: { useCache?: boolean },
220
- ) => Promise<string[]>;
221
- };
222
- }
223
-
224
- const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
225
- {
226
- providerId: "mistral",
227
- getApiKey: getMistralApiKey,
228
- baseUrl: "https://api.mistral.ai/v1",
229
- api: "openai-completions",
230
- defaultShowPaid: false,
231
- modelDefaults: { contextWindow: 32_768, maxTokens: 16_384 },
232
- },
233
- {
234
- providerId: "groq",
235
- getApiKey: getGroqApiKey,
236
- baseUrl: "https://api.groq.com/openai/v1",
237
- api: "openai-completions",
238
- defaultShowPaid: false,
239
- },
240
- {
241
- providerId: "cerebras",
242
- getApiKey: getCerebrasApiKey,
243
- baseUrl: "https://api.cerebras.ai/v1",
244
- api: "openai-completions",
245
- defaultShowPaid: false,
246
- },
247
- {
248
- providerId: "xai",
249
- getApiKey: getXaiApiKey,
250
- baseUrl: "https://api.x.ai/v1",
251
- api: "openai-completions",
252
- defaultShowPaid: false,
253
- },
254
- {
255
- providerId: "opencode",
256
- getApiKey: getOpencodeApiKey,
257
- baseUrl: "https://opencode.ai/zen/v1",
258
- api: OPENCODE_DYNAMIC_API,
259
- defaultShowPaid: getOpencodeShowPaid,
260
- // OpenCode API returns no pricing — _pricingKnown=false, name-based detection
261
- probe: {
262
- run: (apiKey, models) =>
263
- runOpenCodeProbe(
264
- "opencode",
265
- apiKey,
266
- "https://opencode.ai/zen/v1",
267
- models,
268
- ),
269
- },
270
- },
271
- {
272
- providerId: "opencode-go",
273
- getApiKey: getOpencodeApiKey,
274
- baseUrl: "https://opencode.ai/zen/go/v1",
275
- api: OPENCODE_DYNAMIC_API,
276
- defaultShowPaid: getOpencodeShowPaid,
277
- // OpenCode Go uses the same OPENCODE_API_KEY and per-request headers
278
- probe: {
279
- run: (apiKey, models) =>
280
- runOpenCodeProbe(
281
- "opencode-go",
282
- apiKey,
283
- "https://opencode.ai/zen/go/v1",
284
- models,
285
- ),
286
- },
287
- },
288
- {
289
- providerId: "openrouter",
290
- getApiKey: getOpenrouterApiKey,
291
- baseUrl: "https://openrouter.ai/api/v1",
292
- api: "openai-completions",
293
- defaultShowPaid: getOpenrouterShowPaid,
294
- // OpenRouter returns full pricing — use its dedicated fetcher
295
- fetchModels: (apiKey) =>
296
- fetchOpenRouterCompatibleModels({
297
- providerId: "openrouter",
298
- baseUrl: "https://openrouter.ai/api/v1",
299
- apiKey,
300
- freeOnly: false,
301
- }),
302
- },
303
- ];
304
-
305
- // =============================================================================
306
- // Discovery + Registration per Provider
307
- // =============================================================================
308
-
309
- async function discoverAndRegister(
310
- pi: ExtensionAPI,
311
- config: DynamicProviderDef,
312
- apiKey: string,
313
- ): Promise<void> {
314
- // Use the shared cache-first loader so dynamic providers (e.g. always-discovered
315
- // FastRouter) don't block startup on a network fetch. The helper serves a fresh
316
- // cache, falls back to stale cache on failure/empty fetch, and persists the
317
- // result for next startup.
318
- const models = await loadCachedOrFetchModels(
319
- config.providerId,
320
- async () => {
321
- let allModels: ProviderModelConfig[];
322
- if (config.fetchModels) {
323
- allModels = await config.fetchModels(apiKey);
324
- } else {
325
- allModels = await fetchModelsFromEndpoint({
326
- providerId: config.providerId,
327
- baseUrl: config.baseUrl,
328
- apiKey,
329
- compat: config.compat,
330
- modelDefaults: config.modelDefaults,
331
- timeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
332
- });
333
- }
334
-
335
- // Apply DeepSeek proxy compat to matching models. OpenCode headers are
336
- // injected per request by createOpenCodeStreamSimple(), not stored here.
337
- return allModels.map((m) => ({
338
- ...m,
339
- api: isOpenCodeProvider(config.providerId)
340
- ? OPENCODE_DYNAMIC_API
341
- : m.api,
342
- compat: getProxyModelCompat(m) ?? m.compat,
343
- }));
344
- },
345
- );
346
-
347
- if (models.length === 0) {
348
- // No usable models and no fallback cache: leave Pi's built-in defaults.
349
- _logger.info(
350
- `[dynamic] ${config.providerId}: no models available; Pi keeps its defaults`,
351
- );
352
- return;
353
- }
354
-
355
- await registerProvider(pi, config, models, apiKey);
356
- }
357
-
358
- // =============================================================================
359
- // OpenCode Probe
360
- // =============================================================================
361
-
362
- /**
363
- * Probe a single OpenCode model with a minimal chat request.
364
- *
365
- * OpenCode expired free promotions return 401 with a body like:
366
- * { error: { message: "Free promotion has ended" } }
367
- *
368
- * We treat 401 and 403 as "broken" for free models, since those codes mean
369
- * the model is no longer accessible under the current credentials. 404 is
370
- * also broken. 429 means the model is reachable but rate-limited (ok).
371
- */
372
- async function probeOpenCodeModel(
373
- apiKey: string,
374
- baseUrl: string,
375
- modelId: string,
376
- ): Promise<"ok" | "broken" | "unknown"> {
377
- try {
378
- const response = await fetchWithTimeout(
379
- `${baseUrl}/chat/completions`,
380
- {
381
- method: "POST",
382
- headers: {
383
- Authorization: `Bearer ${apiKey}`,
384
- "Content-Type": "application/json",
385
- "User-Agent": "opencode/1.15.5",
386
- "x-opencode-client": "cli",
387
- },
388
- body: JSON.stringify({
389
- model: modelId,
390
- messages: [{ role: "user", content: "hi" }],
391
- max_tokens: 1,
392
- }),
393
- },
394
- OPENCODE_PROBE_TIMEOUT_MS,
395
- );
396
-
397
- if (response.status === 401 || response.status === 403) return "broken";
398
- if (response.status === 404) return "broken";
399
- if (response.status === 429) return "ok";
400
- if (response.ok) return "ok";
401
- return "ok";
402
- } catch {
403
- return "unknown";
404
- }
405
- }
406
-
407
- async function runOpenCodeProbe(
408
- providerId: string,
409
- apiKey: string,
410
- baseUrl: string,
411
- models: ProviderModelConfig[],
412
- options: { useCache?: boolean } = {},
413
- ): Promise<string[]> {
414
- const freeModels = models.filter((m) =>
415
- isFreeModel({ ...m, provider: providerId }, models),
416
- );
417
- const modelIdsToProbe = options.useCache
418
- ? new Set(
419
- getModelsDueForProbe(
420
- providerId,
421
- freeModels.map((m) => m.id),
422
- ),
423
- )
424
- : undefined;
425
- const probeCandidates = modelIdsToProbe
426
- ? freeModels.filter((m) => modelIdsToProbe.has(m.id))
427
- : freeModels;
428
-
429
- if (probeCandidates.length === 0) {
430
- _logger.info(`Auto-probe: ${providerId} probe cache is fresh`);
431
- return [];
432
- }
433
-
434
- const broken: string[] = [];
435
- const cacheableResults: Array<{ modelId: string; status: "ok" | "broken" }> =
436
- [];
437
- const batchSize = 5;
438
-
439
- for (let i = 0; i < probeCandidates.length; i += batchSize) {
440
- const batch = probeCandidates.slice(i, i + batchSize);
441
- const results = await Promise.all(
442
- batch.map(async (m) => {
443
- const status = await probeOpenCodeModel(apiKey, baseUrl, m.id);
444
- return { id: m.id, status };
445
- }),
446
- );
447
- for (const r of results) {
448
- if (r.status === "broken") broken.push(r.id);
449
- if (r.status !== "unknown") {
450
- cacheableResults.push({ modelId: r.id, status: r.status });
451
- }
452
- }
453
- }
454
-
455
- await recordModelProbeResults(providerId, cacheableResults);
456
- return broken;
457
- }
458
-
459
- async function discoverAndRegisterHF(
460
- pi: ExtensionAPI,
461
- apiKey: string,
462
- ): Promise<void> {
463
- const config: DynamicProviderDef = {
464
- providerId: "huggingface",
465
- getApiKey: getHfToken,
466
- baseUrl: "https://api-inference.huggingface.co",
467
- api: "openai-completions",
468
- defaultShowPaid: false,
469
- };
470
-
471
- let allModels: ProviderModelConfig[];
472
- try {
473
- allModels = await fetchHuggingFaceModels(apiKey);
474
- } catch (error) {
475
- _logger.info(
476
- "[dynamic] huggingface: discovery failed, Pi keeps its defaults",
477
- { error: error instanceof Error ? error.message : String(error) },
478
- );
479
- return;
480
- }
481
-
482
- await registerProvider(pi, config, allModels, apiKey);
483
- }
484
-
485
- // =============================================================================
486
- // Registration Logic (sets up toggles, commands, status bar)
487
- // =============================================================================
488
-
489
- async function registerProvider(
490
- pi: ExtensionAPI,
491
- config: DynamicProviderDef,
492
- allModels: ProviderModelConfig[],
493
- apiKey: string,
494
- ): Promise<void> {
495
- const freeModels = allModels.filter((m) =>
496
- isFreeModel({ ...m, provider: config.providerId }, allModels),
497
- );
498
-
499
- _logger.info(
500
- `[dynamic] ${config.providerId}: ${allModels.length} total, ${freeModels.length} free`,
501
- );
502
-
503
- // Re-register function: called by toggle and initial apply
504
- const reRegister = (models: ProviderModelConfig[]) => {
505
- pi.registerProvider(config.providerId, {
506
- baseUrl: config.baseUrl,
507
- apiKey,
508
- api: config.api,
509
- ...(isOpenCodeProvider(config.providerId)
510
- ? { streamSimple: createOpenCodeStreamSimple(_opencodeSession) }
511
- : {}),
512
- models: enhanceWithCI(models, config.providerId),
513
- });
514
- };
515
-
516
- const stored: { free: ProviderModelConfig[]; all: ProviderModelConfig[] } = {
517
- free: freeModels,
518
- all: allModels,
519
- };
520
-
521
- /**
522
- * Hide broken free models in config and update the active model list.
523
- */
524
- async function hideBrokenModels(brokenIds: string[]): Promise<void> {
525
- if (brokenIds.length === 0) return;
526
-
527
- await updateConfig((cfg) => {
528
- const existingHidden = new Set(cfg.hidden_models ?? []);
529
- for (const id of brokenIds) {
530
- existingHidden.add(`${config.providerId}/${id}`);
531
- }
532
- return { hidden_models: Array.from(existingHidden) };
533
- });
534
-
535
- stored.all = stored.all.filter((m) => !brokenIds.includes(m.id));
536
- stored.free = stored.free.filter((m) => !brokenIds.includes(m.id));
537
- toggleState.setModels(stored);
538
- toggleState.applyCurrent(reRegister);
539
-
540
- _logger.info(
541
- `[dynamic] ${config.providerId}: hidden ${brokenIds.length} broken models`,
542
- );
543
- }
544
-
545
- // Toggle state
546
- const toggleState = createToggleState({
547
- providerId: config.providerId,
548
- initialShowPaid:
549
- typeof config.defaultShowPaid === "function"
550
- ? config.defaultShowPaid()
551
- : config.defaultShowPaid,
552
- initialModels: { free: freeModels, all: allModels },
553
- });
554
-
555
- // Toggle command
556
- pi.registerCommand(`toggle-${config.providerId}`, {
557
- description: `Toggle between free and all ${config.providerId} models`,
558
- handler: async (_args, ctx) => {
559
- const applied = toggleState.toggle(reRegister);
560
- ctx.ui.notify(
561
- applied.mode === "all"
562
- ? `${config.providerId}: showing all ${allModels.length} models`
563
- : `${config.providerId}: showing ${freeModels.length} free models`,
564
- "info",
565
- );
566
- },
567
- });
568
-
569
- // Global toggle
570
- registerWithGlobalToggle(
571
- config.providerId,
572
- { free: freeModels, all: allModels },
573
- reRegister,
574
- true,
575
- );
576
-
577
- // Register models (this swaps in our discovered models over Pi's defaults)
578
- toggleState.applyCurrent(reRegister);
579
-
580
- // ── Probe command for providers whose free model status expires ─────
581
- if (config.probe) {
582
- pi.registerCommand(`probe-${config.providerId}`, {
583
- description: `Test ${config.providerId} free models for expired promotions`,
584
- handler: async (_args, ctx) => {
585
- const modelsToTest =
586
- toggleState.getCurrentMode() === "all" ? stored.all : stored.free;
587
- ctx.ui.notify(
588
- `Probing ${modelsToTest.length} ${config.providerId} models…`,
589
- "info",
590
- );
591
-
592
- const broken = await config.probe!.run(apiKey, modelsToTest, {
593
- useCache: false,
594
- });
595
-
596
- if (broken.length === 0) {
597
- ctx.ui.notify(
598
- `All ${config.providerId} models are accessible ✅`,
599
- "info",
600
- );
601
- return;
602
- }
603
-
604
- ctx.ui.notify(
605
- `Found ${broken.length} expired free models:\n${broken.join("\n")}`,
606
- "warning",
607
- );
608
- await hideBrokenModels(broken);
609
- },
610
- });
611
-
612
- // ── Lazy auto-probe on first session_start ───────────────────────
613
- let _autoProbeDone = false;
614
- pi.on(
615
- "session_start",
616
- wrapSessionStartHandler(`${config.providerId}-auto-probe`, async () => {
617
- if (_autoProbeDone) return;
618
- _autoProbeDone = true;
619
- if (
620
- areAllModelsFresh(
621
- config.providerId,
622
- stored.free.map((m) => m.id),
623
- )
624
- ) {
625
- _logger.info(
626
- `[probe] ${config.providerId}: auto-probe cache is fresh`,
627
- );
628
- return;
629
- }
630
- _logger.info(
631
- `Starting lazy auto-probe of ${config.providerId} free models...`,
632
- );
633
- try {
634
- const broken = await config.probe!.run(apiKey, stored.free, {
635
- useCache: true,
636
- });
637
- await hideBrokenModels(broken);
638
- } catch (err) {
639
- _logger.warn("Auto-probe failed", {
640
- error: err instanceof Error ? err.message : String(err),
641
- });
642
- }
643
- }),
644
- );
645
- }
646
-
647
- _logger.info(`[dynamic] ${config.providerId}: registered`);
648
- }
649
-
650
- // =============================================================================
651
- // Main Entry
652
- // =============================================================================
653
-
654
- /**
655
- * Kick off model discovery for all configured providers.
656
- * Runs each fetch concurrently so startup waits for the slowest provider,
657
- * not `n * provider latency`.
658
- *
659
- * Pi flushes provider registrations after async extension startup completes,
660
- * so this function must await discovery before returning. Otherwise late
661
- * pi.registerProvider() calls may not be visible to startup flows such as
662
- * `pi --list-models` or the initial model picker.
663
- */
664
- export async function setupDynamicBuiltInProviders(
665
- pi: ExtensionAPI,
666
- ): Promise<void> {
667
- const fetchers: Promise<void>[] = [];
668
-
669
- for (const config of DYNAMIC_PROVIDERS) {
670
- const apiKey = config.getApiKey();
671
- if (!apiKey) continue;
672
- fetchers.push(discoverAndRegister(pi, config, apiKey));
673
- }
674
-
675
- const hfKey = getHfToken();
676
- if (hfKey) {
677
- fetchers.push(discoverAndRegisterHF(pi, hfKey));
678
- }
679
-
680
- // FastRouter: always discovered (model listing needs no auth), but Pi
681
- // requires a non-empty apiKey/env-var name when replacing a provider's models.
682
- // Use the real configured key when present; otherwise register with the env
683
- // var name so startup does not fail for users who have not configured it yet.
684
- const fastrouterApiKey = getFastrouterApiKey();
685
- fetchers.push(
686
- discoverAndRegister(
687
- pi,
688
- {
689
- providerId: "fastrouter",
690
- getApiKey: getFastrouterApiKey,
691
- baseUrl: "https://api.fastrouter.ai/api/v1",
692
- api: "openai-completions",
693
- defaultShowPaid: getFastrouterShowPaid,
694
- fetchModels: () =>
695
- fetchOpenRouterCompatibleModels({
696
- providerId: "fastrouter",
697
- baseUrl: "https://api.fastrouter.ai/api/v1",
698
- apiKey: fastrouterApiKey,
699
- freeOnly: false,
700
- }),
701
- },
702
- fastrouterApiKey ?? "$FASTROUTER_API_KEY",
703
- ),
704
- );
705
-
706
- if (fetchers.length === 0) return;
707
-
708
- _logger.info(
709
- `[dynamic] Kicking off discovery for ${fetchers.length} providers (concurrent)...`,
710
- );
711
-
712
- const results = await Promise.allSettled(fetchers);
713
- const succeeded = results.filter((r) => r.status === "fulfilled").length;
714
- const failed = results.filter((r) => r.status === "rejected").length;
715
- _logger.info(
716
- `[dynamic] Discovery complete: ${succeeded} succeeded, ${failed} failed/rejected`,
717
- );
718
- }
1
+ /**
2
+ * Dynamic Built-in Provider Fetcher
3
+ *
4
+ * Fetches models dynamically from Pi's built-in providers via their
5
+ * standard /models endpoints when the user has configured an API key.
6
+ *
7
+ * Uses a single generic fetch function instead of per-provider boilerplate.
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
+ *
12
+ * Providers handled:
13
+ * - mistral (MISTRAL_API_KEY)
14
+ * - groq (GROQ_API_KEY)
15
+ * - cerebras (CEREBRAS_API_KEY)
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)
20
+ * - huggingface (HF_TOKEN - optional, special-cased API shape)
21
+ *
22
+ * OpenAI is intentionally skipped per user request.
23
+ */
24
+
25
+ import type { Api } from "@earendil-works/pi-ai/compat";
26
+ import type {
27
+ ExtensionAPI,
28
+ ProviderModelConfig,
29
+ } from "@earendil-works/pi-coding-agent";
30
+ import {
31
+ getCerebrasApiKey,
32
+ getFastrouterApiKey,
33
+ getFastrouterShowPaid,
34
+ getGroqApiKey,
35
+ getHfToken,
36
+ getMistralApiKey,
37
+ getOpencodeApiKey,
38
+ getOpencodeShowPaid,
39
+ getOpenrouterApiKey,
40
+ getOpenrouterShowPaid,
41
+ getXaiApiKey,
42
+ } from "../../config.ts";
43
+ import { DEFAULT_FETCH_TIMEOUT_MS } from "../../constants.ts";
44
+ import { createLogger } from "../../lib/logger.ts";
45
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
46
+ import { getProxyModelCompat } from "../../lib/provider-compat.ts";
47
+ import {
48
+ areAllModelsFresh,
49
+ getModelsDueForProbe,
50
+ recordModelProbeResults,
51
+ } from "../../lib/probe-cache.ts";
52
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
53
+ import { updateConfig } from "../../config.ts";
54
+ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
55
+ import { fetchWithTimeout } from "../../lib/util.ts";
56
+ import { fetchOpenRouterCompatibleModels } from "../model-fetcher.ts";
57
+ import { createToggleState } from "../../lib/toggle-state.ts";
58
+ import { enhanceWithCI, loadCachedOrFetchModels } from "../../provider-helper.ts";
59
+ import {
60
+ OPENCODE_DYNAMIC_API,
61
+ createOpenCodeSessionTracker,
62
+ createOpenCodeStreamSimple,
63
+ isOpenCodeProvider,
64
+ } from "../opencode-session.ts";
65
+
66
+ const _logger = createLogger("dynamic-built-in");
67
+
68
+ const OPENCODE_PROBE_TIMEOUT_MS = 15_000;
69
+
70
+ // OpenCode headers must be regenerated for every LLM request.
71
+ const _opencodeSession = createOpenCodeSessionTracker();
72
+
73
+ // =============================================================================
74
+ // Generic Model Fetcher
75
+ // =============================================================================
76
+
77
+ interface FetchModelsOptions {
78
+ providerId: string;
79
+ baseUrl: string;
80
+ apiKey: string;
81
+ compat?: ProviderModelConfig["compat"];
82
+ modelDefaults?: Partial<ProviderModelConfig>;
83
+ timeoutMs?: number;
84
+ }
85
+
86
+ /**
87
+ * Fetch models from any standard {baseUrl}/models endpoint.
88
+ * Handles both OpenAI-style { object: "list", data: [...] } and plain arrays.
89
+ * Uses AbortSignal.timeout for non-retry, fail-fast behaviour.
90
+ */
91
+ async function fetchModelsFromEndpoint(
92
+ opts: FetchModelsOptions,
93
+ ): Promise<ProviderModelConfig[]> {
94
+ let cleanBase = opts.baseUrl;
95
+ while (cleanBase.endsWith("/")) cleanBase = cleanBase.slice(0, -1);
96
+ const url = `${cleanBase}/models`;
97
+ const headers: Record<string, string> = {
98
+ Accept: "application/json",
99
+ Authorization: `Bearer ${opts.apiKey}`,
100
+ };
101
+
102
+ const response = await fetch(url, {
103
+ headers,
104
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
105
+ });
106
+
107
+ if (!response.ok) {
108
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
109
+ }
110
+
111
+ const body = (await response.json()) as
112
+ | Array<Record<string, unknown>>
113
+ | { data?: Array<Record<string, unknown>> };
114
+ const rawModels: Array<Record<string, unknown>> = Array.isArray(body)
115
+ ? body
116
+ : (body.data ?? []);
117
+
118
+ const models = rawModels.map((m) => {
119
+ const id = String(m.id ?? "");
120
+ const inputModalities = m.input_modalities as string[] | undefined;
121
+ return {
122
+ id,
123
+ name: (m.name as string) ?? (m.model as string) ?? id,
124
+ reasoning: !!(m.reasoning ?? false),
125
+ input: inputModalities?.includes("image")
126
+ ? (["text", "image"] as const)
127
+ : (["text"] as const),
128
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
129
+ contextWindow:
130
+ ((m.context_length ??
131
+ m.max_context_length ??
132
+ m.context_window) as number) ??
133
+ opts.modelDefaults?.contextWindow ??
134
+ 128_000,
135
+ maxTokens:
136
+ ((m.max_tokens ?? m.max_completion_tokens) as number) ??
137
+ opts.modelDefaults?.maxTokens ??
138
+ 16_384,
139
+ _pricingKnown: false as boolean | undefined,
140
+ ...opts.modelDefaults,
141
+ ...(opts.compat ? { compat: opts.compat } : {}),
142
+ } satisfies ProviderModelConfig & { _pricingKnown?: boolean };
143
+ });
144
+
145
+ return await safeEnrichModelsWithModelsDev(models, {
146
+ providerId: opts.providerId,
147
+ });
148
+ }
149
+
150
+ // =============================================================================
151
+ // Hugging Face (special-cased: non-standard API shape)
152
+ // =============================================================================
153
+
154
+ async function fetchHuggingFaceModels(
155
+ apiKey?: string,
156
+ ): Promise<ProviderModelConfig[]> {
157
+ const headers: Record<string, string> = {
158
+ "Content-Type": "application/json",
159
+ };
160
+ if (apiKey) {
161
+ headers.Authorization = `Bearer ${apiKey}`;
162
+ }
163
+
164
+ const response = await fetch(
165
+ "https://api-inference.huggingface.co/models?pipeline_tag=text-generation&limit=50",
166
+ { headers, signal: AbortSignal.timeout(DEFAULT_FETCH_TIMEOUT_MS) },
167
+ );
168
+
169
+ if (!response.ok) {
170
+ throw new Error(`Hugging Face API error: ${response.status}`);
171
+ }
172
+
173
+ const body = (await response.json()) as Array<{
174
+ id: string;
175
+ modelId?: string;
176
+ }>;
177
+
178
+ const models = Array.isArray(body) ? body.slice(0, 50) : [];
179
+ return models.map((m): ProviderModelConfig => {
180
+ const id = m.id || m.modelId || "unknown";
181
+ return {
182
+ id,
183
+ name: id.split("/").pop() || "Unknown",
184
+ reasoning: false,
185
+ input: ["text"],
186
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
187
+ contextWindow: 4096,
188
+ maxTokens: 2048,
189
+ };
190
+ });
191
+ }
192
+
193
+ // =============================================================================
194
+ // Provider Definitions
195
+ // =============================================================================
196
+
197
+ interface DynamicProviderDef {
198
+ providerId: string;
199
+ getApiKey: () => string | undefined;
200
+ baseUrl: string;
201
+ api: Api;
202
+ defaultShowPaid: boolean | (() => boolean);
203
+ /** Optional per-provider compat overrides (e.g., DeepSeek proxy). */
204
+ compat?: ProviderModelConfig["compat"];
205
+ /** Per-model field defaults when the API doesn't expose them. */
206
+ modelDefaults?: Partial<ProviderModelConfig>;
207
+ /**
208
+ * Custom model fetcher (e.g., OpenRouter uses its own pricing-aware fetcher).
209
+ * When not provided, fetchModelsFromEndpoint is used (no pricing, _pricingKnown=false).
210
+ */
211
+ fetchModels?: (apiKey: string) => Promise<ProviderModelConfig[]>;
212
+ /**
213
+ * Optional probe support for providers whose free model status expires.
214
+ */
215
+ probe?: {
216
+ run: (
217
+ apiKey: string,
218
+ models: ProviderModelConfig[],
219
+ options?: { useCache?: boolean },
220
+ ) => Promise<string[]>;
221
+ };
222
+ }
223
+
224
+ const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
225
+ {
226
+ providerId: "mistral",
227
+ getApiKey: getMistralApiKey,
228
+ baseUrl: "https://api.mistral.ai/v1",
229
+ api: "openai-completions",
230
+ defaultShowPaid: false,
231
+ modelDefaults: { contextWindow: 32_768, maxTokens: 16_384 },
232
+ },
233
+ {
234
+ providerId: "groq",
235
+ getApiKey: getGroqApiKey,
236
+ baseUrl: "https://api.groq.com/openai/v1",
237
+ api: "openai-completions",
238
+ defaultShowPaid: false,
239
+ },
240
+ {
241
+ providerId: "cerebras",
242
+ getApiKey: getCerebrasApiKey,
243
+ baseUrl: "https://api.cerebras.ai/v1",
244
+ api: "openai-completions",
245
+ defaultShowPaid: false,
246
+ },
247
+ {
248
+ providerId: "xai",
249
+ getApiKey: getXaiApiKey,
250
+ baseUrl: "https://api.x.ai/v1",
251
+ api: "openai-completions",
252
+ defaultShowPaid: false,
253
+ },
254
+ {
255
+ providerId: "opencode",
256
+ getApiKey: getOpencodeApiKey,
257
+ baseUrl: "https://opencode.ai/zen/v1",
258
+ api: OPENCODE_DYNAMIC_API,
259
+ defaultShowPaid: getOpencodeShowPaid,
260
+ // OpenCode API returns no pricing — _pricingKnown=false, name-based detection
261
+ probe: {
262
+ run: (apiKey, models) =>
263
+ runOpenCodeProbe(
264
+ "opencode",
265
+ apiKey,
266
+ "https://opencode.ai/zen/v1",
267
+ models,
268
+ ),
269
+ },
270
+ },
271
+ {
272
+ providerId: "opencode-go",
273
+ getApiKey: getOpencodeApiKey,
274
+ baseUrl: "https://opencode.ai/zen/go/v1",
275
+ api: OPENCODE_DYNAMIC_API,
276
+ defaultShowPaid: getOpencodeShowPaid,
277
+ // OpenCode Go uses the same OPENCODE_API_KEY and per-request headers
278
+ probe: {
279
+ run: (apiKey, models) =>
280
+ runOpenCodeProbe(
281
+ "opencode-go",
282
+ apiKey,
283
+ "https://opencode.ai/zen/go/v1",
284
+ models,
285
+ ),
286
+ },
287
+ },
288
+ {
289
+ providerId: "openrouter",
290
+ getApiKey: getOpenrouterApiKey,
291
+ baseUrl: "https://openrouter.ai/api/v1",
292
+ api: "openai-completions",
293
+ defaultShowPaid: getOpenrouterShowPaid,
294
+ // OpenRouter returns full pricing — use its dedicated fetcher
295
+ fetchModels: (apiKey) =>
296
+ fetchOpenRouterCompatibleModels({
297
+ providerId: "openrouter",
298
+ baseUrl: "https://openrouter.ai/api/v1",
299
+ apiKey,
300
+ freeOnly: false,
301
+ }),
302
+ },
303
+ ];
304
+
305
+ // =============================================================================
306
+ // Discovery + Registration per Provider
307
+ // =============================================================================
308
+
309
+ async function discoverAndRegister(
310
+ pi: ExtensionAPI,
311
+ config: DynamicProviderDef,
312
+ apiKey: string,
313
+ ): Promise<void> {
314
+ // Use the shared cache-first loader so dynamic providers (e.g. always-discovered
315
+ // FastRouter) don't block startup on a network fetch. The helper serves a fresh
316
+ // cache, falls back to stale cache on failure/empty fetch, and persists the
317
+ // result for next startup.
318
+ const models = await loadCachedOrFetchModels(
319
+ config.providerId,
320
+ async () => {
321
+ let allModels: ProviderModelConfig[];
322
+ if (config.fetchModels) {
323
+ allModels = await config.fetchModels(apiKey);
324
+ } else {
325
+ allModels = await fetchModelsFromEndpoint({
326
+ providerId: config.providerId,
327
+ baseUrl: config.baseUrl,
328
+ apiKey,
329
+ compat: config.compat,
330
+ modelDefaults: config.modelDefaults,
331
+ timeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
332
+ });
333
+ }
334
+
335
+ // Apply DeepSeek proxy compat to matching models. OpenCode headers are
336
+ // injected per request by createOpenCodeStreamSimple(), not stored here.
337
+ return allModels.map((m) => ({
338
+ ...m,
339
+ api: isOpenCodeProvider(config.providerId)
340
+ ? OPENCODE_DYNAMIC_API
341
+ : m.api,
342
+ compat: getProxyModelCompat(m) ?? m.compat,
343
+ }));
344
+ },
345
+ );
346
+
347
+ if (models.length === 0) {
348
+ // No usable models and no fallback cache: leave Pi's built-in defaults.
349
+ _logger.info(
350
+ `[dynamic] ${config.providerId}: no models available; Pi keeps its defaults`,
351
+ );
352
+ return;
353
+ }
354
+
355
+ await registerProvider(pi, config, models, apiKey);
356
+ }
357
+
358
+ // =============================================================================
359
+ // OpenCode Probe
360
+ // =============================================================================
361
+
362
+ /**
363
+ * Probe a single OpenCode model with a minimal chat request.
364
+ *
365
+ * OpenCode expired free promotions return 401 with a body like:
366
+ * { error: { message: "Free promotion has ended" } }
367
+ *
368
+ * We treat 401 and 403 as "broken" for free models, since those codes mean
369
+ * the model is no longer accessible under the current credentials. 404 is
370
+ * also broken. 429 means the model is reachable but rate-limited (ok).
371
+ */
372
+ async function probeOpenCodeModel(
373
+ apiKey: string,
374
+ baseUrl: string,
375
+ modelId: string,
376
+ ): Promise<"ok" | "broken" | "unknown"> {
377
+ try {
378
+ const response = await fetchWithTimeout(
379
+ `${baseUrl}/chat/completions`,
380
+ {
381
+ method: "POST",
382
+ headers: {
383
+ Authorization: `Bearer ${apiKey}`,
384
+ "Content-Type": "application/json",
385
+ "User-Agent": "opencode/1.15.5",
386
+ "x-opencode-client": "cli",
387
+ },
388
+ body: JSON.stringify({
389
+ model: modelId,
390
+ messages: [{ role: "user", content: "hi" }],
391
+ max_tokens: 1,
392
+ }),
393
+ },
394
+ OPENCODE_PROBE_TIMEOUT_MS,
395
+ );
396
+
397
+ if (response.status === 401 || response.status === 403) return "broken";
398
+ if (response.status === 404) return "broken";
399
+ if (response.status === 429) return "ok";
400
+ if (response.ok) return "ok";
401
+ return "ok";
402
+ } catch {
403
+ return "unknown";
404
+ }
405
+ }
406
+
407
+ async function runOpenCodeProbe(
408
+ providerId: string,
409
+ apiKey: string,
410
+ baseUrl: string,
411
+ models: ProviderModelConfig[],
412
+ options: { useCache?: boolean } = {},
413
+ ): Promise<string[]> {
414
+ const freeModels = models.filter((m) =>
415
+ isFreeModel({ ...m, provider: providerId }, models),
416
+ );
417
+ const modelIdsToProbe = options.useCache
418
+ ? new Set(
419
+ getModelsDueForProbe(
420
+ providerId,
421
+ freeModels.map((m) => m.id),
422
+ ),
423
+ )
424
+ : undefined;
425
+ const probeCandidates = modelIdsToProbe
426
+ ? freeModels.filter((m) => modelIdsToProbe.has(m.id))
427
+ : freeModels;
428
+
429
+ if (probeCandidates.length === 0) {
430
+ _logger.info(`Auto-probe: ${providerId} probe cache is fresh`);
431
+ return [];
432
+ }
433
+
434
+ const broken: string[] = [];
435
+ const cacheableResults: Array<{ modelId: string; status: "ok" | "broken" }> =
436
+ [];
437
+ const batchSize = 5;
438
+
439
+ for (let i = 0; i < probeCandidates.length; i += batchSize) {
440
+ const batch = probeCandidates.slice(i, i + batchSize);
441
+ const results = await Promise.all(
442
+ batch.map(async (m) => {
443
+ const status = await probeOpenCodeModel(apiKey, baseUrl, m.id);
444
+ return { id: m.id, status };
445
+ }),
446
+ );
447
+ for (const r of results) {
448
+ if (r.status === "broken") broken.push(r.id);
449
+ if (r.status !== "unknown") {
450
+ cacheableResults.push({ modelId: r.id, status: r.status });
451
+ }
452
+ }
453
+ }
454
+
455
+ await recordModelProbeResults(providerId, cacheableResults);
456
+ return broken;
457
+ }
458
+
459
+ async function discoverAndRegisterHF(
460
+ pi: ExtensionAPI,
461
+ apiKey: string,
462
+ ): Promise<void> {
463
+ const config: DynamicProviderDef = {
464
+ providerId: "huggingface",
465
+ getApiKey: getHfToken,
466
+ baseUrl: "https://api-inference.huggingface.co",
467
+ api: "openai-completions",
468
+ defaultShowPaid: false,
469
+ };
470
+
471
+ let allModels: ProviderModelConfig[];
472
+ try {
473
+ allModels = await fetchHuggingFaceModels(apiKey);
474
+ } catch (error) {
475
+ _logger.info(
476
+ "[dynamic] huggingface: discovery failed, Pi keeps its defaults",
477
+ { error: error instanceof Error ? error.message : String(error) },
478
+ );
479
+ return;
480
+ }
481
+
482
+ await registerProvider(pi, config, allModels, apiKey);
483
+ }
484
+
485
+ // =============================================================================
486
+ // Registration Logic (sets up toggles, commands, status bar)
487
+ // =============================================================================
488
+
489
+ async function registerProvider(
490
+ pi: ExtensionAPI,
491
+ config: DynamicProviderDef,
492
+ allModels: ProviderModelConfig[],
493
+ apiKey: string,
494
+ ): Promise<void> {
495
+ const freeModels = allModels.filter((m) =>
496
+ isFreeModel({ ...m, provider: config.providerId }, allModels),
497
+ );
498
+
499
+ _logger.info(
500
+ `[dynamic] ${config.providerId}: ${allModels.length} total, ${freeModels.length} free`,
501
+ );
502
+
503
+ // Re-register function: called by toggle and initial apply
504
+ const reRegister = (models: ProviderModelConfig[]) => {
505
+ pi.registerProvider(config.providerId, {
506
+ baseUrl: config.baseUrl,
507
+ apiKey,
508
+ api: config.api,
509
+ ...(isOpenCodeProvider(config.providerId)
510
+ ? { streamSimple: createOpenCodeStreamSimple(_opencodeSession) }
511
+ : {}),
512
+ models: enhanceWithCI(models, config.providerId),
513
+ });
514
+ };
515
+
516
+ const stored: { free: ProviderModelConfig[]; all: ProviderModelConfig[] } = {
517
+ free: freeModels,
518
+ all: allModels,
519
+ };
520
+
521
+ /**
522
+ * Hide broken free models in config and update the active model list.
523
+ */
524
+ async function hideBrokenModels(brokenIds: string[]): Promise<void> {
525
+ if (brokenIds.length === 0) return;
526
+
527
+ await updateConfig((cfg) => {
528
+ const existingHidden = new Set(cfg.hidden_models ?? []);
529
+ for (const id of brokenIds) {
530
+ existingHidden.add(`${config.providerId}/${id}`);
531
+ }
532
+ return { hidden_models: Array.from(existingHidden) };
533
+ });
534
+
535
+ stored.all = stored.all.filter((m) => !brokenIds.includes(m.id));
536
+ stored.free = stored.free.filter((m) => !brokenIds.includes(m.id));
537
+ toggleState.setModels(stored);
538
+ toggleState.applyCurrent(reRegister);
539
+
540
+ _logger.info(
541
+ `[dynamic] ${config.providerId}: hidden ${brokenIds.length} broken models`,
542
+ );
543
+ }
544
+
545
+ // Toggle state
546
+ const toggleState = createToggleState({
547
+ providerId: config.providerId,
548
+ initialShowPaid:
549
+ typeof config.defaultShowPaid === "function"
550
+ ? config.defaultShowPaid()
551
+ : config.defaultShowPaid,
552
+ initialModels: { free: freeModels, all: allModels },
553
+ });
554
+
555
+ // Toggle command
556
+ pi.registerCommand(`toggle-${config.providerId}`, {
557
+ description: `Toggle between free and all ${config.providerId} models`,
558
+ handler: async (_args, ctx) => {
559
+ const applied = toggleState.toggle(reRegister);
560
+ ctx.ui.notify(
561
+ applied.mode === "all"
562
+ ? `${config.providerId}: showing all ${allModels.length} models`
563
+ : `${config.providerId}: showing ${freeModels.length} free models`,
564
+ "info",
565
+ );
566
+ },
567
+ });
568
+
569
+ // Global toggle
570
+ registerWithGlobalToggle(
571
+ config.providerId,
572
+ { free: freeModels, all: allModels },
573
+ reRegister,
574
+ true,
575
+ );
576
+
577
+ // Register models (this swaps in our discovered models over Pi's defaults)
578
+ toggleState.applyCurrent(reRegister);
579
+
580
+ // ── Probe command for providers whose free model status expires ─────
581
+ if (config.probe) {
582
+ pi.registerCommand(`probe-${config.providerId}`, {
583
+ description: `Test ${config.providerId} free models for expired promotions`,
584
+ handler: async (_args, ctx) => {
585
+ const modelsToTest =
586
+ toggleState.getCurrentMode() === "all" ? stored.all : stored.free;
587
+ ctx.ui.notify(
588
+ `Probing ${modelsToTest.length} ${config.providerId} models…`,
589
+ "info",
590
+ );
591
+
592
+ const broken = await config.probe!.run(apiKey, modelsToTest, {
593
+ useCache: false,
594
+ });
595
+
596
+ if (broken.length === 0) {
597
+ ctx.ui.notify(
598
+ `All ${config.providerId} models are accessible ✅`,
599
+ "info",
600
+ );
601
+ return;
602
+ }
603
+
604
+ ctx.ui.notify(
605
+ `Found ${broken.length} expired free models:\n${broken.join("\n")}`,
606
+ "warning",
607
+ );
608
+ await hideBrokenModels(broken);
609
+ },
610
+ });
611
+
612
+ // ── Lazy auto-probe on first session_start ───────────────────────
613
+ let _autoProbeDone = false;
614
+ pi.on(
615
+ "session_start",
616
+ wrapSessionStartHandler(`${config.providerId}-auto-probe`, async () => {
617
+ if (_autoProbeDone) return;
618
+ _autoProbeDone = true;
619
+ if (
620
+ areAllModelsFresh(
621
+ config.providerId,
622
+ stored.free.map((m) => m.id),
623
+ )
624
+ ) {
625
+ _logger.info(
626
+ `[probe] ${config.providerId}: auto-probe cache is fresh`,
627
+ );
628
+ return;
629
+ }
630
+ _logger.info(
631
+ `Starting lazy auto-probe of ${config.providerId} free models...`,
632
+ );
633
+ try {
634
+ const broken = await config.probe!.run(apiKey, stored.free, {
635
+ useCache: true,
636
+ });
637
+ await hideBrokenModels(broken);
638
+ } catch (err) {
639
+ _logger.warn("Auto-probe failed", {
640
+ error: err instanceof Error ? err.message : String(err),
641
+ });
642
+ }
643
+ }),
644
+ );
645
+ }
646
+
647
+ _logger.info(`[dynamic] ${config.providerId}: registered`);
648
+ }
649
+
650
+ // =============================================================================
651
+ // Main Entry
652
+ // =============================================================================
653
+
654
+ /**
655
+ * Kick off model discovery for all configured providers.
656
+ * Runs each fetch concurrently so startup waits for the slowest provider,
657
+ * not `n * provider latency`.
658
+ *
659
+ * Pi flushes provider registrations after async extension startup completes,
660
+ * so this function must await discovery before returning. Otherwise late
661
+ * pi.registerProvider() calls may not be visible to startup flows such as
662
+ * `pi --list-models` or the initial model picker.
663
+ */
664
+ export async function setupDynamicBuiltInProviders(
665
+ pi: ExtensionAPI,
666
+ ): Promise<void> {
667
+ const fetchers: Promise<void>[] = [];
668
+
669
+ for (const config of DYNAMIC_PROVIDERS) {
670
+ const apiKey = config.getApiKey();
671
+ if (!apiKey) continue;
672
+ fetchers.push(discoverAndRegister(pi, config, apiKey));
673
+ }
674
+
675
+ const hfKey = getHfToken();
676
+ if (hfKey) {
677
+ fetchers.push(discoverAndRegisterHF(pi, hfKey));
678
+ }
679
+
680
+ // FastRouter: always discovered (model listing needs no auth), but Pi
681
+ // requires a non-empty apiKey/env-var name when replacing a provider's models.
682
+ // Use the real configured key when present; otherwise register with the env
683
+ // var name so startup does not fail for users who have not configured it yet.
684
+ const fastrouterApiKey = getFastrouterApiKey();
685
+ fetchers.push(
686
+ discoverAndRegister(
687
+ pi,
688
+ {
689
+ providerId: "fastrouter",
690
+ getApiKey: getFastrouterApiKey,
691
+ baseUrl: "https://api.fastrouter.ai/api/v1",
692
+ api: "openai-completions",
693
+ defaultShowPaid: getFastrouterShowPaid,
694
+ fetchModels: () =>
695
+ fetchOpenRouterCompatibleModels({
696
+ providerId: "fastrouter",
697
+ baseUrl: "https://api.fastrouter.ai/api/v1",
698
+ apiKey: fastrouterApiKey,
699
+ freeOnly: false,
700
+ }),
701
+ },
702
+ fastrouterApiKey ?? "$FASTROUTER_API_KEY",
703
+ ),
704
+ );
705
+
706
+ if (fetchers.length === 0) return;
707
+
708
+ _logger.info(
709
+ `[dynamic] Kicking off discovery for ${fetchers.length} providers (concurrent)...`,
710
+ );
711
+
712
+ const results = await Promise.allSettled(fetchers);
713
+ const succeeded = results.filter((r) => r.status === "fulfilled").length;
714
+ const failed = results.filter((r) => r.status === "rejected").length;
715
+ _logger.info(
716
+ `[dynamic] Discovery complete: ${succeeded} succeeded, ${failed} failed/rejected`,
717
+ );
718
+ }