SLOPSHOPPER

shunt · gateway

A Claude Mod for the shunt gateway: a band above the prompt shows how much of each window is used and the time to reset — the shared account pool's, read from…

newbandprocessnetworktimer
★ 291v0.1.0MIT OR Apache-2.0updated 2026-10-08pleaseai/shunt/plugins/shunt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shunt
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ⟨Claude Code's own drawing⟩ 5H 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 5H 31%
README

shunt

A Claude Mod — a Claude Code plugin whose behaviour is a function hooks module — that shows usage in a band above the prompt — the shunt gateway's shared account pool on shunt, the session's own Claude rate limits otherwise — and answers /shunt:usage with the pool's full breakdown.

shunt · 5H 5% ↻1h 41m · WK 46% ↻1d 7h · Fable 31% ↻1d 7h ⚠ anthropic degraded
> /shunt:usage

shunt: pool — degraded   http://127.0.0.1:3001

  5h    ▓▓▓▓▓▓░░░░  62% left   resets 04:11
  7d    ▓▓▓▓▓▓▓▓░░  81% left   resets Sun 01:11
  fable ▓▓░░░░░░░░  19% left   resets Sun 01:11

  claude  ok         5h  71%  7d  84%  fable  19%
  codex   exhausted  5h   0%  7d  40%  fable    —

  headroom left, averaged over the pool's accounts; a shared figure, not a promise about your next request

The mod reads the gateway's GET /usage endpoint and prints it. It answers the command itself — the hook returns without calling next, so nothing is sent to the model and the answer costs no tokens.

The usage band

On a shunt gateway the band shows the pool, tagged shunt ·. Each figure is how much of the window is used — 1 - remaining, the same pool-wide mean the command reports as headroom — followed by the time to that window's earliest reset. Used rather than left, so it reads the same way as Claude Code's own /usage and as the band reads off shunt. A figure turns amber from 70% and red from 90%. Each pooled provider whose status is not ok is flagged at the end (⚠ anthropic degraded: amber for degraded, red for exhausted or capped), or the pool itself when no provider is listed. A window no account reports is left out.

Off shunt — no gateway set, a gateway that answers GET /usage with 404, a successful response that is not a pool report, or one that pools no provider — the band shows the session's own rate limits instead, untagged, as $.session.usage() and session.measure report them:

5H 5% ↻1h 41m · WK 46% ↻1d 7h

On shunt it reads GET /usage when the session starts, every minute after, and after each turn — the moment the pool has just been spent from — and ignores the session's own limits, which come from whichever pool account answered last. A refused token (401 or 403), an unreachable gateway, another error status, or a shunt gateway token helper that fails is a fault on shunt, so it is flagged rather than replaced: shunt · ⚠ credential refused, or shunt · ⚠ gateway login unavailable for the helper. With neither a gateway nor rate limits (an API key sent straight to Anthropic) the band draws nothing. Whatever another plugin draws in the band stays on its left.

Collapse the band for the session with its [-] (ctrl+x ctrl+a), or turn it off with the usageBand option below.

Reading the numbers

remaining is the fraction of the pool's combined capacity still usable, so 62% means 62% of the headroom is left, not that 62% is spent. It is mean(clamp(cap - utilization, 0, 1)) over the non-disabled accounts that report the window, where cap is the account's max_utilization hard cap for that window (100% when none is set): nine exhausted accounts plus one fresh uncapped one read 10%, not 100%, and an account at 44% under a 50% cap counts only 6%. An account a cap already excludes from the window's requests counts zero; a 5h or 7d cap excludes it from every request, Fable ones included.

It is a pool-wide aggregate, not a prediction — routing also weighs availability, model, session affinity and priority, so a healthy figure is not a promise that your next request is admitted. For the routing-aware worst case, use GET /api/oauth/usage instead.

WindowWhat it covers
5hThe rolling 5-hour session window
7dThe shared weekly window
fableThe Fable-scoped weekly window (7d_oi)

A window reads — when no non-disabled account reports it. ChatGPT/Codex accounts populate 5h and 7d from x-codex-* response headers and have no Fable-scoped signal of their own.

pool is the aggregate across every pooled provider; the rows beneath it are the same aggregate per pooled provider, so a session routed to one provider can read that provider's headroom instead of the blended figure. The endpoint never carries account names, counts, priorities or per-account numbers — that detail stays behind the admin-only GET /admin/api/pool.

Prerequisites

  1. Point Claude Code at your gateway, as you already do to route through it:
   export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
   export ANTHROPIC_AUTH_TOKEN=<your client token>
  1. Enable the endpoint on the gateway. GET /usage is opt-in and requires either [server.auth] (client tokens) or [server.gateway] (gateway login), next to the [server.usage] table in shunt.toml:
   [server.auth]

   # Presence alone opts in; the table takes no keys.
   [server.usage]

With [server.gateway] instead of [server.auth], see shunt gateway claude sessions below. [server.auth] takes its tokens from the environment rather than the TOML — by default SHUNT_CLIENT_TOKENS, as name:token pairs — and the gateway fails to start when it is unset. Export it where the gateway runs, using the same token you set as ANTHROPIC_AUTH_TOKEN above:

   export SHUNT_CLIENT_TOKENS="claude-code:<your client token>"
  1. Run Claude Code with function hooks enabled — the feature is early access:
   CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude

Without step 3 there is no band, and the command still exists but falls back to commands/usage.md, which asks the model to read the endpoint with a tool call instead.

Install

/plugin marketplace add pleaseai/shunt
/plugin install shunt@shunt

Configuration

One option, a row in /config (stored in settings.json under pluginConfigs):

OptionDefaultPurpose
usageBandtrueShow the band above the prompt. Off, the mod registers no band and polls nothing; /shunt:usage still answers

The mod reads five environment variables and writes none. By default it sends the session's own credential to the gateway the session is already sending every message to, so it reaches no host the session was not already using; SHUNT_BASE_URL is the one deliberate exception, and points it at a gateway you name instead. With no base URL set at all it asks nothing rather than sending the credential to Anthropic's API.

VariablePurpose
SHUNT_BASE_URLThe gateway base URL; overrides ANTHROPIC_BASE_URL
ANTHROPIC_BASE_URLThe gateway this session already routes through
SHUNT_TOKENThe client token; overrides both below, sent as Authorization: Bearer
ANTHROPIC_AUTH_TOKENSent as Authorization: Bearer, as Claude Code sends it
ANTHROPIC_API_KEYSent as x-api-key, as Claude Code sends it

SHUNT_BASE_URL is what lets you read one gateway's pool while routing traffic through another.

shunt gateway claude sessions

