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 +9 -9
- package/dist/discover.js +9 -225
- package/dist/index.js +218 -42
- package/dist/provider.d.ts +3 -0
- package/dist/provider.js +1 -1
- package/dist/types.d.ts +0 -2
- package/package.json +1 -1
- package/dist/cache.d.ts +0 -1
- package/dist/cache.js +0 -8
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,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
|
|
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:
|
|
440
|
-
reasoning:
|
|
233
|
+
name: catalogModel?.name ?? `${id} (no metadata)`,
|
|
234
|
+
reasoning: catalogModel?.reasoning ?? false,
|
|
441
235
|
thinkingLevelMap: catalogModel?.thinkingLevelMap,
|
|
442
|
-
input:
|
|
443
|
-
cost:
|
|
444
|
-
contextWindow:
|
|
445
|
-
maxTokens:
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
-
|
|
129
|
-
const
|
|
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:
|
|
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
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
})
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
978
|
-
|
|
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
|
-
|
|
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}` };
|
package/dist/provider.d.ts
CHANGED
|
@@ -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
package/package.json
CHANGED
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
|
-
}
|