Skip to content

Commit ecb4011

Browse files
committed
Warn at startup when Albert daily quota is likely exhausted
Two complementary signals, surfaced as a single WarningMessage banner when the TUI mounts: - Persistent state: when the server returns a daily-quota 429 (rpd/tpd), write the model name, reason, body excerpt and UTC timestamp to ~/.albert-code/throttle_state.json. On the next launch the file is re-read and shown as a warning, with automatic decay at the next midnight UTC reset. Cleared on a successful call against the same model. - Live estimate: fetch /v1/me/usage on startup, sum prompt_tokens since midnight UTC for the active model, compare to the documented EXP TPD (DOCUMENTED_MODEL_LIMITS). Warn at >= 80%, hard-warn at >= 95%. Best effort: /me/usage only returns the last N events so the estimate is a lower bound. Threading: model.name is now passed alongside model.alias from complete()/complete_streaming() down to _handle_429 so the persisted event records the full model identifier (matches what /v1/models and DOCUMENTED_MODEL_LIMITS expect). New file albert_code/core/llm/quota_state.py with save/load/clear helpers. THROTTLE_STATE_FILE added to global_paths.py.
1 parent 67cadb8 commit ecb4011

5 files changed

Lines changed: 273 additions & 5 deletions

File tree

‎albert_code/cli/textual_ui/app.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ def update_context_progress(stats: AgentStats) -> None:
330330
await self._show_dangerous_directory_warning()
331331
await self._resume_history_from_messages()
332332
await self._check_and_show_whats_new()
333+
self.run_worker(self._check_quota_warning(), exclusive=False)
333334
self._schedule_update_notification()
334335
self.agent_loop.emit_new_session_telemetry()
335336

@@ -1664,6 +1665,70 @@ async def _show_dangerous_directory_warning(self) -> None:
16641665
)
16651666
await self._mount_and_scroll(WarningMessage(warning, show_border=False))
16661667

1668+
async def _check_quota_warning(self) -> None:
1669+
"""Combine persisted 429 (B) and live /me/usage estimation (A)."""
1670+
from albert_code.core.llm.quota import (
1671+
documented_limits_for,
1672+
fetch_albert_usage,
1673+
is_albert_provider,
1674+
sum_prompt_tokens_today,
1675+
)
1676+
from albert_code.core.llm.quota_state import (
1677+
is_event_still_relevant,
1678+
load_terminal_quota_event,
1679+
)
1680+
1681+
active_model = self.config.get_active_model()
1682+
provider = self.config.get_provider_for_model(active_model)
1683+
if not is_albert_provider(provider):
1684+
return
1685+
1686+
warnings: list[str] = []
1687+
1688+
# B - persisted terminal 429
1689+
event = load_terminal_quota_event()
1690+
if event is not None and is_event_still_relevant(event):
1691+
if event.model_name == active_model.name:
1692+
ts = event.recorded_at.strftime("%Y-%m-%d %H:%M UTC")
1693+
warnings.append(
1694+
f"Daily quota was exhausted on `{event.model_name}` "
1695+
f"at {ts}. Resets at midnight UTC. "
1696+
f"Server: {event.body_excerpt}"
1697+
)
1698+
else:
1699+
ts = event.recorded_at.strftime("%H:%M UTC")
1700+
warnings.append(
1701+
f"Earlier daily-quota 429 recorded on "
1702+
f"`{event.model_name}` at {ts} (active model is "
1703+
f"`{active_model.name}`, may be unaffected)."
1704+
)
1705+
1706+
# A - live /me/usage estimation
1707+
usage = await fetch_albert_usage(provider)
1708+
documented = documented_limits_for(active_model.name)
1709+
if usage is not None and documented is not None and documented.exp.tpd:
1710+
used_today = sum_prompt_tokens_today(usage, active_model.name)
1711+
tpd_limit = documented.exp.tpd
1712+
pct = used_today / tpd_limit * 100.0 if tpd_limit > 0 else 0.0
1713+
if pct >= 95.0:
1714+
warnings.append(
1715+
f"TPD usage on `{active_model.name}` is at "
1716+
f"{used_today:,}/{tpd_limit:,} ({pct:.0f}%) for today "
1717+
f"(EXP tier). Likely to 429 on the next call."
1718+
)
1719+
elif pct >= 80.0:
1720+
warnings.append(
1721+
f"TPD usage on `{active_model.name}` is at "
1722+
f"{used_today:,}/{tpd_limit:,} ({pct:.0f}%) for today "
1723+
f"(EXP tier). Approaching the daily ceiling."
1724+
)
1725+
1726+
if not warnings:
1727+
return
1728+
body = "⚠ Quota check\n\n" + "\n\n".join(f"- {line}" for line in warnings)
1729+
body += "\n\nUse `/limits` for the full picture or `/config` to switch model."
1730+
await self._mount_and_scroll(WarningMessage(body, show_border=False))
1731+
16671732
async def _check_and_show_whats_new(self) -> None:
16681733
if self._update_cache_repository is None:
16691734
return

