pi-free 2.2.5 → 2.2.7

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.
@@ -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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * AnyAPI provider extension.
3
+ *
4
+ * AnyAPI is an OpenAI-compatible gateway with a free plan and a catalog of
5
+ * explicitly free models. It exposes the catalog at /v1/models and routes
6
+ * chat requests through /v1/chat/completions.
7
+ *
8
+ * Setup:
9
+ * ANYAPI_API_KEY=...
10
+ * # or add anyapi_api_key to ~/.pi/free.json
11
+ */
12
+
13
+ import type {
14
+ ExtensionAPI,
15
+ ProviderModelConfig,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import {
18
+ applyHidden,
19
+ getAnyapiApiKey,
20
+ getAnyapiShowPaid,
21
+ } from "../../config.ts";
22
+ import {
23
+ BASE_URL_ANYAPI,
24
+ DEFAULT_FETCH_TIMEOUT_MS,
25
+ PROVIDER_ANYAPI,
26
+ } from "../../constants.ts";
27
+ import { createLogger } from "../../lib/logger.ts";
28
+ import { loadProviderCache } from "../../lib/provider-cache.ts";
29
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
30
+ import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
31
+ import { fetchWithRetry, mapOpenRouterModel } from "../../lib/util.ts";
32
+ import {
33
+ createReRegister,
34
+ loadCachedOrFetchModels,
35
+ setupProvider,
36
+ } from "../../provider-helper.ts";
37
+
38
+ const _logger = createLogger("anyapi");
39
+
40
+ interface AnyApiModel {
41
+ id: string;
42
+ name?: string;
43
+ context_length?: number;
44
+ max_completion_tokens?: number | null;
45
+ top_provider?: {
46
+ context_length?: number | null;
47
+ max_completion_tokens?: number | null;
48
+ };
49
+ pricing?: {
50
+ prompt?: string | number | null;
51
+ completion?: string | number | null;
52
+ input_cache_read?: string | number | null;
53
+ input_cache_write?: string | number | null;
54
+ };
55
+ architecture?: {
56
+ input_modalities?: string[] | null;
57
+ output_modalities?: string[] | null;
58
+ };
59
+ supported_parameters?: string[] | null;
60
+ tags?: string[];
61
+ isFree?: boolean;
62
+ }
63
+
64
+ const ANYAPI_METADATA_VERSION = 1;
65
+
66
+ type AnyApiProviderModel = ProviderModelConfig & {
67
+ _pricingKnown?: boolean;
68
+ _freeKnown?: boolean;
69
+ _isFree?: boolean;
70
+ _anyapiMetadataVersion?: number;
71
+ };
72
+
73
+ function hasPricing(model: AnyApiModel): boolean {
74
+ return (
75
+ (model.pricing?.prompt !== null && model.pricing?.prompt !== undefined) ||
76
+ (model.pricing?.completion !== null &&
77
+ model.pricing?.completion !== undefined) ||
78
+ (model.pricing?.input_cache_read !== null &&
79
+ model.pricing?.input_cache_read !== undefined) ||
80
+ (model.pricing?.input_cache_write !== null &&
81
+ model.pricing?.input_cache_write !== undefined)
82
+ );
83
+ }
84
+
85
+ function normalizePrice(
86
+ value: string | number | null | undefined,
87
+ ): string | null {
88
+ return value === null || value === undefined ? null : String(value);
89
+ }
90
+
91
+ /**
92
+ * Detect AnyAPI's explicitly free model labels without treating every model
93
+ * with omitted pricing as free. The API may also expose an authoritative flag
94
+ * or zero pricing, both of which are handled here.
95
+ */
96
+ export function isAnyApiFreeModel(model: AnyApiModel): boolean {
97
+ if (typeof model.isFree === "boolean") return model.isFree;
98
+
99
+ const label = `${model.id} ${model.name ?? ""}`.toLowerCase();
100
+ if (/\bfree\b/.test(label)) return true;
101
+
102
+ if (!hasPricing(model)) return false;
103
+ const input = Number(model.pricing?.prompt);
104
+ const output = Number(model.pricing?.completion);
105
+ return input === 0 && output === 0;
106
+ }
107
+
108
+ export function mapAnyApiModel(model: AnyApiModel): AnyApiProviderModel {
109
+ const name = model.name ?? model.id;
110
+ const pricingKnown = hasPricing(model);
111
+ const freeKnown =
112
+ typeof model.isFree === "boolean" ||
113
+ /\bfree\b/i.test(`${model.id} ${name}`) ||
114
+ (pricingKnown && isAnyApiFreeModel(model));
115
+
116
+ const tags = model.tags ?? [];
117
+ const supportsReasoning =
118
+ tags.includes("reasoning") || tags.includes("chat_completions:reasoning");
119
+ const supportsVision =
120
+ tags.includes("vision") || tags.includes("chat_completions:vision");
121
+
122
+ const mapped = mapOpenRouterModel({
123
+ ...model,
124
+ name,
125
+ supported_parameters:
126
+ model.supported_parameters ?? (supportsReasoning ? ["reasoning"] : []),
127
+ architecture: model.architecture ?? {
128
+ input_modalities: supportsVision ? ["text", "image"] : ["text"],
129
+ output_modalities: ["text"],
130
+ },
131
+ pricing: model.pricing
132
+ ? {
133
+ prompt: normalizePrice(model.pricing.prompt),
134
+ completion: normalizePrice(model.pricing.completion),
135
+ input_cache_read: normalizePrice(model.pricing.input_cache_read),
136
+ input_cache_write: normalizePrice(model.pricing.input_cache_write),
137
+ }
138
+ : undefined,
139
+ });
140
+
141
+ return {
142
+ ...mapped,
143
+ _pricingKnown: pricingKnown,
144
+ ...(freeKnown && {
145
+ _freeKnown: true,
146
+ _isFree: isAnyApiFreeModel(model),
147
+ }),
148
+ };
149
+ }
150
+
151
+ function isTextModel(model: AnyApiModel): boolean {
152
+ const outputModalities = model.architecture?.output_modalities ?? [];
153
+ if (outputModalities.length > 0 && !outputModalities.includes("text")) {
154
+ return false;
155
+ }
156
+
157
+ const tags = model.tags;
158
+ if (!tags || tags.length === 0) return true;
159
+ return tags.some((tag) => tag.startsWith("chat_completions:"));
160
+ }
161
+
162
+ async function fetchAnyApiModels(
163
+ apiKey: string,
164
+ ): Promise<AnyApiProviderModel[]> {
165
+ const response = await fetchWithRetry(
166
+ `${BASE_URL_ANYAPI}/models`,
167
+ {
168
+ headers: {
169
+ Authorization: `Bearer ${apiKey}`,
170
+ Accept: "application/json",
171
+ "Content-Type": "application/json",
172
+ },
173
+ },
174
+ 3,
175
+ 1000,
176
+ DEFAULT_FETCH_TIMEOUT_MS,
177
+ );
178
+
179
+ if (!response.ok) {
180
+ throw new Error(
181
+ `AnyAPI API error: ${response.status} ${response.statusText}`,
182
+ );
183
+ }
184
+
185
+ const json = (await response.json()) as { data?: AnyApiModel[] };
186
+ const models = (json.data ?? []).flatMap((model) => {
187
+ if (!model.id || !isTextModel(model)) return [];
188
+ return [mapAnyApiModel(model)];
189
+ });
190
+
191
+ _logger.info(`[anyapi] Fetched ${models.length} text models`);
192
+
193
+ // AnyAPI's /models response omits context and output limits for its
194
+ // catalog. Use the global models.dev catalog so canonical model IDs such
195
+ // as qwen/qwen3-coder:free do not fall back to the generic 4096-token
196
+ // defaults in mapOpenRouterModel. The AnyAPI-scoped models.dev entry only
197
+ // contains a small paid subset and does not cover the free catalog.
198
+ const enriched = await safeEnrichModelsWithModelsDev(models);
199
+ return applyHidden(
200
+ enriched.map((model) => ({
201
+ ...model,
202
+ _anyapiMetadataVersion: ANYAPI_METADATA_VERSION,
203
+ })),
204
+ PROVIDER_ANYAPI,
205
+ ) as AnyApiProviderModel[];
206
+ }
207
+
208
+ export default async function anyapiProvider(pi: ExtensionAPI) {
209
+ const apiKey = getAnyapiApiKey();
210
+ if (!apiKey) {
211
+ _logger.info("[anyapi] Skipping — ANYAPI_API_KEY not set.");
212
+ return;
213
+ }
214
+
215
+ const cachedModels = loadProviderCache(PROVIDER_ANYAPI) as
216
+ | AnyApiProviderModel[]
217
+ | undefined;
218
+ const needsMetadataMigration = cachedModels?.some(
219
+ (model) => model._anyapiMetadataVersion !== ANYAPI_METADATA_VERSION,
220
+ );
221
+ const allModels = await loadCachedOrFetchModels(
222
+ PROVIDER_ANYAPI,
223
+ () => fetchAnyApiModels(apiKey),
224
+ // Force one refresh for caches written before context metadata was added;
225
+ // subsequent warm startups continue using the normal one-hour cache.
226
+ needsMetadataMigration ? { ttlMs: -1 } : undefined,
227
+ );
228
+ if (allModels.length === 0) {
229
+ _logger.warn("[anyapi] No text models available");
230
+ return;
231
+ }
232
+
233
+ const freeModels = allModels.filter((model) =>
234
+ isFreeModel({ ...model, provider: PROVIDER_ANYAPI }, allModels),
235
+ );
236
+ const stored = { free: freeModels, all: allModels };
237
+
238
+ _logger.info(
239
+ `[anyapi] Registered ${allModels.length} models (${freeModels.length} free)`,
240
+ );
241
+
242
+ const reRegister = createReRegister(pi, {
243
+ providerId: PROVIDER_ANYAPI,
244
+ baseUrl: BASE_URL_ANYAPI,
245
+ apiKey,
246
+ });
247
+
248
+ registerWithGlobalToggle(PROVIDER_ANYAPI, stored, reRegister, true);
249
+
250
+ setupProvider(
251
+ pi,
252
+ {
253
+ providerId: PROVIDER_ANYAPI,
254
+ initialShowPaid: getAnyapiShowPaid(),
255
+ hasKey: true,
256
+ tosUrl: "https://anyapi.ai/terms-of-service",
257
+ reRegister: (models, current) => {
258
+ if (current) {
259
+ stored.free = current.free;
260
+ stored.all = current.all;
261
+ }
262
+ reRegister(models);
263
+ },
264
+ },
265
+ stored,
266
+ );
267
+
268
+ const showPaid = getAnyapiShowPaid();
269
+ reRegister(showPaid ? stored.all : stored.free);
270
+ }
@@ -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
 
@@ -216,12 +226,7 @@ export default async function kiloProvider(pi: ExtensionAPI) {
216
226
 
217
227
  // Register with global toggle system
218
228
  const hasKiloKey = !!kiloApiKey;
219
- registerWithGlobalToggle(
220
- PROVIDER_KILO,
221
- stored,
222
- reRegister,
223
- hasKiloKey,
224
- );
229
+ registerWithGlobalToggle(PROVIDER_KILO, stored, reRegister, hasKiloKey);
225
230
 
226
231
  // OAuth config for Kilo
227
232
  const oauthConfig = {
@@ -295,7 +300,7 @@ export default async function kiloProvider(pi: ExtensionAPI) {
295
300
  "User-Agent": "pi-free-providers",
296
301
  },
297
302
  models: enhanceWithCI(modelsWithCompat),
298
- ...(!!kiloApiKey ? {} : { oauth: oauthConfig }),
303
+ ...(kiloApiKey ? {} : { oauth: oauthConfig }),
299
304
  });
300
305
 
301
306
  // Registration complete - models registered silently (use LOG_LEVEL=info to see details)
@@ -449,6 +454,15 @@ export default async function kiloProvider(pi: ExtensionAPI) {
449
454
  );
450
455
  stored.free = freeModels;
451
456
 
457
+ // Persist refreshed models to disk cache for fast next startup
458
+ saveProviderCacheGuarded(PROVIDER_KILO, allModels).catch((err) => {
459
+ logWarning(
460
+ "kilo",
461
+ "Failed to persist refreshed models to cache",
462
+ err,
463
+ );
464
+ });
465
+
452
466
  // Update global toggle registration
453
467
  const baseCtxReRegister = createCtxReRegister(ctx as any, {
454
468
  ...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");