pi-provider-litellm 4.1.0 → 4.3.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/src/budget.ts ADDED
@@ -0,0 +1,561 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { fetchJson, normalizeBaseUrl } from "./discover.js";
4
+ import type { LiteLLMRuntimeAuth } from "./types.js";
5
+
6
+ export type BudgetLevelName = "key" | "user" | "team" | "member" | "org";
7
+ export const BUDGET_LEVELS: readonly BudgetLevelName[] = ["key", "user", "team", "member", "org"];
8
+
9
+ export type BudgetEndpoint = "key" | "userV2" | "user" | "team" | "org";
10
+
11
+ export interface BudgetLevel {
12
+ spend: number;
13
+ maxBudget: number;
14
+ resetAt?: number;
15
+ }
16
+ export type BudgetLevels = Partial<Record<BudgetLevelName, BudgetLevel>>;
17
+
18
+ export type BudgetPoll = { ok: true; levels: BudgetLevels; keyPolled: boolean } | { ok: false; reason: string };
19
+
20
+ type Obj = Record<string, unknown>;
21
+ type Fetched = { status: "ok"; data: unknown } | { status: "denied" } | { status: "transient"; reason: string };
22
+
23
+ function obj(value: unknown): Obj | undefined {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Obj) : undefined;
25
+ }
26
+
27
+ function str(value: unknown): string | undefined {
28
+ return typeof value === "string" && value !== "" ? value : undefined;
29
+ }
30
+
31
+ // A level exists only for a positive, finite limit; anything else means "no budget".
32
+ function level(spend: unknown, maxBudget: unknown, resetAt: unknown): BudgetLevel | undefined {
33
+ if (typeof maxBudget !== "number" || !Number.isFinite(maxBudget) || maxBudget <= 0) return undefined;
34
+ const parsedReset = typeof resetAt === "string" ? Date.parse(resetAt) : Number.NaN;
35
+ return {
36
+ spend: typeof spend === "number" && Number.isFinite(spend) && spend >= 0 ? spend : 0,
37
+ maxBudget,
38
+ ...(Number.isFinite(parsedReset) ? { resetAt: parsedReset } : {}),
39
+ };
40
+ }
41
+
42
+ // Levels carried over from the last good poll when their endpoint failed transiently.
43
+ function keep(previous: BudgetLevels, names: readonly BudgetLevelName[]): BudgetLevels {
44
+ const kept: BudgetLevels = {};
45
+ for (const name of names) if (previous[name]) kept[name] = previous[name];
46
+ return kept;
47
+ }
48
+
49
+ async function get(
50
+ auth: LiteLLMRuntimeAuth,
51
+ path: string,
52
+ endpoint: BudgetEndpoint,
53
+ denied: Map<BudgetEndpoint, number>,
54
+ timeoutMs: number,
55
+ ): Promise<Fetched> {
56
+ try {
57
+ const url = `${normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp)}${path}`;
58
+ const result = await fetchJson<unknown>(url, auth.apiKey, { timeoutMs, headers: auth.headers });
59
+ if (result.ok) return { status: "ok", data: result.data };
60
+ // LiteLLM answers 500 when handling the request raises (no database, a credential it cannot look up, a
61
+ // version bug), which repeats on every call, so it is remembered like a 4xx. 429 and 502-504 stay retryable.
62
+ if ((result.status >= 400 && result.status < 500 && result.status !== 429) || result.status === 500) {
63
+ denied.set(endpoint, result.status);
64
+ return { status: "denied" };
65
+ }
66
+ return { status: "transient", reason: `HTTP ${result.status}` };
67
+ } catch {
68
+ return { status: "transient", reason: "request failed" };
69
+ }
70
+ }
71
+
72
+ type UserPoll = { levels: BudgetLevels; userId?: string; teamIds?: string[]; transient?: true };
73
+
74
+ async function pollUser(
75
+ auth: LiteLLMRuntimeAuth,
76
+ denied: Map<BudgetEndpoint, number>,
77
+ previous: BudgetLevels,
78
+ timeoutMs: number,
79
+ ): Promise<UserPoll> {
80
+ const keepUser = (): UserPoll => ({ levels: keep(previous, ["user"]), transient: true });
81
+ let fetched: Fetched | undefined;
82
+ let nested = false;
83
+ if (!denied.has("userV2")) {
84
+ fetched = await get(auth, "/v2/user/info", "userV2", denied, timeoutMs);
85
+ if (fetched.status === "denied" && denied.get("userV2") === 404) fetched = undefined;
86
+ } else if (denied.get("userV2") !== 404) {
87
+ return { levels: {} };
88
+ }
89
+ if (!fetched) {
90
+ if (denied.has("user")) return { levels: {} };
91
+ nested = true;
92
+ fetched = await get(auth, "/user/info", "user", denied, timeoutMs);
93
+ }
94
+ if (fetched.status === "denied") return { levels: {} };
95
+ if (fetched.status === "transient") return keepUser();
96
+ const data = obj(fetched.data);
97
+ const body = nested ? obj(data?.user_info) : data;
98
+ if (!body) return keepUser();
99
+ const user = level(body.spend, body.max_budget, body.budget_reset_at);
100
+ // `/v2/user/info` lists team ids; `/user/info` lists team rows.
101
+ const teams = Array.isArray(data?.teams) ? data.teams.map((team) => str(team) ?? str(obj(team)?.team_id)) : [];
102
+ return {
103
+ levels: user ? { user } : {},
104
+ userId: str(body.user_id),
105
+ teamIds: teams.filter((team): team is string => team !== undefined),
106
+ };
107
+ }
108
+
109
+ type TeamPoll = { levels: BudgetLevels; organizationId?: string; transient?: true };
110
+
111
+ async function pollTeam(
112
+ auth: LiteLLMRuntimeAuth,
113
+ teamId: string,
114
+ userId: string | undefined,
115
+ denied: Map<BudgetEndpoint, number>,
116
+ previous: BudgetLevels,
117
+ timeoutMs: number,
118
+ ): Promise<TeamPoll> {
119
+ const path = `/team/info?team_id=${encodeURIComponent(teamId)}`;
120
+ const fetched = await get(auth, path, "team", denied, timeoutMs);
121
+ if (fetched.status === "denied") return { levels: {} };
122
+ const body = fetched.status === "ok" ? obj(fetched.data) : undefined;
123
+ const info = obj(body?.team_info);
124
+ if (!info) return { levels: keep(previous, ["team", "member"]), transient: true };
125
+
126
+ const levels: BudgetLevels = {};
127
+ const team = level(info.spend, info.max_budget, info.budget_reset_at);
128
+ if (team) levels.team = team;
129
+ if (userId) {
130
+ const memberships = body?.team_memberships;
131
+ const own = Array.isArray(memberships) ? memberships.map(obj).find((m) => m?.user_id === userId) : undefined;
132
+ const table = obj(own?.litellm_budget_table) ?? obj(info.team_member_budget_table);
133
+ const member = level(own?.spend, table?.max_budget, table?.budget_reset_at);
134
+ if (member) levels.member = member;
135
+ }
136
+ return { levels, organizationId: str(info.organization_id) };
137
+ }
138
+
139
+ async function pollOrg(
140
+ auth: LiteLLMRuntimeAuth,
141
+ organizationId: string,
142
+ denied: Map<BudgetEndpoint, number>,
143
+ previous: BudgetLevels,
144
+ timeoutMs: number,
145
+ ): Promise<BudgetLevels> {
146
+ const fetched = await get(auth, "/organization/list", "org", denied, timeoutMs);
147
+ if (fetched.status === "denied") return {};
148
+ if (fetched.status === "transient" || !Array.isArray(fetched.data)) return keep(previous, ["org"]);
149
+ const entry = fetched.data.map(obj).find((o) => o?.organization_id === organizationId);
150
+ const table = obj(entry?.litellm_budget_table);
151
+ const org = level(entry?.spend, table?.max_budget, table?.budget_reset_at);
152
+ return org ? { org } : {};
153
+ }
154
+
155
+ export async function pollBudget(
156
+ auth: LiteLLMRuntimeAuth,
157
+ denied: Map<BudgetEndpoint, number>,
158
+ previous: BudgetLevels,
159
+ timeoutMs: number,
160
+ ): Promise<BudgetPoll> {
161
+ const levels: BudgetLevels = {};
162
+ let userId: string | undefined;
163
+ let teamId: string | undefined;
164
+ let organizationId: string | undefined;
165
+ const keyPolled = !denied.has("key");
166
+
167
+ if (keyPolled) {
168
+ const fetched = await get(auth, "/key/info", "key", denied, timeoutMs);
169
+ if (fetched.status === "transient") return { ok: false, reason: fetched.reason };
170
+ if (fetched.status === "ok") {
171
+ const info = obj(obj(fetched.data)?.info);
172
+ if (!info) return { ok: false, reason: "malformed response" };
173
+ const key = level(info.spend, info.max_budget, info.budget_reset_at);
174
+ if (key) levels.key = key;
175
+ userId = str(info.user_id);
176
+ teamId = str(info.team_id);
177
+ organizationId = str(info.organization_id);
178
+ }
179
+ }
180
+ const keyKnown = keyPolled && !denied.has("key");
181
+
182
+ const userPoll: Promise<UserPoll> =
183
+ userId || !keyKnown ? pollUser(auth, denied, previous, timeoutMs) : Promise.resolve({ levels: {} });
184
+ // A JWT (LiteLLM's own test: three dot-separated parts), such as an OIDC login's id_token, has no key row, and LiteLLM
185
+ // charges its user's team: the only one is certain, but with several it depends on the proxy's JWT settings. A
186
+ // virtual key's user may be in a team the key is not charged to, such as the master key's default user.
187
+ if (!keyKnown && auth.apiKey.split(".").length === 3) {
188
+ const user = await userPoll;
189
+ if (user.transient) Object.assign(levels, keep(previous, ["team", "member", "org"]));
190
+ else if (user.teamIds?.length === 1) [teamId, userId] = [user.teamIds[0], user.userId];
191
+ }
192
+ const [user, team] = await Promise.all([
193
+ userPoll,
194
+ teamId && !denied.has("team")
195
+ ? pollTeam(auth, teamId, userId, denied, previous, timeoutMs)
196
+ : ({ levels: {} } as TeamPoll),
197
+ ]);
198
+ Object.assign(levels, user.levels, team.levels);
199
+
200
+ const org = organizationId ?? team.organizationId;
201
+ if (org && userId && !denied.has("org")) Object.assign(levels, await pollOrg(auth, org, denied, previous, timeoutMs));
202
+ // The team was the only source of the org id, so a temporary team failure keeps the last org figure too.
203
+ else if (!org && team.transient) Object.assign(levels, keep(previous, ["org"]));
204
+ return { ok: true, levels, keyPolled: keyKnown };
205
+ }
206
+
207
+ export type BudgetDisplay = "all" | "tightest";
208
+ export interface BudgetTheme {
209
+ fg(color: "dim" | "warning" | "error", text: string): string;
210
+ }
211
+
212
+ // A header counts when it is a non-blank string holding a finite number >= 0.
213
+ function headerNumber(value: string | undefined): number | undefined {
214
+ if (value === undefined || value.trim() === "") return undefined;
215
+ const parsed = Number(value);
216
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
217
+ }
218
+
219
+ export function mergeKeyHeaders(
220
+ key: BudgetLevel | undefined,
221
+ keyPolled: boolean,
222
+ headers: Record<string, string>,
223
+ ): BudgetLevel | undefined {
224
+ const spend = headerNumber(headers["x-litellm-key-spend"]);
225
+ if (spend === undefined) return key;
226
+ const limit = headerNumber(headers["x-litellm-key-max-budget"]);
227
+ const maxBudget = limit !== undefined && limit > 0 ? limit : undefined;
228
+ if (!keyPolled) return maxBudget === undefined ? undefined : { spend, maxBudget };
229
+ if (key) return { ...key, spend: Math.max(key.spend, spend), maxBudget: maxBudget ?? key.maxBudget };
230
+ return maxBudget === undefined ? undefined : { spend, maxBudget };
231
+ }
232
+
233
+ // Locale formatting rounds the decimal value (1.815 to 1.82), where toFixed rounds the binary one (to 1.81).
234
+ const money = (value: number, decimals: number): string =>
235
+ `$${value.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: 2 })}`;
236
+
237
+ export function formatCompactAmount(value: number): string {
238
+ if (value < 100) return money(value, Number.isInteger(value) ? 0 : 2);
239
+ if (value < 1000) return `$${Math.round(value)}`;
240
+ const [scaled, suffix] = value >= 1_000_000 ? [value / 1_000_000, "M"] : [value / 1000, "k"];
241
+ return `$${scaled < 10 ? scaled.toFixed(1).replace(/\.0$/, "") : Math.round(scaled)}${suffix}`;
242
+ }
243
+
244
+ export function formatRelativeReset(resetAt: number | undefined, now: number): string | undefined {
245
+ if (resetAt === undefined || resetAt <= now) return undefined;
246
+ const ms = resetAt - now;
247
+ if (ms >= 86_400_000) return `${Math.floor(ms / 86_400_000)}d`;
248
+ if (ms >= 3_600_000) return `${Math.floor(ms / 3_600_000)}h`;
249
+ return `${Math.max(1, Math.floor(ms / 60_000))}m`;
250
+ }
251
+
252
+ const percent = (l: BudgetLevel): number => Math.floor((l.spend / l.maxBudget) * 100);
253
+
254
+ function colour(l: BudgetLevel): "dim" | "warning" | "error" {
255
+ const ratio = l.spend / l.maxBudget;
256
+ return ratio >= 1 ? "error" : ratio >= 0.8 ? "warning" : "dim";
257
+ }
258
+
259
+ export function formatBudgetStatus(
260
+ levels: BudgetLevels,
261
+ display: BudgetDisplay,
262
+ prefix: string,
263
+ theme: BudgetTheme,
264
+ now: number,
265
+ ): string | undefined {
266
+ const set = BUDGET_LEVELS.flatMap((name) => (levels[name] ? [{ name, level: levels[name] }] : []));
267
+ if (set.length === 0) return undefined;
268
+ const head = `${theme.fg("dim", prefix)} `;
269
+ const text = (name: string, l: BudgetLevel) =>
270
+ `${name} ${formatCompactAmount(l.spend)}/${formatCompactAmount(l.maxBudget)}`;
271
+ if (display === "all") {
272
+ return head + set.map(({ name, level: l }) => theme.fg(colour(l), text(name, l))).join(theme.fg("dim", " · "));
273
+ }
274
+ // Strict "<" keeps the first level in order on ties.
275
+ const { name, level: l } = set.reduce((a, b) =>
276
+ b.level.maxBudget - b.level.spend < a.level.maxBudget - a.level.spend ? b : a,
277
+ );
278
+ const reset = formatRelativeReset(l.resetAt, now);
279
+ return (
280
+ head +
281
+ theme.fg(colour(l), `${text(name, l)} (${percent(l)}%)`) +
282
+ (reset ? theme.fg("dim", ` · resets ${reset}`) : "")
283
+ );
284
+ }
285
+
286
+ export function formatBudgetDetails(
287
+ providerName: string,
288
+ levels: BudgetLevels,
289
+ denied: ReadonlyMap<BudgetEndpoint, number>,
290
+ now: number,
291
+ ): string {
292
+ const lines = [`LiteLLM ("${providerName}") budget`];
293
+ for (const name of BUDGET_LEVELS) {
294
+ const l = levels[name];
295
+ if (!l) continue;
296
+ const reset = formatRelativeReset(l.resetAt, now);
297
+ const limit = money(l.maxBudget, Number.isInteger(l.maxBudget) ? 0 : 2);
298
+ lines.push(
299
+ ` ${name.padEnd(8)}${money(l.spend, 2)} of ${limit} (${percent(l)}%)${reset ? `, resets in ${reset}` : ""}`,
300
+ );
301
+ }
302
+ const userV2 = denied.get("userV2");
303
+ const unreadable = [
304
+ ["key", denied.get("key")],
305
+ ["user", userV2 !== undefined && userV2 !== 404 ? userV2 : denied.get("user")],
306
+ ["team", denied.get("team")],
307
+ ["org", denied.get("org")],
308
+ ].flatMap(([name, code]) => (code === undefined ? [] : [`${name} (${code})`]));
309
+ if (lines.length === 1 && unreadable.length === 0) lines.push(" no budgets set");
310
+ if (unreadable.length > 0) lines.push(` Not readable with this credential: ${unreadable.join(", ")}`);
311
+ return lines.join("\n");
312
+ }
313
+
314
+ export function budgetDisplaySetting(budgetSettings: unknown): { display: BudgetDisplay; warning?: string } {
315
+ const display = obj(budgetSettings)?.display;
316
+ if (display === undefined || display === "all" || display === "tightest") return { display: display ?? "all" };
317
+ return { display: "all", warning: `LiteLLM budget: unknown display ${JSON.stringify(display)}; using "all".` };
318
+ }
319
+
320
+ export const BUDGET_STATUS_KEY = "litellm-budget";
321
+ const POLL_SETTLE_DELAY_MS = 15_000;
322
+ const POLL_MIN_INTERVAL_MS = 60_000;
323
+
324
+ export interface BudgetOptions {
325
+ providers: ReadonlyArray<{ name: string; displayName: string }>;
326
+ display: BudgetDisplay;
327
+ displayWarning?: string;
328
+ resolveAuth(ctx: ExtensionContext, providerName: string): Promise<LiteLLMRuntimeAuth | undefined>;
329
+ timeoutMs(): number;
330
+ disabledReason(): string | null;
331
+ hostOffline(): boolean;
332
+ missingCredentials(providerName: string): string;
333
+ }
334
+
335
+ type PollOutcome = "ok" | "no-auth" | { failed: string };
336
+
337
+ interface ProviderState {
338
+ digest?: string;
339
+ levels: BudgetLevels;
340
+ denied: Map<BudgetEndpoint, number>;
341
+ keyPolled: boolean;
342
+ lastPollAt?: number;
343
+ lastTurnAt?: number;
344
+ inFlight?: Promise<PollOutcome>;
345
+ }
346
+
347
+ export function setupLiteLLMBudget(pi: ExtensionAPI, options: BudgetOptions): void {
348
+ const displayNames = new Map(options.providers.map(({ name, displayName }) => [name, displayName]));
349
+ const states = new Map<string, ProviderState>();
350
+ // Keyed like the MCP registration identity in index.ts, because custom headers can carry gateway credentials.
351
+ const identityKey = randomBytes(32);
352
+ let shownText: string | undefined;
353
+ let activeProvider: string | undefined;
354
+ let latestCtx: ExtensionContext | undefined;
355
+ let timer: ReturnType<typeof setTimeout> | undefined;
356
+ let generation = 0;
357
+ let warned = false;
358
+
359
+ const stateFor = (name: string): ProviderState => {
360
+ let state = states.get(name);
361
+ if (!state) {
362
+ state = { levels: {}, denied: new Map(), keyPolled: false };
363
+ states.set(name, state);
364
+ }
365
+ return state;
366
+ };
367
+ const forget = (state: ProviderState): void => {
368
+ state.levels = {};
369
+ state.denied.clear();
370
+ state.keyPolled = false;
371
+ };
372
+ const gated = (): boolean => options.disabledReason() !== null || options.hostOffline();
373
+
374
+ // Draws only the active provider's text, and only when it changed, so a context without
375
+ // setStatus is untouched while there is nothing to show.
376
+ function render(ctx: ExtensionContext, name: string): void {
377
+ if (!ctx.hasUI || activeProvider !== name) return;
378
+ const text = formatBudgetStatus(
379
+ stateFor(name).levels,
380
+ options.display,
381
+ displayNames.get(name)!,
382
+ ctx.ui.theme,
383
+ Date.now(),
384
+ );
385
+ if (text === shownText) return;
386
+ ctx.ui.setStatus(BUDGET_STATUS_KEY, text);
387
+ shownText = text;
388
+ }
389
+
390
+ async function run(ctx: ExtensionContext, name: string, state: ProviderState): Promise<PollOutcome> {
391
+ const started = generation;
392
+ state.lastPollAt = Date.now();
393
+ let auth: LiteLLMRuntimeAuth | undefined;
394
+ try {
395
+ auth = await options.resolveAuth(ctx, name);
396
+ } catch {
397
+ auth = undefined;
398
+ }
399
+ if (!auth) {
400
+ forget(state);
401
+ state.digest = undefined;
402
+ if (started === generation) render(ctx, name);
403
+ return "no-auth";
404
+ }
405
+ const headers = Object.entries(auth.headers ?? {}).sort(([a], [b]) => (a < b ? -1 : 1));
406
+ const identity = JSON.stringify([auth.baseUrl, auth.apiKey, headers]);
407
+ const digest = createHmac("sha256", identityKey).update(identity).digest("hex");
408
+ if (state.digest !== undefined && state.digest !== digest) {
409
+ forget(state);
410
+ if (started === generation) render(ctx, name);
411
+ }
412
+ state.digest = digest;
413
+ const result = await pollBudget(auth, state.denied, state.levels, options.timeoutMs());
414
+ if (result.ok) {
415
+ const levels = { ...result.levels };
416
+ if (!result.keyPolled && state.levels.key) levels.key = state.levels.key;
417
+ state.levels = levels;
418
+ state.keyPolled = result.keyPolled;
419
+ }
420
+ if (started === generation) render(ctx, name);
421
+ return result.ok ? "ok" : { failed: result.reason };
422
+ }
423
+
424
+ function poll(ctx: ExtensionContext, name: string): Promise<PollOutcome> {
425
+ const state = stateFor(name);
426
+ state.inFlight ??= run(ctx, name, state).finally(() => {
427
+ state.inFlight = undefined;
428
+ });
429
+ return state.inFlight;
430
+ }
431
+
432
+ function track(ctx: ExtensionContext, provider: string | undefined): string | undefined {
433
+ latestCtx = ctx;
434
+ activeProvider = provider !== undefined && displayNames.has(provider) ? provider : undefined;
435
+ return activeProvider;
436
+ }
437
+
438
+ function warnOnce(ctx: ExtensionContext): void {
439
+ if (warned || !options.displayWarning) return;
440
+ warned = true;
441
+ if (ctx.hasUI) ctx.ui.notify(options.displayWarning, "warning");
442
+ else process.stderr.write(`${options.displayWarning}\n`);
443
+ }
444
+
445
+ function schedulePoll(name: string): void {
446
+ const state = stateFor(name);
447
+ const now = Date.now();
448
+ const at = Math.max(state.lastTurnAt! + POLL_SETTLE_DELAY_MS, (state.lastPollAt ?? 0) + POLL_MIN_INTERVAL_MS);
449
+ timer = setTimeout(() => {
450
+ timer = undefined;
451
+ if (latestCtx && activeProvider === name && !gated()) {
452
+ poll(latestCtx, name).catch(() => {});
453
+ // A turn that ended within the settle delay may not have its spend written yet: poll once more. Compared
454
+ // with the planned time, not Date.now(), because a timer can fire a few milliseconds early.
455
+ if (state.lastTurnAt! + POLL_SETTLE_DELAY_MS > at) schedulePoll(name);
456
+ }
457
+ }, at - now);
458
+ timer.unref();
459
+ }
460
+
461
+ pi.on("session_start", (_event, ctx) => {
462
+ warnOnce(ctx);
463
+ if (!ctx.hasUI) return;
464
+ const name = track(ctx, ctx.model?.provider);
465
+ if (name && !gated()) void poll(ctx, name);
466
+ });
467
+
468
+ pi.on("model_select", (event, ctx) => {
469
+ if (!ctx.hasUI) return;
470
+ const previous = activeProvider;
471
+ const name = track(ctx, event.model.provider);
472
+ // A pending turn poll belongs to the provider its turns ran on; a newly selected one is polled below if stale.
473
+ if (name !== previous) {
474
+ clearTimeout(timer);
475
+ timer = undefined;
476
+ }
477
+ if (!name) {
478
+ if (shownText !== undefined) {
479
+ ctx.ui.setStatus(BUDGET_STATUS_KEY, undefined);
480
+ shownText = undefined;
481
+ }
482
+ return;
483
+ }
484
+ render(ctx, name);
485
+ const state = stateFor(name);
486
+ if (gated()) return;
487
+ if (state.lastPollAt === undefined || Date.now() - state.lastPollAt >= POLL_MIN_INTERVAL_MS) void poll(ctx, name);
488
+ // A turn whose poll was dropped when this provider was switched away from still needs one.
489
+ const missed = state.lastTurnAt !== undefined && state.lastTurnAt + POLL_SETTLE_DELAY_MS > (state.lastPollAt ?? 0);
490
+ if (missed && !timer) schedulePoll(name);
491
+ });
492
+
493
+ pi.on("turn_end", (_event, ctx) => {
494
+ if (!ctx.hasUI) return;
495
+ const name = track(ctx, ctx.model?.provider);
496
+ if (!name || gated()) return;
497
+ stateFor(name).lastTurnAt = Date.now();
498
+ if (!timer) schedulePoll(name);
499
+ });
500
+
501
+ pi.on("after_provider_response", (event, ctx) => {
502
+ if (!ctx.hasUI) return;
503
+ const name = ctx.model?.provider;
504
+ if (!name || !displayNames.has(name)) return;
505
+ const state = stateFor(name);
506
+ const key = mergeKeyHeaders(state.levels.key, state.keyPolled, event.headers ?? {});
507
+ if (key) state.levels.key = key;
508
+ else delete state.levels.key;
509
+ render(ctx, name);
510
+ });
511
+
512
+ pi.on("session_shutdown", (_event, ctx) => {
513
+ clearTimeout(timer);
514
+ timer = undefined;
515
+ generation++;
516
+ if (shownText !== undefined && ctx?.hasUI) ctx.ui.setStatus(BUDGET_STATUS_KEY, undefined);
517
+ shownText = undefined;
518
+ activeProvider = undefined;
519
+ latestCtx = undefined;
520
+ });
521
+
522
+ pi.registerCommand("litellm-budget", {
523
+ description: "Show LiteLLM key, user, team, member, and organization budgets",
524
+ getArgumentCompletions: (prefix) => {
525
+ const matches = [...displayNames.keys()].filter((name) => name.startsWith(prefix));
526
+ return matches.length > 0 ? matches.map((name) => ({ value: name, label: name })) : null;
527
+ },
528
+ handler: async (args, ctx) => {
529
+ const notify = (message: string, level: "info" | "warning"): void => {
530
+ if (ctx.hasUI) ctx.ui.notify(message, level);
531
+ else process.stderr.write(`${message}\n`);
532
+ };
533
+ const disabledReason = options.disabledReason();
534
+ if (disabledReason) {
535
+ notify(`LiteLLM: budget check skipped (${disabledReason}).`, "warning");
536
+ return;
537
+ }
538
+ const requested = args.trim();
539
+ const selected = requested ? options.providers.filter(({ name }) => name === requested) : options.providers;
540
+ if (selected.length === 0) {
541
+ const configured = options.providers.map(({ name }) => JSON.stringify(name)).join(", ");
542
+ notify(`LiteLLM: unknown provider ${JSON.stringify(requested)}; configured: ${configured}.`, "warning");
543
+ return;
544
+ }
545
+ for (const { name } of selected) {
546
+ const label = `LiteLLM (${JSON.stringify(name)})`;
547
+ const state = stateFor(name);
548
+ await state.inFlight?.catch(() => undefined);
549
+ state.denied.clear();
550
+ const outcome = await poll(ctx, name);
551
+ if (outcome === "no-auth") {
552
+ notify(`${label}: budget check skipped; ${options.missingCredentials(name)}`, "warning");
553
+ } else if (outcome !== "ok") {
554
+ notify(`${label}: budget check failed (${outcome.failed}).`, "warning");
555
+ } else {
556
+ notify(formatBudgetDetails(name, state.levels, state.denied, Date.now()), "info");
557
+ }
558
+ }
559
+ },
560
+ });
561
+ }
package/src/discover.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  resolveCatalogProvider,
9
9
  routesOnlyThrough,