‎albert_code/core/llm/backend/generic.py‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
from albert_code.core.llm.backend.base import APIAdapter, PreparedRequest
2323
from albert_code.core.llm.backend.vertex import VertexAnthropicAdapter
2424
from albert_code.core.llm.exceptions import BackendErrorBuilder, TerminalRateLimitError
25+
from albert_code.core.llm.quota_state import (
26+
clear_terminal_quota_event_for_model,
27+
save_terminal_quota_event,
28+
)
2529
from albert_code.core.llm.message_utils import merge_consecutive_user_messages
2630
from albert_code.core.llm.throttling import get_throttler
2731
from albert_code.core.types import (
@@ -575,7 +579,11 @@ async def complete(
575579

576580
try:
577581
res_data, _ = await self._make_request(
578-
url, req.body, headers, model_alias=model.alias
582+
url,
583+
req.body,
584+
headers,
585+
model_alias=model.alias,
586+
model_name=model.name,
579587
)
580588
chunk = adapter.parse_response(res_data, self._provider)
581589
if chunk.usage is not None:
@@ -586,6 +594,7 @@ async def complete(
586594
else:
587595
throttler.record_request()
588596
throttler.record_success(model_alias=model.alias)
597+
clear_terminal_quota_event_for_model(model.name)
589598
return chunk
590599

591600
except TerminalRateLimitError as e:
@@ -697,7 +706,11 @@ async def complete_streaming(
697706

698707
try:
699708
async for res_data in self._make_streaming_request(
700-
url, req.body, headers, model_alias=model.alias
709+
url,
710+
req.body,
711+
headers,
712+
model_alias=model.alias,
713+
model_name=model.name,
701714
):
702715
chunk = adapter.parse_response(res_data, self._provider)
703716
if chunk.usage is not None:
@@ -725,6 +738,7 @@ async def complete_streaming(
725738
completion_tokens=last_usage.completion_tokens,
726739
)
727740
throttler.record_success(model_alias=model.alias)
741+
clear_terminal_quota_event_for_model(model.name)
728742

729743
except TerminalRateLimitError as e:
730744
raise BackendErrorBuilder.build_terminal_rate_limit(
@@ -772,18 +786,25 @@ async def _make_request(
772786
data: bytes,
773787
headers: dict[str, str],
774788
model_alias: str | None = None,
789+
model_name: str | None = None,
775790
) -> HTTPResponse:
776791
client = self._get_client()
777792
response = await client.post(url, content=data, headers=headers)
778-
await self._handle_429(response, model_alias=model_alias)
793+
await self._handle_429(
794+
response, model_alias=model_alias, model_name=model_name
795+
)
779796
response.raise_for_status()
780797

781798
response_headers = dict(response.headers.items())
782799
response_body = response.json()
783800
return self.HTTPResponse(response_body, response_headers)
784801

785802
async def _handle_429(
786-
self, response: httpx.Response, *, model_alias: str | None = None
803+
self,
804+
response: httpx.Response,
805+
*,
806+
model_alias: str | None = None,
807+
model_name: str | None = None,
787808
) -> None:
788809
"""If the upstream returned 429, log it, sleep Retry-After, then let
789810
async_retry retry. Also records the event on the throttler so it can
@@ -805,6 +826,11 @@ async def _handle_429(
805826
get_throttler(self._provider).record_rate_limit(
806827
model_alias=model_alias
807828
)
829+
save_terminal_quota_event(
830+
model_name=model_name or model_alias or "unknown",
831+
reason="daily-quota",
832+
body_text=body_text,
833+
)
808834
raise TerminalRateLimitError(
809835
status=HTTP_TOO_MANY_REQUESTS,
810836
headers=response.headers,
@@ -847,14 +873,17 @@ async def _make_streaming_request(
847873
data: bytes,
848874
headers: dict[str, str],
849875
model_alias: str | None = None,
876+
model_name: str | None = None,
850877
) -> AsyncGenerator[dict[str, Any]]:
851878
client = self._get_client()
852879
async with client.stream(
853880
method="POST", url=url, content=data, headers=headers
854881
) as response:
855882
if not response.is_success:
856883
await response.aread()
857-
await self._handle_429(response, model_alias=model_alias)
884+
await self._handle_429(
885+
response, model_alias=model_alias, model_name=model_name
886+
)
858887
response.raise_for_status()
859888
async for line in response.aiter_lines():
860889
if line.strip() == "":

‎albert_code/core/llm/quota.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,67 @@ def is_albert_provider(provider: ProviderConfig) -> bool:
6262
return provider.name == ALBERT_PROVIDER_NAME
6363

6464

65+
async def fetch_albert_usage(
66+
provider: ProviderConfig, *, timeout: float = 10.0
67+
) -> list[dict[str, object]] | None:
68+
"""Fetch /v1/me/usage. Returns the list of usage events, or None on failure.
69+
70+
Albert returns a list of recent calls with their token counts; the
71+
payload schema is `{object: "list", data: [...]}`. We just hand back
72+
`data` as a list of dicts and let callers slice what they need.
73+
"""
74+
if _check_albert_preconditions(provider) is not None:
75+
return None
76+
api_key = os.getenv(provider.api_key_env_var) or ""
77+
url = f"{provider.api_base.rstrip('/')}/me/usage"
78+
headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json"}
79+
try:
80+
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client:
81+
response = await client.get(url, headers=headers)
82+
response.raise_for_status()
83+
payload = response.json()
84+
except httpx.HTTPError as exc:
85+
logger.debug("Albert /me/usage fetch failed: %s", exc)
86+
return None
87+
except ValueError as exc:
88+
logger.debug("Albert /me/usage returned non-JSON: %s", exc)
89+
return None
90+
data = payload.get("data") if isinstance(payload, dict) else None
91+
return data if isinstance(data, list) else None
92+
93+
94+
def sum_prompt_tokens_today(
95+
usage_events: list[dict[str, object]], model_name: str
96+
) -> int:
97+
"""Sum prompt_tokens for `model_name` calls that happened today (UTC).
98+
99+
Daily quotas reset at midnight UTC, so any event before today's
100+
00:00 UTC doesn't consume the running counter.
101+
"""
102+
import datetime as dt
103+
104+
midnight_utc = (
105+
dt.datetime.now(dt.UTC)
106+
.replace(hour=0, minute=0, second=0, microsecond=0)
107+
.timestamp()
108+
)
109+
total = 0
110+
for event in usage_events:
111+
if not isinstance(event, dict):
112+
continue
113+
if event.get("model") != model_name:
114+
continue
115+
created = event.get("created")
116+
if not isinstance(created, (int, float)) or created < midnight_utc:
117+
continue
118+
usage = event.get("usage") or {}
119+
if isinstance(usage, dict):
120+
tokens = usage.get("prompt_tokens", 0)
121+
if isinstance(tokens, (int, float)):
122+
total += int(tokens)
123+
return total
124+
125+
65126
async def fetch_albert_quotas_detailed(
66127
provider: ProviderConfig, *, timeout: float = 10.0
67128
) -> QuotaFetchResult:
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Persistent record of the last terminal 429 (daily quota exhausted).
2+
3+
Lets the next albert-code launch warn the user *before* the first call:
4+
if Qwen3-Coder TPD was exhausted at 22:30 UTC and you reopen at 08:00 UTC,
5+
the daily counter has not reset yet and the very first request will 429.
6+
7+
Stored as a tiny JSON file in ALBERT_CODE_HOME so it survives restarts
8+
and machine reboots. Cross-process (other Albert clients on the same
9+
account) is not detected here - that's what option A (live /me/usage
10+
estimation) covers.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from dataclasses import asdict, dataclass
16+
import datetime as dt
17+
import json
18+
import logging
19+
20+
from albert_code.core.paths.global_paths import THROTTLE_STATE_FILE
21+
22+
logger = logging.getLogger(__name__)
23+
24+
# Daily quota windows on Albert reset at midnight UTC. We never want to
25+
# show a stale "TPD exhausted" warning past the next reset.
26+
_RESET_HOUR_UTC = 0
27+
28+
29+
@dataclass(frozen=True)
30+
class TerminalQuotaEvent:
31+
model_name: str
32+
reason: str
33+
body_excerpt: str
34+
recorded_at_iso: str # UTC ISO8601
35+
36+
@property
37+
def recorded_at(self) -> dt.datetime:
38+
return dt.datetime.fromisoformat(self.recorded_at_iso)
39+
40+
41+
def save_terminal_quota_event(
42+
model_name: str, reason: str, body_text: str | None
43+
) -> None:
44+
"""Persist the last 429 daily-quota event. Best-effort, never raises."""
45+
payload = TerminalQuotaEvent(
46+
model_name=model_name,
47+
reason=reason,
48+
body_excerpt=(body_text or "")[:300],
49+
recorded_at_iso=dt.datetime.now(dt.UTC).isoformat(),
50+
)
51+
try:
52+
path = THROTTLE_STATE_FILE.path
53+
path.parent.mkdir(parents=True, exist_ok=True)
54+
path.write_text(json.dumps(asdict(payload), indent=2), encoding="utf-8")
55+
except OSError as exc:
56+
logger.debug("Could not persist throttle state: %s", exc)
57+
58+
59+
def load_terminal_quota_event() -> TerminalQuotaEvent | None:
60+
"""Read the persisted 429 event. Returns None if absent or malformed."""
61+
try:
62+
text = THROTTLE_STATE_FILE.path.read_text(encoding="utf-8")
63+
except FileNotFoundError:
64+
return None
65+
except OSError as exc:
66+
logger.debug("Could not read throttle state: %s", exc)
67+
return None
68+
try:
69+
data = json.loads(text)
70+
return TerminalQuotaEvent(
71+
model_name=data["model_name"],
72+
reason=data["reason"],
73+
body_excerpt=data.get("body_excerpt", ""),
74+
recorded_at_iso=data["recorded_at_iso"],
75+
)
76+
except (json.JSONDecodeError, KeyError, TypeError) as exc:
77+
logger.debug("Throttle state file is malformed: %s", exc)
78+
return None
79+
80+
81+
def is_event_still_relevant(
82+
event: TerminalQuotaEvent, *, now: dt.datetime | None = None
83+
) -> bool:
84+
"""A daily-quota 429 stays relevant until the next midnight UTC after it."""
85+
now = now or dt.datetime.now(dt.UTC)
86+
recorded = event.recorded_at
87+
if recorded.tzinfo is None:
88+
recorded = recorded.replace(tzinfo=dt.UTC)
89+
next_reset = (recorded + dt.timedelta(days=1)).replace(
90+
hour=_RESET_HOUR_UTC, minute=0, second=0, microsecond=0
91+
)
92+
return now < next_reset
93+
94+
95+
def clear_terminal_quota_event_for_model(model_name: str) -> None:
96+
"""Remove the persisted file if it concerns `model_name`.
97+
98+
Called on a successful call. Only clears state for the same model the
99+
user just exercised - a successful call on `albert-large` shouldn't
100+
silence a still-valid `albert-code` warning.
101+
"""
102+
event = load_terminal_quota_event()
103+
if event is None:
104+
return
105+
if event.model_name != model_name:
106+
return
107+
try:
108+
THROTTLE_STATE_FILE.path.unlink(missing_ok=True)
109+
except OSError as exc:
110+
logger.debug("Could not clear throttle state: %s", exc)

‎albert_code/core/paths/global_paths.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,8 @@ def _get_vibe_home() -> Path:
3838
)
3939
LOG_DIR = GlobalPath(lambda: ALBERT_CODE_HOME.path / "logs")
4040
LOG_FILE = GlobalPath(lambda: ALBERT_CODE_HOME.path / "logs" / "albert-code.log")
41+
THROTTLE_STATE_FILE = GlobalPath(
42+
lambda: ALBERT_CODE_HOME.path / "throttle_state.json"
43+
)
4144

4245
DEFAULT_TOOL_DIR = GlobalPath(lambda: ALBERT_CODE_ROOT / "core" / "tools" / "builtins")

0 commit comments

Comments
 (0)