Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion apps/web/src/components/usage-display.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { accountUsageFromRateLimit, formatResetTime } from "./usage-display";
import {
accountUsageFromRateLimit,
accountUsagesFromRateLimit,
formatResetTime,
} from "./usage-display";

describe("formatResetTime", () => {
beforeEach(() => {
Expand Down Expand Up @@ -118,3 +122,70 @@ describe("accountUsageFromRateLimit (SDK rate_limit_info shape)", () => {
expect(a?.status).toBe("rejected");
});
});

describe("accountUsagesFromRateLimit (multi-window buckets shape)", () => {
const futureSec = Math.floor(Date.now() / 1000) + 3600;
const pastSec = Math.floor(Date.now() / 1000) - 3600;

it("parses 5h + weekly windows, ordered, normalizing 0–1 to 0–100", () => {
const windows = accountUsagesFromRateLimit({
buckets: {
// out of order on purpose — should sort five_hour first
seven_day: {
utilization: 0.46,
status: "allowed",
resetsAt: futureSec,
},
five_hour: {
utilization: 0.14,
status: "allowed",
resetsAt: futureSec,
},
seven_day_sonnet: {
utilization: 0.07,
status: "allowed",
resetsAt: futureSec,
},
},
});
expect(windows.map((w) => w.label)).toEqual([
"Current session",
"Current week",
"Current week (Sonnet)",
]);
expect(windows[0].utilization).toBeCloseTo(14);
expect(windows[1].utilization).toBeCloseTo(46);
expect(windows[2].utilization).toBeCloseTo(7);
});

it("keeps a rejected window and drops one whose reset has passed", () => {
const windows = accountUsagesFromRateLimit({
buckets: {
five_hour: { utilization: 1, status: "rejected", resetsAt: futureSec },
seven_day: { utilization: 0.9, status: "allowed", resetsAt: pastSec },
},
});
expect(windows).toHaveLength(1);
expect(windows[0].label).toBe("Current session");
expect(windows[0].status).toBe("rejected");
expect(windows[0].utilization).toBeCloseTo(100);
});

it("falls back to the legacy flat single-window shape", () => {
const windows = accountUsagesFromRateLimit({
rateLimitType: "seven_day",
status: "allowed",
utilization: 73.7,
resetsAt: futureSec,
});
expect(windows).toHaveLength(1);
expect(windows[0].label).toBe("Current week");
expect(windows[0].utilization).toBeCloseTo(73.7);
});

it("returns [] for an empty/unknown snapshot", () => {
expect(accountUsagesFromRateLimit(null)).toEqual([]);
expect(accountUsagesFromRateLimit({})).toEqual([]);
expect(accountUsagesFromRateLimit({ buckets: {} })).toEqual([]);
});
});
158 changes: 127 additions & 31 deletions apps/web/src/components/usage-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,102 @@ export function accountUsageFromRateLimit(
};
}

/** The freshest credential's account-limit snapshot (rows already carry the
* account-level rateLimit; pick the most recently active credential). */
function latestAccountUsage(
// Window ordering for the bars: short rolling window first, then weekly caps.
const WINDOW_ORDER = [
"five_hour",
"seven_day",
"seven_day_opus",
"seven_day_sonnet",
"overage",
];

/** Parse one window of the multi-window `buckets` snapshot (the live 5h/weekly
* utilization Harness fetches from Anthropic's rate-limit headers). */
function parseWindow(type: string, w: unknown): AccountUsage | null {
if (!w || typeof w !== "object") return null;
const o = w as Record<string, unknown>;
const rawStatus = typeof o.status === "string" ? o.status : "allowed";
const status: AccountUsage["status"] =
rawStatus === "rejected"
? "rejected"
: rawStatus.includes("warning")
? "warning"
: "allowed";
let utilization: number | undefined;
if (typeof o.utilization === "number" && Number.isFinite(o.utilization)) {
// Anthropic's unified headers report a 0–1 fraction; legacy flat
// snapshots used 0–100. Normalize both to a 0–100 percentage.
const u = o.utilization <= 1 ? o.utilization * 100 : o.utilization;
utilization = Math.max(0, Math.min(100, u));
}
const resetsAtMs = toResetMs(o.resetsAt);
// A window whose reset already passed carries a stale number — drop it.
if (resetsAtMs !== undefined && resetsAtMs <= Date.now()) return null;
if (
status === "allowed" &&
utilization === undefined &&
resetsAtMs === undefined
) {
return null;
}
return {
label: RATE_LIMIT_LABELS[type] ?? "Claude account",
status,
utilization,
resetsAtMs,
};
}

/** All renderable windows from a credential's snapshot. Handles the multi-window
* `{ buckets: { five_hour, seven_day, … } }` shape (subscription usage) and the
* legacy flat single-window shape. */
export function accountUsagesFromRateLimit(rateLimit: unknown): AccountUsage[] {
if (!rateLimit || typeof rateLimit !== "object") return [];
const rl = rateLimit as Record<string, unknown>;
const buckets = rl.buckets;
if (buckets && typeof buckets === "object") {
return Object.entries(buckets as Record<string, unknown>)
.map(([type, w]) => ({ type, a: parseWindow(type, w) }))
.filter((x): x is { type: string; a: AccountUsage } => x.a !== null)
.sort((x, y) => {
const ix = WINDOW_ORDER.indexOf(x.type);
const iy = WINDOW_ORDER.indexOf(y.type);
return (ix < 0 ? 99 : ix) - (iy < 0 ? 99 : iy);
})
.map((x) => x.a);
}
const a = accountUsageFromRateLimit(rl);
return a ? [a] : [];
}

/** The freshest credential's window list (rows carry the account-level snapshot;
* pick the most recently active credential that has one). */
function latestAccountUsages(
rows: AgentUsageRow[] | undefined,
): AccountUsage | null {
): AccountUsage[] {
const byRecency = [...(rows ?? [])].sort(
(a, b) => (b.lastTurnAt ?? 0) - (a.lastTurnAt ?? 0),
);
for (const r of byRecency) {
const a = accountUsageFromRateLimit(r.rateLimit);
if (a) return a;
const windows = accountUsagesFromRateLimit(r.rateLimit);
if (windows.length) return windows;
}
return null;
return [];
}

/** The single most-restrictive window (a hit limit, else the highest %) — used
* for the compact badge color. */
function latestAccountUsage(
rows: AgentUsageRow[] | undefined,
): AccountUsage | null {
const windows = latestAccountUsages(rows);
if (!windows.length) return null;
const rejected = windows.find((w) => w.status === "rejected");
if (rejected) return rejected;
return windows.reduce(
(m, w) => ((w.utilization ?? 0) > (m.utilization ?? 0) ? w : m),
windows[0],
);
}

/** "in 5h 12m" until a reset timestamp (ms), or undefined when past/absent. */
Expand Down Expand Up @@ -467,11 +550,8 @@ export function UsageDisplay() {
*/
function AccountLimitsSection() {
const { data } = useQuery(convexQuery(api.agentUsage.getMyAgentUsage, {}));
const acct = latestAccountUsage(data as AgentUsageRow[] | undefined);
if (!acct) return null;

const resetsIn = resetsInLabel(acct.resetsAtMs);
const resetSub = resetsIn ? `Resets in ${resetsIn}` : undefined;
const windows = latestAccountUsages(data as AgentUsageRow[] | undefined);
if (windows.length === 0) return null;

return (
<div className="space-y-3 border-t border-white/10 pt-4">
Expand All @@ -483,25 +563,41 @@ function AccountLimitsSection() {
your subscription
</span>
</div>
{acct.status === "rejected" ? (
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{acct.label} limit reached
{resetsIn ? ` · resets in ${resetsIn}` : ""}. Agent turns are paused
until it resets.
</div>
) : acct.utilization !== undefined ? (
<ProgressBar
pct={acct.utilization}
label={acct.label}
sublabel={resetSub}
/>
) : (
<p className="text-[11px] text-foreground/50">
{acct.label}
{acct.status === "warning" ? " · approaching limit" : ""}
{resetsIn ? ` · resets in ${resetsIn}` : ""}
</p>
)}
<div className="space-y-3">
{windows.map((w) => {
const resetsIn = resetsInLabel(w.resetsAtMs);
const resetSub = resetsIn ? `Resets in ${resetsIn}` : undefined;
if (w.status === "rejected") {
return (
<div
key={w.label}
className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
>
{w.label} limit reached
{resetsIn ? ` · resets in ${resetsIn}` : ""}. Agent turns are
paused until it resets.
</div>
);
}
if (w.utilization !== undefined) {
return (
<ProgressBar
key={w.label}
pct={w.utilization}
label={w.label}
sublabel={resetSub}
/>
);
}
return (
<p key={w.label} className="text-[11px] text-foreground/50">
{w.label}
{w.status === "warning" ? " · approaching limit" : ""}
{resetsIn ? ` · resets in ${resetsIn}` : ""}
</p>
);
})}
</div>
</div>
);
}
Expand Down
60 changes: 59 additions & 1 deletion packages/fastapi/app/services/agents/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,10 @@ def __init__(self):
# Fire-and-forget usage-recording tasks; held so they aren't GC'd
# mid-flight, discarded on completion.
self._usage_tasks: set[asyncio.Task] = set()
# Per-credential debounce for the subscription-usage ping (monotonic ts
# of the last fetch) — the windows move slowly, so once a minute is plenty
# and keeps the extra inference calls negligible.
self._sub_usage_fetched_at: dict[str, float] = {}

@staticmethod
def _runtime_key(
Expand All @@ -1043,6 +1047,55 @@ def _http_client(self) -> httpx.AsyncClient:
self._http = httpx.AsyncClient(timeout=30.0)
return self._http

def _maybe_fetch_subscription_usage(
self, session: AgentSession, cred_id: str
) -> None:
"""Kick off a debounced subscription-usage fetch for this credential."""
now = time.monotonic()
if now - self._sub_usage_fetched_at.get(cred_id, 0.0) < 60.0:
return
# Set before firing so the many agent_usage events in one turn don't all
# queue a fetch; a failure simply waits out the TTL before retrying.
self._sub_usage_fetched_at[cred_id] = now
task = asyncio.create_task(
self._fetch_subscription_usage(session.user_id, cred_id)
)
self._usage_tasks.add(task)
task.add_done_callback(self._usage_tasks.discard)

async def _fetch_subscription_usage(self, user_id: str, cred_id: str) -> None:
"""Resolve the credential's OAuth token, read the live 5h + weekly
windows off Anthropic's rate-limit headers, and persist them onto the
credential. Only OAuth (subscription) credentials carry these windows;
api-key credentials are skipped. Best-effort."""
try:
from app.services.agents.credentials import resolve_agent_credentials
from app.services.usage import (
fetch_subscription_usage,
record_agent_rate_limit,
)

creds = await resolve_agent_credentials(
self._http_client(), "claude-code", user_id, cred_id
)
token = (creds.env or {}).get("CLAUDE_CODE_OAUTH_TOKEN")
if not token:
return # api-key credential — no subscription windows to show
usage = await fetch_subscription_usage(self._http_client(), token)
if usage:
await record_agent_rate_limit(
self._http_client(),
user_id=user_id,
agent_credential_id=cred_id,
rate_limit=usage,
)
except Exception:
logger.warning(
"Subscription usage fetch failed for credential '%s'",
cred_id,
exc_info=True,
)

async def _inject_workspace_env(
self, creds, harness: HarnessConfig, user_id: str,
) -> str:
Expand Down Expand Up @@ -2108,10 +2161,10 @@ async def _process_session_update(self, session: AgentSession, params: dict) ->
# see the reset time.
if event["event"] == "agent_usage":
rl = (update.get("_meta") or {}).get("_claude/rateLimit")
cred_id = session.harness.agent_credential_id
if rl is not None:
changed = rl != session.last_rate_limit
session.last_rate_limit = rl
cred_id = session.harness.agent_credential_id
# Only persist when the snapshot actually changed — avoids a
# credential-row write on every usage_update that merely
# repeats the same rate-limit state.
Expand All @@ -2128,6 +2181,11 @@ async def _process_session_update(self, session: AgentSession, params: dict) ->
)
self._usage_tasks.add(rl_task)
rl_task.add_done_callback(self._usage_tasks.discard)
elif cred_id:
# The Claude Agent SDK doesn't surface subscription usage in
# the stream (the _meta snapshot is empty), so fetch the 5h +
# weekly windows ourselves — debounced per credential.
self._maybe_fetch_subscription_usage(session, cred_id)
# Persist per-credential agent usage on the terminal usage_update
# (the only one carrying cost). Thin: no cache tokens, cache-excluded
# cost — for claude-code the SDK result message upgrades this row in
Expand Down
Loading
Loading