Add src/selectel_ml_tui/services.py
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
"""Сервисный слой: агрегация данных для TUI.
|
||||
|
||||
Объединяет Billing API (стоимость за месяц) и Gateway API (метрики, токены,
|
||||
модели). При недоступности API подставляет последние данные из SQLite-кэша
|
||||
(офлайн-режим).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from selectel_ml_tui.api.billing import BillingClient
|
||||
from selectel_ml_tui.api.client import SelectelApiError
|
||||
from selectel_ml_tui.api.gateway import GatewayClient
|
||||
from selectel_ml_tui.auth import IamClient, IamTokenManager
|
||||
from selectel_ml_tui.config import Credentials, Settings
|
||||
from selectel_ml_tui.models import (
|
||||
ApiKeyUsage,
|
||||
BudgetInfo,
|
||||
Consumption,
|
||||
HistoryPoint,
|
||||
Metrics,
|
||||
ModelMetrics,
|
||||
TokenUsage,
|
||||
)
|
||||
from selectel_ml_tui.storage import Storage
|
||||
|
||||
_NANO = Decimal("1000000000")
|
||||
|
||||
|
||||
def _nano_to_rub(value: Any) -> Decimal:
|
||||
"""Нанорубли (строка/число) -> рубли."""
|
||||
if value is None:
|
||||
return Decimal("0")
|
||||
try:
|
||||
return Decimal(str(value)) / _NANO
|
||||
except (ValueError, ArithmeticError, TypeError):
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def _pick(data: dict[str, Any], keys: tuple[str, ...]) -> Any:
|
||||
for key in keys:
|
||||
if key in data and data[key] is not None:
|
||||
return data[key]
|
||||
return None
|
||||
|
||||
|
||||
def _dict(value: Any) -> dict[str, Any]:
|
||||
"""Привести произвольное значение к dict (иначе пустой dict)."""
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def parse_tokens(data: dict[str, Any]) -> TokenUsage:
|
||||
"""Извлечь токены из объекта ответа (устойчиво к разным ключам).
|
||||
|
||||
Поддерживает как вложенную структуру Gateway (``tokens.input`` и т.п.),
|
||||
так и плоские ключи (``input_tokens`` и т.п.).
|
||||
"""
|
||||
nested = _dict(data.get("tokens"))
|
||||
return TokenUsage(
|
||||
input_tokens=int(
|
||||
_pick(data, ("input_tokens", "input", "inputTokens", "tokens_in"))
|
||||
or _pick(nested, ("input", "input_tokens"))
|
||||
or 0
|
||||
),
|
||||
output_tokens=int(
|
||||
_pick(data, ("output_tokens", "output", "outputTokens", "tokens_out"))
|
||||
or _pick(nested, ("output", "output_tokens"))
|
||||
or 0
|
||||
),
|
||||
cache_read_tokens=int(
|
||||
_pick(data, ("cache_read_tokens", "cacheReadTokens", "cache_read"))
|
||||
or _pick(nested, ("cached_input", "cache_read_tokens", "cache_read"))
|
||||
or 0
|
||||
),
|
||||
cache_write_tokens=int(
|
||||
_pick(data, ("cache_write_tokens", "cacheWriteTokens", "cache_write"))
|
||||
or _pick(nested, ("cache_write", "cache_write_tokens"))
|
||||
or 0
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def parse_overview(raw: dict[str, Any], period: dt.datetime) -> Consumption:
|
||||
"""Разобрать /gateways/{id}/overview в Consumption.
|
||||
|
||||
Сумма из ``totals.cost_rub_nano`` (нанорубли), токены из ``totals.tokens``,
|
||||
общее число токенов из ``totals.tokens_total.total``.
|
||||
"""
|
||||
totals = _dict(raw.get("totals"))
|
||||
tokens_total = _dict(totals.get("tokens_total"))
|
||||
tokens = parse_tokens(totals)
|
||||
return Consumption(
|
||||
period=period,
|
||||
amount_rub=_nano_to_rub(totals.get("cost_rub_nano")),
|
||||
requests=int(totals.get("requests") or 0),
|
||||
tokens_total=int(tokens_total.get("total") or 0),
|
||||
**tokens.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
def parse_metrics(raw: dict[str, Any]) -> Metrics | None:
|
||||
"""Разобрать /gateways/{id}/metrics в Metrics.
|
||||
|
||||
Доступность из ``global.success_rate_pct``, TTFT/latency из секунд
|
||||
``global.avg_ttft_seconds``/``avg_latency_seconds`` (в мс), токены из
|
||||
``global.tokens``.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
global_ = _dict(raw.get("global")) or raw
|
||||
tokens = parse_tokens(global_)
|
||||
return Metrics(
|
||||
availability_pct=float(global_.get("success_rate_pct") or 100.0),
|
||||
avg_ttft_ms=float(global_.get("avg_ttft_seconds") or 0.0) * 1000,
|
||||
avg_latency_ms=float(global_.get("avg_latency_seconds") or 0.0) * 1000,
|
||||
requests=int(global_.get("requests") or 0),
|
||||
input_tokens=tokens.input_tokens,
|
||||
output_tokens=tokens.output_tokens,
|
||||
cache_read_tokens=tokens.cache_read_tokens,
|
||||
cache_write_tokens=tokens.cache_write_tokens,
|
||||
)
|
||||
|
||||
|
||||
def parse_models(raw_metrics: dict[str, Any]) -> list[ModelMetrics]:
|
||||
"""Построить метрики по моделям из ``by_model`` в /metrics.
|
||||
|
||||
По модели API отдаёт: ``model``, ``requests``, ``success_rate_pct``,
|
||||
``avg_ttft_seconds`` (токенов по модели нет).
|
||||
"""
|
||||
by_model = raw_metrics.get("by_model", [])
|
||||
if not isinstance(by_model, list):
|
||||
by_model = []
|
||||
models: list[ModelMetrics] = []
|
||||
for item in by_model:
|
||||
model_id = str(item.get("model") or "") if isinstance(item, dict) else ""
|
||||
models.append(
|
||||
ModelMetrics(
|
||||
model_id=model_id,
|
||||
model_name=model_id,
|
||||
availability_pct=float(item.get("success_rate_pct") or 100.0)
|
||||
if isinstance(item, dict)
|
||||
else 100.0,
|
||||
avg_ttft_ms=(
|
||||
float(item.get("avg_ttft_seconds") or 0.0) * 1000
|
||||
if isinstance(item, dict)
|
||||
else 0.0
|
||||
),
|
||||
avg_latency_ms=(
|
||||
float(item.get("avg_latency_seconds") or 0.0) * 1000
|
||||
if isinstance(item, dict)
|
||||
else 0.0
|
||||
),
|
||||
requests=(
|
||||
int(item.get("requests") or 0) if isinstance(item, dict) else 0
|
||||
),
|
||||
)
|
||||
)
|
||||
return models
|
||||
|
||||
|
||||
def parse_keys(raw_keys: list[dict[str, Any]]) -> list[ApiKeyUsage]:
|
||||
"""Разобрать /keys в список лимитов по API-ключам."""
|
||||
keys: list[ApiKeyUsage] = []
|
||||
for item in raw_keys:
|
||||
budget = _dict(item.get("budget"))
|
||||
limit = _nano_to_rub(budget.get("spend_limit_rub_nano"))
|
||||
usage = _dict(budget.get("current_usage")) or _dict(item.get("usage"))
|
||||
spend = _nano_to_rub(usage.get("spend_rub_nano"))
|
||||
keys.append(
|
||||
ApiKeyUsage(
|
||||
name=str(item.get("name") or ""),
|
||||
prefix=str(item.get("prefix") or ""),
|
||||
is_active=bool(item.get("is_active", True)),
|
||||
spend_limit_rub=limit,
|
||||
spend_rub=spend,
|
||||
remaining_rub=max(limit - spend, Decimal("0")),
|
||||
)
|
||||
)
|
||||
return keys
|
||||
|
||||
|
||||
def parse_budget(raw: dict[str, Any]) -> BudgetInfo | None:
|
||||
"""Бюджет роутера из ``budget`` в /overview."""
|
||||
budget = _dict(raw.get("budget"))
|
||||
if not budget:
|
||||
return None
|
||||
limit = _nano_to_rub(budget.get("spend_limit_rub_nano"))
|
||||
spend = _nano_to_rub(budget.get("spend_rub_nano"))
|
||||
return BudgetInfo(
|
||||
spend_limit_rub=limit,
|
||||
spend_rub=spend,
|
||||
remaining_rub=max(limit - spend, Decimal("0")),
|
||||
window=str(budget.get("window") or ""),
|
||||
)
|
||||
|
||||
|
||||
class DashboardData(BaseModel):
|
||||
"""Данные для отображения на главном экране."""
|
||||
|
||||
month_consumption: Consumption | None = None
|
||||
today_amount_rub: Decimal | None = None
|
||||
metrics: Metrics | None = None
|
||||
models: list[ModelMetrics] = Field(default_factory=list)
|
||||
keys: list[ApiKeyUsage] = Field(default_factory=list)
|
||||
budget: BudgetInfo | None = None
|
||||
offline: bool = False
|
||||
updated_at: dt.datetime | None = None
|
||||
error: str | None = None
|
||||
source: str = "live"
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
return (
|
||||
self.month_consumption is not None
|
||||
or self.metrics is not None
|
||||
or bool(self.models)
|
||||
)
|
||||
|
||||
|
||||
class DataService:
|
||||
"""Собирает данные из API и сохраняет снимки в кэш."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
credentials: Credentials,
|
||||
storage: Storage | None = None,
|
||||
transport: Any = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._credentials = credentials
|
||||
self._storage = storage
|
||||
self._transport = transport
|
||||
|
||||
async def refresh(self) -> DashboardData:
|
||||
"""Получить актуальные данные (online) или последние из кэша (offline)."""
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
data = DashboardData(updated_at=now)
|
||||
errors: list[str] = []
|
||||
|
||||
await self._load_billing(data, month_start, now, errors)
|
||||
await self._load_gateway(data, errors)
|
||||
|
||||
if data.month_consumption is None and self._storage is not None:
|
||||
cached = self._storage.get_latest_consumption()
|
||||
if cached is not None:
|
||||
data.month_consumption = cached
|
||||
data.offline = True
|
||||
data.source = "cache"
|
||||
|
||||
if errors:
|
||||
data.error = "; ".join(errors)
|
||||
|
||||
if data.month_consumption is not None and self._storage is not None:
|
||||
history_amount = data.today_amount_rub
|
||||
if history_amount is None:
|
||||
history_amount = data.month_consumption.amount_rub
|
||||
self._storage.save_snapshot(
|
||||
consumption=data.month_consumption,
|
||||
metrics=data.metrics,
|
||||
models=data.models,
|
||||
history_point=HistoryPoint(
|
||||
timestamp=now.replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
amount_rub=history_amount,
|
||||
tokens=TokenUsage(
|
||||
input_tokens=data.metrics.input_tokens if data.metrics else 0,
|
||||
output_tokens=(
|
||||
data.metrics.output_tokens if data.metrics else 0
|
||||
),
|
||||
cache_read_tokens=(
|
||||
data.metrics.cache_read_tokens if data.metrics else 0
|
||||
),
|
||||
cache_write_tokens=(
|
||||
data.metrics.cache_write_tokens if data.metrics else 0
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return data
|
||||
|
||||
async def _load_billing(
|
||||
self,
|
||||
data: DashboardData,
|
||||
month_start: dt.datetime,
|
||||
now: dt.datetime,
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
billing = BillingClient(
|
||||
self._settings.api_base_url,
|
||||
self._credentials.static_token,
|
||||
transport=self._transport,
|
||||
)
|
||||
try:
|
||||
# end — текущий момент с временем, чтобы включить сегодняшний день
|
||||
# (end = дата без времени отсекает текущий день из суммы).
|
||||
# API принимает только naive datetime (без смещения часового пояса).
|
||||
end_full = now.replace(tzinfo=None).isoformat(timespec="milliseconds")
|
||||
total_kop = await billing.get_summary(
|
||||
month_start.date().isoformat(), end_full
|
||||
)
|
||||
data.month_consumption = Consumption(period=now, amount_rub=total_kop / 100)
|
||||
# Дневная стоимость сегодняшнего дня — для точки истории.
|
||||
try:
|
||||
today_items = await billing.get_consumption(
|
||||
now.date().isoformat(), end_full, period_group_type="day"
|
||||
)
|
||||
today_total = sum(
|
||||
(item.value for item in today_items), start=Decimal("0")
|
||||
)
|
||||
data.today_amount_rub = today_total / 100
|
||||
except SelectelApiError:
|
||||
data.today_amount_rub = None
|
||||
except SelectelApiError as exc:
|
||||
errors.append(f"Billing: {exc}")
|
||||
finally:
|
||||
await billing.aclose()
|
||||
|
||||
async def _load_gateway(self, data: DashboardData, errors: list[str]) -> None:
|
||||
"""Gateway API требует IAM-доступ; недоступность не роняет дашборд."""
|
||||
if not self._credentials.service_user or not self._credentials.password:
|
||||
errors.append(
|
||||
"Gateway: не настроен сервисный пользователь (токены/модели недоступны)"
|
||||
)
|
||||
return
|
||||
try:
|
||||
iam = IamClient(
|
||||
self._settings, self._credentials, transport=self._transport
|
||||
)
|
||||
provider = IamTokenManager(iam)
|
||||
gateway = GatewayClient(
|
||||
self._settings.api_base_url,
|
||||
provider,
|
||||
self._credentials.project_id,
|
||||
self._credentials.domain_id,
|
||||
transport=self._transport,
|
||||
)
|
||||
try:
|
||||
gateways = await gateway.list_gateways()
|
||||
router_id = self._settings.router_id or (
|
||||
str(gateways[0]["id"]) if gateways else ""
|
||||
)
|
||||
if router_id:
|
||||
overview = await gateway.get_overview(router_id)
|
||||
metrics = await gateway.get_metrics(router_id)
|
||||
data.metrics = parse_metrics(metrics.raw)
|
||||
data.models = parse_models(metrics.raw)
|
||||
data.keys = parse_keys(await gateway.list_keys())
|
||||
data.budget = parse_budget(overview.raw)
|
||||
# Сумма за месяц из Gateway Overview (совпадает с вкладкой
|
||||
# «Обзор»); биллинг используется как фолбэк.
|
||||
data.month_consumption = parse_overview(
|
||||
overview.raw, dt.datetime.now(dt.timezone.utc)
|
||||
)
|
||||
else:
|
||||
errors.append("Gateway: нет ИИ-роутеров")
|
||||
finally:
|
||||
await gateway.aclose()
|
||||
except Exception as exc: # noqa: BLE001 - на уровне сервиса ловим всё
|
||||
errors.append(f"Gateway: {exc}")
|
||||
Reference in New Issue
Block a user