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 FUTURE.md
docs/runtime-usage-dashboard-plan.md docs/runtime-usage-dashboard-plan.md
docs/remaining-usage-accuracy-review-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 ( from app.schemas.runtime_usage import (
ProviderUsageResponse, ProviderUsageResponse,
ProviderUsageScrapeResult, ProviderUsageScrapeResult,
ProviderUsageWindow,
RuntimeUsageResponse, RuntimeUsageResponse,
) )
from app.db.session import async_session_maker from app.db.session import async_session_maker
@ -356,6 +357,19 @@ async def get_gateway_provider_usage(
scraped_at=r.scraped_at, scraped_at=r.scraped_at,
fresh=r.fresh, fresh=r.fresh,
freshness_ttl_seconds=r.freshness_ttl_seconds, 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, current_pct=r.parsed.current_pct,
remaining_ms=r.parsed.remaining_ms, remaining_ms=r.parsed.remaining_ms,
remaining_label=r.parsed.remaining_label, remaining_label=r.parsed.remaining_label,
@ -365,6 +379,8 @@ async def get_gateway_provider_usage(
weekly_cost_usd=r.parsed.weekly_cost_usd, weekly_cost_usd=r.parsed.weekly_cost_usd,
raw_text=r.parsed.raw_text, raw_text=r.parsed.raw_text,
error=r.error or r.parsed.error, error=r.error or r.parsed.error,
source=r.parsed.source,
confidence=r.parsed.confidence,
) )
for r in scrape_results for r in scrape_results
] ]

View File

