pi-free 2.0.10 → 2.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,206 +1,208 @@
1
- /**
2
- * DeepInfra Provider Extension
3
- *
4
- * DeepInfra is an AI inference cloud with an OpenAI-compatible API for
5
- * 100+ open-source models (Llama, DeepSeek, Mistral, Qwen, Mixtral, etc.).
6
- *
7
- * NOTE: DeepInfra's /v1/openai/models buries real model data in a "metadata"
8
- * field (context_length, max_tokens, pricing, tags). We extract it here.
9
- * Pricing is per-MILLION tokens.
10
- *
11
- * Free tier:
12
- * - $5 one-time credit on signup (no credit card)
13
- * - ~5M tokens, expires after 90 days
14
- * - 60 RPM (varies by model)
15
- *
16
- * Paid: pay-per-token after credits exhaust
17
- *
18
- * Endpoint:
19
- * Chat: https://api.deepinfra.com/v1/openai/chat/completions
20
- *
21
- * Setup:
22
- * 1. Sign up at https://deepinfra.com/ (GitHub or email)
23
- * 2. Get API key from https://deepinfra.com/dash/api_keys
24
- * 3. Set DEEPINFRA_TOKEN env var (or add to ~/.pi/free.json)
25
- *
26
- * Usage:
27
- * pi install git:github.com/apmantza/pi-free
28
- * # Set DEEPINFRA_TOKEN env var
29
- * # Models appear in /model selector as "deepinfra/meta-llama/..."
30
- */
31
-
32
- import type {
33
- ExtensionAPI,
34
- ProviderModelConfig,
35
- } from "@earendil-works/pi-coding-agent";
36
- import { getDeepinfraApiKey } from "../../config.ts";
37
- import {
38
- BASE_URL_DEEPINFRA,
39
- DEFAULT_FETCH_TIMEOUT_MS,
40
- PROVIDER_DEEPINFRA,
41
- } from "../../constants.ts";
42
- import { createLogger } from "../../lib/logger.ts";
43
- import {
44
- getProxyModelCompat,
45
- isLikelyReasoningModel,
46
- } from "../../lib/provider-compat.ts";
47
- import { registerWithGlobalToggle } from "../../lib/registry.ts";
48
- import { fetchWithRetry } from "../../lib/util.ts";
49
- import { createReRegister, setupProvider } from "../../provider-helper.ts";
50
-
51
- const _logger = createLogger("deepinfra");
52
-
53
- // =============================================================================
54
- // Types
55
- // =============================================================================
56
-
57
- interface DeepInfraModel {
58
- id: string;
59
- metadata?: {
60
- context_length?: number;
61
- max_tokens?: number;
62
- description?: string;
63
- pricing?: {
64
- input_tokens?: number;
65
- output_tokens?: number;
66
- };
67
- tags?: string[];
68
- };
69
- }
70
-
71
- // =============================================================================
72
- // Fetch
73
- // =============================================================================
74
-
75
- async function fetchDeepinfraModels(
76
- apiKey: string,
77
- ): Promise<ProviderModelConfig[]> {
78
- const response = await fetchWithRetry(
79
- `${BASE_URL_DEEPINFRA}/models`,
80
- {
81
- headers: {
82
- Authorization: `Bearer ${apiKey}`,
83
- "Content-Type": "application/json",
84
- },
85
- },
86
- 3,
87
- 1000,
88
- DEFAULT_FETCH_TIMEOUT_MS,
89
- );
90
-
91
- if (!response.ok) {
92
- throw new Error(
93
- `DeepInfra API error: ${response.status} ${response.statusText}`,
94
- );
95
- }
96
-
97
- const json = (await response.json()) as { data?: DeepInfraModel[] };
98
- const models = json.data ?? [];
99
-
100
- _logger.info(`[deepinfra] Fetched ${models.length} models`);
101
-
102
- return models
103
- .filter((m) => {
104
- const id = m.id.toLowerCase();
105
- // Filter out non-chat models
106
- if (id.includes("embed")) return false;
107
- if (id.includes("rerank")) return false;
108
- if (id.includes("whisper")) return false;
109
- if (id.includes("speech")) return false;
110
- return true;
111
- })
112
- .map((m): ProviderModelConfig => {
113
- const meta = m.metadata;
114
- const name = m.id.split("/").pop() || m.id;
115
-
116
- // Reasoning: check tags first, fall back to name heuristic
117
- const reasoning =
118
- meta?.tags?.includes("reasoning") ??
119
- isLikelyReasoningModel({ id: m.id, name });
120
-
121
- // Pricing is per-MILLION tokens. Divide to get per-token (Pi convention).
122
- const inputCost = (meta?.pricing?.input_tokens ?? 0.3) / 1_000_000;
123
- const outputCost = (meta?.pricing?.output_tokens ?? 0.9) / 1_000_000;
124
-
125
- return {
126
- id: m.id,
127
- name,
128
- reasoning,
129
- input: ["text"],
130
- cost: {
131
- input: inputCost,
132
- output: outputCost,
133
- cacheRead: 0,
134
- cacheWrite: 0,
135
- },
136
- contextWindow: meta?.context_length ?? 128_000,
137
- maxTokens: meta?.max_tokens ?? 16_384,
138
- compat: getProxyModelCompat({ id: m.id, name }),
139
- };
140
- });
141
- }
142
-
143
- // =============================================================================
144
- // Extension Entry Point
145
- // =============================================================================
146
-
147
- export default async function deepinfraProvider(pi: ExtensionAPI) {
148
- const apiKey = getDeepinfraApiKey();
149
-
150
- if (!apiKey) {
151
- _logger.info(
152
- "[deepinfra] Skipping — DEEPINFRA_TOKEN not set. Sign up at https://deepinfra.com/",
153
- );
154
- return;
155
- }
156
-
157
- // Fetch models
158
- const allModels = await fetchDeepinfraModels(apiKey);
159
-
160
- if (allModels.length === 0) {
161
- _logger.warn("[deepinfra] No chat models available");
162
- return;
163
- }
164
-
165
- // DeepInfra is a trial credit provider — $5 one-time credit, no truly free models.
166
- // All models are marked as paid. When free-only mode is ON, no models are shown.
167
- // Toggle free-only OFF to see all models.
168
- const freeModels: ProviderModelConfig[] = [];
169
- const stored = { free: freeModels, all: allModels };
170
-
171
- _logger.info(
172
- `[deepinfra] Registered ${allModels.length} chat models (trial credit, 0 free)`,
173
- );
174
-
175
- // Create re-register function
176
- const reRegister = createReRegister(pi, {
177
- providerId: PROVIDER_DEEPINFRA,
178
- baseUrl: BASE_URL_DEEPINFRA,
179
- apiKey,
180
- });
181
-
182
- // Register with global toggle
183
- registerWithGlobalToggle(PROVIDER_DEEPINFRA, stored, reRegister, true);
184
-
185
- // Setup provider with toggle command
186
- setupProvider(
187
- pi,
188
- {
189
- providerId: PROVIDER_DEEPINFRA,
190
- initialShowPaid: true, // trial credit: default to showing all models
191
- tosUrl: "https://deepinfra.com/pricing",
192
- reRegister: (models, _stored) => {
193
- if (_stored) {
194
- stored.free = _stored.free;
195
- stored.all = _stored.all;
196
- }
197
- reRegister(models);
198
- },
199
- },
200
- stored,
201
- );
202
-
203
- // Initial registration — DeepInfra is a trial-credit provider,
204
- // so always show all models. Users see them immediately on setup.
205
- reRegister(allModels);
206
- }
1
+ /**
2
+ * DeepInfra Provider Extension
3
+ *
4
+ * DeepInfra is an AI inference cloud with an OpenAI-compatible API for
5
+ * 100+ open-source models (Llama, DeepSeek, Mistral, Qwen, Mixtral, etc.).
6
+ *
7
+ * NOTE: DeepInfra's /v1/openai/models buries real model data in a "metadata"
8
+ * field (context_length, max_tokens, pricing, tags). We extract it here.
9
+ * Pricing is per-MILLION tokens.
10
+ *
11
+ * Free tier:
12
+ * - $5 one-time credit on signup (no credit card)
13
+ * - ~5M tokens, expires after 90 days
14
+ * - 60 RPM (varies by model)
15
+ *
16
+ * Paid: pay-per-token after credits exhaust
17
+ *
18
+ * Endpoint:
19
+ * Chat: https://api.deepinfra.com/v1/openai/chat/completions
20
+ *
21
+ * Setup:
22
+ * 1. Sign up at https://deepinfra.com/ (GitHub or email)
23
+ * 2. Get API key from https://deepinfra.com/dash/api_keys
24
+ * 3. Set DEEPINFRA_TOKEN env var (or add to ~/.pi/free.json)
25
+ *
26
+ * Usage:
27
+ * pi install git:github.com/apmantza/pi-free
28
+ * # Set DEEPINFRA_TOKEN env var
29
+ * # Models appear in /model selector as "deepinfra/meta-llama/..."
30
+ */
31
+
32
+ import type {
33
+ ExtensionAPI,
34
+ ProviderModelConfig,
35
+ } from "@earendil-works/pi-coding-agent";
36
+ import { getDeepinfraApiKey } from "../../config.ts";
37
+ import {
38
+ BASE_URL_DEEPINFRA,
39
+ DEFAULT_FETCH_TIMEOUT_MS,
40
+ PROVIDER_DEEPINFRA,
41
+ } from "../../constants.ts";
42
+ import { createLogger } from "../../lib/logger.ts";
43
+ import {
44
+ getProxyModelCompat,
45
+ isLikelyReasoningModel,
46
+ } from "../../lib/provider-compat.ts";
47
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
48
+ import { fetchWithRetry } from "../../lib/util.ts";
49
+ import { createReRegister, setupProvider } from "../../provider-helper.ts";
50
+
51
+ const _logger = createLogger("deepinfra");
52
+
53
+ // =============================================================================
54
+ // Types
55
+ // =============================================================================
56
+
57
+ interface DeepInfraModel {
58
+ id: string;
59
+ metadata?: {
60
+ context_length?: number;
61
+ max_tokens?: number;
62
+ description?: string;
63
+ pricing?: {
64
+ input_tokens?: number;
65
+ output_tokens?: number;
66
+ };
67
+ tags?: string[];
68
+ };
69
+ }
70
+
71
+ // =============================================================================
72
+ // Fetch
73
+ // =============================================================================
74
+
75
+ async function fetchDeepinfraModels(
76
+ apiKey: string,
77
+ ): Promise<ProviderModelConfig[]> {
78
+ const response = await fetchWithRetry(
79
+ `${BASE_URL_DEEPINFRA}/models`,
80
+ {
81
+ headers: {
82
+ Authorization: `Bearer ${apiKey}`,
83
+ "Content-Type": "application/json",
84
+ },
85
+ },
86
+ 3,
87
+ 1000,
88
+ DEFAULT_FETCH_TIMEOUT_MS,
89
+ );
90
+
91
+ if (!response.ok) {
92
+ throw new Error(
93
+ `DeepInfra API error: ${response.status} ${response.statusText}`,
94
+ );
95
+ }
96
+
97
+ const json = (await response.json()) as { data?: DeepInfraModel[] };
98
+ const models = json.data ?? [];
99
+
100
+ _logger.info(`[deepinfra] Fetched ${models.length} models`);
101
+
102
+ return models
103
+ .filter((m) => {
104
+ const id = m.id.toLowerCase();
105
+ // Filter out non-chat models
106
+ if (id.includes("embed")) return false;
107
+ if (id.includes("rerank")) return false;
108
+ if (id.includes("whisper")) return false;
109
+ if (id.includes("speech")) return false;
110
+ return true;
111
+ })
112
+ .map((m): ProviderModelConfig => {
113
+ const meta = m.metadata;
114
+ const name = m.id.split("/").pop() || m.id;
115
+
116
+ // Reasoning: check tags first, fall back to name heuristic
117
+ const reasoning =
118
+ meta?.tags?.includes("reasoning") ??
119
+ isLikelyReasoningModel({ id: m.id, name });
120
+
121
+ // Pricing is per-MILLION tokens. Divide to get per-token (Pi convention).
122
+ const inputCost = (meta?.pricing?.input_tokens ?? 0.3) / 1_000_000;
123
+ const outputCost = (meta?.pricing?.output_tokens ?? 0.9) / 1_000_000;
124
+
125
+ return {
126
+ id: m.id,
127
+ name,
128
+ reasoning,
129
+ input: ["text"],
130
+ cost: {
131
+ input: inputCost,
132
+ output: outputCost,
133
+ cacheRead: 0,
134
+ cacheWrite: 0,
135
+ },
136
+ contextWindow: meta?.context_length ?? 128_000,
137
+ maxTokens: meta?.max_tokens ?? 16_384,
138
+ compat: getProxyModelCompat({ id: m.id, name }),
139
+ _pricingKnown: meta?.pricing !== undefined,
140
+ } as ProviderModelConfig & { _pricingKnown?: boolean };
141
+ });
142
+ }
143
+
144
+ // =============================================================================
145
+ // Extension Entry Point
146
+ // =============================================================================
147
+
148
+ export default async function deepinfraProvider(pi: ExtensionAPI) {
149
+ const apiKey = getDeepinfraApiKey();
150
+
151
+ if (!apiKey) {
152
+ _logger.info(
153
+ "[deepinfra] Skipping — DEEPINFRA_TOKEN not set. Sign up at https://deepinfra.com/",
154
+ );
155
+ return;
156
+ }
157
+
158
+ // Fetch models
159
+ const allModels = await fetchDeepinfraModels(apiKey);
160
+
161
+ if (allModels.length === 0) {
162
+ _logger.warn("[deepinfra] No chat models available");
163
+ return;
164
+ }
165
+
166
+ // DeepInfra is a trial credit provider $5 one-time credit, no truly free models.
167
+ // Use isFreeModel for consistent detection across all providers.
168
+ const freeModels = allModels.filter((m) =>
169
+ isFreeModel({ ...m, provider: PROVIDER_DEEPINFRA }, allModels),
170
+ );
171
+ const stored = { free: freeModels, all: allModels };
172
+
173
+ _logger.info(
174
+ `[deepinfra] Registered ${allModels.length} chat models (trial credit, 0 free)`,
175
+ );
176
+
177
+ // Create re-register function
178
+ const reRegister = createReRegister(pi, {
179
+ providerId: PROVIDER_DEEPINFRA,
180
+ baseUrl: BASE_URL_DEEPINFRA,
181
+ apiKey,
182
+ });
183
+
184
+ // Register with global toggle
185
+ registerWithGlobalToggle(PROVIDER_DEEPINFRA, stored, reRegister, true);
186
+
187
+ // Setup provider with toggle command
188
+ setupProvider(
189
+ pi,
190
+ {
191
+ providerId: PROVIDER_DEEPINFRA,
192
+ initialShowPaid: true, // trial credit: default to showing all models
193
+ tosUrl: "https://deepinfra.com/pricing",
194
+ reRegister: (models, _stored) => {
195
+ if (_stored) {
196
+ stored.free = _stored.free;
197
+ stored.all = _stored.all;
198
+ }
199
+ reRegister(models);
200
+ },
201
+ },
202
+ stored,
203
+ );
204
+
205
+ // Initial registration — DeepInfra is a trial-credit provider,
206
+ // so always show all models. Users see them immediately on setup.
207
+ reRegister(allModels);
208
+ }
@@ -5,15 +5,18 @@
5
5
  * standard /models endpoints when the user has configured an API key.
