diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fdd1be..fc692ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # 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. +- 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 - added separate main and backup Claude API credentials and spending rows in 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/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_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"}, 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": [