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