pi-free 2.0.2 → 2.0.4

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,88 @@ 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
+ const url = `${opts.baseUrl.replace(/\/+$/, "")}/models`;
62
+ const headers: Record<string, string> = {
63
+ Accept: "application/json",
64
+ Authorization: `Bearer ${opts.apiKey}`,
138
65
  };
139
66
 
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
- );
67
+ const response = await fetch(url, {
68
+ headers,
69
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 1_000),
70
+ });
181
71
 
182
72
  if (!response.ok) {
183
- throw new Error(`Cerebras API error: ${response.status}`);
73
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
184
74
  }
185
75
 
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
- );
76
+ const body = (await response.json()) as
77
+ | Array<Record<string, unknown>>
78
+ | { data?: Array<Record<string, unknown>> };
79
+ const rawModels: Array<Record<string, unknown>> = Array.isArray(body)
80
+ ? body
81
+ : (body.data ?? []);
82
+
83
+ return rawModels.map((m) => {
84
+ const id = String(m.id ?? "");
85
+ const inputModalities = m.input_modalities as string[] | undefined;
86
+ return {
87
+ id,
88
+ name: (m.name as string) ?? (m.model as string) ?? id,
89
+ reasoning: !!(m.reasoning ?? false),
90
+ input: inputModalities?.includes("image")
91
+ ? (["text", "image"] as const)
92
+ : (["text"] as const),
93
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
94
+ contextWindow:
95
+ ((m.max_context_length ?? m.context_window) as number) ??
96
+ opts.modelDefaults?.contextWindow ??
97
+ 128_000,
98
+ maxTokens:
99
+ ((m.max_tokens ?? m.max_completion_tokens) as number) ??
100
+ opts.modelDefaults?.maxTokens ??
101
+ 16_384,
102
+ ...opts.modelDefaults,
103
+ ...(opts.compat ? { compat: opts.compat } : {}),
104
+ } satisfies ProviderModelConfig;
105
+ });
215
106
  }
216
107
 
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
- }
108
+ // =============================================================================
109
+ // Hugging Face (special-cased: non-standard API shape)
110
+ // =============================================================================
265
111
 
266
112
  async function fetchHuggingFaceModels(
267
113
  apiKey?: string,
268
114
  ): 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
115
  const headers: Record<string, string> = {
272
116
  "Content-Type": "application/json",
273
117
  };
@@ -275,56 +119,59 @@ async function fetchHuggingFaceModels(
275
119
  headers.Authorization = `Bearer ${apiKey}`;
276
120
  }
277
121
 
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,
122
+ const response = await fetch(
123
+ "https://api-inference.huggingface.co/models?pipeline_tag=text-generation&limit=50",
124
+ { headers, signal: AbortSignal.timeout(1_000) },
285
125
  );
286
126
 
287
127
  if (!response.ok) {
288
128
  throw new Error(`Hugging Face API error: ${response.status}`);
289
129
  }
290
130
 
291
- const json = (await response.json()) as Array<{
131
+ const body = (await response.json()) as Array<{
292
132
  id: string;
293
133
  modelId?: string;
294
134
  }>;
295
135
 
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",
136
+ const models = Array.isArray(body) ? body.slice(0, 50) : [];
137
+ return models.map((m): ProviderModelConfig => {
138
+ const id = m.id || m.modelId || "unknown";
139
+ return {
140
+ id,
141
+ name: id.split("/").pop() || "Unknown",
303
142
  reasoning: false,
304
143
  input: ["text"],
305
- cost: {
306
- input: 0,
307
- output: 0,
308
- cacheRead: 0,
309
- cacheWrite: 0,
310
- },
144
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
311
145
  contextWindow: 4096,
312
146
  maxTokens: 2048,
313
- }),
314
- );
147
+ };
148
+ });
315
149
  }
316
150
 
317
151
  // =============================================================================
318
- // Provider Configurations Map
152
+ // Provider Definitions
319
153
  // =============================================================================
320
154
 
