See your LiteLLM virtual key inside Claude Code: budget, spend, limits, expiry, models and recent usage. Admins can also create and edit keys, grant extra…

<img src="docs/assets/banner.svg" alt="cc-litellm: your LiteLLM key, budget and fallbacks inside Claude Code" width="100%">
<a href="#install"><img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude%20Code-plugin-d97757?style=for-the-badge"></a> <img alt="LiteLLM v1.99.1 and v1.104.0" src="https://img.shields.io/badge/LiteLLM-v1.99.1%20%C2%B7%20v1.104.0%20tested-6366f1?style=for-the-badge"> <img alt="Claude Code 2.1.289" src="https://img.shields.io/badge/Claude%20Code-2.1.289%20tested-0ea5e9?style=for-the-badge"> <img alt="CI" src="https://img.shields.io/github/actions/workflow/status/juninmd/cc-litellm/ci.yml?branch=main&style=for-the-badge&label=CI"> <img alt="License: MIT" src="https://img.shields.io/github/license/juninmd/cc-litellm?style=for-the-badge&color=22c55e"> <img alt="Version 0.3.0" src="https://img.shields.io/badge/version-0.3.0-f472b6?style=for-the-badge">
<b>English</b> · <a href="docs/i18n/README.pt-BR.md">Português</a> · <a href="docs/i18n/README.es.md">Español</a> · <a href="docs/i18n/README.fr.md">Français</a> · <a href="docs/i18n/README.ja.md">日本語</a> · <a href="docs/i18n/README.it.md">Italiano</a> · <a href="docs/i18n/README.zh-CN.md">简体中文</a> · <a href="docs/i18n/README.de.md">Deutsch</a> · <a href="docs/i18n/README.ru.md">Русский</a> · <a href="docs/i18n/README.tr.md">Türkçe</a> · <a href="docs/i18n/README.hi.md">हिन्दी</a>
A Claude Code plugin for people who reach their models through a LiteLLM proxy. It shows what the proxy knows about the virtual key Claude Code is using (budget, spend, limits, expiry, models, 30 days of usage) and, for admins, lets you create and edit keys, give someone extra budget, block a key and read the router's fallback chains without leaving the terminal.
This repository is a plugin marketplace (cc-litellm) with one plugin: litellm-key.
<img src="docs/evidence/pane.png" alt="The /litellm pane beside the conversation, against a real LiteLLM proxy" width="92%">
| 👀 Watch | Status line under the prompt, always visible | ⚠ litellm-key: 86% of budget · $30.00 of $35.00 · resets in 27d (30d) |
/litellm pane | meters for key, team, user and team-member budgets, the user's role, limits, expiry, models, 7-day sparkline, top models of the week, and a runway forecast; refreshes itself | |
| Pane tabs | Usage (spend, requests or tokens per day as bars over 7, 14 or 30 days, a day to pick, how each model moved), Models (what each spent, a filter, a sort), Details (the key's fields, LiteLLM's version and database, latency), Ping (press p: every endpoint timed, latency average, best, worst and a sparkline of the last pings) | |
| Guidance | Allowance (what to spend a day to last until the reset), Headroom (how many more requests the cap holds), Today against the usual day, Session (what this Claude Code session spent, and at what rate) | |
| Toasts | at 80% (configurable), 95%, 100%; key about to expire; key blocked or expired; today over your daily alert. Once per budget window, even across sessions | |
| Over-budget banner | a red band above the prompt that stays for as long as a budget is spent up (key, user, team, window or model) and leaves only when the numbers are normal again | |
| 📊 Report | /litellm pace, usage, compare, day, status | where the budget is heading, the days and models as tables, what changed against the days before, one day by model |
/litellm check | OK, WARNING, CRITICAL or UNKNOWN and the exit code of a claude -p run (0 to 3), for scripts and monitoring | |
/litellm json / csv | everything as JSON, the days as CSV; copy puts any report on the clipboard, share hands it to Claude to ask about | |
| 🛠️ Manage (admin) | /litellm key new | create a virtual key; the secret goes to your clipboard, never the transcript |
/litellm grant | extra budget for a key, a user, a team or an organization, with a preview and a confirmation | |
/litellm key set / reset-spend | change a key's models, limits, expiry or alias; zero its spend counter | |
/litellm key block / unblock | stop (or restore) a key in one line | |
/litellm org | an organization's budget, which a virtual key cannot read | |
/litellm keys | list keys: yours, a user's, a team's, or all | |
/litellm fallbacks | the router's fallback chains (cloud/auto → cloud/auto-long → …), plus context-window fallbacks |
Every change shows a preview first, asks in Claude Code's native dialog, applies, then reads the result back from the proxy.
Needs a recent Claude Code: the plugin uses function hooks (an early-access API), tested on 2.1.289.
/plugin marketplace add juninmd/cc-litellm
/plugin install litellm-key@cc-litellm
Try it from a clone without installing: claude --plugin-dir ./plugins/litellm-key.
If Claude Code already talks to LiteLLM, there is nothing to configure: the plugin reads the same URL and key Claude Code uses. Admin commands also want litellm_admin_key (see Admin commands).
<img src="docs/evidence/statusline.png" alt="Claude Code with the litellm-key status line under the prompt" width="92%">
/litellm models lists what the key may call, /litellm keys the keys you own:
<img src="docs/evidence/keys.png" alt="/litellm models and /litellm keys output" width="92%">
The pane has four tabs (plus Ping, and Admin for a proxy admin: see below). Overview is the dashboard above; Usage draws the last 7, 14 or 30 days as bars, counting spend, requests or tokens, lets you pick a day for its models, and says which model moved against the days before:
1: Overview 2: Usage 3: Models 4: Details
Spend per day (UTC) 7d d: 14d 30d m: chart: spend v: CSV
$11.4 ██████
▁▁▁▁▁▁ ▇▇▇▇▇▇ ██████
██████ ██████ ██████
▇▇▇▇▇▇ ██████ ██████ ▃▃▃▃▃▃ ██████
▅▅▅▅▅▅ ██████ ██████ ██████ ██████ ██████
$0 ██████ ██████ ██████ ██████ ██████ ██████
Wed Thu Fri Sat Sun Mon Tue
$3.10 $5.40 · $7.90 $9.20 $4.40 $11.4
Spend $41.37 · $5.91/day
Requests 369 · $0.112 each
Failed 4 requests (1.1%)
Peak day $11.37 on Tue Oct 6
Trend ▲ 34% vs the 7 days before (full days)
By model, last 7 days · ▲▼ vs the 7 before ─────────────────────────────────
claude-sonnet-4-5 ▄▄▄▄▄▄▄▄▄▄▁▁▁▁▁▁▁▁▁▁ 52% $21.51 ▲ 34% · 189 requests
claude-opus-4-1 ▄▄▄▄▄▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 28% $11.58 ▲ 34% · 99 requests
Models lists what the key may call with what each spent over the range (sort by spend or name, and type in the filter to narrow a long list); Details groups what the proxy said of the key, its LiteLLM version and database state, and how long /key/info took. The range, sort and chart you pick are kept for next time, and /litellm opens the pane on the tab you left it.
/litellm pace
Budget $41.37 / $50.00 (83%) · $8.63 left · resets in 9d 3h (30d)
Runway out in 1d 6h at $6.75/day · resets in 9d 3h
Allowance $0.95/day to last · 86% less than lately
Headroom about 76 more requests at $0.112 each
Today $11.37 · 102 requests · 2.2× the usual day ($5.21)
Session +$0.40 since 03:03 (12m ago)
claude -p "/litellm check"; echo $? # WARNING · 83% of budget … (exit 1)
claude -p "/litellm json" | jq .budget.percent
claude -p "/litellm csv 30" > usage.csv
The plugin tells a blocked key from an expired one from a wrong one, instead of a generic 401:
<table> <tr> <td width="50%"><img src="docs/evidence/warning.png" alt="86% of the budget used"><br><sub><b>86%</b>: warning toast and status line</sub></td> <td width="50%"><img src="docs/evidence/over-budget.png" alt="Over budget"><br><sub><b>Over budget</b>: a banner that stays until the budget is normal</sub></td> </tr> <tr> <td width="50%"><img src="docs/evidence/blocked.png" alt="Key blocked"><br><sub><b>Blocked</b> key, named as blocked</sub></td> <td width="50%"><img src="docs/evidence/expired.png" alt="Key expired"><br><sub><b>Expired</b> key, named as expired</sub></td> </tr> </table>
<table> <tr> <td width="50%"><img src="docs/evidence/key-new-dialog.png" alt="Native confirmation before creating a key"><br><sub>Preview, then Claude Code's native confirmation</sub></td> <td width="50%"><img src="docs/evidence/key-new-done.png" alt="The key was copied to the clipboard"><br><sub>The secret goes to the clipboard. The transcript only sees <code>sk-…9FKg</code></sub></td> </tr> </table>
<table> <tr> <td width="50%"><img src="docs/evidence/grant-dialog.png" alt="Preview of a budget grant"><br><sub><code>$25 → $35 (+$10)</code>, what is spent, what would be left</sub></td> <td width="50%"><img src="docs/evidence/grant-recovers.png" alt="The key has room again after a grant"><br><sub>Applied and read back; the status line follows (101% → 79%)</sub></td> </tr> </table>
A team can cap what each member spends (team_member_budget). The proxy refuses the request while the key's own budget is fine, so the plugin reads the cap and shows it as a Member meter, and the over-budget banner names it:
<img src="docs/evidence/member-cap.png" alt="The pane with a Member meter over its cap, the banner above the prompt, and the key's organization named" width="92%">
<sub>Shot against dev/mock-litellm.py --scenario member. The proxy does not report a member's total to a virtual key, so the meter counts <b>this key's spend</b> and says so. It can read low, and against a cap that resets it can read high (a reset zeroes the member's spend, not the key's), so the banner is raised only for a cap that never resets. The key's organization is named too; its budget is admin-only, <code>/litellm org</code> reads it.</sub>
<img src="docs/evidence/fallbacks-filtered.png" alt="/litellm fallbacks cloud/auto" width="92%">
<img src="docs/evidence/models-prices.png" alt="/litellm models with the price per million tokens in and out and the context window" width="92%">
Everything above is the real LiteLLM v1.99.1 admin UI reflecting what the plugin did:
<table> <tr> <td width="50%"><img src="docs/evidence/litellm-ui-keys.png" alt="LiteLLM UI, Virtual Keys"><br><sub>Keys created and raised from Claude Code; one expired</sub></td> <td width="50%"><img src="docs/evidence/litellm-ui-usage.png" alt="LiteLLM UI, Usage"><br><sub>Spend shows up in Usage</sub></td> </tr> <tr> <td width="50%"><img src="docs/evidence/litellm-ui-users.png" alt="LiteLLM UI, Internal Users"><br><sub>The user's proxy role (<code>internal_user</code>, <code>proxy_admin</code>) is what the pane's <b>Role</b> line shows</sub></td> <td width="50%"></td> </tr> </table>
| Command | Does | |||
|---|---|---|---|---|
/litellm | Open the pane, on the tab you left it (and answer with a one-line summary). No screen: print that tab as text. | |||
/litellm tab <name> | Open the pane on overview, usage, models or details (or 1 to 4). | |||
/litellm refresh | Read again now. | |||
/litellm info | Print the full summary in the transcript. | |||
/litellm status | Print the status line as text. | |||
/litellm pace | Where the budget is heading, what it can spend a day to last, and the same for the team and the user. | |||
| `/litellm usage [7\ | 14\ | 30]` | The spend, requests and tokens per day as a table, with the totals and the models. | |
| `/litellm compare [7\ | 14]` | The last full days against the same number before them, as a whole and model by model. | ||
/litellm day [when] | One day by model: today, yesterday, 2026-10-03, 10-03 or a weekday (mon). | |||
/litellm models [text] | List the models this key can call, with their price per million tokens and context window; with a text, only those whose name has it. | |||
/litellm check [warn%] | OK, WARNING, CRITICAL or UNKNOWN, and the exit code of a claude -p run: 0, 1, 2, 3. A budget over its cap, or a key the proxy says is blocked, expired or rejected, is CRITICAL; a proxy that does not answer is UNKNOWN. | |||
/litellm json | Everything the plugin knows of the key as JSON (no key, no hash). | |||
| `/litellm csv [7\ | 14\ | 30]` | The days as CSV. | |
/litellm copy [what] | Put a report on the clipboard: overview, usage, models, details, pace, compare, csv or json. | |||
/litellm share [what] | Hand a report to Claude, out of sight, so the next question can be about it. | |||
/litellm ping | Try every endpoint the plugin reads, with its status and time. | |||
/litellm debug | Show where the URL and the keys come from (always masked), what was tried, the result. | |||
/litellm close | Close the pane. | |||
| `/litellm keys [--user ID \ | --team ID \ | --all]` | List keys. Default: the keys of your own user. 🔐 | |
/litellm key new <alias> [flags] | Create a key. 🔐 | |||
| `/litellm key block <alias\ | hash> / unblock` | Block or restore a key. 🔐 | ||
| `/litellm key set <alias\ | hash> [flags]` | Change the models, limits, expiry or alias of a key. 🔐 | ||
| `/litellm key reset-spend <alias\ | hash>` | Set a key's spend counter back to zero. 🔐 | ||
| `/litellm grant <amount> [--key \ | --user \ | --team \ | --org] [--set]` | Add budget. 🔐 |
| `/litellm org [id\ | alias]` | An organization's budget; no name: the key's own organization, else the list. 🔐 | ||
/litellm fallbacks [model] | Router fallback chains, optionally for models matching a name. 🔐 |
🔐 = admin command, see below. In the pane (focus it with a click or ctrl+x tab): 1 to 4 switch tab, r refreshes, c copies the tab you are on, q closes, arrows scroll; each button names its key (Refresh (r), Copy (c), Close (q)). On Usage, d steps through 7, 14 and 30 days, m through spend, requests and tokens, v copies the days as CSV; on Models, s sorts and f goes to the filter. Esc also closes the pane on an empty prompt (on Models it only leaves the filter).
A mistyped command gets a guess (Did you mean "usage"?). A command that cannot do what it was asked says so in a sentence, and the ones meant for scripts (check, json, csv, ping) end with exit code 3 when there is nothing to report.
The pane adapts to the space: beside the conversation (full screen, from 110 columns) each meter takes two lines; above the prompt, from 122 columns, the meters become a table; in narrower terminals it keeps two lines per meter, or turns compact if you enable compact_pane. Beside the conversation the pane gets titled sections (BUDGETS, KEY, LAST 7 DAYS, TOP MODELS) and a letter under each day of the week; TOP MODELS ranks the five models that spent most, each with its share of the week as a bar. A long name is cut in the middle, so claude-sonnet-4-5 and claude-sonnet-4-6 stay apart. Color is never the only signal: ▲ marks a budget that is close to its cap, ✖ one that is spent up, and a day with no spend is a ·, never a short bar.
Runway. The Runway row (in the pane and in /litellm info) sets the pace of the last 7 days (fewer for a key younger than that, never fewer than one) against the cap: lasts until the reset at $2.18/day, or out in 2d 6h at $2.18/day · resets in 6d 12h when the budget would run out first. The status line adds out in 2d 6h at this pace only when that is coming: before the reset, or within 3 days for a key with no reset. A key with no cap, one already spent up, and one whose reset is due get no forecast.
<img src="docs/evidence/runway.png" alt="The pane for a key on course to run out: the Runway row and the status line warn, and the week is split by model" width="92%">
<sub>Shot against dev/mock-litellm.py --scenario warning: the local lab has no week of history to forecast from.</sub>
<img src="docs/evidence/help.png" alt="/litellm help" width="92%">
Ping (5) times every endpoint the plugin reads and shows the latency to /key/info with its average, best, worst and a sparkline; p asks again, and /litellm tab ping opens it already measured.
<img src="docs/evidence/ping.png" alt="The Ping tab" width="92%">
Admin (6) appears when the key is a proxy admin (user_role: proxy_admin) or litellm_admin_key is set. It lists the keys that spent most, the teams, and what each model spent across the whole proxy, and every row has buttons: Block/Unblock a key and +$10 for a key or a team. A press goes through the same preview and native confirmation as /litellm key block and /litellm grant, then the lists are read again.
<img src="docs/evidence/admin-tab.png" alt="The Admin tab" width="92%">
Reads and changes of keys need a proxy admin. Set the litellm_admin_key option (stored in your OS credential store, never in settings.json). Without it the plugin tries with your virtual key and, if the proxy refuses, tells you exactly that.
/litellm key new ci-runner --budget 5 --every 7d --rpm 60 --user ana@example.com
/litellm key new batch --budget 20 --models cloud/auto,cloud/auto-long --expires 30d --team platform-eng
/litellm grant 10 --key claude-code-ana # +$10 on top of the current budget
/litellm grant 200 --team platform-eng --set # cap the team at exactly $200
/litellm grant 25 --org acme # +$25 on the organization (LiteLLM before 1.102, or enterprise)
/litellm key set ci-runner --models cloud/auto --rpm 30 --expires 14d
/litellm key set ci-runner --rpm none --expires never # none removes a limit; --models all clears the list
/litellm key reset-spend ci-runner # the budget counter back to $0
/litellm key block old-contractor
/litellm fallbacks cloud/auto
key new flag | Meaning |
|---|---|
--budget 10 | Spend cap in dollars. |
--every 30d | Budget window: it resets every 30 days (s m h d w mo). |
--soft 8 | Soft alert threshold. |
--models a,b | Models the key may call (default: all). |
--rpm 60 / --tpm 100000 / --parallel 4 | Rate limits. |
--expires 30d | The key stops working after this long. |
--user ID / --team ID | Who owns it (and whose budget also applies). |
key set flag | Meaning |
|---|---|
--models a,b / --models all | Replace the models the key may call (all: every model). |
--rpm N / --tpm N / --parallel N | Set a limit; none removes it. |
--expires 30d / --expires never | Expire after this long from now, or never. |
--alias NEW | Rename the key. |
A field you leave out stays as it is. The preview shows before → after for each field, and warns when the key is the one Claude Code is using.
Safety rails, on every admin command:
--dry-run stops there; --yes skips the confirmation; otherwise Claude Code's native dialog asks (Apply / Cancel).--reveal prints it, with a warning that it is now saved in the transcript.sk-… values are refused as key references: use an alias or the key hash. Unknown flags are errors, not silently ignored.grant says when the spend already exceeds the new budget, when there is no cap to add to (use --set), when nothing would change, and when --user would create a user the proxy has never seen.What can be given as extra budget today, on LiteLLM v1.99.1 and v1.104.0: raise a key budget, a user budget, or a team budget (--team, which needs a proxy admin), as an increment or an absolute value (--set). An organization budget (--org) works up to v1.101; from v1.102 the proxy keeps organizations for enterprise licenses and the plugin says so. A temporary budget increase (temp_budget_increase) and per-model budgets are enterprise-only on the proxy side (see Budgets), so the plugin does not offer them rather than pretend.
Checked live against LiteLLM v1.99.1 (open-source proxy, no license); the member cap and organizations were also checked on v1.104.0:
| Budget | Works? | How |
|---|---|---|
| Per key (cap + reset window) | ✅ | /litellm key new --budget 10 --every 30d; raise with /litellm grant 5 --key NAME |
| Per user | ✅ | /litellm grant 5 --user ID (applies to every key the user owns) |
| Per team | ✅ | /litellm grant 50 --team NAME (needs a proxy admin) |
Per member of a team (team_member_budget) | 👀 read-only | blocks the user's requests in that team (HTTP 429, 422 from v1.104). The pane shows the cap as Member…; set it in the LiteLLM UI or API. A virtual key cannot read the member's total, so the meter counts this key's spend and says so. A reset zeroes the member's spend but not the key's, so the over-budget banner is raised only for a cap that never resets; against a cap that resets the meter warns, it does not claim a block |
| Per organization | ✅ up to v1.101 · ⛔ enterprise from v1.102 | blocks every key in it (HTTP 429). A virtual key cannot read it: the pane names the organization, /litellm org shows the budget (admin), grant --org raises it |
Several windows on one key (budget_limits, e.g. $5/hour + $50/month) | read-only | shown as Window 1h meters when the proxy has them |
Per model on a key (model_max_budget) | ⛔ enterprise | the proxy answers "You must have an enterprise license to set model_max_budget", also for /budget/new. If your proxy has the license, the pane shows those meters (Model gpt-4o) |
Temporary budget increase (temp_budget_increase) | ⛔ enterprise | the open-source proxy accepts the field and never enforces it |
Per-model budget without the license: make one key per model, each with its own cap, e.g. /litellm key new auto-only --models cloud/auto --budget 5 --every 30d. The key can call only that model and stops at $5.
The plugin reads the same URL and key Claude Code uses, in this order (process variables first, then the env block of settings.json):
| What | From |
|---|---|
| URL | option litellm_url, ANTHROPIC_BASE_URL, LITELLM_PROXY_API_BASE |
| Key | option litellm_key, the x-litellm-api-key header in ANTHROPIC_CUSTOM_HEADERS, ANTHROPIC_AUTH_TOKEN,
hooks/register.tsx 299 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ViewName } from '../types'
5
6import { adminLink } from './admin-link'
7import { adminBusy, stepAdmin } from './admin-pane'
8import { overBudgetBand } from './band'
9import type { CommandContext } from './commands'
10import { runCommand } from './commands'
11import { exceededItems } from './exceeded'
12import { clean } from './format'
13import type { Reply } from './litellm'
14import type { Init, Ports } from './ports'
15import { parsePrefs } from './prefs'
16import { pinged, pinging } from './ping-state'
17import { ping, pingRound } from './probe'
18import type { Session } from './session'
19import { createSession } from './session'
20import { configOf } from './settings'
21import { dashboard } from './view'
22
23const PANE = 'litellm-key'
24const REQUEST_MS = 4_000
25
26const snapshotState = atom({ plugin: 'litellm-key', key: 'snapshot' } as const, null)
27const failureState = atom({ plugin: 'litellm-key', key: 'failure' } as const, null)
28const loadingState = atom({ plugin: 'litellm-key', key: 'isLoading' } as const, false)
29const viewState = atom({ plugin: 'litellm-key', key: 'view' } as const, 'overview')
30const rangeState = atom({ plugin: 'litellm-key', key: 'range' } as const, 7)
31const sortState = atom({ plugin: 'litellm-key', key: 'sort' } as const, 'spend')
32const metricState = atom({ plugin: 'litellm-key', key: 'metric' } as const, 'spend')
33const filterState = atom({ plugin: 'litellm-key', key: 'filter' } as const, '')
34const dayState = atom({ plugin: 'litellm-key', key: 'day' } as const, null)
35const pingState = atom({ plugin: 'litellm-key', key: 'ping' } as const, null)
36const adminState = atom({ plugin: 'litellm-key', key: 'admin' } as const, null)
37
38/**
39 * Esc closes the pane at an empty prompt, as the person's close does, and so it would when pressed to leave the filter
40 * field: the Models tab, the only one with a field, leaves Esc to the field alone and is closed by q or the button.
41 */
42const paneArgs = (tab: ViewName) =>
43 ({ id: PANE, title: 'LiteLLM key', ...(tab === 'models' ? {} : { closeOnEscape: true as const }), rows: 22, columns: 76 }) as const
44
45/** What the person chose is kept for next time; a store that fails only loses that. */
46const savePrefs = async ($: EngineInterface): Promise<void> => {
47 const prefs = { range: await read($, rangeState), sort: await read($, sortState), metric: await read($, metricState) }
48
49 await $.store.set('prefs', prefs).catch(() => undefined)
50}
51
52const loadPrefs = async ($: EngineInterface): Promise<void> => {
53 const { range, sort, metric } = parsePrefs(await $.store.get('prefs').catch(() => undefined))
54 if (range !== undefined) await update($, rangeState, () => range)
55 if (sort !== undefined) await update($, sortState, () => sort)
56 if (metric !== undefined) await update($, metricState, () => metric)
57}
58
59/** One request, timed, and given up on after `ms`. */
60const fetchWithin = async ($: EngineInterface, url: string, init: Init, ms: number): Promise<Reply> => {
61 const started = await $.clock.now()
62 let timer: { cancel: () => void } | undefined
63 const late = new Promise<never>((_, reject) => {
64 timer = $.clock.after(ms, () => reject(new Error(`no answer within ${ms / 1000}s`)))
65 })
66
67 try {
68 const { status, text } = await Promise.race([$.http.fetch(url, init), late])
69
70 return { status, text, ms: (await $.clock.now()) - started }
71 } finally {
72 timer?.cancel()
73 }
74}
75
76// `$` stays in this file: the runtime follows it only here, so everything else gets these closures.
77const portsOf = ($: EngineInterface): Ports => ({
78 now: () => $.clock.now(),
79 env: async () => ({
80 ANTHROPIC_BASE_URL: await $.env.get('ANTHROPIC_BASE_URL'),
81 ANTHROPIC_AUTH_TOKEN: await $.env.get('ANTHROPIC_AUTH_TOKEN'),
82 ANTHROPIC_API_KEY: await $.env.get('ANTHROPIC_API_KEY'),
83 ANTHROPIC_CUSTOM_HEADERS: await $.env.get('ANTHROPIC_CUSTOM_HEADERS'),
84 LITELLM_PROXY_API_BASE: await $.env.get('LITELLM_PROXY_API_BASE'),
85 LITELLM_PROXY_API_KEY: await $.env.get('LITELLM_PROXY_API_KEY'),
86 }),
87 settings: () => $.settings.read(),
88 fetch: (url, init, ms = REQUEST_MS) => fetchWithin($, url, init, ms),
89 loading: async isLoading => {
90 await update($, loadingState, () => isLoading)
91 },
92 publish: async (snapshot, failure) => {
93 await update($, snapshotState, () => snapshot)
94 await update($, failureState, () => failure)
95 },
96 status: text => $.ui.status(text),
97 toast: message => $.ui.toast(message, { timeoutMs: 8000 }),
98 remembered: () => $.store.get('notified'),
99 remember: async ids => void (await $.store.set('notified', ids)),
100})
101
102/** Runs one round for the Ping tab and keeps its time beside the rounds before; one at a time. */
103const runPingTab = async ($: EngineInterface, session: Session): Promise<void> => {
104 const before = await read($, pingState)
105
106 if (!before?.isRunning) {
107 await update($, pingState, () => pinging(before))
108 const round = await pingRound(session, portsOf($)).catch(() => null)
109
110 await update($, pingState, () => pinged(before, round))
111 }
112}
113
114/** Reads the Admin lists, after an action when there is one; the action asks its own confirmation first. */
115const runAdminTab = async ($: EngineInterface, session: Session, action?: Parameters<typeof stepAdmin>[2]): Promise<void> => {
116 const before = await read($, adminState)
117
118 if (!before?.isLoading) {
119 await update($, adminState, () => adminBusy(before))
120 const { state, isChanged } = await stepAdmin(() => adminOf($, session), before, action)
121
122 await update($, adminState, () => state)
123 if (isChanged) void session.load(portsOf($), 'force')
124 }
125}
126
127/** The tabs that read something measure by themselves the first time they open. */
128const startTab = async ($: EngineInterface, session: Session, tab: ViewName): Promise<void> => {
129 if (tab === 'ping' && (await read($, pingState)) === null) void runPingTab($, session)
130 if (tab === 'admin' && (await read($, adminState)) === null) void runAdminTab($, session)
131}
132
133/** What the pane was last told about Esc: whether it closes the pane (it does, except on the tab with the field). */
134type PaneMode = { escapes: boolean }
135
136const adminOf = ($: EngineInterface, session: Session) =>
137 adminLink(session, portsOf($), {
138 surfaces: () => $.session.surfaces(),
139 ask: (question, options) => $.ui.ask(question, options),
140 copy: async value => (await $.ui.copy({ text: value })).isCopied,
141 })
142
143const contextOf = ($: EngineInterface, session: Session, pane: PaneMode): CommandContext => {
144 const ports = portsOf($)
145
146 return {
147 session,
148 now: () => $.clock.now(),
149 surfaces: () => $.session.surfaces(),
150 ensureFresh: () => session.ensureFresh(ports),
151 refresh: () => session.load(ports, 'force'),
152 reload: () => session.reload(ports, 'force'),
153 openPane: async tab => {
154 if (tab !== null) {
155 await update($, viewState, () => tab)
156 }
157
158 const shown = tab ?? (await read($, viewState))
159
160 pane.escapes = shown !== 'models'
161 await startTab($, session, shown)
162
163 return $.ui.open(paneArgs(shown))
164 },
165 closePane: async () => {
166 await $.ui.close({ id: PANE })
167 },
168 sleep: ms => $.clock.sleep(ms),
169 view: async () => ({ tab: await read($, viewState), range: await read($, rangeState) }),
170 copy: async text => {
171 try {
172 return await $.ui.copy({ text })
173 } catch {
174 return { isCopied: false, reason: 'refused' } // a clipboard that throws is one that refuses
175 }
176 },
177 ping: () => ping(session, ports),
178 admin: () => adminOf($, session),
179 }
180}
181
182export const register: Register = (on, options) => {
183 const session = createSession()
184 const pane: PaneMode = { escapes: true }
185 const { state } = session
186
187 state.config = configOf(options)
188
189 on('session.start', async ($, e, next) => {
190 await $.command.register({
191 name: 'litellm',
192 description: 'Show your LiteLLM virtual key: budget, limits, models and usage',
193 argumentHint: '[refresh|info|status|pace|usage|compare|day|models|check|json|csv|copy|share|ping|keys|key|grant|org|fallbacks|debug|close|help]',
194 })
195 state.ticker?.cancel()
196 state.ticker = $.clock.every(state.config.refreshSeconds * 1000, () => {
197 session.reload(portsOf($), 'tick')
198 })
199 $.clock.after(250, () => {
200 session.reload(portsOf($), 'force')
201 })
202 await loadPrefs($)
203
204 return next(e)
205 })
206
207 on('turn.complete', async ($, e, next) => {
208 $.clock.after(1500, () => {
209 session.reload(portsOf($), 'turn')
210 })
211
212 return next(e)
213 })
214
215 on('command.run', { command: 'litellm' }, ($, e) => runCommand(contextOf($, session, pane), e.args))
216
217 // Unlike a toast, this stays for as long as a budget is spent up, and goes only when the next reading is normal.
218 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
219 const snapshot = await read($, snapshotState)
220 const items = snapshot && !e.props.hasSurvey ? exceededItems(snapshot, await $.clock.now()) : []
221
222 return items.length === 0 ? next(e) : overBudgetBand($.ui.resolve(e), items)
223 })
224
225 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
226 const ui = $.ui.resolve(e)
227 const snapshot = await read($, snapshotState)
228 const failure = await read($, failureState)
229 const isLoading = await read($, loadingState)
230 const tab = await read($, viewState)
231 const range = await read($, rangeState)
232 const sort = await read($, sortState)
233 const metric = await read($, metricState)
234 const filter = await read($, filterState)
235 const day = await read($, dayState)
236 const ping = await read($, pingState)
237 const admin = await read($, adminState)
238 const now = await $.clock.now()
239 const { config } = state
240
241 return dashboard(ui, {
242 snapshot,
243 failure,
244 isLoading,
245 now,
246 columns: e.props.bodyColumns,
247 placement: e.props.placement,
248 hasField: e.surface !== 'mobile',
249 isCompact: config.isCompact,
250 isUsageShown: config.isUsageShown,
251 warnPercent: config.warnPercent,
252 refreshSeconds: config.refreshSeconds,
253 tab,
254 range,
255 sort,
256 metric,
257 filter,
258 day,
259 ping,
260 admin,
261 isAdmin: snapshot?.userRole?.startsWith('proxy_admin') === true || config.adminKey !== null,
262 onAdminLoad: () => void runAdminTab($, session),
263 onAdminDo: (command, input) => void runAdminTab($, session, { command, input }),
264 onPing: () => void runPingTab($, session),
265 onRefresh: () => void session.reload(portsOf($), 'force'),
266 onClose: () => void $.ui.close({ id: PANE }),
267 onCopy: (text, what, press) => {
268 const toast = (message: string) => $.ui.toast(message, { timeoutMs: 2500 })
269
270 $.ui.copy({ text, surface: press.surface }).then(result => toast(result.isCopied ? `Copied ${what}` : `Could not copy ${what} (${result.reason})`)).catch(() => undefined)
271 },
272 onTab: next => {
273 void update($, viewState, () => next)
274 .then(() => read($, viewState))
275 .then(shown => {
276 void startTab($, session, shown)
277 // Open again to change what Esc does, when the tab that is really showing (two presses can land before a
278 // redraw) is not the kind the pane was told about: the one tab that has a field keeps Esc to itself.
279 if ((shown !== 'models') !== pane.escapes) {
280 pane.escapes = shown !== 'models'
281
282 return $.ui.open(paneArgs(shown))
283 }
284
285 return undefined
286 })
287 .catch(() => undefined)
288 },
289 onRange: next => void update($, rangeState, () => next).then(() => savePrefs($)),
290 onSort: next => void update($, sortState, () => next).then(() => savePrefs($)),
291 onMetric: next => void update($, metricState, () => next).then(() => savePrefs($)),
292 // Typed or pasted, it goes into a field the engine refuses to draw if it holds a control character.
293 onFilter: text => void update($, filterState, () => clean(text)),
294 onDay: date => void update($, dayState, () => date),
295 onFocusFilter: () => void $.ui.focus({ requestId: PANE, key: 'filter' }).catch(() => undefined),
296 })
297 })
298}
299hooks/admin-link.ts 66 lines1import type { Deps } from './admin-commands'
2import { resolveCredentials } from './credentials'
3import type { Ports } from './ports'
4import type { Session } from './session'
5import { sourcesOf } from './settings'
6import { failureText } from './summary'
7
8const ADMIN_REQUEST_MS = 15_000
9
10/** What the admin commands need from the engine besides the proxy. */
11export type AdminIo = {
12 surfaces: () => Promise<readonly string[]>
13 ask: Deps['ask']
14 copy: Deps['copy']
15}
16
17// Admin calls use litellm_admin_key when set, else the virtual key itself, and only ever go to the root that answered /key/info.
18export const adminLink = async (session: Session, ports: Ports, io: AdminIo): Promise<{ deps: Deps } | { text: string }> => {
19 const { state } = session
20
21 await session.ensureFresh(ports)
22 const now = await ports.now()
23 const resolved = resolveCredentials(await sourcesOf(state.config, ports), now)
24
25 if (!resolved.ok) {
26 return { text: failureText(resolved.failure) }
27 }
28 if (state.pinnedRoot !== null && !session.isPinnedFor(resolved.credentials)) {
29 // the URL or the key changed since a proxy answered: ask again before trusting any root
30 await session.load(ports, 'force')
31 }
32 if (!session.isPinnedFor(resolved.credentials) || state.pinnedRoot === null) {
33 // the admin key only goes to a proxy that already accepted this session's own key
34 return {
35 text: state.latest.failure
36 ? `${failureText(state.latest.failure)}\nAdmin commands wait until the proxy accepts this session's key; use another session or the LiteLLM UI to fix it.`
37 : 'The proxy has not answered yet. Try again in a moment.',
38 }
39 }
40 const { credentials } = resolved
41 const { adminKey } = state.config
42 // with an admin key nothing of the virtual key's headers goes along: x-litellm-api-key would win over authorization
43 const headers: Record<string, string> = adminKey
44 ? { accept: 'application/json', authorization: `Bearer ${adminKey}`, 'content-type': 'application/json' }
45 : { ...credentials.headers, 'content-type': 'application/json' }
46
47 return {
48 deps: {
49 admin: {
50 root: state.pinnedRoot,
51 headers,
52 send: (url, init) => ports.fetch(url, init, ADMIN_REQUEST_MS),
53 secrets: adminKey ? [credentials.key, adminKey] : [credentials.key],
54 isOwnKey: adminKey === null,
55 },
56 ownHash: state.latest.snapshot?.key.keyHash ?? null,
57 ownUserId: state.latest.snapshot?.key.userId ?? null,
58 ownOrgId: state.latest.snapshot?.key.organizationId ?? null,
59 surfaces: await io.surfaces(),
60 now,
61 ask: io.ask,
62 copy: io.copy,
63 },
64 }
65}
66hooks/admin-pane.ts 40 lines1import type { AdminState } from '../types'
2import type { AdminCommand } from './admin-commands'
3import { runAdmin } from './admin-commands'
4import type { Deps } from './admin-flow'
5import { readAdminView } from './admin-view'
6import { truncate } from './format'
7
8export type AdminLink = () => Promise<{ deps: Deps } | { text: string }>
9
10/** What the tab shows while it works: what it had stays, so the lists do not blink away. */
11export const adminBusy = (before: AdminState | null): AdminState => ({ view: before?.view ?? null, failure: null, message: before?.message ?? null, isLoading: true })
12
13/**
14 * One round of the Admin tab: an action first, when there is one (it goes through the same preview and confirmation as
15 * the slash command), then the lists read again so they show what is there. Never throws.
16 */
17export const stepAdmin = async (link: AdminLink, before: AdminState | null, action?: { command: AdminCommand; input: string }): Promise<{ state: AdminState; isChanged: boolean }> => {
18 const had = before?.view ?? null
19
20 try {
21 const ready = await link()
22
23 if (!('deps' in ready)) {
24 return { state: { view: had, isLoading: false, failure: ready.text, message: null }, isChanged: false }
25 }
26 const outcome = action ? await runAdmin(ready.deps, action.command, action.input) : null
27 const lines = outcome?.text.split('\n') ?? []
28 // A change says what it did first; a stop (cancelled, dry run, an error) says why on its last line, after the preview.
29 const message = outcome === null ? null : truncate((outcome.isChanged ? lines[0] : lines[lines.length - 1]) ?? '', 120)
30 const view = await readAdminView(ready.deps.admin, ready.deps.now)
31
32 return {
33 state: { view: view.ok ? view.value : had, isLoading: false, failure: view.ok ? null : view.message, message },
34 isChanged: outcome?.isChanged === true,
35 }
36 } catch {
37 return { state: { view: had, isLoading: false, failure: 'The admin read failed unexpectedly.', message: null }, isChanged: false }
38 }
39}
40hooks/band.tsx 53 lines1import type { Elements } from 'claude-code'
2
3import type { Exceeded } from './exceeded'
4import { gauge, money, percent } from './format'
5
6type Ui = Pick<Elements['terminal'], 'Box' | 'Text'>
7
8const BAR = 12
9
10/** The band above the prompt while a budget is spent up: no way to dismiss it, it leaves when the numbers are normal again. */
11export const overBudgetBand = ({ Box, Text }: Ui, items: readonly Exceeded[]) => {
12 const width = Math.max(...items.map(item => item.label.length))
13 const { full } = gauge(1, BAR)
14
15 return (
16 <Box flexDirection="column">
17 <Box gap={1}>
18 <Text bold inverse color="error">
19 {' ✖ BUDGET USED UP '}
20 </Text>
21 <Text bold color="error">
22 the proxy rejects requests until it resets or an admin adds budget
23 </Text>
24 </Box>
25 {items.map(item => (
26 <Box key={item.label} gap={1} paddingLeft={2}>
27 <Box width={width} flexShrink={0}>
28 <Text bold>{item.label}</Text>
29 </Box>
30 <Text color="error">{full}</Text>
31 <Text bold color="error">
32 {`${percent(item.spend, item.limit) ?? 100}%`.padStart(4)}
33 </Text>
34 <Text>
35 {money(item.spend)} of {money(item.limit)}
36 {item.resets ? ` · resets ${item.resets}` : ''}
37 </Text>
38 </Box>
39 ))}
40 {items.some(item => item.isGrantable) && (
41 <Box paddingLeft={2}>
42 <Text>An admin can raise it with /litellm grant <amount>; otherwise it resets as shown.</Text>
43 </Box>
44 )}
45 {items.some(item => !item.isGrantable) && (
46 <Box paddingLeft={2}>
47 <Text>A member cap is set on the team (team_member_budget) that never resets, and the amount counts this key only: an admin changes the cap there.</Text>
48 </Box>
49 )}
50 </Box>
51 )
52}
53hooks/commands.ts 246 lines1import type { ViewName } from '../types'
2import type { AdminCommand } from './admin-commands'
3import { GRANT_HELP, KEY_HELP, runAdmin } from './admin-commands'
4import type { CommandContext, CommandResult } from './command-context'
5import { report } from './command-context'
6import {
7 checkCommand,
8 compareCommand,
9 csvCommand,
10 dayCommand,
11 jsonCommand,
12 modelsCommand,
13 paceCommand,
14 refreshCommand,
15 statusCommand,
16 usageCommand,
17} from './commands-reports'
18import { copyCommand, shareCommand } from './commands-share'
19import { clock, maskKey, money, redact, truncate, withoutCredentials } from './format'
20import { oneLine, summaryText } from './summary'
21import { textOf } from './tab-text'
22import { closest, splitWords } from './words'
23
24export type { CommandContext, CommandResult } from './command-context'
25
26const PANE_WAIT_MS = 2_500
27
28const HELP = [
29 '/litellm open the live pane (on the tab you left it)',
30 '/litellm tab <name> open the pane on overview, usage, models, details, ping or admin',
31 '/litellm refresh read the key again now',
32 '/litellm info print the full summary here',
33 '/litellm status print the status line as text',
34 '/litellm pace where the budget is heading, and what it can spend a day',
35 '/litellm usage [7|14|30] print the spend per day, as a table',
36 '/litellm compare [7|14] what changed against the days before, model by model',
37 '/litellm day [when] one day by model: today, yesterday, 2026-10-03, 10-03, mon',
38 '/litellm models [text] list the models this key can call, with their prices',
39 '/litellm check [warn%] OK, WARNING, CRITICAL or UNKNOWN: the exit code of a -p run too',
40 '/litellm json everything as JSON, for scripts',
41 '/litellm csv [7|14|30] the days as CSV',
42 '/litellm copy [what] copy a report: overview, usage, models, details, pace, compare, csv, json',
43 '/litellm share [what] hand a report to Claude, to ask about it',
44 '/litellm ping try every endpoint the plugin reads, with times',
45 '/litellm debug show where the URL and the key come from',
46 '/litellm close close the pane',
47 '',
48 'Admin commands (need litellm_admin_key, or a key that may manage keys):',
49 '/litellm keys [--user ID | --team ID | --all]',
50 KEY_HELP,
51 GRANT_HELP,
52 '/litellm org [id|alias]',
53 '/litellm fallbacks [model]',
54 'Every change shows a preview first; --dry-run stops there, --yes skips the confirmation.',
55 'A new key goes to the clipboard, never to the transcript.',
56].join('\n')
57
58// What a typo of a subcommand is held against: the names, not their aliases.
59const VIEWS: readonly ViewName[] = ['overview', 'usage', 'models', 'details', 'ping', 'admin']
60
61const viewNamed = (word: string): ViewName | null =>
62 VIEWS.find((view, at) => view === word.toLowerCase() || String(at + 1) === word) ?? null
63
64const NAMES = [
65 'tab',
66 'refresh',
67 'info',
68 'status',
69 'pace',
70 'usage',
71 'compare',
72 'day',
73 'models',
74 'check',
75 'json',
76 'csv',
77 'copy',
78 'share',
79 'ping',
80 'keys',
81 'key',
82 'grant',
83 'org',
84 'fallbacks',
85 'debug',
86 'close',
87 'help',
88]
89
90const debugText = async (ctx: CommandContext): Promise<string> => {
91 const surfaces = await ctx.surfaces()
92 const { config, diagnostics, pinnedRoot, latest } = ctx.session.state
93 const view = await ctx.view()
94 const { snapshot, failure } = latest
95 const lines = [
96 `Refresh every ${config.refreshSeconds}s · status line ${config.isStatusShown ? 'on' : 'off'} · related ${config.isRelatedShown ? 'on' : 'off'} · usage ${config.isUsageShown ? 'on' : 'off'} · compact pane ${config.isCompact ? 'on' : 'off'}`,
97 `Alerts toasts ${config.isToastShown ? 'on' : 'off'} · warn at ${config.warnPercent}% · daily alert ${config.dailyAlert > 0 ? money(config.dailyAlert) : 'off'}`,
98 `Pane ${view.tab} tab · ${view.range} days`,
99 diagnostics
100 ? `Proxy ${diagnostics.host} (tries ${diagnostics.roots.map(withoutCredentials).join(', ')}${pinnedRoot ? `; using ${withoutCredentials(pinnedRoot)}` : ''})`
101 : 'Proxy not resolved',
102 diagnostics ? `Key ${diagnostics.keyHint} from ${diagnostics.keySource}` : 'Key not resolved',
103 config.adminKey
104 ? `Admin ${maskKey(config.adminKey)} from plugin option litellm_admin_key`
105 : 'Admin not set (admin commands use the virtual key, which the proxy may refuse)',
106 failure
107 ? `Result failed (${failure.kind}${failure.status === null ? '' : ` ${failure.status}`}): ${failure.message}`
108 : snapshot
109 ? `Result ok at ${clock(snapshot.fetchedAt)}`
110 : 'Result nothing fetched yet',
111 `Surfaces ${surfaces.length === 0 ? 'none (headless)' : surfaces.join(', ')}`,
112 ...(failure?.hint ? [`Hint ${failure.hint}`] : []),
113 ...(snapshot ? snapshot.notes.map(note => `Note ${note}`) : []),
114 ]
115
116 return lines.join('\n')
117}
118
119/** Opens the pane, on `tab` when one is given, and answers with what to print: a line, or the tab as text if nothing draws. */
120const showPane = async (ctx: CommandContext, tab: ViewName | null): Promise<string> => {
121 const reading = ctx.ensureFresh()
122
123 if ((await ctx.surfaces()).length === 0) {
124 await reading
125 const view = await ctx.view()
126
127 return report(ctx, textOf(tab ?? view.tab, view.range, ctx.session.state.config))
128 }
129 const opened = await ctx.openPane(tab)
130
131 await Promise.race([reading, ctx.sleep(PANE_WAIT_MS)])
132 const line = await report(ctx, (snapshot, now) => oneLine(snapshot, now))
133
134 return opened.isPlaced ? line : `${line}\nThe pane could not be shown (${opened.reason}). Use /litellm info instead.`
135}
136
137/** Runs `/litellm <args>` and answers with the text to print. */
138export const runCommand = async (ctx: CommandContext, args: string): Promise<CommandResult> => {
139 const { config } = ctx.session.state
140 const { word, rest } = splitWords(args)
141
142 try {
143 switch (word.toLowerCase()) {
144 case '':
145 case 'pane':
146 case 'open':
147 return { text: await showPane(ctx, null) }
148 case 'tab':
149 case 'view': {
150 const named = rest[0] ?? ''
151 const tab = viewNamed(named)
152 const guess = tab === null ? closest(named, VIEWS) : null
153
154 return {
155 text:
156 tab === null
157 ? `Unknown tab "${truncate(named, 30)}".${guess === null ? '' : ` Did you mean "${guess}"?`} The tabs are ${VIEWS.join(', ')}.`
158 : await showPane(ctx, tab),
159 }
160 }
161 case 'close':
162 case 'hide':
163 await ctx.closePane()
164
165 return { text: 'Pane closed.' }
166 case 'refresh':
167 case 'reload':
168 case 'r':
169 return refreshCommand(ctx)
170 case 'info':
171 case 'text':
172 case 'summary':
173 await ctx.ensureFresh()
174
175 return { text: await report(ctx, (snapshot, now) => summaryText(snapshot, now, config.warnPercent)) }
176 case 'status':
177 case 'line':
178 return statusCommand(ctx)
179 case 'pace':
180 case 'forecast':
181 case 'runway':
182 return paceCommand(ctx)
183 case 'usage':
184 return usageCommand(ctx, rest)
185 case 'compare':
186 case 'movers':
187 return compareCommand(ctx, rest)
188 case 'day':
189 return dayCommand(ctx, rest[0] ?? '')
190 case 'models':
191 return modelsCommand(ctx, rest)
192 case 'check':
193 return checkCommand(ctx, rest[0])
194 case 'json':
195 return jsonCommand(ctx)
196 case 'csv':
197 return csvCommand(ctx, rest)
198 case 'copy':
199 return copyCommand(ctx, rest)
200 case 'share':
201 return shareCommand(ctx, rest)
202 case 'ping':
203 case 'health':
204 return ctx.ping()
205 case 'keys':
206 case 'key':
207 case 'grant':
208 case 'org':
209 case 'fallbacks': {
210 const ready = await ctx.admin()
211
212 if (!('deps' in ready)) {
213 return { text: ready.text }
214 }
215 const outcome = await runAdmin(ready.deps, word.toLowerCase() as AdminCommand, args.trim().slice(word.length))
216
217 if (outcome.isChanged) {
218 ctx.reload()
219 }
220
221 return { text: outcome.text }
222 }
223 case 'debug':
224 case 'diag':
225 case 'doctor':
226 await ctx.ensureFresh()
227
228 return { text: await debugText(ctx) }
229 case 'help':
230 case '-h':
231 case '--help':
232 return { text: HELP }
233 default: {
234 const guess = closest(word, NAMES)
235
236 return { text: `Unknown option "${truncate(word, 30)}".${guess === null ? '' : ` Did you mean "${guess}"?`}\n${HELP}` }
237 }
238 }
239 } catch (error) {
240 return {
241 text: `Unexpected error: ${truncate(redact(error instanceof Error ? error.message : String(error)), 160)}`,
242 exitCode: 3,
243 }
244 }
245}
246hooks/exceeded.ts 35 lines1import type { Snapshot } from '../types'
2import { money, until } from './format'
3
4/** Spent up means the proxy rejects requests; 99.6% rounds to 100% on screen, but it is not spent up. */
5export const isSpentUp = (spend: number, limit: number | null): limit is number => limit !== null && spend >= limit
6
7/** A budget that is spent up: what it is, how much of what, and when it resets. `isGrantable` is false for a cap /litellm grant cannot move. */
8export type Exceeded = { label: string; spend: number; limit: number; resets: string | null; isGrantable: boolean }
9
10const over = (label: string, spend: number, limit: number | null, resetAt: number | null, now: number, isGrantable = true): Exceeded | null =>
11 isSpentUp(spend, limit) ? { label, spend, limit, resets: resetAt === null ? null : until(resetAt, now), isGrantable } : null
12
13/** Every budget that is spent up (the proxy rejects requests while one is); empty when all is normal. */
14export const exceededItems = (snapshot: Snapshot, now: number): Exceeded[] => {
15 const { key, member, team, user } = snapshot
16
17 return [
18 over(`key ${key.alias ?? snapshot.keyHint}`, key.budget.spend, key.budget.limit, key.budget.resetAt, now),
19 team && over(`team ${team.label}`, team.budget.spend, team.budget.limit, team.budget.resetAt, now),
20 user && over(`user ${user.label}`, user.budget.spend, user.budget.limit, user.budget.resetAt, now),
21 // a cap that resets zeroes the member's spend but not this key's, so that floor may be older periods' spend: only a cap that never resets proves it
22 member && member.budget.duration === null
23 ? over(`member ${member.label} (team ${team?.label ?? key.teamId ?? '?'})`, member.budget.spend, member.budget.limit, member.budget.resetAt, now, false)
24 : null,
25 ...key.windows.map(window => over(`window ${window.duration}`, window.spend ?? 0, window.limit, window.resetAt, now)),
26 ...key.modelBudgets.map(item => over(`model ${item.model}`, item.spend, item.limit, null, now)),
27 ].filter((item): item is Exceeded => item !== null && item !== undefined)
28}
29
30export const exceededLine = (item: Exceeded): string =>
31 `${item.label}: ${money(item.spend)} of ${money(item.limit)}${item.resets ? ` · resets ${item.resets}` : ''}`
32
33/** The same budgets, one line each. */
34export const exceededBudgets = (snapshot: Snapshot, now: number): string[] => exceededItems(snapshot, now).map(exceededLine)
35hooks/format.ts 267 lines1const BLOCKS = '▁▂▃▄▅▆▇█'
2const RISERS = ' ▁▂▃▄▅▆▇█'
3const EIGHTHS = ' ▏▎▍▌▋▊▉█'
4const DAY_MS = 86_400_000
5
6const group = (digits: string): string => digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
7
8const trim = (n: number): string =>
9 (Math.abs(n) >= 100 ? n.toFixed(0) : n.toFixed(1)).replace(/\.0$/, '')
10
11export const money = (value: number | null | undefined): string => {
12 if (value === null || value === undefined || !Number.isFinite(value)) {
13 return '—'
14 }
15 const sign = value < 0 ? '-' : ''
16 const abs = Math.abs(value)
17
18 if (abs === 0) {
19 return '$0.00'
20 }
21 if (abs < 0.0001) {
22 return `${sign}<$0.0001`
23 }
24 if (abs < 0.01) {
25 return `${sign}$${abs.toFixed(4)}`
26 }
27 const [whole = '0', cents = '00'] = abs.toFixed(2).split('.')
28
29 return `${sign}$${group(whole)}.${cents}`
30}
31
32/** A whole count with thousands grouped: 12,345. */
33export const count = (value: number): string => {
34 const rounded = Math.round(value)
35
36 return `${rounded < 0 ? '-' : ''}${group(String(Math.abs(rounded)))}`
37}
38
39/** How many times one amount is another, to a tenth under ten: "0.4×", "4.7×", "12×". */
40export const times = (ratio: number): string => {
41 if (!Number.isFinite(ratio)) {
42 return '—'
43 }
44
45 return `${ratio >= 10 ? Math.round(ratio) : ratio.toFixed(1).replace(/\.0$/, '')}×`
46}
47
48export type Change = { pct: number; direction: 'up' | 'down' | 'flat' }
49
50/** How far `current` moved from `previous`, in whole percent; null when there is nothing to compare with. */
51export const change = (current: number, previous: number): Change | null => {
52 if (!(previous > 0) || !Number.isFinite(current)) {
53 return null
54 }
55 const pct = Math.round(((current - previous) / previous) * 100)
56
57 return { pct: Math.abs(pct), direction: pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat' }
58}
59
60/** A money amount in about six cells, for chart axes and narrow columns: $0, $0.42, $12.3, $412, $1.2k. */
61export const shortMoney = (value: number): string => {
62 if (!Number.isFinite(value)) {
63 return '—'
64 }
65 const sign = value < 0 ? '-' : ''
66 const abs = Math.abs(value)
67
68 if (abs === 0) {
69 return '$0'
70 }
71 if (abs >= 1e6) {
72 return `${sign}$${trim(abs / 1e6)}M`
73 }
74 if (abs >= 1e3) {
75 return `${sign}$${trim(abs / 1e3)}k`
76 }
77 if (abs >= 100) {
78 return `${sign}$${abs.toFixed(0)}`
79 }
80 if (abs >= 10) {
81 return `${sign}$${abs.toFixed(1).replace(/\.0$/, '')}`
82 }
83
84 return abs >= 0.01 ? `${sign}$${abs.toFixed(2)}` : `${sign}<$0.01`
85}
86
87export const compact = (value: number): string => {
88 const abs = Math.abs(value)
89
90 if (abs >= 1e9) {
91 return `${trim(value / 1e9)}B`
92 }
93 if (abs >= 1e6) {
94 return `${trim(value / 1e6)}M`
95 }
96 if (abs >= 1e3) {
97 return `${trim(value / 1e3)}k`
98 }
99
100 return String(Math.round(value))
101}
102
103export const percent = (used: number, limit: number | null): number | null =>
104 limit === null || !(limit > 0) ? null : Math.round((used / limit) * 100)
105
106/** The share of a cap that is used, null with no cap; a cap of $0 is used up from the first cent, as the banner reads it. */
107export const usedShare = (used: number, limit: number | null): number | null =>
108 limit === null ? null : (percent(used, limit) ?? 100)
109
110/** A bar in eighths of a cell: `full` is the filled part, `track` the rest, so each can take its own color. */
111export const gauge = (fraction: number, width: number): { full: string; track: string } => {
112 const clamped = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0
113 const eighths = Math.round(clamped * width * 8)
114 const whole = Math.floor(eighths / 8)
115 const part = eighths % 8
116 const full = '█'.repeat(whole) + (part > 0 ? EIGHTHS.charAt(part) : '')
117
118 return { full, track: '░'.repeat(width - whole - (part > 0 ? 1 : 0)) }
119}
120
121/** A slim bar in whole cells, low in the cell, so rows stacked on each other stay apart. Any share above zero gets a cell. */
122export const rule = (fraction: number, width: number): { full: string; track: string } => {
123 const clamped = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0
124 const cells = clamped > 0 ? Math.min(width, Math.max(1, Math.round(clamped * width))) : 0
125
126 return { full: '▄'.repeat(cells), track: '▁'.repeat(width - cells) }
127}
128
129/** One block per value, scaled to the biggest; a day with nothing is a dot, so "none" never looks like "a little". */
130/**
131 * Vertical bars, one per value, scaled to the biggest, as `height` rows of text from the top down. Each bar is
132 * `barWidth` cells wide with `gap` blank cells between them. Any value above zero shows at least one eighth of a row.
133 */
134export const columnChart = (values: readonly number[], height: number, barWidth: number, gap = 1): string[] => {
135 const most = Math.max(0, ...values)
136 const rows: string[] = []
137
138 for (let row = height - 1; row >= 0; row -= 1) {
139 rows.push(
140 values
141 .map(value => {
142 const eighths = !(value > 0) || most <= 0 ? 0 : Math.max(1, Math.round((value / most) * height * 8))
143
144 return RISERS.charAt(Math.min(8, Math.max(0, eighths - row * 8))).repeat(barWidth)
145 })
146 .join(' '.repeat(gap)),
147 )
148 }
149
150 return rows
151}
152
153export const sparkline = (values: readonly number[]): string => {
154 const max = Math.max(0, ...values)
155
156 return values
157 .map(value => (value <= 0 ? '·' : BLOCKS.charAt(Math.min(7, Math.max(0, Math.round((value / max) * 7))))))
158 .join('')
159}
160
161export const span = (ms: number): string => {
162 const abs = Math.abs(ms)
163
164 if (abs < 60_000) {
165 return `${Math.floor(abs / 1000)}s`
166 }
167 const minutes = Math.round(abs / 60_000)
168
169 if (minutes < 60) {
170 return `${minutes}m`
171 }
172 const hours = Math.floor(minutes / 60)
173
174 if (hours < 24) {
175 return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`
176 }
177 const days = Math.floor(hours / 24)
178
179 return days >= 10 || hours % 24 === 0 ? `${days}d` : `${days}d ${hours % 24}h`
180}
181
182export const until = (target: number | null, now: number): string | null => {
183 if (target === null) {
184 return null
185 }
186
187 return target >= now ? `in ${span(target - now)}` : `${span(now - target)} ago`
188}
189
190/** How long ago, as a status wants it: "just now" under five seconds. */
191export const ago = (at: number, now: number): string => (now - at < 5000 ? 'just now' : `${span(now - at)} ago`)
192
193export const clock = (ms: number): string => {
194 const date = new Date(ms)
195 const two = (n: number): string => String(n).padStart(2, '0')
196
197 return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`
198}
199
200export const utcDay = (now: number, back: number): string =>
201 new Date(now - back * DAY_MS).toISOString().slice(0, 10)
202
203export const maskKey = (key: string): string => {
204 const trimmed = key.trim()
205
206 if (trimmed.length <= 8) {
207 return '••••'
208 }
209
210 return `${trimmed.startsWith('sk-') ? 'sk-' : ''}…${trimmed.slice(-4)}`
211}
212
213// The engine refuses a text child that holds a control character, and a pane it cannot draw is closed.
214const ESCAPE_SEQUENCES = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)?)/g
215const CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g
216
217/** The text without its terminal escape sequences and other control characters, which are never drawn. */
218export const clean = (text: string): string => text.replace(ESCAPE_SEQUENCES, '').replace(CONTROLS, '')
219
220/**
221 * The url with any `user:password@` taken out: the root is shown and linked, so it must never carry credentials. The
222 * userinfo runs to the last `@` before the path, as a password may hold one.
223 */
224export const withoutCredentials = (url: string): string => url.replace(/^([a-z][a-z\d+.-]*:\/\/)[^/\s]*@/i, '$1')
225
226export const redact = (text: string, secrets: readonly string[] = []): string => {
227 let masked = clean(text)
228
229 for (const secret of secrets) {
230 if (secret.length >= 6) {
231 masked = masked.split(secret).join(maskKey(secret))
232 }
233 }
234
235 return (
236 masked
237 .replace(/\b([a-z][a-z\d+.-]*:\/\/)[^/\s]*@/gi, '$1')
238 .replace(/\bsk-[A-Za-z0-9_-]{6,}/g, 'sk-…')
239 .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer …')
240 // The sha256 of a key is the name the proxy knows it by, and a 401 says it ("Key Hash (Token) =…"): kept out too,
241 // whole or cut short in the url of a request that an error names.
242 .replace(/\b(api_key=)[^&\s"']+/gi, '$1…')
243 .replace(/\b[0-9a-f]{64}\b/gi, '…')
244 )
245}
246
247export const truncate = (text: string, max: number): string =>
248 text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
249
250/** Cuts from the middle, so names that differ at the end (claude-sonnet-4-5, claude-sonnet-4-6) stay apart. */
251export const truncateMiddle = (text: string, max: number): string => {
252 if (text.length <= max) {
253 return text
254 }
255 if (max <= 1) {
256 return max === 1 ? '…' : ''
257 }
258 const room = max - 1
259 const head = Math.ceil(room / 2)
260 const tail = room - head
261
262 return `${text.slice(0, head)}…${tail > 0 ? text.slice(-tail) : ''}`
263}
264
265export const plural = (count: number, word: string): string =>
266 `${count} ${word}${count === 1 ? '' : 's'}`
267hooks/litellm.ts 211 lines1import type { Failure, ProxyInfo, Snapshot } from '../types'
2import { hasMoreRows, historyDays, parseUsage, usageQuery } from './activity'
3import type { Credentials } from './credentials'
4import { hostOf } from './credentials'
5import { classify, describeError, failure, looksLikeLiteLLM } from './failures'
6import { maskKey, redact, truncate, withoutCredentials } from './format'
7import type { Json } from './json'
8import { isObject, parse, scrub } from './json'
9import {
10 parseHealth,
11 parseKey,
12 parseMember,
13 parseModelPrices,
14 parseModels,
15 parseTeam,
16 parseUser,
17 parseUserRole,
18} from './parsers'
19
20/** What the proxy answered, and how long it took when the caller timed it. */
21export type Reply = { status: number; text: string; ms?: number }
22export type Http = (url: string, headers: Record<string, string>) => Promise<Reply>
23
24export type Fetched =
25 | { ok: true; snapshot: Snapshot; root: string }
26 | { ok: false; failure: Failure }
27
28export type FetchRequest = {
29 credentials: Credentials
30 http: Http
31 now: number
32 pinnedRoot: string | null
33 wantRelated: boolean
34 wantUsage: boolean
35 refreshSlow: boolean
36 previous: Snapshot | null
37}
38
39const PARTIAL_USAGE = 'usage history is partial: the proxy has more rows than one page holds'
40
41/** What the proxy says of itself is its own words: the key is never in them, and they stay short. */
42const unkeyed = (info: ProxyInfo | null, key: string): ProxyInfo | null =>
43 info && {
44 version: info.version === null ? null : truncate(redact(info.version, [key]), 40),
45 db: info.db === null ? null : truncate(redact(info.db, [key]), 40),
46 }
47
48export const fetchSnapshot = async (request: FetchRequest): Promise<Fetched> => {
49 const { credentials, http, now, pinnedRoot } = request
50 const { key, headers } = credentials
51 const notes: string[] = []
52 const roots =
53 pinnedRoot !== null && credentials.roots.includes(pinnedRoot)
54 ? [pinnedRoot, ...credentials.roots.filter(root => root !== pinnedRoot)]
55 : credentials.roots
56 const mismatches: Failure[] = []
57 let found: { root: string; body: Json; info: Json; ms: number | null } | null = null
58 let rejected: Failure | null = null
59
60 for (const root of roots) {
61 let reply: Reply
62
63 try {
64 reply = await http(`${root}/key/info`, headers)
65 } catch (error) {
66 mismatches.push(
67 failure('network', `Could not reach ${hostOf(root)}: ${describeError(error, key)}`, now, {
68 hint: 'Check that the proxy is running and that ANTHROPIC_BASE_URL points at it.',
69 }),
70 )
71 break
72 }
73 const json = parse(reply.text)
74
75 if (reply.status === 200 && isObject(json) && isObject(json.info)) {
76 // A reading that took no time at all is a clock that did not move, not a proxy that answered at once.
77 found = { root, body: json, info: json.info, ms: reply.ms !== undefined && reply.ms > 0 ? reply.ms : null }
78 break
79 }
80 if (looksLikeLiteLLM(reply.status, json)) {
81 rejected = classify(reply.status, json, reply.text, key, now)
82 break
83 }
84 const isGateway = reply.status >= 500 || reply.status === 408 || root === pinnedRoot
85
86 mismatches.push(
87 isGateway
88 ? failure('http', `The proxy answered ${reply.status} to /key/info.`, now, { status: reply.status })
89 : failure(
90 'not-litellm',
91 `${hostOf(root)} answered ${reply.status} to /key/info and does not look like a LiteLLM proxy.`,
92 now,
93 {
94 status: reply.status,
95 hint: 'If LiteLLM sits behind a path prefix, set litellm_url to the proxy root.',
96 },
97 ),
98 )
99 }
100
101 if (!found) {
102 return {
103 ok: false,
104 failure:
105 rejected ??
106 mismatches.find(item => item.kind !== 'network') ??
107 mismatches[0] ??
108 failure('network', 'No LiteLLM endpoint answered.', now),
109 }
110 }
111 const { root, body, info, ms } = found
112 const keyInfo = parseKey(body, info, now)
113 const get = async (path: string, isOptional = false): Promise<unknown> => {
114 const reply = await http(`${root}${path}`, headers)
115
116 if (isOptional && reply.status === 404) {
117 return null
118 }
119 if (reply.status !== 200) {
120 throw new Error(`${path.split('?')[0]} answered ${reply.status}`)
121 }
122
123 return parse(reply.text)
124 }
125 // A read that fails is undefined, to tell it from one that found nothing: the last good answer stands in for it.
126 const attempt = async <T>(label: string, run: () => Promise<T>): Promise<T | undefined> => {
127 try {
128 return await run()
129 } catch (error) {
130 notes.push(`${label} unavailable: ${describeError(error, key)}`)
131
132 return undefined
133 }
134 }
135 // What the proxy says of itself is a courtesy: when it will not say, nothing is worth a note.
136 const quiet = async <T>(run: () => Promise<T>): Promise<T | undefined> => {
137 try {
138 return await run()
139 } catch {
140 return undefined
141 }
142 }
143 const days = historyDays(now)
144 const { previous, refreshSlow, wantRelated } = request
145 const wantUsage = request.wantUsage && keyInfo.userId !== null
146 const [userBody, teamBody, models, usage, priceBody, proxy] = await Promise.all([
147 wantRelated && keyInfo.userId
148 ? attempt('user budget', () => get(`/user/info?user_id=${encodeURIComponent(keyInfo.userId ?? '')}`, true))
149 : Promise.resolve(null),
150 wantRelated && keyInfo.teamId
151 ? attempt('team budget', () => get(`/team/info?team_id=${encodeURIComponent(keyInfo.teamId ?? '')}&key_limit=1`, true))
152 : Promise.resolve(null),
153 refreshSlow
154 ? attempt('model list', async () => parseModels(await get('/v1/models')))
155 : Promise.resolve(previous?.models ?? null),
156 !wantUsage
157 ? Promise.resolve(null)
158 : refreshSlow
159 ? attempt('usage history', async () => {
160 const body = await get(`/user/daily/activity?${usageQuery(keyInfo, days)}`)
161
162 if (hasMoreRows(body)) {
163 notes.push(PARTIAL_USAGE)
164 }
165
166 return parseUsage(body, days)
167 })
168 : Promise.resolve(previous?.usage ?? null),
169 refreshSlow ? attempt('model prices', () => get('/model_group/info', true)) : Promise.resolve(null),
170 refreshSlow ? quiet(async () => unkeyed(parseHealth(await get('/health/readiness')), key)) : Promise.resolve(previous?.proxy ?? null),
171 ])
172
173 // the fast ticks reuse the last usage, partial or not, so they keep saying so
174 if (wantUsage && !refreshSlow && previous?.notes.includes(PARTIAL_USAGE)) {
175 notes.push(PARTIAL_USAGE)
176 }
177 // A read that failed leaves what the last good one held (for this key's user and team), and a note.
178 const sameUser = previous !== null && previous.key.userId === keyInfo.userId
179 const sameTeam = previous !== null && previous.key.teamId === keyInfo.teamId
180 const kept = <T,>(read: T | undefined, before: T | null | undefined): T | null => (read === undefined ? (before ?? null) : read)
181
182 return {
183 ok: true,
184 root,
185 // What the proxy sent is drawn as it came, so it goes out clean: a control character in it would close the pane.
186 snapshot: scrub({
187 fetchedAt: now,
188 host: credentials.host,
189 root: withoutCredentials(root),
190 keySource: credentials.keySource,
191 keyHint: maskKey(key),
192 key: keyInfo,
193 user: userBody === undefined ? (sameUser ? (previous?.user ?? null) : null) : parseUser(userBody),
194 userRole: userBody === undefined ? (sameUser ? (previous?.userRole ?? null) : null) : parseUserRole(userBody),
195 team: teamBody === undefined ? (sameTeam ? (previous?.team ?? null) : null) : parseTeam(teamBody),
196 member: teamBody === undefined ? (sameTeam ? (previous?.member ?? null) : null) : parseMember(teamBody, keyInfo),
197 models: kept(models, previous?.models),
198 prices: refreshSlow
199 ? priceBody === undefined
200 ? (previous?.prices ?? null)
201 : parseModelPrices(priceBody, kept(models, previous?.models))
202 : (previous?.prices ?? null),
203 usage: kept(usage, previous?.usage),
204 proxy: kept(proxy, previous?.proxy),
205 latencyMs: ms,
206 session: null,
207 notes,
208 }),
209 }
210}
211hooks/ports.ts 24 lines1import type { Failure, Snapshot } from '../types'
2import type { EnvName } from './credentials'
3import type { Reply } from './litellm'
4
5export type Mode = 'tick' | 'turn' | 'force'
6
7export type Diagnostics = { host: string; roots: string[]; keySource: string; keyHint: string }
8
9export type Init = { method?: string; headers: Record<string, string>; body?: string }
10
11/** What the reading cycle needs from the engine. `$` never leaves register.tsx: the runtime follows it only inside that file. */
12export type Ports = {
13 now: () => Promise<number>
14 env: () => Promise<Partial<Record<EnvName, string>>>
15 settings: () => Promise<{ env?: unknown }>
16 fetch: (url: string, init: Init, ms?: number) => Promise<Reply>
17 loading: (isLoading: boolean) => Promise<void>
18 publish: (snapshot: Snapshot | null, failure: Failure | null) => Promise<void>
19 status: (text: string | undefined) => void
20 toast: (message: string) => void
21 remembered: () => Promise<unknown>
22 remember: (ids: string[]) => Promise<void>
23}
24hooks/prefs.ts 22 lines1import type { MetricName, SortName } from '../types'
2import { METRICS, RANGES } from './history'
3import { isObject } from './json'
4
5/** What the person chose in the pane, and keeps for next time. */
6export type Prefs = { range: number; sort: SortName; metric: MetricName }
7
8/** What was kept, as far as it still makes sense: a choice the pane no longer offers is dropped, not trusted. */
9export const parsePrefs = (stored: unknown): Partial<Prefs> => {
10 if (!isObject(stored)) {
11 return {}
12 }
13 const { range, sort } = stored
14 const metric = METRICS.find(item => item === stored.metric)
15
16 return {
17 ...(typeof range === 'number' && RANGES.includes(range) ? { range } : {}),
18 ...(sort === 'spend' || sort === 'name' ? { sort } : {}),
19 ...(metric === undefined ? {} : { metric }),
20 }
21}
22hooks/ping-state.ts 34 lines1import type { PingState } from '../types'
2import type { Round } from './probe'
3
4const HISTORY = 20
5
6/** The state of a tab that has not run yet, or has nothing left from the round before. */
7const EMPTY: PingState = { host: '', root: '', at: 0, probes: [], history: [], failure: null, isRunning: false }
8
9/** The tab while a round is out: what it showed stays, so the table does not blink away. */
10export const pinging = (before: PingState | null): PingState => ({ ...(before ?? EMPTY), isRunning: true })
11
12/** The tab once the round is over: the new table and its time beside the rounds before, or why it could not run. */
13export const pinged = (before: PingState | null, round: Round | null): PingState => {
14 const last = before ?? EMPTY
15
16 if (round === null) {
17 return { ...last, isRunning: false }
18 }
19 if ('failure' in round) {
20 return { ...last, failure: round.failure, isRunning: false }
21 }
22 const ms = round.probes[0]?.ms ?? null
23
24 return {
25 host: round.host,
26 root: round.root,
27 at: round.at,
28 probes: round.probes,
29 history: ms === null ? last.history : [...last.history, ms].slice(-HISTORY),
30 failure: null,
31 isRunning: false,
32 }
33}
34hooks/probe.ts 171 lines1import type { Snapshot } from '../types'
2import { historyDays, usageQuery } from './activity'
3import type { Credentials } from './credentials'
4import { resolveCredentials } from './credentials'
5import { describeError, messageOf } from './failures'
6import { redact, truncate, withoutCredentials } from './format'
7import { isObject, parse, str } from './json'
8import type { Http } from './litellm'
9import { parseHealth, parseModelPrices, parseModels, parseTeam, parseUsage, parseUser } from './parsers'
10import type { Ports } from './ports'
11import type { Session } from './session'
12import { sourcesOf } from './settings'
13import { failureText } from './summary'
14
15/** One endpoint asked on purpose, to say which of the plugin's reads work and how fast. */
16export type Probe = {
17 path: string
18 /** Null when no answer came: the connection failed or timed out. */
19 status: number | null
20 ms: number | null
21 ok: boolean
22 /** What came back, in a few words: a count, a version, or why it did not. */
23 detail: string
24}
25
26export type ProbeRequest = {
27 credentials: Credentials
28 /** The proxy root that answers. */
29 root: string
30 /** Times each answer (`ms`) and gives up on one that never comes. */
31 http: Http
32 /** What was read last: it names the user and the team to ask about. */
33 snapshot: Snapshot | null
34 now: number
35}
36
37const HINTS: Record<string, string> = {
38 '/user/daily/activity': 'the usage history is a beta endpoint, missing from some LiteLLM versions',
39 '/user/info': 'the user budget is optional: turn show_related off to stop asking',
40 '/team/info': 'the team budget is optional: turn show_related off to stop asking',
41 '/health/readiness': 'optional: only the version and database state of the proxy',
42 '/v1/models': 'the model list is optional: the key still works without it',
43 '/model_group/info': 'the prices are optional: /litellm models just lists the names without them',
44}
45
46type Target = { path: string; query?: string; sum: (json: unknown) => string }
47
48const targetsOf = ({ snapshot, now }: ProbeRequest): Target[] => {
49 const userId = snapshot?.key.userId ?? null
50 const teamId = snapshot?.key.teamId ?? null
51
52 return [
53 { path: '/key/info', sum: json => (isObject(json) && isObject(json.info) ? (str(json.info.status) ?? 'ok') : 'ok') },
54 ...(userId === null
55 ? []
56 : [{ path: '/user/info', query: `user_id=${encodeURIComponent(userId)}`, sum: (json: unknown) => (parseUser(json) ? 'has a budget' : 'no budget cap') }]),
57 ...(teamId === null
58 ? []
59 : [{ path: '/team/info', query: `team_id=${encodeURIComponent(teamId)}&key_limit=1`, sum: (json: unknown) => (parseTeam(json) ? 'has a budget' : 'no budget cap') }]),
60 { path: '/v1/models', sum: json => `${parseModels(json)?.length ?? 0} models` },
61 { path: '/model_group/info', sum: json => `${Object.keys(parseModelPrices(json, null) ?? {}).length} priced` },
62 ...(snapshot === null || userId === null
63 ? []
64 : [
65 {
66 path: '/user/daily/activity',
67 query: usageQuery(snapshot.key, historyDays(now)),
68 sum: (json: unknown) => {
69 const used = parseUsage(json, historyDays(now))?.history.filter(day => day.requests > 0 || day.spend > 0)
70
71 return used === undefined ? 'unreadable' : `${used.length} active days`
72 },
73 },
74 ]),
75 {
76 path: '/health/readiness',
77 sum: json => {
78 const info = parseHealth(json)
79
80 return info === null ? 'no version' : [info.version ? `v${info.version}` : null, info.db ? `database ${info.db.toLowerCase()}` : null].filter(Boolean).join(' · ')
81 },
82 },
83 ]
84}
85
86/**
87 * Asks each endpoint the plugin reads, once and in parallel, for a status, a time and a word on what came back. Never
88 * throws: a failure is a row. Only what the last reading already knows (the user, the team) can be asked about.
89 */
90export const probeEndpoints = async (request: ProbeRequest): Promise<Probe[]> => {
91 const { credentials, root, http } = request
92 const { key, headers } = credentials
93
94 return Promise.all(
95 targetsOf(request).map(async (target): Promise<Probe> => {
96 const url = `${root}${target.path}${target.query ? `?${target.query}` : ''}`
97
98 try {
99 const reply = await http(url, headers)
100 const json = parse(reply.text)
101 const ms = reply.ms !== undefined && reply.ms > 0 ? Math.round(reply.ms) : null
102
103 if (reply.status === 200) {
104 return { path: target.path, status: 200, ms, ok: true, detail: truncate(redact(target.sum(json), [key]), 60) }
105 }
106 const why = truncate(redact(messageOf(json, reply.text).replace(/\s+/g, ' '), [key]), 60)
107 const hint = HINTS[target.path]
108
109 return { path: target.path, status: reply.status, ms, ok: false, detail: hint !== undefined && reply.status < 500 ? `${why} · ${hint}` : why }
110 } catch (error) {
111 return { path: target.path, status: null, ms: null, ok: false, detail: describeError(error, key) }
112 }
113 }),
114 )
115}
116
117/** The probes as a table: whether each endpoint answered, with what, and how long it took. */
118export const pingReport = (host: string, root: string, probes: readonly Probe[]): string => {
119 const width = Math.max(0, ...probes.map(probe => probe.path.length))
120
121 return [
122 `${host} · ${root}`,
123 ...probes.map(probe =>
124 [probe.ok ? '✓' : '✗', probe.path.padEnd(width), (probe.status === null ? '—' : String(probe.status)).padStart(3), (probe.ms === null ? '—' : `${probe.ms} ms`).padStart(7), probe.detail]
125 .join(' ')
126 .trimEnd(),
127 ),
128 ].join('\n')
129}
130
131/** What one round of probes found, or why it could not run. */
132export type Round = { host: string; root: string; probes: Probe[]; at: number } | { failure: string }
133
134/** One round of probes against the proxy the key points to: what `/litellm ping` prints and the Ping tab draws. */
135export const pingRound = async (session: Session, ports: Ports): Promise<Round> => {
136 // A reading first, to know the user and the team to ask about; if it fails, the rest is asked all the same.
137 await session.ensureFresh(ports)
138 const now = await ports.now()
139 const resolved = resolveCredentials(await sourcesOf(session.state.config, ports), now)
140
141 if (!resolved.ok) {
142 return { failure: failureText(resolved.failure) }
143 }
144 const { credentials } = resolved
145 const { pinnedRoot, latest } = session.state
146 const root = pinnedRoot !== null && credentials.roots.includes(pinnedRoot) ? pinnedRoot : (credentials.roots[0] ?? '')
147 const probes = await probeEndpoints({
148 credentials,
149 root,
150 http: (url, headers) => ports.fetch(url, { headers }),
151 snapshot: latest.snapshot,
152 now,
153 })
154
155 return { host: credentials.host, root: withoutCredentials(root), probes, at: now }
156}
157
158/** `/litellm ping`: every endpoint the plugin reads, asked once, with its status and its time. */
159export const ping = async (session: Session, ports: Ports): Promise<{ text: string; exitCode?: number }> => {
160 const round = await pingRound(session, ports)
161
162 if ('failure' in round) {
163 return { text: round.failure, exitCode: 3 }
164 }
165 const { probes } = round
166 const text = pingReport(round.host, round.root, probes)
167
168 // The key info is what the plugin cannot do without; the rest is optional, and does not fail a script.
169 return probes[0]?.ok === false ? { text, exitCode: 3 } : { text }
170}
171