SLOPSHOPPER

litellm-key

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…

newpanebandcommandtoaststatus
v0.7.0MITupdated 2026-10-08juninmd/cc-litellm/plugins/litellm-key
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · litellm-key
│ ┃ LiteLLM key ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ⚠ Claude Code is not routed through a │ litellm-key │ │ ┃ LiteLLM proxy (ANTHROPIC_BASE_URL is not ⏺ Read(src/auth.ts) │ Not configured. Run /litellm for setup │ │ ┃ set). ⎿ Read 6 lines │ help. │ │ ┃ Set ANTHROPIC_BASE_URL and ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ANTHROPIC_AUTH_TOKEN, or fill litellm_url ⎿ Added 2 lines, removed 1 line │ ┃ and litellm_key with: claude plugin ⏺ Bash(bun test) │ ┃ configure litellm-key ⎿ 3 pass, 1 fail │ ┃ │ ┃ Set these under "env" in ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ~/.claude/settings.json: │ ┃ ANTHROPIC_BASE_URL ✻ Worked for 42s · done 4:20 PM │ ┃ https://your-litellm-host │ ┃ ANTHROPIC_AUTH_TOKEN <your virtual key> › /litellm │ ┃ ⎿ litellm-key: Reading the key from the proxy… the answer shows up │ ┃ [ Refresh (r) ] [ Close (q) ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · LiteLLM key
⚠ Claude Code is not routed through a LiteLLM proxy (ANTHROPIC_BASE_URL is not set). Set ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN, or fill litellm_url and litellm_key with: claude plugin configure litellm-key Set these under "env" in ~/.claude/settings.json: ANTHROPIC_BASE_URL https://your-litellm-host ANTHROPIC_AUTH_TOKEN <your virtual key> [ Refresh (r) ] [ Close (q) ]
README

<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>

cc-litellm

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%">

What you get

👀 WatchStatus line under the prompt, always visible⚠ litellm-key: 86% of budget · $30.00 of $35.00 · resets in 27d (30d)
/litellm panemeters 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 tabsUsage (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)
GuidanceAllowance (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)
Toastsat 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 bannera 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, statuswhere the budget is heading, the days and models as tables, what changed against the days before, one day by model
/litellm checkOK, WARNING, CRITICAL or UNKNOWN and the exit code of a claude -p run (0 to 3), for scripts and monitoring
/litellm json / csveverything 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 newcreate a virtual key; the secret goes to your clipboard, never the transcript
/litellm grantextra budget for a key, a user, a team or an organization, with a preview and a confirmation
/litellm key set / reset-spendchange a key's models, limits, expiry or alias; zero its spend counter
/litellm key block / unblockstop (or restore) a key in one line
/litellm organ organization's budget, which a virtual key cannot read
/litellm keyslist keys: yours, a user's, a team's, or all
/litellm fallbacksthe 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.

Install

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).

A tour

Watch the budget

<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%">

Look closer: usage, models, details

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.

Ask for a report, or hand it to a script

/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

Spot trouble early, and name it

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>

Generate a key

<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>

Give extra budget

<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>

When the team caps each member

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>

Read the fallback chains

<img src="docs/evidence/fallbacks-filtered.png" alt="/litellm fallbacks cloud/auto" width="92%">

Know what a model costs

<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%">

And the proxy agrees

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>

Commands

CommandDoes
/litellmOpen 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 refreshRead again now.
/litellm infoPrint the full summary in the transcript.
/litellm statusPrint the status line as text.
/litellm paceWhere 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 jsonEverything 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 pingTry every endpoint the plugin reads, with its status and time.
/litellm debugShow where the URL and the keys come from (always masked), what was tried, the result.
/litellm closeClose 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 and Admin tabs

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%">

Admin commands

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 flagMeaning
--budget 10Spend cap in dollars.
--every 30dBudget window: it resets every 30 days (s m h d w mo).
--soft 8Soft alert threshold.
--models a,bModels the key may call (default: all).
--rpm 60 / --tpm 100000 / --parallel 4Rate limits.
--expires 30dThe key stops working after this long.
--user ID / --team IDWho owns it (and whose budget also applies).
key set flagMeaning
--models a,b / --models allReplace the models the key may call (all: every model).
--rpm N / --tpm N / --parallel NSet a limit; none removes it.
--expires 30d / --expires neverExpire after this long from now, or never.
--alias NEWRename 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:

  • Preview first. --dry-run stops there; --yes skips the confirmation; otherwise Claude Code's native dialog asks (Apply / Cancel).
  • Read-back. After a grant the plugin re-reads the budget from the proxy and reports what is there, not what it sent.
  • The new secret never lands in the transcript. It goes to the clipboard. If the clipboard cannot take it, the key is deleted again (rolled back) instead of kept unreadable. --reveal prints it, with a warning that it is now saved in the transcript.
  • Raw sk-… values are refused as key references: use an alias or the key hash. Unknown flags are errors, not silently ignored.
  • Honest numbers. 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.
  • The admin key is sent only to the proxy that already accepted your session's own key, and never printed (errors are redacted).

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.

Budgets: what LiteLLM can and cannot do

Checked live against LiteLLM v1.99.1 (open-source proxy, no license); the member cap and organizations were also checked on v1.104.0:

BudgetWorks?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-onlyblocks 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.102blocks 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-onlyshown as Window 1h meters when the proxy has them
Per model on a key (model_max_budget)⛔ enterprisethe 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)⛔ enterprisethe 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.

Configuration

The plugin reads the same URL and key Claude Code uses, in this order (process variables first, then the env block of settings.json):

WhatFrom
URLoption 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,

Source 61 files
hooks/register.tsx 299 lines
1import { 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}
299
hooks/admin-link.ts 66 lines
1import 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}
66
hooks/admin-pane.ts 40 lines
1import 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}
40
hooks/band.tsx 53 lines
1import 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 &lt;amount&gt;; 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}
53
hooks/commands.ts 246 lines
1import 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}
246
hooks/exceeded.ts 35 lines
1import 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)
35
hooks/format.ts 267 lines
1const 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'}`
267
hooks/litellm.ts 211 lines
1import 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}
211
hooks/ports.ts 24 lines
1import 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}
24
hooks/prefs.ts 22 lines
1import 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}
22
hooks/ping-state.ts 34 lines
1import 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}
34
hooks/probe.ts 171 lines
1import 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