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.
- package/README.md +9 -6
- package/dist/index.js +146 -12
- 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
|
|
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.
|
|
54
|
-
4.
|
|
55
|
-
5.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
const
|
|
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:
|
|
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
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
})
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
|
|
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) {
|