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 +2 -0
- package/dist/index.js +135 -46
- package/dist/types.d.ts +1 -0
- package/package.json +6 -6
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
|
-
|
|
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]
|
|
36
|
-
const envKey = process.env[ENV_API_KEY]
|
|
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
|
|
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:
|
|
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
|
|
82
|
-
if (!rawBaseUrl || !
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
261
|
+
let creds = await resolveCredentials({ executeHelpers: false });
|
|
198
262
|
const cache = await readCache(getCachePath());
|
|
199
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-litellm",
|
|
3
|
-
"version": "1.2.
|
|
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": "
|
|
59
|
-
"@earendil-works/pi-coding-agent": "
|
|
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.
|
|
68
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
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.
|
|
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",
|