pi-provider-litellm 2.1.0 → 2.2.0

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/README.md CHANGED
@@ -157,14 +157,11 @@ Treat the configured LiteLLM proxy as trusted: Skills can add instructions to th
157
157
  | `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Background and explicit discovery fetch timeout in ms; `0` disables automatic discovery |
158
158
  | `LITELLM_CLI_JWT_EXPIRATION_HOURS` | `24` | CLI SSO token lifetime fallback for older proxies whose poll response omits `expires_in`; mirror a non-default proxy setting locally |
159
159
  | `LITELLM_VERBOSE_DISCOVERY` | unset | If `1`, enable progress messages during model and MCP discovery (login, refresh, startup); discovery is silent by default |
160
- | `LITELLM_MODELS_DEV` | enabled | Set to `0` to disable models.dev metadata enrichment, including its cache and network request; `/v1/models` still uses Pi catalog metadata and defaults |
161
160
 
162
161
  Only use a trusted `LITELLM_API_KEY_HELPER` or `!command`. Prefer an absolute executable path, keep secrets out of command arguments, and print only the token to stdout without logging it to stderr.
163
162
 
164
163
  `LITELLM_DISCOVERY_TIMEOUT_MS=0` disables automatic and explicit refresh model discovery. It does not replace the base URL or API key settings required to send requests when you are not using `/login litellm`.
165
164
 
166
- Models.dev metadata is cached in `litellm-models-dev.json` under the Pi agent directory for 28 days. Fresh data avoids the public request; stale data is used immediately while one background refresh updates the cache. Set `LITELLM_MODELS_DEV=0` when your LiteLLM metadata is authoritative and no external enrichment is wanted.
167
-
168
165
  ### Google ADC token auth
169
166
 
170
167
  When your LiteLLM proxy accepts Google OAuth access tokens, you can let the extension refresh tokens from Application Default Credentials:
package/dist/discover.js CHANGED
@@ -1,15 +1,9 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import { isIP } from "node:net";
3
2
  import { getModels, getProviders } from "@earendil-works/pi-ai/compat";
4
- import { writeJsonAtomic } from "./cache.js";
5
3
  const DEFAULT_TIMEOUT_MS = 5000;
6
4
  const DEFAULT_CONTEXT_WINDOW = 128_000;
7
5
  const DEFAULT_MAX_TOKENS = 16_384;
8
6
  const KNOWN_PROVIDER_SET = new Set(getProviders());
9
- const MODELS_DEV_URL = "https://models.dev/api.json";
10
- const MODELS_DEV_CACHE_TTL_MS = 28 * 24 * 60 * 60 * 1000;
11
- const modelsDevCaches = new Map();
12
- const modelsDevRefreshes = new Map();
13
7
  export function normalizeBaseUrl(input) {
14
8
  const url = new URL(input);
15
9
  const hostname = url.hostname.toLowerCase();
@@ -150,42 +144,6 @@ function mapModelInfoCost(info, fallback) {
150
144
  ...(fallback?.tiers ? { tiers: fallback.tiers } : {}),
151
145
  };
152
146
  }
153
- function getFallbackProviderAndModel(id, ownedBy) {
154
- const [prefix, ...rest] = id.split("/");
155
- const prefixProvider = toKnownProvider(prefix);
156
- if (prefixProvider && rest.length > 0) {
157
- return { provider: prefixProvider, modelId: rest.join("/") };
158
- }
159
- return { provider: toKnownProvider(ownedBy), modelId: id };
160
- }
161
- function findModelsDevModel(catalog, id, ownedBy) {
162
- const { provider, modelId } = getFallbackProviderAndModel(id, ownedBy);
163
- if (!provider)
164
- return undefined;
165
- const models = catalog?.[provider]?.models;
166
- return models && Object.hasOwn(models, modelId) ? models[modelId] : undefined;
167
- }
168
- function awaitWithSignal(promise, signal) {
169
- if (!signal)
170
- return promise;
171
- if (signal.aborted)
172
- return Promise.reject(signal.reason);
173
- return new Promise((resolve, reject) => {
174
- const cleanup = () => signal.removeEventListener("abort", onAbort);
175
- const onAbort = () => {
176
- cleanup();
177
- reject(signal.reason);
178
- };
179
- signal.addEventListener("abort", onAbort, { once: true });
180
- promise.then((value) => {
181
- cleanup();
182
- resolve(value);
183
- }, (error) => {
184
- cleanup();
185
- reject(error);
186
- });
187
- });
188
- }
189
147
  async function fetchJson(url, apiKey, options) {
190
148
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
191
149
  const response = await fetch(url, {
@@ -199,169 +157,6 @@ async function fetchJson(url, apiKey, options) {
199
157
  const data = (await response.json());
200
158
  return { ok: true, data };
201
159
  }
202
- async function fetchPublicJson(url, options) {
203
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
204
- const response = await fetch(url, {
205
- headers: { Accept: "application/json" },
206
- signal: options.signal
207
- ? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)])
208
- : AbortSignal.timeout(timeoutMs),
209
- });
210
- if (!response.ok)
211
- throw new Error(`${url} returned ${response.status}`);
212
- return (await response.json());
213
- }
214
- function isRecord(value) {
215
- return typeof value === "object" && value !== null && !Array.isArray(value);
216
- }
217
- function finiteNumber(value) {
218
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
219
- }
220
- function normalizeModelsDevCatalog(value) {
221
- if (!isRecord(value))
222
- return undefined;
223
- const catalog = {};
224
- for (const [providerId, providerValue] of Object.entries(value)) {
225
- if (!isRecord(providerValue) || !isRecord(providerValue.models))
226
- continue;
227
- const models = {};
228
- for (const [modelId, modelValue] of Object.entries(providerValue.models)) {
229
- if (!isRecord(modelValue))
230
- continue;
231
- const model = {};
232
- if (typeof modelValue.name === "string")
233
- model.name = modelValue.name;
234
- if (typeof modelValue.reasoning === "boolean")
235
- model.reasoning = modelValue.reasoning;
236
- if (isRecord(modelValue.modalities) && Array.isArray(modelValue.modalities.input)) {
237
- const input = modelValue.modalities.input.filter((entry) => typeof entry === "string");
238
- if (input.length > 0)
239
- model.modalities = { input };
240
- }
241
- if (isRecord(modelValue.limit)) {
242
- const limit = {};
243
- const context = finiteNumber(modelValue.limit.context);
244
- const input = finiteNumber(modelValue.limit.input);
245
- const output = finiteNumber(modelValue.limit.output);
246
- if (context !== undefined)
247
- limit.context = context;
248
- if (input !== undefined)
249
- limit.input = input;
250
- if (output !== undefined)
251
- limit.output = output;
252
- if (Object.keys(limit).length > 0)
253
- model.limit = limit;
254
- }
255
- if (isRecord(modelValue.cost)) {
256
- const cost = {};
257
- const input = finiteNumber(modelValue.cost.input);
258
- const output = finiteNumber(modelValue.cost.output);
259
- const cacheRead = finiteNumber(modelValue.cost.cache_read);
260
- const cacheWrite = finiteNumber(modelValue.cost.cache_write);
261
- if (input !== undefined)
262
- cost.input = input;
263
- if (output !== undefined)
264
- cost.output = output;
265
- if (cacheRead !== undefined)
266
- cost.cache_read = cacheRead;
267
- if (cacheWrite !== undefined)
268
- cost.cache_write = cacheWrite;
269
- if (Object.keys(cost).length > 0)
270
- model.cost = cost;
271
- }
272
- models[modelId] = model;
273
- }
274
- catalog[providerId] = { models };
275
- }
276
- return catalog;
277
- }
278
- async function readModelsDevCache(path) {
279
- try {
280
- const parsed = JSON.parse(await readFile(path, "utf8"));
281
- if (!isRecord(parsed) ||
282
- typeof parsed.fetchedAt !== "number" ||
283
- !Number.isFinite(parsed.fetchedAt) ||
284
- parsed.fetchedAt < 0 ||
285
- parsed.fetchedAt > Date.now()) {
286
- return undefined;
287
- }
288
- const catalog = normalizeModelsDevCatalog(parsed.catalog);
289
- return catalog ? { fetchedAt: parsed.fetchedAt, catalog } : undefined;
290
- }
291
- catch {
292
- return undefined;
293
- }
294
- }
295
- function refreshModelsDevCatalog(key, options) {
296
- const active = modelsDevRefreshes.get(key);
297
- if (active)
298
- return active;
299
- const refresh = (async () => {
300
- try {
301
- const catalog = normalizeModelsDevCatalog(await fetchPublicJson(MODELS_DEV_URL, options));
302
- if (!catalog)
303
- return undefined;
304
- const cache = { fetchedAt: Date.now(), catalog };
305
- modelsDevCaches.set(key, cache);
306
- if (options.modelsDevCachePath) {
307
- await writeJsonAtomic(options.modelsDevCachePath, cache).catch(() => undefined);
308
- }
309
- return catalog;
310
- }
311
- catch {
312
- return undefined;
313
- }
314
- finally {
315
- modelsDevRefreshes.delete(key);
316
- }
317
- })();
318
- modelsDevRefreshes.set(key, refresh);
319
- return refresh;
320
- }
321
- async function getModelsDevCatalog(options) {
322
- const key = options.modelsDevCachePath ?? MODELS_DEV_URL;
323
- const refreshOptions = { ...options, timeoutMs: DEFAULT_TIMEOUT_MS, signal: undefined };
324
- let cache = modelsDevCaches.get(key);
325
- if (!cache && options.modelsDevCachePath) {
326
- cache = await readModelsDevCache(options.modelsDevCachePath);
327
- if (cache)
328
- modelsDevCaches.set(key, cache);
329
- }
330
- if (!cache) {
331
- const timeout = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
332
- return awaitWithSignal(refreshModelsDevCatalog(key, refreshOptions), options.signal ? AbortSignal.any([options.signal, timeout]) : timeout);
333
- }
334
- if (Date.now() - cache.fetchedAt < MODELS_DEV_CACHE_TTL_MS)
335
- return cache.catalog;
336
- void refreshModelsDevCatalog(key, refreshOptions);
337
- return cache.catalog;
338
- }
339
- function mapModelsDevMetadata(model) {
340
- if (!model)
341
- return {};
342
- const metadata = {};
343
- if (model.name)
344
- metadata.name = model.name;
345
- if (model.reasoning !== undefined)
346
- metadata.reasoning = model.reasoning;
347
- if (model.modalities?.input) {
348
- metadata.input = model.modalities.input.includes("image") ? ["text", "image"] : ["text"];
349
- }
350
- const contextWindow = model.limit?.context ?? model.limit?.input;
351
- if (contextWindow !== undefined)
352
- metadata.contextWindow = contextWindow;
353
- if (model.limit?.output !== undefined)
354
- metadata.maxTokens = model.limit.output;
355
- if (model.cost) {
356
- metadata.cost = {
357
- input: model.cost.input ?? 0,
358
- output: model.cost.output ?? 0,
359
- cacheRead: model.cost.cache_read ?? 0,
360
- cacheWrite: model.cost.cache_write ?? 0,
361
- };
362
- }
363
- return metadata;
364
- }
365
160
  function mapReasoningEfforts(info) {
366
161
  const flags = [
367
162
  ["off", "none", info.supports_none_reasoning_effort],
@@ -428,21 +223,20 @@ function mapFromHealthEndpoint(entry) {
428
223
  compat: buildCompat(id),
429
224
  };
430
225
  }
431
- function mapFromModelsList(entry, modelsDev) {
226
+ function mapFromModelsList(entry) {
432
227
  const id = entry.id;
433
228
  if (!id)
434
229
  return undefined;
435
230
  const catalogModel = findCatalogModel(id, entry.owned_by);
436
- const modelsDevMetadata = mapModelsDevMetadata(findModelsDevModel(modelsDev, id, entry.owned_by));
437
231
  return {
438
232
  id,
439
- name: modelsDevMetadata.name ?? catalogModel?.name ?? `${id} (no metadata)`,
440
- reasoning: modelsDevMetadata.reasoning ?? catalogModel?.reasoning ?? false,
233
+ name: catalogModel?.name ?? `${id} (no metadata)`,
234
+ reasoning: catalogModel?.reasoning ?? false,
441
235
  thinkingLevelMap: catalogModel?.thinkingLevelMap,
442
- input: modelsDevMetadata.input ?? catalogModel?.input ?? ["text"],
443
- cost: modelsDevMetadata.cost ?? catalogModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
444
- contextWindow: modelsDevMetadata.contextWindow ?? catalogModel?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
445
- maxTokens: modelsDevMetadata.maxTokens ?? catalogModel?.maxTokens ?? DEFAULT_MAX_TOKENS,
236
+ input: catalogModel?.input ?? ["text"],
237
+ cost: catalogModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
238
+ contextWindow: catalogModel?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
239
+ maxTokens: catalogModel?.maxTokens ?? DEFAULT_MAX_TOKENS,
446
240
  compat: buildCompat(id),
447
241
  };
448
242
  }
@@ -507,15 +301,10 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
507
301
  // Ref: docs.litellm.ai/docs/proxy/model_discovery
508
302
  if (models.some((m) => m.id.includes("*"))) {
509
303
  progress?.("/model/info has wildcard entries, expanding via /v1/models...");
510
- let modelsDev;
511
- if (options.modelsDev !== false) {
512
- progress?.("Loading models.dev catalog for metadata enrichment...");
513
- modelsDev = await getModelsDevCatalog(options);
514
- }
515
304
  const listResult = await fetchJson(`${base}/v1/models`, apiKey, options);
516
305
  if (listResult.ok) {
517
306
  const expanded = (listResult.data.data ?? [])
518
- .map((entry) => mapFromModelsList(entry, modelsDev))
307
+ .map(mapFromModelsList)
519
308
  .filter((m) => m !== undefined && !m.id.includes("*"));
520
309
  const seen = new Set(models.map((m) => m.id));
521
310
  models = [...models.filter((m) => !m.id.includes("*")), ...expanded.filter((m) => !seen.has(m.id))];
@@ -537,13 +326,8 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
537
326
  }
538
327
  throw new Error(`/v1/models returned ${listResult.status}`);
539
328
  }
540
- let modelsDev;
541
- if (options.modelsDev !== false) {
542
- progress?.("Loading models.dev catalog for metadata enrichment...");
543
- modelsDev = await getModelsDevCatalog(options);
544
- }
545
329
  const models = (listResult.data.data ?? [])
546
- .map((entry) => mapFromModelsList(entry, modelsDev))
330
+ .map(mapFromModelsList)
547
331
  .filter((m) => m !== undefined);
548
332
  return { source: "models_list", models: deduplicateModels(models) };
549
333
  }
package/dist/index.js CHANGED
@@ -2,13 +2,13 @@ import { execFileSync } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
6
6
  import { setupLiteLLMCostTracking } from "./cost.js";
7
7
  import { discoverModels, isGpt55Model, isMoonshotModel, normalizeBaseUrl, shouldSuppressReasoningContent, } from "./discover.js";
8
8
  import { getGcloudToken, getGcloudTokenCacheKey, getGcloudTokenCommand, isGcloudTokenAuthEnabled, } from "./gcloud-token.js";
9
9
  import { getSessionIdFromFile } from "./litellm.js";
10
10
  import { createMcpToolDefinitions } from "./mcp-tools.js";
11
- import { createLiteLLMProvider } from "./provider.js";
11
+ import { createLiteLLMProvider, toNativeModels } from "./provider.js";
12
12
  import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
13
13
  const PROVIDER_NAME = "litellm";
14
14
  const SETTINGS_KEY = "litellm";
@@ -20,22 +20,15 @@ const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
20
20
  const ENV_CLI_JWT_EXPIRATION_HOURS = "LITELLM_CLI_JWT_EXPIRATION_HOURS";
21
21
  const ENV_OFFLINE = "LITELLM_OFFLINE";
22
22
  const ENV_VERBOSE_DISCOVERY = "LITELLM_VERBOSE_DISCOVERY";
23
- const ENV_MODELS_DEV = "LITELLM_MODELS_DEV";
24
23
  const DEFAULT_TIMEOUT_MS = 5000;
24
+ const SEED_TIMEOUT_MS = 3000;
25
25
  const LOGIN_TIMEOUT_MS = 10_000;
26
26
  const CLI_SSO_POLL_INTERVAL_MS = 2_000;
27
27
  const CLI_SSO_EXPIRES_IN_SECONDS = 600;
28
28
  const DEFAULT_CLI_JWT_EXPIRATION_HOURS = 24;
29
- const MODELS_DEV_CACHE_FILENAME = "litellm-models-dev.json";
30
29
  const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
31
30
  const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
32
31
  const EXPIRE_TOKEN_IMMEDIATELY = 0;
33
- function getModelsDevDiscoveryOptions() {
34
- return {
35
- modelsDev: process.env[ENV_MODELS_DEV] !== "0",
36
- modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME),
37
- };
38
- }
39
32
  function isPlainObject(value) {
40
33
  return typeof value === "object" && value !== null && !Array.isArray(value);
41
34
  }
@@ -359,6 +352,19 @@ function getDiscoveryTimeoutMs() {
359
352
  function isOffline() {
360
353
  return process.env[ENV_OFFLINE] === "1";
361
354
  }
355
+ /** Pi disables all model network access when PI_OFFLINE is set; activation must honour it too. */
356
+ function isHostOffline() {
357
+ return process.env.PI_OFFLINE !== undefined;
358
+ }
359
+ /**
360
+ * Absolute budget for activation-time discovery. Pi cannot paint its UI until every extension has
361
+ * activated, so this is deliberately capped rather than following LITELLM_DISCOVERY_TIMEOUT_MS,
362
+ * which is a per-request timeout that deployments raise to tens of seconds. Missing the budget only
363
+ * costs the startup seed; Pi's own refresh and /model still populate the catalog.
364
+ */
365
+ function getSeedTimeoutMs() {
366
+ return Math.min(getDiscoveryTimeoutMs(), SEED_TIMEOUT_MS);
367
+ }
362
368
  function isVerboseDiscovery() {
363
369
  return process.env[ENV_VERBOSE_DISCOVERY] === "1";
364
370
  }
@@ -935,14 +941,14 @@ export default async function (pi) {
935
941
  "https://litellm.example.com";
936
942
  return `${normalizeBaseUrl(baseUrl)}/v1`;
937
943
  }
938
- async function authForCredential(definition, credential) {
939
- if (credential.type === "oauth") {
944
+ async function authForCredential(definition, credential, executeHelpers = true) {
945
+ if (credential?.type === "oauth") {
940
946
  const baseUrl = typeof credential.baseUrl === "string"
941
947
  ? normalizeBaseUrl(credential.baseUrl)
942
948
  : normalizeBaseUrl(requestBaseUrl(definition));
943
949
  return { baseUrl, apiKey: credential.access, headers: resolveHeaders(definition) };
944
950
  }
945
- const resolved = await resolveApiKeyAuth(definition, { env: async (name) => process.env[name] }, credential);
951
+ const resolved = await resolveApiKeyAuth(definition, { env: async (name) => process.env[name] }, credential, executeHelpers);
946
952
  if (!resolved?.auth.apiKey) {
947
953
  throw new Error(`no credentials for ${definition.name}. Run /login litellm or set env vars.`);
948
954
  }
@@ -1045,27 +1051,52 @@ export default async function (pi) {
1045
1051
  mcpRegistration = undefined;
1046
1052
  }
1047
1053
  }
1048
- for (const definition of definitions) {
1054
+ async function runDiscovery(auth, signal) {
1055
+ const result = await discoverModels(auth.baseUrl, auth.apiKey, {
1056
+ timeoutMs: getDiscoveryTimeoutMs(),
1057
+ signal,
1058
+ headers: auth.headers,
1059
+ silent: !isVerboseDiscovery(),
1060
+ onProgress: isVerboseDiscovery() ? (message) => process.stderr.write(`LiteLLM: ${message}\n`) : undefined,
1061
+ });
1062
+ signal?.throwIfAborted();
1063
+ return { ...result, baseUrl: `${normalizeBaseUrl(auth.baseUrl)}/v1` };
1064
+ }
1065
+ // Pi's startup path only ever refreshes with allowNetwork:false, and it returns before the
1066
+ // network phase, so the provider would stay empty until the user opens /model. Discover here
1067
+ // instead, the way 1.x did, and hand the catalog over as the provider's baseline models.
1068
+ async function seedModels(definition) {
1069
+ if (discoveryDisabledReason() || isHostOffline())
1070
+ return [];
1071
+ try {
1072
+ const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
1073
+ // executeHelpers:false — activation must never run the user's key helper as a side effect,
1074
+ // so helper-backed setups keep waiting for Pi's own refresh.
1075
+ const auth = await authForCredential(definition, stored, false);
1076
+ const result = await runDiscovery(auth, AbortSignal.timeout(getSeedTimeoutMs()));
1077
+ return toNativeModels(definition.name, result.baseUrl, result.models);
1078
+ }
1079
+ catch (error) {
1080
+ // No credentials yet is the normal unconfigured case; anything else is worth one line.
1081
+ if (isVerboseDiscovery()) {
1082
+ process.stderr.write(`LiteLLM (${definition.name}): startup discovery skipped (${error instanceof Error ? error.message : String(error)}).\n`);
1083
+ }
1084
+ return [];
1085
+ }
1086
+ }
1087
+ const seeded = await Promise.all(definitions.map(seedModels));
1088
+ for (const [index, definition] of definitions.entries()) {
1049
1089
  const provider = createLiteLLMProvider({
1050
1090
  id: definition.name,
1051
1091
  name: definition.displayName,
1052
1092
  baseUrl: requestBaseUrl(definition),
1053
1093
  auth: createProviderAuth(definition),
1094
+ models: seeded[index],
1054
1095
  discover: async (credential, signal) => {
1055
1096
  const disabledReason = discoveryDisabledReason();
1056
1097
  if (disabledReason)
1057
1098
  throw new Error(`discovery disabled (${disabledReason})`);
1058
- const auth = await authForCredential(definition, credential);
1059
- const result = await discoverModels(auth.baseUrl, auth.apiKey, {
1060
- ...getModelsDevDiscoveryOptions(),
1061
- timeoutMs: getDiscoveryTimeoutMs(),
1062
- signal,
1063
- headers: auth.headers,
1064
- silent: !isVerboseDiscovery(),
1065
- onProgress: isVerboseDiscovery() ? (message) => process.stderr.write(`LiteLLM: ${message}\n`) : undefined,
1066
- });
1067
- signal?.throwIfAborted();
1068
- return { ...result, baseUrl: `${normalizeBaseUrl(auth.baseUrl)}/v1` };
1099
+ return runDiscovery(await authForCredential(definition, credential), signal);
1069
1100
  },
1070
1101
  });
1071
1102
  Object.assign(provider, { headers: resolveHeaders(definition) });
@@ -1104,16 +1135,27 @@ export default async function (pi) {
1104
1135
  return;
1105
1136
  return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
1106
1137
  });
1138
+ // Skills enrichment is best-effort: an expired credential or an unreachable proxy must not
1139
+ // report an extension error on every turn. The failing model request states the real problem.
1107
1140
  pi.on("before_agent_start", async (event, ctx) => {
1108
1141
  defaultRuntimeAuth = undefined;
1109
1142
  if (!skillsEnabled || discoveryDisabledReason())
1110
1143
  return;
1111
- const auth = await getRuntimeAuth(ctx);
1112
- if (!auth)
1144
+ let section;
1145
+ try {
1146
+ const auth = await getRuntimeAuth(ctx);
1147
+ if (!auth)
1148
+ return;
1149
+ defaultRuntimeAuth = auth;
1150
+ const skills = await listSkills(auth.baseUrl, auth.apiKey, auth.headers);
1151
+ section = createSkillsPromptSection(skills);
1152
+ }
1153
+ catch (error) {
1154
+ if (isVerboseDiscovery()) {
1155
+ process.stderr.write(`LiteLLM: skipping Skills (${error instanceof Error ? error.message : String(error)}).\n`);
1156
+ }
1113
1157
  return;
1114
- defaultRuntimeAuth = auth;
1115
- const skills = await listSkills(defaultRuntimeAuth.baseUrl, defaultRuntimeAuth.apiKey, defaultRuntimeAuth.headers);
1116
- const section = createSkillsPromptSection(skills);
1158
+ }
1117
1159
  if (!section)
1118
1160
  return;
1119
1161
  return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
@@ -5,6 +5,9 @@ export type LiteLLMProviderOptions = {
5
5
  name: string;
6
6
  baseUrl: string;
7
7
  auth: ProviderAuth;
8
+ /** Catalog discovered during activation. Pi's startup refresh never allows network access, so
9
+ * without a seed the provider stays empty until the user opens /model. */
10
+ models?: readonly Model<LiteLLMApi>[];
8
11
  discover(credential: Credential, signal?: AbortSignal): Promise<DiscoveryResult & {
9
12
  baseUrl?: string;
10
13
  }>;
package/dist/provider.js CHANGED
@@ -15,7 +15,7 @@ export function createLiteLLMProvider(options) {
15
15
  name: options.name,
16
16
  baseUrl: options.baseUrl,
17
17
  auth: options.auth,
18
- models: [],
18
+ models: options.models ?? [],
19
19
  async fetchModels(context) {
20
20
  if (!context.credential)
21
21
  throw new Error("LiteLLM model discovery requires a credential");
package/dist/types.d.ts CHANGED
@@ -17,8 +17,6 @@ export interface DiscoveryOptions {
17
17
  timeoutMs?: number;
18
18
  signal?: AbortSignal;
19
19
  headers?: Record<string, string>;
20
- modelsDev?: boolean;
21
- modelsDevCachePath?: string;
22
20
  }
23
21
  export interface ModelInfoEntry {
24
22
  model_name?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-litellm",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "LiteLLM proxy provider extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/dist/cache.d.ts DELETED
@@ -1 +0,0 @@
1
- export declare function writeJsonAtomic(path: string, value: unknown): Promise<void>;
package/dist/cache.js DELETED
@@ -1,8 +0,0 @@
1
- import { mkdir, rename, writeFile } from "node:fs/promises";
2
- import { dirname } from "node:path";
3
- export async function writeJsonAtomic(path, value) {
4
- await mkdir(dirname(path), { recursive: true });
5
- const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
6
- await writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
7
- await rename(tmp, path);
8
- }