pi-provider-litellm 1.2.3 → 1.2.5

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
@@ -61,6 +61,7 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
61
61
 
62
62
  | Variable | Default | Effect |
63
63
  |---|---|---|
64
+ | `LITELLM_API_KEY_HELPER` | unset | Command that prints a fresh LiteLLM bearer token. Takes precedence over `LITELLM_API_KEY`. Registered as a `!command` provider key; Pi re-runs it on every request (the per-request auth path is uncached), so rotating/short-lived tokens stay fresh. |
64
65
  | `LITELLM_OFFLINE` | unset | If `1`, skip discovery on this start; use cache only |
65
66
  | `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Discovery fetch timeout in ms; `0` to skip discovery |
66
67
 
@@ -117,6 +118,7 @@ If the cache is older than 24 hours, the extension refreshes it in the backgroun
117
118
  | "discovered no models" | Proxy returned an empty list — check pi's startup log for the actual response |
118
119
  | `/model/info` returning 401/403/404 | Expected behavior with virtual keys — extension falls back to `/v1/models` |
119
120
  | Discovery times out | Increase `LITELLM_DISCOVERY_TIMEOUT_MS` or set `LITELLM_OFFLINE=1` to fall back on cached models |
121
+ | `401 Token expired` | Set `LITELLM_API_KEY_HELPER`. |
120
122
 
121
123
  ## License
122
124
 
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { execSync } from "node:child_process";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { AuthStorage, getAgentDir } from "@earendil-works/pi-coding-agent";
@@ -8,12 +9,16 @@ import { getSessionIdFromFile } from "./litellm.js";
8
9
  const PROVIDER_NAME = "litellm";
9
10
  const ENV_BASE_URL = "LITELLM_BASE_URL";
10
11
  const ENV_API_KEY = "LITELLM_API_KEY";
12
+ const ENV_API_KEY_HELPER = "LITELLM_API_KEY_HELPER";
11
13
  const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
12
14
  const ENV_OFFLINE = "LITELLM_OFFLINE";
13
15
  const DEFAULT_TIMEOUT_MS = 5000;
14
16
  const LOGIN_TIMEOUT_MS = 10_000;
15
17
  const CACHE_FILENAME = "litellm-models.json";
16
18
  const CACHE_STALE_MS = 24 * 60 * 60 * 1000;
19
+ const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
20
+ const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
21
+ const EXPIRE_TOKEN_IMMEDIATELY = 0;
17
22
  function getAuthPath() {
18
23
  return join(getAgentDir(), "auth.json");
19
24
  }
@@ -30,20 +35,74 @@ async function readAuthEntry() {
30
35
  return undefined;
31
36
  }
32
37
  }
33
- async function resolveCredentials() {
38
+ function cleanConfig(raw) {
39
+ const trimmed = raw?.trim();
40
+ return trimmed && trimmed !== "undefined" ? trimmed : undefined;
41
+ }
42
+ function normalizeCommand(raw) {
43
+ const trimmed = cleanConfig(raw);
44
+ if (!trimmed)
45
+ return undefined;
46
+ return trimmed.startsWith("!") ? trimmed : `!${trimmed}`;
47
+ }
48
+ function getApiKeyHelperCommand() {
49
+ return normalizeCommand(process.env[ENV_API_KEY_HELPER]);
50
+ }
51
+ function executeApiKeyCommand(commandConfig) {
52
+ const command = commandConfig.startsWith("!") ? commandConfig.slice(1) : commandConfig;
53
+ const output = execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim();
54
+ if (!output)
55
+ throw new Error(`LiteLLM API key helper produced no output: ${command}`);
56
+ return output;
57
+ }
58
+ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
59
+ const [, payload] = apiKey.split(".");
60
+ if (!payload)
61
+ return opaqueFallback;
62
+ try {
63
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
64
+ return typeof claims.exp === "number"
65
+ ? Math.max(Date.now(), claims.exp * 1000 - TOKEN_REFRESH_LEAD_MS)
66
+ : opaqueFallback;
67
+ }
68
+ catch {
69
+ return opaqueFallback;
70
+ }
71
+ }
72
+ function getLiteLLMApiKey(credentials) {
73
+ return credentials.access;
74
+ }
75
+ function resolveOAuthApiKey(credentials) {
76
+ return credentials.refresh.startsWith("!")
77
+ ? executeApiKeyCommand(credentials.refresh)
78
+ : getLiteLLMApiKey(credentials);
79
+ }
80
+ async function resolveCredentials({ executeHelpers = true } = {}) {
34
81
  const entry = await readAuthEntry();
35
- const envBase = process.env[ENV_BASE_URL]?.trim();
36
- const envKey = process.env[ENV_API_KEY]?.trim();
82
+ const envBase = cleanConfig(process.env[ENV_BASE_URL]);
83
+ const envKey = cleanConfig(process.env[ENV_API_KEY]);
84
+ const envHelperCommand = getApiKeyHelperCommand();
37
85
  const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
38
86
  const authKey = entry?.type === "oauth"
39
- ? entry.access?.trim()
87
+ ? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
40
88
  : entry?.type === "api_key"
41
89
  ? (await AuthStorage.create(getAuthPath()).getApiKey(PROVIDER_NAME, { includeFallback: false }))?.trim()
42
90
  : undefined;
91
+ const apiKey = authKey || (executeHelpers && envHelperCommand ? executeApiKeyCommand(envHelperCommand) : undefined) || envKey;
92
+ const apiKeyFingerprint = entry?.type === "oauth" && entry.refresh.startsWith("!")
93
+ ? fingerprint(entry.refresh)
94
+ : authKey
95
+ ? fingerprint(authKey)
96
+ : envHelperCommand
97
+ ? fingerprint(envHelperCommand)
98
+ : envKey
99
+ ? fingerprint(envKey)
100
+ : undefined;
43
101
  const rawBase = authBase || envBase;
44
102
  return {
45
103
  baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
46
- apiKey: authKey || envKey || undefined,
104
+ apiKey: apiKey || undefined,
105
+ apiKeyFingerprint,
47
106
  };
48
107
  }
49
108
  function getDiscoveryTimeoutMs() {
@@ -78,17 +137,19 @@ async function loginLiteLLM(callbacks, onCacheWrite) {
78
137
  message: "Enter LiteLLM proxy URL (no trailing /v1):",
79
138
  placeholder: "https://litellm.example.com",
80
139
  })).trim();
81
- const apiKey = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
82
- if (!rawBaseUrl || !apiKey)
140
+ const apiKeyInput = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
141
+ if (!rawBaseUrl || !apiKeyInput)
83
142
  throw new Error("Both base URL and API key are required");
84
143
  const baseUrl = normalizeBaseUrl(rawBaseUrl);
144
+ const refresh = apiKeyInput.startsWith("!") ? apiKeyInput : "";
145
+ const apiKey = refresh ? executeApiKeyCommand(refresh) : apiKeyInput;
85
146
  const { models, source } = await discoverModels(baseUrl, apiKey, {
86
147
  timeoutMs: LOGIN_TIMEOUT_MS,
87
148
  signal: callbacks.signal,
88
149
  });
89
150
  const cache = {
90
151
  baseUrl,
91
- apiKeyFingerprint: fingerprint(apiKey),
152
+ apiKeyFingerprint: fingerprint(refresh || apiKey),
92
153
  fetchedAt: Date.now(),
93
154
  source,
94
155
  models,
@@ -98,13 +159,16 @@ async function loginLiteLLM(callbacks, onCacheWrite) {
98
159
  callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
99
160
  return {
100
161
  access: apiKey,
101
- refresh: "",
102
- expires: Number.MAX_SAFE_INTEGER,
162
+ refresh,
163
+ expires: tokenExpiresAt(apiKey, refresh ? EXPIRE_TOKEN_IMMEDIATELY : PERMANENT_TOKEN_EXPIRES_AT),
103
164
  baseUrl,
104
165
  };
105
166
  }
106
167
  async function refreshLiteLLM(credentials) {
107
- return credentials;
168
+ if (!credentials.refresh.startsWith("!"))
169
+ return credentials;
170
+ const access = executeApiKeyCommand(credentials.refresh);
171
+ return { ...credentials, access, expires: tokenExpiresAt(access, EXPIRE_TOKEN_IMMEDIATELY) };
108
172
  }
109
173
  function modifyLiteLLMModels(models, cred) {
110
174
  const baseUrl = cred.baseUrl;
@@ -194,9 +258,9 @@ function normalizeThinkTags(message) {
194
258
  return { ...message, content };
195
259
  }
196
260
  export default async function (pi) {
197
- const creds = await resolveCredentials();
261
+ let creds = await resolveCredentials({ executeHelpers: false });
198
262
  const cache = await readCache(getCachePath());
199
- const fp = creds.apiKey ? fingerprint(creds.apiKey) : undefined;
263
+ let fp = creds.apiKeyFingerprint;
200
264
  let cacheFetchedAt = cache?.fetchedAt ?? 0;
201
265
  const cacheValid = cache !== null &&
202
266
  creds.baseUrl !== undefined &&
@@ -204,61 +268,86 @@ export default async function (pi) {
204
268
  cache.baseUrl === creds.baseUrl &&
205
269
  cache.apiKeyFingerprint === fp;
206
270
  let models = cacheValid && cache ? cache.models : [];
207
- const haveCreds = creds.baseUrl !== undefined && creds.apiKey !== undefined && fp !== undefined;
208
- const shouldFetch = haveCreds && !isOffline() && (!cacheValid || isListModelsMode());
209
- if (shouldFetch && creds.baseUrl && creds.apiKey && fp) {
271
+ const shouldFetch = creds.baseUrl !== undefined &&
272
+ fp !== undefined &&
273
+ !isOffline() &&
274
+ getDiscoveryTimeoutMs() > 0 &&
275
+ (!cacheValid || isListModelsMode());
276
+ let credentialWarning;
277
+ if (shouldFetch) {
278
+ try {
279
+ creds = await resolveCredentials();
280
+ fp = creds.apiKeyFingerprint;
281
+ }
282
+ catch (error) {
283
+ if (!cacheValid || !cache)
284
+ throw error;
285
+ credentialWarning = error instanceof Error ? error.message : String(error);
286
+ process.stderr.write(`LiteLLM: discovery failed (${credentialWarning}); using cached models.\n`);
287
+ models = cache.models;
288
+ }
289
+ }
290
+ if (shouldFetch && !credentialWarning && creds.baseUrl && creds.apiKey && fp) {
210
291
  const timeoutMs = getDiscoveryTimeoutMs();
211
- if (timeoutMs > 0) {
212
- const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
213
- timeoutMs,
214
- });
215
- if (warning) {
216
- if (cacheValid && cache) {
217
- process.stderr.write(`LiteLLM: discovery failed (${warning}); using cached models.\n`);
218
- models = cache.models;
219
- }
220
- else {
221
- process.stderr.write(`LiteLLM: discovery failed (${warning}); registering provider with no models.\n`);
222
- models = [];
223
- }
292
+ const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
293
+ timeoutMs,
294
+ });
295
+ if (warning) {
296
+ if (cacheValid && cache) {
297
+ process.stderr.write(`LiteLLM: discovery failed (${warning}); using cached models.\n`);
298
+ models = cache.models;
224
299
  }
225
300
  else {
226
- models = result.models;
227
- const next = {
228
- baseUrl: creds.baseUrl,
229
- apiKeyFingerprint: fp,
230
- fetchedAt: Date.now(),
231
- source: result.source,
232
- models: result.models,
233
- };
234
- await writeCache(getCachePath(), next);
235
- cacheFetchedAt = next.fetchedAt;
236
- if (isListModelsMode()) {
237
- process.stderr.write(`LiteLLM: ${result.models.length} models discovered (source: ${result.source}).\n`);
238
- }
301
+ process.stderr.write(`LiteLLM: discovery failed (${warning}); registering provider with no models.\n`);
302
+ models = [];
303
+ }
304
+ }
305
+ else {
306
+ models = result.models;
307
+ const next = {
308
+ baseUrl: creds.baseUrl,
309
+ apiKeyFingerprint: fp,
310
+ fetchedAt: Date.now(),
311
+ source: result.source,
312
+ models: result.models,
313
+ };
314
+ await writeCache(getCachePath(), next);
315
+ cacheFetchedAt = next.fetchedAt;
316
+ if (isListModelsMode()) {
317
+ process.stderr.write(`LiteLLM: ${result.models.length} models discovered (source: ${result.source}).\n`);
239
318
  }
240
319
  }
241
320
  }
321
+ let updateCosts = () => undefined;
242
322
  const oauth = {
243
323
  name: "LiteLLM",
244
324
  login: (callbacks) => loginLiteLLM(callbacks, (next) => {
245
325
  cacheFetchedAt = next.fetchedAt;
326
+ registerProvider(next.baseUrl, next.models);
327
+ updateCosts(next.models);
246
328
  }),
247
329
  refreshToken: refreshLiteLLM,
248
- getApiKey: (cred) => cred.access,
330
+ getApiKey: getLiteLLMApiKey,
249
331
  modifyModels: modifyLiteLLMModels,
250
332
  };
251
333
  function registerProvider(baseUrl, models) {
252
334
  pi.registerProvider(PROVIDER_NAME, {
253
335
  baseUrl: baseUrl ? `${baseUrl}/v1` : "https://litellm.example.com/v1",
254
- apiKey: ENV_API_KEY,
336
+ // When LITELLM_API_KEY_HELPER is set we register the helper as a `!command` provider key.
337
+ // Pi's per-request auth path (ModelRegistry.getApiKeyAndHeaders) resolves provider keys via
338
+ // resolveConfigValueOrThrow -> resolveConfigValueUncached, i.e. the command is re-executed on
339
+ // every request (it does NOT use the process-lifetime command cache, which only applies to
340
+ // resolveConfigValue). So a short-lived/rotating helper token stays fresh. The OAuth hooks
341
+ // remain registered for `/login litellm` users. See the regression test
342
+ // "re-runs the helper command on every request" in tests/index.test.ts.
343
+ apiKey: getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`,
255
344
  api: "openai-completions",
