Skip to content

Commit a9792d7

Browse files
committed
feat(ui): Live rpm/tpm/rpd/tpd gauge in the bottom bar
Adds a compact quota indicator next to the model label so users can keep an eye on how close they are to Albert's per-minute and per-day caps without having to type /rpm or /quota each time. The gauge displays `[rpm USED/LIMIT tpm USED/LIMIT rpd USED/LIMIT tpd USED/LIMIT]` with a colour change above 80% (yellow) and 95% (red, bold). Per-minute counters come from the in-process rolling 60-second window the throttler already maintained ; the per-day counters are populated by a 30-second poller against /v1/me/usage that filters today's events for the active model. UsageSnapshot grows four optional fields (rpd_limit, rpd_used, tpd_limit, tpd_used) and Throttler.update_daily_usage caches the latter, keyed by canonical model id. quota.count_requests_today mirrors the existing sum_prompt_tokens_today helper. Resolves Albert API aliases. /v1/models exposes an `aliases` array on each entry (e.g. `openweight-code` -> `Qwen/Qwen3-Coder-30B-...`) and the throttler now fetches it once on init alongside /v1/me/info. That lets users who configured `openweight-code` in their TOML finally get the right per-model EXP tier (50 rpm, 1000 rpd, 128k tpm, 2.46M tpd) instead of the misleading account-wide max, and lets the daily poller match /v1/me/usage events (which always carry the canonical id, never the alias). Adjacent fixes : - /model display now shows the canonical Albert id in parentheses after the local alias, so users can tell at a glance which underlying model their alias points to. - The bottom-bar model label was statically rendered at compose time and never refreshed: it now updates after a /config save and on the 3-second tick. Auto-fallback transitions surface with a `↳` prefix instead of `⚙` when requests are temporarily routed to the configured fallback model. Tests: 1465 passed, including 4 throttling tests updated to mock /v1/models alongside /v1/me/info now that the throttler fetches both at init time.
1 parent 89850ad commit a9792d7

7 files changed

Lines changed: 438 additions & 11 deletions

File tree

‎albert_code/cli/textual_ui/app.py‎

Lines changed: 141 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from albert_code.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
5555
from albert_code.cli.textual_ui.widgets.path_display import PathDisplay
5656
from albert_code.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
57+
from albert_code.cli.textual_ui.widgets.quota_display import QuotaDisplay
5758
from albert_code.cli.textual_ui.widgets.question_app import QuestionApp
5859
from albert_code.cli.textual_ui.widgets.session_picker import SessionPickerApp
5960
from albert_code.cli.textual_ui.widgets.teleport_message import TeleportMessage
@@ -293,6 +294,7 @@ def compose(self) -> ComposeResult:
293294
with Horizontal(id="bottom-bar"):
294295
yield PathDisplay(self.config.displayed_workdir or Path.cwd())
295296
yield NoMarkupStatic(id="spacer")
297+
yield QuotaDisplay(id="bottom-quota")
296298
yield NoMarkupStatic(self._format_model_label(), id="bottom-model")
297299
yield ContextProgress()
298300

@@ -331,6 +333,11 @@ def update_context_progress(stats: AgentStats) -> None:
331333
await self._resume_history_from_messages()
332334
await self._check_and_show_whats_new()
333335
self.run_worker(self._check_quota_warning(), exclusive=False)
336+
# Live rpm/tpm gauge in the bottom bar. Refresh frequently so the
337+
# rolling 60-second window stays visible without a slash command.
338+
self.run_worker(self._prefetch_quota_limits(), exclusive=False)
339+
self._refresh_quota_display()
340+
self.set_interval(3.0, self._refresh_quota_display)
334341
self._schedule_update_notification()
335342
self.agent_loop.emit_new_session_telemetry()
336343

@@ -896,6 +903,23 @@ def _on_fallback_event(
896903
)
897904
self.notify(message, title=title, severity=severity, timeout=8)
898905