6
6
  *
7
7
  * Uses a single generic fetch function instead of per-provider boilerplate.
8
- * Discovery runs concurrently with 1s timeout per provider, fire-and-forget
9
- * so extension init never blocks. Pi's built-in defaults serve until
10
- * discovery completes and replaces them.
8
+ * Discovery runs concurrently and is awaited by the extension entry point.
9
+ * Pi only flushes provider registrations after async extension startup, so
10
+ * dynamic providers must register before setup returns.
11
11
  *
12
12
  * Providers handled:
13
13
  * - mistral (MISTRAL_API_KEY)
14
14
  * - groq (GROQ_API_KEY)
15
15
  * - cerebras (CEREBRAS_API_KEY)
16
16
  * - xai (XAI_API_KEY)
17
+ * - opencode (OPENCODE_API_KEY from auth.json)
18
+ * - openrouter (OPENROUTER_API_KEY from auth.json)
19
+ * - fastrouter (always discovered, FASTROUTER_API_KEY)
17
20
  * - huggingface (HF_TOKEN - optional, special-cased API shape)
18
21
  *
19
22
  * OpenAI is intentionally skipped per user request.
@@ -25,14 +28,22 @@ import type {
25
28
  } from "@earendil-works/pi-coding-agent";
26
29
  import {
27
30
  getCerebrasApiKey,
31
+ getFastrouterApiKey,
32
+ getFastrouterShowPaid,
28
33
  getGroqApiKey,
29
34
  getHfToken,
30
35
  getMistralApiKey,
36
+ getOpencodeApiKey,
37
+ getOpencodeShowPaid,
38
+ getOpenrouterApiKey,
39
+ getOpenrouterShowPaid,
31
40
  getXaiApiKey,
32
41
  } from "../../config.ts";
