From 02eb03d408345d90687d7a4d9bb6af7226fdd941 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 21 May 2026 01:32:59 -0500 Subject: [PATCH] feat(usage): fix local window estimation + provider-native windows + pricing updates (#37 #38 #40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 2 + backend/app/api/gateways.py | 16 + backend/app/models/task_custom_fields.py | 4 +- backend/app/schemas/runtime_usage.py | 4 +- .../app/services/openclaw/runtime_usage.py | 212 +++++++++---- .../app/services/openclaw/usage_scrapers.py | 291 ++++++++++++------ .../f0a1b2c3d4e5_add_missing_tables.py | 201 ++++++++++++ backend/tests/test_runtime_usage_scrapers.py | 58 ++++ backend/tests/test_runtime_usage_service.py | 56 ++++ frontend/src/api/generated/model/index.ts | 1 + .../api/generated/model/modelUsageEntry.ts | 2 + .../model/providerUsageScrapeResult.ts | 6 + .../generated/model/providerUsageWindow.ts | 20 ++ .../generated/model/runtimeUsageCurrent.ts | 4 + .../api/generated/model/runtimeUsageWindow.ts | 4 + .../src/api/generated/model/topSession.ts | 2 + frontend/src/app/dashboard/page.tsx | 74 ++++- .../dashboard/RuntimeUsageSection.tsx | 149 +++++++-- 18 files changed, 923 insertions(+), 183 deletions(-) create mode 100644 backend/migrations/versions/f0a1b2c3d4e5_add_missing_tables.py create mode 100644 frontend/src/api/generated/model/providerUsageWindow.ts diff --git a/.gitignore b/.gitignore index 1c2003a..35da004 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index b9ff9f0..8393766 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -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 ] diff --git a/backend/app/models/task_custom_fields.py b/backend/app/models/task_custom_fields.py index f6e2e06..7a6bbe3 100644 --- a/backend/app/models/task_custom_fields.py +++ b/backend/app/models/task_custom_fields.py @@ -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", ), ) diff --git a/backend/app/schemas/runtime_usage.py b/backend/app/schemas/runtime_usage.py index abeb943..7026da4 100644 --- a/backend/app/schemas/runtime_usage.py +++ b/backend/app/schemas/runtime_usage.py @@ -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 # 0–100 % of current window used remaining_ms: int | None = None # ms until window resets diff --git a/backend/app/services/openclaw/runtime_usage.py b/backend/app/services/openclaw/runtime_usage.py index 0ca9067..851a4a2 100644 --- a/backend/app/services/openclaw/runtime_usage.py +++ b/backend/app/services/openclaw/runtime_usage.py @@ -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) diff --git a/backend/app/services/openclaw/usage_scrapers.py b/backend/app/services/openclaw/usage_scrapers.py index 2c7c100..3c233ff 100644 --- a/backend/app/services/openclaw/usage_scrapers.py +++ b/backend/app/services/openclaw/usage_scrapers.py @@ -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 diff --git a/backend/migrations/versions/f0a1b2c3d4e5_add_missing_tables.py b/backend/migrations/versions/f0a1b2c3d4e5_add_missing_tables.py new file mode 100644 index 0000000..1a41325 --- /dev/null +++ b/backend/migrations/versions/f0a1b2c3d4e5_add_missing_tables.py @@ -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") diff --git a/backend/tests/test_runtime_usage_scrapers.py b/backend/tests/test_runtime_usage_scrapers.py index 7549dd7..a46991a 100644 --- a/backend/tests/test_runtime_usage_scrapers.py +++ b/backend/tests/test_runtime_usage_scrapers.py @@ -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) diff --git a/backend/tests/test_runtime_usage_service.py b/backend/tests/test_runtime_usage_service.py index 82e3032..5f7b7bd 100644 --- a/backend/tests/test_runtime_usage_service.py +++ b/backend/tests/test_runtime_usage_service.py @@ -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" # --------------------------------------------------------------------------- diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index 501c414..dd56806 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -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"; diff --git a/frontend/src/api/generated/model/modelUsageEntry.ts b/frontend/src/api/generated/model/modelUsageEntry.ts index c8f11cd..886f882 100644 --- a/frontend/src/api/generated/model/modelUsageEntry.ts +++ b/frontend/src/api/generated/model/modelUsageEntry.ts @@ -20,4 +20,6 @@ export interface ModelUsageEntry { cost_usd: number; calls: number; unpriced: boolean; + /** Source of this usage data */ + source?: string; } diff --git a/frontend/src/api/generated/model/providerUsageScrapeResult.ts b/frontend/src/api/generated/model/providerUsageScrapeResult.ts index 6cb1e37..3d3aaf3 100644 --- a/frontend/src/api/generated/model/providerUsageScrapeResult.ts +++ b/frontend/src/api/generated/model/providerUsageScrapeResult.ts @@ -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; } diff --git a/frontend/src/api/generated/model/providerUsageWindow.ts b/frontend/src/api/generated/model/providerUsageWindow.ts new file mode 100644 index 0000000..d1b7617 --- /dev/null +++ b/frontend/src/api/generated/model/providerUsageWindow.ts @@ -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; +} diff --git a/frontend/src/api/generated/model/runtimeUsageCurrent.ts b/frontend/src/api/generated/model/runtimeUsageCurrent.ts index d4dff98..c3e1b84 100644 --- a/frontend/src/api/generated/model/runtimeUsageCurrent.ts +++ b/frontend/src/api/generated/model/runtimeUsageCurrent.ts @@ -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; } diff --git a/frontend/src/api/generated/model/runtimeUsageWindow.ts b/frontend/src/api/generated/model/runtimeUsageWindow.ts index 781b90d..6f41e13 100644 --- a/frontend/src/api/generated/model/runtimeUsageWindow.ts +++ b/frontend/src/api/generated/model/runtimeUsageWindow.ts @@ -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; } diff --git a/frontend/src/api/generated/model/topSession.ts b/frontend/src/api/generated/model/topSession.ts index 5f9e0e0..1849f24 100644 --- a/frontend/src/api/generated/model/topSession.ts +++ b/frontend/src/api/generated/model/topSession.ts @@ -15,4 +15,6 @@ export interface TopSession { cost_usd: number; total_tokens: number; updated_at?: string | null; + /** Source of this session data */ + source?: string; } diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 46b545f..b5da6bf 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -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({ + 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() {
diff --git a/frontend/src/components/dashboard/RuntimeUsageSection.tsx b/frontend/src/components/dashboard/RuntimeUsageSection.tsx index 29c812c..a39febd 100644 --- a/frontend/src/components/dashboard/RuntimeUsageSection.tsx +++ b/frontend/src/components/dashboard/RuntimeUsageSection.tsx @@ -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 = { + 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 ( @@ -243,40 +275,89 @@ export function RuntimeUsageSection({ ) : noData ? ( - ) : usage ? ( + ) : hasRuntimeData || providerRows.length > 0 ? (
- {/* Top stat cards */} -
- 0.8 ? "warning" : "default"} - icon={} - /> - } - /> - } - /> - 0 ? `${fmtCost(usage.costUsdPerMinute)}/m` : "—"} - sub={usage.tokensPerMinute > 0 ? `${fmtTokens(usage.tokensPerMinute)} tok/m` : undefined} - icon={} - /> -
+ {providerRows.length > 0 && ( +
+

+ Provider-native usage windows +

+
+ {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 ( +
+
+

+ {window.label} + {isEstimate ? " (estimate)" : ""} +

+

+ {window.pctUsed === null ? "—" : `${Math.round(window.pctUsed)}% used`} +

+
+
+
+
+

+ Resets in {resetText} · {window.provider} · {window.gatewayLabel} +

+
+ ); + })} +
+
+ )} + + {usage && hasRuntimeData && ( +
+ 0.8 ? "warning" : "default"} + icon={} + /> + } + /> + } + /> + 0 ? `${fmtCost(usage.costUsdPerMinute)}/m` : "—"} + sub={usage.tokensPerMinute > 0 ? `${fmtTokens(usage.tokensPerMinute)} tok/m` : undefined} + icon={} + /> +
+ )} {/* Unpriced models warning */} - {modelRows.some(([, e]) => e.unpriced) && ( + {usage && hasRuntimeData && modelRows.some(([, e]) => e.unpriced) && (
Some models have no pricing config — costs may be under-reported. @@ -284,7 +365,7 @@ export function RuntimeUsageSection({ )} {/* Per-model breakdown */} - {modelRows.length > 0 && ( + {usage && hasRuntimeData && modelRows.length > 0 && (