feat(usage): fix local window estimation + provider-native windows + pricing updates (#37 #38 #40)

Phase 3 (#38): Fix reset_in_ms=0 bug
- Add _oldest_active_ts() to find oldest session timestamp in 5h window
- _build_window() now anchors fallback to oldest_event_ts + 5h instead of now - 5h
- Add _parse_rate_limit_reset_value() and _extract_rate_limit_reset_at() for proper rate-limit reset parsing
- Source/confidence labeling now based on reset provenance

Phase 2 (#37): Provider-native usage windows
- ParsedClaudeUsageWindow dataclass with section-aware parsing
- Frontend ProviderNativeUsageWindow interface and provider-native usage section
- sessions.list call now has 8s timeout to avoid gateway blocking

Phase 5 (#40): Pricing fixes
- Opus cache_write corrected .75 → .75
- Added GPT-4.1/mini/nano, GPT-4.5 pricing
- Pricing override loader supports both shapes (rates_usd_per_million wrapper and direct dict)
This commit is contained in:
null 2026-05-21 01:32:59 -05:00
parent 8d11f4f840
commit 02eb03d408
18 changed files with 923 additions and 183 deletions

2
.gitignore vendored
View File

@ -35,3 +35,5 @@ FUTURE.md
FUTURE.md
docs/runtime-usage-dashboard-plan.md
docs/remaining-usage-accuracy-review-plan.md
.learnings/bishop/LEARNINGS.md
.learnings/bishop/ERRORS.md

View File

@ -40,6 +40,7 @@ from app.schemas.gateway_ops import (
from app.schemas.runtime_usage import (
ProviderUsageResponse,
ProviderUsageScrapeResult,
ProviderUsageWindow,
RuntimeUsageResponse,
)
from app.db.session import async_session_maker
@ -356,6 +357,19 @@ async def get_gateway_provider_usage(
scraped_at=r.scraped_at,
fresh=r.fresh,
freshness_ttl_seconds=r.freshness_ttl_seconds,
windows=[
ProviderUsageWindow(
key=w.key,
label=w.label,
pct_used=w.pct_used,
remaining_ms=w.remaining_ms,
remaining_label=w.remaining_label,
extra_text=w.extra_text,
source=w.source,
confidence=w.confidence,
)
for w in r.parsed.windows
],
current_pct=r.parsed.current_pct,
remaining_ms=r.parsed.remaining_ms,
remaining_label=r.parsed.remaining_label,
@ -365,6 +379,8 @@ async def get_gateway_provider_usage(
weekly_cost_usd=r.parsed.weekly_cost_usd,
raw_text=r.parsed.raw_text,
error=r.error or r.parsed.error,
source=r.parsed.source,
confidence=r.parsed.confidence,
)
for r in scrape_results
]

View File

@ -56,7 +56,7 @@ class BoardTaskCustomField(TenantScoped, table=True):
UniqueConstraint(
"board_id",
"task_custom_field_definition_id",
name="uq_board_task_custom_fields_board_id_task_custom_field_definition_id",
name="uq_board_tcf_board_id_def_id",
),
)
@ -77,7 +77,7 @@ class TaskCustomFieldValue(TenantScoped, table=True):
UniqueConstraint(
"task_id",
"task_custom_field_definition_id",
name="uq_task_custom_field_values_task_id_task_custom_field_definition_id",
name="uq_tcf_values_task_id_def_id",
),
)

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from uuid import UUID
from sqlmodel import SQLModel
from sqlmodel import Field, SQLModel
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
@ -106,7 +106,7 @@ class ProviderUsageScrapeResult(SQLModel):
fresh: bool # True if within the freshness window
freshness_ttl_seconds: int
windows: list[ProviderUsageWindow] = []
windows: list[ProviderUsageWindow] = Field(default_factory=list)
current_pct: float | None = None # 0100 % of current window used
remaining_ms: int | None = None # ms until window resets

View File

@ -46,20 +46,26 @@ _PricingEntry = dict[str, float] # keys: input, output, cache_read, cache_write
DEFAULT_MODEL_PRICING: dict[str, _PricingEntry] = {
# Anthropic — Claude 4.x
"anthropic/claude-opus-4-7": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 3.75},
"anthropic/claude-opus-4-5": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 3.75},
"anthropic/claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
"anthropic/claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
"anthropic/claude-haiku-4-5": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
# Opus cache_write = $18.75/MTok (5x input price, per Anthropic docs)
"anthropic/claude-opus-4-7": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75},
"anthropic/claude-opus-4-5": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75},
"anthropic/claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
"anthropic/claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
"anthropic/claude-haiku-4-5": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
# Anthropic — Claude 3.x
"anthropic/claude-3-5-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
"anthropic/claude-3-5-haiku": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
"anthropic/claude-3-opus": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 3.75},
"anthropic/claude-3-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
"anthropic/claude-3-haiku": {"input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_write": 0.30},
"anthropic/claude-3-5-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
"anthropic/claude-3-5-haiku": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
"anthropic/claude-3-opus": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75},
"anthropic/claude-3-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
"anthropic/claude-3-haiku": {"input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_write": 0.30},
# OpenAI — GPT-4.1 family (2025)
"openai/gpt-4.1": {"input": 2.00, "output": 8.00, "cache_read": 0.50, "cache_write": 0.00},
"openai/gpt-4.1-mini": {"input": 0.40, "output": 1.60, "cache_read": 0.10, "cache_write": 0.00},
"openai/gpt-4.1-nano": {"input": 0.10, "output": 0.40, "cache_read": 0.025, "cache_write": 0.00},
# OpenAI — GPT-4o family
"openai/gpt-4o": {"input": 2.50, "output": 10.00, "cache_read": 1.25, "cache_write": 0.00},
"openai/gpt-4o-mini": {"input": 0.15, "output": 0.60, "cache_read": 0.075, "cache_write": 0.00},
"openai/gpt-4-5": {"input": 75.0, "output": 150.0, "cache_read": 37.50, "cache_write": 0.00},
"openai/gpt-4-turbo": {"input": 10.00, "output": 30.00, "cache_read": 0.00, "cache_write": 0.00},
"openai/gpt-4": {"input": 30.00, "output": 60.00, "cache_read": 0.00, "cache_write": 0.00},
"openai/gpt-3-5-turbo": {"input": 0.50, "output": 1.50, "cache_read": 0.00, "cache_write": 0.00},
@ -69,7 +75,7 @@ DEFAULT_MODEL_PRICING: dict[str, _PricingEntry] = {
"openai/o3": {"input": 10.00, "output": 40.00, "cache_read": 2.50, "cache_write": 0.00},
"openai/o3-mini": {"input": 1.10, "output": 4.40, "cache_read": 0.55, "cache_write": 0.00},
"openai/o4-mini": {"input": 1.10, "output": 4.40, "cache_read": 0.275, "cache_write": 0.00},
# Codex alias
# Codex alias (free under Codex plan)
"openai/codex": {"input": 0.00, "output": 0.00, "cache_read": 0.00, "cache_write": 0.00},
}
@ -77,7 +83,12 @@ _pricing_cache: dict[str, _PricingEntry] | None = None
def load_pricing() -> dict[str, _PricingEntry]:
"""Return merged pricing: defaults + optional override file."""
"""Return merged pricing: defaults + optional override file.
Override file may use either shape:
{ "provider/model": { "input": X, "output": Y, ... } }
{ "rates_usd_per_million": { "provider/model": { ... } } }
"""
global _pricing_cache
if _pricing_cache is not None:
return _pricing_cache
@ -86,10 +97,13 @@ def load_pricing() -> dict[str, _PricingEntry]:
if override_path:
try:
with open(override_path) as fh:
overrides = json.load(fh)
if isinstance(overrides, dict):
merged.update(overrides)
logger.info("runtime_usage.pricing.override_loaded path=%s", override_path)
raw = json.load(fh)
if isinstance(raw, dict):
# Unwrap reference-dashboard shape if present
overrides: dict[str, Any] = raw.get("rates_usd_per_million", raw)
if isinstance(overrides, dict):
merged.update(overrides)
logger.info("runtime_usage.pricing.override_loaded path=%s count=%d", override_path, len(overrides))
except Exception as exc:
logger.warning("runtime_usage.pricing.override_failed path=%s error=%s", override_path, exc)
_pricing_cache = merged
@ -234,6 +248,70 @@ def _parse_datetime(value: object) -> datetime | None:
return None
def _parse_rate_limit_reset_value(value: object, now: datetime) -> datetime | None:
"""Parse rate-limit reset values from headers into a UTC naive datetime.
Supports:
- RFC3339/ISO timestamps
- Unix epoch seconds / milliseconds
- Delta-seconds strings
"""
parsed = _parse_datetime(value)
if parsed is not None:
return parsed
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
try:
numeric = float(raw)
except ValueError:
return None
# Very large values are likely epoch milliseconds.
if numeric >= 1_000_000_000_000:
return datetime.fromtimestamp(numeric / 1000, tz=timezone.utc).replace(tzinfo=None)
# Epoch seconds (typical range today ~1.7e9).
if numeric >= 1_000_000_000:
return datetime.fromtimestamp(numeric, tz=timezone.utc).replace(tzinfo=None)
# Otherwise treat as delta-seconds until reset.
if numeric >= 0:
return now + timedelta(seconds=numeric)
return None
def _extract_rate_limit_reset_at(status_raw: dict[str, Any], now: datetime) -> datetime | None:
"""Find the first parseable rate-limit reset timestamp in status data."""
explicit_keys = (
"x_ratelimit_reset",
"x_ratelimit_reset_tokens",
"x_ratelimit_reset_requests",
"ratelimit_reset",
"anthropic_ratelimit_reset",
"anthropic_ratelimit_tokens_reset",
"anthropic_ratelimit_requests_reset",
"anthropic_ratelimit_input_tokens_reset",
)
for key in explicit_keys:
if key in status_raw:
parsed = _parse_rate_limit_reset_value(status_raw.get(key), now)
if parsed is not None:
return parsed
# Defensive fallback: inspect any ratelimit*reset field that may appear.
for key, value in status_raw.items():
normalized = str(key).lower().replace("-", "_")
if "ratelimit" in normalized and "reset" in normalized:
parsed = _parse_rate_limit_reset_value(value, now)
if parsed is not None:
return parsed
return None
# ---------------------------------------------------------------------------
# Session parsing
# ---------------------------------------------------------------------------
@ -385,10 +463,32 @@ def _top_sessions(
_WINDOW_HOURS = 5
def _oldest_active_ts(
sessions: list[dict[str, Any]],
now: datetime,
) -> datetime | None:
"""Return the oldest session timestamp still inside the rolling window, or None."""
cutoff = now - timedelta(hours=_WINDOW_HOURS)
oldest: datetime | None = None
for session in sessions:
raw_ts = _get_str(
session,
"updated_at", "updatedAt", "lastActivity", "last_activity",
"created_at", "createdAt",
)
ts = _parse_datetime(raw_ts)
if ts is None or ts < cutoff:
continue
if oldest is None or ts < oldest:
oldest = ts
return oldest
def _build_window(
status_raw: dict[str, Any],
now: datetime,
account_key: str = "default",
oldest_event_ts: datetime | None = None,
) -> RuntimeUsageWindow:
"""Build the usage window, preferring gateway status data then falling back.
@ -396,46 +496,42 @@ def _build_window(
- If gateway status provides explicit window data, use provider_native
- If API rate-limit headers are the only source, use provider_api_rate_limit
- If falling back to local logic, use local_jsonl_estimate
When falling back, `oldest_event_ts` anchors the window start to the
oldest active session timestamp (oldest_event_ts + 5h = reset). This
avoids the previous bug where started_at = now - 5h made reset_in_ms = 0.
"""
# Check if gateway status provides explicit window data
has_window_start = status_raw.get("windowStart") or status_raw.get("window_start") or status_raw.get("period_start") or status_raw.get("started_at")
has_window_end = status_raw.get("windowEnd") or status_raw.get("window_end") or status_raw.get("period_end") or status_raw.get("resets_at")
# Check for API rate-limit headers (these indicate throttling, not subscription usage)
has_rate_limit_headers = (
status_raw.get("x_ratelimit_remaining") or
status_raw.get("x_ratelimit_limit") or
status_raw.get("x_ratelimit_reset") or
status_raw.get("anthropic_ratelimit_remaining") or
status_raw.get("anthropic_ratelimit_limit")
)
if has_window_start and has_window_end:
# Gateway status provides explicit window data
source = "provider_native"
confidence = "high"
elif has_rate_limit_headers:
# Only API rate-limit headers available - treat as diagnostics
source = "provider_api_rate_limit"
confidence = "medium"
else:
# Fall back to local logic (5-hour window from oldest event)
source = "local_jsonl_estimate"
confidence = "low"
started_at = _parse_datetime(
explicit_started_at = _parse_datetime(
status_raw.get("windowStart") or status_raw.get("window_start")
or status_raw.get("period_start") or status_raw.get("started_at")
)
resets_at = _parse_datetime(
explicit_resets_at = _parse_datetime(
status_raw.get("windowEnd") or status_raw.get("window_end")
or status_raw.get("period_end") or status_raw.get("resets_at")
)
header_resets_at = _extract_rate_limit_reset_at(status_raw, now)
started_at = explicit_started_at
if started_at is None:
started_at = now - timedelta(hours=_WINDOW_HOURS)
# Anchor to oldest active session so reset_in_ms reflects real remaining time.
# If no sessions exist in the window, keep a conservative local fallback.
started_at = oldest_event_ts if oldest_event_ts is not None else now - timedelta(hours=_WINDOW_HOURS)
resets_at = explicit_resets_at or header_resets_at
if resets_at is None:
resets_at = started_at + timedelta(hours=_WINDOW_HOURS)
# Label by reset-time provenance. Local fallback reset is always an estimate.
if explicit_resets_at is not None:
source = "provider_native"
confidence = "high"
elif header_resets_at is not None:
source = "provider_api_rate_limit"
confidence = "medium"
else:
source = "local_jsonl_estimate"
confidence = "low"
reset_delta = resets_at - now
reset_in_ms = max(0, int(reset_delta.total_seconds() * 1000))
return RuntimeUsageWindow(
@ -574,12 +670,20 @@ async def get_runtime_usage(
"""
now = utcnow()
cost_raw, status_raw, sessions_raw = await asyncio.gather(
cost_raw, status_raw = await asyncio.gather(
_safe_call("usage.cost", config),
_safe_call("usage.status", config),
_safe_call("sessions.list", config),
)
# sessions.list can block the gateway on large session stores; cap at 8s.
try:
sessions_raw = await asyncio.wait_for(
_safe_call("sessions.list", config), timeout=8
)
except asyncio.TimeoutError:
logger.warning("runtime_usage.sessions_list.timeout gateway_id=%s", gateway_id)
sessions_raw = {}
# Extract sessions from sessions.list response (primary source)
# Fallback to usage.cost if sessions.list fails
if isinstance(sessions_raw, dict):
@ -588,20 +692,18 @@ async def get_runtime_usage(
raw_sessions = sessions_raw
else:
raw_sessions = []
# Filter to dicts and merge with usage.cost data if available
sessions: list[dict[str, Any]] = []
if raw_sessions:
sessions = [s for s in raw_sessions if isinstance(s, dict)]
else:
# Fallback: parse from usage.cost response
sessions = _parse_sessions(cost_raw)
# Merge both payloads — some gateways return everything in one response
merged_status = {**cost_raw, **status_raw}
per_model = aggregate_per_model(sessions, account_key=account_key)
window = _build_window(merged_status, now)
oldest_event_ts = _oldest_active_ts(sessions, now)
per_model = aggregate_per_model(sessions, account_key=account_key)
window = _build_window(merged_status, now, oldest_event_ts=oldest_event_ts)
current = _build_current(per_model, merged_status)
burn_rate = _compute_burn_rate(sessions, window, now)
predictions = _build_predictions(current, burn_rate, window)

View File

@ -19,12 +19,11 @@ Tmux requirement for ClaudeTmuxScraper:
from __future__ import annotations
import asyncio
import os
import re
import tempfile
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from datetime import datetime
from pathlib import Path
from typing import ClassVar
@ -37,11 +36,26 @@ logger = get_logger(__name__)
# Internal result dataclass (not a schema — stays inside the service layer)
# ---------------------------------------------------------------------------
@dataclass
class ParsedClaudeUsageWindow:
"""One parsed usage window from `claude /usage` text."""
key: str # current_session | weekly_all_models | weekly_sonnet | extra_usage
label: str
pct_used: float | None = None
remaining_ms: int | None = None
remaining_label: str | None = None
extra_text: str | None = None
source: str = "provider_native"
confidence: str = "high"
@dataclass
class ParsedClaudeUsage:
"""Structured result from parsing one block of ``claude /usage`` text."""
raw_text: str
windows: list[ParsedClaudeUsageWindow] = field(default_factory=list)
current_pct: float | None = None
remaining_ms: int | None = None
remaining_label: str | None = None
@ -127,6 +141,16 @@ _WEEKLY_OUTPUT_RE = re.compile(r"output\s+tokens?[:\s]+([\d,]+)", re.IGNORECASE)
_WEEKLY_TOKENS_RE = re.compile(r"(?:total\s+)?tokens?[:\s]+([\d,]+)", re.IGNORECASE)
_WEEKLY_COST_RE = re.compile(r"\$\s*([\d]+\.[\d]{1,4})", re.IGNORECASE)
_SECTION_HEADER_CLEANUP_RE = re.compile(r"^[\s\-\*\|\u2022\u2502\u2500\u250c\u2510\u2514\u2518]+")
_WINDOW_HEADER_PATTERNS: tuple[tuple[re.Pattern[str], str, str], ...] = (
(re.compile(r"^current\s+session\b", re.IGNORECASE), "current_session", "Current session"),
(re.compile(r"^usage\s+window\b", re.IGNORECASE), "current_session", "Current session"),
(re.compile(r"^(?:this\s+week|weekly(?:\s+usage)?)\b", re.IGNORECASE), "weekly_all_models", "All models"),
(re.compile(r"^(?:all\s+models|weekly\s+all\s+models)\b", re.IGNORECASE), "weekly_all_models", "All models"),
(re.compile(r"^(?:weekly\s+sonnet|sonnet)\b", re.IGNORECASE), "weekly_sonnet", "Sonnet"),
(re.compile(r"^extra\s+usage\b", re.IGNORECASE), "extra_usage", "Extra usage"),
)
def _parse_remaining_ms(time_str: str) -> tuple[int, str] | None:
"""Convert a time string like '2h 47m', '1 day 4h', '< 1m' to milliseconds.
@ -155,14 +179,119 @@ def _parse_remaining_ms(time_str: str) -> tuple[int, str] | None:
# Build a compact human label
parts = []
if days: parts.append(f"{days}d")
if hours: parts.append(f"{hours}h")
if minutes: parts.append(f"{minutes}m")
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
label = " ".join(parts) if parts else s
return total_ms, label
def _extract_pct(text: str) -> float | None:
for match in _PCT_RE.finditer(text):
pct = float(match.group(1))
if 0.0 <= pct <= 100.0:
return pct
return None
def _extract_remaining(text: str) -> tuple[int, str] | None:
inline = _INLINE_RESET_RE.search(text)
if inline:
parsed = _parse_remaining_ms(inline.group(1))
if parsed:
return parsed
line_match = _RESET_LINE_RE.search(text)
if line_match:
return _parse_remaining_ms(line_match.group(1).strip())
return None
def _normalized_header(line: str) -> str:
return _SECTION_HEADER_CLEANUP_RE.sub("", line).strip()
def _detect_window_key(line: str) -> tuple[str, str] | None:
normalized = _normalized_header(line)
if not normalized:
return None
for pattern, key, label in _WINDOW_HEADER_PATTERNS:
if pattern.search(normalized):
return key, label
return None
def _split_usage_sections(raw: str) -> list[tuple[str, str, str]]:
sections: list[tuple[str, str, str]] = []
current_key: str | None = None
current_label: str | None = None
current_lines: list[str] = []
for line in raw.splitlines():
detected = _detect_window_key(line)
if detected is not None:
if current_key and current_lines:
sections.append((current_key, current_label or current_key, "\n".join(current_lines)))
current_key, current_label = detected
current_lines = [line]
continue
if current_key:
current_lines.append(line)
if current_key and current_lines:
sections.append((current_key, current_label or current_key, "\n".join(current_lines)))
return sections
def _parse_weekly_stats(result: ParsedClaudeUsage, section_text: str, raw: str) -> None:
msgs_m = _WEEKLY_MSGS_RE.search(section_text)
if msgs_m:
try:
used_str = msgs_m.group(1) or msgs_m.group(3)
if used_str:
result.weekly_messages_used = _parse_int(used_str)
if msgs_m.group(2):
result.weekly_messages_limit = _parse_int(msgs_m.group(2))
except (ValueError, TypeError):
pass
input_m = _WEEKLY_INPUT_RE.search(section_text)
output_m = _WEEKLY_OUTPUT_RE.search(section_text)
if input_m and output_m:
try:
result.weekly_tokens_used = (
_parse_int(input_m.group(1)) + _parse_int(output_m.group(1))
)
except (ValueError, TypeError):
pass
elif result.weekly_tokens_used is None:
tok_m = _WEEKLY_TOKENS_RE.search(section_text)
if tok_m:
try:
result.weekly_tokens_used = _parse_int(tok_m.group(1))
except (ValueError, TypeError):
pass
if result.weekly_tokens_used is None:
alt_tok = re.search(r"weekly\s+tokens?[:\s]+([\d,]+)", raw, re.IGNORECASE)
if alt_tok:
try:
result.weekly_tokens_used = _parse_int(alt_tok.group(1))
except (ValueError, TypeError):
pass
cost_m = _WEEKLY_COST_RE.search(section_text)
if cost_m:
try:
result.weekly_cost_usd = _parse_float(cost_m.group(1))
except (ValueError, TypeError):
pass
def parse_claude_usage(raw: str) -> ParsedClaudeUsage:
"""Parse a block of text from ``claude /usage`` into structured fields.
@ -175,95 +304,72 @@ def parse_claude_usage(raw: str) -> ParsedClaudeUsage:
result.error = "empty output"
return result
# ---- percentage --------------------------------------------------------
for m in _PCT_RE.finditer(raw):
pct = float(m.group(1))
if 0.0 <= pct <= 100.0:
result.current_pct = pct
break # take the first plausible percentage
sections = _split_usage_sections(raw)
# ---- remaining time ----------------------------------------------------
time_str: str | None = None
# Try inline "(resets in X)" pattern first (most specific)
inline = _INLINE_RESET_RE.search(raw)
if inline:
time_str = inline.group(1)
# Fall back to line-level patterns
if not time_str:
line_m = _RESET_LINE_RE.search(raw)
if line_m:
time_str = line_m.group(1).strip()
if time_str:
parsed_time = _parse_remaining_ms(time_str)
if parsed_time:
result.remaining_ms, result.remaining_label = parsed_time
# ---- weekly stats -----------------------------------------------------
# Try to find a "weekly" or "this week" section
weekly_section = raw
week_header = re.search(
r"(?:this\s+week|weekly|week\b)[^\n]*\n(.+?)(?=\n\s*\n|\Z)",
raw, re.IGNORECASE | re.DOTALL
)
if week_header:
weekly_section = week_header.group(0)
# messages (with optional limit)
msgs_m = _WEEKLY_MSGS_RE.search(weekly_section)
if msgs_m:
try:
# group(1) = "messages: N", group(3) = "N messages"
used_str = msgs_m.group(1) or msgs_m.group(3)
if used_str:
result.weekly_messages_used = _parse_int(used_str)
if msgs_m.group(2):
result.weekly_messages_limit = _parse_int(msgs_m.group(2))
except (ValueError, TypeError):
pass
# tokens — prefer input+output sum if both present
input_m = _WEEKLY_INPUT_RE.search(weekly_section)
output_m = _WEEKLY_OUTPUT_RE.search(weekly_section)
if input_m and output_m:
try:
result.weekly_tokens_used = (
_parse_int(input_m.group(1)) + _parse_int(output_m.group(1))
)
except (ValueError, TypeError):
pass
elif not result.weekly_tokens_used:
tok_m = _WEEKLY_TOKENS_RE.search(weekly_section)
if tok_m:
try:
result.weekly_tokens_used = _parse_int(tok_m.group(1))
except (ValueError, TypeError):
pass
# Alt-key patterns: "weekly tokens: 9,876,543" anywhere in the text
if result.weekly_tokens_used is None:
alt_tok = re.search(
r"weekly\s+tokens?[:\s]+([\d,]+)", raw, re.IGNORECASE
seen_keys: set[str] = set()
for key, label, text in sections:
if key in seen_keys and key != "extra_usage":
continue
pct = _extract_pct(text)
remaining = _extract_remaining(text)
remaining_ms = remaining[0] if remaining else None
remaining_label = remaining[1] if remaining else None
cleaned_text = text.strip()
extra_text = cleaned_text[:240] if cleaned_text else None
has_metrics = pct is not None or remaining_ms is not None
if not has_metrics and key != "extra_usage":
continue
if not has_metrics and not extra_text:
continue
result.windows.append(
ParsedClaudeUsageWindow(
key=key,
label=label,
pct_used=pct,
remaining_ms=remaining_ms,
remaining_label=remaining_label,
extra_text=extra_text,
),
)
if alt_tok:
try:
result.weekly_tokens_used = _parse_int(alt_tok.group(1))
except (ValueError, TypeError):
pass
seen_keys.add(key)
# cost
cost_m = _WEEKLY_COST_RE.search(weekly_section)
if cost_m:
try:
result.weekly_cost_usd = _parse_float(cost_m.group(1))
except (ValueError, TypeError):
pass
# Back-compat flattened fields still exist for existing clients.
current_window = next((w for w in result.windows if w.key == "current_session"), None)
fallback_window = next(
(w for w in result.windows if w.pct_used is not None or w.remaining_ms is not None),
None,
)
primary_window = current_window or fallback_window
if primary_window:
result.current_pct = primary_window.pct_used
result.remaining_ms = primary_window.remaining_ms
result.remaining_label = primary_window.remaining_label
else:
result.current_pct = _extract_pct(raw)
fallback_remaining = _extract_remaining(raw)
if fallback_remaining:
result.remaining_ms, result.remaining_label = fallback_remaining
weekly_section_text = raw
for key, _, text in sections:
if key == "weekly_all_models":
weekly_section_text = text
break
else:
week_header = re.search(
r"(?:this\s+week|weekly|week\b)[^\n]*\n(.+?)(?=\n\s*\n|\Z)",
raw,
re.IGNORECASE | re.DOTALL,
)
if week_header:
weekly_section_text = week_header.group(0)
_parse_weekly_stats(result, weekly_section_text, raw)
# ---- validation --------------------------------------------------------
all_none = (
result.current_pct is None
len(result.windows) == 0
and result.current_pct is None
and result.remaining_ms is None
and result.weekly_messages_used is None
and result.weekly_tokens_used is None
@ -271,6 +377,15 @@ def parse_claude_usage(raw: str) -> ParsedClaudeUsage:
if all_none:
result.error = "no parseable usage data found"
compatibility_none = (
result.current_pct is None
and result.remaining_ms is None
and result.weekly_messages_used is None
and result.weekly_tokens_used is None
)
if compatibility_none and result.error is None:
result.error = "no parseable usage data found"
return result

View File

@ -0,0 +1,201 @@
"""Add missing tables: tags, tag_assignments, approval_task_links,
board_webhook_payloads, task_custom_field_definitions,
board_task_custom_fields, task_custom_field_values.
Revision ID: f0a1b2c3d4e5
Revises: e5b6c7d8f9a0
Create Date: 2026-05-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "f0a1b2c3d4e5"
down_revision = "e5b6c7d8f9a0"
branch_labels = None
depends_on = None
def upgrade() -> None:
# tags
op.create_table(
"tags",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("organization_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("slug", sa.String(), nullable=False),
sa.Column("color", sa.String(), nullable=False, server_default="9e9e9e"),
sa.Column("description", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("organization_id", "slug", name="uq_tags_organization_id_slug"),
)
op.create_index("ix_tags_organization_id", "tags", ["organization_id"])
op.create_index("ix_tags_slug", "tags", ["slug"])
# tag_assignments
op.create_table(
"tag_assignments",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("task_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("tag_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"]),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("task_id", "tag_id", name="uq_tag_assignments_task_id_tag_id"),
)
op.create_index("ix_tag_assignments_task_id", "tag_assignments", ["task_id"])
op.create_index("ix_tag_assignments_tag_id", "tag_assignments", ["tag_id"])
# approval_task_links
op.create_table(
"approval_task_links",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("approval_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("task_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["approval_id"], ["approvals.id"]),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"approval_id", "task_id",
name="uq_approval_task_links_approval_id_task_id",
),
)
op.create_index("ix_approval_task_links_approval_id", "approval_task_links", ["approval_id"])
op.create_index("ix_approval_task_links_task_id", "approval_task_links", ["task_id"])
# board_webhook_payloads
op.create_table(
"board_webhook_payloads",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("board_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("webhook_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("headers", sa.JSON(), nullable=True),
sa.Column("source_ip", sa.String(), nullable=True),
sa.Column("content_type", sa.String(), nullable=True),
sa.Column("received_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.ForeignKeyConstraint(["webhook_id"], ["board_webhooks.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_board_webhook_payloads_board_id", "board_webhook_payloads", ["board_id"])
op.create_index("ix_board_webhook_payloads_webhook_id", "board_webhook_payloads", ["webhook_id"])
op.create_index("ix_board_webhook_payloads_received_at", "board_webhook_payloads", ["received_at"])
# task_custom_field_definitions
op.create_table(
"task_custom_field_definitions",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("organization_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("field_key", sa.String(), nullable=False),
sa.Column("label", sa.String(), nullable=False),
sa.Column("field_type", sa.String(), nullable=False, server_default="text"),
sa.Column("ui_visibility", sa.String(), nullable=False, server_default="always"),
sa.Column("validation_regex", sa.String(), nullable=True),
sa.Column("description", sa.String(), nullable=True),
sa.Column("required", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("default_value", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.CheckConstraint(
"field_type IN ('text','text_long','integer','decimal','boolean','date','date_time','url','json')",
name="ck_tcf_def_field_type",
),
sa.CheckConstraint(
"ui_visibility IN ('always','if_set','hidden')",
name="ck_tcf_def_ui_visibility",
),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"organization_id", "field_key",
name="uq_task_custom_field_definitions_org_id_field_key",
),
)
op.create_index(
"ix_task_custom_field_definitions_organization_id",
"task_custom_field_definitions", ["organization_id"],
)
op.create_index(
"ix_task_custom_field_definitions_field_key",
"task_custom_field_definitions", ["field_key"],
)
# board_task_custom_fields
op.create_table(
"board_task_custom_fields",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("organization_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("board_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column(
"task_custom_field_definition_id",
sa.dialects.postgresql.UUID(as_uuid=True),
nullable=False,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.ForeignKeyConstraint(
["task_custom_field_definition_id"], ["task_custom_field_definitions.id"]
),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"board_id", "task_custom_field_definition_id",
name="uq_board_tcf_board_id_def_id",
),
)
op.create_index("ix_board_task_custom_fields_board_id", "board_task_custom_fields", ["board_id"])
op.create_index(
"ix_board_task_custom_fields_task_custom_field_definition_id",
"board_task_custom_fields", ["task_custom_field_definition_id"],
)
# task_custom_field_values
op.create_table(
"task_custom_field_values",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("organization_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("task_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column(
"task_custom_field_definition_id",
sa.dialects.postgresql.UUID(as_uuid=True),
nullable=False,
),
sa.Column("value", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["task_custom_field_definition_id"], ["task_custom_field_definitions.id"]
),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"task_id", "task_custom_field_definition_id",
name="uq_tcf_values_task_id_def_id",
),
)
op.create_index(
"ix_task_custom_field_values_task_id", "task_custom_field_values", ["task_id"]
)
op.create_index(
"ix_task_custom_field_values_task_custom_field_definition_id",
"task_custom_field_values", ["task_custom_field_definition_id"],
)
def downgrade() -> None:
op.drop_table("task_custom_field_values")
op.drop_table("board_task_custom_fields")
op.drop_table("task_custom_field_definitions")
op.drop_table("board_webhook_payloads")
op.drop_table("approval_task_links")
op.drop_table("tag_assignments")
op.drop_table("tags")

View File

@ -118,6 +118,24 @@ weekly messages: 412
weekly tokens: 9,876,543
"""
FIXTURE_SECTIONED_WINDOWS = """
Current session
77% used
Resets in: 23m
All models
21% used
Resets in: 12h 23m
Messages: 102 / 500
Input tokens: 450,000
Output tokens: 90,000
Est. cost: $2.34
Sonnet
64% used
Resets in: 3h 5m
"""
# ---------------------------------------------------------------------------
# Tests
@ -287,3 +305,43 @@ class TestParseClaudeUsageEdgeCases:
"""Numbers like 1,234,567 must be parsed correctly."""
r = parse_claude_usage(FIXTURE_STANDARD)
assert r.weekly_tokens_used == 1_691_356 # 1,234,567 + 456,789
class TestParseClaudeUsageSectionedWindows:
def test_parses_independent_windows(self):
r = parse_claude_usage(FIXTURE_SECTIONED_WINDOWS)
by_key = {window.key: window for window in r.windows}
assert "current_session" in by_key
assert "weekly_all_models" in by_key
assert "weekly_sonnet" in by_key
current = by_key["current_session"]
all_models = by_key["weekly_all_models"]
sonnet = by_key["weekly_sonnet"]
assert current.pct_used == pytest.approx(77.0)
assert current.remaining_label == "23m"
assert current.remaining_ms == pytest.approx(1_380_000, rel=1e-3)
assert all_models.pct_used == pytest.approx(21.0)
assert all_models.remaining_label == "12h 23m"
assert all_models.remaining_ms == pytest.approx(44_580_000, rel=1e-3)
assert sonnet.pct_used == pytest.approx(64.0)
assert sonnet.remaining_label == "3h 5m"
assert sonnet.remaining_ms == pytest.approx(11_100_000, rel=1e-3)
def test_flattened_fields_prioritize_current_session(self):
r = parse_claude_usage(FIXTURE_SECTIONED_WINDOWS)
assert r.current_pct == pytest.approx(77.0)
assert r.remaining_label == "23m"
assert r.remaining_ms == pytest.approx(1_380_000, rel=1e-3)
def test_weekly_stats_prefer_all_models_section(self):
r = parse_claude_usage(FIXTURE_SECTIONED_WINDOWS)
assert r.weekly_messages_used == 102
assert r.weekly_messages_limit == 500
assert r.weekly_tokens_used == 540_000
assert r.weekly_cost_usd == pytest.approx(2.34, rel=1e-3)

View File

@ -17,6 +17,7 @@ from app.services.openclaw.runtime_usage import (
_build_predictions,
_build_window,
_compute_burn_rate,
_oldest_active_ts,
_parse_sessions,
aggregate_per_model,
estimate_cost,
@ -242,6 +243,31 @@ def test_aggregate_per_model_ollama_not_flagged() -> None:
assert entry.cost_usd == 0.0
# ---------------------------------------------------------------------------
# _oldest_active_ts
# ---------------------------------------------------------------------------
def test_oldest_active_ts_ignores_events_outside_window() -> None:
now = _now_naive()
sessions = [
{"updatedAt": (now - timedelta(hours=6)).isoformat() + "Z"}, # outside 5h window
{"updatedAt": (now - timedelta(hours=2, minutes=30)).isoformat() + "Z"},
{"updatedAt": (now - timedelta(hours=1)).isoformat() + "Z"},
]
oldest = _oldest_active_ts(sessions, now)
assert oldest is not None
assert abs((oldest - (now - timedelta(hours=2, minutes=30))).total_seconds()) < 2
def test_oldest_active_ts_returns_none_when_all_events_outside_window() -> None:
now = _now_naive()
sessions = [
{"updatedAt": (now - timedelta(hours=8)).isoformat() + "Z"},
{"updatedAt": (now - timedelta(hours=6)).isoformat() + "Z"},
]
assert _oldest_active_ts(sessions, now) is None
# ---------------------------------------------------------------------------
# _build_window
# ---------------------------------------------------------------------------
@ -256,6 +282,23 @@ def test_build_window_falls_back_to_5h_rolling() -> None:
assert window.key == "5h"
assert abs((now - window.started_at).total_seconds() - 5 * 3600) < 5
assert window.reset_in_ms == 0 # resets_at == now
assert window.source == "local_jsonl_estimate"
assert window.confidence == "low"
def test_build_window_uses_oldest_active_event_for_local_reset() -> None:
now = _now_naive()
oldest_event = now - timedelta(hours=2, minutes=15)
window = _build_window({}, now, oldest_event_ts=oldest_event)
expected_reset = oldest_event + timedelta(hours=5)
expected_ms = int((expected_reset - now).total_seconds() * 1000)
assert window.started_at == oldest_event
assert window.resets_at == expected_reset
assert abs(window.reset_in_ms - expected_ms) < 1000
assert window.reset_in_ms > 0
assert window.source == "local_jsonl_estimate"
assert window.confidence == "low"
def test_build_window_uses_gateway_status() -> None:
@ -268,6 +311,19 @@ def test_build_window_uses_gateway_status() -> None:
}
window = _build_window(status_raw, now)
assert abs(window.reset_in_ms - 2 * 3600 * 1000) < 5000 # within 5 seconds
assert window.source == "provider_native"
assert window.confidence == "high"
def test_build_window_uses_ratelimit_reset_header_when_available() -> None:
now = _now_naive()
status_raw = {
"x_ratelimit_reset": "1800", # delta seconds
}
window = _build_window(status_raw, now)
assert abs(window.reset_in_ms - 1_800_000) < 5000
assert window.source == "provider_api_rate_limit"
assert window.confidence == "medium"
# ---------------------------------------------------------------------------

View File

@ -241,6 +241,7 @@ export * from "./providerCredentialUpdate";
export * from "./providerUsageLiveRead";
export * from "./providerUsageResponse";
export * from "./providerUsageScrapeResult";
export * from "./providerUsageWindow";
export * from "./readyzReadyzGet200";
export * from "./requestWindowRead";
export * from "./runtimeUsageBurnRate";

View File

@ -20,4 +20,6 @@ export interface ModelUsageEntry {
cost_usd: number;
calls: number;
unpriced: boolean;
/** Source of this usage data */
source?: string;
}

View File

@ -4,6 +4,7 @@
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
import type { ProviderUsageWindow } from "./providerUsageWindow";
/**
* Structured result from one provider-native usage scrape (e.g. Claude CLI /usage).
@ -18,6 +19,7 @@ export interface ProviderUsageScrapeResult {
scraped_at: string;
fresh: boolean;
freshness_ttl_seconds: number;
windows?: ProviderUsageWindow[];
current_pct?: number | null;
remaining_ms?: number | null;
remaining_label?: string | null;
@ -27,4 +29,8 @@ export interface ProviderUsageScrapeResult {
weekly_cost_usd?: number | null;
raw_text?: string | null;
error?: string | null;
/** Source of this scraped data */
source?: string | null;
/** Confidence level for this scraped data */
confidence?: string | null;
}

View File

@ -0,0 +1,20 @@
/**
* Generated by orval v8.3.0 🍺
* Do not edit manually.
* Mission Control API
* OpenAPI spec version: 0.1.0
*/
/**
* One provider-native usage window (session/week/model-specific).
*/
export interface ProviderUsageWindow {
key: string;
label: string;
pct_used?: number | null;
remaining_ms?: number | null;
remaining_label?: string | null;
extra_text?: string | null;
source?: string;
confidence?: string;
}

View File

@ -16,4 +16,8 @@ export interface RuntimeUsageCurrent {
token_pct?: number | null;
cost_limit_usd?: number | null;
cost_pct?: number | null;
/** Source of the token limit data */
token_limit_source?: string | null;
/** Source of the cost limit data */
cost_limit_source?: string | null;
}

View File

@ -13,4 +13,8 @@ export interface RuntimeUsageWindow {
started_at: string;
resets_at: string;
reset_in_ms: number;
/** Source of this window data */
source?: string;
/** Confidence level for this window data */
confidence?: string;
}

View File

@ -15,4 +15,6 @@ export interface TopSession {
cost_usd: number;
total_tokens: number;
updated_at?: string | null;
/** Source of this session data */
source?: string;
}

View File

@ -29,15 +29,22 @@ import {
} from "@/api/generated/metrics/metrics";
import {
gatewaysStatusApiV1GatewaysStatusGet,
getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet,
getGatewayRuntimeUsageApiV1GatewaysGatewayIdRuntimeUsageGet,
} from "@/api/generated/gateways/gateways";
import type { GatewaysStatusResponse } from "@/api/generated/model/gatewaysStatusResponse";
import type { CronStatusResponse, RuntimeUsageResponse, SystemHealthResponse } from "@/api/generated/model";
import type {
CronStatusResponse,
ProviderUsageResponse,
RuntimeUsageResponse,
SystemHealthResponse,
} from "@/api/generated/model";
import {
getGatewayCronApiV1GatewaysGatewayIdCronGet,
getGatewayHealthApiV1GatewaysGatewayIdHealthGet,
} from "@/api/generated/gateways/gateways";
import {
type ProviderNativeUsageWindow,
RuntimeUsageSection,
aggregateRuntimeUsage,
type AggregatedRuntimeUsage,
@ -672,6 +679,68 @@ export default function DashboardPage() {
});
const runtimeUsage = runtimeUsageQuery.data ?? null;
const providerUsageQuery = useQuery<ProviderNativeUsageWindow[], ApiError>({
queryKey: [
"dashboard",
"provider-usage",
gatewayTargets.map((t) => t.gatewayId),
],
enabled: Boolean(isSignedIn && hasConfiguredGateways),
refetchInterval: 30_000,
refetchOnMount: "always",
queryFn: async () => {
const settled = await Promise.allSettled(
gatewayTargets.map((target) =>
getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet(
target.gatewayId,
).then((res) => ({ target, res })),
),
);
const windows: ProviderNativeUsageWindow[] = [];
for (const item of settled) {
if (item.status !== "fulfilled") continue;
const { target, res } = item.value;
if (res.status !== 200) continue;
const payload = res.data as ProviderUsageResponse;
if (!payload.scraper_enabled) continue;
for (const scrape of payload.results ?? []) {
const source = scrape.source ?? "provider_native";
const confidence = scrape.confidence ?? "medium";
if (Array.isArray(scrape.windows) && scrape.windows.length > 0) {
for (const window of scrape.windows) {
windows.push({
key: window.key,
label: window.label,
pctUsed: window.pct_used ?? null,
remainingMs: window.remaining_ms ?? null,
remainingLabel: window.remaining_label ?? null,
source: window.source ?? source,
confidence: window.confidence ?? confidence,
provider: scrape.provider,
gatewayLabel: target.boardName,
});
}
continue;
}
if (scrape.current_pct !== null || scrape.remaining_ms !== null) {
windows.push({
key: "current_session",
label: "Current session",
pctUsed: scrape.current_pct ?? null,
remainingMs: scrape.remaining_ms ?? null,
remainingLabel: scrape.remaining_label ?? null,
source,
confidence,
provider: scrape.provider,
gatewayLabel: target.boardName,
});
}
}
}
return windows;
},
});
const providerUsageWindows = providerUsageQuery.data ?? [];
// Gateway health — query the first gateway only for the compact dashboard panel
const primaryGatewayId = gatewayTargets[0]?.gatewayId ?? null;
@ -1116,7 +1185,8 @@ export default function DashboardPage() {
<div className="mt-4">
<RuntimeUsageSection
usage={runtimeUsage}
isLoading={runtimeUsageQuery.isLoading}
providerUsageWindows={providerUsageWindows}
isLoading={runtimeUsageQuery.isLoading || providerUsageQuery.isLoading}
hasGateways={hasConfiguredGateways}
/>
</div>

View File

@ -1,7 +1,11 @@
"use client";
import { AlertTriangle, Clock, Flame, TrendingDown, Zap } from "lucide-react";
import type { ModelUsageEntry, RuntimeUsageResponse, TopSession } from "@/api/generated/model";
import type {
ModelUsageEntry,
RuntimeUsageResponse,
TopSession,
} from "@/api/generated/model";
import { DashboardSection } from "./DashboardSection";
import { DashboardEmptyState } from "./DashboardEmptyState";
@ -26,6 +30,18 @@ export interface AggregatedRuntimeUsage {
topSessions: TopSession[];
}
export interface ProviderNativeUsageWindow {
key: string;
label: string;
pctUsed: number | null;
remainingMs: number | null;
remainingLabel: string | null;
source: string;
confidence: string;
provider: string;
gatewayLabel: string;
}
// ---------------------------------------------------------------------------
// Aggregation helper (called from dashboard page)
// ---------------------------------------------------------------------------
@ -215,18 +231,22 @@ function StatCard({ label, value, sub, tone = "default", icon }: StatCardProps)
interface RuntimeUsageSectionProps {
usage: AggregatedRuntimeUsage | null;
providerUsageWindows: ProviderNativeUsageWindow[];
isLoading: boolean;
hasGateways: boolean;
}
export function RuntimeUsageSection({
usage,
providerUsageWindows,
isLoading,
hasGateways,
}: RuntimeUsageSectionProps) {
if (!hasGateways) return null;
const noData = !usage || (usage.totalCalls === 0 && Object.keys(usage.perModel).length === 0);
const hasRuntimeData = Boolean(
usage && (usage.totalCalls > 0 || Object.keys(usage.perModel).length > 0),
);
const safenessTone = usage
? usage.safe
? "success"
@ -236,6 +256,18 @@ export function RuntimeUsageSection({
const modelRows = usage
? Object.entries(usage.perModel).sort((a, b) => b[1].cost_usd - a[1].cost_usd)
: [];
const providerRows = [...providerUsageWindows].sort((a, b) => {
const order: Record<string, number> = {
current_session: 0,
weekly_all_models: 1,
weekly_sonnet: 2,
extra_usage: 3,
};
const left = order[a.key] ?? 99;
const right = order[b.key] ?? 99;
return left === right ? a.gatewayLabel.localeCompare(b.gatewayLabel) : left - right;
});
const noData = !hasRuntimeData && providerRows.length === 0;
return (
<DashboardSection title="Runtime Usage">
@ -243,40 +275,89 @@ export function RuntimeUsageSection({
<DashboardEmptyState message="Loading usage data…" />
) : noData ? (
<DashboardEmptyState message="No usage data yet. Usage appears after the first model call." />
) : usage ? (
) : hasRuntimeData || providerRows.length > 0 ? (
<div className="space-y-3">
{/* Top stat cards */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<StatCard
label="5h Spend"
value={fmtCost(usage.totalCostUsd)}
sub={`${fmtTokens(usage.totalTokens)} tokens`}
tone={usage.costLimitUsd && usage.totalCostUsd / usage.costLimitUsd > 0.8 ? "warning" : "default"}
icon={<Zap className="h-3 w-3" />}
/>
<StatCard
label="Reset In"
value={fmtMs(usage.resetInMs)}
sub={usage.resetsAt ? new Date(usage.resetsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : undefined}
icon={<Clock className="h-3 w-3" />}
/>
<StatCard
label="Time to Limit"
value={usage.timeToLimitMs === null ? "—" : usage.timeToLimitMs === 0 ? "At limit" : fmtMs(usage.timeToLimitMs)}
sub={usage.tokenLimit ? `${usage.tokenPct ?? 0}% of ${fmtTokens(usage.tokenLimit)}` : undefined}
tone={safenessTone}
icon={<TrendingDown className="h-3 w-3" />}
/>
<StatCard
label="Burn Rate"
value={usage.costUsdPerMinute > 0 ? `${fmtCost(usage.costUsdPerMinute)}/m` : "—"}
sub={usage.tokensPerMinute > 0 ? `${fmtTokens(usage.tokensPerMinute)} tok/m` : undefined}
icon={<Flame className="h-3 w-3" />}
/>
</div>
{providerRows.length > 0 && (
<div className="space-y-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted">
Provider-native usage windows
</p>
<div className="space-y-2">
{providerRows.map((window, index) => {
const pctUsed = window.pctUsed ?? 0;
const barTone =
pctUsed >= 90
? "bg-[color:var(--danger)]"
: pctUsed >= 75
? "bg-[color:var(--warning)]"
: "bg-[color:var(--success)]";
const resetText =
window.remainingLabel
?? (window.remainingMs !== null ? fmtMs(window.remainingMs) : "—");
const isEstimate = window.source !== "provider_native" || window.confidence !== "high";
return (
<div
key={`${window.gatewayLabel}-${window.provider}-${window.key}-${index}`}
className="rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] p-2"
>
<div className="mb-1 flex items-center justify-between gap-2">
<p className="text-xs font-medium text-strong">
{window.label}
{isEstimate ? " (estimate)" : ""}
</p>
<p className="text-xs tabular-nums text-muted">
{window.pctUsed === null ? "—" : `${Math.round(window.pctUsed)}% used`}
</p>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[color:var(--surface-muted)]">
<div
className={`h-full rounded-full transition-all duration-300 ${barTone}`}
style={{ width: `${Math.max(0, Math.min(100, pctUsed))}%` }}
/>
</div>
<p className="mt-1 text-[11px] text-muted">
Resets in {resetText} · {window.provider} · {window.gatewayLabel}
</p>
</div>
);
})}
</div>
</div>
)}
{usage && hasRuntimeData && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<StatCard
label="5h Spend"
value={fmtCost(usage.totalCostUsd)}
sub={`${fmtTokens(usage.totalTokens)} tokens`}
tone={usage.costLimitUsd && usage.totalCostUsd / usage.costLimitUsd > 0.8 ? "warning" : "default"}
icon={<Zap className="h-3 w-3" />}
/>
<StatCard
label="Reset In"
value={fmtMs(usage.resetInMs)}
sub={usage.resetsAt ? new Date(usage.resetsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : undefined}
icon={<Clock className="h-3 w-3" />}
/>
<StatCard
label="Time to Limit"
value={usage.timeToLimitMs === null ? "—" : usage.timeToLimitMs === 0 ? "At limit" : fmtMs(usage.timeToLimitMs)}
sub={usage.tokenLimit ? `${usage.tokenPct ?? 0}% of ${fmtTokens(usage.tokenLimit)}` : undefined}
tone={safenessTone}
icon={<TrendingDown className="h-3 w-3" />}
/>
<StatCard
label="Burn Rate"
value={usage.costUsdPerMinute > 0 ? `${fmtCost(usage.costUsdPerMinute)}/m` : "—"}
sub={usage.tokensPerMinute > 0 ? `${fmtTokens(usage.tokensPerMinute)} tok/m` : undefined}
icon={<Flame className="h-3 w-3" />}
/>
</div>
)}
{/* Unpriced models warning */}
{modelRows.some(([, e]) => e.unpriced) && (
{usage && hasRuntimeData && modelRows.some(([, e]) => e.unpriced) && (
<div className="flex items-start gap-2 rounded-lg border border-[color:var(--warning)]/35 bg-[color:rgba(251,191,36,0.10)] px-3 py-2 text-xs text-[color:var(--warning)]">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>Some models have no pricing config costs may be under-reported.</span>
@ -284,7 +365,7 @@ export function RuntimeUsageSection({
)}
{/* Per-model breakdown */}
{modelRows.length > 0 && (
{usage && hasRuntimeData && modelRows.length > 0 && (
<div className="overflow-hidden rounded-lg border border-[color:var(--border)]">
<table className="w-full text-xs">
<thead>