pi-provider-litellm 4.0.1 → 4.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 +46 -4
- package/package.json +3 -3
- package/src/backend-identity.ts +1 -1
- package/src/budget.ts +561 -0
- package/src/discover.ts +10 -5
- package/src/index.ts +24 -2
- package/src/public-catalog.ts +4 -2
package/README.md
CHANGED
|
@@ -156,7 +156,7 @@ Provider fields:
|
|
|
156
156
|
| `baseUrl` | `LITELLM_BASE_URL` for `litellm`; required for aliases | LiteLLM proxy URL, with or without `/v1`. Must be a full `http`/`https` URL; a provider with no resolvable base URL exposes no models (see [Model host enforcement](#model-host-enforcement)) |
|
|
157
157
|
| `apiKey` | `LITELLM_API_KEY_HELPER`/`LITELLM_API_KEY` for `litellm`; required for aliases | Pi config value for this provider's key. Use `$ENV_VAR`, `${ENV_VAR}`, `!command`, or a literal key. Escape a literal `$` as `$$`. |
|
|
158
158
|
| `headers` | `$LITELLM_HEADERS` for `litellm`; unset for aliases | JSON string env reference or inline object of request headers |
|
|
159
|
-
| `displayName` |
|
|
159
|
+
| `displayName` | `LITELLM_DISPLAY_NAME` / `"LiteLLM"` for `litellm`; alias name for aliases | Label shown in Pi UI |
|
|
160
160
|
| `enabled` | `true` | Set `false` to skip an alias |
|
|
161
161
|
| `oidc` | unset | `litellm` only. Sign in directly with an OpenID Connect identity provider instead of the LiteLLM-hosted flows; see [Direct OIDC login](#direct-oidc-login) |
|
|
162
162
|
| `allowInsecureHttp` | `false` | Set `true` to permit plaintext HTTP for this provider, for example `http://host.docker.internal`. Credentials and request data will not be encrypted. Loopback HTTP works without this setting. |
|
|
@@ -165,7 +165,7 @@ Provider fields:
|
|
|
165
165
|
|
|
166
166
|
### Optional LiteLLM features
|
|
167
167
|
|
|
168
|
-
LiteLLM Skills
|
|
168
|
+
LiteLLM Skills, MCP integration, and the budget footer are enabled by default. Disable any of them, choose how MCP tools reach the model, or choose how budgets are shown, globally in `~/.pi/agent/settings.json`:
|
|
169
169
|
|
|
170
170
|
```json
|
|
171
171
|
{
|
|
@@ -180,12 +180,16 @@ LiteLLM Skills and MCP integration are enabled by default. Disable either featur
|
|
|
180
180
|
"*delete*": "hidden",
|
|
181
181
|
"github-search_code": "direct"
|
|
182
182
|
}
|
|
183
|
+
},
|
|
184
|
+
"budget": {
|
|
185
|
+
"enabled": true,
|
|
186
|
+
"display": "all"
|
|
183
187
|
}
|
|
184
188
|
}
|
|
185
189
|
}
|
|
186
190
|
```
|
|
187
191
|
|
|
188
|
-
Setting `skills.enabled` to `false` disables the Skills Gateway management tools, skill fetching, and system-prompt injection. Setting `mcp.enabled` to `false` stops the extension from registering the proxy's MCP server. Restart Pi after changing these settings.
|
|
192
|
+
Setting `skills.enabled` to `false` disables the Skills Gateway management tools, skill fetching, and system-prompt injection. Setting `mcp.enabled` to `false` stops the extension from registering the proxy's MCP server. Setting `budget.enabled` to `false` registers no budget footer, polls, or `/litellm-budget` command; `budget.display` is described under [Budget status](#budget-status). Restart Pi after changing these settings.
|
|
189
193
|
|
|
190
194
|
`mcp.exposure` and `mcp.toolExposure` are passed to Pi unchanged and mean what they mean in a Pi `mcp.json` server entry: `codemode` (Pi's default when `exposure` is unset; callable from Pi's `codemode` scripts), `deferred` (declared to the model once Pi's `tool_search` loads it), `direct` (declared on every request), or `hidden`. `toolExposure` keys are tool names as LiteLLM's `/mcp` endpoint offers them, which LiteLLM prefixes with the server name, for example `github-search_code`; `*` matches any characters. Pi validates both settings, and an unusable value is reported once.
|
|
191
195
|
|
|
@@ -203,6 +207,43 @@ Every request routed through a configured LiteLLM provider includes Pi's canonic
|
|
|
203
207
|
`x-litellm-session-id` header. This groups Chat Completions, Responses, and native Messages requests in LiteLLM without
|
|
204
208
|
adding transport-specific fields to request bodies.
|
|
205
209
|
|
|
210
|
+
## Budget status
|
|
211
|
+
|
|
212
|
+
Pi's footer shows the LiteLLM budgets set for your credential, its user, its team, and its organization, for the active model's provider:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
LiteLLM key $3.10/$10 · user $40/$100 · team $412/$1k · member $20/$50 · org $9.2k/$50k
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Each segment is spend and limit for one level: `key`, `user`, `team`, `member` (your own budget inside the team), and `org`, in that order. These are the limits set on each level, and LiteLLM may not enforce every one of them on this key: since LiteLLM 1.95, a user's personal budget applies to a team key only when the operator sets `apply_user_budget_to_team_keys`. A level whose limit is unset, `null`, or `0` is not shown, so with no budgets the footer is empty. A segment is dim, turns to the warning colour from 80 % used, and to the error colour from 100 %. Set `"display": "tightest"` to show only the level with the least money left, with its percentage and reset time; any other `display` value falls back to `"all"` with a warning:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
LiteLLM team $412/$1k (41%) · resets 12d
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The footer carries only the provider's display name, these labels, and numbers; team names, key aliases, and proxy messages never reach it.
|
|
225
|
+
|
|
226
|
+
The extension reads `/key/info` first, then `/v2/user/info` (`/user/info` on older proxies) and `/team/info`, then `/organization/list`, with the provider's own credential and custom headers, and only from that provider's proxy. LiteLLM's permissions decide what you can see:
|
|
227
|
+
|
|
228
|
+
- A team key without a user shows no `user`, `member`, or `org` level.
|
|
229
|
+
- A JWT credential, such as the one a [Direct OIDC login](#direct-oidc-login) presents, has no key row, so `/key/info` cannot be read. It shows the `user` level and, when that user belongs to exactly one team, that team's `team`, `member`, and `org` levels. With several teams, the one LiteLLM charges depends on the proxy's JWT settings, so only `user` shows. A virtual key whose `/key/info` cannot be read shows no team levels, because its user's team need not be the one the key is charged to.
|
|
230
|
+
- Organization budgets need LiteLLM Enterprise.
|
|
231
|
+
- A key whose `allowed_routes` exclude the info routes cannot be polled. It falls back to the `x-litellm-key-spend` and `x-litellm-key-max-budget` headers on every model response, which show only the `key` level, and only when the key has a limit.
|
|
232
|
+
- An endpoint that answers 4xx (other than 429) or 500 is not asked again until the credential or the custom headers change, or you run `/litellm-budget`. LiteLLM answers 500 for errors that repeat on every call, such as a proxy without a database. Other errors keep the last values and are retried at the next refresh. Nothing is reported while polling.
|
|
233
|
+
|
|
234
|
+
It refreshes when a session starts, when you select a LiteLLM model whose numbers are older than a minute, and after a turn: 15 seconds later, so LiteLLM can record the turn's spend, and never within 60 seconds of the previous poll. Idle sessions do not poll. Between polls the response headers can only raise the key's spend. LiteLLM writes spend to its database every 10 to 60 seconds, and spend resets are applied by a job that runs about every 10 minutes, so spend can lag by up to a minute and a reset by about ten minutes; a reset time already past is not shown.
|
|
235
|
+
|
|
236
|
+
`/litellm-budget` forgets remembered 4xx and 500 answers, polls every configured LiteLLM provider now (or `/litellm-budget <provider>` for one), and prints a breakdown with two-decimal amounts, percentages, reset times, and the levels this credential cannot read, with their HTTP status:
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
LiteLLM ("litellm") budget
|
|
240
|
+
key $3.10 of $10 (31%), resets in 12h
|
|
241
|
+
team $412.30 of $1,000 (41%), resets in 24d
|
|
242
|
+
Not readable with this credential: org (401)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Automatic polls need Pi's interactive UI and do not run under `LITELLM_OFFLINE=1`, `LITELLM_DISCOVERY_TIMEOUT_MS=0`, or `PI_OFFLINE`; header updates still apply. `/litellm-budget` is stopped by the first two only. The credential and custom headers are kept in memory only as a keyed digest, and proxy responses are reduced to the numbers shown and never logged.
|
|
246
|
+
|
|
206
247
|
## Model transport
|
|
207
248
|
|
|
208
249
|
A `/model/info` route group uses native Anthropic `/v1/messages` only when every deployment explicitly reports Chat mode, identifies a Claude backend through an Anthropic-, Bedrock-, or Vertex-capable adapter, and every declared backend identity resolves the same Anthropic compatibility policy. A `claude-<name>-<major>-<minor>` backend id resolves through its `claude-<name>-<major>` entry when the exact id is absent; an unknown generation stays on Chat Completions. When `supported_endpoints` is a list, it must include `/v1/messages`; an explicit endpoint list without it keeps the group on Chat Completions. A `null` value, which LiteLLM v1.100 and later report for models absent from their model map, counts as absent. An unresolved routing or base-model identity denies native Messages rather than being discarded from the unanimity check. Mixed-generation Claude groups can use Messages when their serializer policies match, with reasoning levels restricted to their common supported set. Groups with incompatible policies, mixed families, or unknown backend evidence remain on Chat Completions; unanimous explicit Responses mode takes precedence. Routes discovered through `/health` never select native Messages: Messages candidates are downgraded to Chat Completions with reasoning controls closed, while correlated Responses detail remains on Responses. Evidence-free `/v1/models` and wholly health-only `/health` groups take transport and presentation metadata from the bounded Pi catalog lookup: `openai-responses` when the catalog says so, otherwise Chat Completions. They expose no speculative reasoning selector and never select native Messages. Catalog identity such as Amazon Bedrock remains separate from the selected wire protocol.
|
|
@@ -214,11 +255,12 @@ Native Messages authenticates with `x-api-key`; every transport carries the `x-l
|
|
|
214
255
|
| Variable | Default | Effect |
|
|
215
256
|
|---|---|---|
|
|
216
257
|
| `LITELLM_API_KEY_HELPER` | unset | Command that prints a fresh LiteLLM bearer token. Takes precedence over `LITELLM_API_KEY`. The extension runs it while resolving request auth, and Pi's per-request auth path is uncached, so rotating/short-lived tokens stay fresh. |
|
|
258
|
+
| `LITELLM_DISPLAY_NAME` | `LiteLLM` | Custom display name for the default `litellm` provider in the Pi UI. |
|
|
217
259
|
| `LITELLM_HEADERS` | unset | JSON object of extra headers sent to LiteLLM provider, discovery, MCP, and Skills Gateway requests. Provider aliases can use it with `"headers": "$LITELLM_HEADERS"`. |
|
|
218
260
|
| `LITELLM_GCLOUD_TOKEN_AUTH` | unset | If set to a non-empty value other than `0`, use Google Application Default Credentials as the LiteLLM bearer token source. This takes precedence over `LITELLM_API_KEY_HELPER` and `LITELLM_API_KEY` when no stored `/login litellm` credential exists. |
|
|
219
261
|
| `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. |
|
|
220
262
|
| `LITELLM_OFFLINE` | unset | If `1`, disable all model and MCP discovery, including post-login discovery; use cached models only when their stored canonical proxy root exactly matches the active credential root, including any path prefix. URL-standard host casing and default ports are canonicalized, but paths remain case-sensitive. |
|
|
221
|
-
| `PI_OFFLINE` | unset | Pi documents it as disabling automatic network activity, including model catalog refreshes. Through Pi 1.0.
|
|
263
|
+
| `PI_OFFLINE` | unset | Pi documents it as disabling automatic network activity, including model catalog refreshes. Through Pi 1.0.3, any set value (including `0` or an empty string) disables startup model discovery and `/model` network refreshes. Run `/litellm-refresh`, or unset it, to discover; cached models can still be used offline. |
|
|
222
264
|
| `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Background and explicit discovery fetch timeout in ms; `0` disables automatic discovery |
|
|
223
265
|
| `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 |
|
|
224
266
|
| `LITELLM_VERBOSE_DISCOVERY` | unset | If `1`, enable progress messages during model discovery (login, refresh, startup), including startup skip reasons and defaulted `/model/info` context windows. Progress messages are off by default |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-litellm",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "LiteLLM proxy provider extension for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@biomejs/biome": "^2.4.15",
|
|
70
|
-
"@earendil-works/pi-ai": "^1.0.
|
|
71
|
-
"@earendil-works/pi-coding-agent": "^1.0.
|
|
70
|
+
"@earendil-works/pi-ai": "^1.0.3",
|
|
71
|
+
"@earendil-works/pi-coding-agent": "^1.0.3",
|
|
72
72
|
"@kitlangton/terminal-control": "1.2.1",
|
|
73
73
|
"@types/node": "^26.0.0",
|
|
74
74
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
package/src/backend-identity.ts
CHANGED
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
|
@@ -26,7 +26,12 @@ import {
|
|
|
26
26
|
wireString,
|
|
27
27
|
} from "./model-groups.js";
|
|
28
28
|
import { type ProxyVersion, type ProxyVersionFloor, probeProxyVersion, proxyVersionAtLeast } from "./proxy-version.js";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
loadPublicCatalog,
|
|
31
|
+
PI_AZURE_PROVIDER,
|
|
32
|
+
type PublicCatalog,
|
|
33
|
+
type PublicCatalogRecord,
|
|
34
|
+
} from "./public-catalog.js";
|
|
30
35
|
import { intersectThinkingLevelMaps } from "./thinking-levels.js";
|
|
31
36
|
import type {
|
|
32
37
|
DiscoveredModel,
|
|
@@ -443,11 +448,11 @@ function nativeMessagesCatalog(
|
|
|
443
448
|
: {};
|
|
444
449
|
}
|
|
445
450
|
|
|
446
|
-
const ADAPTER_CATALOG_PROVIDERS: Readonly<Record<string, BuiltinProvider>> = {
|
|
451
|
+
const ADAPTER_CATALOG_PROVIDERS: Readonly<Record<string, BuiltinProvider | undefined>> = {
|
|
447
452
|
anthropic: "anthropic",
|
|
448
453
|
claude: "anthropic",
|
|
449
|
-
azure:
|
|
450
|
-
azure_ai:
|
|
454
|
+
azure: toKnownProvider(PI_AZURE_PROVIDER),
|
|
455
|
+
azure_ai: toKnownProvider(PI_AZURE_PROVIDER),
|
|
451
456
|
bedrock: "amazon-bedrock",
|
|
452
457
|
bedrock_converse: "amazon-bedrock",
|
|
453
458
|
chatgpt: "openai-codex",
|
|
@@ -601,7 +606,7 @@ function loadDiscoveryPublicCatalog(options: DiscoveryOptions): Promise<PublicCa
|
|
|
601
606
|
);
|
|
602
607
|
}
|
|
603
608
|
|
|
604
|
-
async function fetchJson<T>(
|
|
609
|
+
export async function fetchJson<T>(
|
|
605
610
|
url: string,
|
|
606
611
|
apiKey: string,
|
|
607
612
|
options: DiscoveryOptions,
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { ExtensionAPI, ExtensionContext, McpServerConfig } from "@earendil-
|
|
|
18
18
|
// A namespace import, so a Pi without `VERSION` reaches the version check instead of failing to link.
|
|
19
19
|
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { budgetDisplaySetting, setupLiteLLMBudget } from "./budget.js";
|
|
21
22
|
import { setupLiteLLMCostTracking } from "./cost.js";
|
|
22
23
|
import { discoverModels, isGpt6OrNewerModel, isGpt55OrNewerModel, normalizeBaseUrl } from "./discover.js";
|
|
23
24
|
import { getGcloudToken, hasGcloudAdcCredentials, isGcloudTokenAuthEnabled } from "./gcloud-token.js";
|
|
@@ -37,6 +38,7 @@ const PROVIDER_NAME = "litellm";
|
|
|
37
38
|
const SETTINGS_KEY = "litellm";
|
|
38
39
|
const ENV_BASE_URL = "LITELLM_BASE_URL";
|
|
39
40
|
const ENV_API_KEY = "LITELLM_API_KEY";
|
|
41
|
+
const ENV_DISPLAY_NAME = "LITELLM_DISPLAY_NAME";
|
|
40
42
|
const GCLOUD_ADC_SOURCE = "gcloud ADC";
|
|
41
43
|
const ENV_API_KEY_HELPER = "LITELLM_API_KEY_HELPER";
|
|
42
44
|
const ENV_HEADERS = "LITELLM_HEADERS";
|
|
@@ -487,7 +489,10 @@ function normalizeProviderSettings(raw: unknown): RawProviderSettings | undefine
|
|
|
487
489
|
return record;
|
|
488
490
|
}
|
|
489
491
|
|
|
490
|
-
function isFeatureEnabled(
|
|
492
|
+
function isFeatureEnabled(
|
|
493
|
+
settings: Record<string, unknown> | undefined,
|
|
494
|
+
feature: "skills" | "mcp" | "budget",
|
|
495
|
+
): boolean {
|
|
491
496
|
const raw = settings?.[feature];
|
|
492
497
|
return !isPlainObject(raw) || raw.enabled !== false;
|
|
493
498
|
}
|
|
@@ -496,6 +501,7 @@ function getProviderDefinitions(settings: Record<string, unknown> | undefined):
|
|
|
496
501
|
const rawProviders = settings?.providers && typeof settings.providers === "object" ? settings.providers : undefined;
|
|
497
502
|
const providerSettings = rawProviders as Record<string, unknown> | undefined;
|
|
498
503
|
const defaultSettings = normalizeProviderSettings(providerSettings?.[PROVIDER_NAME]);
|
|
504
|
+
const defaultDisplayName = cleanConfig(process.env[ENV_DISPLAY_NAME]) ?? "LiteLLM";
|
|
499
505
|
|
|
500
506
|
const makeDefinition = (
|
|
501
507
|
name: string,
|
|
@@ -503,7 +509,7 @@ function getProviderDefinitions(settings: Record<string, unknown> | undefined):
|
|
|
503
509
|
isDefault: boolean,
|
|
504
510
|
): ProviderDefinition => ({
|
|
505
511
|
name,
|
|
506
|
-
displayName: stringSetting(raw?.displayName) ?? (isDefault ?
|
|
512
|
+
displayName: stringSetting(raw?.displayName) ?? (isDefault ? defaultDisplayName : name),
|
|
507
513
|
baseUrl: stringSetting(raw?.baseUrl),
|
|
508
514
|
apiKeyConfig: stringSetting(raw?.apiKey),
|
|
509
515
|
headers: raw?.headers ?? (isDefault ? `$${ENV_HEADERS}` : undefined),
|
|
@@ -1881,6 +1887,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1881
1887
|
const definitions = getProviderDefinitions(settings);
|
|
1882
1888
|
const skillsEnabled = isFeatureEnabled(settings, "skills");
|
|
1883
1889
|
const mcpEnabled = isFeatureEnabled(settings, "mcp");
|
|
1890
|
+
const budgetEnabled = isFeatureEnabled(settings, "budget");
|
|
1884
1891
|
const providerNames = new Set(definitions.map((definition) => definition.name));
|
|
1885
1892
|
const oauthRuntimeRoots = new Map<string, { apiKey: string; root: string }>();
|
|
1886
1893
|
let mcpUI: ExtensionContext["ui"] | undefined;
|
|
@@ -2228,6 +2235,21 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2228
2235
|
|
|
2229
2236
|
setupLiteLLMCostTracking(pi, [...providerNames]);
|
|
2230
2237
|
|
|
2238
|
+
if (budgetEnabled) {
|
|
2239
|
+
const { display, warning } = budgetDisplaySetting(settings?.budget);
|
|
2240
|
+
const definitionNamed = (name: string) => definitions.find((definition) => definition.name === name)!;
|
|
2241
|
+
setupLiteLLMBudget(pi, {
|
|
2242
|
+
providers: definitions.map(({ name, displayName }) => ({ name, displayName })),
|
|
2243
|
+
display,
|
|
2244
|
+
displayWarning: warning,
|
|
2245
|
+
resolveAuth: (ctx, name) => getRuntimeAuth(ctx, definitionNamed(name)),
|
|
2246
|
+
timeoutMs: getDiscoveryTimeoutMs,
|
|
2247
|
+
disabledReason: discoveryDisabledReason,
|
|
2248
|
+
hostOffline: isHostOffline,
|
|
2249
|
+
missingCredentials: (name) => missingCredentials(definitionNamed(name)),
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2231
2253
|
if (skillsEnabled) {
|
|
2232
2254
|
for (const tool of createSkillToolDefinitions(resolveDefaultRuntimeAuth)) {
|
|
2233
2255
|
pi.registerTool(tool);
|
package/src/public-catalog.ts
CHANGED
|
@@ -8,6 +8,8 @@ export const MODELS_DEV_URL = "https://models.dev/api.json";
|
|
|
8
8
|
const MODELS_DEV_CACHE_TTL_MS = 28 * 24 * 60 * 60 * 1000;
|
|
9
9
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
10
10
|
const KNOWN_PROVIDERS = new Set<string>(getProviders());
|
|
11
|
+
// Pi 1.0.3 renamed its Azure catalog from `azure-openai-responses` to `azure`.
|
|
12
|
+
export const PI_AZURE_PROVIDER = KNOWN_PROVIDERS.has("azure") ? "azure" : "azure-openai-responses";
|
|
11
13
|
|
|
12
14
|
export interface PublicCatalogRecord {
|
|
13
15
|
source: "models.dev" | "pi-vendor" | "pi-adapter";
|
|
@@ -72,8 +74,8 @@ const PROVIDER_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
|
72
74
|
|
|
73
75
|
const PI_PROVIDER_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
74
76
|
"amazon-bedrock": ["amazon-bedrock"],
|
|
75
|
-
azure: [
|
|
76
|
-
azure_ai: [
|
|
77
|
+
azure: [PI_AZURE_PROVIDER],
|
|
78
|
+
azure_ai: [PI_AZURE_PROVIDER],
|
|
77
79
|
chatgpt: ["openai-codex"],
|
|
78
80
|
"fireworks-ai": ["fireworks"],
|
|
79
81
|
openai: ["openai"],
|