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.
- package/CHANGELOG.md +12 -0
- package/config.ts +815 -821
- package/constants.ts +113 -124
- package/lib/built-in-toggle.ts +426 -426
- package/lib/model-detection.ts +1 -1
- package/package.json +5 -5
- package/provider-helper.ts +29 -7
- package/providers/anyapi/anyapi.ts +270 -270
- package/providers/cline/cline-auth.ts +473 -473
- package/providers/cline/cline-xml-bridge.ts +1506 -1506
- package/providers/cline/cline.ts +205 -205
- package/providers/dynamic-built-in/index.ts +718 -718
- package/providers/kilo/kilo-auth.ts +152 -155
- package/providers/kilo/kilo.ts +13 -7
- package/providers/opencode-session.ts +514 -514
- package/providers/qoder/auth.ts +548 -548
- package/providers/qoder/qoder.ts +119 -119
- package/providers/qoder/stream.ts +585 -585
- package/providers/qoder/thinking-parser.ts +251 -251
- package/providers/qoder/transform.ts +192 -192
- package/providers/tokenrouter/tokenrouter.ts +636 -636
- package/providers/qwen/qwen-auth.ts +0 -416
- package/providers/qwen/qwen-models.ts +0 -101
- package/providers/qwen/qwen.ts +0 -200
|
@@ -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
|
+
}
|