10
10
  } from "./backend-identity.js";
11
+ import { type DiscoveredImageModel, mapFromImageModelInfoGroup } from "./images.js";
11
12
  import {
12
13
  type CatalogResolution,
13
14
  catalogResolution,
@@ -87,7 +88,14 @@ export function normalizeBaseUrl(input: string, allowInsecureHttp = false): stri
87
88
  if (url.protocol !== "https:" && !(url.protocol === "http:" && (loopback || allowInsecureHttp))) {
88
89
  throw new Error("LiteLLM base URL must use HTTPS except for loopback hosts");
89
90
  }
90
- return input.replace(/\/+$/, "").replace(/\/v1\/?$/i, "");
91
+ return trimTrailingSlashes(input).replace(/\/v1\/?$/i, "");
92
+ }
93
+
94
+ // A loop, not /\/+$/: that regex backtracks quadratically on a long slash run followed by another character.
95
+ export function trimTrailingSlashes(value: string): string {
96
+ let end = value.length;
97
+ while (end > 0 && value[end - 1] === "/") end--;
98
+ return value.slice(0, end);
91
99
  }
92
100
 
93
101
  // Matches both the conventional `anthropic/...` prefix and aliases that
@@ -606,7 +614,7 @@ function loadDiscoveryPublicCatalog(options: DiscoveryOptions): Promise<PublicCa
606
614
  );