shunt gateway claude scrubs ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY from the environment it hands Claude Code, and wires apiKeyHelper to shunt gateway token instead; Claude Code consumes the helper's output itself and never re-exports it. When none of the variables above holds a credential, the mod therefore reads the merged settings (--settings included) and, if apiKeyHelper is shunt's own — shunt gateway token, by bare name or by path, as the launcher writes it — runs it directly, without a shell, and sends the gateway login token it prints as Authorization: Bearer. Because there is no shell, a leading ~/ in an unquoted helper path is expanded to the home directory (HOME, else USERPROFILE) by the mod itself; a quoted '~/…' stays literal, as in a shell. The login belongs to the gateway the session talks to (ANTHROPIC_BASE_URL), so it is sent only when SHUNT_BASE_URL is unset or spells that same base URL — compared as a string after trimming whitespace and trailing slashes, so localhost and 127.0.0.1 count as different — and the helper is not even run otherwise; to read another gateway's pool, export SHUNT_TOKEN for it. If that helper fails — a non-zero exit, no output, or it cannot be started — the band flags shunt · ⚠ gateway login unavailable and /shunt:usage says to run shunt gateway login, rather than silently showing the session's own limits.

The token is reused for five minutes, so a poll every minute does not run the helper every minute. A reused token the gateway refuses is dropped and the helper asked once more, since a refresh may have rotated it. Any other apiKeyHelper — a password manager, a script that prompts — is never run: the mod would otherwise run it every few minutes for a usage figure. In such a session export SHUNT_TOKEN with a [server.auth] client token instead, or the band falls back to the session's own rate limits.

The gateway has to accept a gateway login on GET /usage, which needs a shunt build that authenticates [server.gateway] logins there; [server.usage] with only [server.gateway] configured is enough.

Why /shunt:usage and not /usage

/usage is Claude Code's own built-in, and the engine refuses to let a plugin take a built-in's name:

$.command.register: "/usage" refused: it is the built-in /usage

$.command.register takes a bare, global name. A plugin's markdown command is namespaced by the plugin instead, so commands/usage.md in a plugin named shunt lists as shunt:usage and collides with nothing. The hooks module then intercepts command.run for that name and answers it directly.

Layout

