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:
parent
8d11f4f840
commit
02eb03d408
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
@ -589,19 +693,17 @@ async def get_runtime_usage(
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -20,4 +20,6 @@ export interface ModelUsageEntry {
|
|||
cost_usd: number;
|
||||
calls: number;
|
||||
unpriced: boolean;
|
||||
/** Source of this usage data */
|
||||
source?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,6 @@ export interface TopSession {
|
|||
cost_usd: number;
|
||||
total_tokens: number;
|
||||
updated_at?: string | null;
|
||||
/** Source of this session data */
|
||||
source?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,15 +29,22 @@ import {
|
|||
} from "@/api/generated/metrics/metrics";
|
||||
import {
|
||||
gatewaysStatusApiV1GatewaysStatusGet,
|
||||
getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet,
|
||||
getGatewayRuntimeUsageApiV1GatewaysGatewayIdRuntimeUsageGet,
|
||||
} from "@/api/generated/gateways/gateways";
|
||||
import type { GatewaysStatusResponse } from "@/api/generated/model/gatewaysStatusResponse";
|
||||
import type { CronStatusResponse, RuntimeUsageResponse, SystemHealthResponse } from "@/api/generated/model";
|
||||
import type {
|
||||
CronStatusResponse,
|
||||
ProviderUsageResponse,
|
||||
RuntimeUsageResponse,
|
||||
SystemHealthResponse,
|
||||
} from "@/api/generated/model";
|
||||
import {
|
||||
getGatewayCronApiV1GatewaysGatewayIdCronGet,
|
||||
getGatewayHealthApiV1GatewaysGatewayIdHealthGet,
|
||||
} from "@/api/generated/gateways/gateways";
|
||||
import {
|
||||
type ProviderNativeUsageWindow,
|
||||
RuntimeUsageSection,
|
||||
aggregateRuntimeUsage,
|
||||
type AggregatedRuntimeUsage,
|
||||
|
|
@ -672,6 +679,68 @@ export default function DashboardPage() {
|
|||
});
|
||||
|
||||
const runtimeUsage = runtimeUsageQuery.data ?? null;
|
||||
const providerUsageQuery = useQuery<ProviderNativeUsageWindow[], ApiError>({
|
||||
queryKey: [
|
||||
"dashboard",
|
||||
"provider-usage",
|
||||
gatewayTargets.map((t) => t.gatewayId),
|
||||
],
|
||||
enabled: Boolean(isSignedIn && hasConfiguredGateways),
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
queryFn: async () => {
|
||||
const settled = await Promise.allSettled(
|
||||
gatewayTargets.map((target) =>
|
||||
getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet(
|
||||
target.gatewayId,
|
||||
).then((res) => ({ target, res })),
|
||||
),
|
||||
);
|
||||
const windows: ProviderNativeUsageWindow[] = [];
|
||||
for (const item of settled) {
|
||||
if (item.status !== "fulfilled") continue;
|
||||
const { target, res } = item.value;
|
||||
if (res.status !== 200) continue;
|
||||
const payload = res.data as ProviderUsageResponse;
|
||||
if (!payload.scraper_enabled) continue;
|
||||
for (const scrape of payload.results ?? []) {
|
||||
const source = scrape.source ?? "provider_native";
|
||||
const confidence = scrape.confidence ?? "medium";
|
||||
if (Array.isArray(scrape.windows) && scrape.windows.length > 0) {
|
||||
for (const window of scrape.windows) {
|
||||
windows.push({
|
||||
key: window.key,
|
||||
label: window.label,
|
||||
pctUsed: window.pct_used ?? null,
|
||||
remainingMs: window.remaining_ms ?? null,
|
||||
remainingLabel: window.remaining_label ?? null,
|
||||
source: window.source ?? source,
|
||||
confidence: window.confidence ?? confidence,
|
||||
provider: scrape.provider,
|
||||
gatewayLabel: target.boardName,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (scrape.current_pct !== null || scrape.remaining_ms !== null) {
|
||||
windows.push({
|
||||
key: "current_session",
|
||||
label: "Current session",
|
||||
pctUsed: scrape.current_pct ?? null,
|
||||
remainingMs: scrape.remaining_ms ?? null,
|
||||
remainingLabel: scrape.remaining_label ?? null,
|
||||
source,
|
||||
confidence,
|
||||
provider: scrape.provider,
|
||||
gatewayLabel: target.boardName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return windows;
|
||||
},
|
||||
});
|
||||
const providerUsageWindows = providerUsageQuery.data ?? [];
|
||||
|
||||
// Gateway health — query the first gateway only for the compact dashboard panel
|
||||
const primaryGatewayId = gatewayTargets[0]?.gatewayId ?? null;
|
||||
|
|
@ -1116,7 +1185,8 @@ export default function DashboardPage() {
|
|||
<div className="mt-4">
|
||||
<RuntimeUsageSection
|
||||
usage={runtimeUsage}
|
||||
isLoading={runtimeUsageQuery.isLoading}
|
||||
providerUsageWindows={providerUsageWindows}
|
||||
isLoading={runtimeUsageQuery.isLoading || providerUsageQuery.isLoading}
|
||||
hasGateways={hasConfiguredGateways}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, Clock, Flame, TrendingDown, Zap } from "lucide-react";
|
||||
import type { ModelUsageEntry, RuntimeUsageResponse, TopSession } from "@/api/generated/model";
|
||||
import type {
|
||||
ModelUsageEntry,
|
||||
RuntimeUsageResponse,
|
||||
TopSession,
|
||||
} from "@/api/generated/model";
|
||||
import { DashboardSection } from "./DashboardSection";
|
||||
import { DashboardEmptyState } from "./DashboardEmptyState";
|
||||
|
||||
|
|
@ -26,6 +30,18 @@ export interface AggregatedRuntimeUsage {
|
|||
topSessions: TopSession[];
|
||||
}
|
||||
|
||||
export interface ProviderNativeUsageWindow {
|
||||
key: string;
|
||||
label: string;
|
||||
pctUsed: number | null;
|
||||
remainingMs: number | null;
|
||||
remainingLabel: string | null;
|
||||
source: string;
|
||||
confidence: string;
|
||||
provider: string;
|
||||
gatewayLabel: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregation helper (called from dashboard page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -215,18 +231,22 @@ function StatCard({ label, value, sub, tone = "default", icon }: StatCardProps)
|
|||
|
||||
interface RuntimeUsageSectionProps {
|
||||
usage: AggregatedRuntimeUsage | null;
|
||||
providerUsageWindows: ProviderNativeUsageWindow[];
|
||||
isLoading: boolean;
|
||||
hasGateways: boolean;
|
||||
}
|
||||
|
||||
export function RuntimeUsageSection({
|
||||
usage,
|
||||
providerUsageWindows,
|
||||
isLoading,
|
||||
hasGateways,
|
||||
}: RuntimeUsageSectionProps) {
|
||||
if (!hasGateways) return null;
|
||||
|
||||
const noData = !usage || (usage.totalCalls === 0 && Object.keys(usage.perModel).length === 0);
|
||||
const hasRuntimeData = Boolean(
|
||||
usage && (usage.totalCalls > 0 || Object.keys(usage.perModel).length > 0),
|
||||
);
|
||||
const safenessTone = usage
|
||||
? usage.safe
|
||||
? "success"
|
||||
|
|
@ -236,6 +256,18 @@ export function RuntimeUsageSection({
|
|||
const modelRows = usage
|
||||
? Object.entries(usage.perModel).sort((a, b) => b[1].cost_usd - a[1].cost_usd)
|
||||
: [];
|
||||
const providerRows = [...providerUsageWindows].sort((a, b) => {
|
||||
const order: Record<string, number> = {
|
||||
current_session: 0,
|
||||
weekly_all_models: 1,
|
||||
weekly_sonnet: 2,
|
||||
extra_usage: 3,
|
||||
};
|
||||
const left = order[a.key] ?? 99;
|
||||
const right = order[b.key] ?? 99;
|
||||
return left === right ? a.gatewayLabel.localeCompare(b.gatewayLabel) : left - right;
|
||||
});
|
||||
const noData = !hasRuntimeData && providerRows.length === 0;
|
||||
|
||||
return (
|
||||
<DashboardSection title="Runtime Usage">
|
||||
|
|
@ -243,40 +275,89 @@ export function RuntimeUsageSection({
|
|||
<DashboardEmptyState message="Loading usage data…" />
|
||||
) : noData ? (
|
||||
<DashboardEmptyState message="No usage data yet. Usage appears after the first model call." />
|
||||
) : usage ? (
|
||||
) : hasRuntimeData || providerRows.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{/* Top stat cards */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<StatCard
|
||||
label="5h Spend"
|
||||
value={fmtCost(usage.totalCostUsd)}
|
||||
sub={`${fmtTokens(usage.totalTokens)} tokens`}
|
||||
tone={usage.costLimitUsd && usage.totalCostUsd / usage.costLimitUsd > 0.8 ? "warning" : "default"}
|
||||
icon={<Zap className="h-3 w-3" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Reset In"
|
||||
value={fmtMs(usage.resetInMs)}
|
||||
sub={usage.resetsAt ? new Date(usage.resetsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : undefined}
|
||||
icon={<Clock className="h-3 w-3" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Time to Limit"
|
||||
value={usage.timeToLimitMs === null ? "—" : usage.timeToLimitMs === 0 ? "At limit" : fmtMs(usage.timeToLimitMs)}
|
||||
sub={usage.tokenLimit ? `${usage.tokenPct ?? 0}% of ${fmtTokens(usage.tokenLimit)}` : undefined}
|
||||
tone={safenessTone}
|
||||
icon={<TrendingDown className="h-3 w-3" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Burn Rate"
|
||||
value={usage.costUsdPerMinute > 0 ? `${fmtCost(usage.costUsdPerMinute)}/m` : "—"}
|
||||
sub={usage.tokensPerMinute > 0 ? `${fmtTokens(usage.tokensPerMinute)} tok/m` : undefined}
|
||||
icon={<Flame className="h-3 w-3" />}
|
||||
/>
|
||||
</div>
|
||||
{providerRows.length > 0 && (
|
||||
<div className="space-y-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
||||
Provider-native usage windows
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{providerRows.map((window, index) => {
|
||||
const pctUsed = window.pctUsed ?? 0;
|
||||
const barTone =
|
||||
pctUsed >= 90
|
||||
? "bg-[color:var(--danger)]"
|
||||
: pctUsed >= 75
|
||||
? "bg-[color:var(--warning)]"
|
||||
: "bg-[color:var(--success)]";
|
||||
const resetText =
|
||||
window.remainingLabel
|
||||
?? (window.remainingMs !== null ? fmtMs(window.remainingMs) : "—");
|
||||
const isEstimate = window.source !== "provider_native" || window.confidence !== "high";
|
||||
return (
|
||||
<div
|
||||
key={`${window.gatewayLabel}-${window.provider}-${window.key}-${index}`}
|
||||
className="rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] p-2"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium text-strong">
|
||||
{window.label}
|
||||
{isEstimate ? " (estimate)" : ""}
|
||||
</p>
|
||||
<p className="text-xs tabular-nums text-muted">
|
||||
{window.pctUsed === null ? "—" : `${Math.round(window.pctUsed)}% used`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[color:var(--surface-muted)]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${barTone}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, pctUsed))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-muted">
|
||||
Resets in {resetText} · {window.provider} · {window.gatewayLabel}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usage && hasRuntimeData && (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<StatCard
|
||||
label="5h Spend"
|
||||
value={fmtCost(usage.totalCostUsd)}
|
||||
sub={`${fmtTokens(usage.totalTokens)} tokens`}
|
||||
tone={usage.costLimitUsd && usage.totalCostUsd / usage.costLimitUsd > 0.8 ? "warning" : "default"}
|
||||
icon={<Zap className="h-3 w-3" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Reset In"
|
||||
value={fmtMs(usage.resetInMs)}
|
||||
sub={usage.resetsAt ? new Date(usage.resetsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : undefined}
|
||||
icon={<Clock className="h-3 w-3" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Time to Limit"
|
||||
value={usage.timeToLimitMs === null ? "—" : usage.timeToLimitMs === 0 ? "At limit" : fmtMs(usage.timeToLimitMs)}
|
||||
sub={usage.tokenLimit ? `${usage.tokenPct ?? 0}% of ${fmtTokens(usage.tokenLimit)}` : undefined}
|
||||
tone={safenessTone}
|
||||
icon={<TrendingDown className="h-3 w-3" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Burn Rate"
|
||||
value={usage.costUsdPerMinute > 0 ? `${fmtCost(usage.costUsdPerMinute)}/m` : "—"}
|
||||
sub={usage.tokensPerMinute > 0 ? `${fmtTokens(usage.tokensPerMinute)} tok/m` : undefined}
|
||||
icon={<Flame className="h-3 w-3" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unpriced models warning */}
|
||||
{modelRows.some(([, e]) => e.unpriced) && (
|
||||
{usage && hasRuntimeData && modelRows.some(([, e]) => e.unpriced) && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-[color:var(--warning)]/35 bg-[color:rgba(251,191,36,0.10)] px-3 py-2 text-xs text-[color:var(--warning)]">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>Some models have no pricing config — costs may be under-reported.</span>
|
||||
|
|
@ -284,7 +365,7 @@ export function RuntimeUsageSection({
|
|||
)}
|
||||
|
||||
{/* Per-model breakdown */}
|
||||
{modelRows.length > 0 && (
|
||||
{usage && hasRuntimeData && modelRows.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border border-[color:var(--border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
|
|
|
|||
Loading…
Reference in New Issue