42
+ import { DEFAULT_FETCH_TIMEOUT_MS } from "../../constants.ts";
33
43
  import { createLogger } from "../../lib/logger.ts";
34
44
  import { getProxyModelCompat } from "../../lib/provider-compat.ts";
35
45
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
46
+ import { fetchOpenRouterCompatibleModels } from "../model-fetcher.ts";
36
47
  import { createToggleState } from "../../lib/toggle-state.ts";
37
48
  import { enhanceWithCI } from "../../provider-helper.ts";
38
49
 
@@ -68,7 +79,7 @@ async function fetchModelsFromEndpoint(
68
79
 
69
80
  const response = await fetch(url, {
70
81
  headers,
71
- signal: AbortSignal.timeout(opts.timeoutMs ?? 1_000),
82
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS),
72
83
  });
73
84
 
74
85
  if (!response.ok) {
@@ -101,9 +112,10 @@ async function fetchModelsFromEndpoint(
101
112
  ((m.max_tokens ?? m.max_completion_tokens) as number) ??
102
113
  opts.modelDefaults?.maxTokens ??
103
114
  16_384,
115
+ _pricingKnown: false as boolean | undefined,
104
116
  ...opts.modelDefaults,
105
117
  ...(opts.compat ? { compat: opts.compat } : {}),
106
- } satisfies ProviderModelConfig;
118
+ } satisfies ProviderModelConfig & { _pricingKnown?: boolean };
107
119
  });
