pi-provider-litellm 2.0.6 → 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
@@ -46,17 +46,17 @@ You'll be prompted for the base URL and API key. Credentials are persisted to `~
46
46
 
47
47
  #### Enterprise SSO login
48
48
 
49
- If your LiteLLM proxy requires SSO/OAuth authentication (enterprise deployments), you can authenticate via a browser SSO flow and optionally pair the resulting JWT with a stable virtual key:
49
+ If your LiteLLM proxy requires SSO/OAuth authentication (enterprise deployments), you can authenticate through LiteLLM's CLI SSO flow:
50
50
 
51
51
  1. Run `/login litellm` inside pi and select `Sign in with LiteLLM SSO`
52
52
  2. Enter the proxy URL
53
- 3. Your default browser opens the LiteLLM SSO login URL (e.g. `https://litellm.your-domain.com/sso/key/generate`) automatically — the URL is also displayed in case it can't be opened. Authenticate via SSO
54
- 4. Copy your token from the LiteLLM UI and paste it at the prompt (copying a full `Bearer ...` header value is fine — the prefix is stripped automatically)
55
- 5. When prompted to generate a virtual key, press Enter to accept (recommended) or enter `n` to use the JWT directly
53
+ 3. Pi displays a verification code and opens the LiteLLM SSO login URL automatically
54
+ 4. Authenticate in the browser and confirm the displayed code
55
+ 5. If your account belongs to multiple teams, select the team for this session in Pi
56
56
 
57
- When you generate a virtual key, the resulting `sk-...` key is stored as your credential and used for all API requests. If the proxy's key policy attaches an expiry to the generated key, Pi will prompt you to re-authenticate when it nears expiry; otherwise the key is treated as permanent until revoked in LiteLLM.
57
+ Pi polls the proxy until authentication completes and stores the returned short-lived CLI token. Newer proxies advertise the token lifetime; for older proxies, Pi uses `LITELLM_CLI_JWT_EXPIRATION_HOURS` or LiteLLM's 24-hour default. On LiteLLM versions that gate CLI SSO, start the proxy with `EXPERIMENTAL_UI_LOGIN=True`.
58
58
 
59
- When using a JWT directly, the extension reads its `exp` claim and Pi will prompt you to re-authenticate when the token nears expiry. Run `/login litellm` again to refresh.
59
+ Older proxies without `/sso/cli/start` fall back to the legacy browser flow: copy the token from the LiteLLM UI, paste it into Pi, and optionally exchange it for a virtual key. Pi reads JWT expiry claims and prompts you to run `/login litellm` again when re-authentication is required.
60
60
 
61
61
  ### Option B — environment variables
62
62
 
@@ -155,15 +155,13 @@ Treat the configured LiteLLM proxy as trusted: Skills can add instructions to th
155
155
  | `GOOGLE_APPLICATION_CREDENTIALS` | Google default ADC path | Optional path to an ADC JSON file used by `LITELLM_GCLOUD_TOKEN_AUTH`. If unset, the extension checks the default gcloud ADC locations. |
156
156
  | `LITELLM_OFFLINE` | unset | If `1`, disable all model and MCP discovery, including post-login discovery; use cached models only |
157
157
  | `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Background and explicit discovery fetch timeout in ms; `0` disables automatic discovery |
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 |
158
159
  | `LITELLM_VERBOSE_DISCOVERY` | unset | If `1`, enable progress messages during model and MCP discovery (login, refresh, startup); discovery is silent by default |
159
- | `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 |
160
160
 
161
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.
162
162
 
