pi-free 2.2.4 → 2.2.6

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/banner.svg +1 -1
  4. package/config.ts +58 -29
  5. package/constants.ts +1 -4
  6. package/index.ts +30 -24
  7. package/lib/built-in-toggle.ts +185 -18
  8. package/lib/provider-cache.ts +22 -0
  9. package/package.json +74 -74
  10. package/provider-failover/benchmark-lookup.ts +4 -1
  11. package/provider-failover/benchmarks-chunk-0.ts +570 -570
  12. package/provider-failover/benchmarks-chunk-1.ts +676 -676
  13. package/provider-failover/benchmarks-chunk-2.ts +673 -673
  14. package/provider-failover/benchmarks-chunk-3.ts +680 -680
  15. package/provider-failover/benchmarks-chunk-4.ts +683 -683
  16. package/provider-failover/benchmarks-chunk-5.ts +816 -474
  17. package/provider-helper.ts +64 -0
  18. package/providers/bai/bai.ts +8 -2
  19. package/providers/crofai/crofai.ts +8 -2
  20. package/providers/deepinfra/deepinfra.ts +8 -2
  21. package/providers/dynamic-built-in/index.ts +36 -26
  22. package/providers/kilo/kilo.ts +41 -22
  23. package/providers/novita/novita.ts +8 -2
  24. package/providers/openmodel/openmodel.ts +8 -2
  25. package/providers/qoder/models.ts +63 -175
  26. package/providers/qoder/qoder.ts +49 -84
  27. package/providers/qoder/stream.ts +182 -274
  28. package/providers/qoder/transform.ts +5 -2
  29. package/providers/routeway/routeway.ts +8 -2
  30. package/providers/sambanova/sambanova.ts +12 -6
  31. package/providers/together/together.ts +8 -2
  32. package/providers/tokenrouter/tokenrouter.ts +8 -2
  33. package/providers/zenmux/zenmux.ts +8 -2
  34. package/providers/codestral/codestral.ts +0 -128
  35. package/providers/qoder/encoding.ts +0 -48
@@ -12,6 +12,12 @@ import type {
12
12
  ProviderModelConfig,
13
13
  } from "@earendil-works/pi-coding-agent";
14
14
  import { saveConfig } from "./config.ts";
15
+ import {
16
+ DEFAULT_PROVIDER_CACHE_TTL_MS,
17
+ isProviderCacheFresh,
18
+ loadProviderCache,
19
+ saveProviderCacheGuarded,
20
+ } from "./lib/provider-cache.ts";
15
21
  import { createLogger } from "./lib/logger.ts";
16
22
  import type { ModelsDevEnrichedMetadata } from "./lib/types.ts";
17
23
  import { enhanceModelNameWithCodingIndex } from "./provider-failover/benchmark-lookup.ts";
@@ -101,6 +107,64 @@ export function enhanceWithCI(
101
107
  }));
102
108
  }
103
109
 