108
120
  }
109
121
 
@@ -123,7 +135,7 @@ async function fetchHuggingFaceModels(
123
135
 
124
136
  const response = await fetch(
125
137
  "https://api-inference.huggingface.co/models?pipeline_tag=text-generation&limit=50",
126
- { headers, signal: AbortSignal.timeout(1_000) },
138
+ { headers, signal: AbortSignal.timeout(DEFAULT_FETCH_TIMEOUT_MS) },
127
139
  );
128
140
 
129
141
  if (!response.ok) {
@@ -159,11 +171,16 @@ interface DynamicProviderDef {
159
171
  getApiKey: () => string | undefined;
160
172
  baseUrl: string;
161
173
  api: "openai-completions" | "mistral-conversations" | "anthropic-messages";
162
- defaultShowPaid: boolean;
174
+ defaultShowPaid: boolean | (() => boolean);
163
175
  /** Optional per-provider compat overrides (e.g., DeepSeek proxy). */
164
176
  compat?: ProviderModelConfig["compat"];
165
177
  /** Per-model field defaults when the API doesn't expose them. */
166
178
  modelDefaults?: Partial<ProviderModelConfig>;
179
+ /**
180
+ * Custom model fetcher (e.g., OpenRouter uses its own pricing-aware fetcher).
181
+ * When not provided, fetchModelsFromEndpoint is used (no pricing, _pricingKnown=false).
182
+ */
183
+ fetchModels?: (apiKey: string) => Promise<ProviderModelConfig[]>;
167
184
  }
168
185
 
169
186
  const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
@@ -196,6 +213,28 @@ const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
196
213
  api: "openai-completions",
197
214
  defaultShowPaid: false,
198
215
  },
216
+ {
217
+ providerId: "opencode",
218
+ getApiKey: getOpencodeApiKey,
219
+ baseUrl: "https://opencode.ai/zen/v1",
220
+ api: "openai-completions",
221
+ defaultShowPaid: getOpencodeShowPaid,
222
+ // OpenCode API returns no pricing — _pricingKnown=false, name-based detection
223
+ },
224
+ {
225
+ providerId: "openrouter",
226
+ getApiKey: getOpenrouterApiKey,
227
+ baseUrl: "https://openrouter.ai/api/v1",
228
+ api: "openai-completions",
229
+ defaultShowPaid: getOpenrouterShowPaid,
230
+ // OpenRouter returns full pricing — use its dedicated fetcher
231
+ fetchModels: (apiKey) =>
232
+ fetchOpenRouterCompatibleModels({
233
+ baseUrl: "https://openrouter.ai/api/v1",
234
+ apiKey,
235
+ freeOnly: false,
236
+ }),
237
+ },
199
238
  ];