163
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`.
164
164
 
165
- 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.
166
-
167
165
  ### Google ADC token auth
168
166
 
169
167
  When your LiteLLM proxy accepts Google OAuth access tokens, you can let the extension refresh tokens from Application Default Credentials:
@@ -242,6 +240,8 @@ Opening `/model` refreshes configured provider catalogs in the background using
242
240
  | Discovery times out | Increase `LITELLM_DISCOVERY_TIMEOUT_MS` or set `LITELLM_OFFLINE=1` to fall back on cached models |
243
241
  | `401 Token expired` | Set `LITELLM_API_KEY_HELPER`. |
244
242
  | No models with gcloud auth | Verify `gcloud auth application-default login` has been run or set `GOOGLE_APPLICATION_CREDENTIALS` to an `authorized_user` ADC file |
243
+ | Enterprise SSO waits for token insertion | The proxy returned 404/405 for `/sso/cli/start`, so Pi used the legacy flow — upgrade LiteLLM or paste the UI token |
244
+ | Enterprise CLI SSO start/poll fails | Check the proxy logs and verify `/sso/cli/start` and `/sso/cli/poll/{login_id}` are reachable; only 404/405 falls back to legacy login |
245
245
  | Enterprise SSO login shows "virtual key generation failed" | The LiteLLM instance may lack a database (`/key/generate` requires one), your user account may lack key-generation permission, or the request timed out; the JWT is used directly as a fallback |
246
246
  | Enterprise SSO token prompt fails with "SSO token is required" | The token field was left empty — paste the token copied from the LiteLLM UI |
247
247
  | MCP tools not showing | Verify the proxy exposes `/mcp-rest/tools/list` and open `/model` after fixing the proxy |
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
@@ -1,13 +1,14 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
5
6
  import { setupLiteLLMCostTracking } from "./cost.js";
6
7
  import { discoverModels, isGpt55Model, isMoonshotModel, normalizeBaseUrl, shouldSuppressReasoningContent, } from "./discover.js";
7
8
  import { getGcloudToken, getGcloudTokenCacheKey, getGcloudTokenCommand, isGcloudTokenAuthEnabled, } from "./gcloud-token.js";
8
9
  import { getSessionIdFromFile } from "./litellm.js";
9
10
  import { createMcpToolDefinitions } from "./mcp-tools.js";
10
- import { createLiteLLMProvider } from "./provider.js";
11
+ import { createLiteLLMProvider, toNativeModels } from "./provider.js";
11
12
  import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
12
13
  const PROVIDER_NAME = "litellm";
13
14
  const SETTINGS_KEY = "litellm";
@@ -16,21 +17,18 @@ const ENV_API_KEY = "LITELLM_API_KEY";
16
17
  const ENV_API_KEY_HELPER = "LITELLM_API_KEY_HELPER";
17
18
  const ENV_HEADERS = "LITELLM_HEADERS";
18
19
  const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
20
+ const ENV_CLI_JWT_EXPIRATION_HOURS = "LITELLM_CLI_JWT_EXPIRATION_HOURS";
19
21
  const ENV_OFFLINE = "LITELLM_OFFLINE";
20
22
  const ENV_VERBOSE_DISCOVERY = "LITELLM_VERBOSE_DISCOVERY";
21
- const ENV_MODELS_DEV = "LITELLM_MODELS_DEV";
22
23
  const DEFAULT_TIMEOUT_MS = 5000;
24
+ const SEED_TIMEOUT_MS = 3000;
23
25
  const LOGIN_TIMEOUT_MS = 10_000;
24
- const MODELS_DEV_CACHE_FILENAME = "litellm-models-dev.json";
26
+ const CLI_SSO_POLL_INTERVAL_MS = 2_000;
27
+ const CLI_SSO_EXPIRES_IN_SECONDS = 600;
28
+ const DEFAULT_CLI_JWT_EXPIRATION_HOURS = 24;
25
29
  const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
26
30
  const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
27
31
  const EXPIRE_TOKEN_IMMEDIATELY = 0;
28
- function getModelsDevDiscoveryOptions() {
29
- return {
30
- modelsDev: process.env[ENV_MODELS_DEV] !== "0",
31
- modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME),
32
- };
33
- }
34
32
  function isPlainObject(value) {
35
33
  return typeof value === "object" && value !== null && !Array.isArray(value);
36
34
  }
@@ -125,10 +123,17 @@ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
125
123
  return opaqueFallback;
126
124
  }
127
125
  }
128
- async function generateVirtualKey(baseUrl, userToken, signal, headers) {
129
- const boundedSignal = signal
126
+ function configuredCliJwtExpiresAt() {
127
+ const configured = Number(process.env[ENV_CLI_JWT_EXPIRATION_HOURS]);
128
+ const hours = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_CLI_JWT_EXPIRATION_HOURS;
129
+ return Date.now() + hours * 60 * 60 * 1_000;
130
+ }
131
+ function boundedLoginSignal(signal) {
132
+ return signal
130
133
  ? AbortSignal.any([signal, AbortSignal.timeout(LOGIN_TIMEOUT_MS)])
131
134
  : AbortSignal.timeout(LOGIN_TIMEOUT_MS);
135
+ }
136
+ async function generateVirtualKey(baseUrl, userToken, signal, headers) {
132
137
  const response = await fetch(`${baseUrl}/key/generate`, {
133
138
  method: "POST",
134
139
  headers: {
@@ -137,7 +142,7 @@ async function generateVirtualKey(baseUrl, userToken, signal, headers) {
137
142
  "Content-Type": "application/json",
138
143
  },
139
144
  body: JSON.stringify({}),
140
- signal: boundedSignal,
145
+ signal: boundedLoginSignal(signal),
141
146
  });
142
147
  if (!response.ok) {
143
148
  throw new Error(`Virtual key generation failed (${response.status})`);
@@ -347,6 +352,19 @@ function getDiscoveryTimeoutMs() {
347
352
  function isOffline() {
348
353
  return process.env[ENV_OFFLINE] === "1";
349
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
+ }
350
368
  function isVerboseDiscovery() {
351
369
  return process.env[ENV_VERBOSE_DISCOVERY] === "1";
352
370
  }
@@ -400,15 +418,108 @@ async function loginApiKey(interaction) {
400
418
  throw new Error("Both base URL and API key are required");
401
419
  return { type: "api_key", key, env: { [ENV_BASE_URL]: normalizeBaseUrl(rawBaseUrl) } };
402
420
  }
403
- async function loginOAuth(interaction, headers) {
404
- const rawBaseUrl = (await interaction.prompt({
405
- type: "text",
406
- message: "Enter LiteLLM proxy URL (no trailing /v1):",
407
- placeholder: "https://litellm.example.com",
408
- })).trim();
409
- if (!rawBaseUrl)
410
- throw new Error("Base URL is required");
411
- const baseUrl = normalizeBaseUrl(rawBaseUrl);
421
+ async function startCliSso(baseUrl, signal, headers) {
422
+ const response = await fetch(`${baseUrl}/sso/cli/start`, {
423
+ method: "POST",
424
+ headers,
425
+ signal: boundedLoginSignal(signal),
426
+ });
427
+ if (response.status === 404 || response.status === 405)
428
+ return undefined;
429
+ if (!response.ok)
430
+ throw new Error(`LiteLLM CLI SSO start failed (HTTP ${response.status})`);
431
+ const data = (await response.json());
432
+ if (typeof data.login_id !== "string" ||
433
+ !data.login_id ||
434
+ typeof data.poll_secret !== "string" ||
435
+ !data.poll_secret ||
436
+ typeof data.user_code !== "string" ||
437
+ !data.user_code) {
438
+ throw new Error("LiteLLM CLI SSO start returned an invalid response");
439
+ }
440
+ return {
441
+ loginId: data.login_id,
442
+ pollSecret: data.poll_secret,
443
+ userCode: data.user_code,
444
+ expiresInSeconds: typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0
445
+ ? data.expires_in
446
+ : CLI_SSO_EXPIRES_IN_SECONDS,
447
+ };
448
+ }
449
+ async function waitForNextCliSsoPoll(interaction) {
450
+ try {
451
+ await delay(CLI_SSO_POLL_INTERVAL_MS, undefined, interaction.signal ? { signal: interaction.signal } : undefined);
452
+ }
453
+ catch (error) {
454
+ if (interaction.signal?.aborted)
455
+ throw interaction.signal.reason;
456
+ throw error;
457
+ }
458
+ }
459
+ async function pollCliSso(baseUrl, start, interaction, headers) {
460
+ const deadline = Date.now() + start.expiresInSeconds * 1_000;
461
+ const pollUrl = new URL(`${baseUrl}/sso/cli/poll/${encodeURIComponent(start.loginId)}`);
462
+ let teamId;
463
+ while (Date.now() < deadline) {
464
+ if (teamId)
465
+ pollUrl.searchParams.set("team_id", teamId);
466
+ let response;
467
+ try {
468
+ response = await fetch(pollUrl, {
469
+ headers: { ...headers, "x-litellm-cli-poll-secret": start.pollSecret },
470
+ signal: boundedLoginSignal(interaction.signal),
471
+ });
472
+ }
473
+ catch {
474
+ if (interaction.signal?.aborted)
475
+ throw interaction.signal.reason;
476
+ await waitForNextCliSsoPoll(interaction);
477
+ continue;
478
+ }
479
+ if (response.status === 429 || response.status >= 500) {
480
+ await waitForNextCliSsoPoll(interaction);
481
+ continue;
482
+ }
483
+ if (response.status === 400)
484
+ throw new Error("LiteLLM CLI SSO login expired or is invalid");
485
+ if (!response.ok)
486
+ throw new Error(`LiteLLM CLI SSO polling failed (HTTP ${response.status})`);
487
+ const data = (await response.json());
488
+ if (data.status === "ready" && typeof data.key === "string" && data.key) {
489
+ return {
490
+ access: data.key,
491
+ expiresInSeconds: typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0
492
+ ? data.expires_in
493
+ : undefined,
494
+ };
495
+ }
496
+ if (data.status === "ready" && data.requires_team_selection === true && !teamId) {
497
+ const details = Array.isArray(data.team_details) ? data.team_details : [];
498
+ const teams = details.flatMap((team) => {
499
+ if (!team || typeof team !== "object")
500
+ return [];
501
+ const record = team;
502
+ const id = typeof record.team_id === "string" ? record.team_id : record.id;
503
+ const alias = record.team_alias;
504
+ return typeof id === "string" && id ? [{ id, label: typeof alias === "string" && alias ? alias : id }] : [];
505
+ });
506
+ if (teams.length === 0 && Array.isArray(data.teams))
507
+ teams.push(...data.teams.flatMap((id) => (typeof id === "string" && id ? [{ id, label: id }] : [])));
508
+ if (teams.length === 0)
509
+ throw new Error("LiteLLM CLI SSO requested team selection without any teams");
510
+ const selected = await interaction.prompt({ type: "select", message: "Select a LiteLLM team:", options: teams });
511
+ if (!teams.some((team) => team.id === selected))
512
+ throw new Error("Invalid LiteLLM team selection");
513
+ teamId = selected;
514
+ continue;
515
+ }
516
+ if (data.status !== "pending")
517
+ throw new Error("LiteLLM CLI SSO polling returned an invalid response");
518
+ await waitForNextCliSsoPoll(interaction);
519
+ }
520
+ throw new Error("LiteLLM CLI SSO login expired");
521
+ }
522
+ async function loginWithPastedToken(interaction, baseUrl, headers) {
412
523
  interaction.notify({
413
524
  type: "auth_url",
414
525
  url: `${baseUrl}/sso/key/generate`,
@@ -448,6 +559,35 @@ async function loginOAuth(interaction, headers) {
448
559
  }
449
560
  return { type: "oauth", access, refresh: "", expires, baseUrl };
450
561
  }
562
+ async function loginOAuth(interaction, headers) {
563
+ const rawBaseUrl = (await interaction.prompt({
564
+ type: "text",
565
+ message: "Enter LiteLLM proxy URL (no trailing /v1):",
566
+ placeholder: "https://litellm.example.com",
567
+ })).trim();
568
+ if (!rawBaseUrl)
569
+ throw new Error("Base URL is required");
570
+ const baseUrl = normalizeBaseUrl(rawBaseUrl);
571
+ const cliSso = await startCliSso(baseUrl, interaction.signal, headers);
572
+ if (!cliSso)
573
+ return loginWithPastedToken(interaction, baseUrl, headers);
574
+ interaction.notify({
575
+ type: "device_code",
576
+ userCode: cliSso.userCode,
577
+ verificationUri: `${baseUrl}/sso/key/generate?${new URLSearchParams({ source: "litellm-cli", key: cliSso.loginId })}`,
578
+ expiresInSeconds: cliSso.expiresInSeconds,
579
+ });
580
+ const result = await pollCliSso(baseUrl, cliSso, interaction, headers);
581
+ return {
582
+ type: "oauth",
583
+ access: result.access,
584
+ refresh: "",
585
+ expires: result.expiresInSeconds
586
+ ? Date.now() + result.expiresInSeconds * 1_000
587
+ : tokenExpiresAt(result.access, configuredCliJwtExpiresAt()),
588
+ baseUrl,
589
+ };
590
+ }
451
591
  async function refreshLiteLLM(credentials, _signal) {
452
592
  if (!credentials.refresh.startsWith("!")) {
453
593
  if (credentials.expires < PERMANENT_TOKEN_EXPIRES_AT) {
@@ -801,14 +941,14 @@ export default async function (pi) {
801
941
  "https://litellm.example.com";
802
942
  return `${normalizeBaseUrl(baseUrl)}/v1`;
803
943
  }
804
- async function authForCredential(definition, credential) {
805
- if (credential.type === "oauth") {
944
+ async function authForCredential(definition, credential, executeHelpers = true) {
945
+ if (credential?.type === "oauth") {
806
946
  const baseUrl = typeof credential.baseUrl === "string"
807
947
  ? normalizeBaseUrl(credential.baseUrl)
808
948
  : normalizeBaseUrl(requestBaseUrl(definition));
809
949
  return { baseUrl, apiKey: credential.access, headers: resolveHeaders(definition) };
810
950
  }
811
- 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);
812
952
  if (!resolved?.auth.apiKey) {
813
953
  throw new Error(`no credentials for ${definition.name}. Run /login litellm or set env vars.`);
814
954
  }
@@ -911,27 +1051,52 @@ export default async function (pi) {
911
1051
  mcpRegistration = undefined;
912
1052
  }
913
1053
  }
914
- 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()) {
915
1089
  const provider = createLiteLLMProvider({
916
1090
  id: definition.name,
917
1091
  name: definition.displayName,
918
1092
  baseUrl: requestBaseUrl(definition),
919
1093
  auth: createProviderAuth(definition),
1094
+ models: seeded[index],
920
1095
  discover: async (credential, signal) => {
921
1096
  const disabledReason = discoveryDisabledReason();
922
1097
  if (disabledReason)
923
1098
  throw new Error(`discovery disabled (${disabledReason})`);
924
- const auth = await authForCredential(definition, credential);
925
- const result = await discoverModels(auth.baseUrl, auth.apiKey, {
926
- ...getModelsDevDiscoveryOptions(),
927
- timeoutMs: getDiscoveryTimeoutMs(),
928
- signal,
929
- headers: auth.headers,
930
- silent: !isVerboseDiscovery(),
931
- onProgress: isVerboseDiscovery() ? (message) => process.stderr.write(`LiteLLM: ${message}\n`) : undefined,
932
- });
933
- signal?.throwIfAborted();
934
- return { ...result, baseUrl: `${normalizeBaseUrl(auth.baseUrl)}/v1` };
1099
+ return runDiscovery(await authForCredential(definition, credential), signal);
935
1100
  },
936
1101
  });
937
1102
  Object.assign(provider, { headers: resolveHeaders(definition) });
@@ -970,16 +1135,27 @@ export default async function (pi) {
970
1135
  return;
971
1136
  return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
972
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.
973
1140
  pi.on("before_agent_start", async (event, ctx) => {
974
1141
  defaultRuntimeAuth = undefined;
975
1142
  if (!skillsEnabled || discoveryDisabledReason())
976
1143
  return;
977
- const auth = await getRuntimeAuth(ctx);
978
- 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
+ }
979
1157
  return;
980
- defaultRuntimeAuth = auth;
981
- const skills = await listSkills(defaultRuntimeAuth.baseUrl, defaultRuntimeAuth.apiKey, defaultRuntimeAuth.headers);
982
- const section = createSkillsPromptSection(skills);
1158
+ }
983
1159
  if (!section)
984
1160
  return;
985
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.0.6",
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
- }