Skip to content

Use Anthropic's real usage API for the weekly bar (with local estimate as fallback)#149

Open
changsunglim wants to merge 19 commits into
phuryn:mainfrom
changsunglim:feat/official-usage-api
Open

Use Anthropic's real usage API for the weekly bar (with local estimate as fallback)#149
changsunglim wants to merge 19 commits into
phuryn:mainfrom
changsunglim:feat/official-usage-api

Conversation

@changsunglim

Copy link
Copy Markdown

What

The live weekly/session bars were computed from local JSONL using cost-weighted token heuristics. Those drift badly from what Claude actually reports — on my account the weekly bar showed 100% while Settings → Usage showed 58%.

Anthropic exposes the ground-truth numbers at GET https://api.anthropic.com/api/oauth/usage — the same source claude /usage reads. This PR reads that endpoint directly and uses it as the primary source, keeping the existing local estimate as an offline fallback (no token / expired token / network error → old behavior, including the manual sync controls).

Changes

limits.py

  • fetch_official_usage() — 60s process-cached, lock-guarded, serves the last-good payload if a refresh transiently fails. Parses five_hour / seven_day / seven_day_sonnet utilization + reset times, with a fallback to the typed limits[] array if the schema drifts.
  • get_limits() — official seven_day percent + reset_at override the estimate when available; the local value is retained as local_percent. weekly_all.source flags official_api / official_api_stale / local_estimate.
  • _ssl_context() — python.org / pyenv builds don't trust the macOS keychain CA store, so every HTTPS call was silently failing with CERTIFICATE_VERIFY_FAILED (this is why API-based plan detection never worked). Uses certifi's bundle, falls back to the stdlib default. Verification is never disabled.
  • Plan detection now reads organization.rate_limit_tier, so default_claude_max_5x resolves correctly without a manual override.

dashboard.py

  • Official mode: solid fill at the real percent, a "Live from Anthropic" badge (5h + Sonnet-7d), and the manual Sync/Edit/Clear controls + drift disclaimer are hidden (kept only as the offline fallback UI).
  • Bar fill recolored to a neutral purple (the old green collided with the haiku swatch and read as "haiku usage"). Local activity split sorted by token volume so a small background model never shows alone.
  • Cache-Control: no-store on the HTML route — the JS is inlined, so the browser was serving stale pages after updates.

DB concurrency (scanner.py + dashboard.py)

  • The threaded server re-scans (writes) on every poll; with the default rollback journal, overlapping read/write raised database is locked (swallowed by a bare except, so the page silently stopped updating). Enable WAL + busy_timeout, serialize incremental scans behind a process lock, and make /api/rescan hold it blocking. Bumped the listen backlog so a page's concurrent fetches don't get connection-refused.

Compatibility / disclosure

  • /api/oauth/usage is an undocumented OAuth endpoint and may change; everything degrades gracefully to the existing local estimate when it's unavailable.
  • No credentials leave the machine — the Claude Code OAuth token is read from the local keychain and sent only to api.anthropic.com, exactly as claude itself does.

Verification

  • Live: /api/limitssource: official_api, matches claude /usage exactly (weekly %, reset time, 5h, Sonnet-7d).
  • 12× concurrent /api/data burst → no lock errors, journal_mode=wal.
  • Test suite: 95/96 (the one failure is a pre-existing hourly-timezone test, unrelated to this change).

changsunglim and others added 19 commits May 22, 2026 16:08
…view

- limits.py: plan auto-detect via keychain OAuth + 24h cache; per-plan
  session-5h + weekly budgets for pro/max_5x/max_20x; rolling 7d split
  by opus/sonnet/haiku
- Dashboard top banner: weekly stacked bar (Opus/Sonnet/Haiku) with
  per-model breakdown line; ThreadingHTTPServer so /api/data and
  /api/limits don't block each other
- Session Health card: heuristic "start new session" warning when
  context >150k, cache-hit <40%, avg >60k tok/turn, or age >3h
- Token Efficiency: weighted grader (Cache Hit 40% + Reuse Ratio 25% +
  Cost Efficiency 20% + Output Discipline 15%) with info tooltip; replaces
  "By Model" pie; respects date-range filter
- Per-message live view: /api/session/<id> groups turns by user prompt;
  click row → polls every 5s; filters skill/system-reminder noise so list
  shows only real human turns
- Session titles from first JSONL user prompt (replaces opaque project IDs)
- Local TZ auto-detect via datetime().astimezone().utcoffset()
- Hourly chart: average divides by calendar days in range (not active-only
  days); SQL pre-shifts so JS no longer double-shifts
- Auto-rescan on every /api/data fetch
- Bug fix: cutoff → start/end ReferenceError that killed all charts
- Fork banner with link to upstream
- Fork features section: limits widget, plan budget table, session
  health heuristic, efficiency grader formula, live view, new endpoints
