pi-free 2.0.2 → 2.0.5

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,15 +1,20 @@
1
1
  /**
2
2
  * Dynamic Built-in Provider Fetcher
3
3
  *
4
- * Fetches models dynamically from Pi's built-in providers
5
- * when the user has configured an API key.
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 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.
6
11
  *
7
12
  * Providers handled:
8
13
  * - mistral (MISTRAL_API_KEY)
9
14
  * - groq (GROQ_API_KEY)
10
15
  * - cerebras (CEREBRAS_API_KEY)
11
16
  * - xai (XAI_API_KEY)
12
- * - huggingface (HF_TOKEN - optional)
17
+ * - huggingface (HF_TOKEN - optional, special-cased API shape)
13
18
  *
14
19
  * OpenAI is intentionally skipped per user request.
15
20
  */
@@ -25,249 +30,90 @@ import {
25
30
  getMistralApiKey,
26
31
  getXaiApiKey,
27
32
  } from "../../config.ts";
28
- import { DEFAULT_FETCH_TIMEOUT_MS } from "../../constants.ts";
29
33
  import { createLogger } from "../../lib/logger.ts";
34
+ import { getProxyModelCompat } from "../../lib/provider-compat.ts";
30
35
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
31
- import { fetchWithRetry } from "../../lib/util.ts";
36
+ import { createToggleState } from "../../lib/toggle-state.ts";
32
37
  import { enhanceWithCI } from "../../provider-helper.ts";
33
38
 
34
39
  const _logger = createLogger("dynamic-built-in");
35
40
 
36
41
  // =============================================================================
37
- // Provider Configurations
42
+ // Generic Model Fetcher
38
43
  // =============================================================================
39
44
 