256
345
  models,
257
346
  oauth,
258
347
  });
259
348
  }
260
349
  registerProvider(creds.baseUrl, models);
261
- const updateCosts = setupLiteLLMCostTracking(pi, models);
350
+ updateCosts = setupLiteLLMCostTracking(pi, models);
262
351
  let refreshInProgress = null;
263
352
  function discoveryDisabledReason() {
264
353
  if (isOffline())
@@ -269,7 +358,7 @@ export default async function (pi) {
269
358
  }
270
359
  async function refreshModelsAndCosts() {
271
360
  const fresh = await resolveCredentials();
272
- const freshFp = fresh.apiKey ? fingerprint(fresh.apiKey) : undefined;
361
+ const freshFp = fresh.apiKeyFingerprint;
273
362
  if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
274
363
  throw new Error("no credentials. Run /login litellm or set env vars.");
275
364
  }
package/dist/types.d.ts CHANGED
@@ -52,4 +52,5 @@ export type AuthFileEntry = {
52
52
  export interface ResolvedCredentials {
53
53
  baseUrl?: string;
54
54
  apiKey?: string;
55
+ apiKeyFingerprint?: string;
55
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-litellm",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "LiteLLM proxy provider extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,8 +55,8 @@
55
55
  "node": ">=22.19.0"
56
56
  },
57
57
  "peerDependencies": {
58
- "@earendil-works/pi-ai": "^0.75.4",
59
- "@earendil-works/pi-coding-agent": "^0.75.4"
58
+ "@earendil-works/pi-ai": "*",
59
+ "@earendil-works/pi-coding-agent": "*"
60
60
  },
61
61
  "overrides": {
62
62
  "basic-ftp": "6.0.1",
@@ -64,11 +64,11 @@
64
64
  "protobufjs": "8.2.1"
65
65
  },
66
66
  "devDependencies": {
67
- "@earendil-works/pi-ai": "^0.75.4",
68
- "@earendil-works/pi-coding-agent": "^0.75.4",
67
+ "@earendil-works/pi-ai": "^0.78.1",
68
+ "@earendil-works/pi-coding-agent": "^0.78.1",
69
69
  "@biomejs/biome": "^2.4.15",
70
70
  "@types/node": "^25.9.1",
71
- "@typescript/native-preview": "7.0.0-dev.20260519.1",
71
+ "@typescript/native-preview": "7.0.0-dev.20260607.1",
72
72
  "shx": "^0.4.0",
73
73
  "tsx": "^4.21.0",
74
74
  "typescript": "^6.0.3",