906+
def _refresh_model_label(self) -> None:
907+
"""Re-render the `#bottom-model` widget from the live config + agent_loop.
908+
909+
Called after `/config` saves a new active model and from the 3 s
910+
timer so that auto-fallback transitions (which don't mutate the
911+
config but route via `_last_resolved_model_alias`) also surface.
912+
Silent on any failure: this is purely cosmetic.
913+
"""
914+
try:
915+
widget = self.query_one("#bottom-model", NoMarkupStatic)
916+
except Exception:
917+
return
918+
try:
919+
widget.update(self._format_model_label())
920+
except Exception:
921+
pass
922+
899923
def _format_model_label(self) -> str:
900924
"""Format the active model for the bottom bar as `⚙ alias (short-name)`.
901925
@@ -904,18 +928,35 @@ def _format_model_label(self) -> str:
904928
(e.g. `Qwen/Qwen3-Coder-30B-A3B-Instruct` -> `Qwen3-Coder-30B-A3B-Instruct`).
905929
- Falls back to a single value if alias and short-name coincide,
906930
and to the raw `active_model` string if no model matches the config.
931+
- When auto-fallback is currently active, prefixes with `↳` and
932+
shows the fallback model so the user knows requests are
933+
temporarily routed elsewhere.
907934
"""
908935
try:
909-
model = self.config.get_active_model()
936+
primary = self.config.get_active_model()
910937
except ValueError:
911938
return f"⚙ {self.config.active_model}"
912939

913-
alias = model.alias or ""
914-
short_name = model.name.split("/")[-1] if "/" in model.name else model.name
940+
# Pick the model that the agent loop actually resolved last - this
941+
# reflects the auto-fallback even though the config's active_model
942+
# hasn't changed.
943+
effective = primary
944+
last_alias = getattr(self.agent_loop, "_last_resolved_model_alias", None)
945+
if last_alias and last_alias != primary.alias:
946+
for candidate in self.config.models:
947+
if candidate.alias == last_alias:
948+
effective = candidate
949+
break
950+
951+
alias = effective.alias or ""
952+
short_name = (
953+
effective.name.split("/")[-1] if "/" in effective.name else effective.name
954+
)
915955

956+
prefix = "↳" if effective is not primary else "⚙"
916957
if not alias or alias == short_name:
917-
return f"⚙ {short_name}"
918-
return f"⚙ {alias} ({short_name})"
958+
return f"{prefix} {short_name}"
959+
return f"{prefix} {alias} ({short_name})"
919960

920961
async def _toggle_fallback(self) -> None:
921962
"""Toggle the auto-fallback-on-429 strategy ON/OFF."""
@@ -1194,6 +1235,7 @@ async def _reload_config(self) -> None:
11941235

