diff --git a/bin/omarchy-agent-usage-claude b/bin/omarchy-agent-usage-claude index 7805c4fa..167e0223 100755 --- a/bin/omarchy-agent-usage-claude +++ b/bin/omarchy-agent-usage-claude @@ -649,6 +649,64 @@ def usage_bucket(payload: dict[str, Any], key: str) -> dict[str, Any] | None: return bucket if isinstance(bucket, dict) else None +# An entry's `kind` names its window the way the flat buckets' keys do +# ("weekly_scoped", "five_hour_scoped"). The panel reads a window out of free +# text, which cannot survive a model name like "Opus 5 (1M context)" — the +# "1M" reads as a one-minute window — so the window is settled here instead +# and travels as an explicit title. +def scoped_window(kind: str) -> str: + text = kind.lower() + if "month" in text: + return "monthly" + if "week" in text or "day" in text: + return "weekly" + if "hour" in text or "session" in text: + return "session" + return "" + + +# Alongside the flat buckets, the payload carries a `limits` array, and that +# array is the only place a model-scoped allowance shows up — a weekly window +# that only Fable draws from, say. The matching legacy keys +# (`seven_day_opus`, `seven_day_sonnet`, …) stayed behind at null, so a +# collector that reads buckets alone silently drops a limit the account is +# actually spending against. A model can hold more than one scoped window, and +# only the pair of model and window tells them apart, so both make the title +# and both make the key that keeps a repeat out. +def scoped_limits(payload: dict[str, Any], percent_scale: bool) -> list[dict[str, Any]]: + entries = payload.get("limits") + if not isinstance(entries, list): + return [] + out: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for entry in entries: + if not isinstance(entry, dict): + continue + scope = entry.get("scope") + model = scope.get("model") if isinstance(scope, dict) else None + if not isinstance(model, dict): + continue + # A display name is what the panel wants, but an entry carrying only an id + # still names a window worth showing. + name = str(model.get("display_name") or model.get("id") or "").strip() + kind = str(entry.get("kind") or "").strip() + if name == "" or (name, kind) in seen: + continue + percent = normalize_utilization(entry.get("percent"), percent_scale) + if percent < 0: + continue + seen.add((name, kind)) + window = scoped_window(kind) + title = name + " " + window if window else name + out.append({ + "label": title, + "title": title, + "percent": percent, + "resetsAt": normalize_reset_at(entry.get("resets_at")), + }) + return out + + def probe_limits(access_token: str) -> dict[str, Any]: request = urllib.request.Request( USAGE_ENDPOINT, @@ -683,6 +741,11 @@ def probe_limits(access_token: str) -> dict[str, Any]: weekly = usage_bucket(payload, "seven_day_oauth_apps") or usage_bucket(payload, "seven_day") session = usage_bucket(payload, "five_hour") raw = [session.get("utilization") if session else None, weekly.get("utilization") if weekly else None] + # One payload speaks one convention, so the scoped entries settle the scale + # alongside the buckets rather than assuming their own. + entries = payload.get("limits") + if isinstance(entries, list): + raw += [entry.get("percent") for entry in entries if isinstance(entry, dict)] percent_scale = any(parse_utilization(v) >= 1 for v in raw) limits = [] @@ -694,6 +757,7 @@ def probe_limits(access_token: str) -> dict[str, Any]: percent = normalize_utilization(weekly.get("utilization"), percent_scale) if percent >= 0: limits.append({"label": "Weekly (7-day)", "percent": percent, "resetsAt": normalize_reset_at(weekly.get("resets_at"))}) + limits.extend(scoped_limits(payload, percent_scale)) if not limits: return {"ok": False, "helpText": "Anthropic's usage endpoint returned no limits. Local Claude Code stats are still shown."} diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index d85a9aa6..94835378 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -92,9 +92,13 @@ Panel { return plain === "" ? "Limit" : plain } - function limitWindow(label, percent, resetAt) { + // A collector that already knows which window a limit belongs to says so, + // and that beats reading it back out of the label: a model-scoped limit is + // titled after its model, and a name like "Opus 5 (1M context)" would parse + // as a one-minute window. + function limitWindow(label, percent, resetAt, title) { return { - title: windowTitle(label), + title: String(title || "") !== "" ? String(title) : windowTitle(label), percent: Number(percent), resetAt: String(resetAt || "") } @@ -107,7 +111,7 @@ Panel { for (var i = 0; i < list.length; i++) { var entry = list[i] || {} var percent = Number(entry.percent) - if (percent >= 0) out.push(limitWindow(entry.label, percent, entry.resetsAt)) + if (percent >= 0) out.push(limitWindow(entry.label, percent, entry.resetsAt, entry.title)) } return out } @@ -701,11 +705,16 @@ Panel { Text { id: limitLabel + // A model-scoped window is titled after its model, and those names run + // long enough to reach the percentage, so the title gives way first. text: limitRow.window ? limitRow.window.title : "" color: root.foreground font.family: root.fontFamily font.pixelSize: Style.font.body + elide: Text.ElideRight anchors.left: parent.left + anchors.right: limitValue.left + anchors.rightMargin: Style.spacing.sm anchors.verticalCenter: parent.verticalCenter } diff --git a/test/shell.d/agent-usage-claude-limits-test.sh b/test/shell.d/agent-usage-claude-limits-test.sh new file mode 100644 index 00000000..61f4e014 --- /dev/null +++ b/test/shell.d/agent-usage-claude-limits-test.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +require_command jq +require_command python3 + +# probe_limits reaches Anthropic, so the reader that interprets its answer is +# exercised on its own: the collector loads as a module, and a recorded payload +# stands in for the response. +read_limits() { + COLLECTOR="$ROOT/bin/omarchy-agent-usage-claude" PAYLOAD="$1" python3 - <<'PY' +import importlib.machinery, importlib.util, io, json, os + +loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"]) +spec = importlib.util.spec_from_loader(loader.name, loader) +collector = importlib.util.module_from_spec(spec) +loader.exec_module(collector) + +collector.urllib.request.urlopen = lambda request, timeout=None: io.BytesIO(os.environ["PAYLOAD"].encode()) + +print(json.dumps(collector.probe_limits("token"))) +PY +} + +# The two flat buckets, then every scoped shape that matters: a model's weekly +# window, a second window for that same model, a model that names only an id, +# and — dropped — a repeat of a window already read, a blank name, and a +# percent that will not parse. +limits=$(read_limits '{ + "five_hour": { "utilization": 78.0 }, + "seven_day": { "utilization": 12.0 }, + "seven_day_opus": null, + "limits": [ + { "kind": "session", "percent": 78, "scope": null }, + { "kind": "weekly_all", "percent": 12, "scope": null }, + { "kind": "weekly_scoped", "percent": 17, "resets_at": "2026-08-15T03:00:00+00:00", + "scope": { "model": { "id": "claude-fable-5", "display_name": "Fable" }, "surface": null } }, + { "kind": "weekly_scoped", "percent": 99, "scope": { "model": { "display_name": "Fable" } } }, + { "kind": "five_hour_scoped", "percent": 95, "scope": { "model": { "display_name": "Fable" } } }, + { "kind": "weekly_scoped", "percent": 42, "scope": { "model": { "id": "claude-opus-5", "display_name": null } } }, + { "kind": "weekly_scoped", "percent": 5, "scope": { "model": { "display_name": " " } } }, + { "kind": "weekly_scoped", "percent": "unknown", "scope": { "model": { "display_name": "Opus" } } } + ] +}') + +expected='[{"label":"Session (5-hour)","percent":0.78,"resetsAt":""},{"label":"Weekly (7-day)","percent":0.12,"resetsAt":""},{"label":"Fable weekly","title":"Fable weekly","percent":0.17,"resetsAt":"2026-08-15T03:00:00+00:00"},{"label":"Fable session","title":"Fable session","percent":0.95,"resetsAt":""},{"label":"claude-opus-5 weekly","title":"claude-opus-5 weekly","percent":0.42,"resetsAt":""}]' +[[ $(jq -c '.limits' <<<"$limits") == "$expected" ]] || + fail "Claude collector reads every model-scoped window once and drops unusable entries" "$limits" +pass "Claude collector reads every model-scoped window once and drops unusable entries" + +# A payload that speaks fractions says so in its buckets, and the scoped +# entries are read on the same scale rather than assuming percentages. +fractions=$(read_limits '{ + "five_hour": { "utilization": 0.78 }, + "limits": [ + { "kind": "session", "percent": 0.78, "scope": null }, + { "kind": "weekly_scoped", "percent": 0.42, "scope": { "model": { "display_name": "Fable" } } } + ] +}') + +[[ $(jq -c '[.limits[].percent]' <<<"$fractions") == "[0.78,0.42]" ]] || + fail "Claude collector reads scoped percentages on the payload's own scale" "$fractions" +pass "Claude collector reads scoped percentages on the payload's own scale" + +# An account with no model-scoped allowance, and an endpoint that never grew +# the array, both keep the session and weekly windows they always had. +for payload in '{"five_hour":{"utilization":78.0},"limits":[{"kind":"session","percent":78,"scope":null}]}' \ + '{"five_hour":{"utilization":78.0},"seven_day":{"utilization":12.0}}'; do + [[ $(jq -c '[.limits[].label]' <<<"$(read_limits "$payload")") != *" weekly"* ]] || + fail "Claude collector adds no limit when the payload scopes none" "$payload" +done +pass "Claude collector adds no limit when the payload scopes none" + +# The panel reads a window out of a label, and that guess cannot survive a +# model name — "Opus 5 (1M context)" parses as a one-minute window. A collector +# that states the title outright is taken at its word. +run_node_test <<'JS' +const fs = require('fs') +const source = fs.readFileSync(root + '/shell/plugins/agents/Panel.qml', 'utf8') +const start = source.indexOf('function windowIsLong') +const end = source.indexOf('// The window that decides') +assert(start > 0 && end > start, 'agents panel exposes its limit-window helpers') +eval(source.slice(start, end)) + +assertDeepEqual( + limitWindows({ limits: [ + { label: 'Session (5-hour)', percent: 0.78, resetsAt: '' }, + { label: 'Opus 5 (1M context) weekly', title: 'Opus 5 (1M context) weekly', percent: 0.42, resetsAt: '' } + ] }), + [ + { title: 'Session', percent: 0.78, resetAt: '' }, + { title: 'Opus 5 (1M context) weekly', percent: 0.42, resetAt: '' } + ], + 'agents panel titles a limit off the collector when it states one' +) + +assertDeepEqual( + limitWindows({ limits: [{ label: 'Weekly (7-day)', percent: 0.12, resetsAt: '' }] }), + [{ title: 'Weekly', percent: 0.12, resetAt: '' }], + 'agents panel still reads a window out of a label that carries no title' +) +JS