- Add limits.py to files table
Anthropic's weekly limit is anchored per-user to the first message of
the cycle and resets exactly 7 days later. The old rolling-7d sum kept
counting tokens from before Anthropic's reset, so the bar disagreed
with Settings → Usage immediately after a fresh reset.

- Persist anchor at ~/.claude/usage-weekly-anchor.json
- Auto-advance: when anchor + 7d <= now, jump to the first turn in
  the new window (or now if no turns yet)
- Manual override via POST /api/weekly/sync-reset, exposed as a
  "Sync reset to now" button on the weekly card
- Replace "7d rolling" footer with live countdown + anchor source
- Tests covering first-run, manual persistence, auto-advance,
  reset_at = anchor + 7d
Extends the anchored-reset mechanism to the 5h session window and
adds a manual edit form so users can correct anchor + percent when
they miss the sync moment.

- New SESSION_ANCHOR_PATH at ~/.claude/usage-session-anchor.json
- set_session_anchor() / clear_session_anchor() helpers; expired
  manual session anchors are cleared on read
- Both weekly and session anchor files now persist baseline_used,
  so compute_weekly / compute_session_5h return
  total = baseline + sum(turns since anchor). Auto-advance resets
  baseline to 0.
- POST /api/weekly/sync-reset and /api/session/sync-reset accept
  JSON body { anchor_at, percent | baseline_used, clear }; percent
  is converted to baseline tokens against the detected plan cap.
- UI: weekly + new session limit cards each have Sync / Edit /
  (session: Clear) buttons. Edit reveals datetime-local + percent
  inputs.
- Tests cover baseline_used round-trip.
The edit form retained the previously-saved anchor on reopen, so a
manual percent entered with a stale anchor (e.g. midnight) got added
to all turns since that anchor — producing a percent that did not
match what the user typed.

- On every Edit open, reset anchor input to current time and clear
  percent input, so typing '7%' makes the bar show 7% immediately
  (anchor = now → no tokens accumulated since)
- Enter saves, Escape cancels in both inputs
- Auto-focus percent field on open
- Clarify label: '% shown in Claude Settings right NOW'
…future-anchor bug

- Rename 'Session · 5h' → 'Current Session · 5h'
- Session Edit form now shows 'time remaining to reset' input (h:mm)
  instead of a raw datetime field. Anchor is auto-calculated as
  now - (5h - remaining) and shown read-only. Enter=save, Esc=cancel.
- Server-side: clamp anchor_at <= now on save. A future anchor
  (caused by clock skew or sub-second delay) made delta=0 permanently
  so the bar never advanced past baseline.
- Add disclaimer note under session bar: auto-count uses a
  community-derived cap estimate; use Edit to sync with claude /usage.
…0m–7d)

The 5h session window relied on Anthropic's undocumented cost-weighted
formula and could not be reproduced from local JSONL. Removed the entire
widget end-to-end (UI, API route, compute fn, anchor helpers, plan budget
field) rather than keep inaccurate state visible.

Replaced with a single user-configurable usage window:
  - Presets: 30m, 1h, 3h, 6h, 12h, 1d, 3d, 7d
  - Custom: 30–10080 minutes
  - Cap scales proportionally from the 7d plan budget
  - Edit form persists window_seconds alongside anchor/baseline/save_at
  - Title and anchor source line show active window length

Also wires the save_at fix from the prior commit through window changes:
delta accumulates from save_at (or anchor_at if absent) so manually
setting "32%" no longer double-counts turns between anchor and save.

Backend:
  - PLAN_BUDGETS drops session_5h_tokens
  - SESSION_ANCHOR_PATH, SESSION_WINDOW removed
  - compute_session_5h, set/clear/load/save_session_anchor removed
  - clear_weekly_anchor added (replaces session-only clear path)
  - get_limits drops session_5h block, returns full_cap + window_seconds
  - MIN/MAX_WINDOW_SECONDS bounds enforced in API + persistence
Replaces the weekly-card window picker (reverted) with a dashboard-wide
custom range so token efficiency, stats, charts, and tables all reflect
the chosen period — not just the weekly bar.

UI:
  - New "Custom…" button in the global range row
  - Input panel: number + unit (minutes/hours/days), Apply button
  - Min 1, max 525,600 minutes (1 year)
  - Panel restored from URL on first load

Filter semantics:
  - Range encoded as `custom:<minutes>` in URL/state
  - getRangeBounds returns startMs/endMs for minute precision
  - Sessions filtered by last_iso (new field, full UTC ISO) when startMs set
  - Sub-day ranges (<24h) recompute by-model totals from sessions, since
    daily_by_model is day-bucketed and would otherwise collapse to today's
    full-day total. Caveat noted in UI: precision = session-level.
  - Daily/hourly charts still day-bucketed (no per-turn data in client);
    chart titles use rangeLabel(selectedRange) for the custom label