40
- interface DynamicProviderConfig {
41
- providerId: string;
42
- getApiKey: () => string | undefined;
45
+ interface FetchModelsOptions {
43
46
  baseUrl: string;
44
- api: "openai-completions" | "mistral-conversations" | "anthropic-messages";
45
- fetchModels: (apiKey: string) => Promise<ProviderModelConfig[]>;
46
- defaultShowPaid: boolean;
47
+ apiKey: string;
48
+ compat?: ProviderModelConfig["compat"];
49
+ modelDefaults?: Partial<ProviderModelConfig>;
50
+ timeoutMs?: number;
47
51
  }
48
52
 
49
- // =============================================================================
50
- // Fetch Functions for Each Provider
51
- // =============================================================================
52
-
53
- async function fetchMistralModels(
54
- apiKey: string,
53
+ /**
54
+ * Fetch models from any standard {baseUrl}/models endpoint.
55
+ * Handles both OpenAI-style { object: "list", data: [...] } and plain arrays.
56
+ * Uses AbortSignal.timeout for non-retry, fail-fast behaviour.
57
+ */
58
+ async function fetchModelsFromEndpoint(
59
+ opts: FetchModelsOptions,
55
60
  ): Promise<ProviderModelConfig[]> {
56
- const response = await fetchWithRetry(
57
- "https://api.mistral.ai/v1/models",
58
- {
59
- headers: {
60
- Authorization: `Bearer ${apiKey}`,
61
- "Content-Type": "application/json",
62
- },
63
- },
64
- 3,
65
- 1000,
66
- DEFAULT_FETCH_TIMEOUT_MS,
67
- );
68
-
69
- if (!response.ok) {
70
- throw new Error(`Mistral API error: ${response.status}`);
71
- }
72
-
73
- const json = (await response.json()) as {
74
- data?: Array<{
75
- id: string;
76
- name?: string;
77
- capabilities?: {
78
- completion_chat?: boolean;
79
- completion_fim?: boolean;
80
- function_calling?: boolean;
81
- vision?: boolean;
82
- };
83
- max_context_length?: number;
84
- }>;
85
- };
86
-
87
- const models = json.data ?? [];
88
- _logger.info(`[dynamic] Fetched ${models.length} models from Mistral`);
89
-
90
- return models
91
- .filter((m) => m.capabilities?.completion_chat) // Only chat models
92
- .map(
93
- (m): ProviderModelConfig => ({
94
- id: m.id,
95
- name: m.name || m.id,
96
- reasoning: false, // Mistral doesn't expose this
97
- input: m.capabilities?.vision ? ["text", "image"] : ["text"],
98
- cost: {
99
- // Mistral pricing not exposed via API, use defaults
100
- input: 0,
101
- output: 0,
102
- cacheRead: 0,
103
- cacheWrite: 0,
104
- },
105
- contextWindow: m.max_context_length ?? 32768,
106
- maxTokens: m.max_context_length
107
- ? Math.floor(m.max_context_length / 2)
108
- : 4096,
109
- }),
110
- );
111
- }
112
-
113
- async function fetchGroqModels(apiKey: string): Promise<ProviderModelConfig[]> {
114
- const response = await fetchWithRetry(
115
- "https://api.groq.com/openai/v1/models",
116
- {
117
- headers: {
118
- Authorization: `Bearer ${apiKey}`,
119
- "Content-Type": "application/json",
120
- },
121
- },
122
- 3,
123
- 1000,
124
- DEFAULT_FETCH_TIMEOUT_MS,
125
- );
126
-
127
- if (!response.ok) {
128
- throw new Error(`Groq API error: ${response.status}`);
129
- }
130
-
131
- const json = (await response.json()) as {
132
- data?: Array<{
133
- id: string;
134
- object: string;
135
- owned_by?: string;
136
- context_window?: number;
137
- }>;
61
+ let cleanBase = opts.baseUrl;
62
+ while (cleanBase.endsWith("/")) cleanBase = cleanBase.slice(0, -1);
63
+ const url = `${cleanBase}/models`;
64
+ const headers: Record<string, string> = {
65
+ Accept: "application/json",
66
+ Authorization: `Bearer ${opts.apiKey}`,
138
67
  };
139
68
 
140
- const models = json.data?.filter((m) => m.object === "model") ?? [];
141
- _logger.info(`[dynamic] Fetched ${models.length} models from Groq`);
142
-
143
- return models.map(
144
- (m): ProviderModelConfig => ({
145
- id: m.id,
146
- name: m.id
147
- .split("-")
148
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
149
- .join(" "),
150
- reasoning: false,
151
- input: ["text"], // Groq models are text-only
152
- cost: {
153
- // Groq pricing not exposed via API
154
- input: 0,
155
- output: 0,
156
- cacheRead: 0,
157
- cacheWrite: 0,
158
- },
159
- contextWindow: m.context_window ?? 8192,
160
- maxTokens: m.context_window ? Math.floor(m.context_window / 2) : 4096,
161
- }),
162
- );
163
- }
164
-
165
- async function fetchCerebrasModels(
166
- apiKey: string,
167
- ): Promise<ProviderModelConfig[]> {
168
- // Cerebras has limited model list, fetch from their API
169
- const response = await fetchWithRetry(
170
- "https://api.cerebras.ai/v1/models",
171
- {
172
- headers: {
173
- Authorization: `Bearer ${apiKey}`,
174
- "Content-Type": "application/json",
175
- },
176
- },
177
- 3,
178
- 1000,
179
- DEFAULT_FETCH_TIMEOUT_MS,
180
- );
69
+ const response = await fetch(url, {
70
+ headers,
71
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 1_000),
72
+ });
181
73
 
182
74
  if (!response.ok) {
183
- throw new Error(`Cerebras API error: ${response.status}`);
75
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
184
76
  }
185
77
 
186
- const json = (await response.json()) as {
187
- data?: Array<{
188
- model?: string;
189
- model_type?: string;
190
- max_context_length?: number;
191
- }>;
192
- };
193
-
194
- const models = json.data ?? [];
195
- _logger.info(`[dynamic] Fetched ${models.length} models from Cerebras`);
196
-
197
- return models.map(
198
- (m): ProviderModelConfig => ({
199
- id: m.model || "unknown",
200
- name: m.model || "Unknown",
201
- reasoning: false,
202
- input: ["text"],
203
- cost: {
204
- input: 0,
205
- output: 0,
206
- cacheRead: 0,
207
- cacheWrite: 0,
208
- },
209
- contextWindow: m.max_context_length ?? 8192,
210
- maxTokens: m.max_context_length
211
- ? Math.floor(m.max_context_length / 2)
212
- : 4096,
213
- }),
214
- );
78
+ const body = (await response.json()) as
79
+ | Array<Record<string, unknown>>
80
+ | { data?: Array<Record<string, unknown>> };
81
+ const rawModels: Array<Record<string, unknown>> = Array.isArray(body)
82
+ ? body
83
+ : (body.data ?? []);
84
+
85
+ return rawModels.map((m) => {
86
+ const id = String(m.id ?? "");
87
+ const inputModalities = m.input_modalities as string[] | undefined;
88
+ return {
89
+ id,
90
+ name: (m.name as string) ?? (m.model as string) ?? id,
91
+ reasoning: !!(m.reasoning ?? false),
92
+ input: inputModalities?.includes("image")
93
+ ? (["text", "image"] as const)
94
+ : (["text"] as const),
95
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
96
+ contextWindow:
97
+ ((m.max_context_length ?? m.context_window) as number) ??
98
+ opts.modelDefaults?.contextWindow ??
99
+ 128_000,
100
+ maxTokens:
101
+ ((m.max_tokens ?? m.max_completion_tokens) as number) ??
102
+ opts.modelDefaults?.maxTokens ??
103
+ 16_384,
104
+ ...opts.modelDefaults,
105
+ ...(opts.compat ? { compat: opts.compat } : {}),
106
+ } satisfies ProviderModelConfig;
107
+ });
215
108
  }
216
109
 
217
- async function fetchXAIModels(apiKey: string): Promise<ProviderModelConfig[]> {
218
- const response = await fetchWithRetry(
219
- "https://api.x.ai/v1/models",
220
- {
221
- headers: {
222
- Authorization: `Bearer ${apiKey}`,
223
- "Content-Type": "application/json",
224
- },
225
- },
226
- 3,
227
- 1000,
228
- DEFAULT_FETCH_TIMEOUT_MS,
229
- );
230
-
231
- if (!response.ok) {
232
- throw new Error(`xAI API error: ${response.status}`);
233
- }
234
-
235
- const json = (await response.json()) as {
236
- data?: Array<{
237
- id: string;
238
- model?: string;
239
- input_modalities?: string[];
240
- }>;
241
- };
242
-
243
- const models = json.data ?? [];
244
- _logger.info(`[dynamic] Fetched ${models.length} models from xAI`);
245
-
246
- return models.map(
247
- (m): ProviderModelConfig => ({
248
- id: m.id,
249
- name: m.model || m.id,
250
- reasoning: false,
251
- input: m.input_modalities?.includes("image")
252
- ? ["text", "image"]
253
- : ["text"],
254
- cost: {
255
- input: 0,
256
- output: 0,
257
- cacheRead: 0,
258
- cacheWrite: 0,
259
- },
260
- contextWindow: 128000, // xAI default
261
- maxTokens: 4096,
262
- }),
263
- );
264
- }
110
+ // =============================================================================
111
+ // Hugging Face (special-cased: non-standard API shape)
112
+ // =============================================================================
265
113
 
266
114
  async function fetchHuggingFaceModels(
267
115
  apiKey?: string,
268
116
  ): Promise<ProviderModelConfig[]> {
269
- // Hugging Face has a public model list, no auth required for listing
270
- // But with auth we get better rate limits
271
117
  const headers: Record<string, string> = {
272
118
  "Content-Type": "application/json",
273
119
  };
@@ -275,56 +121,59 @@ async function fetchHuggingFaceModels(
275
121
  headers.Authorization = `Bearer ${apiKey}`;
276
122
  }
277
123
 
278
- // Hugging Face inference API models endpoint
279
- const response = await fetchWithRetry(
280
- "https://api-inference.huggingface.co/models?pipeline_tag=text-generation&limit=100",
281
- { headers },
282
- 3,
283
- 1000,
284
- DEFAULT_FETCH_TIMEOUT_MS,
124
+ const response = await fetch(
125
+ "https://api-inference.huggingface.co/models?pipeline_tag=text-generation&limit=50",
126
+ { headers, signal: AbortSignal.timeout(1_000) },
285
127
  );
286
128
 
287
129
  if (!response.ok) {
288
130
  throw new Error(`Hugging Face API error: ${response.status}`);
289
131
  }
290
132
 
291
- const json = (await response.json()) as Array<{
133
+ const body = (await response.json()) as Array<{
292
134
  id: string;
293
135
  modelId?: string;
294
136
  }>;
295
137
 
296
- const models = Array.isArray(json) ? json.slice(0, 50) : []; // Limit to 50
297
- _logger.info(`[dynamic] Fetched ${models.length} models from Hugging Face`);
298
-
299
- return models.map(
300
- (m): ProviderModelConfig => ({
301
- id: m.id || m.modelId || "unknown",
302
- name: (m.id || m.modelId || "unknown").split("/").pop() || "Unknown",
138
+ const models = Array.isArray(body) ? body.slice(0, 50) : [];
139
+ return models.map((m): ProviderModelConfig => {
140
+ const id = m.id || m.modelId || "unknown";
141
+ return {
142
+ id,
143
+ name: id.split("/").pop() || "Unknown",
303
144
  reasoning: false,
304
145
  input: ["text"],
305
- cost: {
306
- input: 0,
307
- output: 0,
308
- cacheRead: 0,
309
- cacheWrite: 0,
310
- },
146
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
311
147
  contextWindow: 4096,
312
148
  maxTokens: 2048,
313
- }),
314
- );
149
+ };
150
+ });
315
151
  }
316
152
 
317
153
  // =============================================================================
318
- // Provider Configurations Map
154
+ // Provider Definitions
319
155
  // =============================================================================
320
156
 
321
- const DYNAMIC_PROVIDERS: Omit<DynamicProviderConfig, "fetchModels">[] = [
157
+ interface DynamicProviderDef {
158
+ providerId: string;
159
+ getApiKey: () => string | undefined;
160
+ baseUrl: string;
161
+ api: "openai-completions" | "mistral-conversations" | "anthropic-messages";
162
+ defaultShowPaid: boolean;
163
+ /** Optional per-provider compat overrides (e.g., DeepSeek proxy). */
164
+ compat?: ProviderModelConfig["compat"];
165
+ /** Per-model field defaults when the API doesn't expose them. */
166
+ modelDefaults?: Partial<ProviderModelConfig>;
167
+ }
168
+
169
+ const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
322
170
  {
323
171
  providerId: "mistral",
324
172
  getApiKey: getMistralApiKey,
325
173
  baseUrl: "https://api.mistral.ai/v1",
326
174
  api: "openai-completions",
327
175
  defaultShowPaid: false,
176
+ modelDefaults: { contextWindow: 32_768, maxTokens: 16_384 },
328
177
  },
329
178
  {
330
179
  providerId: "groq",
@@ -347,87 +196,190 @@ const DYNAMIC_PROVIDERS: Omit<DynamicProviderConfig, "fetchModels">[] = [
347
196
  api: "openai-completions",
348
197
  defaultShowPaid: false,
349
198
  },
350
- {
199
+ ];
200
+
201
+ // =============================================================================
202
+ // Discovery + Registration per Provider
203
+ // =============================================================================
204
+
205
+ async function discoverAndRegister(
206
+ pi: ExtensionAPI,
207
+ config: DynamicProviderDef,
208
+ apiKey: string,
209
+ ): Promise<void> {
210
+ let allModels: ProviderModelConfig[];
211
+
212
+ try {
213
+ allModels = await fetchModelsFromEndpoint({
214
+ baseUrl: config.baseUrl,
215
+ apiKey,
216
+ compat: config.compat,
217
+ modelDefaults: config.modelDefaults,
218
+ timeoutMs: 1_000,
219
+ });
220
+
221
+ // Apply DeepSeek proxy compat to matching models
222
+ allModels = allModels.map((m) => ({
223
+ ...m,
224
+ compat: getProxyModelCompat(m) ?? m.compat,
225
+ }));
226
+ } catch {
227
+ _logger.info(
228
+ `[dynamic] ${config.providerId}: discovery failed, Pi keeps its defaults`,
229
+ );
230
+ return;
231
+ }
232
+
233
+ await registerProvider(pi, config, allModels, apiKey);
234
+ }
235
+
236
+ async function discoverAndRegisterHF(
237
+ pi: ExtensionAPI,
238
+ apiKey: string,
239
+ ): Promise<void> {
240
+ const config: DynamicProviderDef = {
351
241
  providerId: "huggingface",
352
242
  getApiKey: getHfToken,
353
243
  baseUrl: "https://api-inference.huggingface.co",
354
244
  api: "openai-completions",
355
245
  defaultShowPaid: false,
356
- },
357
- ];
246
+ };
358
247
 
359
- // Map provider IDs to their fetch functions
360
- const FETCH_FUNCTIONS: Record<
361
- string,
362
- (apiKey: string) => Promise<ProviderModelConfig[]>
363
- > = {
364
- mistral: fetchMistralModels,
365
- groq: fetchGroqModels,
366
- cerebras: fetchCerebrasModels,
367
- xai: fetchXAIModels,
368
- huggingface: fetchHuggingFaceModels,
369
- };
248
+ let allModels: ProviderModelConfig[];
249
+ try {
250
+ allModels = await fetchHuggingFaceModels(apiKey);
251
+ } catch {
252
+ _logger.info(
253
+ "[dynamic] huggingface: discovery failed, Pi keeps its defaults",
254
+ );
255
+ return;
256
+ }
257
+
258
+ await registerProvider(pi, config, allModels, apiKey);
259
+ }
370
260
 
371
261
  // =============================================================================
372
- // Main Setup Function
262
+ // Registration Logic (sets up toggles, commands, status bar)
373
263
  // =============================================================================
374
264
 
375
- export async function setupDynamicBuiltInProviders(
265
+ async function registerProvider(
376
266
  pi: ExtensionAPI,
267
+ config: DynamicProviderDef,
268
+ allModels: ProviderModelConfig[],
269
+ apiKey: string,
377
270
  ): Promise<void> {
378
- _logger.info("[dynamic] Setting up dynamic built-in providers...");
271
+ const freeModels = allModels.filter((m) =>
272
+ isFreeModel({ ...m, provider: config.providerId }, allModels),
273
+ );
379
274
 
380
- for (const config of DYNAMIC_PROVIDERS) {
381
- const apiKey = config.getApiKey();
275
+ _logger.info(
276
+ `[dynamic] ${config.providerId}: ${allModels.length} total, ${freeModels.length} free`,
277
+ );
382
278
 
383
- if (!apiKey) {
384
- _logger.info(
385
- `[dynamic] Skipping ${config.providerId} - no API key configured`,
279
+ // Re-register function: called by toggle and initial apply
280
+ const reRegister = (models: ProviderModelConfig[]) => {
281
+ pi.registerProvider(config.providerId, {
282
+ baseUrl: config.baseUrl,
283
+ apiKey,
284
+ api: config.api,
285
+ models: enhanceWithCI(models, config.providerId),
286
+ });
287
+ };
288
+
289
+ // Toggle state
290
+ const toggleState = createToggleState({
291
+ providerId: config.providerId,
292
+ initialShowPaid: config.defaultShowPaid,
293
+ initialModels: { free: freeModels, all: allModels },
294
+ });
295
+
296
+ // Toggle command
297
+ pi.registerCommand(`toggle-${config.providerId}`, {
298
+ description: `Toggle between free and all ${config.providerId} models`,
299
+ handler: async (_args, ctx) => {
300
+ const applied = toggleState.toggle(reRegister);
301
+ ctx.ui.notify(
302
+ applied.mode === "all"
303
+ ? `${config.providerId}: showing all ${allModels.length} models`
304
+ : `${config.providerId}: showing ${freeModels.length} free models`,
305
+ "info",
386
306
  );
387
- continue;
388
- }
307
+ },
308
+ });
309
+
310
+ // Global toggle
311
+ registerWithGlobalToggle(
312
+ config.providerId,
313
+ { free: freeModels, all: allModels },
314
+ reRegister,
315
+ true,
316
+ );
389
317
 
390
- try {
391
- _logger.info(`[dynamic] Fetching models for ${config.providerId}...`);
318
+ // Status bar
319
+ const pid = config.providerId;
320
+ pi.on("model_select", (_event, ctx) => {
321
+ if (_event.model?.provider !== pid) {
322
+ ctx.ui.setStatus(`${pid}-status`, undefined);
323
+ return;
324
+ }
325
+ const f = freeModels.length;
326
+ const t = allModels.length;
327
+ const p = t - f;
328
+ const mode = toggleState.getCurrentMode();
329
+ const status =
330
+ p === 0
331
+ ? `${pid}: ${f} free models`
332
+ : mode === "all"
333
+ ? `${pid}: ${t} models (free + paid)`
334
+ : `${pid}: ${f} free \u00b7 ${p} paid`;
335
+ ctx.ui.setStatus(`${pid}-status`, `${status} 🔑`);
336
+ });
337
+
338
+ // Register models (this swaps in our discovered models over Pi's defaults)
339
+ toggleState.applyCurrent(reRegister);
340
+ _logger.info(`[dynamic] ${config.providerId}: registered`);
341
+ }
392
342
 
393
- // Fetch models
394
- const allModels = await FETCH_FUNCTIONS[config.providerId](apiKey);
395
- const freeModels = allModels.filter(isFreeModel);
343
+ // =============================================================================
344
+ // Main Entry Fire-and-Forget
345
+ // =============================================================================
396
346
 
397
- _logger.info(
398
- `[dynamic] ${config.providerId}: ${allModels.length} total, ${freeModels.length} free`,
399
- );
347
+ /**
348
+ * 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.
351
+ *
352
+ * Pi's built-in defaults serve until discovery completes and this
353
+ * function replaces them via pi.registerProvider().
354
+ */
355
+ export async function setupDynamicBuiltInProviders(
356
+ pi: ExtensionAPI,
357
+ ): Promise<void> {
358
+ const fetchers: Promise<void>[] = [];
400
359
 
401
- // Create re-register function for global toggle
402
- const reRegister = (models: ProviderModelConfig[]) => {
403
- pi.registerProvider(config.providerId, {
404
- baseUrl: config.baseUrl,
405
- apiKey,
406
- api: config.api,
407
- models: enhanceWithCI(models, config.providerId),
408
- });
409
- };
410
-
411
- // Register with global toggle
412
- registerWithGlobalToggle(
413
- config.providerId,
414
- { free: freeModels, all: allModels },
415
- reRegister,
416
- true, // hasKey
417
- );
360
+ for (const config of DYNAMIC_PROVIDERS) {
361
+ const apiKey = config.getApiKey();
362
+ if (!apiKey) continue;
363
+ fetchers.push(discoverAndRegister(pi, config, apiKey));
364
+ }
418
365
 
419
- // Initial registration (default to free)
420
- const initialModels = config.defaultShowPaid ? allModels : freeModels;
421
- reRegister(initialModels);
422
-
423
- _logger.info(`[dynamic] ${config.providerId}: registered successfully`);
424
- } catch (error) {
425
- _logger.error(
426
- `[dynamic] Failed to setup ${config.providerId}`,
427
- error instanceof Error
428
- ? { error: error.message }
429
- : { error: String(error) },
430
- );
431
- }
366
+ const hfKey = getHfToken();
367
+ if (hfKey) {
368
+ fetchers.push(discoverAndRegisterHF(pi, hfKey));
432
369
  }
370
+
371
+ if (fetchers.length === 0) return;
372
+
373
+ _logger.info(
374
+ `[dynamic] Kicking off discovery for ${fetchers.length} providers (1s timeout each, concurrent)...`,
375
+ );
376
+
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
+ });
433
385
  }