110
+ /**
111
+ * Cache-first model loader for network-fetching providers.
112
+ *
113
+ * - If a fresh, non-empty disk cache exists, return it immediately (no network).
114
+ * - Otherwise fetch; persist the result unless it looks like a degenerate /
115
+ * transiently-shrunk response (poisoning guard), so a flaky API can't wipe a
116
+ * good cached list for the TTL window.
117
+ * - On fetch error, fall back to a stale cache entry if one exists.
118
+ */
119
+ export async function loadCachedOrFetchModels(
120
+ providerId: string,
121
+ fetcher: () => Promise<ProviderModelConfig[]>,
122
+ options?: { ttlMs?: number },
123
+ ): Promise<ProviderModelConfig[]> {
124
+ const ttlMs = options?.ttlMs ?? DEFAULT_PROVIDER_CACHE_TTL_MS;
125
+ const cached = loadProviderCache(providerId);
126
+
127
+ if (
128
+ cached &&
129
+ cached.length > 0 &&
130
+ isProviderCacheFresh(providerId, ttlMs)
131
+ ) {
132
+ return cached;
133
+ }
134
+
135
+ let fetched: ProviderModelConfig[] = [];
136
+ try {
137
+ fetched = await fetcher();
138
+ } catch (err) {
139
+ // Network/discovery failure: keep serving whatever cache we have so the
140
+ // provider still registers models instead of going empty.
141
+ if (cached && cached.length > 0) {
142
+ _logger.info(
143
+ `[${providerId}] fetch failed; serving ${cached.length} cached models`,
144
+ { error: err instanceof Error ? err.message : String(err) },
145
+ );
146
+ return cached;
147
+ }
148
+ return [];
149
+ }
150
+
151
+ // Persist the fresh list unless it looks like a degenerate /
152
+ // transiently-shrunk response (poisoning guard, centralized in provider-cache),
153
+ // so a flaky API can't wipe a good cached list for the TTL window.
154
+ if (fetched.length > 0) {
155
+ saveProviderCacheGuarded(providerId, fetched).catch((err) => {
156
+ _logger.error(`[${providerId}] failed to persist provider cache`, {
157
+ error: err instanceof Error ? err.message : String(err),
158
+ });
159
+ });
160
+ } else if (cached && cached.length > 0) {
161
+ // Empty fetch but we have a cache: keep serving cache.
162
+ return cached;
163
+ }
164
+
165
+ return fetched;
166
+ }
167
+
104
168
  /**
105
169
  * Register an OpenAI-compatible provider with standard headers.
106
170
  * Reduces boilerplate across providers that use the OpenAI API format.
@@ -40,7 +40,11 @@ import {
40
40
  } from "../../lib/provider-compat.ts";
41
41
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
42
42
  import { cleanModelName, fetchWithRetry } from "../../lib/util.ts";
43
- import { createReRegister, setupProvider } from "../../provider-helper.ts";
43
+ import {
44
+ createReRegister,
45
+ loadCachedOrFetchModels,
46
+ setupProvider,
47
+ } from "../../provider-helper.ts";
44
48
 
45
49
  const _logger = createLogger("bai");
46
50
 
@@ -176,7 +180,9 @@ export default async function baiProvider(pi: ExtensionAPI) {
176
180
  return;
177
181
  }
178
182
 
179
- const allModels = await fetchBaiModels(apiKey);
183
+ const allModels = await loadCachedOrFetchModels(PROVIDER_BAI, () =>
184
+ fetchBaiModels(apiKey),
185
+ );
180
186
 
181
187
  if (allModels.length === 0) {
182
188
  // Either the API failed (already logged inside fetchBaiModels) or
@@ -35,7 +35,11 @@ import {
35
35
  } from "../../lib/provider-compat.ts";
36
36
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
37
37
  import { fetchWithRetry } from "../../lib/util.ts";
38
- import { createReRegister, setupProvider } from "../../provider-helper.ts";
38
+ import {
39
+ createReRegister,
40
+ loadCachedOrFetchModels,
41
+ setupProvider,
42
+ } from "../../provider-helper.ts";
39
43
 
40
44
  const _logger = createLogger("crofai");
41
45
 
@@ -147,7 +151,9 @@ export default async function crofaiProvider(pi: ExtensionAPI) {
147
151
  }
148
152
 
149
153
  // Fetch models
150
- const allModels = await fetchCrofaiModels(apiKey);
154
+ const allModels = await loadCachedOrFetchModels(PROVIDER_CROFAI, () =>
155
+ fetchCrofaiModels(apiKey),
156
+ );
151
157
 
152
158
  if (allModels.length === 0) {
153
159
  _logger.warn("[crofai] No models available");
@@ -49,7 +49,11 @@ import { createProviderProbe } from "../../lib/provider-probe.ts";
49
49
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
50
50
  import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
51
51
  import { fetchWithRetry, fetchWithTimeout } from "../../lib/util.ts";
52
- import { createReRegister, setupProvider } from "../../provider-helper.ts";
52
+ import {
53
+ createReRegister,
54
+ loadCachedOrFetchModels,
55
+ setupProvider,
56
+ } from "../../provider-helper.ts";
53
57
 
54
58
  const _logger = createLogger("deepinfra");
55
59
 
@@ -163,7 +167,9 @@ export default async function deepinfraProvider(pi: ExtensionAPI) {
163
167
  }
164
168
 
165
169
  // Fetch models
166
- const allModels = await fetchDeepinfraModels(apiKey);
170
+ const allModels = await loadCachedOrFetchModels(PROVIDER_DEEPINFRA, () =>
171
+ fetchDeepinfraModels(apiKey),
172
+ );
167
173
 
168
174
  if (allModels.length === 0) {
169
175
  _logger.warn("[deepinfra] No chat models available");
@@ -55,7 +55,7 @@ import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
55
55
  import { fetchWithTimeout } from "../../lib/util.ts";
56
56
  import { fetchOpenRouterCompatibleModels } from "../model-fetcher.ts";
57
57
  import { createToggleState } from "../../lib/toggle-state.ts";
58
- import { enhanceWithCI } from "../../provider-helper.ts";
58
+ import { enhanceWithCI, loadCachedOrFetchModels } from "../../provider-helper.ts";
59
59
  import {
60
60
  OPENCODE_DYNAMIC_API,
61
61
  createOpenCodeSessionTracker,
@@ -311,38 +311,48 @@ async function discoverAndRegister(
311
311
  config: DynamicProviderDef,
312
312
  apiKey: string,
313
313
  ): Promise<void> {
314
- let allModels: ProviderModelConfig[];
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
+ }
315
334
 
316
- try {
317
- if (config.fetchModels) {
318
- allModels = await config.fetchModels(apiKey);
319
- } else {
320
- allModels = await fetchModelsFromEndpoint({
321
- providerId: config.providerId,
322
- baseUrl: config.baseUrl,
323
- apiKey,
324
- compat: config.compat,
325
- modelDefaults: config.modelDefaults,
326
- timeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
327
- });
328
- }
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
+ );
329
346
 
330
- // Apply DeepSeek proxy compat to matching models. OpenCode headers are
331
- // injected per request by createOpenCodeStreamSimple(), not stored here.
332
- allModels = allModels.map((m) => ({
333
- ...m,
334
- api: isOpenCodeProvider(config.providerId) ? OPENCODE_DYNAMIC_API : m.api,
335
- compat: getProxyModelCompat(m) ?? m.compat,
336
- }));
337
- } catch (error) {
347
+ if (models.length === 0) {
348
+ // No usable models and no fallback cache: leave Pi's built-in defaults.
338
349
  _logger.info(
339
- `[dynamic] ${config.providerId}: discovery failed, Pi keeps its defaults`,
340
- { error: error instanceof Error ? error.message : String(error) },
350
+ `[dynamic] ${config.providerId}: no models available; Pi keeps its defaults`,
341
351
  );
342
352
  return;
343
353
  }
344
354
 
345
- await registerProvider(pi, config, allModels, apiKey);
355
+ await registerProvider(pi, config, models, apiKey);
346
356
  }
347
357
 
348
358
  // =============================================================================
@@ -26,12 +26,14 @@ import {
26
26
  } from "../../config.ts";
27
27
  import { URL_KILO_TOS } from "../../constants.ts";
28
28
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
29
+ import { saveProviderCacheGuarded } from "../../lib/provider-cache.ts";
29
30
  import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
30
31
  import { cleanModelName, logWarning } from "../../lib/util.ts";
31
32
  import {
32
33
  createCtxReRegister,
33
34
  createReRegister,
34
35
  enhanceWithCI,
36
+ loadCachedOrFetchModels,
35
37
  type StoredModels,
36
38
  } from "../../provider-helper.ts";
37
39
  import { loginKilo, refreshKiloToken } from "./kilo-auth.ts";
@@ -176,27 +178,27 @@ export default async function kiloProvider(pi: ExtensionAPI) {
176
178
  // Resolve API key (env var or ~/.pi/free.json)
177
179
  const kiloApiKey = getKiloApiKey();
178
180
 
179
- // Try to fetch ALL models at startup (like Cline/OpenRouter)
180
- // With API key: returns all models; without: returns free-only
181
- let allModels: ProviderModelConfig[] = [];
182
- let freeModels: ProviderModelConfig[] = [];
183
-
184
- try {
185
- // Fetch all models (returns free-only if no auth, all if auth available)
186
- allModels = await fetchKiloModels({ token: kiloApiKey, freeOnly: false });
187
- // Derive free list using isFreeModel with allModels for detection
188
- freeModels = allModels.filter((m) =>
189
- isFreeModel({ ...m, provider: PROVIDER_KILO }, allModels),
190
- );
191
- } catch (error) {
192
- logWarning("kilo", "Failed to fetch models at startup", error);
193
- // Fallback: try to fetch just free models
194
- try {
195
- freeModels = await fetchKiloModels({ freeOnly: true });
196
- } catch (e) {
197
- logWarning("kilo", "Failed to fetch free models", e);
198
- }
199
- }
181
+ // Cache-first at startup via the shared helper (mirrors the other fetchers):
182
+ // serve a fresh cache instantly, else fetch (full free-only fallback),
183
+ // else fall back to a stale cache. The session_start handler below keeps
184
+ // the cache fresh.
185
+ let allModels: ProviderModelConfig[] = await loadCachedOrFetchModels(
186
+ PROVIDER_KILO,
187
+ async () => {
188
+ try {
189
+ return await fetchKiloModels({ token: kiloApiKey, freeOnly: false });
190
+ } catch (error) {
191
+ logWarning("kilo", "Failed to fetch models at startup", error);
192
+ return fetchKiloModels({ freeOnly: true }).catch((err) => {
193
+ logWarning("kilo", "Failed to fetch free models at startup", err);
194
+ return [];
195
+ });
196
+ }
197
+ },
198
+ );
199
+ let freeModels: ProviderModelConfig[] = allModels.filter((m) =>
200
+ isFreeModel({ ...m, provider: PROVIDER_KILO }, allModels),
201
+ );
200
202
 
201
203
  // State tracking
202
204
  const kiloShowPaid = getKiloShowPaid();
@@ -204,6 +206,14 @@ export default async function kiloProvider(pi: ExtensionAPI) {
204
206
  let showPaidModels = kiloShowPaid;
205
207
  let currentModels = kiloShowPaid && !kiloFreeOnly ? allModels : freeModels;
206
208
 
209
+ if (currentModels.length === 0) {
210
+ logWarning(
211
+ "kilo",
212
+ "No Kilo models available at startup; provider will be empty until models can be fetched",
213
+ undefined,
214
+ );
215
+ }
216
+
207
217
  // Shared model storage for global toggle
208
218
  const stored: StoredModels = { free: freeModels, all: allModels };
209
219
 
@@ -295,7 +305,7 @@ export default async function kiloProvider(pi: ExtensionAPI) {
295
305
  "User-Agent": "pi-free-providers",
296
306
  },
297
307
  models: enhanceWithCI(modelsWithCompat),
298
- ...(!!kiloApiKey ? {} : { oauth: oauthConfig }),
308
+ ...(kiloApiKey ? {} : { oauth: oauthConfig }),
299
309
  });
300
310
 
301
311
  // Registration complete - models registered silently (use LOG_LEVEL=info to see details)
@@ -449,6 +459,15 @@ export default async function kiloProvider(pi: ExtensionAPI) {
449
459
  );
450
460
  stored.free = freeModels;
451
461
 
462
+ // Persist refreshed models to disk cache for fast next startup
463
+ saveProviderCacheGuarded(PROVIDER_KILO, allModels).catch((err) => {
464
+ logWarning(
465
+ "kilo",
466
+ "Failed to persist refreshed models to cache",
467
+ err,
468
+ );
469
+ });
470
+
452
471
  // Update global toggle registration
453
472
  const baseCtxReRegister = createCtxReRegister(ctx as any, {
454
473
  ...KILO_PROVIDER_CONFIG,
@@ -41,7 +41,11 @@ import { createProviderProbe } from "../../lib/provider-probe.ts";
41
41
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
42
42
  import { wrapSessionStartHandler } from "../../lib/session-start-metrics.ts";
43
43
  import { fetchWithRetry, fetchWithTimeout } from "../../lib/util.ts";
44
- import { createReRegister, setupProvider } from "../../provider-helper.ts";
44
+ import {
45
+ createReRegister,
46
+ loadCachedOrFetchModels,
47
+ setupProvider,
48
+ } from "../../provider-helper.ts";
45
49
 
46
50
  const _logger = createLogger("novita");
47
51
 
@@ -157,7 +161,9 @@ export default async function novitaProvider(pi: ExtensionAPI) {
157
161
  }
158
162
 
159
163
  // Fetch models
160
- const allModels = await fetchNovitaModels(apiKey);
164
+ const allModels = await loadCachedOrFetchModels(PROVIDER_NOVITA, () =>
165
+ fetchNovitaModels(apiKey),
166
+ );
161
167
 
162
168
  if (allModels.length === 0) {
163
169
  _logger.warn("[novita] No chat models available");
@@ -45,7 +45,11 @@ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
45
45
  import { isLikelyReasoningModel } from "../../lib/provider-compat.ts";
46
46
  import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
47
47
  import { fetchWithRetry } from "../../lib/util.ts";
48
- import { createReRegister, setupProvider } from "../../provider-helper.ts";
48
+ import {
49
+ createReRegister,
50
+ loadCachedOrFetchModels,
51
+ setupProvider,
52
+ } from "../../provider-helper.ts";
49
53
 
50
54
  const _logger = createLogger("openmodel");
51
55
 
@@ -465,7 +469,9 @@ export default async function openmodelProvider(pi: ExtensionAPI) {
465
469
  return;
466
470
  }
467
471
 
468
- const allModels = await fetchOpenModelModels(apiKey);
472
+ const allModels = await loadCachedOrFetchModels(PROVIDER_OPENMODEL, () =>
473
+ fetchOpenModelModels(apiKey),
474
+ );
469
475
 
470
476
  if (allModels.length === 0) {
471
477
  _logger.warn(