Reverted from prior commit:
  - lc-weekly-window-preset / lc-weekly-window-custom UI
  - window_seconds field in weekly anchor JSON
  - cap scaling in get_limits
  - MIN/MAX_WINDOW_SECONDS, WEEKLY_WINDOW_SECONDS constants
  - window_seconds parameter on set_weekly_anchor + POST handler

Kept from prior commit:
  - Session 5h widget removal (still gone end-to-end)
  - save_at fix preventing baseline double-count
Brings in: anchored weekly window with sync/edit, session 5h widget
removal, save_at fix (no baseline double-count), custom global range
filter (minutes/hours/days).
…locks pass

set_weekly_anchor no longer auto-sets save_at=real_now when baseline_used>0
(broke tests that exercise compute with a fake 'now' parameter — save_at
fell after fake_now+1h so subsequent turns were excluded from delta).

API handler now explicitly passes save_at=now when baseline>0, preserving
the no-double-count behavior for real users.
Weekly all-models bar and per-model breakdown summed every model's
tokens 1:1, undercounting Opus-heavy weeks and forcing recurring manual
edits. Apply per-model weights (Opus ~5x, Sonnet 1x, Haiku ~0.25x)
anchored to the Sonnet-equivalent plan caps; weight cache_creation 1.25x
input. Add accuracy disclaimer under the weekly card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
compute_efficiency_warning's turn query didn't SELECT model, so the
per-model _billable() lookup raised KeyError ("No item with that key"),
crashing /api/limits and blanking the whole limits widget. Add model
to the SELECT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KeepAlive-managed launchd jobs restart the server on crash/login; the
auto-opened browser tab would spam on every restart. --no-browser runs
headless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The weekly bar was computed from local JSONL with cost-weighted token
heuristics, drifting badly from Claude's actual numbers (showed 100% when
Settings → Usage showed 58%). Anthropic exposes the ground-truth figures
at GET /api/oauth/usage — the same source `claude /usage` reads — so use
it directly and keep the local estimate only as an offline fallback.

limits.py:
- fetch_official_usage(): 60s process-cached, lock-guarded, serves the
  last good payload if a refresh transiently fails. Parses five_hour /
  seven_day / seven_day_sonnet utilization + reset times, with a fallback
  to the typed `limits[]` array on schema drift.
- get_limits(): official seven_day percent + reset_at override the local
  estimate when available; local value retained as `local_percent`.
  weekly_all.source flags official_api / official_api_stale / local_estimate.
- _ssl_context(): python.org/pyenv builds don't trust the macOS keychain
  CA store, so every HTTPS call was silently dying with
  CERTIFICATE_VERIFY_FAILED — which is why plan auto-detect never worked.
  Use certifi's bundle (fall back to stdlib default); verification stays on.
- plan detection now reads organization.rate_limit_tier (resolves
  default_claude_max_5x), so the hardcoded plan override is unnecessary.

dashboard.py:
- Official mode: solid fill at the real percent, "Live from Anthropic"
  badge with 5h + Sonnet-7d, hides the now-obsolete Sync/Edit/Clear manual
  controls and drift disclaimer. Manual sync survives as offline fallback.
- Bar fill uses a neutral purple (was green, which collided with haiku's
  swatch color and read as "haiku usage").
- Local activity split sorted by token volume desc and relabeled, so a
  small background model (haiku from memory/summaries) never shows alone
  or on top; cap-relative percents dropped in official mode.
- no-store on the HTML response: JS is inlined, so the browser was serving
  stale pages after every code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ocked"

The dashboard is a ThreadingHTTPServer that re-scans (writes) the DB on
every /api/data and /api/session poll. With the default rollback journal,
overlapping reads/writes raised sqlite3.OperationalError: database is
locked, which was swallowed by a bare except — so the page loaded but
silently stopped updating.

- scanner.get_db: enable WAL (readers run during a write) + busy_timeout
  (contenders wait instead of erroring) + connect timeout.
- dashboard + limits read connections: busy_timeout so reads queue rather
  than throw under write contention.
- _safe_rescan(): a process-wide lock serializes incremental scans;
  concurrent pollers skip a redundant scan instead of colliding.
- /api/rescan (delete + full rebuild) holds the lock blocking so it can
  never race an in-flight scan or reader.
- Bump ThreadingHTTPServer listen backlog (default 5) to 128 so a burst of
  concurrent fetches on page load doesn't get connection-refused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`HTML_TEMPLATE` is a Python raw string (r\"\"\"). `\\'` in a raw string
emits two literal backslashes, so the single-quoted JS string
`'...Anthropic\\'s...'` was parsed as: escaped-backslash + quote-end +
`s billed...` → SyntaxError → entire script block dead → page stuck on
Loading...

Fix: single `\'` in the raw string → JS `\'` = escaped apostrophe → valid.

Also adds a background startup scan in `serve()` so the DB is warm
before the first browser request arrives (cold launchd boot no longer
serves an empty dashboard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant