usage panel on the claude code pill
clicking the harness pill opens what claude code's own usage display shows: session/weekly/scoped limits with reset countdowns, tokens by day for the last week, tokens by model. officer has none of these numbers and could not derive them. per-session cost is in-memory in websocket.ts and dies on restart, and the rate-limit windows belong to the ANTHROPIC ACCOUNT — spent by every `claude` on the box, not only by this chat. so both halves come from the cli's own record: the oauth usage endpoint for the windows, ~/.claude/projects for the tokens, ~/.claude.json for which account is signed in. shape and the non-obvious correctness details are lifted from omarchy's agents collector (basecamp/omarchy quattro, via pastilhas.agents which adds the account email): dedup by message id, the four token counters, the per-payload percent-vs-fraction scale, and reading the model-scoped window out of limits[] rather than the flat buckets — every seven_day_* bucket is null on this account while limits[] carries a real fable window. verified against the live payload today; limitsFromPayload is tested against it. owner-only in the handler, not the registry: `chat` is confined, so a member holding it reaches every route here, and these are someone else's windows. the pill falls back to the plain label it always was for anyone else and for opencode. 442ms cold, 1ms warm on 87MB of transcripts; cached 60s server-side, and the query only runs when the popover opens. not exercised through http — the route needs an officer restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
-113
@@ -1,113 +0,0 @@
|
||||
# Next session — two chat fixes
|
||||
|
||||
**Disposable.** Written 2026-08-18 ~05:00 as a handoff across a compaction. Delete it when both are done.
|
||||
|
||||
Both are in `/chat`. Do them in order — the first is small and well understood, the second is a design
|
||||
question wearing a UI job's clothes.
|
||||
|
||||
---
|
||||
|
||||
## 1. The model dropdown says `claude-code` instead of the model that is answering
|
||||
|
||||
### Symptom
|
||||
|
||||
In the composer, bottom right, the model select renders the literal string **`claude-code`**. It should
|
||||
name a model — the owner expects "Opus".
|
||||
|
||||
### Why, exactly
|
||||
|
||||
`DEFAULT_MODEL` in `src/servers/api/chat/websocket.ts` is the bare harness string `claude-code`, used when
|
||||
the client sends no model and the account has no default. It routes correctly — `isClaudeModel` is a prefix
|
||||
test and `model.split('/')[1]` is `undefined`, so no `--model` is passed and **the CLI picks for itself**.
|
||||
|
||||
It names no catalogue entry, so Radix's `SelectValue` matches no item and renders nothing. `773f0e1c` added
|
||||
a fallback in `ModelSelector.tsx` so a blank trigger became the raw string's last segment
|
||||
(`displayModel.split('/').pop()`), which for `claude-code` is `claude-code`. Honest, and useless.
|
||||
|
||||
### The fix, and the distinction it turns on
|
||||
|
||||
**The server does not know which model answered** — it deliberately did not choose one. The transcript does:
|
||||
every assistant entry carries `message.model` (`claude-opus-5`), and `officerModelForApiModel()` in
|
||||
`src/servers/api/chat/model-aliases.ts` already maps that back to `claude-code/opus`.
|
||||
|
||||
So: when the effective model is the bare harness, display **what answered**, not what was asked for. That is
|
||||
the same `effective`-vs-requested distinction `createMessageHandler` already makes for tier switches.
|
||||
|
||||
Options, cheapest first:
|
||||
|
||||
1. **Client-side.** The turn's `result`/assistant messages already reach `useChat`. If a message carries a
|
||||
model, prefer it over the bare harness for display only — do not write it back to `selectedModel`, or the
|
||||
next turn will request a tier the user never picked.
|
||||
2. **Server-side.** `GET /chat/sessions/:id` already returns `officerModel`, resolved from the stored row
|
||||
with a family-guess fallback. A live session could report the same thing on `session:init`.
|
||||
|
||||
Option 1 is smaller. Option 2 is more consistent with how a resumed session already works.
|
||||
|
||||
### Traps
|
||||
|
||||
- **Do not store the resolved value.** `record()` in `websocket.ts` refuses to persist a model without a `/`
|
||||
precisely because a bare harness overwrote a correctly backfilled `claude-code/opus` and made the picker
|
||||
render blank. Read that comment before touching either end.
|
||||
- **This picker has been broken once already today.** Verify: a session with no explicit model, a session on
|
||||
`opus[1m]`, and a resumed session all show something sensible.
|
||||
- `chat_session_models.model` holds Officer ids (`claude-code/opus[1m]`), not API ids. Do not mix them.
|
||||
|
||||
---
|
||||
|
||||
## 2. Usage popover on the "Claude Code" pill
|
||||
|
||||
### What is wanted
|
||||
|
||||
Clicking the harness pill — the left-hand label in the composer's control row, which reads **Claude Code**
|
||||
and is inert once a conversation has started — should open a panel showing what Claude Code's own usage
|
||||
display shows:
|
||||
|
||||
- **Limits**: Session %, Weekly %, Fable Weekly %, each with a reset countdown
|
||||
- **Tokens by day**, last seven days, as bars
|
||||
- **Tokens by model**: Opus 5, Opus 4.8, and any others, with totals
|
||||
|
||||
The owner supplied a screenshot of the target and a reference implementation:
|
||||
**https://github.com/basecamp/omarchy/tree/quattro/shell/plugins/agents**
|
||||
|
||||
### Read the reference FIRST — this is a data question, not a UI one
|
||||
|
||||
**Officer does not have any of these numbers.** Per-session cost is accumulated in memory in
|
||||
`websocket.ts` (search `session.meta.cost`), it dies with `pm2 restart officer`, and nothing aggregates it
|
||||
across sessions, days or models. There is no usage table.
|
||||
|
||||
So before writing any component, answer: **where does omarchy get this?** Two possibilities, and they are
|
||||
completely different jobs:
|
||||
|
||||
- **It reads local Claude Code state** — something under `~/.claude`. Then this is a display job: a sidecar
|
||||
route reads the file, the panel renders it, and the numbers are the same ones the CLI shows.
|
||||
- **It calls an Anthropic endpoint** — then it needs the OAuth credential, which lives with
|
||||
`officer-anthropic-proxy` and never leaves it, and the route belongs there rather than in the chat socket.
|
||||
|
||||
Do not build a schema for this until that is known. The wrong answer is inventing Officer's own token
|
||||
accounting to reproduce a number the CLI already computes.
|
||||
|
||||
### Where the pill is
|
||||
|
||||
`src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx` — the provider label rendered when
|
||||
`providerStripLocked` is true (a `<span>` with `bg-muted`, currently not a button). It sits left of the
|
||||
background-job button and the model select.
|
||||
|
||||
Note the owner's framing: _"in a running chat that is not responding to harness change anymore"_ — the pill
|
||||
is already inert in that state, which is exactly why it is free to become a button.
|
||||
|
||||
### Constraints
|
||||
|
||||
- **Owner-only data.** These are the owner's subscription limits. A member must not see them — gate it.
|
||||
- **No palette classes.** Semantic tokens only (`bg-muted`, `text-muted-foreground`, `bg-primary/15`); the
|
||||
ratchet is `bun run check:theme`.
|
||||
- **Do not block the composer.** If the source is unreachable the popover says so; it must not spin.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
`bunx tsgo` is clean and `bun test` is at **7 pre-existing failures** (registry/totality drift and a cliamp
|
||||
socket — none yours). Compare against that baseline, not against zero.
|
||||
|
||||
Everything else from tonight is committed and pushed across four repositories: `platform`, `officer-oas`,
|
||||
`plugins/email`, `plugins/kiosk`.
|
||||
@@ -31,6 +31,7 @@ import { logger } from './logger';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import type { ChatIdentity } from './claude-sessions';
|
||||
import { officerModelForApiModel } from './model-aliases';
|
||||
import { readClaudeUsage } from './claude-usage';
|
||||
import { readSttConfig } from '../server-settings/stt';
|
||||
|
||||
/**
|
||||
@@ -393,6 +394,24 @@ chatRouter.post('/tasks/:id/pause', async (ctx) => {
|
||||
});
|
||||
|
||||
// GET /chat/models — Claude tiers only (the runner is the `claude` CLI).
|
||||
/*
|
||||
GET /chat/usage — what Claude Code's own usage display shows.
|
||||
|
||||
OWNER ONLY, and not because of the registry: `chat` is a `confined` permission, so a member holding it
|
||||
reaches every route in this router. These are the OWNER'S subscription windows and the OWNER'S transcripts,
|
||||
spent by every `claude` on the machine — a member seeing them would be reading someone else's account, and
|
||||
a member seeing them go red would be reading a bill they cannot act on.
|
||||
|
||||
A 403 rather than an empty reading: there is no per-member version of this to fall back to. Members run
|
||||
`claude` as their own Linux account with their own credential, so the honest answer to "what is my usage"
|
||||
for them is a different query against a different login, and inventing it here would answer the wrong one.
|
||||
*/
|
||||
chatRouter.get('/usage', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
if (!who.isOwner) throw errors.FORBIDDEN('Usage belongs to the account that owns this machine');
|
||||
return ctx.json(await readClaudeUsage());
|
||||
});
|
||||
|
||||
chatRouter.get('/models', async (ctx: Context) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { friendlyModelName, limitsFromPayload } from './claude-usage';
|
||||
|
||||
/**
|
||||
* The live payload, 2026-08-18, trimmed to the fields this reads.
|
||||
*
|
||||
* Kept verbatim rather than minimised because the two things that are easy to get wrong are both visible
|
||||
* only in the whole shape: every model-scoped BUCKET is null while `limits[]` carries a real scoped entry,
|
||||
* and the flat buckets and the array report the same windows twice.
|
||||
*/
|
||||
const LIVE = {
|
||||
five_hour: { utilization: 15.0, resets_at: '2026-08-18T08:00:00.434323+00:00' },
|
||||
seven_day: { utilization: 22.0, resets_at: '2026-08-19T01:00:00.434348+00:00' },
|
||||
seven_day_oauth_apps: null,
|
||||
seven_day_opus: null,
|
||||
seven_day_sonnet: null,
|
||||
limits: [
|
||||
{ kind: 'session', group: 'session', percent: 15, resets_at: '2026-08-18T08:00:00.434323+00:00', scope: null },
|
||||
{ kind: 'weekly_all', group: 'weekly', percent: 22, resets_at: '2026-08-19T01:00:00.434348+00:00', scope: null },
|
||||
{
|
||||
kind: 'weekly_scoped',
|
||||
group: 'weekly',
|
||||
percent: 6,
|
||||
resets_at: '2026-08-19T01:00:00.434561+00:00',
|
||||
scope: { model: { id: null, display_name: 'Fable' }, surface: null },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('limitsFromPayload', () => {
|
||||
test('reads the live payload the way Claude Code displays it', () => {
|
||||
expect(limitsFromPayload(LIVE)).toEqual([
|
||||
{ label: 'Session', percent: 15, resetsAt: '2026-08-18T08:00:00.434323+00:00' },
|
||||
{ label: 'Weekly', percent: 22, resetsAt: '2026-08-19T01:00:00.434348+00:00' },
|
||||
{ label: 'Fable Weekly', percent: 6, resetsAt: '2026-08-19T01:00:00.434561+00:00' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a scoped window survives its flat bucket being null', () => {
|
||||
// `seven_day_opus` and friends stayed behind at null when the scoped entries moved into `limits[]`, so
|
||||
// a reader that trusts the buckets alone drops an allowance the account is actually spending against.
|
||||
const scoped = limitsFromPayload(LIVE).find((l) => l.label === 'Fable Weekly');
|
||||
expect(scoped?.percent).toBe(6);
|
||||
expect(LIVE.seven_day_opus).toBeNull();
|
||||
});
|
||||
|
||||
test('the unscoped entries in limits[] are not repeated as their own rows', () => {
|
||||
// `session` and `weekly_all` restate the flat buckets. Reading both would print Session and Weekly twice.
|
||||
expect(limitsFromPayload(LIVE).map((l) => l.label)).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('the oauth-apps bucket shadows the plain weekly one when it is populated', () => {
|
||||
const limits = limitsFromPayload({
|
||||
...LIVE,
|
||||
seven_day_oauth_apps: { utilization: 40, resets_at: null },
|
||||
});
|
||||
expect(limits.find((l) => l.label === 'Weekly')?.percent).toBe(40);
|
||||
});
|
||||
|
||||
test('a fractional payload is scaled up, and a percent payload is left alone', () => {
|
||||
/*
|
||||
The two conventions are indistinguishable one value at a time — `1.0` is either 1% or 100% — so the
|
||||
scale is decided by the WHOLE payload: any value at or above 1 means it is already percentages.
|
||||
*/
|
||||
const fractions = { five_hour: { utilization: 0.37, resets_at: null }, limits: [] };
|
||||
expect(limitsFromPayload(fractions)[0]?.percent).toBeCloseTo(37);
|
||||
|
||||
const percents = { five_hour: { utilization: 1.0, resets_at: null }, seven_day: { utilization: 22 }, limits: [] };
|
||||
expect(limitsFromPayload(percents)[0]?.percent).toBe(1);
|
||||
});
|
||||
|
||||
test('a scoped window is named from its kind, never from the model name', () => {
|
||||
// "Opus 5 (1M context)" contains "1M", and reading a window out of the title turns that into a minute.
|
||||
const limits = limitsFromPayload({
|
||||
limits: [
|
||||
{
|
||||
kind: 'weekly_scoped',
|
||||
percent: 12,
|
||||
resets_at: null,
|
||||
scope: { model: { display_name: 'Opus 5 (1M context)' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(limits).toEqual([{ label: 'Opus 5 (1M context) Weekly', percent: 12, resetsAt: null }]);
|
||||
});
|
||||
|
||||
test('an empty or malformed payload produces no limits rather than throwing', () => {
|
||||
expect(limitsFromPayload({})).toEqual([]);
|
||||
expect(limitsFromPayload({ limits: 'not an array', five_hour: 7 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('friendlyModelName', () => {
|
||||
test('rejoins the version and drops the date suffix', () => {
|
||||
expect(friendlyModelName('claude-opus-5')).toBe('Opus 5');
|
||||
expect(friendlyModelName('claude-opus-4-8')).toBe('Opus 4.8');
|
||||
expect(friendlyModelName('claude-haiku-4-5-20251001')).toBe('Haiku 4.5');
|
||||
expect(friendlyModelName('claude-fable-5')).toBe('Fable 5');
|
||||
});
|
||||
|
||||
test('handles ids that are not Anthropic ids', () => {
|
||||
expect(friendlyModelName('kimi-for-coding')).toBe('Kimi For Coding');
|
||||
expect(friendlyModelName('')).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getOwnerHomeDir } from '@@/data-path';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* What Claude Code's own usage display shows, for the OWNER of this machine.
|
||||
*
|
||||
* ── Why this reads the CLI's state instead of counting its own ──
|
||||
*
|
||||
* Officer has none of these numbers and cannot get them by watching its own traffic. Per-session cost is
|
||||
* accumulated in memory in `websocket.ts` and dies with `pm2 restart officer`; nothing aggregates it across
|
||||
* sessions, days or models; and the rate-limit windows are a property of the ANTHROPIC ACCOUNT, spent by
|
||||
* every `claude` on the machine — this chat, a terminal, a task runner, a pipeline step. A number derived
|
||||
* from what Officer happened to see would be lower than the truth by however much was spent elsewhere, and
|
||||
* silently so.
|
||||
*
|
||||
* So both halves come from the harness's own record:
|
||||
*
|
||||
* - **Limits** from Anthropic's OAuth usage endpoint, with the token the CLI stored at login. This is the
|
||||
* only source; there is no local file holding the window percentages.
|
||||
* - **Tokens** by scanning the transcripts under `~/.claude/projects`, which is what every turn writes and
|
||||
* what the CLI reads back for the same display.
|
||||
*
|
||||
* The shape is lifted from omarchy's `omarchy-agent-usage-claude` collector (basecamp/omarchy, quattro), and
|
||||
* the correctness details that are not obvious were taken from it rather than rediscovered: dedup by message
|
||||
* id, the four token counters, the percent-versus-fraction detection, and reading the model-scoped window out
|
||||
* of `limits[]` rather than the flat buckets.
|
||||
*
|
||||
* ── Owner only, and structurally so ──
|
||||
*
|
||||
* These are the owner's subscription limits and the owner's transcripts. `getOwnerHomeDir` ignores the email
|
||||
* it is passed and always answers the owner, which is the one thing that must not happen by accident here —
|
||||
* so nothing in this file takes an identity at all. The caller checks `isOwner`; this module cannot be asked
|
||||
* about anyone else.
|
||||
*/
|
||||
|
||||
export type UsageLimit = {
|
||||
/** `Session`, `Weekly`, `Fable Weekly` — already the label to render. */
|
||||
label: string;
|
||||
percent: number;
|
||||
resetsAt: string | null;
|
||||
};
|
||||
|
||||
export type UsageDay = {
|
||||
/** `YYYY-MM-DD`, local. The client turns it into a weekday. */
|
||||
date: string;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
export type UsageModel = {
|
||||
/** `Opus 5`, not `claude-opus-5`. */
|
||||
name: string;
|
||||
total: number;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
|
||||
export type ClaudeUsage = {
|
||||
account: { email: string | null; plan: string | null };
|
||||
limits: UsageLimit[];
|
||||
/**
|
||||
* Why the limits are missing or stale. Local token stats are unaffected and still shown — which is the
|
||||
* whole reason this is a field rather than an error response.
|
||||
*/
|
||||
limitsProblem?: string;
|
||||
/** Seven entries, oldest first. The last one is today, including when it is zero. */
|
||||
days: UsageDay[];
|
||||
models: UsageModel[];
|
||||
/** When this was measured, so the panel can say how old it is rather than implying it is live. */
|
||||
fetchedAt: string;
|
||||
};
|
||||
|
||||
const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
||||
|
||||
/**
|
||||
* How long a reading is reused.
|
||||
*
|
||||
* Both halves are expensive in different ways — the endpoint rate-limits, and the scan reads ~90MB of JSONL —
|
||||
* and neither changes meaningfully inside a minute. Cached SEPARATELY so a network failure does not throw
|
||||
* away a good scan, and so opening and shutting the popover is not a request per flick.
|
||||
*/
|
||||
const LIMITS_TTL_MS = 60_000;
|
||||
const SCAN_TTL_MS = 60_000;
|
||||
|
||||
const claudeDir = (): string => process.env.CLAUDE_CONFIG_DIR?.trim() || join(getOwnerHomeDir(''), '.claude');
|
||||
|
||||
// ── Credentials and account ──
|
||||
|
||||
type OauthLogin = { accessToken: string; plan: string | null };
|
||||
|
||||
/**
|
||||
* The access token and the display-safe plan label.
|
||||
*
|
||||
* Nothing else leaves the credential file: the token goes into one Authorization header and is never
|
||||
* returned, logged or stored. `refreshToken` is deliberately not read — Officer does not refresh the CLI's
|
||||
* login, and a token it never holds is one it cannot leak.
|
||||
*/
|
||||
async function readOauthLogin(): Promise<OauthLogin | null> {
|
||||
try {
|
||||
const raw = await readFile(join(claudeDir(), '.credentials.json'), 'utf-8');
|
||||
const login = (JSON.parse(raw) as Record<string, unknown>).claudeAiOauth as Record<string, unknown> | undefined;
|
||||
const token = typeof login?.accessToken === 'string' ? login.accessToken : '';
|
||||
if (!login || !token) return null;
|
||||
return { accessToken: token, plan: planLabel(login) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `default_claude_max_20x` → `Max 20x`; else the subscription word, capitalised; else nothing. */
|
||||
function planLabel(login: Record<string, unknown>): string | null {
|
||||
const tier = typeof login.rateLimitTier === 'string' ? login.rateLimitTier : '';
|
||||
const match = /max_(\d+x)/i.exec(tier);
|
||||
if (match) return `Max ${match[1]}`;
|
||||
const sub = typeof login.subscriptionType === 'string' ? login.subscriptionType : '';
|
||||
return sub ? sub[0]!.toUpperCase() + sub.slice(1) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which account the CLI is logged in as.
|
||||
*
|
||||
* Not in the credential file and not in the usage payload — it lives in `~/.claude.json`, beside the config
|
||||
* rather than inside `.claude/`. Worth showing because this machine's owner has more than one Anthropic
|
||||
* account and the limits belong to exactly one of them; a panel of percentages that does not say whose is
|
||||
* a panel you cannot act on.
|
||||
*/
|
||||
async function readAccountEmail(): Promise<string | null> {
|
||||
try {
|
||||
const raw = await readFile(join(getOwnerHomeDir(''), '.claude.json'), 'utf-8');
|
||||
const account = (JSON.parse(raw) as Record<string, unknown>).oauthAccount as Record<string, unknown> | undefined;
|
||||
const email = account?.emailAddress;
|
||||
return typeof email === 'string' && email ? email : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Limits ──
|
||||
|
||||
type LimitsResult = { limits: UsageLimit[]; problem?: string };
|
||||
|
||||
let limitsCache: { at: number; result: LimitsResult } | null = null;
|
||||
|
||||
const asPercent = (value: unknown): number => {
|
||||
const n = Number(
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.replace('%', ''),
|
||||
);
|
||||
return Number.isFinite(n) ? n : -1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalise a utilization figure to a percentage.
|
||||
*
|
||||
* The endpoint currently reports percentages (`15.0`), and older payloads reported fractions (`0.15`). The
|
||||
* two are indistinguishable one value at a time — `1.0` is either 1% or 100% — so the SCALE IS DECIDED PER
|
||||
* PAYLOAD: any value at or above 1 anywhere in it means the whole payload is percent-scaled.
|
||||
*/
|
||||
const normalise = (value: unknown, percentScale: boolean): number => {
|
||||
const n = asPercent(value);
|
||||
if (n < 0) return -1;
|
||||
return percentScale ? n : n * 100;
|
||||
};
|
||||
|
||||
const resetAt = (value: unknown): string | null => (typeof value === 'string' && value ? value : null);
|
||||
|
||||
/** `weekly_scoped` → `Weekly`. Read from the KIND, never from the model name — see `scopedLimits`. */
|
||||
function scopedWindow(kind: string): string {
|
||||
const text = kind.toLowerCase();
|
||||
if (text.includes('month')) return 'Monthly';
|
||||
if (text.includes('week') || text.includes('day')) return 'Weekly';
|
||||
if (text.includes('hour') || text.includes('session')) return 'Session';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-scoped allowances, which live ONLY in the `limits` array.
|
||||
*
|
||||
* The matching flat buckets (`seven_day_opus`, `seven_day_sonnet`, …) are all null on this account while
|
||||
* `limits[]` carries a real `weekly_scoped` entry for Fable — so a reader that trusts the buckets alone
|
||||
* silently drops a window the account is actually spending against. Verified against the live payload,
|
||||
* 2026-08-18.
|
||||
*
|
||||
* The window comes from `kind` rather than from parsing the title, because a display name like
|
||||
* `Opus 5 (1M context)` puts "1M" in the text and that reads as a one-minute window.
|
||||
*/
|
||||
function scopedLimits(payload: Record<string, unknown>, percentScale: boolean): UsageLimit[] {
|
||||
const entries = payload.limits;
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out: UsageLimit[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of entries as Record<string, unknown>[]) {
|
||||
const scope = entry?.scope as Record<string, unknown> | undefined;
|
||||
const model = scope?.model as Record<string, unknown> | undefined;
|
||||
if (!model) continue;
|
||||
// An entry carrying only an id still names a window worth showing.
|
||||
const name = String(model.display_name ?? model.id ?? '').trim();
|
||||
const kind = String(entry.kind ?? '').trim();
|
||||
// A model can hold more than one window, so only the pair tells them apart.
|
||||
const key = `${name} | ||||