Initial import: selectel-ml-tui TUI for Selectel AI-router token usage

This commit is contained in:
2026-09-02 16:38:03 +03:00
commit f95223b88b
41 changed files with 6061 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""TUI-приложение для отображения потребления ИИ-роутера Selectel."""
__version__ = "1.0.3"
+4
View File
@@ -0,0 +1,4 @@
from selectel_ml_tui.cli import main
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
"""Клиенты API Selectel: billing (стоимость) и gateway (ИИ-роутер)."""
from selectel_ml_tui.api.billing import BillingApiError, BillingClient
from selectel_ml_tui.api.gateway import GatewayApiError, GatewayClient
__all__ = [
"BillingApiError",
"BillingClient",
"GatewayApiError",
"GatewayClient",
]
+132
View File
@@ -0,0 +1,132 @@
"""Клиент Billing Statistics API Selectel (провайдер aig).
Аутентификация: статический токен в заголовке X-Token.
Данные: стоимость потребления ИИ-роутера по дням/часам.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any, cast
import httpx
from selectel_ml_tui.api.client import BaseApiClient, SelectelApiError
from selectel_ml_tui.models import ConsumptionItem
BillingApiError = SelectelApiError
def normalize_dt(value: str) -> str:
"""Привести дату к формату, принимаемому Billing API.
Billing API ожидает полный ISO-таймстамп (например
``2026-08-01T00:00:00.000``); короткая дата дополняется.
"""
if "T" in value:
return value
return f"{value}T00:00:00.000"
class BillingClient(BaseApiClient):
"""Клиент /v1/cloud_billing/statistic/* для провайдера aig."""
PROVIDER = "aig"
def __init__(
self,
api_base_url: str,
static_token: str,
timeout: float = 30.0,
max_retries: int = 2,
transport: httpx.AsyncBaseTransport | None = None,
sleep: Any = None,
) -> None:
super().__init__(
timeout=timeout,
max_retries=max_retries,
transport=transport,
sleep=sleep,
)
self._base_url = api_base_url.rstrip("/")
self._token = static_token
def _headers(self) -> dict[str, str]:
return {"X-Token": self._token, "Content-Type": "application/json"}
async def _get(
self, path: str, params: dict[str, str] | None = None
) -> dict[str, Any]:
url = f"{self._base_url}{path}"
response = await self.request(
"GET", url, params=params, headers=self._headers()
)
data = response.json()
if not isinstance(data, dict) or data.get("status") != "success":
raise BillingApiError(f"Billing API: {data}")
return data
async def get_consumption(
self,
start: str,
end: str,
locale: str = "ru",
group_type: str = "metric",
period_group_type: str = "day",
) -> list[ConsumptionItem]:
"""Получить потребление за период.
group_type: project | object | metric | project_metric | region_metric
period_group_type: hour | day | week | month | year | all
"""
data = await self._get(
"/v1/cloud_billing/statistic/consumption",
params={
"provider_keys": self.PROVIDER,
"start": normalize_dt(start),
"end": normalize_dt(end),
"locale": locale,
"group_type": group_type,
"period_group_type": period_group_type,
},
)
return [ConsumptionItem.model_validate(item) for item in data.get("data", [])]
async def get_consumption_history(
self,
start: str,
end: str,
group: str = "day",
) -> list[ConsumptionItem]:
"""История потребления по дням/часам за период.
group: hour | day | week | month | year | all
"""
return await self.get_consumption(
start, end, period_group_type=group, group_type="metric"
)
async def get_summary(
self,
start: str,
end: str,
group_type: str = "metric",
period_group_type: str = "month",
) -> Decimal:
"""Суммарная стоимость за период в копейках (value)."""
data = await self._get(
"/v1/cloud_billing/statistic/summary_stats",
params={
"provider_keys": self.PROVIDER,
"start": normalize_dt(start),
"end": normalize_dt(end),
"group_type": group_type,
"period_group_type": period_group_type,
"items_count_period_group_type": "all",
},
)
rows = data.get("data", [])
total: Decimal = Decimal("0")
for row in cast(list[dict[str, Any]], rows):
total += Decimal(str(row.get("value", 0)))
return total
+93
View File
@@ -0,0 +1,93 @@
"""Базовый HTTP-слой для клиентов API Selectel.
httpx.AsyncClient с таймаутами и лимитом соединений; единая обработка ошибок:
- 429 (rate limit) и 5xx — повтор с экспоненциальной паузой;
- сетевые ошибки httpx — повтор;
- остальные 4xx — исключение SelectelApiError с понятным текстом.
Слой отделён от TUI и тестируется моками httpx.MockTransport.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any
import httpx
class SelectelApiError(RuntimeError):
"""Ошибка обращения к API Selectel.
Атрибут ``status_code`` заполнен для HTTP-ошибок (None для сетевых).
"""
def __init__(self, message: str, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
class BaseApiClient:
"""Базовый клиент: транспорт, ретраи, понятные ошибки."""
def __init__(
self,
*,
timeout: float = 30.0,
max_retries: int = 2,
transport: httpx.AsyncBaseTransport | None = None,
sleep: Callable[[float], Awaitable[None]] | None = None,
) -> None:
limits = httpx.Limits(max_connections=10, max_keepalive_connections=5)
self._client = httpx.AsyncClient(
timeout=timeout,
limits=limits,
transport=transport,
)
self._max_retries = max_retries
self._sleep = sleep if sleep is not None else asyncio.sleep
async def aclose(self) -> None:
"""Закрыть транспортные соединения."""
await self._client.aclose()
async def request(
self,
method: str,
url: str,
*,
params: dict[str, str] | None = None,
json: Any = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
"""Выполнить запрос с повторами и понятной обработкой ошибок."""
last_exc: SelectelApiError | None = None
for attempt in range(self._max_retries + 1):
try:
response = await self._client.request(
method, url, params=params, json=json, headers=headers
)
except httpx.HTTPError as exc:
if attempt < self._max_retries:
await self._sleep(self._backoff_seconds(attempt))
continue
raise SelectelApiError(f"Ошибка сети: {exc}") from exc
if response.status_code < 400:
return response
retriable = response.status_code == 429 or response.status_code >= 500
if retriable and attempt < self._max_retries:
await self._sleep(self._backoff_seconds(attempt))
continue
raise SelectelApiError(
f"HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
raise last_exc if last_exc else SelectelApiError("Запрос не выполнен")
@staticmethod
def _backoff_seconds(attempt: int) -> float:
return float(0.5 * (2**attempt))
+98
View File
@@ -0,0 +1,98 @@
"""Клиент Gateway API ИИ-роутера Selectel (/aig/gateway/v1/*).
Аутентификация: IAM-токен проекта (X-Auth-Token) + X-Project-Id и др.
Данные: потребление (overview), метрики 24ч, модели, ключи.
Токен берётся из IamTokenManager; при HTTP 401 выполняется его
автоматическое обновление и один повтор запроса.
"""
from __future__ import annotations
from typing import Any, cast
import httpx
from selectel_ml_tui.api.client import BaseApiClient, SelectelApiError
from selectel_ml_tui.auth import IamTokenManager
from selectel_ml_tui.models import RouterMetrics, RouterOverview
GatewayApiError = SelectelApiError
class GatewayClient(BaseApiClient):
"""Клиент /aig/gateway/v1/*."""
def __init__(
self,
api_base_url: str,
token_provider: IamTokenManager,
project_id: str,
domain_id: str,
timeout: float = 30.0,
max_retries: int = 2,
transport: httpx.AsyncBaseTransport | None = None,
sleep: Any = None,
) -> None:
super().__init__(
timeout=timeout,
max_retries=max_retries,
transport=transport,
sleep=sleep,
)
self._base_url = api_base_url.rstrip("/")
self._token_provider = token_provider
self._project_id = project_id
self._domain_id = domain_id
def _headers(self, token: str) -> dict[str, str]:
return {
"X-Auth-Token": token,
"X-Project-Id": self._project_id,
"X-Project-Domain-Id": self._domain_id,
"X-Project-Domain-Name": self._domain_id,
"X-Roles": "reader",
"Content-Type": "application/json",
}
async def _get(self, path: str) -> dict[str, Any]:
url = f"{self._base_url}{path}"
token = await self._token_provider.get_token()
try:
response = await self.request("GET", url, headers=self._headers(token))
except SelectelApiError as exc:
if exc.status_code == 401:
self._token_provider.invalidate()
token = await self._token_provider.get_token()
response = await self.request("GET", url, headers=self._headers(token))
else:
raise
data = response.json()
if isinstance(data, dict) and data.get("error"):
raise GatewayApiError(f"Gateway API: {data['error']}")
return cast(dict[str, Any], data)
async def list_gateways(self) -> list[dict[str, Any]]:
"""Список ИИ-роутеров."""
data = await self._get("/aig/gateway/v1/gateways")
return cast(list[dict[str, Any]], data.get("data", data.get("gateways", [])))
async def get_overview(self, router_id: str) -> RouterOverview:
"""Потребление роутера (вкладка «Обзор»)."""
data = await self._get(f"/aig/gateway/v1/gateways/{router_id}/overview")
return RouterOverview(raw=data)
async def get_metrics(self, router_id: str) -> RouterMetrics:
"""Метрики роутера за 24 часа (вкладка «Метрики»)."""
data = await self._get(f"/aig/gateway/v1/gateways/{router_id}/metrics")
return RouterMetrics(raw=data)
async def list_models(self) -> list[dict[str, Any]]:
"""Каталог моделей."""
data = await self._get("/aig/gateway/v1/models")
return cast(list[dict[str, Any]], data.get("data", data.get("models", [])))
async def list_keys(self) -> list[dict[str, Any]]:
"""Список API-ключей роутера с лимитами."""
data = await self._get("/aig/gateway/v1/keys")
return cast(list[dict[str, Any]], data.get("data", data.get("keys", [])))
+137
View File
@@ -0,0 +1,137 @@
"""IAM-авторизация Selectel.
Получение и кэширование IAM-токена проекта через
POST {identity_url}/auth/tokens (сервисный пользователь, scope project).
Время жизни токена — 24 часа; IamTokenManager автоматически обновляет токен
при приближении к истечению (запас 5 минут).
"""
from __future__ import annotations
import datetime as dt
from typing import cast
import httpx
from pydantic import BaseModel, ConfigDict, Field
from selectel_ml_tui.config import Credentials, Settings
class IamToken(BaseModel):
"""IAM-токен с временем жизни."""
model_config = ConfigDict(populate_by_name=True)
value: str = Field(alias="X-Subject-Token")
issued_at: dt.datetime = Field(
default_factory=lambda: dt.datetime.now(dt.timezone.utc)
)
expires_at: dt.datetime = Field(alias="expires_at")
class IamAuthError(RuntimeError):
"""Ошибка получения IAM-токена."""
def _parse_expires_at(raw: str) -> dt.datetime:
"""Распарсить expires_at из ответа IAM (ISO8601, возможно с 'Z')."""
text = raw.replace("Z", "+00:00")
return dt.datetime.fromisoformat(text)
class IamClient:
"""Клиент получения IAM-токена проекта."""
def __init__(
self,
settings: Settings,
credentials: Credentials,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self._settings = settings
self._credentials = credentials
self._transport = transport
async def get_token(self) -> IamToken:
"""Получить IAM-токен проекта для аккаунта/домена."""
if not self._credentials.service_user or not self._credentials.password:
raise IamAuthError(
"Не заданы логин/пароль сервисного пользователя. "
"Выполните: selectel-ml-tui config"
)
body = {
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": self._credentials.service_user,
"domain": {"name": self._credentials.domain_id},
"password": self._credentials.password,
}
},
},
"scope": {"project": {"id": self._credentials.project_id}},
}
}
url = f"{self._settings.identity_url}/auth/tokens"
try:
async with httpx.AsyncClient(
timeout=30, transport=self._transport
) as client:
response = await client.post(url, json=body)
except httpx.HTTPError as exc:
raise IamAuthError(f"Ошибка сети при обращении к {url}: {exc}") from exc
if response.status_code not in (200, 201):
raise IamAuthError(
f"IAM: HTTP {response.status_code}: {response.text[:300]}"
)
token = cast(str | None, response.headers.get("X-Subject-Token"))
if not token:
raise IamAuthError("IAM: отсутствует заголовок X-Subject-Token в ответе")
data = response.json()
expires_raw = (
data.get("token", {}).get("expires_at") if isinstance(data, dict) else None
)
if not isinstance(expires_raw, str):
raise IamAuthError("IAM: отсутствует поле token.expires_at в ответе")
return IamToken.model_validate(
{
"X-Subject-Token": token,
"expires_at": _parse_expires_at(expires_raw),
}
)
class IamTokenManager:
"""Кэширует IAM-токен и обновляет его по истечении TTL.
Позволяет разделить получение токена и его использование клиентами.
"""
SAFETY_MARGIN = dt.timedelta(minutes=5)
def __init__(self, client: IamClient) -> None:
self._client = client
self._token: str | None = None
self._expires_at: dt.datetime | None = None
def invalidate(self) -> None:
"""Сбросить кэш (например, после HTTP 401)."""
self._token = None
self._expires_at = None
def is_valid(self) -> bool:
if self._token is None or self._expires_at is None:
return False
now = dt.datetime.now(dt.timezone.utc)
return self._expires_at > now + self.SAFETY_MARGIN
async def get_token(self) -> str:
"""Вернуть валидный токен, при необходимости обновив его."""
if self.is_valid():
return cast(str, self._token)
iam_token = await self._client.get_token()
self._token = iam_token.value
self._expires_at = iam_token.expires_at
return iam_token.value
+201
View File
@@ -0,0 +1,201 @@
"""CLI-интерфейс selectel-ml-tui."""
from __future__ import annotations
from pathlib import Path
from typing import cast
import click
from selectel_ml_tui import __version__
from selectel_ml_tui.config import (
Credentials,
Settings,
config_dir,
data_dir,
load_credentials,
load_settings,
save_credentials,
save_settings,
)
def _mask(value: str) -> str:
"""Замаскировать секрет: показать первые 4 символа и длину."""
if not value:
return "(не задан)"
return f"{value[:4]}…({len(value)})"
def _print_config(settings: Settings, credentials: Credentials) -> None:
click.echo(f"API base URL: {settings.api_base_url}")
click.echo(f"Identity URL: {settings.identity_url}")
click.echo(f"Refresh interval: {settings.refresh_interval} c")
router_id = settings.router_id or "(не задан, будет выбран первый)"
click.echo(f"Router id: {router_id}")
if settings.region:
click.echo(f"Region: {settings.region}")
click.echo(f"Service user: {credentials.service_user or '(не задан)'}")
click.echo(f"Project id: {credentials.project_id or '(не задан)'}")
click.echo(f"Domain id: {credentials.domain_id or '(не задан)'}")
click.echo(f"Static token: {_mask(credentials.static_token)}")
click.echo(f"Password: {_mask(credentials.password)}")
def _prompt_str(
label: str,
current: str,
hide_input: bool = False,
default_note: str = "(Enter — оставить)",
) -> str:
prompt = f"{label} [{current or 'пусто'}] {default_note}: "
value = click.prompt(prompt, default="", hide_input=hide_input, show_default=False)
if value == "":
return current
return cast(str, value)
@click.group()
@click.version_option(__version__, prog_name="selectel-ml-tui")
def main() -> None:
"""TUI-приложение для отображения потребления ИИ-роутера Selectel."""
@main.command()
@click.option("--path", is_flag=True, help="Показать пути к конфигурации и выйти")
@click.option("--show", is_flag=True, help="Показать текущие настройки и выйти")
@click.option(
"--non-interactive",
is_flag=True,
help="Не запрашивать ввод (эквивалентно --show)",
)
def config(path: bool, show: bool, non_interactive: bool) -> None:
"""Настроить приложение (интерактивный мастер) или показать настройки."""
if path:
click.echo(f"config dir: {config_dir()}")
click.echo(f"data dir: {data_dir()}")
return
settings = load_settings()
credentials = load_credentials()
if show or non_interactive:
_print_config(settings, credentials)
return
click.echo("Настройка selectel-ml-tui. Пустые значения сохраняют текущие.")
settings.api_base_url = _prompt_str("API base URL", settings.api_base_url)
while True:
raw = _prompt_str(
"Интервал обновления (сек, min 10)",
str(settings.refresh_interval),
)
try:
value = int(raw)
except ValueError:
click.echo("Ошибка: введите целое число секунд")
continue
if value < 10:
click.echo("Ошибка: интервал не может быть меньше 10 секунд")
continue
settings.refresh_interval = value
break
settings.router_id = _prompt_str(
"ID ИИ-роутера (необязательно)", settings.router_id
)
settings.region = _prompt_str("Регион (необязательно)", settings.region)
credentials.service_user = _prompt_str(
"Сервисный пользователь", credentials.service_user
)
credentials.domain_id = _prompt_str(
"Номер аккаунта (domain)", credentials.domain_id
)
credentials.project_id = _prompt_str("ID проекта (ML)", credentials.project_id)
credentials.password = _prompt_str(
"Пароль сервисного пользователя",
credentials.password,
hide_input=True,
)
credentials.static_token = _prompt_str(
"Статический токен (X-Token)",
credentials.static_token,
hide_input=True,
)
save_settings(settings)
save_credentials(credentials)
click.echo(f"Сохранено: {config_dir()}")
_print_config(settings, credentials)
@main.command()
@click.option(
"--format",
"fmt",
type=click.Choice(["csv", "json"]),
default="json",
help="Формат экспорта",
)
@click.option(
"--output",
"-o",
type=click.Path(path_type=Path),
default=None,
help="Путь к файлу экспорта (по умолчанию history.csv/json в каталоге данных)",
)
def export(fmt: str, output: Path | None) -> None:
"""Экспортировать историю потребления в CSV/JSON."""
from selectel_ml_tui.storage import Storage
path = output if output is not None else data_dir() / f"history.{fmt}"
storage = Storage()
try:
if fmt == "csv":
storage.export_csv(path)
else:
storage.export_json(path)
finally:
storage.close()
click.echo(f"Экспортировано: {path}")
@main.command()
def doctor() -> None:
"""Проверить окружение (ОС, терминал, кодировку) и пути."""
from selectel_ml_tui.platform import check_environment, get_platform_info
info = get_platform_info()
click.echo(f"ОС: {info.system}")
click.echo(f"Python: {info.python_version}")
failed = False
for check in check_environment():
mark = "OK " if check.ok else "WARN"
failed = failed or not check.ok
click.echo(f"[{mark}] {check.name}: {check.detail}")
credentials = load_credentials()
has_token = bool(credentials.static_token or credentials.password)
hint = "не настроен — выполните: selectel-ml-tui config"
token_status = "настроен" if has_token else hint
click.echo(f"[{'OK ' if has_token else 'WARN'}] token: {token_status}")
click.echo(f"Config dir: {config_dir()}")
click.echo(f"Data dir: {data_dir()}")
if failed:
raise click.exceptions.Exit(1)
@main.command()
def run() -> None:
"""Запустить TUI-интерфейс (дашборд)."""
from selectel_ml_tui.ui.app import run_app
credentials = load_credentials()
if not credentials.static_token and not credentials.password:
msg = "Учётные данные не настроены. Выполните: selectel-ml-tui config"
click.echo(msg, err=True)
raise click.exceptions.Exit(1)
run_app()
if __name__ == "__main__":
main()
+206
View File
@@ -0,0 +1,206 @@
"""Конфигурация приложения: настройки, пути, секреты.
Поддерживается чтение переменных окружения с префиксом SELCTEL_ и файла
конфигурации config.toml. Секреты (пароль сервисного пользователя,
статический токен) хранятся отдельно в файле credentials (права 0600).
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any
from platformdirs import user_config_dir, user_data_dir
from pydantic import BaseModel, Field, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
APP_NAME = "selectel-ml-tui"
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover - Python 3.10 fallback
import tomli as tomllib
class Settings(BaseSettings):
"""Несекретные настройки приложения.
Источники: env (SELCTEL_*), затем config.toml.
"""
model_config = SettingsConfigDict(
env_prefix="SELCTEL_", env_file=".env", extra="ignore"
)
api_base_url: str = Field(
default="https://api.selectel.ru",
description="Базовый URL API Selectel",
)
identity_url: str = Field(
default="https://cloud.api.selcloud.ru/identity/v3",
description="URL сервиса IAM-авторизации",
)
refresh_interval: int = Field(
default=60,
ge=10,
description="Интервал автообновления TUI, секунд",
)
router_id: str = Field(
default="",
description="ID ИИ-роутера (если не указан, берётся первый из списка)",
)
region: str = Field(
default="",
description="Регион/пул (необязательно, для совместимости)",
)
class Credentials(BaseModel):
"""Учётные данные для доступа к API."""
service_user: str = ""
password: str = ""
project_id: str = ""
domain_id: str = ""
static_token: str = ""
def config_dir() -> Path:
"""Каталог конфигурации (Linux: ~/.config, macOS: ~/Library/Application Support)."""
return Path(user_config_dir(APP_NAME))
def data_dir() -> Path:
"""Каталог данных (Linux: ~/.local/share, macOS: ~/Library/Application Support)."""
return Path(user_data_dir(APP_NAME))
def _config_file() -> Path:
return config_dir() / "config.toml"
def _credentials_file() -> Path:
return config_dir() / "credentials.json"
def load_settings() -> Settings:
"""Загрузить настройки: env > config.toml > значения по умолчанию.
Дополнительные env-алиасы: SELCTEL_API_URL (для api_base_url).
Некорректные значения (например, refresh_interval < 10 из старого
конфига) не приводят к ошибке: поле приводится к допустимому значению.
"""
base = _settings_from_env()
cfg = _read_config_file()
if not cfg:
settings = base
else:
# Поля, заданные через env, имеют приоритет над файлом конфигурации.
env_fields = {
name: value
for name, value in base.model_dump().items()
if name in base.model_fields_set
}
settings = _validate_settings({**cfg, **env_fields})
api_url = os.environ.get("SELCTEL_API_URL")
if api_url:
settings = settings.model_copy(update={"api_base_url": api_url})
return settings
def _settings_from_env() -> Settings:
"""Настройки из переменных окружения (устойчиво к некорректным значениям)."""
try:
return Settings()
except ValidationError:
# Некорректное значение в env (например, SELCTEL_REFRESH_INTERVAL=5):
# model_construct() не читает env и не валидирует — чистые дефолты.
return Settings.model_construct()
def _validate_settings(data: dict[str, Any]) -> Settings:
"""Валидировать настройки; при ошибке починить и повторить."""
try:
return Settings.model_validate(data)
except ValidationError:
return Settings.model_validate(_sanitize_settings(data))
def _sanitize_settings(data: dict[str, Any]) -> dict[str, Any]:
"""Привести некорректные значения к допустимым (refresh_interval >= 10)."""
repaired = dict(data)
interval = repaired.get("refresh_interval")
if interval is not None:
try:
repaired["refresh_interval"] = max(10, int(interval))
except (TypeError, ValueError):
repaired["refresh_interval"] = 60
return repaired
def save_settings(settings: Settings) -> None:
"""Сохранить настройки в config.toml."""
path = _config_file()
path.parent.mkdir(parents=True, exist_ok=True)
_write_config_file(path, settings.model_dump(exclude_defaults=True))
def _read_config_file() -> dict[str, Any]:
path = _config_file()
if not path.exists():
return {}
with path.open("rb") as fh:
return dict(tomllib.load(fh))
def _write_config_file(path: Path, data: dict[str, Any]) -> None:
try:
import tomli_w
except ImportError: # pragma: no cover
# tomli_w не установлен — пишем JSON (совместимый фолбэк)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
return
path.write_text(tomli_w.dumps(data))
def load_credentials() -> Credentials:
"""Загрузить секреты: env (SELCTEL_*) > файл credentials.json.
Env-алиасы: SELCTEL_TOKEN (static_token), SELCTEL_SERVICE_USER,
SELCTEL_PASSWORD, SELCTEL_PROJECT_ID, SELCTEL_DOMAIN_ID.
"""
path = _credentials_file()
if path.exists():
with path.open(encoding="utf-8") as fh:
data: dict[str, Any] = json.load(fh)
else:
data = {}
env_overrides = {
"static_token": os.environ.get("SELCTEL_TOKEN"),
"service_user": os.environ.get("SELCTEL_SERVICE_USER"),
"password": os.environ.get("SELCTEL_PASSWORD"),
"project_id": os.environ.get("SELCTEL_PROJECT_ID"),
"domain_id": os.environ.get("SELCTEL_DOMAIN_ID"),
}
for name, value in env_overrides.items():
if value is not None:
data[name] = value
return Credentials.model_validate(data)
def save_credentials(credentials: Credentials) -> None:
"""Сохранить секреты в файл credentials.json с правами 0600."""
path = _credentials_file()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(credentials.model_dump_json(indent=2))
_chmod_0600(path)
def _chmod_0600(path: Path) -> None:
if os.name == "posix": # pragma: no cover
path.chmod(0o600)
+197
View File
@@ -0,0 +1,197 @@
"""Pydantic-модели данных Selectel ML.
Модели ответов Billing Statistics API и Gateway API ИИ-роутера, а также
агрегированные структуры для TUI (потребление, метрики, история).
Все модели устойчивы к отсутствующим полям (значения по умолчанию) и
валидируют неотрицательные числа.
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
def parse_iso_dt(value: str) -> datetime:
"""Распарсить ISO-дату, допускающую 'Z' и нативные (без tz) значения."""
text = value.replace("Z", "+00:00")
try:
return datetime.fromisoformat(text)
except ValueError as exc:
raise ValueError(f"Некорректная дата: {value!r}") from exc
def _validate_dt(value: Any) -> Any:
if isinstance(value, str):
return parse_iso_dt(value)
return value
def format_rub(amount: Decimal | int | float | str) -> str:
"""Сумма в рублях: 854.83 -> '854,83 ₽', 1234.5 -> '1 234,50 ₽'."""
value = Decimal(str(amount))
rub, kop = f"{value:.2f}".split(".")
rub = f"{int(rub):,}".replace(",", " ")
return f"{rub},{kop} ₽"
def format_tokens(value: int) -> str:
"""Число токенов с разделителями: 1234567 -> '1 234 567'."""
return f"{int(value):,}".replace(",", " ")
def format_percent(value: float) -> str:
"""Процент: 99.912 -> '99,9%'."""
return f"{float(value):.1f}".replace(".", ",") + "%"
class TokenUsage(BaseModel):
"""Метрики токенов (все неотрицательные)."""
input_tokens: int = Field(default=0, ge=0)
output_tokens: int = Field(default=0, ge=0)
cache_read_tokens: int = Field(default=0, ge=0)
cache_write_tokens: int = Field(default=0, ge=0)
class Consumption(TokenUsage):
"""Потребление за период: стоимость + токены."""
period: datetime
amount_rub: Decimal = Field(default=Decimal("0"), ge=0)
requests: int = Field(default=0, ge=0)
tokens_total: int = Field(default=0, ge=0)
_validate_period = field_validator("period", mode="before")(_validate_dt)
class Metrics(TokenUsage):
"""Общие метрики роутера за 24 часа."""
availability_pct: float = Field(default=100.0, ge=0, le=100)
avg_ttft_ms: float = Field(default=0.0, ge=0)
avg_latency_ms: float = Field(default=0.0, ge=0)
requests: int = Field(default=0, ge=0)
class ModelMetrics(Metrics):
"""Метрики по конкретной модели."""
model_id: str = ""
model_name: str = ""
class ApiKeyUsage(BaseModel):
"""Лимиты и расход по одному API-ключу роутера."""
name: str = ""
prefix: str = ""
is_active: bool = True
spend_limit_rub: Decimal = Field(default=Decimal("0"), ge=0)
spend_rub: Decimal = Field(default=Decimal("0"), ge=0)
remaining_rub: Decimal = Field(default=Decimal("0"), ge=0)
class BudgetInfo(BaseModel):
"""Бюджет роутера: лимит, расход, остаток."""
spend_limit_rub: Decimal = Field(default=Decimal("0"), ge=0)
spend_rub: Decimal = Field(default=Decimal("0"), ge=0)
remaining_rub: Decimal = Field(default=Decimal("0"), ge=0)
window: str = ""
class HistoryPoint(BaseModel):
"""Точка истории для графика: время, стоимость, токены."""
timestamp: datetime
amount_rub: Decimal = Field(default=Decimal("0"), ge=0)
tokens: TokenUsage = Field(default_factory=TokenUsage)
_validate_timestamp = field_validator("timestamp", mode="before")(_validate_dt)
class MetricInfo(BaseModel):
"""Информация о метрике из billing statistics API."""
id: str = ""
name: str = ""
unit: str = ""
quantity: Decimal | None = None
locale: str | None = None
region: str | None = None
class ProjectRef(BaseModel):
"""Ссылка на проект (ML)."""
id: str = ""
name: str = ""
class ObjectRef(BaseModel):
"""Ссылка на объект (ключ/роутер)."""
id: str = ""
name: str = ""
parent_id: str | None = None
parent_name: str | None = None
type: str | None = None
type_name: str | None = None
class ConsumptionItem(BaseModel):
"""Элемент потребления из /v1/cloud_billing/statistic/consumption.
``project`` и ``object`` могут быть строкой (id), объектом или null.
"""
model_config = ConfigDict(populate_by_name=True)
account_id: str = ""
period: str = ""
provider_key: str = ""
metric: MetricInfo | None = None
value: Decimal = Decimal("0")
project: ProjectRef | str | None = Field(default=None, alias="project")
object: ObjectRef | str | None = Field(default=None, alias="object")
balance: Decimal | None = None
provision_start: str | None = None
provision_end: str | None = None
@property
def project_id(self) -> str | None:
"""ID проекта (из строки или объекта)."""
if isinstance(self.project, ProjectRef):
return self.project.id
return self.project
@property
def object_id(self) -> str | None:
"""ID объекта (из строки или объекта)."""
if isinstance(self.object, ObjectRef):
return self.object.id
return self.object
class RouterOverview(BaseModel):
"""Потребление ИИ-роутера (GET /aig/gateway/v1/gateways/{id}/overview).
``raw`` хранит необработанные данные до полного описания схемы.
"""
raw: dict[str, Any] = Field(
default_factory=dict, description="Необработанные данные ответа"
)
class RouterMetrics(BaseModel):
"""Метрики ИИ-роутера за 24 часа (GET /aig/gateway/v1/gateways/{id}/metrics)."""
raw: dict[str, Any] = Field(
default_factory=dict, description="Необработанные данные ответа"
)
+83
View File
@@ -0,0 +1,83 @@
"""Платформенно-зависимые проверки и диагностика окружения.
Изолирует код, зависящий от ОС и терминала (Python, TERM, LANG, кодировка),
чтобы его можно было тестировать на любой платформе.
"""
from __future__ import annotations
import os
import platform as _platform
import sys
from dataclasses import dataclass
MIN_PYTHON = (3, 10)
# TERM-значения, которые Textual считает пригодными для TUI.
_SUPPORTED_TERM_MARKERS = ("xterm", "linux", "tmux", "screen")
@dataclass(frozen=True)
class PlatformInfo:
"""Снимок окружения, на котором запущено приложение."""
system: str
python_version: str
term: str
lang: str
encoding: str
utf8: bool
@dataclass(frozen=True)
class CheckResult:
"""Результат одной проверки окружения."""
ok: bool
name: str
detail: str
def get_platform_info() -> PlatformInfo:
"""Собрать информацию об ОС и терминале."""
encoding = sys.stdout.encoding or sys.getdefaultencoding() or ""
return PlatformInfo(
system=_platform.system(),
python_version=_platform.python_version(),
term=os.environ.get("TERM", ""),
lang=os.environ.get("LANG", ""),
encoding=encoding,
utf8=encoding.lower().replace("-", "").replace("_", "") == "utf8",
)
def check_environment() -> list[CheckResult]:
"""Проверить окружение: Python, TERM, LANG, кодировку UTF-8."""
info = get_platform_info()
major, minor = (int(x) for x in _platform.python_version_tuple()[:2])
checks: list[CheckResult] = [
CheckResult(
ok=(major, minor) >= MIN_PYTHON,
name="python",
detail=(
f"{info.python_version} (требуется >= {MIN_PYTHON[0]}.{MIN_PYTHON[1]})"
),
),
CheckResult(
ok=bool(info.term)
and any(marker in info.term for marker in _SUPPORTED_TERM_MARKERS),
name="TERM",
detail=info.term or "не задан (рекомендуется xterm-256color)",
),
CheckResult(
ok=bool(info.lang),
name="LANG",
detail=info.lang or "не задан (рекомендуется *.UTF-8)",
),
CheckResult(
ok=info.utf8,
name="UTF-8",
detail=f"{info.encoding} (stdout)",
),
]
return checks
+365
View File
@@ -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}")
+361
View File
@@ -0,0 +1,361 @@
"""Локальный кэш и история данных (SQLite).
База данных в каталоге данных платформы (Linux: ~/.local/share,
macOS: ~/Library/Application Support). Хранит снимки потребления и метрик
при каждом успешном обновлении, а также историю по периодам для графиков.
При недоступности API TUI может показать последние данные из кэша.
"""
from __future__ import annotations
import csv
import datetime as dt
import json
import sqlite3
from decimal import Decimal
from pathlib import Path
from typing import Any, cast
from selectel_ml_tui.config import data_dir
from selectel_ml_tui.models import (
Consumption,
HistoryPoint,
Metrics,
ModelMetrics,
TokenUsage,
parse_iso_dt,
)
SCHEMA = """
CREATE TABLE IF NOT EXISTS consumption_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
period TEXT NOT NULL,
amount_rub TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS metrics_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
availability_pct REAL NOT NULL DEFAULT 100,
avg_ttft_ms REAL NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS model_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
model_id TEXT NOT NULL,
model_name TEXT NOT NULL DEFAULT '',
availability_pct REAL NOT NULL DEFAULT 100,
avg_ttft_ms REAL NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS history (
timestamp TEXT PRIMARY KEY,
amount_rub TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0
);
"""
def default_db_path() -> Path:
"""Путь к базе данных по умолчанию (каталог данных платформы)."""
return data_dir() / "cache.db"
def _utc_now() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat()
def _history_columns(point: HistoryPoint) -> tuple[Any, ...]:
return (
point.timestamp.isoformat(),
str(point.amount_rub),
point.tokens.input_tokens,
point.tokens.output_tokens,
point.tokens.cache_read_tokens,
point.tokens.cache_write_tokens,
)
def _history_from_row(row: sqlite3.Row) -> HistoryPoint:
return HistoryPoint(
timestamp=parse_iso_dt(cast(str, row["timestamp"])),
amount_rub=Decimal(str(row["amount_rub"])),
tokens=TokenUsage(
input_tokens=cast(int, row["input_tokens"]),
output_tokens=cast(int, row["output_tokens"]),
cache_read_tokens=cast(int, row["cache_read_tokens"]),
cache_write_tokens=cast(int, row["cache_write_tokens"]),
),
)
class Storage:
"""SQLite-кэш: снимки потребления/метрик и история для графиков."""
def __init__(self, path: Path | None = None) -> None:
self._path = path if path is not None else default_db_path()
self._path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(self._path)
self._conn.row_factory = sqlite3.Row
self._init_schema()
@property
def path(self) -> Path:
return self._path
def _init_schema(self) -> None:
self._conn.executescript(SCHEMA)
self._conn.commit()
def save_consumption(self, item: Consumption) -> None:
"""Сохранить снимок потребления."""
self._conn.execute(
"""
INSERT INTO consumption_snapshots
(created_at, period, amount_rub, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
_utc_now(),
item.period.isoformat(),
str(item.amount_rub),
item.input_tokens,
item.output_tokens,
item.cache_read_tokens,
item.cache_write_tokens,
),
)
self._conn.commit()
def save_metrics(self, metrics: Metrics) -> None:
"""Сохранить снимок общих метрик."""
self._conn.execute(
"""
INSERT INTO metrics_snapshots
(created_at, availability_pct, avg_ttft_ms, input_tokens,
output_tokens, cache_read_tokens, cache_write_tokens)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
_utc_now(),
metrics.availability_pct,
metrics.avg_ttft_ms,
metrics.input_tokens,
metrics.output_tokens,
metrics.cache_read_tokens,
metrics.cache_write_tokens,
),
)
self._conn.commit()
def save_model_metrics(self, models: list[ModelMetrics]) -> None:
"""Сохранить снимок метрик по моделям (один пакет = один created_at)."""
batch = _utc_now()
for model in models:
self._conn.execute(
"""
INSERT INTO model_metrics
(created_at, model_id, model_name, availability_pct,
avg_ttft_ms, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
batch,
model.model_id,
model.model_name,
model.availability_pct,
model.avg_ttft_ms,
model.input_tokens,
model.output_tokens,
model.cache_read_tokens,
model.cache_write_tokens,
),
)
self._conn.commit()
def save_history(self, point: HistoryPoint) -> None:
"""Сохранить/обновить точку истории (upsert по timestamp)."""
self._conn.execute(
"""
INSERT OR REPLACE INTO history
(timestamp, amount_rub, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens)
VALUES (?, ?, ?, ?, ?, ?)
""",
_history_columns(point),
)
self._conn.commit()
def save_snapshot(
self,
*,
consumption: Consumption | None = None,
metrics: Metrics | None = None,
models: list[ModelMetrics] | None = None,
history_point: HistoryPoint | None = None,
) -> None:
"""Сохранить полный снимок данных после успешного обновления."""
if consumption is not None:
self.save_consumption(consumption)
if metrics is not None:
self.save_metrics(metrics)
if models:
self.save_model_metrics(models)
if history_point is not None:
self.save_history(history_point)
def get_latest_consumption(self) -> Consumption | None:
"""Последний снимок потребления (для офлайн-режима)."""
row = self._conn.execute(
"SELECT * FROM consumption_snapshots ORDER BY id DESC LIMIT 1"
).fetchone()
if row is None:
return None
return Consumption(
period=parse_iso_dt(cast(str, row["period"])),
amount_rub=Decimal(str(row["amount_rub"])),
input_tokens=cast(int, row["input_tokens"]),
output_tokens=cast(int, row["output_tokens"]),
cache_read_tokens=cast(int, row["cache_read_tokens"]),
cache_write_tokens=cast(int, row["cache_write_tokens"]),
)
def get_latest_metrics(self) -> Metrics | None:
"""Последний снимок общих метрик."""
row = self._conn.execute(
"SELECT * FROM metrics_snapshots ORDER BY id DESC LIMIT 1"
).fetchone()
if row is None:
return None
return Metrics(
availability_pct=cast(float, row["availability_pct"]),
avg_ttft_ms=cast(float, row["avg_ttft_ms"]),
input_tokens=cast(int, row["input_tokens"]),
output_tokens=cast(int, row["output_tokens"]),
cache_read_tokens=cast(int, row["cache_read_tokens"]),
cache_write_tokens=cast(int, row["cache_write_tokens"]),
)
def get_latest_model_metrics(self) -> list[ModelMetrics]:
"""Снимок метрик по моделям за последнее обновление."""
rows = self._conn.execute(
"""
SELECT * FROM model_metrics
WHERE created_at = (SELECT MAX(created_at) FROM model_metrics)
ORDER BY model_id
"""
).fetchall()
models: list[ModelMetrics] = []
for row in rows:
models.append(
ModelMetrics(
model_id=cast(str, row["model_id"]),
model_name=cast(str, row["model_name"]),
availability_pct=cast(float, row["availability_pct"]),
avg_ttft_ms=cast(float, row["avg_ttft_ms"]),
input_tokens=cast(int, row["input_tokens"]),
output_tokens=cast(int, row["output_tokens"]),
cache_read_tokens=cast(int, row["cache_read_tokens"]),
cache_write_tokens=cast(int, row["cache_write_tokens"]),
)
)
return models
def get_history(self, days: int = 7) -> list[HistoryPoint]:
"""Серия точек истории за последние ``days`` дней для графика."""
cutoff = (
dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)
).isoformat()
rows = self._conn.execute(
"SELECT * FROM history WHERE timestamp >= ? ORDER BY timestamp ASC",
(cutoff,),
).fetchall()
return [_history_from_row(row) for row in rows]
def cleanup(self, max_age_days: int = 180) -> int:
"""Удалить записи старше ``max_age_days`` дней. Возвращает число удалённых."""
cutoff = (
dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=max_age_days)
).isoformat()
deleted = 0
for table, column in (
("consumption_snapshots", "created_at"),
("metrics_snapshots", "created_at"),
("model_metrics", "created_at"),
("history", "timestamp"),
):
cursor = self._conn.execute(
f"DELETE FROM {table} WHERE {column} < ?", (cutoff,)
)
deleted += cursor.rowcount
self._conn.commit()
return deleted
def export_json(self, path: Path) -> None:
"""Экспортировать всю историю в JSON."""
points = self.get_history(days=3650)
data = [
{
"timestamp": point.timestamp.isoformat(),
"amount_rub": str(point.amount_rub),
"input_tokens": point.tokens.input_tokens,
"output_tokens": point.tokens.output_tokens,
"cache_read_tokens": point.tokens.cache_read_tokens,
"cache_write_tokens": point.tokens.cache_write_tokens,
}
for point in points
]
path.write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
)
def export_csv(self, path: Path) -> None:
"""Экспортировать всю историю в CSV."""
points = self.get_history(days=3650)
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(
[
"timestamp",
"amount_rub",
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
]
)
for point in points:
writer.writerow(_history_columns(point))
def clear(self) -> int:
"""Полностью очистить кэш. Возвращает число удалённых записей."""
deleted = 0
for table in (
"consumption_snapshots",
"metrics_snapshots",
"model_metrics",
"history",
):
cursor = self._conn.execute(f"DELETE FROM {table}")
deleted += cursor.rowcount
self._conn.commit()
return deleted
def close(self) -> None:
self._conn.close()
+5
View File
@@ -0,0 +1,5 @@
"""TUI-интерфейс selectel-ml-tui."""
from selectel_ml_tui.ui.app import SelectelMLTUI, run_app
__all__ = ["SelectelMLTUI", "run_app"]
+589
View File
@@ -0,0 +1,589 @@
"""TUI-приложение selectel-ml-tui на Textual.
Экраны: Dashboard (главный), History (история), Settings (настройки).
Автообновление по таймеру, асинхронная загрузка через workers, офлайн-режим
с показом последних данных из SQLite-кэша.
"""
from __future__ import annotations
from typing import Any
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import (
Button,
DataTable,
Footer,
Header,
Input,
Static,
)
from selectel_ml_tui.config import (
config_dir,
data_dir,
load_credentials,
load_settings,
save_settings,
)
from selectel_ml_tui.models import (
ApiKeyUsage,
ModelMetrics,
format_percent,
format_rub,
format_tokens,
)
from selectel_ml_tui.services import DashboardData, DataService
from selectel_ml_tui.storage import Storage
PERIOD_OPTIONS = (7, 30, 90)
def _mask_token(value: str) -> str:
if not value:
return "(не задан)"
return f"{value[:4]}…({len(value)})"
class ConsumptionBars(Static):
"""Unicode-бар-график потребления (столбцы)."""
data: reactive[list[float]] = reactive([])
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.height = 7
def render(self) -> Any:
values = self.data
if not values:
return "Нет данных для графика"
maximum = max(values)
if maximum <= 0:
return "Нет данных для графика"
lines: list[str] = []
for level in range(self.height, 0, -1):
row = ""
for value in values:
bar_height = max(1, int(value / maximum * self.height))
row += "█" if bar_height >= level else " "
lines.append(row)
return "\n".join(lines) + f"\nmax: {maximum:.0f} ₽"
class DashboardScreen(Screen[Any]):
"""Главный экран: потребление за месяц, метрики, модели."""
BINDINGS = [
Binding("r", "refresh", "Обновить", show=True),
Binding("h", "history", "История", show=True),
Binding("k", "keys", "Ключи", show=True),
Binding("s", "settings", "Настройки", show=True),
]
data = reactive(DashboardData())
loading = reactive(False)
def __init__(
self,
service: DataService,
refresh_interval: int,
storage: Storage,
settings: Any,
credentials: Any,
) -> None:
super().__init__()
self._service = service
self._refresh_interval = refresh_interval
self._storage = storage
self._settings = settings
self._credentials = credentials
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Vertical(id="dashboard"):
with Horizontal(id="panels"):
yield Static("", id="month-panel", classes="panel")
yield Static("", id="metrics-panel", classes="panel")
yield Static("По моделям", id="models-title")
yield DataTable(id="models-table")
yield Footer()
def on_mount(self) -> None:
self.set_interval(self._refresh_interval, self._refresh)
self._refresh()
@work(exclusive=True, group="refresh")
async def _refresh(self) -> None:
self.loading = True
try:
self.data = await self._service.refresh()
except Exception as exc: # noqa: BLE001 - показываем toast при ошибке
self.notify(f"Ошибка обновления: {exc}", severity="error")
finally:
self.loading = False
def watch_data(self, data: DashboardData) -> None:
status = "offline" if data.offline else "online"
amount = (
format_rub(data.month_consumption.amount_rub)
if data.month_consumption
else "—"
)
self.app.sub_title = f"{status} · {amount}"
self._update_month_panel(data)
self._update_metrics_panel(data)
self._update_models_table(data)
def _update_month_panel(self, data: DashboardData) -> None:
text = "[bold]Потребление за месяц[/]\n\n"
if data.month_consumption is None:
text += "Нет данных"
else:
c = data.month_consumption
text += f"Сумма: [b]{format_rub(c.amount_rub)}[/]\n"
text += f"Запросы: {format_tokens(c.requests)}\n"
text += f"Токены: {format_tokens(c.tokens_total)}\n"
if data.today_amount_rub is not None:
text += f"[dim]Сегодня: {format_rub(data.today_amount_rub)}[/]\n"
if data.metrics is not None:
m = data.metrics
text += f"Input: {format_tokens(m.input_tokens)}\n"
text += f"Output: {format_tokens(m.output_tokens)}\n"
text += f"Cache read: {format_tokens(m.cache_read_tokens)}\n"
text += f"Cache write: {format_tokens(m.cache_write_tokens)}"
else:
text += "\n[dim](токены — только через IAM)[/]"
if data.budget is not None:
b = data.budget
text += (
f"\n\n[bold]Бюджет[/]\n"
f"Лимит: {format_rub(b.spend_limit_rub)}\n"
f"Потрачено: {format_rub(b.spend_rub)}\n"
f"Остаток: {format_rub(b.remaining_rub)}"
)
if data.offline:
text += "\n\n[dim]офлайн (данные из кэша)[/]"
if data.error:
text += f"\n\n[red]{data.error}[/]"
self.query_one("#month-panel", Static).update(text)
def _update_metrics_panel(self, data: DashboardData) -> None:
text = "[bold]Метрики за 24 часа[/]\n\n"
if data.metrics is None:
text += "Нет данных"
else:
m = data.metrics
text += f"Доступность: {format_percent(m.availability_pct)}\n"
text += f"Средний TTFT: {m.avg_ttft_ms:.1f} мс\n"
text += f"Средняя задержка: {m.avg_latency_ms:.1f} мс\n"
text += f"Запросы: {format_tokens(m.requests)}\n"
text += f"Input: {format_tokens(m.input_tokens)}\n"
text += f"Output: {format_tokens(m.output_tokens)}\n"
text += f"Cache read: {format_tokens(m.cache_read_tokens)}\n"
text += f"Cache write: {format_tokens(m.cache_write_tokens)}"
self.query_one("#metrics-panel", Static).update(text)
def _update_models_table(self, data: DashboardData) -> None:
table = self.query_one("#models-table", DataTable)
if not table.columns:
table.add_columns(
"Модель",
"Запросы",
"Доступность",
"TTFT, мс",
"Задержка, мс",
)
table.cursor_type = "row"
table.clear()
for model in data.models:
table.add_row(
model.model_name,
format_tokens(model.requests),
format_percent(model.availability_pct),
f"{model.avg_ttft_ms:.1f}",
f"{model.avg_latency_ms:.1f}",
)
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
event.stop()
if not self.data.models:
return
row = event.cursor_row
if row is None or row >= len(self.data.models):
return
self.app.push_screen(ModelDetailScreen(self.data.models[row]))
def action_refresh(self) -> None:
self._refresh()
def action_history(self) -> None:
self.app.push_screen(HistoryScreen(self._storage))
def action_keys(self) -> None:
self.app.push_screen(KeysScreen(self.data.keys, self.data.budget))
def action_settings(self) -> None:
self.app.push_screen(
SettingsScreen(self._settings, self._credentials, self._storage)
)
def action_model_detail(self) -> None:
if not self.data.models:
return
table = self.query_one("#models-table", DataTable)
row = table.cursor_row
if row is None:
row = 0
if row >= len(self.data.models):
return
self.app.push_screen(ModelDetailScreen(self.data.models[row]))
class KeysScreen(Screen[Any]):
"""Лимиты и расход по API-ключам роутера."""
BINDINGS = [
Binding("escape", "back", "Назад", show=True),
]
def __init__(self, keys: list[ApiKeyUsage], budget: Any | None = None) -> None:
super().__init__()
self._keys = keys
self._budget = budget
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield Static("API-ключи и лимиты", id="keys-title")
if self._budget is not None:
yield Static("", id="keys-budget")
yield DataTable(id="keys-table")
yield Footer()
def on_mount(self) -> None:
if self._budget is not None:
b = self._budget
self.query_one("#keys-budget", Static).update(
f"[bold]Бюджет роутера[/]\n"
f"Лимит: {format_rub(b.spend_limit_rub)} "
f"Потрачено: {format_rub(b.spend_rub)} "
f"Остаток: {format_rub(b.remaining_rub)}"
)
table = self.query_one("#keys-table", DataTable)
table.add_columns("Ключ", "Префикс", "Статус", "Лимит", "Потрачено", "Остаток")
for key in self._keys:
table.add_row(
key.name,
key.prefix,
"активен" if key.is_active else "неактивен",
format_rub(key.spend_limit_rub),
format_rub(key.spend_rub),
format_rub(key.remaining_rub),
)
def action_back(self) -> None:
self.app.pop_screen()
class HistoryScreen(Screen[Any]):
"""История потребления: бар-график и таблица по дням."""
BINDINGS = [
Binding("escape", "back", "Назад", show=True),
Binding("r", "refresh", "Обновить", show=True),
]
def __init__(self, storage: Storage) -> None:
super().__init__()
self._storage = storage
self._days = 30
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield Static("История потребления", id="history-title")
with Horizontal(id="period-buttons"):
for days in PERIOD_OPTIONS:
yield Button(
f"{days} дней",
id=f"period-{days}",
variant="primary" if days == self._days else "default",
)
yield Static("Стоимость, руб. (по дням)", id="bars-title")
yield ConsumptionBars("", id="history-bars")
yield DataTable(id="history-table")
yield Footer()
def on_mount(self) -> None:
self._reload()
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id
if button_id and button_id.startswith("period-"):
self._days = int(button_id.split("-", 1)[1])
for days in PERIOD_OPTIONS:
button = self.query_one(f"#period-{days}", Button)
button.variant = "primary" if days == self._days else "default"
self._reload()
def action_refresh(self) -> None:
self._reload()
def _reload(self) -> None:
points = self._storage.get_history(days=self._days)
self.query_one("#history-title", Static).update(
f"История потребления (последние {self._days} дней)"
)
values = [float(point.amount_rub) for point in points]
self.query_one("#history-bars", ConsumptionBars).data = values
table = self.query_one("#history-table", DataTable)
if not table.columns:
table.add_columns("Дата", "Сумма", "Input", "Output", "Cache")
table.clear()
for point in reversed(points):
table.add_row(
str(point.timestamp.date()),
format_rub(point.amount_rub),
format_tokens(point.tokens.input_tokens),
format_tokens(point.tokens.output_tokens),
format_tokens(
point.tokens.cache_read_tokens + point.tokens.cache_write_tokens
),
)
def action_back(self) -> None:
self.app.pop_screen()
class ModelDetailScreen(Screen[Any]):
"""Детализация метрик конкретной модели."""
BINDINGS = [
Binding("escape", "back", "Назад", show=True),
]
def __init__(self, model: ModelMetrics) -> None:
super().__init__()
self._model = model
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield Static("", id="model-detail")
yield Footer()
def on_mount(self) -> None:
m = self._model
body = f"[b]{m.model_name}[/b]\nID: {m.model_id}\n\n"
body += f"Доступность: {format_percent(m.availability_pct)}\n"
body += f"Запросы: {format_tokens(m.requests)}\n"
body += f"Средний TTFT: {m.avg_ttft_ms:.1f} мс\n"
body += f"Средняя задержка: {m.avg_latency_ms:.1f} мс"
self.query_one("#model-detail", Static).update(body)
def action_back(self) -> None:
self.app.pop_screen()
class SettingsScreen(Screen[Any]):
"""Настройки: интервал обновления, очистка кэша, пути и токен."""
BINDINGS = [
Binding("escape", "back", "Назад", show=True),
]
def __init__(self, settings: Any, credentials: Any, storage: Storage) -> None:
super().__init__()
self._settings = settings
self._credentials = credentials
self._storage = storage
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield Static("Настройки", id="settings-title")
with Vertical(id="settings-body"):
yield Static("Интервал обновления, сек:", id="interval-label")
yield Input(
value=str(self._settings.refresh_interval),
id="interval-input",
type="integer",
)
with Horizontal(id="settings-actions"):
yield Button("Сохранить", id="save-settings", variant="primary")
yield Button("Очистить кэш", id="clear-cache", variant="error")
yield Static("", id="settings-info")
yield Footer()
def on_mount(self) -> None:
info = (
f"API base URL: {self._settings.api_base_url}\n"
f"Router id: {self._settings.router_id or '(авто)'}\n"
f"Сервисный пользователь: "
f"{self._credentials.service_user or '(не задан)'}\n"
f"Токен: {_mask_token(self._credentials.static_token)}\n"
f"Config dir: {config_dir()}\n"
f"Data dir: {data_dir()}"
)
self.query_one("#settings-info", Static).update(info)
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id
if button_id == "save-settings":
self._save_interval()
elif button_id == "clear-cache":
self._clear_cache()
def _save_interval(self) -> None:
raw = self.query_one("#interval-input", Input).value
try:
interval = int(raw)
except ValueError:
self.notify("Некорректное значение интервала", severity="error")
return
if interval < 10:
self.notify("Интервал должен быть не менее 10 секунд", severity="error")
return
if interval == self._settings.refresh_interval:
return
self._settings.refresh_interval = interval
save_settings(self._settings)
self.notify("Настройки сохранены")
def _clear_cache(self) -> None:
deleted = self._storage.clear()
self.notify(f"Кэш очищен: удалено записей: {deleted}", severity="information")
def action_back(self) -> None:
self._save_interval()
self.app.pop_screen()
class SelectelMLTUI(App[Any]):
"""Главное приложение."""
TITLE = "Selectel ML Tokens"
BINDINGS = [
Binding("q", "quit", "Выход", show=False),
]
async def action_quit(self) -> None:
self.exit()
CSS = """
#panels {
height: 10;
}
.panel {
width: 1fr;
height: 100%;
border: round $primary;
padding: 1 2;
margin: 0 1 0 0;
}
#models-title {
margin: 1 0 0 0;
text-style: bold;
}
#models-table {
height: 8;
}
#keys-title {
margin: 1 0 0 0;
text-style: bold;
}
#keys-budget {
margin: 1 0;
padding: 1 2;
border: round $primary;
}
#keys-table {
height: 60%;
margin: 1 0 0 0;
}
#history-bars {
height: 9;
margin: 1 0;
border: round $secondary;
padding: 0 1;
}
#history-table {
height: 40%;
margin: 1 0 0 0;
}
#period-buttons {
height: 3;
margin: 1 0 0 0;
}
#period-buttons Button {
width: 10;
margin: 0 1 0 0;
}
#bars-title {
margin: 1 0 0 0;
text-style: bold;
}
#settings-body {
margin: 1 2;
}
#interval-label {
margin: 1 0 0 0;
}
#interval-input {
width: 20;
}
#settings-actions {
margin: 1 0;
}
#settings-actions Button {
width: 16;
margin: 0 1 0 0;
}
#settings-info {
margin: 1 0 0 0;
}
#model-detail {
margin: 2 4;
padding: 1 2;
border: round $primary;
}
"""
def __init__(
self,
settings: Any | None = None,
credentials: Any | None = None,
storage: Storage | None = None,
service: DataService | None = None,
) -> None:
super().__init__()
self._settings = settings if settings is not None else load_settings()
self._credentials = (
credentials if credentials is not None else load_credentials()
)
self._storage = storage if storage is not None else Storage()
self._service = (
service
if service is not None
else DataService(self._settings, self._credentials, self._storage)
)
def on_mount(self) -> None:
self.push_screen(
DashboardScreen(
self._service,
self._settings.refresh_interval,
self._storage,
self._settings,
self._credentials,
)
)
def run_app() -> None:
"""Запустить TUI-приложение (entry point)."""
app = SelectelMLTUI()
app.run()