@ -56,7 +56,7 @@ class BoardTaskCustomField(TenantScoped, table=True):
UniqueConstraint( UniqueConstraint(
"board_id", "board_id",
"task_custom_field_definition_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( UniqueConstraint(
"task_id", "task_id",
"task_custom_field_definition_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 datetime import datetime
from uuid import UUID from uuid import UUID
from sqlmodel import SQLModel from sqlmodel import Field, SQLModel
RUNTIME_ANNOTATION_TYPES = (datetime, UUID) RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
@ -106,7 +106,7 @@ class ProviderUsageScrapeResult(SQLModel):
fresh: bool # True if within the freshness window fresh: bool # True if within the freshness window
freshness_ttl_seconds: int freshness_ttl_seconds: int
windows: list[ProviderUsageWindow] = [] windows: list[ProviderUsageWindow] = Field(default_factory=list)
current_pct: float | None = None # 0100 % of current window used current_pct: float | None = None # 0100 % of current window used
remaining_ms: int | None = None # ms until window resets 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] = { DEFAULT_MODEL_PRICING: dict[str, _PricingEntry] = {
# Anthropic — Claude 4.x # Anthropic — Claude 4.x
"anthropic/claude-opus-4-7": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 3.75}, # Opus cache_write = $18.75/MTok (5x input price, per Anthropic docs)
"anthropic/claude-opus-4-5": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 3.75}, "anthropic/claude-opus-4-7": {"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-opus-4-5": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75},
"anthropic/claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "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-haiku-4-5": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00}, "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.x
"anthropic/claude-3-5-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, "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-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-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-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-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 family
"openai/gpt-4o": {"input": 2.50, "output": 10.00, "cache_read": 1.25, "cache_write": 0.00}, "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-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-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-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}, "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": {"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/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}, "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}, "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]: 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 global _pricing_cache
if _pricing_cache is not None: if _pricing_cache is not None:
return _pricing_cache return _pricing_cache
@ -86,10 +97,13 @@ def load_pricing() -> dict[str, _PricingEntry]:
if override_path: if override_path:
try: try:
with open(override_path) as fh: with open(override_path) as fh:
overrides = json.load(fh) raw = json.load(fh)
if isinstance(overrides, dict): if isinstance(raw, dict):
merged.update(overrides) # Unwrap reference-dashboard shape if present
logger.info("runtime_usage.pricing.override_loaded path=%s", override_path) 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: except Exception as exc:
logger.warning("runtime_usage.pricing.override_failed path=%s error=%s", override_path, exc) logger.warning("runtime_usage.pricing.override_failed path=%s error=%s", override_path, exc)
_pricing_cache = merged _pricing_cache = merged
@ -234,6 +248,70 @@ def _parse_datetime(value: object) -> datetime | None:
return 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 # Session parsing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -385,10 +463,32 @@ def _top_sessions(
_WINDOW_HOURS = 5 _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( def _build_window(
status_raw: dict[str, Any], status_raw: dict[str, Any],
now: datetime, now: datetime,
account_key: str = "default", account_key: str = "default",
oldest_event_ts: datetime | None = None,
) -> RuntimeUsageWindow: ) -> RuntimeUsageWindow:
"""Build the usage window, preferring gateway status data then falling back. """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 gateway status provides explicit window data, use provider_native
- If API rate-limit headers are the only source, use provider_api_rate_limit - If API rate-limit headers are the only source, use provider_api_rate_limit
- If falling back to local logic, use local_jsonl_estimate - 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 explicit_started_at = _parse_datetime(
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(
status_raw.get("windowStart") or status_raw.get("window_start") status_raw.get("windowStart") or status_raw.get("window_start")
or status_raw.get("period_start") or status_raw.get("started_at") 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") status_raw.get("windowEnd") or status_raw.get("window_end")
or status_raw.get("period_end") or status_raw.get("resets_at") 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: 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: if resets_at is None:
resets_at = started_at + timedelta(hours=_WINDOW_HOURS) 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_delta = resets_at - now
reset_in_ms = max(0, int(reset_delta.total_seconds() * 1000)) reset_in_ms = max(0, int(reset_delta.total_seconds() * 1000))
return RuntimeUsageWindow( return RuntimeUsageWindow(
@ -574,12 +670,20 @@ async def get_runtime_usage(
""" """
now = utcnow() 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.cost", config),
_safe_call("usage.status", 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) # Extract sessions from sessions.list response (primary source)
# Fallback to usage.cost if sessions.list fails # Fallback to usage.cost if sessions.list fails
if isinstance(sessions_raw, dict): if isinstance(sessions_raw, dict):
@ -588,20 +692,18 @@ async def get_runtime_usage(
raw_sessions = sessions_raw raw_sessions = sessions_raw
else: else:
raw_sessions = [] raw_sessions = []
# Filter to dicts and merge with usage.cost data if available
sessions: list[dict[str, Any]] = [] sessions: list[dict[str, Any]] = []
if raw_sessions: if raw_sessions:
sessions = [s for s in raw_sessions if isinstance(s, dict)] sessions = [s for s in raw_sessions if isinstance(s, dict)]
else: else:
# Fallback: parse from usage.cost response
sessions = _parse_sessions(cost_raw) sessions = _parse_sessions(cost_raw)
# Merge both payloads — some gateways return everything in one response
merged_status = {**cost_raw, **status_raw} merged_status = {**cost_raw, **status_raw}
per_model = aggregate_per_model(sessions, account_key=account_key) oldest_event_ts = _oldest_active_ts(sessions, now)
window = _build_window(merged_status, 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) current = _build_current(per_model, merged_status)
burn_rate = _compute_burn_rate(sessions, window, now) burn_rate = _compute_burn_rate(sessions, window, now)
predictions = _build_predictions(current, burn_rate, window) predictions = _build_predictions(current, burn_rate, window)

View File

@ -19,12 +19,11 @@ Tmux requirement for ClaudeTmuxScraper:
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os
import re import re
import tempfile import tempfile
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import ClassVar from typing import ClassVar
@ -37,11 +36,26 @@ logger = get_logger(__name__)
# Internal result dataclass (not a schema — stays inside the service layer) # 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 @dataclass
class ParsedClaudeUsage: class ParsedClaudeUsage:
"""Structured result from parsing one block of ``claude /usage`` text.""" """Structured result from parsing one block of ``claude /usage`` text."""
raw_text: str raw_text: str
windows: list[ParsedClaudeUsageWindow] = field(default_factory=list)
current_pct: float | None = None current_pct: float | None = None
remaining_ms: int | None = None remaining_ms: int | None = None
remaining_label: str | 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_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) _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: def _parse_remaining_ms(time_str: str) -> tuple[int, str] | None:
"""Convert a time string like '2h 47m', '1 day 4h', '< 1m' to milliseconds. """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 # Build a compact human label
parts = [] parts = []
if days: parts.append(f"{days}d") if days:
if hours: parts.append(f"{hours}h") parts.append(f"{days}d")
if minutes: parts.append(f"{minutes}m") if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
label = " ".join(parts) if parts else s label = " ".join(parts) if parts else s
return total_ms, label 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: def parse_claude_usage(raw: str) -> ParsedClaudeUsage:
"""Parse a block of text from ``claude /usage`` into structured fields. """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" result.error = "empty output"
return result return result
# ---- percentage -------------------------------------------------------- sections = _split_usage_sections(raw)
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
# ---- remaining time ---------------------------------------------------- seen_keys: set[str] = set()
time_str: str | None = None for key, label, text in sections:
if key in seen_keys and key != "extra_usage":
# Try inline "(resets in X)" pattern first (most specific) continue
inline = _INLINE_RESET_RE.search(raw) pct = _extract_pct(text)
if inline: remaining = _extract_remaining(text)
time_str = inline.group(1) remaining_ms = remaining[0] if remaining else None
remaining_label = remaining[1] if remaining else None
# Fall back to line-level patterns cleaned_text = text.strip()
if not time_str: extra_text = cleaned_text[:240] if cleaned_text else None
line_m = _RESET_LINE_RE.search(raw) has_metrics = pct is not None or remaining_ms is not None
if line_m: if not has_metrics and key != "extra_usage":
time_str = line_m.group(1).strip() continue
if not has_metrics and not extra_text:
if time_str: continue
parsed_time = _parse_remaining_ms(time_str) result.windows.append(
if parsed_time: ParsedClaudeUsageWindow(
result.remaining_ms, result.remaining_label = parsed_time key=key,
label=label,
# ---- weekly stats ----------------------------------------------------- pct_used=pct,
# Try to find a "weekly" or "this week" section remaining_ms=remaining_ms,
weekly_section = raw remaining_label=remaining_label,
week_header = re.search( extra_text=extra_text,
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
) )
if alt_tok: seen_keys.add(key)
try:
result.weekly_tokens_used = _parse_int(alt_tok.group(1))
except (ValueError, TypeError):
pass
# cost # Back-compat flattened fields still exist for existing clients.
cost_m = _WEEKLY_COST_RE.search(weekly_section) current_window = next((w for w in result.windows if w.key == "current_session"), None)
if cost_m: fallback_window = next(
try: (w for w in result.windows if w.pct_used is not None or w.remaining_ms is not None),
result.weekly_cost_usd = _parse_float(cost_m.group(1)) None,
except (ValueError, TypeError): )
pass 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 -------------------------------------------------------- # ---- validation --------------------------------------------------------
all_none = ( 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.remaining_ms is None
and result.weekly_messages_used is None and result.weekly_messages_used is None
and result.weekly_tokens_used is None and result.weekly_tokens_used is None
@ -271,6 +377,15 @@ def parse_claude_usage(raw: str) -> ParsedClaudeUsage:
if all_none: if all_none:
result.error = "no parseable usage data found" 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 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 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 # Tests
@ -287,3 +305,43 @@ class TestParseClaudeUsageEdgeCases:
"""Numbers like 1,234,567 must be parsed correctly.""" """Numbers like 1,234,567 must be parsed correctly."""
r = parse_claude_usage(FIXTURE_STANDARD) r = parse_claude_usage(FIXTURE_STANDARD)
assert r.weekly_tokens_used == 1_691_356 # 1,234,567 + 456,789 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_predictions,
_build_window, _build_window,
_compute_burn_rate, _compute_burn_rate,
_oldest_active_ts,
_parse_sessions, _parse_sessions,
aggregate_per_model, aggregate_per_model,
estimate_cost, estimate_cost,
@ -242,6 +243,31 @@ def test_aggregate_per_model_ollama_not_flagged() -> None:
assert entry.cost_usd == 0.0 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 # _build_window
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -256,6 +282,23 @@ def test_build_window_falls_back_to_5h_rolling() -> None:
assert window.key == "5h" assert window.key == "5h"
assert abs((now - window.started_at).total_seconds() - 5 * 3600) < 5 assert abs((now - window.started_at).total_seconds() - 5 * 3600) < 5
assert window.reset_in_ms == 0 # resets_at == now 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: 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) window = _build_window(status_raw, now)
assert abs(window.reset_in_ms - 2 * 3600 * 1000) < 5000 # within 5 seconds 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 "./providerUsageLiveRead";
export * from "./providerUsageResponse"; export * from "./providerUsageResponse";
export * from "./providerUsageScrapeResult"; export * from "./providerUsageScrapeResult";
export * from "./providerUsageWindow";
export * from "./readyzReadyzGet200"; export * from "./readyzReadyzGet200";
export * from "./requestWindowRead"; export * from "./requestWindowRead";
export * from "./runtimeUsageBurnRate"; export * from "./runtimeUsageBurnRate";

View File

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

View File

@ -4,6 +4,7 @@
* Mission Control API * Mission Control API
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { ProviderUsageWindow } from "./providerUsageWindow";
/** /**
* Structured result from one provider-native usage scrape (e.g. Claude CLI /usage). * Structured result from one provider-native usage scrape (e.g. Claude CLI /usage).
@ -18,6 +19,7 @@ export interface ProviderUsageScrapeResult {
scraped_at: string; scraped_at: string;
fresh: boolean; fresh: boolean;
freshness_ttl_seconds: number; freshness_ttl_seconds: number;
windows?: ProviderUsageWindow[];
current_pct?: number | null; current_pct?: number | null;
remaining_ms?: number | null; remaining_ms?: number | null;
remaining_label?: string | null; remaining_label?: string | null;
@ -27,4 +29,8 @@ export interface ProviderUsageScrapeResult {
weekly_cost_usd?: number | null; weekly_cost_usd?: number | null;
raw_text?: string | null; raw_text?: string | null;
error?: 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; token_pct?: number | null;
cost_limit_usd?: number | null; cost_limit_usd?: number | null;
cost_pct?: 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; started_at: string;
resets_at: string; resets_at: string;
reset_in_ms: number; 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; cost_usd: number;
total_tokens: number; total_tokens: number;
updated_at?: string | null; updated_at?: string | null;
/** Source of this session data */
source?: string;
} }

View File

@ -29,15 +29,22 @@ import {
} from "@/api/generated/metrics/metrics"; } from "@/api/generated/metrics/metrics";
import { import {
gatewaysStatusApiV1GatewaysStatusGet, gatewaysStatusApiV1GatewaysStatusGet,
getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet,
getGatewayRuntimeUsageApiV1GatewaysGatewayIdRuntimeUsageGet, getGatewayRuntimeUsageApiV1GatewaysGatewayIdRuntimeUsageGet,
} from "@/api/generated/gateways/gateways"; } from "@/api/generated/gateways/gateways";
import type { GatewaysStatusResponse } from "@/api/generated/model/gatewaysStatusResponse"; 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 { import {
getGatewayCronApiV1GatewaysGatewayIdCronGet, getGatewayCronApiV1GatewaysGatewayIdCronGet,
getGatewayHealthApiV1GatewaysGatewayIdHealthGet, getGatewayHealthApiV1GatewaysGatewayIdHealthGet,
} from "@/api/generated/gateways/gateways"; } from "@/api/generated/gateways/gateways";
import { import {
type ProviderNativeUsageWindow,
RuntimeUsageSection, RuntimeUsageSection,
aggregateRuntimeUsage, aggregateRuntimeUsage,
type AggregatedRuntimeUsage, type AggregatedRuntimeUsage,
@ -672,6 +679,68 @@ export default function DashboardPage() {
}); });
const runtimeUsage = runtimeUsageQuery.data ?? null; 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 // Gateway health — query the first gateway only for the compact dashboard panel
const primaryGatewayId = gatewayTargets[0]?.gatewayId ?? null; const primaryGatewayId = gatewayTargets[0]?.gatewayId ?? null;
@ -1116,7 +1185,8 @@ export default function DashboardPage() {
<div className="mt-4"> <div className="mt-4">
<RuntimeUsageSection <RuntimeUsageSection
usage={runtimeUsage} usage={runtimeUsage}
isLoading={runtimeUsageQuery.isLoading} providerUsageWindows={providerUsageWindows}
isLoading={runtimeUsageQuery.isLoading || providerUsageQuery.isLoading}
hasGateways={hasConfiguredGateways} hasGateways={hasConfiguredGateways}
/> />
</div> </div>

View File

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