321
- const DYNAMIC_PROVIDERS: Omit<DynamicProviderConfig, "fetchModels">[] = [
155
+ interface DynamicProviderDef {
156
+ providerId: string;
157
+ getApiKey: () => string | undefined;
158
+ baseUrl: string;
159
+ api: "openai-completions" | "mistral-conversations" | "anthropic-messages";
160
+ defaultShowPaid: boolean;
161
+ /** Optional per-provider compat overrides (e.g., DeepSeek proxy). */
162
+ compat?: ProviderModelConfig["compat"];
163
+ /** Per-model field defaults when the API doesn't expose them. */
164
+ modelDefaults?: Partial<ProviderModelConfig>;
165
+ }
166
+
167
+ const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
322
168
  {
323
169
  providerId: "mistral",
324
170
  getApiKey: getMistralApiKey,
325
171
  baseUrl: "https://api.mistral.ai/v1",
326
172
  api: "openai-completions",
327
173
  defaultShowPaid: false,
174
+ modelDefaults: { contextWindow: 32_768, maxTokens: 16_384 },
328
175
  },
329
176
  {
330
177
  providerId: "groq",
@@ -347,87 +194,190 @@ const DYNAMIC_PROVIDERS: Omit<DynamicProviderConfig, "fetchModels">[] = [
347
194
  api: "openai-completions",
348
195
  defaultShowPaid: false,
349
196
  },
350
- {
197
+ ];
198
+
199
+ // =============================================================================
200
+ // Discovery + Registration per Provider
201
+ // =============================================================================
202
+
203
+ async function discoverAndRegister(
204
+ pi: ExtensionAPI,
205
+ config: DynamicProviderDef,
206
+ apiKey: string,
207
+ ): Promise<void> {
208
+ let allModels: ProviderModelConfig[];
209
+
210
+ try {
211
+ allModels = await fetchModelsFromEndpoint({
212
+ baseUrl: config.baseUrl,
213
+ apiKey,
214
+ compat: config.compat,
215
+ modelDefaults: config.modelDefaults,
216
+ timeoutMs: 1_000,
217
+ });
218
+
219
+ // Apply DeepSeek proxy compat to matching models
220
+ allModels = allModels.map((m) => ({
221
+ ...m,
222
+ compat: getProxyModelCompat(m) ?? m.compat,
223
+ }));
224
+ } catch {
225
+ _logger.info(
226
+ `[dynamic] ${config.providerId}: discovery failed, Pi keeps its defaults`,
227
+ );
228
+ return;
229
+ }
230
+
231
+ await registerProvider(pi, config, allModels, apiKey);
232
+ }
233
+
234
+ async function discoverAndRegisterHF(
235
+ pi: ExtensionAPI,
236
+ apiKey: string,
237
+ ): Promise<void> {
238
+ const config: DynamicProviderDef = {
351
239
  providerId: "huggingface",
352
240
  getApiKey: getHfToken,
353
241
  baseUrl: "https://api-inference.huggingface.co",
354
242
  api: "openai-completions",
355
243
  defaultShowPaid: false,
356
- },
357
- ];
244
+ };
358
245
 
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
- };
246
+ let allModels: ProviderModelConfig[];
247
+ try {
248
+ allModels = await fetchHuggingFaceModels(apiKey);
249
+ } catch {
250
+ _logger.info(
251
+ "[dynamic] huggingface: discovery failed, Pi keeps its defaults",
252
+ );
253
+ return;
254
+ }
255
+
256
+ await registerProvider(pi, config, allModels, apiKey);
257
+ }
370
258
 
371
259
  // =============================================================================
372
- // Main Setup Function
260
+ // Registration Logic (sets up toggles, commands, status bar)
373
261
  // =============================================================================
374
262
 
