- Removed fabricated used_percent numbers and fake *_api source tags from quota_collector.py - Marked quota snapshot sources as baseline/estimated and added visual estimation indicators in accounts_view.py - Eliminated silent fallback on fake user_code/device_code in codex_oauth.py and grok_oauth.py - Gated mock OAuth sessions strictly behind HERMES_HUB_DEV_MODE=1 with visible UI warnings - Documented Google, OpenAI, xAI, and Claude OAuth clients in docs/OAUTH_CLIENT.md - Relocated unused gui_server.py and gui_cockpit.html to legacy/ - Added test_data_truthfulness_and_oauth_security.py covering fail-closed and source truthfulness invariants - Verified 98 passed tests (100%) and 7/7 release gate checks
262 lines
10 KiB
Python
262 lines
10 KiB
Python
"""Hermes Hub — Unified Account Identity and Quota Abstraction Models.
|
||
|
||
Defines normalized models for:
|
||
- SubscriptionPlan (plan code, display name, source, expiry)
|
||
- AccountIdentity (email, display name, org, plan, auth method, status)
|
||
- QuotaBucket (percentages, absolute counts, model family, reset time windows)
|
||
- QuotaSnapshot (collection of quota buckets, freshness, caching)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
|
||
def _utc_now() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def _parse_datetime(val: Any) -> Optional[datetime]:
|
||
if val in (None, ""):
|
||
return None
|
||
if isinstance(val, datetime):
|
||
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
|
||
if isinstance(val, (int, float)):
|
||
# Handle milliseconds vs seconds
|
||
ts = float(val) / 1000.0 if float(val) > 1e11 else float(val)
|
||
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
||
if isinstance(val, str):
|
||
s = val.strip()
|
||
if not s:
|
||
return None
|
||
if s.endswith("Z"):
|
||
s = s[:-1] + "+00:00"
|
||
try:
|
||
dt = datetime.fromisoformat(s)
|
||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
@dataclass
|
||
class SubscriptionPlan:
|
||
"""Normalized subscription plan details."""
|
||
code: str = "UNKNOWN" # e.g. "PRO", "PLUS", "ULTRA", "MAX", "TEAM", "FREE", "UNKNOWN"
|
||
display_name: str = "Тариф: неизвестен"
|
||
source: str = "provider_api" # "provider_api", "jwt_claim", "inferred", "unknown"
|
||
expires_at: Optional[datetime] = None
|
||
renews_at: Optional[datetime] = None
|
||
|
||
@classmethod
|
||
def create(
|
||
cls,
|
||
raw_code: Optional[str],
|
||
source: str = "provider_api",
|
||
expires_at: Any = None,
|
||
renews_at: Any = None,
|
||
) -> SubscriptionPlan:
|
||
if not raw_code or not str(raw_code).strip():
|
||
return cls(code="UNKNOWN", display_name="Тариф: неизвестен", source="unknown")
|
||
|
||
cleaned = str(raw_code).strip().upper().replace("_", " ").replace("-", " ")
|
||
code_upper = cleaned.split()[0] if cleaned else "UNKNOWN"
|
||
|
||
# Standard display names
|
||
display_map = {
|
||
"PRO": "PRO",
|
||
"PLUS": "PLUS",
|
||
"ULTRA": "ULTRA",
|
||
"MAX": "MAX",
|
||
"TEAM": "TEAM",
|
||
"BUSINESS": "BUSINESS",
|
||
"ENTERPRISE": "ENTERPRISE",
|
||
"FREE": "FREE",
|
||
"TIER1": "TIER 1",
|
||
"TIER2": "TIER 2",
|
||
"SUPERGROK": "SUPERGROK",
|
||
"GROK PRO": "GROK PRO",
|
||
}
|
||
disp = display_map.get(cleaned, display_map.get(code_upper, cleaned))
|
||
return cls(
|
||
code=code_upper,
|
||
display_name=disp,
|
||
source=source,
|
||
expires_at=_parse_datetime(expires_at),
|
||
renews_at=_parse_datetime(renews_at),
|
||
)
|
||
|
||
def is_known(self) -> bool:
|
||
return self.code != "UNKNOWN"
|
||
|
||
|
||
@dataclass
|
||
class AccountIdentity:
|
||
"""Normalized account identity across all providers."""
|
||
provider: str
|
||
profile_id: str
|
||
email: Optional[str] = None
|
||
display_name: Optional[str] = None
|
||
account_id: Optional[str] = None
|
||
organization: Optional[str] = None
|
||
plan: SubscriptionPlan = field(default_factory=SubscriptionPlan)
|
||
auth_method: str = "oauth" # "oauth", "api_key", "imported", "unconfigured"
|
||
authenticated: bool = False
|
||
last_verified_at: Optional[datetime] = None
|
||
|
||
def primary_identifier(self) -> str:
|
||
"""Resolve highest priority identifier for UI presentation.
|
||
Priority: email -> display_name -> account_id -> profile_id.
|
||
"""
|
||
if self.email and self.email.strip():
|
||
return self.email.strip()
|
||
if self.display_name and self.display_name.strip():
|
||
return self.display_name.strip()
|
||
if self.account_id and self.account_id.strip():
|
||
return self.account_id.strip()
|
||
return self.profile_id
|
||
|
||
def masked_identifier(self) -> str:
|
||
"""Return privacy-safe masked identifier."""
|
||
raw = self.primary_identifier()
|
||
if "@" in raw:
|
||
parts = raw.split("@")
|
||
user, domain = parts[0], parts[1]
|
||
if len(user) <= 2:
|
||
masked_user = user[0] + "***"
|
||
else:
|
||
masked_user = user[0] + "***" + user[-1]
|
||
return f"{masked_user}@{domain}"
|
||
if len(raw) > 10:
|
||
return f"{raw[:4]}...{raw[-4:]}"
|
||
return raw
|
||
|
||
|
||
@dataclass
|
||
class QuotaBucket:
|
||
"""Normalized quota bucket representing a single limit pool."""
|
||
id: str # e.g. "antigravity.claude.5h", "codex.weekly", "grok.frequent_tasks"
|
||
display_name: str # e.g. "5h", "Weekly", "Частые задачи"
|
||
model_family: Optional[str] = None # "claude", "gemini", "gpt", "grok", "opencode"
|
||
used_percent: Optional[float] = None
|
||
remaining_percent: Optional[float] = None
|
||
used_absolute: Optional[int] = None
|
||
remaining_absolute: Optional[int] = None
|
||
limit_absolute: Optional[int] = None
|
||
reset_at: Optional[datetime] = None
|
||
reset_in_seconds: Optional[int] = None
|
||
period: Optional[str] = None # "5h", "7d", "30d", "sliding"
|
||
status: str = "healthy" # "healthy", "warning", "exhausted", "unknown"
|
||
|
||
def __post_init__(self):
|
||
# Auto-reconcile percentages
|
||
if self.remaining_percent is None and self.used_percent is not None:
|
||
self.remaining_percent = max(0.0, min(100.0, 100.0 - float(self.used_percent)))
|
||
elif self.used_percent is None and self.remaining_percent is not None:
|
||
self.used_percent = max(0.0, min(100.0, 100.0 - float(self.remaining_percent)))
|
||
|
||
# Auto-determine status
|
||
if self.remaining_percent is not None:
|
||
if self.remaining_percent <= 0.0 or (self.used_percent is not None and self.used_percent >= 100.0):
|
||
self.status = "exhausted"
|
||
elif self.remaining_percent < 15.0:
|
||
self.status = "warning"
|
||
else:
|
||
self.status = "healthy"
|
||
elif self.remaining_absolute is not None and self.limit_absolute is not None:
|
||
if self.remaining_absolute <= 0:
|
||
self.status = "exhausted"
|
||
elif self.remaining_absolute / max(1, self.limit_absolute) < 0.15:
|
||
self.status = "warning"
|
||
else:
|
||
self.status = "healthy"
|
||
|
||
@property
|
||
def is_exhausted(self) -> bool:
|
||
return self.status == "exhausted"
|
||
|
||
def formatted_remaining(self) -> str:
|
||
"""User-facing unambiguous string."""
|
||
if self.remaining_absolute is not None and self.limit_absolute is not None:
|
||
used = self.used_absolute if self.used_absolute is not None else (self.limit_absolute - self.remaining_absolute)
|
||
rem_pct_str = f" · Осталось {self.remaining_percent:.0f}%" if self.remaining_percent is not None else ""
|
||
return f"{used}/{self.limit_absolute}{rem_pct_str}"
|
||
if self.remaining_percent is not None:
|
||
return f"Осталось {self.remaining_percent:.0f}%"
|
||
if self.used_percent is not None:
|
||
return f"Использовано {self.used_percent:.0f}%"
|
||
return "Доступна"
|
||
|
||
def formatted_reset(self) -> Optional[str]:
|
||
"""User-facing reset time string."""
|
||
if self.reset_at:
|
||
delta = self.reset_at - _utc_now()
|
||
tot_sec = int(delta.total_seconds())
|
||
if tot_sec <= 0:
|
||
return "Сброс: сейчас"
|
||
hrs, rem = divmod(tot_sec, 3600)
|
||
mins = rem // 60
|
||
if hrs >= 24:
|
||
days, hrs = divmod(hrs, 24)
|
||
return f"Сброс через {days}д {hrs}ч"
|
||
if hrs > 0:
|
||
return f"Сброс через {hrs}ч {mins}м"
|
||
return f"Сброс через {max(1, mins)}м"
|
||
if self.reset_in_seconds and self.reset_in_seconds > 0:
|
||
hrs, rem = divmod(self.reset_in_seconds, 3600)
|
||
mins = rem // 60
|
||
if hrs > 0:
|
||
return f"Сброс через {hrs}ч {mins}м"
|
||
return f"Сброс через {max(1, mins)}м"
|
||
return None
|
||
|
||
|
||
@dataclass
|
||
class QuotaSnapshot:
|
||
"""Normalized snapshot of all quota buckets for a provider profile."""
|
||
account_id: str
|
||
provider: str
|
||
buckets: List[QuotaBucket] = field(default_factory=list)
|
||
fetched_at: datetime = field(default_factory=_utc_now)
|
||
stale_after_seconds: int = 300
|
||
source: str = "baseline"
|
||
unavailable_reason: Optional[str] = None
|
||
|
||
@property
|
||
def is_estimated(self) -> bool:
|
||
"""True if values are baseline or locally estimated rather than measured by live server API."""
|
||
return self.source in ("baseline", "estimated", "unconfigured", "local_heuristic")
|
||
|
||
def is_stale(self) -> bool:
|
||
delta = _utc_now() - self.fetched_at
|
||
return delta.total_seconds() > self.stale_after_seconds
|
||
|
||
def freshness_label(self) -> str:
|
||
delta = _utc_now() - self.fetched_at
|
||
sec = int(delta.total_seconds())
|
||
if sec < 45:
|
||
return "Обновлено: только что"
|
||
mins = sec // 60
|
||
if mins < 60:
|
||
return f"Обновлено: {mins} мин назад"
|
||
hrs = mins // 60
|
||
return f"Обновлено: {hrs} ч назад"
|
||
|
||
def get_bucket_for_model(self, model_or_family: str) -> Optional[QuotaBucket]:
|
||
"""Find the relevant quota bucket for a given model or model family."""
|
||
target = model_or_family.lower()
|
||
# Direct family match
|
||
for b in self.buckets:
|
||
if b.model_family and b.model_family.lower() in target:
|
||
return b
|
||
# Fallback to first available bucket
|
||
return self.buckets[0] if self.buckets else None
|
||
|
||
def is_model_available(self, model_or_family: str) -> bool:
|
||
"""True if the quota bucket governing this model is healthy."""
|
||
bucket = self.get_bucket_for_model(model_or_family)
|
||
if not bucket:
|
||
return True # If no bucket defined, assume available
|
||
return not bucket.is_exhausted
|