pi-provider-litellm 2.0.6 → 2.1.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.
Files changed (3) hide show
  1. package/README.md +9 -6
  2. package/dist/index.js +146 -12
  3. package/package.json +1 -1
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,6 +155,7 @@ 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
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 |
160
161
 
@@ -242,6 +243,8 @@ Opening `/model` refreshes configured provider catalogs in the background using
242
243
  | Discovery times out | Increase `LITELLM_DISCOVERY_TIMEOUT_MS` or set `LITELLM_OFFLINE=1` to fall back on cached models |
243
244
  | `401 Token expired` | Set `LITELLM_API_KEY_HELPER`. |
244
245
  | 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 |
246
+ | 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 |
247
+ | 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
248
  | 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
249
  | 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
250
  | MCP tools not showing | Verify the proxy exposes `/mcp-rest/tools/list` and open `/model` after fixing the proxy |
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
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 { setTimeout as delay } from "node:timers/promises";
4
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
6
  import { setupLiteLLMCostTracking } from "./cost.js";
6
7
  import { discoverModels, isGpt55Model, isMoonshotModel, normalizeBaseUrl, shouldSuppressReasoningContent, } from "./discover.js";
@@ -16,11 +17,15 @@ 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
23
  const ENV_MODELS_DEV = "LITELLM_MODELS_DEV";
22
24
  const DEFAULT_TIMEOUT_MS = 5000;
23
25
  const LOGIN_TIMEOUT_MS = 10_000;
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;
24
29
  const MODELS_DEV_CACHE_FILENAME = "litellm-models-dev.json";
25
30
  const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
26
31
  const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
@@ -125,10 +130,17 @@ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
125
130
  return opaqueFallback;
126
131
  }
127
132
  }