607
615
  }
608
616
 
609
- async function fetchJson<T>(
617
+ export async function fetchJson<T>(
610
618
  url: string,
611
619
  apiKey: string,
612
620
  options: DiscoveryOptions,
@@ -1167,9 +1175,12 @@ export async function discoverModels(
1167
1175
  const progress = options.silent ? undefined : options.onProgress;
1168
1176
  progress?.("Querying /model/info endpoint...");
1169
1177
  const infoResult = await fetchJson<ModelInfoResponse>(`${base}/model/info`, apiKey, options);
1170
- if (infoResult.ok) {
1178
+ // An empty `/model/info` list is treated like an unavailable endpoint: some proxies
1179
+ // answer 200 with no rows for keys that can still list models through `/v1/models`.
1180
+ const infoEntries = infoResult.ok ? (infoResult.data.data ?? []) : [];
1181
+ if (infoEntries.length > 0) {
1171
1182
  const groups = new Map<string, ModelInfoEntry[]>();
1172
- for (const entry of infoResult.data.data ?? []) {
1183
+ for (const entry of infoEntries) {
1173
1184
  // A route without a readable public name cannot be grouped or addressed.
1174
1185
  const route = wireString(entry.model_name);
1175
1186
  if (!route) continue;
@@ -1294,12 +1305,21 @@ export async function discoverModels(
1294
1305
  "set model_info.max_input_tokens on every deployment in each route",
1295
1306
  );
1296
1307
  }
1297
- return { source: "model_info", models, ...(proxyVersion ? { proxyVersion } : {}) };
1308
+ // Image routes are a separate operation in Pi; the chat reducer above withholds them.
1309
+ const imageModels = [...groups.entries()]
1310
+ .map(([route, group]) => mapFromImageModelInfoGroup(route, group))
1311
+ .filter((model): model is DiscoveredImageModel => model !== undefined);
1312
+ return {
1313
+ source: "model_info",
1314
+ models,
1315
+ ...(imageModels.length > 0 ? { imageModels } : {}),
1316
+ ...(proxyVersion ? { proxyVersion } : {}),
1317
+ };
1298
1318
  }
1299
- if (![401, 403, 404].includes(infoResult.status)) {
1319
+ if (!infoResult.ok && ![401, 403, 404].includes(infoResult.status)) {
1300
1320
  throw new Error(`/model/info returned ${infoResult.status}`);
1301
1321
  }
1302
- progress?.("/model/info unavailable, trying /v1/models...");
1322
+ progress?.("/model/info unavailable or empty, trying /v1/models...");
1303
1323
  const listResult = await fetchJson<ModelsListResponse>(`${base}/v1/models`, apiKey, options);
1304
1324
  if (!listResult.ok) {
1305
1325
  if ([401, 403, 404].includes(listResult.status)) {