375
- export async function setupDynamicBuiltInProviders(
263
+ async function registerProvider(
376
264
  pi: ExtensionAPI,
265
+ config: DynamicProviderDef,
266
+ allModels: ProviderModelConfig[],
267
+ apiKey: string,
377
268
  ): Promise<void> {
378
- _logger.info("[dynamic] Setting up dynamic built-in providers...");
269
+ const freeModels = allModels.filter((m) =>
270
+ isFreeModel({ ...m, provider: config.providerId }, allModels),
271
+ );
379
272
 
380
- for (const config of DYNAMIC_PROVIDERS) {
381
- const apiKey = config.getApiKey();
273
+ _logger.info(
274
+ `[dynamic] ${config.providerId}: ${allModels.length} total, ${freeModels.length} free`,
275
+ );
382
276
 
383
- if (!apiKey) {
384
- _logger.info(
385
- `[dynamic] Skipping ${config.providerId} - no API key configured`,
277
+ // Re-register function: called by toggle and initial apply
278
+ const reRegister = (models: ProviderModelConfig[]) => {
279
+ pi.registerProvider(config.providerId, {
280
+ baseUrl: config.baseUrl,
281
+ apiKey,
282
+ api: config.api,
283
+ models: enhanceWithCI(models, config.providerId),
284
+ });
285
+ };
286
+
287
+ // Toggle state
288
+ const toggleState = createToggleState({
289
+ providerId: config.providerId,
290
+ initialShowPaid: config.defaultShowPaid,
291
+ initialModels: { free: freeModels, all: allModels },
292
+ });
293
+
294
+ // Toggle command
295
+ pi.registerCommand(`toggle-${config.providerId}`, {
296
+ description: `Toggle between free and all ${config.providerId} models`,
297
+ handler: async (_args, ctx) => {
298
+ const applied = toggleState.toggle(reRegister);
299
+ ctx.ui.notify(
300
+ applied.mode === "all"
301
+ ? `${config.providerId}: showing all ${allModels.length} models`
302
+ : `${config.providerId}: showing ${freeModels.length} free models`,
303
+ "info",
386
304
  );
387
- continue;
388
- }
305
+ },
306
+ });
307
+
308
+ // Global toggle
309
+ registerWithGlobalToggle(
310
+ config.providerId,
311
+ { free: freeModels, all: allModels },
312
+ reRegister,
313
+ true,
314
+ );
389
315
 
390
- try {
391
- _logger.info(`[dynamic] Fetching models for ${config.providerId}...`);
316
+ // Status bar
317
+ const pid = config.providerId;
318
+ pi.on("model_select", (_event, ctx) => {
319
+ if (_event.model?.provider !== pid) {
320
+ ctx.ui.setStatus(`${pid}-status`, undefined);
321
+ return;
322
+ }
323
+ const f = freeModels.length;
324
+ const t = allModels.length;
325
+ const p = t - f;
326
+ const mode = toggleState.getCurrentMode();
327
+ const status =
328
+ p === 0
329
+ ? `${pid}: ${f} free models`
330
+ : mode === "all"
331
+ ? `${pid}: ${t} models (free + paid)`
332
+ : `${pid}: ${f} free \u00b7 ${p} paid`;
333
+ ctx.ui.setStatus(`${pid}-status`, `${status} 🔑`);
334
+ });
335
+
336
+ // Register models (this swaps in our discovered models over Pi's defaults)
337
+ toggleState.applyCurrent(reRegister);
338
+ _logger.info(`[dynamic] ${config.providerId}: registered`);
339
+ }
392
340
 
393
- // Fetch models
394
- const allModels = await FETCH_FUNCTIONS[config.providerId](apiKey);
395
- const freeModels = allModels.filter(isFreeModel);
341
+ // =============================================================================
342
+ // Main Entry Fire-and-Forget
343
+ // =============================================================================
396
344
 
397
- _logger.info(
398
- `[dynamic] ${config.providerId}: ${allModels.length} total, ${freeModels.length} free`,
399
- );
345
+ /**
346
+ * Kick off model discovery for all configured providers.
347
+ * Runs each fetch concurrently with a 1s timeout so the worst-case
348
+ * wall time is ~1s, not `n * 1s`. Extension init never blocks.
349
+ *
350
+ * Pi's built-in defaults serve until discovery completes and this
351
+ * function replaces them via pi.registerProvider().
352
+ */
353
+ export async function setupDynamicBuiltInProviders(
354
+ pi: ExtensionAPI,
355
+ ): Promise<void> {
356
+ const fetchers: Promise<void>[] = [];
400
357
 
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
- );
358
+ for (const config of DYNAMIC_PROVIDERS) {
359
+ const apiKey = config.getApiKey();
360
+ if (!apiKey) continue;
361
+ fetchers.push(discoverAndRegister(pi, config, apiKey));
362
+ }
418
363
 
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
- }
364
+ const hfKey = getHfToken();
365
+ if (hfKey) {
366
+ fetchers.push(discoverAndRegisterHF(pi, hfKey));
432
367
  }
368
+
369
+ if (fetchers.length === 0) return;
370
+
371
+ _logger.info(
372
+ `[dynamic] Kicking off discovery for ${fetchers.length} providers (1s timeout each, concurrent)...`,
373
+ );
374
+
375
+ // Fire-and-forget: log results, never block init
376
+ void Promise.allSettled(fetchers).then((results) => {
377
+ const succeeded = results.filter((r) => r.status === "fulfilled").length;
378
+ const failed = results.filter((r) => r.status === "rejected").length;
379
+ _logger.info(
380
+ `[dynamic] Discovery complete: ${succeeded} succeeded, ${failed} failed/rejected`,
381
+ );
382
+ });
433
383
  }