128
- async function generateVirtualKey(baseUrl, userToken, signal, headers) {
129
- const boundedSignal = signal
133
+ function configuredCliJwtExpiresAt() {
134
+ const configured = Number(process.env[ENV_CLI_JWT_EXPIRATION_HOURS]);
135
+ const hours = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_CLI_JWT_EXPIRATION_HOURS;
136
+ return Date.now() + hours * 60 * 60 * 1_000;
137
+ }
138
+ function boundedLoginSignal(signal) {
139
+ return signal
130
140
  ? AbortSignal.any([signal, AbortSignal.timeout(LOGIN_TIMEOUT_MS)])
131
141
  : AbortSignal.timeout(LOGIN_TIMEOUT_MS);
142
+ }
143
+ async function generateVirtualKey(baseUrl, userToken, signal, headers) {
132
144
  const response = await fetch(`${baseUrl}/key/generate`, {
133
145
  method: "POST",
134
146
  headers: {
@@ -137,7 +149,7 @@ async function generateVirtualKey(baseUrl, userToken, signal, headers) {
137
149
  "Content-Type": "application/json",
138
150
  },
139
151
  body: JSON.stringify({}),
140
- signal: boundedSignal,
152
+ signal: boundedLoginSignal(signal),
141
153
  });
142
154
  if (!response.ok) {
143
155
  throw new Error(`Virtual key generation failed (${response.status})`);
@@ -400,15 +412,108 @@ async function loginApiKey(interaction) {
400
412
  throw new Error("Both base URL and API key are required");
401
413
  return { type: "api_key", key, env: { [ENV_BASE_URL]: normalizeBaseUrl(rawBaseUrl) } };
402
414
  }
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);
415
+ async function startCliSso(baseUrl, signal, headers) {
416
+ const response = await fetch(`${baseUrl}/sso/cli/start`, {
417
+ method: "POST",
418
+ headers,
419
+ signal: boundedLoginSignal(signal),
420
+ });
421
+ if (response.status === 404 || response.status === 405)
422
+ return undefined;
423
+ if (!response.ok)
424
+ throw new Error(`LiteLLM CLI SSO start failed (HTTP ${response.status})`);
425
+ const data = (await response.json());
426
+ if (typeof data.login_id !== "string" ||
427
+ !data.login_id ||
428
+ typeof data.poll_secret !== "string" ||
429
+ !data.poll_secret ||
430
+ typeof data.user_code !== "string" ||
431
+ !data.user_code) {
432
+ throw new Error("LiteLLM CLI SSO start returned an invalid response");
433
+ }
434
+ return {
435
+ loginId: data.login_id,
436
+ pollSecret: data.poll_secret,
437
+ userCode: data.user_code,
438
+ expiresInSeconds: typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0
439
+ ? data.expires_in
440
+ : CLI_SSO_EXPIRES_IN_SECONDS,
441
+ };
442
+ }
443
+ async function waitForNextCliSsoPoll(interaction) {
444
+ try {
445
+ await delay(CLI_SSO_POLL_INTERVAL_MS, undefined, interaction.signal ? { signal: interaction.signal } : undefined);
446
+ }
447
+ catch (error) {
448
+ if (interaction.signal?.aborted)
449
+ throw interaction.signal.reason;
450
+ throw error;
451
+ }
452
+ }
453
+ async function pollCliSso(baseUrl, start, interaction, headers) {
454
+ const deadline = Date.now() + start.expiresInSeconds * 1_000;
455
+ const pollUrl = new URL(`${baseUrl}/sso/cli/poll/${encodeURIComponent(start.loginId)}`);
456
+ let teamId;
457
+ while (Date.now() < deadline) {
458
+ if (teamId)
459
+ pollUrl.searchParams.set("team_id", teamId);
460
+ let response;
461
+ try {
462
+ response = await fetch(pollUrl, {
463
+ headers: { ...headers, "x-litellm-cli-poll-secret": start.pollSecret },
464
+ signal: boundedLoginSignal(interaction.signal),
465
+ });
466
+ }
467
+ catch {
468
+ if (interaction.signal?.aborted)
469
+ throw interaction.signal.reason;
470
+ await waitForNextCliSsoPoll(interaction);
471
+ continue;
472
+ }
473
+ if (response.status === 429 || response.status >= 500) {
474
+ await waitForNextCliSsoPoll(interaction);
475
+ continue;
476
+ }
477
+ if (response.status === 400)
478
+ throw new Error("LiteLLM CLI SSO login expired or is invalid");
479
+ if (!response.ok)
480
+ throw new Error(`LiteLLM CLI SSO polling failed (HTTP ${response.status})`);
481
+ const data = (await response.json());
482
+ if (data.status === "ready" && typeof data.key === "string" && data.key) {
483
+ return {
484
+ access: data.key,
485
+ expiresInSeconds: typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0
486
+ ? data.expires_in
487
+ : undefined,
488
+ };
489
+ }
490
+ if (data.status === "ready" && data.requires_team_selection === true && !teamId) {
491
+ const details = Array.isArray(data.team_details) ? data.team_details : [];
492
+ const teams = details.flatMap((team) => {
493
+ if (!team || typeof team !== "object")
494
+ return [];
495
+ const record = team;
496
+ const id = typeof record.team_id === "string" ? record.team_id : record.id;
497
+ const alias = record.team_alias;
498
+ return typeof id === "string" && id ? [{ id, label: typeof alias === "string" && alias ? alias : id }] : [];
499
+ });
500
+ if (teams.length === 0 && Array.isArray(data.teams))
501
+ teams.push(...data.teams.flatMap((id) => (typeof id === "string" && id ? [{ id, label: id }] : [])));
502
+ if (teams.length === 0)
503
+ throw new Error("LiteLLM CLI SSO requested team selection without any teams");
504
+ const selected = await interaction.prompt({ type: "select", message: "Select a LiteLLM team:", options: teams });
505
+ if (!teams.some((team) => team.id === selected))
506
+ throw new Error("Invalid LiteLLM team selection");
507
+ teamId = selected;
508
+ continue;
509
+ }
510
+ if (data.status !== "pending")
511
+ throw new Error("LiteLLM CLI SSO polling returned an invalid response");
512
+ await waitForNextCliSsoPoll(interaction);
513
+ }
514
+ throw new Error("LiteLLM CLI SSO login expired");
515
+ }
516
+ async function loginWithPastedToken(interaction, baseUrl, headers) {
412
517
  interaction.notify({
413
518
  type: "auth_url",
414
519
  url: `${baseUrl}/sso/key/generate`,
@@ -448,6 +553,35 @@ async function loginOAuth(interaction, headers) {
448
553
  }
449
554
  return { type: "oauth", access, refresh: "", expires, baseUrl };
450
555
  }
556
+ async function loginOAuth(interaction, headers) {
557
+ const rawBaseUrl = (await interaction.prompt({
558
+ type: "text",
559
+ message: "Enter LiteLLM proxy URL (no trailing /v1):",
560
+ placeholder: "https://litellm.example.com",
561
+ })).trim();
562
+ if (!rawBaseUrl)
563
+ throw new Error("Base URL is required");
564
+ const baseUrl = normalizeBaseUrl(rawBaseUrl);
565
+ const cliSso = await startCliSso(baseUrl, interaction.signal, headers);
566
+ if (!cliSso)
567
+ return loginWithPastedToken(interaction, baseUrl, headers);
568
+ interaction.notify({
569
+ type: "device_code",
570
+ userCode: cliSso.userCode,
571
+ verificationUri: `${baseUrl}/sso/key/generate?${new URLSearchParams({ source: "litellm-cli", key: cliSso.loginId })}`,
572
+ expiresInSeconds: cliSso.expiresInSeconds,
573
+ });
574
+ const result = await pollCliSso(baseUrl, cliSso, interaction, headers);
575
+ return {
576
+ type: "oauth",
577
+ access: result.access,
578
+ refresh: "",
579
+ expires: result.expiresInSeconds
580
+ ? Date.now() + result.expiresInSeconds * 1_000
581
+ : tokenExpiresAt(result.access, configuredCliJwtExpiresAt()),
582
+ baseUrl,
583
+ };
584
+ }
451
585
  async function refreshLiteLLM(credentials, _signal) {
452
586
  if (!credentials.refresh.startsWith("!")) {
453
587
  if (credentials.expires < PERMANENT_TOKEN_EXPIRES_AT) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-litellm",
3
- "version": "2.0.6",
3
+ "version": "2.1.0",
4
4
  "description": "LiteLLM proxy provider extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",