PathWhat it is
hooks/register.tsxThe hooks: command.run on shunt:usage, and the band's session.start, session.measure, turn.complete and ui.render on AbovePrompt
hooks/endpoint.tsResolves the gateway URL and credential header
hooks/report.tsReads a GET /usage body, defensively
hooks/reading.tsTurns one round trip into a report or a problem, full and brief
hooks/views.tsRenders the command's bars, percentages and reset times
hooks/band.tsThe band's cells, alerts, levels and countdowns, from the pool or the session's own limits
hooks/names.tsThe command name and the fixed texts
types/index.d.tsThe $.state contract: the pool snapshot, the session's limits and the clock the band draws from
commands/usage.mdThe command, and the no-function-hooks fallback
tests/*.spec.tsVitest suite over the pure modules
tests/mod/*.test.tsxEngine suite over the hooks, run by claude plugin test

Development

# The static side-effect analysis: which events it hooks, which $ calls and
# environment variables it makes and reads.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate plugins/shunt

# The pure modules' suite and typecheck (both CI gates).
cd plugins/shunt && npm ci && npm run typecheck && npm test

# The hooks against the engine itself: the band and the command, with the
# gateway, clock and environment mocked. Needs a Claude Code binary, so CI
# does not run it.
claude plugin test plugins/shunt

# Run it from source against a live gateway.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/shunt

hooks/register.tsx is the only hooks file that imports claude-code. Typechecking it needs the engine's own claude-code.d.ts, which Claude Code writes into a plugin author's project via /plugin-types; the other modules import nothing from the engine, which is why the suite covers them without it. For the same reason tsconfig.json excludes hooks/register.tsx from npm run typecheck, along with tests/mod, the engine suite — that exclusion is deliberate, not an oversight, and typechecking that one file means running /plugin-types first.

Function hooks are early access. Hooks modules load only where they are enabled, and the API this mod is written against may change between Claude Code releases without notice.

License

MIT OR Apache-2.0, matching the shunt project.

Source 9 files
hooks/register.tsx 384 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3
4import type { ShuntRateLimit, ShuntSnapshot } from '../types'
5
6import { bandOf, snapshotOf } from './band'
7import type { Level } from './band'
8import type { Endpoint, Environment } from './endpoint'
9import { endpointOf, helperMayRideTo } from './endpoint'
10import { shuntHelperArgvOf } from './helper'
11import { COMMAND_NAME, HOOK_FAILED_TEXT, NO_TOKEN_TEXT } from './names'
12import type { Reading } from './reading'
13import {
14  CREDENTIAL_REFUSED,
15  helperFailedOf,
16  readingOf,
17  unreachableOf,
18} from './reading'
19import { reportText } from './views'
20
21/** How often the band reads `GET /usage` again while the session is open. */
22const POLL_MS = 60_000
23
24/** How long a token from shunt's `apiKeyHelper` is reused before asking again. */
25const HELPER_TTL_MS = 5 * 60_000
26
27const COLORS: Readonly<Record<Level, string | undefined>> = {
28  ok: undefined,
29  warn: 'yellow',
30  hot: 'red',
31}
32
33/** The gateway as last read; `null` off shunt, where `limits` is drawn. */
34const snapshot = atom(
35  { plugin: 'shunt', key: 'snapshot' } as const,
36  null as ShuntSnapshot | null,
37)
38
39/** The session's own rate limits, as Claude Code last measured them. */
40const limits = atom(
41  { plugin: 'shunt', key: 'limits' } as const,
42  [] as ShuntRateLimit[],
43)
44
45/** The engine clock at the last refresh: what the countdowns run from. */
46const now = atom({ plugin: 'shunt', key: 'now' } as const, 0)
47
48/** A plain copy: the engine's values are frozen, the state's are owned. */
49const copyOf = (rateLimits: readonly ShuntRateLimit[]): ShuntRateLimit[] =>
50  rateLimits.map(limit => ({ ...limit }))
51
52/**
53 * The last token shunt's `apiKeyHelper` printed, and when. A module variable
54 * rather than `$.state`: it is a credential, and nothing draws from it. A
55 * reload drops it, and the next read asks the helper again.
56 */
57let helperToken: { value: string; at: number } | undefined
58
59/**
60 * The gateway login token of a `shunt gateway claude` session: what shunt's
61 * own `apiKeyHelper` prints, run the way Claude Code runs it. `undefined`
62 * when the helper is not shunt's (see `hooks/helper.ts`); a `failure` when it
63 * is shunt's but could not give a token, which is a fault to flag rather than
64 * a set-up to fall back from.
65 *
66 * Reused for five minutes so a poll every minute does not run the helper
67 * every minute; `isCached` says the token came from that reuse.
68 */
69async function helperTokenOf(
70  $: EngineInterface,
71): Promise<
72  { value: string; isCached: boolean } | { failure: Reading } | undefined
73> {
74  const at = await $.clock.now()
75
76  if (helperToken !== undefined && at - helperToken.at < HELPER_TTL_MS) {
77    return { value: helperToken.value, isCached: true }
78  }
79
80  helperToken = undefined
81
82  const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
83  const argv = shuntHelperArgvOf((await $.settings.read()).apiKeyHelper, home)
84
85  if (argv === null) {
86    return undefined
87  }
88
89  try {
90    const run = await $.process.run(argv)
91    const value = run.exitCode === 0 ? run.stdout.trim() : ''
92
93    if (value === '') {
94      return { failure: helperFailedOf(run) }
95    }
96
97    helperToken = { value, at }
98
99    return { value, isCached: false }
100  } catch (error) {
101    return { failure: helperFailedOf({ error }) }
102  }
103}
104
105/** One request to `GET /usage`, read into a reading. */
106async function fetchUsage($: EngineInterface, endpoint: Endpoint): Promise<Reading> {
107  try {
108    const response = await $.http.fetch(endpoint.url, {
109      headers: endpoint.headers,
110    })
111
112    return readingOf(endpoint, response)
113  } catch (error) {
114    return unreachableOf(endpoint, error)
115  }
116}
117
118/**
119 * One `GET /usage` round trip against the gateway this session routes
120 * through, with the credential it already sends there: a variable from the
121 * environment, or else the gateway login token shunt's `apiKeyHelper` prints.
122 *
123 * Each variable is read with its own literal `$.env.get`, so
124 * `claude plugin validate` can list them.
125 */
126async function readUsage($: EngineInterface): Promise<Reading> {
127  const env: Environment = {
128    shuntBaseUrl: await $.env.get('SHUNT_BASE_URL'),
129    anthropicBaseUrl: await $.env.get('ANTHROPIC_BASE_URL'),
130    shuntToken: await $.env.get('SHUNT_TOKEN'),
131    anthropicAuthToken: await $.env.get('ANTHROPIC_AUTH_TOKEN'),
132    anthropicApiKey: await $.env.get('ANTHROPIC_API_KEY'),
133  }
134
135  const resolved = endpointOf(env)
136
137  if (!('problem' in resolved)) {
138    return fetchUsage($, resolved.endpoint)
139  }
140
141  if (resolved.problem !== NO_TOKEN_TEXT) {
142    return { problem: resolved.problem, brief: null }
143  }
144
145  // No credential in the environment: a `shunt gateway claude` session keeps
146  // its gateway login behind the helper instead. That login belongs to the
147  // session's own gateway, so when `SHUNT_BASE_URL` names another one the
148  // helper is not even run.
149  if (!helperMayRideTo(env)) {
150    return { problem: resolved.problem, brief: null }
151  }
152
153  const readWithHelper = async (): Promise<Reading & { isCached?: boolean }> => {
154    const token = await helperTokenOf($)
155
156    if (token === undefined) {
157      return { problem: resolved.problem, brief: null }
158    }
159
160    if ('failure' in token) {
161      return token.failure
162    }
163
164    const withHelper = endpointOf({ ...env, helperToken: token.value })
165
166    return 'problem' in withHelper
167      ? { problem: withHelper.problem, brief: null }
168      : { ...(await fetchUsage($, withHelper.endpoint)), isCached: token.isCached }
169  }
170
171  const reading = await readWithHelper()
172
173  // A reused token the gateway refused has likely rotated: drop it and ask
174  // the helper once more. A fresh one it refused is refused for good.
175  if (reading.isCached && 'problem' in reading && reading.brief === CREDENTIAL_REFUSED) {
176    helperToken = undefined
177
178    return readWithHelper()
179  }
180
181  return reading
182}
183
184/** The refresh under way, which a refresh asked for meanwhile joins. */
185let inFlight: Promise<void> | undefined
186
187/** Set when a refresh joined the one under way: it reads once more. */
188let again = false
189
190/**
191 * Reads the pool again and moves the band's clock on. Off a gateway the read
192 * resolves at once, with no request, and only the clock moves. A refresh that
193 * fails outright leaves the last snapshot standing rather than blanking the
194 * band.
195 */
196async function refresh($: EngineInterface) {
197  // The minute's poll and a turn's end can land together: join the read
198  // under way rather than run the helper and the request twice. A read that
199  // began before the turn's usage was recorded would leave the band stale, so
200  // the joiner asks for one trailing read, however many join.
201  if (inFlight !== undefined) {
202    again = true
203
204    return inFlight
205  }
206
207  inFlight = (async () => {
208    try {
209      do {
210        again = false
211
212        try {
213          const reading = await readUsage($)
214          const at = await $.clock.now()
215
216          await update($, snapshot, () => snapshotOf(reading))
217          await update($, now, () => at)
218        } catch {
219          // A timer's callback has no caller to report to: the next tick retries.
220        }
221      } while (again)
222    } finally {
223      inFlight = undefined
224    }
225  })()
226
227  return inFlight
228}
229
230/**
231 * The `/shunt:usage` command: answered from the gateway's `GET /usage` rather
232 * than sent to the model.
233 *
234 * The command itself is `commands/usage.md`, which lists as `shunt:usage`
235 * because the plugin namespaces it; the markdown body is the fallback for a
236 * session without function hooks, and this hook answers before it is ever
237 * read. `$.command.register` cannot serve this command: it takes a bare global
238 * name and refuses `usage` as the built-in's.
239 */
240function registerCommand(on: On) {
241  /**
242   * A hook that throws or overruns its budget is treated as absent, and core
243   * runs in its place — for this command that is `commands/usage.md`, which
244   * asks the model to fetch the endpoint with a tool call. The answer would
245   * still arrive, but it would cost a model turn in a mod whose whole point is
246   * that it costs none, so the failure is reported rather than handed on.
247   */
248  on('command.run', { command: COMMAND_NAME }, async $ => {
249    const reading = await readUsage($)
250
251    if ('problem' in reading) {
252      return { text: reading.problem }
253    }
254
255    return {
256      text: reportText(reading.report, reading.base, await $.clock.now()),
257    }
258  }).catch(() => ({ text: HOOK_FAILED_TEXT }))
259}
260
261/**
262 * The usage band: how much of each window is used and the time to each
263 * reset, one row above the prompt.
264 *
265 * On a shunt gateway that serves `GET /usage` it shows the pool, tagged
266 * `shunt ·`; anywhere else, the session's own rate limits as Claude Code
267 * measures them. It reads the pool when the session starts, every minute
268 * after, and after each turn, which is when the pool has just been spent
269 * from. Whatever another plugin draws in the band stays on its left.
270 */
271function registerBand(on: On) {
272  registerPolling(on)
273  registerDrawing(on)
274}
275
276/**
277 * Keeps the band's figures current: the pool at session start, every minute
278 * and after each turn, and the session's own rate limits as they are measured.
279 */
280function registerPolling(on: On) {
281  on('session.start', async ($, e, next) => {
282    const result = await next(e)
283
284    $.clock.after(0, () => refresh($))
285    $.clock.every(POLL_MS, () => refresh($))
286
287    // The session's own limits are the fallback, so failing to read them
288    // must not cost the pool its polling: `session.measure` brings them later.
289    try {
290      const usage = await $.session.usage()
291
292      await update($, limits, () => copyOf(usage.rateLimits))
293    } catch {
294      // Left empty until the first measurement.
295    }
296
297    return result
298  })
299
300  on('session.measure', async ($, e, next) => {
301    if (e.changed.includes('rateLimits')) {
302      await update($, limits, () => copyOf(e.rateLimits))
303    }
304
305    return next(e)
306  })
307
308  on('turn.complete', async ($, e, next) => {
309    const result = await next(e)
310
311    $.clock.after(0, () => refresh($))
312
313    return result
314  })
315}
316
317/**
318 * Draws the band to the right of whatever is drawn beneath it, or leaves the
319 * row alone when there is nothing to show or a survey holds it.
320 */
321function registerDrawing(on: On) {
322  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
323    const inner = await next(e)
324
325    if (e.props.hasSurvey) {
326      return inner
327    }
328
329    const [current, rateLimits, at] = await Promise.all([
330      read($, snapshot),
331      read($, limits),
332      read($, now),
333    ])
334
335    const band = bandOf(current, rateLimits, at || (await $.clock.now()))
336
337    if (band === null) {
338      return inner
339    }
340
341    const { Box, Text } = $.ui.resolve(e)
342
343    return (
344      <Box flexDirection="row" gap={2} flexGrow={1}>
345        <Box flexGrow={1}>{inner}</Box>
346        <Box key="usage" flexDirection="row">
347          {band.isShunt ? <Text dimColor>{'shunt · '}</Text> : null}
348          {band.cells.map((cell, index) => (
349            <Box key={cell.key} flexDirection="row">
350              <Text dimColor>{`${index === 0 ? '' : ' · '}${cell.label} `}</Text>
351              <Text color={COLORS[cell.level]}>{cell.percent}</Text>
352              {cell.reset === '' ? null : (
353                <Text dimColor>{` ↻${cell.reset}`}</Text>
354              )}
355            </Box>
356          ))}
357          {band.alerts.map((alert, index) => (
358            <Box key={`alert:${index}:${alert.text}`} flexDirection="row">
359              <Text color={COLORS[alert.level]}>
360                {`${index === 0 && band.cells.length === 0 ? '' : ' '}⚠ ${alert.text}`}
361              </Text>
362            </Box>
363          ))}
364        </Box>
365      </Box>
366    )
367  })
368}
369
370/**
371 * Registers `/shunt:usage`, and the usage band unless the `usageBand` option
372 * turns it off — off, the mod polls nothing and draws nothing.
373 *
374 * @param on the engine's registrar
375 * @param options the manifest's `userConfig` values, defaults filled in
376 */
377export function register(on: On, options: PluginOptions) {
378  registerCommand(on)
379
380  if (options.usageBand !== false) {
381    registerBand(on)
382  }
383}
384
hooks/band.ts 207 lines
1import type { ShuntRateLimit, ShuntSnapshot } from '../types'
2
3import type { Reading } from './reading'
4import type { WindowKey } from './report'
5import { WINDOW_KEYS } from './report'
6import { percentOf } from './views'
7
8/** How loudly a figure is drawn: plain, amber, red. */
9export type Level = 'ok' | 'warn' | 'hot'
10
11/** One window as the band draws it. */
12export type BandCell = {
13  key: string
14  /** `5H`, `WK`, `Fable`, `Spend`. */
15  label: string
16  /** How much of the window is used, whole percent: `46%`. */
17  percent: string
18  /** Time to the window's reset (`1h 41m`), `due`, or `''`. */
19  reset: string
20  level: Level
21}
22
23/** A status worth flagging after the cells: `anthropic degraded`. */
24export type BandAlert = { text: string; level: Level }
25
26/**
27 * The band's whole content. `isShunt` puts the `shunt ·` tag in front: the
28 * figures are the gateway pool's, not the session's own account's.
29 */
30export type Band = { isShunt: boolean; cells: BandCell[]; alerts: BandAlert[] }
31
32/** Usage from which a window turns amber, then red. */
33const WARN_FROM = 0.7
34const HOT_FROM = 0.9
35
36const SHUNT_LABELS: Readonly<Record<WindowKey, string>> = {
37  '5h': '5H',
38  '7d': 'WK',
39  fable: 'Fable',
40}
41
42const LIMIT_LABELS: Readonly<Record<string, string>> = {
43  five_hour: '5H',
44  seven_day: 'WK',
45  spend_limit: 'Spend',
46}
47
48const STATUS_LEVELS: Readonly<Record<string, Level>> = {
49  ok: 'ok',
50  degraded: 'warn',
51  exhausted: 'hot',
52  capped: 'hot',
53}
54
55/**
56 * The level of one window's usage, a fraction: red from nine tenths used,
57 * amber from seven.
58 */
59export const levelOf = (used: number): Level =>
60  used >= HOT_FROM ? 'hot' : used >= WARN_FROM ? 'warn' : 'ok'
61
62/**
63 * A status's level. One this build does not know is amber rather than plain:
64 * the gateway said something other than `ok`.
65 */
66export const statusLevelOf = (status: string): Level =>
67  STATUS_LEVELS[status] ?? 'warn'
68
69/**
70 * The time from `nowMs` to `resetsAt` (epoch seconds) as the band shows it:
71 * `4d 0h`, `2h 55m`, `12m`; `due` once it has passed, `''` with no reset.
72 *
73 * A countdown rather than the clock time `/shunt:usage` prints: the band sits
74 * there for hours, and "in 2h 55m" stays readable without a calendar.
75 */
76export const countdownOf = (resetsAt: number | null, nowMs: number): string => {
77  if (resetsAt === null) {
78    return ''
79  }
80
81  const ms = resetsAt * 1000 - nowMs
82
83  if (ms <= 0) {
84    return 'due'
85  }
86
87  const minutes = Math.max(1, Math.round(ms / 60_000))
88  const days = Math.floor(minutes / 1440)
89  const hours = Math.floor((minutes % 1440) / 60)
90  const rest = minutes % 60
91
92  return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${rest}m` : `${rest}m`
93}
94
95/**
96 * What the band keeps of a reading: the pool and each provider's status, the
97 * brief reason, or `null` where the session is not on a shunt gateway that
98 * serves `GET /usage` — the band then shows the session's own rate limits.
99 */
100export function snapshotOf(reading: Reading): ShuntSnapshot | null {
101  if ('report' in reading) {
102    const { pool, providers } = reading.report
103
104    return {
105      pool,
106      providers: providers.map(([name, provider]) => ({
107        name,
108        status: provider.status,
109      })),
110    }
111  }
112
113  return reading.brief === null ? null : { problem: reading.brief }
114}
115
116const cellOf = (
117  key: string,
118  label: string,
119  used: number,
120  resetsAt: number | null,
121  nowMs: number,
122): BandCell => ({
123  key,
124  label,
125  percent: percentOf(used).trim(),
126  reset: countdownOf(resetsAt, nowMs),
127  level: levelOf(used),
128})
129
130/**
131 * The gateway pool's band. Usage is `1 - remaining`, the mean share of the
132 * pool's capacity spent, so it reads the same way as the session's own limits
133 * do without the tag. A window no account reports is left out; each provider
134 * whose status is not `ok` is flagged, or the pool itself where none is.
135 */
136function shuntBandOf(snapshot: ShuntSnapshot, nowMs: number): Band {
137  if ('problem' in snapshot) {
138    return {
139      isShunt: true,
140      cells: [],
141      alerts: [{ text: snapshot.problem, level: 'warn' }],
142    }
143  }
144
145  const { pool, providers } = snapshot
146
147  const cells = WINDOW_KEYS.flatMap(key => {
148    const { remaining, resetsAt } = pool.windows[key]
149
150    return remaining === null
151      ? []
152      : [cellOf(key, SHUNT_LABELS[key], 1 - remaining, resetsAt, nowMs)]
153  })
154
155  const flagged = providers
156    .filter(provider => provider.status !== 'ok')
157    .map(provider => ({
158      text: `${provider.name} ${provider.status}`,
159      level: statusLevelOf(provider.status),
160    }))
161
162  const alerts =
163    flagged.length > 0 || pool.status === 'ok'
164      ? flagged
165      : [{ text: `pool ${pool.status}`, level: statusLevelOf(pool.status) }]
166
167  return { isShunt: true, cells, alerts }
168}
169
170const epochOf = (iso: string | undefined): number | null => {
171  const ms = iso === undefined ? NaN : Date.parse(iso)
172
173  return Number.isFinite(ms) ? ms / 1000 : null
174}
175
176/** The session's own rate limits, as Claude Code reports them. */
177function limitsBandOf(limits: readonly ShuntRateLimit[], nowMs: number): Band {
178  const cells = limits.map(limit =>
179    cellOf(
180      limit.kind,
181      LIMIT_LABELS[limit.kind] ?? limit.kind.replace(/_/g, ' '),
182      limit.percentUsed / 100,
183      epochOf(limit.resetsAt),
184      nowMs,
185    ),
186  )
187
188  return { isShunt: false, cells, alerts: [] }
189}
190
191/**
192 * What the band draws: the gateway pool when the session is on shunt, else
193 * the session's own rate limits, else nothing (`null`) — an API-key session
194 * off any gateway has no limits to show.
195 */
196export function bandOf(
197  snapshot: ShuntSnapshot | null,
198  limits: readonly ShuntRateLimit[],
199  nowMs: number,
200): Band | null {
201  if (snapshot !== null) {
202    return shuntBandOf(snapshot, nowMs)
203  }
204
205  return limits.length === 0 ? null : limitsBandOf(limits, nowMs)
206}
207
hooks/endpoint.ts 110 lines
1import { NO_BASE_URL_TEXT, NO_TOKEN_TEXT, USAGE_PATH } from './names'
2
3/**
4 * The environment the mod resolves the gateway from, read once per run with
5 * one literal `$.env.get` each so `claude plugin validate` can list them.
6 */
7export type Environment = {
8  shuntBaseUrl?: string
9  anthropicBaseUrl?: string
10  shuntToken?: string
11  anthropicAuthToken?: string
12  anthropicApiKey?: string
13  /**
14   * What shunt's own `apiKeyHelper` printed: the gateway login token of a
15   * `shunt gateway claude` session, which keeps no credential in the
16   * environment. Asked for only when none of the variables above holds one.
17   */
18  helperToken?: string
19}
20
21/**
22 * Where to ask, and with what: the absolute `GET /usage` URL and the one
23 * credential header to send.
24 */
25export type Endpoint = {
26  /** The gateway's base, as it was configured, for error text. */
27  base: string
28  url: string
29  headers: Record<string, string>
30}
31
32/**
33 * Either an endpoint to call or the reason there is none to call.
34 */
35export type Resolved = { endpoint: Endpoint } | { problem: string }
36
37/**
38 * Joins `base` and `USAGE_PATH` without doubling or dropping the separator.
39 */
40const usageUrlOf = (base: string) => `${base.replace(/\/+$/, '')}${USAGE_PATH}`
41
42const baseOf = (url: string | undefined): string =>
43  (url ?? '').trim().replace(/\/+$/, '')
44
45/**
46 * Whether the helper's gateway login may be sent to the gateway this
47 * environment resolves to. The helper prints the login of the gateway the
48 * session talks to (`ANTHROPIC_BASE_URL`); `SHUNT_BASE_URL` can name another
49 * gateway, and that one must never receive it. So it rides only when
50 * `SHUNT_BASE_URL` is unset or blank, or names the same base — compared after
51 * trimming and dropping trailing slashes, exactly otherwise.
52 */
53export function helperMayRideTo(env: Environment): boolean {
54  const shunt = baseOf(env.shuntBaseUrl)
55
56  return shunt === '' || shunt === baseOf(env.anthropicBaseUrl)
57}
58
59/**
60 * Resolves the gateway endpoint from the session's environment.
61 *
62 * The base URL is the gateway this session already sends every message to, so
63 * asking it for `/usage` reaches no host the session was not already using —
64 * with no base URL set there is nothing to ask, and the mod says so instead of
65 * sending the session's credential to Anthropic's own API.
66 *
67 * The credential rides the header Claude Code itself uses for that variable —
68 * `ANTHROPIC_AUTH_TOKEN` as a `Bearer`, `ANTHROPIC_API_KEY` as `x-api-key` —
69 * so whichever shape the operator's `[server.auth]` matches on, it matches the
70 * same way here. `SHUNT_TOKEN` overrides both and rides as a `Bearer`, and the
71 * helper's gateway login token rides as a `Bearer` when nothing else is set and
72 * `helperMayRideTo` allows it; otherwise it resolves as no credential.
73 *
74 * A credential that is set but blank is no credential: `$.env.get` reads an
75 * exported-but-empty variable as `''`, so every candidate is normalized to
76 * absent before the choice is made rather than after. That cuts both ways — a
77 * blank override falls back instead of shadowing a working one, and a blank
78 * fallback reports the missing token here instead of sending an empty header
79 * for the gateway to reject as a wrong one.
80 */
81export function endpointOf(env: Environment): Resolved {
82  const base = env.shuntBaseUrl?.trim() || env.anthropicBaseUrl
83
84  if (base === undefined || base.trim() === '') {
85    return { problem: NO_BASE_URL_TEXT }
86  }
87
88  const bearer = env.shuntToken?.trim() || env.anthropicAuthToken?.trim()
89  const apiKey = env.anthropicApiKey?.trim()
90  const helper = helperMayRideTo(env) ? env.helperToken?.trim() : undefined
91
92  const headers: Record<string, string> | undefined = bearer
93    ? { authorization: `Bearer ${bearer}` }
94    : apiKey
95      ? { 'x-api-key': apiKey }
96      : helper
97        ? { authorization: `Bearer ${helper}` }
98        : undefined
99
100  if (headers === undefined) {
101    return { problem: NO_TOKEN_TEXT }
102  }
103
104  const trimmed = base.trim()
105
106  return {
107    endpoint: { base: trimmed, url: usageUrlOf(trimmed), headers },
108  }
109}
110
hooks/helper.ts 69 lines
1/**
2 * Recognizes shunt's own `apiKeyHelper`, so the mod can ask it for the
3 * gateway login token a `shunt gateway claude` session authenticates with.
4 *
5 * That launcher scrubs `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_API_KEY` from the
6 * environment and hands Claude Code an inline `--settings` document whose
7 * `apiKeyHelper` is `'<path>/shunt' gateway token`; Claude Code consumes the
8 * helper's output itself and never re-exports it. `shunt gateway login`
9 * suggests the bare `shunt gateway token` for a hand-written setting.
10 *
11 * The command is a shell command line to Claude Code, which expands a leading
12 * `~/` in an unquoted executable word; the mod runs the argv without a shell,
13 * so it expands that one case itself. A quoted `'~/x'` is literal in a shell
14 * and stays literal here.
15 *
16 * Only that command is run. Any other helper — a password manager, a script
17 * that prompts — is left alone: the mod would otherwise run it every few
18 * minutes for a usage figure, with whatever side effects it has.
19 */
20
21/** The executable's quoted or bare spelling, then `gateway token`. */
22const SHUNT_HELPER =
23  /^\s*('(?:[^']|'\\'')*'|"[^"]*"|[^\s'"]+)\s+gateway\s+token\s*$/
24
25const BASENAME = /(?:^|[\\/])shunt(?:\.exe)?$/
26
27/**
28 * Undoes the quoting `shunt gateway claude` applies: POSIX single quotes with
29 * `'\''` for an embedded quote, or plain double quotes on Windows.
30 */
31const unquote = (word: string): string =>
32  word.startsWith("'")
33    ? word.slice(1, -1).replaceAll(`'\\''`, `'`)
34    : word.startsWith('"')
35      ? word.slice(1, -1)
36      : word
37
38/**
39 * The argv to run for an `apiKeyHelper` setting when it is shunt's own, or
40 * `null` for anything else (including no setting at all).
41 *
42 * The argv is run directly rather than through a shell, so nothing in the
43 * setting is interpreted beyond the quoting the launcher writes.
44 *
45 * @param command the merged settings' `apiKeyHelper`, as read
46 * @param home the home directory a leading `~/` expands to, when known
47 */
48export function shuntHelperArgvOf(command: unknown, home?: string): string[] | null {
49  if (typeof command !== 'string') {
50    return null
51  }
52
53  const match = SHUNT_HELPER.exec(command)
54  const word = match?.[1] ?? ''
55  const executable = unquote(word)
56
57  if (!BASENAME.test(executable)) {
58    return null
59  }
60
61  if (word.startsWith('~/')) {
62    const root = home?.replace(/[\\/]+$/, '') ?? ''
63
64    return root === '' ? null : [`${root}${executable.slice(1)}`, 'gateway', 'token']
65  }
66
67  return [executable, 'gateway', 'token']
68}
69
hooks/names.ts 51 lines
1/**
2 * The command the mod answers, and the fixed texts it prints.
3 *
4 * The name is namespaced by the plugin (`shunt` + `commands/usage.md`), which
5 * is what keeps it off the built-in `/usage`: `$.command.register` takes a
6 * bare global name and refuses `usage` outright ("it is the built-in /usage"),
7 * while a plugin's markdown command lists as `shunt:usage` and is free.
8 */
9export const COMMAND_NAME = 'shunt:usage'
10
11/**
12 * The path `[server.usage]` registers on the gateway.
13 */
14export const USAGE_PATH = '/usage'
15
16export const NO_BASE_URL_TEXT =
17  'no gateway to ask — neither SHUNT_BASE_URL nor ANTHROPIC_BASE_URL is ' +
18  'set, so this session is talking to Anthropic directly. Point Claude Code at ' +
19  'your gateway (export ANTHROPIC_BASE_URL=http://127.0.0.1:3001) and run ' +
20  '/shunt:usage again.'
21
22export const NO_TOKEN_TEXT =
23  'no usable credential to authenticate with — none of SHUNT_TOKEN, ' +
24  'ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is set, and there is no shunt ' +
25  'gateway token apiKeyHelper to read a gateway login from. Log in with ' +
26  'shunt gateway login, or ask the gateway operator for a client token.'
27
28export const NOT_ENABLED_TEXT =
29  'the gateway answered, but GET /usage is not enabled on it. Add an ' +
30  '[server.usage] table to the gateway config and restart it; the table takes ' +
31  'no keys, but it requires [server.auth] (client tokens) or ' +
32  '[server.gateway] (gateway login) to be set as well.'
33
34export const REFUSED_TEXT =
35  'the gateway refused the credential this session is using. The first ' +
36  'of SHUNT_TOKEN, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY and the ' +
37  'shunt gateway token helper that is set is the one sent, and the rest are ' +
38  'ignored. A client token must be one the gateway\'s [server.auth] accepts; ' +
39  'a gateway login may have expired or been revoked, so log in again with ' +
40  'shunt gateway login.'
41
42export const HOOK_FAILED_TEXT =
43  'could not read the pool usage — the hook that answers this command ' +
44  'failed or ran past its budget. Run /shunt:usage again; if it keeps ' +
45  'failing, check that the gateway is up and reachable.'
46
47export const NO_POOL_TEXT =
48  'the gateway reports no pooled provider, so there is no shared quota ' +
49  'to show. Pool usage appears once a provider is configured with pooled ' +
50  'accounts (auth mode oauth or chatgpt).'
51
hooks/reading.ts 143 lines
1import type { Endpoint } from './endpoint'
2import { NOT_ENABLED_TEXT, NO_POOL_TEXT, REFUSED_TEXT } from './names'
3import type { UsageReport } from './report'
4import { WINDOW_KEYS, parseReport } from './report'
5
6/**
7 * What one `GET /usage` round trip came to: the report, or the reason there
8 * is none.
9 *
10 * `problem` is the full sentence `/shunt:usage` prints. `brief` is the few
11 * words the band above the prompt flags instead, or `null` where the session
12 * is not on a shunt gateway that serves `GET /usage`: no gateway is set, it
13 * answers 404 or with a body that is not a pool report (another proxy), or it
14 * pools no provider. Those are set-ups, not faults, and the band shows the
15 * session's own rate limits for them rather than nagging.
16 */
17export type Reading =
18  | { report: UsageReport; base: string }
19  | { problem: string; brief: string | null }
20
21/** The band's brief for a 401 or 403: the gateway refused the credential. */
22export const CREDENTIAL_REFUSED = 'credential refused'
23
24/** The band's brief when shunt's own `apiKeyHelper` could not give a token. */
25export const HELPER_FAILED = 'gateway login unavailable'
26
27/** The subset of the engine's `HttpResponse` a reading needs. */
28export type Response = { status: number; ok: boolean; text: string }
29
30/**
31 * An error's message, less the `shunt: ` the engine stamps on its own — the
32 * transcript line already carries the plugin's name, so keeping it would read
33 * `shunt: ... — shunt: ...`.
34 */
35const messageOf = (error: unknown): string =>
36  (error instanceof Error ? error.message : String(error)).replace(
37    /^shunt: /,
38    '',
39  )
40
41/**
42 * The first line of a body, clipped, for an error the mod has no words of its
43 * own for — enough to recognize the gateway's own message without pasting a
44 * page of HTML into the transcript.
45 */
46const firstLineOf = (text: string): string => {
47  const line = text.split('\n', 1)[0]?.trim() ?? ''
48
49  return line.length > 200 ? `${line.slice(0, 200)}…` : line
50}
51
52/**
53 * The reading for shunt's own `apiKeyHelper` (`shunt gateway token`) failing:
54 * it exited non-zero, printed nothing, or could not be started. The session
55 * logs in through that helper, so this is a fault on shunt — flagged rather
56 * than hidden behind the session's own limits.
57 *
58 * @param cause the finished run, or what `$.process.run` rejected with
59 */
60export function helperFailedOf(
61  cause:
62    | { exitCode: number; stdout: string; stderr: string }
63    | { error: unknown },
64): Reading {
65  const stderr = 'error' in cause ? '' : firstLineOf(cause.stderr)
66
67  const detail =
68    'error' in cause
69      ? messageOf(cause.error)
70      : stderr !== ''
71        ? stderr
72        : cause.exitCode === 0
73          ? 'it printed nothing'
74          : `it exited with ${cause.exitCode}`
75
76  return {
77    problem:
78      'the apiKeyHelper is shunt gateway token, but running it failed ' +
79      `${'—'} ${detail}. Run shunt gateway login to log in again.`,
80    brief: HELPER_FAILED,
81  }
82}
83
84/**
85 * The reading for a request that never got an answer.
86 *
87 * @param endpoint where it was sent
88 * @param error what `$.http.fetch` rejected with
89 */
90export function unreachableOf(endpoint: Endpoint, error: unknown): Reading {
91  return {
92    problem:
93      `could not reach the gateway at ${endpoint.base} ` +
94      `${'—'} ${messageOf(error)}`,
95    brief: 'gateway unreachable',
96  }
97}
98
99/**
100 * Reads the gateway's answer to `GET /usage`.
101 *
102 * @param endpoint where it was sent
103 * @param response the status and body the gateway answered with
104 */
105export function readingOf(endpoint: Endpoint, response: Response): Reading {
106  if (response.status === 404) {
107    return { problem: NOT_ENABLED_TEXT, brief: null }
108  }
109
110  if (response.status === 401 || response.status === 403) {
111    return { problem: REFUSED_TEXT, brief: CREDENTIAL_REFUSED }
112  }
113
114  if (!response.ok) {
115    const detail = firstLineOf(response.text)
116
117    return {
118      problem:
119        `the gateway answered GET /usage with ${response.status}` +
120        `${detail === '' ? '' : ` ${'—'} ${detail}`}`,
121      brief: `GET /usage answered ${response.status}`,
122    }
123  }
124
125  const parsed = parseReport(response.text)
126
127  if ('problem' in parsed) {
128    return { problem: parsed.problem, brief: null }
129  }
130
131  const { report } = parsed
132
133  const isSilent =
134    report.providers.length === 0 &&
135    WINDOW_KEYS.every(key => report.pool.windows[key].remaining === null)
136
137  if (isSilent) {
138    return { problem: NO_POOL_TEXT, brief: null }
139  }
140
141  return { report, base: endpoint.base }
142}
143
hooks/views.ts 154 lines
1import type { PoolStatus, UsageReport, WindowKey } from './report'
2import { WINDOW_KEYS } from './report'
3
4const BAR_WIDTH = 10
5const BAR_FULL = '▓'
6const BAR_EMPTY = '░'
7const DASH = '—'
8
9const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
10
11const pad2 = (value: number) => String(value).padStart(2, '0')
12
13/**
14 * The bar for one window's headroom; an unreported window has no bar to draw.
15 */
16export const barOf = (remaining: number | null): string => {
17  if (remaining === null) {
18    return ' '.repeat(BAR_WIDTH)
19  }
20
21  const filled = Math.round(remaining * BAR_WIDTH)
22
23  return BAR_FULL.repeat(filled) + BAR_EMPTY.repeat(BAR_WIDTH - filled)
24}
25
26/**
27 * A window's headroom as whole percent, right-aligned; `—` when unreported.
28 *
29 * Rounds away from the ends so a pool with a sliver left does not read `0%`
30 * and one a hair short of full does not read `100%`.
31 */
32export const percentOf = (remaining: number | null): string => {
33  if (remaining === null) {
34    return DASH.padStart(4)
35  }
36
37  const raw = remaining * 100
38
39  const whole =
40    raw > 0 && raw < 1 ? 1 : raw < 100 && raw > 99 ? 99 : Math.round(raw)
41
42  return `${whole}%`.padStart(4)
43}
44
45/**
46 * When the window's earliest reset lands, in local time: the clock alone when
47 * it lands today, the weekday too on any later date, and `due` once it has
48 * passed.
49 *
50 * The cut is the calendar date rather than a 24-hour delta, because a bare
51 * clock is only unambiguous on today's date: at 04:13 on Thursday a reset 22
52 * hours out is Friday 02:13, and rendering it as `02:13` reads as a time that
53 * has already gone by.
54 */
55export const resetTextOf = (
56  resetsAt: number | null,
57  nowMs: number,
58): string => {
59  if (resetsAt === null) {
60    return ''
61  }
62
63  const atMs = resetsAt * 1000
64
65  if (atMs <= nowMs) {
66    return 'resets due'
67  }
68
69  const at = new Date(atMs)
70  const clock = `${pad2(at.getHours())}:${pad2(at.getMinutes())}`
71
72  const now = new Date(nowMs)
73  const today =
74    at.getFullYear() === now.getFullYear() &&
75    at.getMonth() === now.getMonth() &&
76    at.getDate() === now.getDate()
77
78  return today ? `resets ${clock}` : `resets ${DAYS[at.getDay()]} ${clock}`
79}
80
81const windowLabel = (key: WindowKey) => key.padEnd(5)
82
83const poolLines = (pool: PoolStatus, nowMs: number): string[] =>
84  WINDOW_KEYS.map(key => {
85    const window = pool.windows[key]
86
87    const row =
88      `  ${windowLabel(key)} ${barOf(window.remaining)} ` +
89      `${percentOf(window.remaining)} left`
90
91    const reset = resetTextOf(window.resetsAt, nowMs)
92
93    return reset === '' ? row : `${row}   ${reset}`
94  })
95
96const providerLines = (
97  providers: UsageReport['providers'],
98): string[] => {
99  if (providers.length === 0) {
100    return []
101  }
102
103  const nameWidth = Math.max(...providers.map(([name]) => name.length))
104
105  const statusWidth = Math.max(
106    ...providers.map(([, status]) => status.status.length),
107  )
108
109  return providers.map(([name, status]) => {
110    const cells = WINDOW_KEYS.map(
111      key => `${key} ${percentOf(status.windows[key].remaining)}`,
112    ).join('  ')
113
114    return `  ${name.padEnd(nameWidth)}  ${status.status.padEnd(statusWidth)}  ${cells}`
115  })
116}
117
118/**
119 * The whole `/shunt:usage` answer: the pool's three windows as bars, then the
120 * same headroom per pooled provider, then what the numbers mean.
121 *
122 * The legend is not decoration — `remaining` is the mean fraction still
123 * *usable* across the pool's accounts (up to each account's `max_utilization`
124 * hard cap), so a bare `62%` invites exactly the wrong
125 * reading ("62% burned"), and it is an aggregate rather than a prediction that
126 * the next request is admitted.
127 *
128 * @param report the parsed `GET /usage` body
129 * @param base the gateway the report came from
130 * @param nowMs the engine's clock, for the reset times
131 */
132export function reportText(
133  report: UsageReport,
134  base: string,
135  nowMs: number,
136): string {
137  const head = `pool ${DASH} ${report.pool.status}   ${base}`
138
139  const providers = providerLines(report.providers)
140
141  const legend =
142    `  headroom left, averaged over the pool's accounts; a shared figure, ` +
143    `not a promise about your next request`
144
145  return [
146    head,
147    '',
148    ...poolLines(report.pool, nowMs),
149    ...(providers.length === 0 ? [] : ['', ...providers]),
150    '',
151    legend,
152  ].join('\n')
153}
154
hooks/report.ts 125 lines
1/**
2 * `GET /usage`'s sanitized aggregate, as the mod reads it.
3 *
4 * The gateway's own shape (src/usage.rs): `{ pool, providers }`, each a status
5 * string and the three tracked windows. Every number here is pool-wide — the
6 * endpoint deliberately carries no account identity, count or priority.
7 */
8
9/** The three windows the gateway tracks, in the order they are shown. */
10export const WINDOW_KEYS = ['5h', '7d', 'fable'] as const
11
12export type WindowKey = (typeof WINDOW_KEYS)[number]
13
14export type WindowStatus = {
15  /**
16   * The fraction of the pool's combined capacity still *usable* before each
17   * account's `max_utilization` hard cap excludes it, `0..=1`, or
18   * `null` where no non-disabled account reports the window. Headroom, not
19   * consumption: a full bar is a fresh pool.
20   */
21  remaining: number | null
22  /** Earliest reported reset, unix epoch seconds, or `null`. */
23  resetsAt: number | null
24}
25
26export type PoolStatus = {
27  /** `ok`, `degraded` or `exhausted`, derived from availability alone. */
28  status: string
29  windows: Record<WindowKey, WindowStatus>
30}
31
32export type UsageReport = {
33  pool: PoolStatus
34  /** One entry per pooled provider, by configured name, sorted. */
35  providers: readonly (readonly [string, PoolStatus])[]
36}
37
38export type Parsed = { report: UsageReport } | { problem: string }
39
40const isRecord = (value: unknown): value is Record<string, unknown> =>
41  typeof value === 'object' && value !== null && !Array.isArray(value)
42
43/**
44 * A finite number in `0..=1`, or `null` for anything else (including the
45 * `null` the gateway sends for an unreported window).
46 */
47const fractionOf = (value: unknown): number | null =>
48  typeof value === 'number' && Number.isFinite(value)
49    ? Math.min(1, Math.max(0, value))
50    : null
51
52const epochOf = (value: unknown): number | null =>
53  typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
54
55const windowOf = (value: unknown): WindowStatus => {
56  const row = isRecord(value) ? value : {}
57
58  return { remaining: fractionOf(row.remaining), resetsAt: epochOf(row.resets_at) }
59}
60
61const poolOf = (value: unknown): PoolStatus | null => {
62  if (!isRecord(value)) {
63    return null
64  }
65
66  const windows = isRecord(value.windows) ? value.windows : {}
67
68  return {
69    status: typeof value.status === 'string' ? value.status : 'unknown',
70    windows: {
71      '5h': windowOf(windows['5h']),
72      '7d': windowOf(windows['7d']),
73      fable: windowOf(windows.fable),
74    },
75  }
76}
77
78/**
79 * Reads a `GET /usage` body, keeping every field the mod draws and refusing a
80 * body that is not one — a proxy's HTML error page parses as neither.
81 *
82 * @param text the response body
83 * @returns the report, or the reason it could not be read
84 */
85export function parseReport(text: string): Parsed {
86  let value: unknown
87
88  try {
89    value = JSON.parse(text)
90  } catch {
91    return {
92      problem:
93        'the gateway answered GET /usage with something that is not ' +
94        'JSON. Check that ANTHROPIC_BASE_URL points at the gateway itself and ' +
95        'not at a proxy in front of it.',
96    }
97  }
98
99  if (!isRecord(value)) {
100    return { problem: 'the gateway answered GET /usage with an unexpected body.' }
101  }
102
103  const pool = poolOf(value.pool)
104
105  if (pool === null) {
106    return {
107      problem:
108        'the gateway answered GET /usage without a pool aggregate. ' +
109        'This build may be older than the endpoint the mod reads.',
110    }
111  }
112
113  const rows = isRecord(value.providers) ? value.providers : {}
114
115  const providers = Object.keys(rows)
116    .sort()
117    .flatMap(name => {
118      const status = poolOf(rows[name])
119
120      return status === null ? [] : [[name, status] as const]
121    })
122
123  return { report: { pool, providers } }
124}
125
types/index.d.ts 60 lines
1/**
2 * The `shunt` mod's `$.state` contract: what the band above the prompt draws
3 * from — the gateway pool as `GET /usage` last reported it, the session's own
4 * Anthropic rate limits, and the clock its countdowns run from.
5 */
6
7/** One window of the pool aggregate, as `hooks/report.ts` reads it. */
8export type ShuntWindow = {
9  /** The unused fraction of the pool's capacity, `0..=1`, or `null`. */
10  remaining: number | null
11  /** The earliest reported reset, unix epoch seconds, or `null`. */
12  resetsAt: number | null
13}
14
15/** The pool-wide aggregate: a status and the three tracked windows. */
16export type ShuntPool = {
17  status: string
18  windows: { '5h': ShuntWindow; '7d': ShuntWindow; fable: ShuntWindow }
19}
20
21/** One pooled provider's status, by its configured name. */
22export type ShuntProviderStatus = { name: string; status: string }
23
24/**
25 * The gateway as last read: the pool and each provider's status, or the short
26 * reason a gateway that serves `GET /usage` could not be read.
27 */
28export type ShuntSnapshot =
29  | { pool: ShuntPool; providers: ShuntProviderStatus[] }
30  | { problem: string }
31
32/**
33 * One of the session's own rate-limit windows, as `$.session.usage()` and
34 * `session.measure` report them.
35 */
36export type ShuntRateLimit = {
37  /** `five_hour`, `seven_day`, `spend_limit`, or another the engine names. */
38  kind: string
39  /** 0 to 100, past 100 on an exceeded spend limit. */
40  percentUsed: number
41  /** ISO 8601. */
42  resetsAt?: string
43}
44
45declare module 'claude-code' {
46  interface PluginState {
47    shunt: {
48      /**
49       * `null` while the session is not on a shunt gateway that serves
50       * `GET /usage`: the band then draws `limits` instead.
51       */
52      snapshot: ShuntSnapshot | null
53      /** The session's own rate limits; empty off a subscription. */
54      limits: ShuntRateLimit[]
55      /** The engine clock at the last refresh, in milliseconds. */
56      now: number
57    }
58  }
59}
60