200
239
 
201
240
  // =============================================================================
@@ -210,22 +249,27 @@ async function discoverAndRegister(
210
249
  let allModels: ProviderModelConfig[];
211
250
 
212
251
  try {
213
- allModels = await fetchModelsFromEndpoint({
214
- baseUrl: config.baseUrl,
215
- apiKey,
216
- compat: config.compat,
217
- modelDefaults: config.modelDefaults,
218
- timeoutMs: 1_000,
219
- });
252
+ if (config.fetchModels) {
253
+ allModels = await config.fetchModels(apiKey);
254
+ } else {
255
+ allModels = await fetchModelsFromEndpoint({
256
+ baseUrl: config.baseUrl,
257
+ apiKey,
258
+ compat: config.compat,
259
+ modelDefaults: config.modelDefaults,
260
+ timeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
261
+ });
262
+ }
220
263
 
221
264
  // Apply DeepSeek proxy compat to matching models
222
265
  allModels = allModels.map((m) => ({
223
266
  ...m,
224
267
  compat: getProxyModelCompat(m) ?? m.compat,
225
268
  }));
226
- } catch {
269
+ } catch (error) {
227
270
  _logger.info(
228
271
  `[dynamic] ${config.providerId}: discovery failed, Pi keeps its defaults`,
272
+ { error: error instanceof Error ? error.message : String(error) },
229
273
  );
230
274
  return;
231
275
  }
@@ -248,9 +292,10 @@ async function discoverAndRegisterHF(
248
292
  let allModels: ProviderModelConfig[];
249
293
  try {
250
294
  allModels = await fetchHuggingFaceModels(apiKey);
251
- } catch {
295
+ } catch (error) {
252
296
  _logger.info(
253
297
  "[dynamic] huggingface: discovery failed, Pi keeps its defaults",
298
+ { error: error instanceof Error ? error.message : String(error) },
254
299
  );
255
300
  return;
256
301
  }
@@ -289,7 +334,10 @@ async function registerProvider(
289
334
  // Toggle state
290
335
  const toggleState = createToggleState({
291
336
  providerId: config.providerId,
292
- initialShowPaid: config.defaultShowPaid,
337
+ initialShowPaid:
338
+ typeof config.defaultShowPaid === "function"
339
+ ? config.defaultShowPaid()
340
+ : config.defaultShowPaid,
293
341
  initialModels: { free: freeModels, all: allModels },
294
342
  });
295
343
 
@@ -341,16 +389,18 @@ async function registerProvider(
341
389
  }
342
390
 
343
391
  // =============================================================================
344
- // Main Entry — Fire-and-Forget
392
+ // Main Entry
345
393
  // =============================================================================
346
394
 
347
395
  /**
348
396
  * Kick off model discovery for all configured providers.
349
- * Runs each fetch concurrently with a 1s timeout so the worst-case
350
- * wall time is ~1s, not `n * 1s`. Extension init never blocks.
397
+ * Runs each fetch concurrently so startup waits for the slowest provider,
398
+ * not `n * provider latency`.
351
399
  *
352
- * Pi's built-in defaults serve until discovery completes and this
353
- * function replaces them via pi.registerProvider().
400
+ * Pi flushes provider registrations after async extension startup completes,
401
+ * so this function must await discovery before returning. Otherwise late
402
+ * pi.registerProvider() calls may not be visible to startup flows such as
403
+ * `pi --list-models` or the initial model picker.
354
404
  */
355
405
  export async function setupDynamicBuiltInProviders(
356
406
  pi: ExtensionAPI,
@@ -368,18 +418,41 @@ export async function setupDynamicBuiltInProviders(
368
418
  fetchers.push(discoverAndRegisterHF(pi, hfKey));
369
419
  }
370
420
 
421
+ // FastRouter: always discovered (model listing needs no auth), but Pi
422
+ // requires a non-empty apiKey/env-var name when replacing a provider's models.
423
+ // Use the real configured key when present; otherwise register with the env
424
+ // var name so startup does not fail for users who have not configured it yet.
425
+ const fastrouterApiKey = getFastrouterApiKey();
426
+ fetchers.push(
427
+ discoverAndRegister(
428
+ pi,
429
+ {
430
+ providerId: "fastrouter",
431
+ getApiKey: getFastrouterApiKey,
432
+ baseUrl: "https://api.fastrouter.ai/api/v1",
433
+ api: "openai-completions",
434
+ defaultShowPaid: getFastrouterShowPaid,
435
+ fetchModels: () =>
436
+ fetchOpenRouterCompatibleModels({
437
+ baseUrl: "https://api.fastrouter.ai/api/v1",
438
+ apiKey: fastrouterApiKey,
439
+ freeOnly: false,
440
+ }),
441
+ },
442
+ fastrouterApiKey ?? "FASTROUTER_API_KEY",
443
+ ),
444
+ );
445
+
371
446
  if (fetchers.length === 0) return;
372
447
 
373
448
  _logger.info(
374
- `[dynamic] Kicking off discovery for ${fetchers.length} providers (1s timeout each, concurrent)...`,
449
+ `[dynamic] Kicking off discovery for ${fetchers.length} providers (concurrent)...`,
375
450
  );
376
451
 
377
- // Fire-and-forget: log results, never block init
378
- void Promise.allSettled(fetchers).then((results) => {
379
- const succeeded = results.filter((r) => r.status === "fulfilled").length;
380
- const failed = results.filter((r) => r.status === "rejected").length;
381
- _logger.info(
382
- `[dynamic] Discovery complete: ${succeeded} succeeded, ${failed} failed/rejected`,
383
- );
384
- });
452
+ const results = await Promise.allSettled(fetchers);
453
+ const succeeded = results.filter((r) => r.status === "fulfilled").length;
454
+ const failed = results.filter((r) => r.status === "rejected").length;
455
+ _logger.info(
456
+ `[dynamic] Discovery complete: ${succeeded} succeeded, ${failed} failed/rejected`,
457
+ );
385
458
  }