From 4758ce202c3abbc2845316ec9f9168263a864149 Mon Sep 17 00:00:00 2001 From: oxo <31625368+okisooo@users.noreply.github.com> Date: Mon, 2 Nov 2026 03:53:43 +0900 Subject: [PATCH 1/2] feat: show waiting usage-limit resets for Codex and Claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex reports rate_limit_reset_credits.available_count in every /wham/usage reply; the soonest expiry comes from /wham/rate-limit-reset-credits, read at most every 15 minutes and only while resets exist. Claude's usage endpoint adds the cedar_ember (granted resets) and juniper_tide (weekly 5-hour reset) blocks when asked with ?cedar_ember=1, as Claude Code's usage menu does; the plain URL returns them as null. Both become one "Resets" row ("1 available · until Dec 1") that also appears among the closed section's meters and never becomes the balance headline. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++++ docs/providers.md | 2 + fleet.py | 106 +++++++++++++++++++++++++++++++++++++- panel_model.py | 6 ++- tests/test_fleet.py | 58 +++++++++++++++++++++ tests/test_panel_model.py | 13 +++++ 6 files changed, 194 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fdd1be..350e205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # changelog +## unreleased - usage-limit resets + +- Codex and Claude now show a **Resets** row when a free usage-limit reset is + waiting, with how many and when the soonest one expires ("1 available · + until Dec 1"). It sits under the provider's meters without opening the + section. Codex reports the count in every usage reply; the expiry is read + from its reset-credit list at most every 15 minutes, only while resets + exist. Claude's resets come from the block its usage endpoint adds when + asked with `cedar_ember=1`, the same request Claude Code's own usage menu + makes. Fleet only reads them; redeem a reset in Codex or Claude Code. + ## unreleased - backup API organization - added separate main and backup Claude API credentials and spending rows in diff --git a/docs/providers.md b/docs/providers.md index 66ede7a..8291df7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -4,6 +4,8 @@ Fleet shows two kinds of provider: - **local clients** — Claude Code, Codex, Grok, Antigravity. Detected automatically from the CLI/app state already on the machine. Nothing to configure. + Codex and Claude also show a **Resets** row while a free usage-limit reset + is waiting; redeem it in Codex (`/usage`) or Claude Code. - **public API keys** — an environment variable, or a Claude key saved through Settings. Each adds its own row to the flyout. diff --git a/fleet.py b/fleet.py index 6f915ef..39aa31a 100644 --- a/fleet.py +++ b/fleet.py @@ -379,7 +379,9 @@ def api_claude(force=False): return _cached_or(out, "claude") hdrs = {"anthropic-beta": "oauth-2025-04-20", "User-Agent": FLEET_UA, "Content-Type": "application/json"} - st, hd, j = _jget("https://api.anthropic.com/api/oauth/usage", tok, hdrs) + # cedar_ember=1 adds the usage-limit resets block, as Claude Code's own + # usage menu asks; the plain URL leaves it null. + st, hd, j = _jget(CLAUDE_USAGE_URL, tok, hdrs) if st == 401: # Revoked or expired early: not re-sent until Claude Code writes a new one. _mark_token_dead("claude", tok) @@ -415,6 +417,9 @@ def api_claude(force=False): ex = j.get("extra_usage") or {} if ex.get("is_enabled"): out["metrics"].append(M("Extra $", None, None, f"${ex.get('used_credits',0)/100:.2f}")) + metric = _claude_resets_metric(j) + if metric: + out["metrics"].append(metric) _save_last("claude", out) return out @@ -451,6 +456,7 @@ def _set_cooldown(agent, secs=300): # Claude Code renews an access token in the last five minutes before it expires; # asking any earlier would only read usage with the old token. CLAUDE_REFRESH_MARGIN = 240 +CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage?cedar_ember=1" CLAUDE_CLI_TIMEOUT = 90 # Lands in tray.log: renewal outcomes were invisible when Claude went stale. _RUNTIME = logging.getLogger("fleet.runtime") @@ -646,6 +652,101 @@ def _codex_credit_metric(balance): return M("Credits", None, None, f"${balance}") +def _count(value): + """A non-negative whole count, or 0 for anything else.""" + if isinstance(value, bool): + return 0 + try: + return max(0, int(value)) + except (TypeError, ValueError): + return 0 + + +def _day(ts): + """'Nov 30' in local time, English month names whatever the Windows locale.""" + d = datetime.fromtimestamp(ts) + return f"{_MONTHS[d.month - 1].title()} {d.day}" + + +def _resets_metric(count, expires=None, extra=()): + """One "Resets" row: how many usage-limit resets are waiting, and until when.""" + parts = [] + if count: + parts.append(f"{count} available") + if expires: + parts.append(f"until {_day(expires)}") + parts.extend(extra) + return M("Resets", None, None, " · ".join(parts)) if parts else None + + +# Codex reports its reset count in every usage reply; the expiry needs a second +# request, so it is read only while resets exist and at most every 15 minutes. +_CODEX_RESET_DETAIL = {"count": None, "at": 0.0, "expires": None} +CODEX_RESET_DETAIL_SECS = 900 + + +def _codex_reset_expiry(count, tok, hdr): + cache = _CODEX_RESET_DETAIL + if cache["count"] == count and time.time() - cache["at"] < CODEX_RESET_DETAIL_SECS: + return cache["expires"] + expires = None + st, _hd, j = _jget("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", tok, hdr) + if st == 200 and isinstance(j, dict): + dates = [_epoch(c.get("expires_at")) for c in (j.get("credits") or []) + if isinstance(c, dict) and c.get("status", "available") == "available" + and c.get("is_supported_by_plan", True) is not False] + dates = [d for d in dates if d and d > time.time()] + expires = min(dates) if dates else None + cache.update(count=count, at=time.time(), expires=expires) + return expires + + +def _codex_resets_metric(summary, tok=None, hdr=None): + """Codex's free usage-limit resets ("You have 1 usage limit reset available").""" + count = _count((summary or {}).get("available_count")) if isinstance(summary, dict) else 0 + if not count: + return None + expires = None + if tok: + try: + expires = _codex_reset_expiry(count, tok, hdr or {}) + except Exception: + expires = None + return _resets_metric(count, expires) + + +def _claude_resets_metric(j, now=None): + """Claude's usage-limit resets, from the blocks /api/oauth/usage?cedar_ember=1 adds. + + cedar_ember: granted resets, each with resets_left and an end date. + juniper_tide: the once-a-week 5-hour reset, when Anthropic offers it. + """ + now = time.time() if now is None else now + count, ends = 0, [] + ce = j.get("cedar_ember") if isinstance(j, dict) else None + if isinstance(ce, dict): + for grant in ce.get("grants") or []: + if not isinstance(grant, dict) or grant.get("paused"): + continue + end = _epoch(grant.get("ends_at")) + if end and end <= now: + continue + left = _count(grant.get("resets_left")) + count += left + if left and end: + ends.append(end) + extra = [] + jt = j.get("juniper_tide") if isinstance(j, dict) else None + if isinstance(jt, dict) and jt.get("eligible"): + if jt.get("available"): + extra.append("5-hour ready") + else: + back = _epoch(jt.get("next_available_at")) + if back and back > now: + extra.append(f"5-hour back {_day(back)}") + return _resets_metric(count, min(ends) if ends else None, extra) + + def api_codex(): out = dict(agent="Codex", plan="", metrics=[], err="", known=False) tok = acct = None @@ -721,6 +822,9 @@ def api_codex(): metric = _codex_credit_metric(cr.get("balance")) if metric: out["metrics"].append(metric) + metric = _codex_resets_metric(j.get("rate_limit_reset_credits"), tok, hdr) + if metric: + out["metrics"].append(metric) _save_last("codex", out) return out diff --git a/panel_model.py b/panel_model.py index 69469b0..3f89029 100644 --- a/panel_model.py +++ b/panel_model.py @@ -245,6 +245,7 @@ def _rings(name, groups): # Providers whose tokens can be priced from local logs: panel id -> ledger provider. LEDGER = {"claude": "claude", "codex": "codex"} PLAIN = ("5-hour limit", "Weekly limit", "Daily limit", "Monthly limit") +RESETS = "Resets" def _meter_label(label): @@ -278,6 +279,8 @@ def _meters(groups): fullest = max(rest, key=lambda r: r["pct"]) if fullest["pct"] > max(r["pct"] for r in chosen): chosen.append(fullest) + # A waiting usage-limit reset is worth seeing without opening the section. + chosen += [r for g in groups for r in g["rows"] if r["label"] == RESETS and r.get("pct") is None] return [dict(r, short=_meter_label(r["label"])) for r in chosen] @@ -401,7 +404,8 @@ def _caption(rows, rings, groups): def _balance(rows): - money = [r for r in rows if r.get("pct") is None and r.get("value") not in (None, "", "—")] + money = [r for r in rows if r.get("pct") is None and r["label"] != RESETS + and r.get("value") not in (None, "", "—")] if not money: return None r = money[0] diff --git a/tests/test_fleet.py b/tests/test_fleet.py index d4359d8..8f75455 100644 --- a/tests/test_fleet.py +++ b/tests/test_fleet.py @@ -7,6 +7,7 @@ import threading import time import unittest +from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace from unittest import mock @@ -1285,6 +1286,63 @@ def test_zero_codex_credits_are_not_a_metric(self): ) +class UsageResetTests(unittest.TestCase): + """Free usage-limit resets show as one "Resets" row for Codex and Claude.""" + + def setUp(self): + fleet._CODEX_RESET_DETAIL.update(count=None, at=0.0, expires=None) + + def test_codex_reset_count_and_expiry(self): + end = time.time() + 20 * 86400 + detail = {"credits": [ + {"status": "available", "is_supported_by_plan": True, "expires_at": end}, + {"status": "redeemed", "expires_at": end - 86400}, + ], "available_count": 1} + with mock.patch.object(fleet, "_jget", return_value=(200, {}, detail)) as get: + metric = fleet._codex_resets_metric({"available_count": 1, "applicable_available_count": 0}, "tok", {}) + again = fleet._codex_resets_metric({"available_count": 1}, "tok", {}) + self.assertEqual(metric, fleet.M("Resets", None, None, f"1 available · until {fleet._day(end)}")) + self.assertEqual(again, metric) + self.assertEqual(get.call_count, 1) # the detail is cached between polls + + def test_codex_without_resets_has_no_row_and_no_extra_request(self): + with mock.patch.object(fleet, "_jget") as get: + self.assertIsNone(fleet._codex_resets_metric({"available_count": 0}, "tok", {})) + self.assertIsNone(fleet._codex_resets_metric(None, "tok", {})) + get.assert_not_called() + + def test_codex_count_survives_a_failed_detail_request(self): + with mock.patch.object(fleet, "_jget", return_value=(500, {}, None)): + self.assertEqual(fleet._codex_resets_metric({"available_count": 2}, "tok", {}), + fleet.M("Resets", None, None, "2 available")) + + def test_claude_grants_add_up_and_skip_paused_or_ended(self): + now = 1_800_000_000 + iso = lambda ts: datetime.fromtimestamp(ts, timezone.utc).isoformat() + payload = {"cedar_ember": {"eligible": True, "grants": [ + {"id": "a", "resets_left": 1, "ends_at": iso(now + 9 * 86400)}, + {"id": "b", "resets_left": 2, "ends_at": iso(now + 3 * 86400)}, + {"id": "c", "resets_left": 4, "paused": True}, + {"id": "d", "resets_left": 5, "ends_at": iso(now - 60)}, + ]}} + metric = fleet._claude_resets_metric(payload, now=now) + self.assertEqual(metric["extra"], f"3 available · until {fleet._day(now + 3 * 86400)}") + + def test_claude_without_resets_has_no_row(self): + self.assertIsNone(fleet._claude_resets_metric({"cedar_ember": None, "juniper_tide": None})) + self.assertIsNone(fleet._claude_resets_metric( + {"cedar_ember": {"eligible": False, "ineligible_reason": "surface", "grants": []}})) + + def test_claude_weekly_five_hour_reset(self): + now = 1_800_000_000 + self.assertEqual(fleet._claude_resets_metric( + {"juniper_tide": {"eligible": True, "available": True}}, now=now)["extra"], "5-hour ready") + back = datetime.fromtimestamp(now + 2 * 86400, timezone.utc).isoformat() + self.assertEqual(fleet._claude_resets_metric( + {"juniper_tide": {"eligible": True, "available": False, "next_available_at": back}}, now=now)["extra"], + f"5-hour back {fleet._day(now + 2 * 86400)}") + + class PanelDismissalTests(unittest.TestCase): def test_click_inside_panel_is_not_outside(self): self.assertFalse( diff --git a/tests/test_panel_model.py b/tests/test_panel_model.py index fcd8229..f12fde7 100644 --- a/tests/test_panel_model.py +++ b/tests/test_panel_model.py @@ -15,6 +15,19 @@ def build(self, data, ts=1000.0): json.dumps(model) # the native panel reads it as plain JSON return model + def test_a_waiting_reset_shows_in_the_closed_section_and_the_table(self): + model = self.build([{ + "agent": "Codex", "plan": "pro", "err": "", "metrics": [ + _metric("Weekly", 24.0, 1000.0 + 86400), _metric("Resets", None, None, "1 available · until Nov 30"), + ], + }]) + provider = model["providers"][0] + self.assertEqual([(m["short"], m["value"]) for m in provider["meters"]], + [("Weekly", "24%"), ("Resets", "1 available · until Nov 30")]) + self.assertIn("Resets", [r["label"] for g in provider["groups"] for r in g["rows"]]) + self.assertEqual(provider["kind"], "rings") + self.assertIsNone(provider["balance"]) + def test_antigravity_quotas_are_grouped_by_model_family(self): model = self.build([{ "agent": "Antigrav", "plan": "Google AI Pro", "err": "", "metrics": [ From 8840a04b37a1659d1a9bc640663a91d0b3f2dc79 Mon Sep 17 00:00:00 2001 From: oxo <31625368+okisooo@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:00:58 +0900 Subject: [PATCH 2/2] fix: read Claude API spend on the first UTC day of the month The cost report holds only finished UTC days. Asking from 00:00 on the 1st (today) returns http 400 "Invalid date range: ending date must be after starting date", which turned both Claude API rows into "Http 400" until midnight UTC. On the 1st, ask from yesterday and skip that bucket when summing the month. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ apikeys.py | 10 ++++++++-- tests/test_anthropic_api.py | 10 ++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350e205..fc692ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ exist. Claude's resets come from the block its usage endpoint adds when asked with `cedar_ember=1`, the same request Claude Code's own usage menu makes. Fleet only reads them; redeem a reset in Codex or Claude Code. +- Fixed both Claude API rows showing "Http 400" on the first UTC day of every + month. Anthropic's cost report holds only finished UTC days and rejects a + range that starts today, so on the 1st Fleet now asks from yesterday and + leaves that day out of the month's total. ## unreleased - backup API organization diff --git a/apikeys.py b/apikeys.py index 9a04443..ba23c67 100644 --- a/apikeys.py +++ b/apikeys.py @@ -610,7 +610,11 @@ def _anthropic_cost(spec, key, http, M, timeout): metrics=[], err="", compact=False) now = datetime.fromtimestamp(time.time(), timezone.utc) today = now.replace(hour=0, minute=0, second=0, microsecond=0) - query = {"starting_at": today.replace(day=1).strftime("%Y-%m-%dT%H:%M:%SZ"), + month = today.replace(day=1) + # The report holds only finished UTC days and rejects a range with none, so + # on the 1st it is asked from yesterday; that day stays out of the month. + start = min(month, today - timedelta(days=1)) + query = {"starting_at": start.strftime("%Y-%m-%dT%H:%M:%SZ"), "bucket_width": "1d", "limit": "31"} headers = {"x-api-key": key, "anthropic-version": "2023-06-01", "Accept": "application/json"} month_cents = today_cents = Decimal(0) @@ -636,8 +640,10 @@ def _anthropic_cost(spec, key, http, M, timeout): results = bucket["results"] if day.tzinfo is None or not isinstance(results, list): raise ValueError() - if not today.replace(day=1) <= day < today + timedelta(days=1): + if not start <= day < today + timedelta(days=1): raise ValueError() + if day < month: + continue for result in results: if result.get("currency", "USD").upper() != "USD": raise ValueError() diff --git a/tests/test_anthropic_api.py b/tests/test_anthropic_api.py index b26645f..12be7c4 100644 --- a/tests/test_anthropic_api.py +++ b/tests/test_anthropic_api.py @@ -66,6 +66,16 @@ def test_sums_all_result_rows_and_converts_decimal_cents(self): self.assertNotIn("Authorization", calls[0][2]) self.assertIsNone(calls[0][3]) + def test_first_of_the_month_asks_from_yesterday_and_keeps_it_out(self): + # The report holds only finished UTC days: a range starting today is + # rejected with "ending date must be after starting date" (http 400). + first = datetime(2026, 11, 1, 18, 59, tzinfo=timezone.utc).timestamp() + with mock.patch.object(apikeys.time, "time", return_value=first): + out, calls = self.collect([{"data": [bucket(31, "1900")]}]) + self.assertEqual(out["err"], "") + self.assertEqual(parse_qs(urlparse(calls[0][1]).query)["starting_at"], ["2026-10-31T00:00:00Z"]) + self.assertEqual([m["extra"] for m in out["metrics"]], ["$0.00", "$0.00"]) + def test_follows_pagination_before_publishing_totals(self): out, calls = self.collect([ {"data": [bucket(1, "100")], "has_more": True, "next_page": "next one"},