11951236
if self._banner:
11961237
self._banner.set_state(base_config, self.agent_loop.skill_manager)
1238+
self._refresh_model_label()
11971239
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
11981240
except Exception as e:
11991241
await self._mount_and_scroll(
@@ -1703,6 +1745,100 @@ async def _check_quota_warning(self) -> None:
17031745
body += "\n\nUse `/limits` for the full picture or `/config` to switch model."
17041746
await self._mount_and_scroll(WarningMessage(body, show_border=False))
17051747

1748+
async def _prefetch_quota_limits(self) -> None:
1749+
"""Force the lazy /v1/me/info fetch so the bottom-bar gauge can appear
1750+
before the user's first message. Silent on failure: the gauge will
1751+
simply stay hidden until limits are known. Also kicks off the
1752+
periodic /v1/me/usage poller for the rpd/tpd counters.
1753+
"""
1754+
from albert_code.core.llm.quota import is_albert_provider
1755+
from albert_code.core.llm.throttling import get_throttler
1756+
1757+
try:
1758+
active_model = self.config.get_active_model()
1759+
provider = self.config.get_provider_for_model(active_model)
1760+
if not is_albert_provider(provider):
1761+
return
1762+
throttler = get_throttler(provider)
1763+
await throttler._ensure_initialized()
1764+
except Exception:
1765+
return
1766+
# Refresh once initialised so the user doesn't have to wait 3 s.
1767+
self._refresh_quota_display()
1768+
# Daily counters need /v1/me/usage which is heavier than the
1769+
# per-minute snapshot. Poll every 30 s in a separate worker.
1770+
self.run_worker(self._poll_daily_usage_loop(), exclusive=False)
1771+
1772+
async def _poll_daily_usage_loop(self) -> None:
1773+
"""Periodically refresh the rpd/tpd counters from /v1/me/usage.
1774+
1775+
Runs every 30 s. Each tick fetches the recent usage events, sums
1776+
today's requests and prompt tokens for the active model, and
1777+
pushes them into the shared throttler. Silent on any failure.
1778+
"""
1779+
import asyncio
1780+
1781+
from albert_code.core.llm.quota import (
1782+
count_requests_today,
1783+
fetch_albert_usage,
1784+
is_albert_provider,
1785+
sum_prompt_tokens_today,
1786+
)
1787+
from albert_code.core.llm.throttling import get_throttler
1788+
1789+
while True:
1790+
try:
1791+
active_model = self.config.get_active_model()
1792+
provider = self.config.get_provider_for_model(active_model)
1793+
if is_albert_provider(provider):
1794+
events = await fetch_albert_usage(provider)
1795+
if events is not None:
1796+
throttler = get_throttler(provider)
1797+
# /v1/me/usage events carry the canonical id, never
1798+
# the alias - so we must resolve before filtering.
1799+
canonical = (
1800+
throttler.resolve_alias(active_model.name)
1801+
or active_model.name
1802+
)
1803+
rpd_used = count_requests_today(events, canonical)
1804+
tpd_used = sum_prompt_tokens_today(events, canonical)
1805+
throttler.update_daily_usage(
1806+
canonical,
1807+
rpd_used=rpd_used,
1808+
tpd_used=tpd_used,
1809+
)
1810+
self._refresh_quota_display()
1811+
except Exception:
1812+
pass
1813+
await asyncio.sleep(30.0)
1814+
1815+
def _refresh_quota_display(self) -> None:
1816+
"""Read the throttler snapshot and update the bottom-bar gauge.
1817+
1818+
Called every 3 s via set_interval. Silent on any failure (the gauge
1819+
is purely cosmetic and must never crash the UI). Also re-renders
1820+
the model label so auto-fallback transitions surface in the bar.
1821+
"""
1822+
self._refresh_model_label()
1823+
try:
1824+
widget = self.query_one("#bottom-quota", QuotaDisplay)
1825+
except Exception:
1826+
return
1827+
try:
1828+
from albert_code.core.llm.quota import is_albert_provider
1829+
from albert_code.core.llm.throttling import get_throttler
1830+
1831+
active_model = self.config.get_active_model()
1832+
provider = self.config.get_provider_for_model(active_model)
1833+
if not is_albert_provider(provider):
1834+
widget.clear()
1835+
return
1836+
throttler = get_throttler(provider)
1837+
snap = throttler.usage_snapshot(model_name=active_model.name)
1838+
widget.update_from_snapshot(snap)
1839+
except Exception:
1840+
widget.clear()
1841+
17061842
async def _check_and_show_whats_new(self) -> None:
17071843
if self._update_cache_repository is None:
17081844
return

‎albert_code/cli/textual_ui/app.tcss‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,8 @@ ExpandingBorder {
915915
}
916916

917917
PathDisplay,
918-
ContextProgress {
918+
ContextProgress,
919+
QuotaDisplay {
919920
width: auto;
920921
height: auto;
921922
background: transparent;
@@ -924,6 +925,19 @@ ContextProgress {
924925
color: ansi_bright_black;
925926
}
926927

928+
QuotaDisplay {
929+
margin-right: 2;
930+
}
931+
932+
QuotaDisplay.quota-warning {
933+
color: ansi_yellow;
934+
}
935+
936+
QuotaDisplay.quota-danger {
937+
color: ansi_red;
938+
text-style: bold;
939+
}
940+
927941
#banner-container {
928942
padding: 0 1 0 0;
929943
width: auto;

‎albert_code/cli/textual_ui/widgets/config_app.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ def on_mount(self) -> None:
9898
self._update_display()
9999
self.focus()
100100

101+
def _decorate_value(self, key: str, value: str) -> str:
102+
"""Cosmetic-only formatting on top of the raw value used for cycling."""
103+
if key == "active_model":
104+
for model in self.config.models:
105+
if model.alias == value and model.name and model.name != value:
106+
return f"{value} ({model.name})"
107+
return value
108+
101109
def _get_display_value(self, setting: SettingDefinition) -> str:
102110
key = setting["key"]
103111
if key in self.changes:
@@ -116,8 +124,13 @@ def _update_display(self) -> None:
116124

117125
label: str = setting["label"]
118126
value: str = self._get_display_value(setting)
127+
# For the model picker, show the canonical Albert name in
128+
# parentheses after the local alias (e.g. "albert-code
129+
# (Qwen/Qwen3-Coder-30B-A3B-Instruct)"). The stored value
130+
# stays the alias - the cycle logic relies on it.
131+
display_value = self._decorate_value(setting["key"], value)
119132

120-
text = f"{cursor}{label}: {value}"
133+
text = f"{cursor}{label}: {display_value}"
121134

122135
widget.update(text)
123136

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Bottom-bar widget showing the live Albert API quota usage.
2+
3+
Shows a compact "rpm USED/LIMIT - tpm USED/LIMIT" string for the
4+
active Albert model, refreshed periodically from the rolling 60s
5+
counters maintained by the Throttler.
6+
7+
The data is local to this albert-code process: it cannot account for
8+
calls made by other clients sharing the same Albert key (e.g. a web
9+
albert-cli running for a classroom on the same teacher key). For a
10+
richer view including documented daily tiers, use the /rpm and
11+
/quota slash commands.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from typing import TYPE_CHECKING, Any
17+
18+
from albert_code.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
19+
20+
if TYPE_CHECKING:
21+
from albert_code.core.llm.throttling import UsageSnapshot
22+
23+
24+
# At or above this fraction of the limit, the widget switches to a
25+
# warning style (yellow) ; above DANGER, to a danger style (red).
26+
WARNING_RATIO = 0.8
27+
DANGER_RATIO = 0.95
28+
29+
30+
class QuotaDisplay(NoMarkupStatic):
31+
"""Compact rpm/tpm gauge for the bottom bar."""
32+
33+
def __init__(self, **kwargs: Any) -> None:
34+
super().__init__(**kwargs)
35+
self.can_focus = False
36+
37+
def clear(self) -> None:
38+
"""Hide the widget (e.g. when the active provider is not Albert)."""
39+
self.remove_class("quota-warning")
40+
self.remove_class("quota-danger")
41+
self.update("")
42+
43+
def update_from_snapshot(self, snap: UsageSnapshot | None) -> None:
44+
"""Refresh the displayed gauge from a Throttler snapshot.
45+
46+
If the snapshot has no rpm limit (Albert quotas not yet fetched
47+
or unknown model tier), the widget hides itself. The snapshot
48+
is captured under the throttler's lock and reflects the rolling
49+
60-second window at this instant. The rpd/tpd part appears as
50+
soon as the periodic /v1/me/usage refresh has populated them.
51+
"""
52+
if snap is None or snap.rpm_limit is None:
53+
self.clear()
54+
return
55+
56+
# Per-minute window (always shown).
57+
parts = [f"rpm {snap.rpm_used}/{snap.rpm_limit}"]
58+
if snap.tpm_limit is not None:
59+
parts.append(
60+
f"tpm {_compact_tokens(snap.tpm_used)}/{_compact_tokens(snap.tpm_limit)}"
61+
)
62+
63+
# Per-day window (shown only when /v1/me/usage has been polled and
64+
# the documented tier carries an rpd/tpd cap).
65+
if snap.rpd_used is not None and snap.rpd_limit is not None:
66+
parts.append(f"rpd {snap.rpd_used}/{snap.rpd_limit}")
67+
if snap.tpd_used is not None and snap.tpd_limit is not None:
68+
parts.append(
69+
f"tpd {_compact_tokens(snap.tpd_used)}/{_compact_tokens(snap.tpd_limit)}"
70+
)
71+
72+
self.update("[" + " ".join(parts) + "]")
73+
74+
# Pick the strongest signal across all four counters to colour the
75+
# line: any one being close to its cap deserves attention.
76+
ratios = [_ratio(snap.rpm_used, snap.rpm_limit)]
77+
if snap.tpm_limit is not None:
78+
ratios.append(_ratio(snap.tpm_used, snap.tpm_limit))
79+
if snap.rpd_used is not None and snap.rpd_limit:
80+
ratios.append(_ratio(snap.rpd_used, snap.rpd_limit))
81+
if snap.tpd_used is not None and snap.tpd_limit:
82+
ratios.append(_ratio(snap.tpd_used, snap.tpd_limit))
83+
ratio = max(ratios)
84+
85+
self.set_class(ratio >= DANGER_RATIO, "quota-danger")
86+
self.set_class(WARNING_RATIO <= ratio < DANGER_RATIO, "quota-warning")
87+
88+
89+
def _ratio(used: int, limit: int | None) -> float:
90+
if not limit or limit <= 0:
91+
return 0.0
92+
return min(1.0, max(0.0, used / limit))
93+
94+
95+
def _compact_tokens(value: int) -> str:
96+
"""Format a token count compactly (3500 -> 3.5k, 128000 -> 128k)."""
97+
if value >= 1_000_000:
98+
return f"{value / 1_000_000:.1f}M"
99+
if value >= 100_000:
100+
return f"{value // 1_000}k"
101+
if value >= 1_000:
102+
return f"{value / 1_000:.1f}k".replace(".0k", "k")
103+
return str(value)

0 commit comments

Comments
 (0)