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
|
FUTURE.md
|
||||||
docs/runtime-usage-dashboard-plan.md
|
docs/runtime-usage-dashboard-plan.md
|
||||||
docs/remaining-usage-accuracy-review-plan.md
|
docs/remaining-usage-accuracy-review-plan.md
|
||||||
|
.learnings/bishop/LEARNINGS.md
|
||||||
|
.learnings/bishop/ERRORS.md
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ from app.schemas.gateway_ops import (
|
||||||
from app.schemas.runtime_usage import (
|
from app.schemas.runtime_usage import (
|
||||||
ProviderUsageResponse,
|
ProviderUsageResponse,
|
||||||
ProviderUsageScrapeResult,
|
ProviderUsageScrapeResult,
|
||||||
|
ProviderUsageWindow,
|
||||||
RuntimeUsageResponse,
|
RuntimeUsageResponse,
|
||||||
)
|
)
|
||||||
from app.db.session import async_session_maker
|
from app.db.session import async_session_maker
|
||||||
|
|
@ -356,6 +357,19 @@ async def get_gateway_provider_usage(
|
||||||
scraped_at=r.scraped_at,
|
scraped_at=r.scraped_at,
|
||||||
fresh=r.fresh,
|
fresh=r.fresh,
|
||||||
freshness_ttl_seconds=r.freshness_ttl_seconds,
|
freshness_ttl_seconds=r.freshness_ttl_seconds,
|
||||||
|
windows=[
|
||||||
|
ProviderUsageWindow(
|
||||||
|
key=w.key,
|
||||||
|
label=w.label,
|
||||||
|
pct_used=w.pct_used,
|
||||||
|
remaining_ms=w.remaining_ms,
|
||||||
|
remaining_label=w.remaining_label,
|
||||||
|
extra_text=w.extra_text,
|
||||||
|
source=w.source,
|
||||||
|
confidence=w.confidence,
|
||||||
|
)
|
||||||
|
for w in r.parsed.windows
|
||||||
|
],
|
||||||
current_pct=r.parsed.current_pct,
|
current_pct=r.parsed.current_pct,
|
||||||
remaining_ms=r.parsed.remaining_ms,
|
remaining_ms=r.parsed.remaining_ms,
|
||||||
remaining_label=r.parsed.remaining_label,
|
remaining_label=r.parsed.remaining_label,
|
||||||
|
|
@ -365,6 +379,8 @@ async def get_gateway_provider_usage(
|
||||||
weekly_cost_usd=r.parsed.weekly_cost_usd,
|
weekly_cost_usd=r.parsed.weekly_cost_usd,
|
||||||
raw_text=r.parsed.raw_text,
|
raw_text=r.parsed.raw_text,
|
||||||
error=r.error or r.parsed.error,
|
error=r.error or r.parsed.error,
|
||||||
|
source=r.parsed.source,
|
||||||
|
confidence=r.parsed.confidence,
|
||||||
)
|
)
|
||||||
for r in scrape_results
|
for r in scrape_results
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ class BoardTaskCustomField(TenantScoped, table=True):
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
"board_id",
|
"board_id",
|
||||||
"task_custom_field_definition_id",
|
"task_custom_field_definition_id",
|
||||||
name="uq_board_task_custom_fields_board_id_task_custom_field_definition_id",
|
name="uq_board_tcf_board_id_def_id",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ class TaskCustomFieldValue(TenantScoped, table=True):
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
"task_id",
|
"task_id",
|
||||||
"task_custom_field_definition_id",
|
"task_custom_field_definition_id",
|
||||||
name="uq_task_custom_field_values_task_id_task_custom_field_definition_id",
|
name="uq_tcf_values_task_id_def_id",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
|
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ class ProviderUsageScrapeResult(SQLModel):
|
||||||
fresh: bool # True if within the freshness window
|
fresh: bool # True if within the freshness window
|
||||||
freshness_ttl_seconds: int
|
freshness_ttl_seconds: int
|
||||||
|
|
||||||
windows: list[ProviderUsageWindow] = []
|
windows: list[ProviderUsageWindow] = Field(default_factory=list)
|
||||||
|
|
||||||
current_pct: float | None = None # 0–100 % of current window used
|
current_pct: float | None = None # 0–100 % of current window used
|
||||||
remaining_ms: int | None = None # ms until window resets
|
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] = {
|
DEFAULT_MODEL_PRICING: dict[str, _PricingEntry] = {
|
||||||
# Anthropic — Claude 4.x
|
# Anthropic — Claude 4.x
|
||||||
"anthropic/claude-opus-4-7": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 3.75},
|
# Opus cache_write = $18.75/MTok (5x input price, per Anthropic docs)
|
||||||
"anthropic/claude-opus-4-5": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 3.75},
|
"anthropic/claude-opus-4-7": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75},
|
||||||
"anthropic/claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
"anthropic/claude-opus-4-5": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75},
|
||||||
"anthropic/claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
"anthropic/claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
||||||
"anthropic/claude-haiku-4-5": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
|
"anthropic/claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
||||||
|
"anthropic/claude-haiku-4-5": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
|
||||||
# Anthropic — Claude 3.x
|
# Anthropic — Claude 3.x
|
||||||
"anthropic/claude-3-5-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
"anthropic/claude-3-5-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
||||||
"anthropic/claude-3-5-haiku": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
|
"anthropic/claude-3-5-haiku": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
|
||||||
"anthropic/claude-3-opus": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 3.75},
|
"anthropic/claude-3-opus": {"input": 15.00, "output": 75.00, "cache_read": 1.50, "cache_write": 18.75},
|
||||||
"anthropic/claude-3-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
"anthropic/claude-3-sonnet": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
|
||||||
"anthropic/claude-3-haiku": {"input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_write": 0.30},
|
"anthropic/claude-3-haiku": {"input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_write": 0.30},
|
||||||
|
# OpenAI — GPT-4.1 family (2025)
|
||||||
|
"openai/gpt-4.1": {"input": 2.00, "output": 8.00, "cache_read": 0.50, "cache_write": 0.00},
|
||||||
|
"openai/gpt-4.1-mini": {"input": 0.40, "output": 1.60, "cache_read": 0.10, "cache_write": 0.00},
|
||||||
|
"openai/gpt-4.1-nano": {"input": 0.10, "output": 0.40, "cache_read": 0.025, "cache_write": 0.00},
|
||||||
# OpenAI — GPT-4o family
|
# OpenAI — GPT-4o family
|
||||||
"openai/gpt-4o": {"input": 2.50, "output": 10.00, "cache_read": 1.25, "cache_write": 0.00},
|
"openai/gpt-4o": {"input": 2.50, "output": 10.00, "cache_read": 1.25, "cache_write": 0.00},
|
||||||
"openai/gpt-4o-mini": {"input": 0.15, "output": 0.60, "cache_read": 0.075, "cache_write": 0.00},
|
"openai/gpt-4o-mini": {"input": 0.15, "output": 0.60, "cache_read": 0.075, "cache_write": 0.00},
|
||||||
|
"openai/gpt-4-5": {"input": 75.0, "output": 150.0, "cache_read": 37.50, "cache_write": 0.00},
|
||||||
"openai/gpt-4-turbo": {"input": 10.00, "output": 30.00, "cache_read": 0.00, "cache_write": 0.00},
|
"openai/gpt-4-turbo": {"input": 10.00, "output": 30.00, "cache_read": 0.00, "cache_write": 0.00},
|
||||||
"openai/gpt-4": {"input": 30.00, "output": 60.00, "cache_read": 0.00, "cache_write": 0.00},
|
"openai/gpt-4": {"input": 30.00, "output": 60.00, "cache_read": 0.00, "cache_write": 0.00},
|
||||||
"openai/gpt-3-5-turbo": {"input": 0.50, "output": 1.50, "cache_read": 0.00, "cache_write": 0.00},
|
"openai/gpt-3-5-turbo": {"input": 0.50, "output": 1.50, "cache_read": 0.00, "cache_write": 0.00},
|
||||||
|
|
@ -69,7 +75,7 @@ DEFAULT_MODEL_PRICING: dict[str, _PricingEntry] = {
|
||||||
"openai/o3": {"input": 10.00, "output": 40.00, "cache_read": 2.50, "cache_write": 0.00},
|
"openai/o3": {"input": 10.00, "output": 40.00, "cache_read": 2.50, "cache_write": 0.00},
|
||||||
"openai/o3-mini": {"input": 1.10, "output": 4.40, "cache_read": 0.55, "cache_write": 0.00},
|
"openai/o3-mini": {"input": 1.10, "output": 4.40, "cache_read": 0.55, "cache_write": 0.00},
|
||||||
"openai/o4-mini": {"input": 1.10, "output": 4.40, "cache_read": 0.275, "cache_write": 0.00},
|
"openai/o4-mini": {"input": 1.10, "output": 4.40, "cache_read": 0.275, "cache_write": 0.00},
|
||||||
# Codex alias
|
# Codex alias (free under Codex plan)
|
||||||
"openai/codex": {"input": 0.00, "output": 0.00, "cache_read": 0.00, "cache_write": 0.00},
|
"openai/codex": {"input": 0.00, "output": 0.00, "cache_read": 0.00, "cache_write": 0.00},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +83,12 @@ _pricing_cache: dict[str, _PricingEntry] | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_pricing() -> dict[str, _PricingEntry]:
|
def load_pricing() -> dict[str, _PricingEntry]:
|
||||||
"""Return merged pricing: defaults + optional override file."""
|
"""Return merged pricing: defaults + optional override file.
|
||||||
|
|
||||||
|
Override file may use either shape:
|
||||||
|
{ "provider/model": { "input": X, "output": Y, ... } }
|
||||||
|
{ "rates_usd_per_million": { "provider/model": { ... } } }
|
||||||
|
"""
|
||||||
global _pricing_cache
|
global _pricing_cache
|
||||||
if _pricing_cache is not None:
|
if _pricing_cache is not None:
|
||||||
return _pricing_cache
|
return _pricing_cache
|
||||||
|
|
@ -86,10 +97,13 @@ def load_pricing() -> dict[str, _PricingEntry]:
|
||||||
if override_path:
|
if override_path:
|
||||||
try:
|
try:
|
||||||
with open(override_path) as fh:
|
with open(override_path) as fh:
|
||||||
overrides = json.load(fh)
|
raw = json.load(fh)
|
||||||
if isinstance(overrides, dict):
|
if isinstance(raw, dict):
|
||||||
merged.update(overrides)
|
# Unwrap reference-dashboard shape if present
|
||||||
logger.info("runtime_usage.pricing.override_loaded path=%s", override_path)
|
overrides: dict[str, Any] = raw.get("rates_usd_per_million", raw)
|
||||||
|
if isinstance(overrides, dict):
|
||||||
|
merged.update(overrides)
|
||||||
|
logger.info("runtime_usage.pricing.override_loaded path=%s count=%d", override_path, len(overrides))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("runtime_usage.pricing.override_failed path=%s error=%s", override_path, exc)
|
logger.warning("runtime_usage.pricing.override_failed path=%s error=%s", override_path, exc)
|
||||||
_pricing_cache = merged
|
_pricing_cache = merged
|
||||||
|
|
@ -234,6 +248,70 @@ def _parse_datetime(value: object) -> datetime | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rate_limit_reset_value(value: object, now: datetime) -> datetime | None:
|
||||||
|
"""Parse rate-limit reset values from headers into a UTC naive datetime.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- RFC3339/ISO timestamps
|
||||||
|
- Unix epoch seconds / milliseconds
|
||||||
|
- Delta-seconds strings
|
||||||
|
"""
|
||||||
|
parsed = _parse_datetime(value)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw = str(value).strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
numeric = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Very large values are likely epoch milliseconds.
|
||||||
|
if numeric >= 1_000_000_000_000:
|
||||||
|
return datetime.fromtimestamp(numeric / 1000, tz=timezone.utc).replace(tzinfo=None)
|
||||||
|
# Epoch seconds (typical range today ~1.7e9).
|
||||||
|
if numeric >= 1_000_000_000:
|
||||||
|
return datetime.fromtimestamp(numeric, tz=timezone.utc).replace(tzinfo=None)
|
||||||
|
# Otherwise treat as delta-seconds until reset.
|
||||||
|
if numeric >= 0:
|
||||||
|
return now + timedelta(seconds=numeric)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_rate_limit_reset_at(status_raw: dict[str, Any], now: datetime) -> datetime | None:
|
||||||
|
"""Find the first parseable rate-limit reset timestamp in status data."""
|
||||||
|
explicit_keys = (
|
||||||
|
"x_ratelimit_reset",
|
||||||
|
"x_ratelimit_reset_tokens",
|
||||||
|
"x_ratelimit_reset_requests",
|
||||||
|
"ratelimit_reset",
|
||||||
|
"anthropic_ratelimit_reset",
|
||||||
|
"anthropic_ratelimit_tokens_reset",
|
||||||
|
"anthropic_ratelimit_requests_reset",
|
||||||
|
"anthropic_ratelimit_input_tokens_reset",
|
||||||
|
)
|
||||||
|
for key in explicit_keys:
|
||||||
|
if key in status_raw:
|
||||||
|
parsed = _parse_rate_limit_reset_value(status_raw.get(key), now)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
# Defensive fallback: inspect any ratelimit*reset field that may appear.
|
||||||
|
for key, value in status_raw.items():
|
||||||
|
normalized = str(key).lower().replace("-", "_")
|
||||||
|
if "ratelimit" in normalized and "reset" in normalized:
|
||||||
|
parsed = _parse_rate_limit_reset_value(value, now)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Session parsing
|
# Session parsing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -385,10 +463,32 @@ def _top_sessions(
|
||||||
_WINDOW_HOURS = 5
|
_WINDOW_HOURS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _oldest_active_ts(
|
||||||
|
sessions: list[dict[str, Any]],
|
||||||
|
now: datetime,
|
||||||
|
) -> datetime | None:
|
||||||
|
"""Return the oldest session timestamp still inside the rolling window, or None."""
|
||||||
|
cutoff = now - timedelta(hours=_WINDOW_HOURS)
|
||||||
|
oldest: datetime | None = None
|
||||||
|
for session in sessions:
|
||||||
|
raw_ts = _get_str(
|
||||||
|
session,
|
||||||
|
"updated_at", "updatedAt", "lastActivity", "last_activity",
|
||||||
|
"created_at", "createdAt",
|
||||||
|
)
|
||||||
|
ts = _parse_datetime(raw_ts)
|
||||||
|
if ts is None or ts < cutoff:
|
||||||
|
continue
|
||||||
|
if oldest is None or ts < oldest:
|
||||||
|
oldest = ts
|
||||||
|
return oldest
|
||||||
|
|
||||||
|
|
||||||
def _build_window(
|
def _build_window(
|
||||||
status_raw: dict[str, Any],
|
status_raw: dict[str, Any],
|
||||||
now: datetime,
|
now: datetime,
|
||||||
account_key: str = "default",
|
account_key: str = "default",
|
||||||
|
oldest_event_ts: datetime | None = None,
|
||||||
) -> RuntimeUsageWindow:
|
) -> RuntimeUsageWindow:
|
||||||
"""Build the usage window, preferring gateway status data then falling back.
|
"""Build the usage window, preferring gateway status data then falling back.
|
||||||
|
|
||||||
|
|
@ -396,46 +496,42 @@ def _build_window(
|
||||||
- If gateway status provides explicit window data, use provider_native
|
- If gateway status provides explicit window data, use provider_native
|
||||||
- If API rate-limit headers are the only source, use provider_api_rate_limit
|
- If API rate-limit headers are the only source, use provider_api_rate_limit
|
||||||
- If falling back to local logic, use local_jsonl_estimate
|
- If falling back to local logic, use local_jsonl_estimate
|
||||||
|
|
||||||
|
When falling back, `oldest_event_ts` anchors the window start to the
|
||||||
|
oldest active session timestamp (oldest_event_ts + 5h = reset). This
|
||||||
|
avoids the previous bug where started_at = now - 5h made reset_in_ms = 0.
|
||||||
"""
|
"""
|
||||||
# Check if gateway status provides explicit window data
|
explicit_started_at = _parse_datetime(
|
||||||
has_window_start = status_raw.get("windowStart") or status_raw.get("window_start") or status_raw.get("period_start") or status_raw.get("started_at")
|
|
||||||
has_window_end = status_raw.get("windowEnd") or status_raw.get("window_end") or status_raw.get("period_end") or status_raw.get("resets_at")
|
|
||||||
|
|
||||||
# Check for API rate-limit headers (these indicate throttling, not subscription usage)
|
|
||||||
has_rate_limit_headers = (
|
|
||||||
status_raw.get("x_ratelimit_remaining") or
|
|
||||||
status_raw.get("x_ratelimit_limit") or
|
|
||||||
status_raw.get("x_ratelimit_reset") or
|
|
||||||
status_raw.get("anthropic_ratelimit_remaining") or
|
|
||||||
status_raw.get("anthropic_ratelimit_limit")
|
|
||||||
)
|
|
||||||
|
|
||||||
if has_window_start and has_window_end:
|
|
||||||
# Gateway status provides explicit window data
|
|
||||||
source = "provider_native"
|
|
||||||
confidence = "high"
|
|
||||||
elif has_rate_limit_headers:
|
|
||||||
# Only API rate-limit headers available - treat as diagnostics
|
|
||||||
source = "provider_api_rate_limit"
|
|
||||||
confidence = "medium"
|
|
||||||
else:
|
|
||||||
# Fall back to local logic (5-hour window from oldest event)
|
|
||||||
source = "local_jsonl_estimate"
|
|
||||||
confidence = "low"
|
|
||||||
|
|
||||||
started_at = _parse_datetime(
|
|
||||||
status_raw.get("windowStart") or status_raw.get("window_start")
|
status_raw.get("windowStart") or status_raw.get("window_start")
|
||||||
or status_raw.get("period_start") or status_raw.get("started_at")
|
or status_raw.get("period_start") or status_raw.get("started_at")
|
||||||
)
|
)
|
||||||
resets_at = _parse_datetime(
|
explicit_resets_at = _parse_datetime(
|
||||||
status_raw.get("windowEnd") or status_raw.get("window_end")
|
status_raw.get("windowEnd") or status_raw.get("window_end")
|
||||||
or status_raw.get("period_end") or status_raw.get("resets_at")
|
or status_raw.get("period_end") or status_raw.get("resets_at")
|
||||||
)
|
)
|
||||||
|
header_resets_at = _extract_rate_limit_reset_at(status_raw, now)
|
||||||
|
|
||||||
|
started_at = explicit_started_at
|
||||||
if started_at is None:
|
if started_at is None:
|
||||||
started_at = now - timedelta(hours=_WINDOW_HOURS)
|
# Anchor to oldest active session so reset_in_ms reflects real remaining time.
|
||||||
|
# If no sessions exist in the window, keep a conservative local fallback.
|
||||||
|
started_at = oldest_event_ts if oldest_event_ts is not None else now - timedelta(hours=_WINDOW_HOURS)
|
||||||
|
|
||||||
|
resets_at = explicit_resets_at or header_resets_at
|
||||||
if resets_at is None:
|
if resets_at is None:
|
||||||
resets_at = started_at + timedelta(hours=_WINDOW_HOURS)
|
resets_at = started_at + timedelta(hours=_WINDOW_HOURS)
|
||||||
|
|
||||||
|
# Label by reset-time provenance. Local fallback reset is always an estimate.
|
||||||
|
if explicit_resets_at is not None:
|
||||||
|
source = "provider_native"
|
||||||
|
confidence = "high"
|
||||||
|
elif header_resets_at is not None:
|
||||||
|
source = "provider_api_rate_limit"
|
||||||
|
confidence = "medium"
|
||||||
|
else:
|
||||||
|
source = "local_jsonl_estimate"
|
||||||
|
confidence = "low"
|
||||||
|
|
||||||
reset_delta = resets_at - now
|
reset_delta = resets_at - now
|
||||||
reset_in_ms = max(0, int(reset_delta.total_seconds() * 1000))
|
reset_in_ms = max(0, int(reset_delta.total_seconds() * 1000))
|
||||||
return RuntimeUsageWindow(
|
return RuntimeUsageWindow(
|
||||||
|
|
@ -574,12 +670,20 @@ async def get_runtime_usage(
|
||||||
"""
|
"""
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
|
|
||||||
cost_raw, status_raw, sessions_raw = await asyncio.gather(
|
cost_raw, status_raw = await asyncio.gather(
|
||||||
_safe_call("usage.cost", config),
|
_safe_call("usage.cost", config),
|
||||||
_safe_call("usage.status", config),
|
_safe_call("usage.status", config),
|
||||||
_safe_call("sessions.list", config),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# sessions.list can block the gateway on large session stores; cap at 8s.
|
||||||
|
try:
|
||||||
|
sessions_raw = await asyncio.wait_for(
|
||||||
|
_safe_call("sessions.list", config), timeout=8
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("runtime_usage.sessions_list.timeout gateway_id=%s", gateway_id)
|
||||||
|
sessions_raw = {}
|
||||||
|
|
||||||
# Extract sessions from sessions.list response (primary source)
|
# Extract sessions from sessions.list response (primary source)
|
||||||
# Fallback to usage.cost if sessions.list fails
|
# Fallback to usage.cost if sessions.list fails
|
||||||
if isinstance(sessions_raw, dict):
|
if isinstance(sessions_raw, dict):
|
||||||
|
|
@ -588,20 +692,18 @@ async def get_runtime_usage(
|
||||||
raw_sessions = sessions_raw
|
raw_sessions = sessions_raw
|
||||||
else:
|
else:
|
||||||
raw_sessions = []
|
raw_sessions = []
|
||||||
|
|
||||||
# Filter to dicts and merge with usage.cost data if available
|
|
||||||
sessions: list[dict[str, Any]] = []
|
sessions: list[dict[str, Any]] = []
|
||||||
if raw_sessions:
|
if raw_sessions:
|
||||||
sessions = [s for s in raw_sessions if isinstance(s, dict)]
|
sessions = [s for s in raw_sessions if isinstance(s, dict)]
|
||||||
else:
|
else:
|
||||||
# Fallback: parse from usage.cost response
|
|
||||||
sessions = _parse_sessions(cost_raw)
|
sessions = _parse_sessions(cost_raw)
|
||||||
|
|
||||||
# Merge both payloads — some gateways return everything in one response
|
|
||||||
merged_status = {**cost_raw, **status_raw}
|
merged_status = {**cost_raw, **status_raw}
|
||||||
|
|
||||||
per_model = aggregate_per_model(sessions, account_key=account_key)
|
oldest_event_ts = _oldest_active_ts(sessions, now)
|
||||||
window = _build_window(merged_status, now)
|
per_model = aggregate_per_model(sessions, account_key=account_key)
|
||||||
|
window = _build_window(merged_status, now, oldest_event_ts=oldest_event_ts)
|
||||||
current = _build_current(per_model, merged_status)
|
current = _build_current(per_model, merged_status)
|
||||||
burn_rate = _compute_burn_rate(sessions, window, now)
|
burn_rate = _compute_burn_rate(sessions, window, now)
|
||||||
predictions = _build_predictions(current, burn_rate, window)
|
predictions = _build_predictions(current, burn_rate, window)
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,11 @@ Tmux requirement for ClaudeTmuxScraper:
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
|
|
@ -37,11 +36,26 @@ logger = get_logger(__name__)
|
||||||
# Internal result dataclass (not a schema — stays inside the service layer)
|
# Internal result dataclass (not a schema — stays inside the service layer)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedClaudeUsageWindow:
|
||||||
|
"""One parsed usage window from `claude /usage` text."""
|
||||||
|
|
||||||
|
key: str # current_session | weekly_all_models | weekly_sonnet | extra_usage
|
||||||
|
label: str
|
||||||
|
pct_used: float | None = None
|
||||||
|
remaining_ms: int | None = None
|
||||||
|
remaining_label: str | None = None
|
||||||
|
extra_text: str | None = None
|
||||||
|
source: str = "provider_native"
|
||||||
|
confidence: str = "high"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ParsedClaudeUsage:
|
class ParsedClaudeUsage:
|
||||||
"""Structured result from parsing one block of ``claude /usage`` text."""
|
"""Structured result from parsing one block of ``claude /usage`` text."""
|
||||||
|
|
||||||
raw_text: str
|
raw_text: str
|
||||||
|
windows: list[ParsedClaudeUsageWindow] = field(default_factory=list)
|
||||||
current_pct: float | None = None
|
current_pct: float | None = None
|
||||||
remaining_ms: int | None = None
|
remaining_ms: int | None = None
|
||||||
remaining_label: str | None = None
|
remaining_label: str | None = None
|
||||||
|
|
@ -127,6 +141,16 @@ _WEEKLY_OUTPUT_RE = re.compile(r"output\s+tokens?[:\s]+([\d,]+)", re.IGNORECASE)
|
||||||
_WEEKLY_TOKENS_RE = re.compile(r"(?:total\s+)?tokens?[:\s]+([\d,]+)", re.IGNORECASE)
|
_WEEKLY_TOKENS_RE = re.compile(r"(?:total\s+)?tokens?[:\s]+([\d,]+)", re.IGNORECASE)
|
||||||
_WEEKLY_COST_RE = re.compile(r"\$\s*([\d]+\.[\d]{1,4})", re.IGNORECASE)
|
_WEEKLY_COST_RE = re.compile(r"\$\s*([\d]+\.[\d]{1,4})", re.IGNORECASE)
|
||||||
|
|
||||||
|
_SECTION_HEADER_CLEANUP_RE = re.compile(r"^[\s\-\*\|\u2022\u2502\u2500\u250c\u2510\u2514\u2518]+")
|
||||||
|
_WINDOW_HEADER_PATTERNS: tuple[tuple[re.Pattern[str], str, str], ...] = (
|
||||||
|
(re.compile(r"^current\s+session\b", re.IGNORECASE), "current_session", "Current session"),
|
||||||
|
(re.compile(r"^usage\s+window\b", re.IGNORECASE), "current_session", "Current session"),
|
||||||
|
(re.compile(r"^(?:this\s+week|weekly(?:\s+usage)?)\b", re.IGNORECASE), "weekly_all_models", "All models"),
|
||||||
|
(re.compile(r"^(?:all\s+models|weekly\s+all\s+models)\b", re.IGNORECASE), "weekly_all_models", "All models"),
|
||||||
|
(re.compile(r"^(?:weekly\s+sonnet|sonnet)\b", re.IGNORECASE), "weekly_sonnet", "Sonnet"),
|
||||||
|
(re.compile(r"^extra\s+usage\b", re.IGNORECASE), "extra_usage", "Extra usage"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_remaining_ms(time_str: str) -> tuple[int, str] | None:
|
def _parse_remaining_ms(time_str: str) -> tuple[int, str] | None:
|
||||||
"""Convert a time string like '2h 47m', '1 day 4h', '< 1m' to milliseconds.
|
"""Convert a time string like '2h 47m', '1 day 4h', '< 1m' to milliseconds.
|
||||||
|
|
@ -155,14 +179,119 @@ def _parse_remaining_ms(time_str: str) -> tuple[int, str] | None:
|
||||||
|
|
||||||
# Build a compact human label
|
# Build a compact human label
|
||||||
parts = []
|
parts = []
|
||||||
if days: parts.append(f"{days}d")
|
if days:
|
||||||
if hours: parts.append(f"{hours}h")
|
parts.append(f"{days}d")
|
||||||
if minutes: parts.append(f"{minutes}m")
|
if hours:
|
||||||
|
parts.append(f"{hours}h")
|
||||||
|
if minutes:
|
||||||
|
parts.append(f"{minutes}m")
|
||||||
label = " ".join(parts) if parts else s
|
label = " ".join(parts) if parts else s
|
||||||
|
|
||||||
return total_ms, label
|
return total_ms, label
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pct(text: str) -> float | None:
|
||||||
|
for match in _PCT_RE.finditer(text):
|
||||||
|
pct = float(match.group(1))
|
||||||
|
if 0.0 <= pct <= 100.0:
|
||||||
|
return pct
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_remaining(text: str) -> tuple[int, str] | None:
|
||||||
|
inline = _INLINE_RESET_RE.search(text)
|
||||||
|
if inline:
|
||||||
|
parsed = _parse_remaining_ms(inline.group(1))
|
||||||
|
if parsed:
|
||||||
|
return parsed
|
||||||
|
line_match = _RESET_LINE_RE.search(text)
|
||||||
|
if line_match:
|
||||||
|
return _parse_remaining_ms(line_match.group(1).strip())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_header(line: str) -> str:
|
||||||
|
return _SECTION_HEADER_CLEANUP_RE.sub("", line).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_window_key(line: str) -> tuple[str, str] | None:
|
||||||
|
normalized = _normalized_header(line)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
for pattern, key, label in _WINDOW_HEADER_PATTERNS:
|
||||||
|
if pattern.search(normalized):
|
||||||
|
return key, label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _split_usage_sections(raw: str) -> list[tuple[str, str, str]]:
|
||||||
|
sections: list[tuple[str, str, str]] = []
|
||||||
|
current_key: str | None = None
|
||||||
|
current_label: str | None = None
|
||||||
|
current_lines: list[str] = []
|
||||||
|
|
||||||
|
for line in raw.splitlines():
|
||||||
|
detected = _detect_window_key(line)
|
||||||
|
if detected is not None:
|
||||||
|
if current_key and current_lines:
|
||||||
|
sections.append((current_key, current_label or current_key, "\n".join(current_lines)))
|
||||||
|
current_key, current_label = detected
|
||||||
|
current_lines = [line]
|
||||||
|
continue
|
||||||
|
if current_key:
|
||||||
|
current_lines.append(line)
|
||||||
|
|
||||||
|
if current_key and current_lines:
|
||||||
|
sections.append((current_key, current_label or current_key, "\n".join(current_lines)))
|
||||||
|
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_weekly_stats(result: ParsedClaudeUsage, section_text: str, raw: str) -> None:
|
||||||
|
msgs_m = _WEEKLY_MSGS_RE.search(section_text)
|
||||||
|
if msgs_m:
|
||||||
|
try:
|
||||||
|
used_str = msgs_m.group(1) or msgs_m.group(3)
|
||||||
|
if used_str:
|
||||||
|
result.weekly_messages_used = _parse_int(used_str)
|
||||||
|
if msgs_m.group(2):
|
||||||
|
result.weekly_messages_limit = _parse_int(msgs_m.group(2))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
input_m = _WEEKLY_INPUT_RE.search(section_text)
|
||||||
|
output_m = _WEEKLY_OUTPUT_RE.search(section_text)
|
||||||
|
if input_m and output_m:
|
||||||
|
try:
|
||||||
|
result.weekly_tokens_used = (
|
||||||
|
_parse_int(input_m.group(1)) + _parse_int(output_m.group(1))
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
elif result.weekly_tokens_used is None:
|
||||||
|
tok_m = _WEEKLY_TOKENS_RE.search(section_text)
|
||||||
|
if tok_m:
|
||||||
|
try:
|
||||||
|
result.weekly_tokens_used = _parse_int(tok_m.group(1))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if result.weekly_tokens_used is None:
|
||||||
|
alt_tok = re.search(r"weekly\s+tokens?[:\s]+([\d,]+)", raw, re.IGNORECASE)
|
||||||
|
if alt_tok:
|
||||||
|
try:
|
||||||
|
result.weekly_tokens_used = _parse_int(alt_tok.group(1))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
cost_m = _WEEKLY_COST_RE.search(section_text)
|
||||||
|
if cost_m:
|
||||||
|
try:
|
||||||
|
result.weekly_cost_usd = _parse_float(cost_m.group(1))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def parse_claude_usage(raw: str) -> ParsedClaudeUsage:
|
def parse_claude_usage(raw: str) -> ParsedClaudeUsage:
|
||||||
"""Parse a block of text from ``claude /usage`` into structured fields.
|
"""Parse a block of text from ``claude /usage`` into structured fields.
|
||||||
|
|
||||||
|
|
@ -175,95 +304,72 @@ def parse_claude_usage(raw: str) -> ParsedClaudeUsage:
|
||||||
result.error = "empty output"
|
result.error = "empty output"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ---- percentage --------------------------------------------------------
|
sections = _split_usage_sections(raw)
|
||||||
for m in _PCT_RE.finditer(raw):
|
|
||||||
pct = float(m.group(1))
|
|
||||||
if 0.0 <= pct <= 100.0:
|
|
||||||
result.current_pct = pct
|
|
||||||
break # take the first plausible percentage
|
|
||||||
|
|
||||||
# ---- remaining time ----------------------------------------------------
|
seen_keys: set[str] = set()
|
||||||
time_str: str | None = None
|
for key, label, text in sections:
|
||||||
|
if key in seen_keys and key != "extra_usage":
|
||||||
# Try inline "(resets in X)" pattern first (most specific)
|
continue
|
||||||
inline = _INLINE_RESET_RE.search(raw)
|
pct = _extract_pct(text)
|
||||||
if inline:
|
remaining = _extract_remaining(text)
|
||||||
time_str = inline.group(1)
|
remaining_ms = remaining[0] if remaining else None
|
||||||
|
remaining_label = remaining[1] if remaining else None
|
||||||
# Fall back to line-level patterns
|
cleaned_text = text.strip()
|
||||||
if not time_str:
|
extra_text = cleaned_text[:240] if cleaned_text else None
|
||||||
line_m = _RESET_LINE_RE.search(raw)
|
has_metrics = pct is not None or remaining_ms is not None
|
||||||
if line_m:
|
if not has_metrics and key != "extra_usage":
|
||||||
time_str = line_m.group(1).strip()
|
continue
|
||||||
|
if not has_metrics and not extra_text:
|
||||||
if time_str:
|
continue
|
||||||
parsed_time = _parse_remaining_ms(time_str)
|
result.windows.append(
|
||||||
if parsed_time:
|
ParsedClaudeUsageWindow(
|
||||||
result.remaining_ms, result.remaining_label = parsed_time
|
key=key,
|
||||||
|
label=label,
|
||||||
# ---- weekly stats -----------------------------------------------------
|
pct_used=pct,
|
||||||
# Try to find a "weekly" or "this week" section
|
remaining_ms=remaining_ms,
|
||||||
weekly_section = raw
|
remaining_label=remaining_label,
|
||||||
week_header = re.search(
|
extra_text=extra_text,
|
||||||
r"(?:this\s+week|weekly|week\b)[^\n]*\n(.+?)(?=\n\s*\n|\Z)",
|
),
|
||||||
raw, re.IGNORECASE | re.DOTALL
|
|
||||||
)
|
|
||||||
if week_header:
|
|
||||||
weekly_section = week_header.group(0)
|
|
||||||
|
|
||||||
# messages (with optional limit)
|
|
||||||
msgs_m = _WEEKLY_MSGS_RE.search(weekly_section)
|
|
||||||
if msgs_m:
|
|
||||||
try:
|
|
||||||
# group(1) = "messages: N", group(3) = "N messages"
|
|
||||||
used_str = msgs_m.group(1) or msgs_m.group(3)
|
|
||||||
if used_str:
|
|
||||||
result.weekly_messages_used = _parse_int(used_str)
|
|
||||||
if msgs_m.group(2):
|
|
||||||
result.weekly_messages_limit = _parse_int(msgs_m.group(2))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# tokens — prefer input+output sum if both present
|
|
||||||
input_m = _WEEKLY_INPUT_RE.search(weekly_section)
|
|
||||||
output_m = _WEEKLY_OUTPUT_RE.search(weekly_section)
|
|
||||||
if input_m and output_m:
|
|
||||||
try:
|
|
||||||
result.weekly_tokens_used = (
|
|
||||||
_parse_int(input_m.group(1)) + _parse_int(output_m.group(1))
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
elif not result.weekly_tokens_used:
|
|
||||||
tok_m = _WEEKLY_TOKENS_RE.search(weekly_section)
|
|
||||||
if tok_m:
|
|
||||||
try:
|
|
||||||
result.weekly_tokens_used = _parse_int(tok_m.group(1))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Alt-key patterns: "weekly tokens: 9,876,543" anywhere in the text
|
|
||||||
if result.weekly_tokens_used is None:
|
|
||||||
alt_tok = re.search(
|
|
||||||
r"weekly\s+tokens?[:\s]+([\d,]+)", raw, re.IGNORECASE
|
|
||||||
)
|
)
|
||||||
if alt_tok:
|
seen_keys.add(key)
|
||||||
try:
|
|
||||||
result.weekly_tokens_used = _parse_int(alt_tok.group(1))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# cost
|
# Back-compat flattened fields still exist for existing clients.
|
||||||
cost_m = _WEEKLY_COST_RE.search(weekly_section)
|
current_window = next((w for w in result.windows if w.key == "current_session"), None)
|
||||||
if cost_m:
|
fallback_window = next(
|
||||||
try:
|
(w for w in result.windows if w.pct_used is not None or w.remaining_ms is not None),
|
||||||
result.weekly_cost_usd = _parse_float(cost_m.group(1))
|
None,
|
||||||
except (ValueError, TypeError):
|
)
|
||||||
pass
|
primary_window = current_window or fallback_window
|
||||||
|
if primary_window:
|
||||||
|
result.current_pct = primary_window.pct_used
|
||||||
|
result.remaining_ms = primary_window.remaining_ms
|
||||||
|
result.remaining_label = primary_window.remaining_label
|
||||||
|
else:
|
||||||
|
result.current_pct = _extract_pct(raw)
|
||||||
|
fallback_remaining = _extract_remaining(raw)
|
||||||
|
if fallback_remaining:
|
||||||
|
result.remaining_ms, result.remaining_label = fallback_remaining
|
||||||
|
|
||||||
|
weekly_section_text = raw
|
||||||
|
for key, _, text in sections:
|
||||||
|
if key == "weekly_all_models":
|
||||||
|
weekly_section_text = text
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
week_header = re.search(
|
||||||
|
r"(?:this\s+week|weekly|week\b)[^\n]*\n(.+?)(?=\n\s*\n|\Z)",
|
||||||
|
raw,
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
if week_header:
|
||||||
|
weekly_section_text = week_header.group(0)
|
||||||
|
|
||||||
|
_parse_weekly_stats(result, weekly_section_text, raw)
|
||||||
|
|
||||||
# ---- validation --------------------------------------------------------
|
# ---- validation --------------------------------------------------------
|
||||||
all_none = (
|
all_none = (
|
||||||
result.current_pct is None
|
len(result.windows) == 0
|
||||||
|
and result.current_pct is None
|
||||||
and result.remaining_ms is None
|
and result.remaining_ms is None
|
||||||
and result.weekly_messages_used is None
|
and result.weekly_messages_used is None
|
||||||
and result.weekly_tokens_used is None
|
and result.weekly_tokens_used is None
|
||||||
|
|
@ -271,6 +377,15 @@ def parse_claude_usage(raw: str) -> ParsedClaudeUsage:
|
||||||
if all_none:
|
if all_none:
|
||||||
result.error = "no parseable usage data found"
|
result.error = "no parseable usage data found"
|
||||||
|
|
||||||
|
compatibility_none = (
|
||||||
|
result.current_pct is None
|
||||||
|
and result.remaining_ms is None
|
||||||
|
and result.weekly_messages_used is None
|
||||||
|
and result.weekly_tokens_used is None
|
||||||
|
)
|
||||||
|
if compatibility_none and result.error is None:
|
||||||
|
result.error = "no parseable usage data found"
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
weekly tokens: 9,876,543
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
FIXTURE_SECTIONED_WINDOWS = """
|
||||||
|
Current session
|
||||||
|
77% used
|
||||||
|
Resets in: 23m
|
||||||
|
|
||||||
|
All models
|
||||||
|
21% used
|
||||||
|
Resets in: 12h 23m
|
||||||
|
Messages: 102 / 500
|
||||||
|
Input tokens: 450,000
|
||||||
|
Output tokens: 90,000
|
||||||
|
Est. cost: $2.34
|
||||||
|
|
||||||
|
Sonnet
|
||||||
|
64% used
|
||||||
|
Resets in: 3h 5m
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests
|
# Tests
|
||||||
|
|
@ -287,3 +305,43 @@ class TestParseClaudeUsageEdgeCases:
|
||||||
"""Numbers like 1,234,567 must be parsed correctly."""
|
"""Numbers like 1,234,567 must be parsed correctly."""
|
||||||
r = parse_claude_usage(FIXTURE_STANDARD)
|
r = parse_claude_usage(FIXTURE_STANDARD)
|
||||||
assert r.weekly_tokens_used == 1_691_356 # 1,234,567 + 456,789
|
assert r.weekly_tokens_used == 1_691_356 # 1,234,567 + 456,789
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseClaudeUsageSectionedWindows:
|
||||||
|
|
||||||
|
def test_parses_independent_windows(self):
|
||||||
|
r = parse_claude_usage(FIXTURE_SECTIONED_WINDOWS)
|
||||||
|
by_key = {window.key: window for window in r.windows}
|
||||||
|
|
||||||
|
assert "current_session" in by_key
|
||||||
|
assert "weekly_all_models" in by_key
|
||||||
|
assert "weekly_sonnet" in by_key
|
||||||
|
|
||||||
|
current = by_key["current_session"]
|
||||||
|
all_models = by_key["weekly_all_models"]
|
||||||
|
sonnet = by_key["weekly_sonnet"]
|
||||||
|
|
||||||
|
assert current.pct_used == pytest.approx(77.0)
|
||||||
|
assert current.remaining_label == "23m"
|
||||||
|
assert current.remaining_ms == pytest.approx(1_380_000, rel=1e-3)
|
||||||
|
|
||||||
|
assert all_models.pct_used == pytest.approx(21.0)
|
||||||
|
assert all_models.remaining_label == "12h 23m"
|
||||||
|
assert all_models.remaining_ms == pytest.approx(44_580_000, rel=1e-3)
|
||||||
|
|
||||||
|
assert sonnet.pct_used == pytest.approx(64.0)
|
||||||
|
assert sonnet.remaining_label == "3h 5m"
|
||||||
|
assert sonnet.remaining_ms == pytest.approx(11_100_000, rel=1e-3)
|
||||||
|
|
||||||
|
def test_flattened_fields_prioritize_current_session(self):
|
||||||
|
r = parse_claude_usage(FIXTURE_SECTIONED_WINDOWS)
|
||||||
|
assert r.current_pct == pytest.approx(77.0)
|
||||||
|
assert r.remaining_label == "23m"
|
||||||
|
assert r.remaining_ms == pytest.approx(1_380_000, rel=1e-3)
|
||||||
|
|
||||||
|
def test_weekly_stats_prefer_all_models_section(self):
|
||||||
|
r = parse_claude_usage(FIXTURE_SECTIONED_WINDOWS)
|
||||||
|
assert r.weekly_messages_used == 102
|
||||||
|
assert r.weekly_messages_limit == 500
|
||||||
|
assert r.weekly_tokens_used == 540_000
|
||||||
|
assert r.weekly_cost_usd == pytest.approx(2.34, rel=1e-3)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from app.services.openclaw.runtime_usage import (
|
||||||
_build_predictions,
|
_build_predictions,
|
||||||
_build_window,
|
_build_window,
|
||||||
_compute_burn_rate,
|
_compute_burn_rate,
|
||||||
|
_oldest_active_ts,
|
||||||
_parse_sessions,
|
_parse_sessions,
|
||||||
aggregate_per_model,
|
aggregate_per_model,
|
||||||
estimate_cost,
|
estimate_cost,
|
||||||
|
|
@ -242,6 +243,31 @@ def test_aggregate_per_model_ollama_not_flagged() -> None:
|
||||||
assert entry.cost_usd == 0.0
|
assert entry.cost_usd == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _oldest_active_ts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_oldest_active_ts_ignores_events_outside_window() -> None:
|
||||||
|
now = _now_naive()
|
||||||
|
sessions = [
|
||||||
|
{"updatedAt": (now - timedelta(hours=6)).isoformat() + "Z"}, # outside 5h window
|
||||||
|
{"updatedAt": (now - timedelta(hours=2, minutes=30)).isoformat() + "Z"},
|
||||||
|
{"updatedAt": (now - timedelta(hours=1)).isoformat() + "Z"},
|
||||||
|
]
|
||||||
|
oldest = _oldest_active_ts(sessions, now)
|
||||||
|
assert oldest is not None
|
||||||
|
assert abs((oldest - (now - timedelta(hours=2, minutes=30))).total_seconds()) < 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_oldest_active_ts_returns_none_when_all_events_outside_window() -> None:
|
||||||
|
now = _now_naive()
|
||||||
|
sessions = [
|
||||||
|
{"updatedAt": (now - timedelta(hours=8)).isoformat() + "Z"},
|
||||||
|
{"updatedAt": (now - timedelta(hours=6)).isoformat() + "Z"},
|
||||||
|
]
|
||||||
|
assert _oldest_active_ts(sessions, now) is None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _build_window
|
# _build_window
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -256,6 +282,23 @@ def test_build_window_falls_back_to_5h_rolling() -> None:
|
||||||
assert window.key == "5h"
|
assert window.key == "5h"
|
||||||
assert abs((now - window.started_at).total_seconds() - 5 * 3600) < 5
|
assert abs((now - window.started_at).total_seconds() - 5 * 3600) < 5
|
||||||
assert window.reset_in_ms == 0 # resets_at == now
|
assert window.reset_in_ms == 0 # resets_at == now
|
||||||
|
assert window.source == "local_jsonl_estimate"
|
||||||
|
assert window.confidence == "low"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_window_uses_oldest_active_event_for_local_reset() -> None:
|
||||||
|
now = _now_naive()
|
||||||
|
oldest_event = now - timedelta(hours=2, minutes=15)
|
||||||
|
window = _build_window({}, now, oldest_event_ts=oldest_event)
|
||||||
|
expected_reset = oldest_event + timedelta(hours=5)
|
||||||
|
expected_ms = int((expected_reset - now).total_seconds() * 1000)
|
||||||
|
|
||||||
|
assert window.started_at == oldest_event
|
||||||
|
assert window.resets_at == expected_reset
|
||||||
|
assert abs(window.reset_in_ms - expected_ms) < 1000
|
||||||
|
assert window.reset_in_ms > 0
|
||||||
|
assert window.source == "local_jsonl_estimate"
|
||||||
|
assert window.confidence == "low"
|
||||||
|
|
||||||
|
|
||||||
def test_build_window_uses_gateway_status() -> None:
|
def test_build_window_uses_gateway_status() -> None:
|
||||||
|
|
@ -268,6 +311,19 @@ def test_build_window_uses_gateway_status() -> None:
|
||||||
}
|
}
|
||||||
window = _build_window(status_raw, now)
|
window = _build_window(status_raw, now)
|
||||||
assert abs(window.reset_in_ms - 2 * 3600 * 1000) < 5000 # within 5 seconds
|
assert abs(window.reset_in_ms - 2 * 3600 * 1000) < 5000 # within 5 seconds
|
||||||
|
assert window.source == "provider_native"
|
||||||
|
assert window.confidence == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_window_uses_ratelimit_reset_header_when_available() -> None:
|
||||||
|
now = _now_naive()
|
||||||
|
status_raw = {
|
||||||
|
"x_ratelimit_reset": "1800", # delta seconds
|
||||||
|
}
|
||||||
|
window = _build_window(status_raw, now)
|
||||||
|
assert abs(window.reset_in_ms - 1_800_000) < 5000
|
||||||
|
assert window.source == "provider_api_rate_limit"
|
||||||
|
assert window.confidence == "medium"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,7 @@ export * from "./providerCredentialUpdate";
|
||||||
export * from "./providerUsageLiveRead";
|
export * from "./providerUsageLiveRead";
|
||||||
export * from "./providerUsageResponse";
|
export * from "./providerUsageResponse";
|
||||||
export * from "./providerUsageScrapeResult";
|
export * from "./providerUsageScrapeResult";
|
||||||
|
export * from "./providerUsageWindow";
|
||||||
export * from "./readyzReadyzGet200";
|
export * from "./readyzReadyzGet200";
|
||||||
export * from "./requestWindowRead";
|
export * from "./requestWindowRead";
|
||||||
export * from "./runtimeUsageBurnRate";
|
export * from "./runtimeUsageBurnRate";
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,6 @@ export interface ModelUsageEntry {
|
||||||
cost_usd: number;
|
cost_usd: number;
|
||||||
calls: number;
|
calls: number;
|
||||||
unpriced: boolean;
|
unpriced: boolean;
|
||||||
|
/** Source of this usage data */
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
* Mission Control API
|
* Mission Control API
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { ProviderUsageWindow } from "./providerUsageWindow";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structured result from one provider-native usage scrape (e.g. Claude CLI /usage).
|
* Structured result from one provider-native usage scrape (e.g. Claude CLI /usage).
|
||||||
|
|
@ -18,6 +19,7 @@ export interface ProviderUsageScrapeResult {
|
||||||
scraped_at: string;
|
scraped_at: string;
|
||||||
fresh: boolean;
|
fresh: boolean;
|
||||||
freshness_ttl_seconds: number;
|
freshness_ttl_seconds: number;
|
||||||
|
windows?: ProviderUsageWindow[];
|
||||||
current_pct?: number | null;
|
current_pct?: number | null;
|
||||||
remaining_ms?: number | null;
|
remaining_ms?: number | null;
|
||||||
remaining_label?: string | null;
|
remaining_label?: string | null;
|
||||||
|
|
@ -27,4 +29,8 @@ export interface ProviderUsageScrapeResult {
|
||||||
weekly_cost_usd?: number | null;
|
weekly_cost_usd?: number | null;
|
||||||
raw_text?: string | null;
|
raw_text?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
/** Source of this scraped data */
|
||||||
|
source?: string | null;
|
||||||
|
/** Confidence level for this scraped data */
|
||||||
|
confidence?: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
token_pct?: number | null;
|
||||||
cost_limit_usd?: number | null;
|
cost_limit_usd?: number | null;
|
||||||
cost_pct?: number | null;
|
cost_pct?: number | null;
|
||||||
|
/** Source of the token limit data */
|
||||||
|
token_limit_source?: string | null;
|
||||||
|
/** Source of the cost limit data */
|
||||||
|
cost_limit_source?: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,8 @@ export interface RuntimeUsageWindow {
|
||||||
started_at: string;
|
started_at: string;
|
||||||
resets_at: string;
|
resets_at: string;
|
||||||
reset_in_ms: number;
|
reset_in_ms: number;
|
||||||
|
/** Source of this window data */
|
||||||
|
source?: string;
|
||||||
|
/** Confidence level for this window data */
|
||||||
|
confidence?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,6 @@ export interface TopSession {
|
||||||
cost_usd: number;
|
cost_usd: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
|
/** Source of this session data */
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,22 @@ import {
|
||||||
} from "@/api/generated/metrics/metrics";
|
} from "@/api/generated/metrics/metrics";
|
||||||
import {
|
import {
|
||||||
gatewaysStatusApiV1GatewaysStatusGet,
|
gatewaysStatusApiV1GatewaysStatusGet,
|
||||||
|
getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet,
|
||||||
getGatewayRuntimeUsageApiV1GatewaysGatewayIdRuntimeUsageGet,
|
getGatewayRuntimeUsageApiV1GatewaysGatewayIdRuntimeUsageGet,
|
||||||
} from "@/api/generated/gateways/gateways";
|
} from "@/api/generated/gateways/gateways";
|
||||||
import type { GatewaysStatusResponse } from "@/api/generated/model/gatewaysStatusResponse";
|
import type { GatewaysStatusResponse } from "@/api/generated/model/gatewaysStatusResponse";
|
||||||
import type { CronStatusResponse, RuntimeUsageResponse, SystemHealthResponse } from "@/api/generated/model";
|
import type {
|
||||||
|
CronStatusResponse,
|
||||||
|
ProviderUsageResponse,
|
||||||
|
RuntimeUsageResponse,
|
||||||
|
SystemHealthResponse,
|
||||||
|
} from "@/api/generated/model";
|
||||||
import {
|
import {
|
||||||
getGatewayCronApiV1GatewaysGatewayIdCronGet,
|
getGatewayCronApiV1GatewaysGatewayIdCronGet,
|
||||||
getGatewayHealthApiV1GatewaysGatewayIdHealthGet,
|
getGatewayHealthApiV1GatewaysGatewayIdHealthGet,
|
||||||
} from "@/api/generated/gateways/gateways";
|
} from "@/api/generated/gateways/gateways";
|
||||||
import {
|
import {
|
||||||
|
type ProviderNativeUsageWindow,
|
||||||
RuntimeUsageSection,
|
RuntimeUsageSection,
|
||||||
aggregateRuntimeUsage,
|
aggregateRuntimeUsage,
|
||||||
type AggregatedRuntimeUsage,
|
type AggregatedRuntimeUsage,
|
||||||
|
|
@ -672,6 +679,68 @@ export default function DashboardPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const runtimeUsage = runtimeUsageQuery.data ?? null;
|
const runtimeUsage = runtimeUsageQuery.data ?? null;
|
||||||
|
const providerUsageQuery = useQuery<ProviderNativeUsageWindow[], ApiError>({
|
||||||
|
queryKey: [
|
||||||
|
"dashboard",
|
||||||
|
"provider-usage",
|
||||||
|
gatewayTargets.map((t) => t.gatewayId),
|
||||||
|
],
|
||||||
|
enabled: Boolean(isSignedIn && hasConfiguredGateways),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
queryFn: async () => {
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
gatewayTargets.map((target) =>
|
||||||
|
getGatewayProviderUsageApiV1GatewaysGatewayIdProviderUsageGet(
|
||||||
|
target.gatewayId,
|
||||||
|
).then((res) => ({ target, res })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const windows: ProviderNativeUsageWindow[] = [];
|
||||||
|
for (const item of settled) {
|
||||||
|
if (item.status !== "fulfilled") continue;
|
||||||
|
const { target, res } = item.value;
|
||||||
|
if (res.status !== 200) continue;
|
||||||
|
const payload = res.data as ProviderUsageResponse;
|
||||||
|
if (!payload.scraper_enabled) continue;
|
||||||
|
for (const scrape of payload.results ?? []) {
|
||||||
|
const source = scrape.source ?? "provider_native";
|
||||||
|
const confidence = scrape.confidence ?? "medium";
|
||||||
|
if (Array.isArray(scrape.windows) && scrape.windows.length > 0) {
|
||||||
|
for (const window of scrape.windows) {
|
||||||
|
windows.push({
|
||||||
|
key: window.key,
|
||||||
|
label: window.label,
|
||||||
|
pctUsed: window.pct_used ?? null,
|
||||||
|
remainingMs: window.remaining_ms ?? null,
|
||||||
|
remainingLabel: window.remaining_label ?? null,
|
||||||
|
source: window.source ?? source,
|
||||||
|
confidence: window.confidence ?? confidence,
|
||||||
|
provider: scrape.provider,
|
||||||
|
gatewayLabel: target.boardName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (scrape.current_pct !== null || scrape.remaining_ms !== null) {
|
||||||
|
windows.push({
|
||||||
|
key: "current_session",
|
||||||
|
label: "Current session",
|
||||||
|
pctUsed: scrape.current_pct ?? null,
|
||||||
|
remainingMs: scrape.remaining_ms ?? null,
|
||||||
|
remainingLabel: scrape.remaining_label ?? null,
|
||||||
|
source,
|
||||||
|
confidence,
|
||||||
|
provider: scrape.provider,
|
||||||
|
gatewayLabel: target.boardName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return windows;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const providerUsageWindows = providerUsageQuery.data ?? [];
|
||||||
|
|
||||||
// Gateway health — query the first gateway only for the compact dashboard panel
|
// Gateway health — query the first gateway only for the compact dashboard panel
|
||||||
const primaryGatewayId = gatewayTargets[0]?.gatewayId ?? null;
|
const primaryGatewayId = gatewayTargets[0]?.gatewayId ?? null;
|
||||||
|
|
@ -1116,7 +1185,8 @@ export default function DashboardPage() {
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<RuntimeUsageSection
|
<RuntimeUsageSection
|
||||||
usage={runtimeUsage}
|
usage={runtimeUsage}
|
||||||
isLoading={runtimeUsageQuery.isLoading}
|
providerUsageWindows={providerUsageWindows}
|
||||||
|
isLoading={runtimeUsageQuery.isLoading || providerUsageQuery.isLoading}
|
||||||
hasGateways={hasConfiguredGateways}
|
hasGateways={hasConfiguredGateways}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, Clock, Flame, TrendingDown, Zap } from "lucide-react";
|
import { AlertTriangle, Clock, Flame, TrendingDown, Zap } from "lucide-react";
|
||||||
import type { ModelUsageEntry, RuntimeUsageResponse, TopSession } from "@/api/generated/model";
|
import type {
|
||||||
|
ModelUsageEntry,
|
||||||
|
RuntimeUsageResponse,
|
||||||
|
TopSession,
|
||||||
|
} from "@/api/generated/model";
|
||||||
import { DashboardSection } from "./DashboardSection";
|
import { DashboardSection } from "./DashboardSection";
|
||||||
import { DashboardEmptyState } from "./DashboardEmptyState";
|
import { DashboardEmptyState } from "./DashboardEmptyState";
|
||||||
|
|
||||||
|
|
@ -26,6 +30,18 @@ export interface AggregatedRuntimeUsage {
|
||||||
topSessions: TopSession[];
|
topSessions: TopSession[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderNativeUsageWindow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
pctUsed: number | null;
|
||||||
|
remainingMs: number | null;
|
||||||
|
remainingLabel: string | null;
|
||||||
|
source: string;
|
||||||
|
confidence: string;
|
||||||
|
provider: string;
|
||||||
|
gatewayLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Aggregation helper (called from dashboard page)
|
// Aggregation helper (called from dashboard page)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -215,18 +231,22 @@ function StatCard({ label, value, sub, tone = "default", icon }: StatCardProps)
|
||||||
|
|
||||||
interface RuntimeUsageSectionProps {
|
interface RuntimeUsageSectionProps {
|
||||||
usage: AggregatedRuntimeUsage | null;
|
usage: AggregatedRuntimeUsage | null;
|
||||||
|
providerUsageWindows: ProviderNativeUsageWindow[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
hasGateways: boolean;
|
hasGateways: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RuntimeUsageSection({
|
export function RuntimeUsageSection({
|
||||||
usage,
|
usage,
|
||||||
|
providerUsageWindows,
|
||||||
isLoading,
|
isLoading,
|
||||||
hasGateways,
|
hasGateways,
|
||||||
}: RuntimeUsageSectionProps) {
|
}: RuntimeUsageSectionProps) {
|
||||||
if (!hasGateways) return null;
|
if (!hasGateways) return null;
|
||||||
|
|
||||||
const noData = !usage || (usage.totalCalls === 0 && Object.keys(usage.perModel).length === 0);
|
const hasRuntimeData = Boolean(
|
||||||
|
usage && (usage.totalCalls > 0 || Object.keys(usage.perModel).length > 0),
|
||||||
|
);
|
||||||
const safenessTone = usage
|
const safenessTone = usage
|
||||||
? usage.safe
|
? usage.safe
|
||||||
? "success"
|
? "success"
|
||||||
|
|
@ -236,6 +256,18 @@ export function RuntimeUsageSection({
|
||||||
const modelRows = usage
|
const modelRows = usage
|
||||||
? Object.entries(usage.perModel).sort((a, b) => b[1].cost_usd - a[1].cost_usd)
|
? Object.entries(usage.perModel).sort((a, b) => b[1].cost_usd - a[1].cost_usd)
|
||||||
: [];
|
: [];
|
||||||
|
const providerRows = [...providerUsageWindows].sort((a, b) => {
|
||||||
|
const order: Record<string, number> = {
|
||||||
|
current_session: 0,
|
||||||
|
weekly_all_models: 1,
|
||||||
|
weekly_sonnet: 2,
|
||||||
|
extra_usage: 3,
|
||||||
|
};
|
||||||
|
const left = order[a.key] ?? 99;
|
||||||
|
const right = order[b.key] ?? 99;
|
||||||
|
return left === right ? a.gatewayLabel.localeCompare(b.gatewayLabel) : left - right;
|
||||||
|
});
|
||||||
|
const noData = !hasRuntimeData && providerRows.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardSection title="Runtime Usage">
|
<DashboardSection title="Runtime Usage">
|
||||||
|
|
@ -243,40 +275,89 @@ export function RuntimeUsageSection({
|
||||||
<DashboardEmptyState message="Loading usage data…" />
|
<DashboardEmptyState message="Loading usage data…" />
|
||||||
) : noData ? (
|
) : noData ? (
|
||||||
<DashboardEmptyState message="No usage data yet. Usage appears after the first model call." />
|
<DashboardEmptyState message="No usage data yet. Usage appears after the first model call." />
|
||||||
) : usage ? (
|
) : hasRuntimeData || providerRows.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Top stat cards */}
|
{providerRows.length > 0 && (
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
<div className="space-y-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
|
||||||
<StatCard
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
||||||
label="5h Spend"
|
Provider-native usage windows
|
||||||
value={fmtCost(usage.totalCostUsd)}
|
</p>
|
||||||
sub={`${fmtTokens(usage.totalTokens)} tokens`}
|
<div className="space-y-2">
|
||||||
tone={usage.costLimitUsd && usage.totalCostUsd / usage.costLimitUsd > 0.8 ? "warning" : "default"}
|
{providerRows.map((window, index) => {
|
||||||
icon={<Zap className="h-3 w-3" />}
|
const pctUsed = window.pctUsed ?? 0;
|
||||||
/>
|
const barTone =
|
||||||
<StatCard
|
pctUsed >= 90
|
||||||
label="Reset In"
|
? "bg-[color:var(--danger)]"
|
||||||
value={fmtMs(usage.resetInMs)}
|
: pctUsed >= 75
|
||||||
sub={usage.resetsAt ? new Date(usage.resetsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : undefined}
|
? "bg-[color:var(--warning)]"
|
||||||
icon={<Clock className="h-3 w-3" />}
|
: "bg-[color:var(--success)]";
|
||||||
/>
|
const resetText =
|
||||||
<StatCard
|
window.remainingLabel
|
||||||
label="Time to Limit"
|
?? (window.remainingMs !== null ? fmtMs(window.remainingMs) : "—");
|
||||||
value={usage.timeToLimitMs === null ? "—" : usage.timeToLimitMs === 0 ? "At limit" : fmtMs(usage.timeToLimitMs)}
|
const isEstimate = window.source !== "provider_native" || window.confidence !== "high";
|
||||||
sub={usage.tokenLimit ? `${usage.tokenPct ?? 0}% of ${fmtTokens(usage.tokenLimit)}` : undefined}
|
return (
|
||||||
tone={safenessTone}
|
<div
|
||||||
icon={<TrendingDown className="h-3 w-3" />}
|
key={`${window.gatewayLabel}-${window.provider}-${window.key}-${index}`}
|
||||||
/>
|
className="rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] p-2"
|
||||||
<StatCard
|
>
|
||||||
label="Burn Rate"
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
value={usage.costUsdPerMinute > 0 ? `${fmtCost(usage.costUsdPerMinute)}/m` : "—"}
|
<p className="text-xs font-medium text-strong">
|
||||||
sub={usage.tokensPerMinute > 0 ? `${fmtTokens(usage.tokensPerMinute)} tok/m` : undefined}
|
{window.label}
|
||||||
icon={<Flame className="h-3 w-3" />}
|
{isEstimate ? " (estimate)" : ""}
|
||||||
/>
|
</p>
|
||||||
</div>
|
<p className="text-xs tabular-nums text-muted">
|
||||||
|
{window.pctUsed === null ? "—" : `${Math.round(window.pctUsed)}% used`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-[color:var(--surface-muted)]">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-300 ${barTone}`}
|
||||||
|
style={{ width: `${Math.max(0, Math.min(100, pctUsed))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-muted">
|
||||||
|
Resets in {resetText} · {window.provider} · {window.gatewayLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{usage && hasRuntimeData && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label="5h Spend"
|
||||||
|
value={fmtCost(usage.totalCostUsd)}
|
||||||
|
sub={`${fmtTokens(usage.totalTokens)} tokens`}
|
||||||
|
tone={usage.costLimitUsd && usage.totalCostUsd / usage.costLimitUsd > 0.8 ? "warning" : "default"}
|
||||||
|
icon={<Zap className="h-3 w-3" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Reset In"
|
||||||
|
value={fmtMs(usage.resetInMs)}
|
||||||
|
sub={usage.resetsAt ? new Date(usage.resetsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : undefined}
|
||||||
|
icon={<Clock className="h-3 w-3" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Time to Limit"
|
||||||
|
value={usage.timeToLimitMs === null ? "—" : usage.timeToLimitMs === 0 ? "At limit" : fmtMs(usage.timeToLimitMs)}
|
||||||
|
sub={usage.tokenLimit ? `${usage.tokenPct ?? 0}% of ${fmtTokens(usage.tokenLimit)}` : undefined}
|
||||||
|
tone={safenessTone}
|
||||||
|
icon={<TrendingDown className="h-3 w-3" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Burn Rate"
|
||||||
|
value={usage.costUsdPerMinute > 0 ? `${fmtCost(usage.costUsdPerMinute)}/m` : "—"}
|
||||||
|
sub={usage.tokensPerMinute > 0 ? `${fmtTokens(usage.tokensPerMinute)} tok/m` : undefined}
|
||||||
|
icon={<Flame className="h-3 w-3" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Unpriced models warning */}
|
{/* Unpriced models warning */}
|
||||||
{modelRows.some(([, e]) => e.unpriced) && (
|
{usage && hasRuntimeData && modelRows.some(([, e]) => e.unpriced) && (
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-[color:var(--warning)]/35 bg-[color:rgba(251,191,36,0.10)] px-3 py-2 text-xs text-[color:var(--warning)]">
|
<div className="flex items-start gap-2 rounded-lg border border-[color:var(--warning)]/35 bg-[color:rgba(251,191,36,0.10)] px-3 py-2 text-xs text-[color:var(--warning)]">
|
||||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
<span>Some models have no pricing config — costs may be under-reported.</span>
|
<span>Some models have no pricing config — costs may be under-reported.</span>
|
||||||
|
|
@ -284,7 +365,7 @@ export function RuntimeUsageSection({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Per-model breakdown */}
|
{/* Per-model breakdown */}
|
||||||
{modelRows.length > 0 && (
|
{usage && hasRuntimeData && modelRows.length > 0 && (
|
||||||
<div className="overflow-hidden rounded-lg border border-[color:var(--border)]">
|
<div className="overflow-hidden rounded-lg border border-[color:var(--border)]">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue