Initial import: selectel-ml-tui TUI for Selectel AI-router token usage
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{md,toml,yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
coverage.xml
|
||||
|
||||
# Secrets and local config
|
||||
.env
|
||||
config.toml
|
||||
credentials
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,92 @@
|
||||
# Changelog
|
||||
|
||||
Все заметные изменения проекта `selectel-ml-tui`.
|
||||
|
||||
## [1.0.3] — 2026-08-19
|
||||
|
||||
Полноценный вывод данных из Gateway API ИИ-роутера (IAM).
|
||||
|
||||
### Добавлено
|
||||
- Парсинг реальной схемы Gateway API:
|
||||
- `/overview` → потребление за месяц (сумма в нанорублях, запросы, токены
|
||||
всего, input/output/cache read/cache write), бюджет роутера (лимит/расход/
|
||||
остаток);
|
||||
- `/metrics` → метрики за 24 ч (доступность `success_rate_pct`, TTFT и
|
||||
задержка в секундах→мс, запросы, токены) и `by_model` — по моделям
|
||||
(запросы/доступность/TTFT);
|
||||
- `/keys` → лимиты и расход по API-ключам (имя, префикс, статус, лимит,
|
||||
потрачено, остаток).
|
||||
- Новый экран **«API-ключи и лимиты»** (клавиша `k`) и панель бюджета на
|
||||
главном экране.
|
||||
- Модели: добавлены поля `requests`, `tokens_total`, `avg_latency_ms`; новые
|
||||
модели `ApiKeyUsage`, `BudgetInfo`.
|
||||
|
||||
### Исправлено
|
||||
- «Потрачено за месяц» берётся из Gateway Overview (совпадает с вкладкой
|
||||
«Обзор»), биллинг — фолбэк при отсутствии IAM.
|
||||
- `SettingsScreen` не перезаписывает конфиг, если интервал не менялся.
|
||||
|
||||
## [1.0.2] — 2026-08-19
|
||||
|
||||
Исправление отображения данных и прав на файлы конфигурации.
|
||||
|
||||
### Исправлено
|
||||
- **Сумма за месяц**: раньше `end` обрезался до начала текущего дня — сегодняшние
|
||||
расходы не входили в сумму. Теперь `end` = текущий момент (naive datetime),
|
||||
сумма включает текущий день.
|
||||
- **История**: в историю записывалась месячная сумма как «точка дня». Теперь
|
||||
сохраняется дневная стоимость (отдельно показывается «Сегодня»).
|
||||
- **Токены/модели**: при отсутствии IAM (нет сервисного пользователя) поля
|
||||
Input/Output/Cache больше не показывают вводящие в заблуждение нули — выводится
|
||||
пояснение «Токены и модели — только через IAM».
|
||||
|
||||
### Примечание
|
||||
- Gateway API (токены по моделям, каталог моделей) требует IAM-токен сервисного
|
||||
пользователя. В аккаунте нет сервисных пользователей — необходимо создать его
|
||||
в панели (my.selectel.ru → IAM → Сервисные пользователи) и настроить через
|
||||
`selectel-ml-tui config`.
|
||||
- `credentials.json`/`config.toml` должны принадлежать текущему пользователю
|
||||
(права 0600). Запуск от `root`/`sudo` создаёт файлы с владельцем root, из-за
|
||||
чего обычный запуск падает с `PermissionError` — исправляется `chown`.
|
||||
|
||||
## [1.0.1] — 2026-08-19
|
||||
|
||||
Исправление: крах при запуске, если в `config.toml` (или env
|
||||
`SELCTEL_REFRESH_INTERVAL`) указан `refresh_interval < 10`.
|
||||
|
||||
### Исправлено
|
||||
- `load_settings()` больше не бросает `ValidationError` на некорректных
|
||||
значениях: `refresh_interval` приводится к допустимому диапазону
|
||||
(минимум 10 секунд), мусорные значения — к умолчанию 60.
|
||||
- Мастер `selectel-ml-tui config` отклоняет интервал < 10 (повторный запрос).
|
||||
|
||||
## [1.0.0] — 2026-08-19
|
||||
|
||||
Релиз, готовый к использованию.
|
||||
|
||||
### Добавлено
|
||||
- TUI-дашборд (Textual): потребление за месяц, метрики за 24 часа, таблица
|
||||
«По моделям», статус `online`/`offline` в заголовке, автообновление.
|
||||
- Экраны: **История** (бар-график + таблица по дням, периоды 7/30/90),
|
||||
**Детали модели** (Enter в таблице моделей), **Настройки** (интервал
|
||||
обновления, «Очистить кэш», пути, маскированный токен).
|
||||
- API-клиенты: Billing Statistics (стоимость), Gateway API (токены/метрики),
|
||||
IAM (получение и кэширование токена с автообновлением).
|
||||
- Модели данных (pydantic) с валидацией и форматированием чисел.
|
||||
- Локальный SQLite-кэш: снимки потребления/метрик/моделей, история по дням
|
||||
(upsert), очистка старых записей (TTL 180 дней), экспорт CSV/JSON.
|
||||
- CLI: `config` (интерактивный мастер), `run`, `export`, `doctor`
|
||||
(диагностика окружения); `python -m selectel_ml_tui`.
|
||||
- Адаптация под Ubuntu и macOS: скрипты установки, `scripts/run.sh`,
|
||||
диагностика `TERM`/`LANG`/UTF-8, CI (GitHub Actions: ubuntu + macos).
|
||||
- Упаковка: однофайловый бинарник через PyInstaller (`make build`).
|
||||
|
||||
### Качество
|
||||
- 89 тестов, покрытие 91% (порог 80%).
|
||||
- ruff (flake8/isort/format), mypy strict — без ошибок.
|
||||
- Подсказка при первом запуске без токена, FAQ и troubleshooting в README.
|
||||
|
||||
[1.0.3]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.3
|
||||
[1.0.2]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.2
|
||||
[1.0.1]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.1
|
||||
[1.0.0]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.0
|
||||
@@ -0,0 +1,48 @@
|
||||
PYTHON ?= $(shell command -v python3.13 || command -v python3.12 || command -v python3.11 || command -v python3.10 || command -v python3)
|
||||
VENV ?= .venv
|
||||
PIP := $(VENV)/bin/pip
|
||||
PY := $(VENV)/bin/python
|
||||
|
||||
.PHONY: help venv install run test lint typecheck format build clean install-ubuntu install-macos
|
||||
|
||||
help: ## Показать доступные цели
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
venv: ## Создать виртуальное окружение
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
|
||||
install: venv ## Установить проект в режиме разработки
|
||||
$(PIP) install --upgrade pip
|
||||
$(PIP) install -e ".[dev]"
|
||||
|
||||
run: ## Запустить TUI-приложение
|
||||
$(PY) -m selectel_ml_tui run
|
||||
|
||||
test: ## Запустить тесты
|
||||
$(PY) -m pytest -v --cov=selectel_ml_tui --cov-report=term-missing
|
||||
|
||||
lint: ## Проверить стиль кода (ruff)
|
||||
$(VENV)/bin/ruff check .
|
||||
|
||||
typecheck: ## Проверить типы (mypy)
|
||||
$(VENV)/bin/mypy src
|
||||
|
||||
format: ## Форматировать код (ruff format)
|
||||
$(VENV)/bin/ruff format .
|
||||
|
||||
build: ## Собрать однофайловый исполняемый файл (PyInstaller)
|
||||
$(PIP) install pyinstaller
|
||||
$(PY) -m PyInstaller --noconfirm --clean --onefile --console \
|
||||
--name selectel-ml-tui \
|
||||
--collect-all textual \
|
||||
--paths src \
|
||||
src/selectel_ml_tui/__main__.py
|
||||
|
||||
install-ubuntu: ## Установить на Ubuntu (apt)
|
||||
./scripts/install_ubuntu.sh
|
||||
|
||||
install-macos: ## Установить на macOS (Homebrew)
|
||||
./scripts/install_macos.sh
|
||||
|
||||
clean: ## Удалить артефакты и виртуальное окружение
|
||||
rm -rf $(VENV) .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov build dist *.egg-info
|
||||
@@ -0,0 +1,282 @@
|
||||
# selectel-ml-tui
|
||||
|
||||
TUI-приложение (Python 3.10+) для отображения потребления **ИИ-роутера Selectel**
|
||||
прямо в терминале. Работает на Ubuntu (22.04+/24.04) и macOS (12+).
|
||||
|
||||
## Возможности
|
||||
|
||||
- Дашборд потребления за месяц (сумма, запросы, input/output/cache токены)
|
||||
- Метрики за 24 часа (доступность, TTFT, задержка) — общие и по моделям
|
||||
- Бюджет роутера (лимит/расход/остаток) и лимиты по API-ключам
|
||||
- История потребления по дням с Unicode-бар-графиком (локальный SQLite-кэш)
|
||||
- Детализация метрик конкретной модели (Enter в таблице моделей)
|
||||
- Офлайн-режим: при недоступности API показывает последние данные из кэша
|
||||
- Автообновление по интервалу из конфигурации (по умолчанию 60 с)
|
||||
- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`)
|
||||
|
||||
## Интерфейс
|
||||
|
||||
```
|
||||
┌─ Selectel ML Tokens ──────────────── online · 1 186,73 ₽ ───────┐
|
||||
│ ┌─ Потребление за месяц ─┐ ┌─ Метрики за 24 часа ──────────────┐ │
|
||||
│ │ Сумма: 1 186,73 ₽ │ │ Доступность: 100,0% │ │
|
||||
│ │ Запросы: 1 096 │ │ TTFT: 3 670,0 мс │ │
|
||||
│ │ Токены: 482 749 246 │ │ Запросы: 1 097 │ │
|
||||
│ │ Input: 244 004 716 │ │ Input/Output/Cache: … │ │
|
||||
│ │ Output/Cache: … │ └───────────────────────────────────┘ │
|
||||
│ │ Бюджет: 2 000 ₽ │ │
|
||||
│ └────────────────────────┘ │
|
||||
│ По моделям │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Модель │ Запросы │ Доступность │ TTFT, мс │ Задержка, мс │ │
|
||||
│ │ deepseek-v4-pro │ 1028 │ 100,0% │ 3,69 │ 12,29 │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ r — обновить h — история k — ключи s — настройки q — выход │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Экран **История** (**h**): бар-график стоимости по дням + таблица
|
||||
(период 7/30/90 дней — кнопками). Экран **Ключи** (**k**): лимиты и расход по
|
||||
API-ключам. Экран **Настройки** (**s**): интервал обновления, «Очистить кэш»,
|
||||
пути и маскированный токен. **Esc** — назад.
|
||||
|
||||
## Горячие клавиши
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| `r` | Обновить данные |
|
||||
| `h` | История потребления |
|
||||
| `k` | API-ключи и лимиты |
|
||||
| `s` | Настройки |
|
||||
| `q` | Выход |
|
||||
| `Esc` | Назад (с экрана истории/настроек/модели) |
|
||||
| `Enter` / клик | Детализация метрик модели (в таблице «По моделям») |
|
||||
|
||||
На macOS горячие клавиши — обычные символьные (`r/h/s/q`); Option/Command
|
||||
зарезервированы системой и не используются.
|
||||
|
||||
## Требования
|
||||
|
||||
- Python 3.10+
|
||||
- Ubuntu 22.04+/24.04, macOS 12+
|
||||
- Терминал с поддержкой цвета и UTF-8 (GNOME Terminal, Konsole, tmux,
|
||||
Terminal.app, iTerm2)
|
||||
|
||||
## Источники данных
|
||||
|
||||
- **Billing Statistics API** (`https://api.selectel.ru`, заголовок `X-Token`):
|
||||
стоимость потребления ИИ-роутера (провайдер `aig`) по дням/часам. Параметры
|
||||
`start`/`end` принимаются в полном ISO-формате (`YYYY-MM-DDTHH:MM:SS.000`),
|
||||
короткие даты дополняются клиентом автоматически. Значение `value` в копейках.
|
||||
- **Gateway API ИИ-роутера** (`/aig/gateway/v1/*`, IAM-токен проекта + `X-Project-Id`):
|
||||
токены, метрики, каталог моделей.
|
||||
- **IAM-авторизация**: `POST https://cloud.api.selcloud.ru/identity/v3/auth/tokens`
|
||||
(логин/пароль сервисного пользователя, scope project, TTL 24 ч, кэш с
|
||||
автообновлением за 5 минут до истечения).
|
||||
|
||||
## Получение токенов (панель Selectel)
|
||||
|
||||
- **Статический токен** (billing-метрики): панель `my.selectel.ru` → профиль →
|
||||
**Доступ** → **API-ключи**. Вставьте токен в мастер настройки.
|
||||
- **IAM-токен** (gateway: сумма/токены/модели/ключи): нужен **сервисный
|
||||
пользователь**. Создайте его в `my.selectel.ru` → **IAM** → **Сервисные
|
||||
пользователи** (имя без email, например `ml-monitor`), назначьте роль на
|
||||
нужный ML-проект. Затем в `selectel-ml-tui config` укажите имя сервисного
|
||||
пользователя, его пароль, `project_id` и `domain_id` (номер аккаунта).
|
||||
Без сервисного пользователя доступна только стоимость из billing.
|
||||
|
||||
Подробнее об API: [Selectel ML / управление роутером](https://selectel.ru/services/ml/) —
|
||||
внутренние API панели и документация по биллингу.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
make install # Ubuntu/macOS
|
||||
# или
|
||||
./scripts/install_ubuntu.sh # Ubuntu
|
||||
./scripts/install_macos.sh # macOS
|
||||
# или make install-ubuntu / make install-macos
|
||||
```
|
||||
|
||||
Всё сводится к двум командам на каждой ОС:
|
||||
|
||||
```bash
|
||||
# Ubuntu
|
||||
sudo apt-get update && sudo apt-get install -y python3 python3-venv python3-pip
|
||||
pip install --user . # или ./scripts/install_ubuntu.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install python@3.11 # или ./scripts/install_macos.sh
|
||||
pip install --user .
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
make run
|
||||
# или
|
||||
.venv/bin/selectel-ml-tui run
|
||||
# или (с корректным TERM/LANG, подходит для tmux/ssh)
|
||||
./scripts/run.sh run
|
||||
```
|
||||
|
||||
CLI-точка входа: `selectel-ml-tui` (console_scripts), а также `python -m selectel_ml_tui`.
|
||||
|
||||
При первом запуске без настроенных учётных данных выводится подсказка
|
||||
(`selectel-ml-tui config`); `selectel-ml-tui doctor` предупреждает, что токен
|
||||
не настроен.
|
||||
|
||||
TUI-дашборд:
|
||||
- **Dashboard**: потребление за месяц (сумма, токены), метрики за 24 часа,
|
||||
таблица «По моделям»; статус в заголовке — `online`/`offline`
|
||||
- **r** — обновить, **h** — история, **s** — настройки, **q** — выход
|
||||
- Автообновление по интервалу из конфигурации (по умолчанию 60 с)
|
||||
- При недоступности API показывает последние данные из кэша (офлайн)
|
||||
- **История** (**h**): таблица по дням из кэша + Unicode-бар-график стоимости,
|
||||
выбор периода 7/30/90 дней (кнопки)
|
||||
- **Модель** (Enter/клик в таблице моделей): детализация метрик конкретной модели
|
||||
- **Настройки** (**s**): изменение интервала обновления (сохранение в конфиг),
|
||||
«Очистить кэш», пути к конфигу/БД, маскированный токен
|
||||
- Навигация: **Esc** — назад
|
||||
|
||||
## Конфигурация
|
||||
|
||||
```bash
|
||||
selectel-ml-tui config # интерактивный мастер настройки
|
||||
selectel-ml-tui config --path # показать пути конфигурации
|
||||
selectel-ml-tui config --show # показать текущие настройки (секреты маскируются)
|
||||
```
|
||||
|
||||
Настройки хранятся в `config.toml`, учётные данные (пароль сервисного
|
||||
пользователя, статический токен) — в `credentials.json` в каталоге конфигурации
|
||||
(права 0600). Каталог: Linux — `~/.config/selectel-ml-tui`, macOS —
|
||||
`~/Library/Application Support/selectel-ml-tui`.
|
||||
|
||||
Переменные окружения имеют приоритет над файлом конфигурации:
|
||||
|
||||
- `SELCTEL_API_URL` — базовый URL API (вместо `api_base_url`)
|
||||
- `SELCTEL_TOKEN` — статический токен (вместо `static_token`)
|
||||
- `SELCTEL_SERVICE_USER`, `SELCTEL_PASSWORD` — сервисный пользователь
|
||||
- `SELCTEL_PROJECT_ID`, `SELCTEL_DOMAIN_ID` — проект и аккаунт
|
||||
- остальные поля: `SELCTEL_API_BASE_URL`, `SELCTEL_REFRESH_INTERVAL`, `SELCTEL_ROUTER_ID`, …
|
||||
|
||||
## Локальный кэш и история
|
||||
|
||||
Данные сохраняются в SQLite-базу (`cache.db`) в каталоге данных платформы
|
||||
(Linux: `~/.local/share/selectel-ml-tui`, macOS:
|
||||
`~/Library/Application Support/selectel-ml-tui`) при каждом успешном обновлении:
|
||||
- `consumption_snapshots` — снимки потребления (сумма, токены)
|
||||
- `metrics_snapshots` — снимки общих метрик
|
||||
- `model_metrics` — метрики по моделям
|
||||
- `history` — точки истории по периодам (upsert, без дублей)
|
||||
|
||||
При недоступности API TUI показывает «офлайн» и последние данные из кэша.
|
||||
Старые записи удаляются автоматически (TTL 180 дней).
|
||||
|
||||
Экспорт истории: `selectel-ml-tui export --format json|csv -o <файл>`.
|
||||
|
||||
## Платформы (Ubuntu и macOS)
|
||||
|
||||
Диагностика окружения: `selectel-ml-tui doctor` — проверяет Python (>=3.10),
|
||||
`TERM`, `LANG`, кодировку UTF-8, выводит пути к конфигурации и данным.
|
||||
|
||||
Отличия платформ:
|
||||
|
||||
| | Ubuntu 22.04+/24.04 | macOS 12+ |
|
||||
|---|---|---|
|
||||
| Python | python3.10+ (apt: `python3 python3-venv python3-pip`) | Homebrew `python@3.11`+ |
|
||||
| Каталог конфигурации | `~/.config/selectel-ml-tui` | `~/Library/Application Support/selectel-ml-tui` |
|
||||
| Каталог данных | `~/.local/share/selectel-ml-tui` | `~/Library/Application Support/selectel-ml-tui` |
|
||||
| Права доступа | 0700/0600 (по умолчанию) | то же; доступ на чтение к `Application Support` |
|
||||
| Терминалы | GNOME Terminal, Konsole, tmux | Terminal.app, iTerm2 |
|
||||
| Клавиши | — | hotkeys через Ctrl/символы (r/h/s/q); Option/Command зарезервированы системой |
|
||||
|
||||
Требования к окружению терминала:
|
||||
- `TERM` должен указывать на цветной терминал (`xterm-256color`, `tmux-256color`).
|
||||
В `tmux`/`screen` задаётся автоматически; `scripts/run.sh` подставляет
|
||||
`xterm-256color`, если переменная не задана.
|
||||
- `LANG`/`locale` должны быть UTF-8 (`ru_RU.UTF-8`, `en_US.UTF-8`), иначе
|
||||
возможны сбои отображения русского текста и граничных символов (▌▀█).
|
||||
|
||||
CI: GitHub Actions (`ubuntu-latest` + `macos-latest`, Python 3.11/3.12) — ruff,
|
||||
mypy, pytest+coverage, doctor.
|
||||
|
||||
## Упаковка (однофайловый исполняемый файл)
|
||||
|
||||
Опционально: сборка автономного бинарника через PyInstaller.
|
||||
|
||||
```bash
|
||||
make build # или ./scripts/build_ubuntu.sh / build_macos.sh
|
||||
./dist/selectel-ml-tui doctor # проверка
|
||||
```
|
||||
|
||||
Артефакт не зависит от установленного Python и venv (собирается на целевой ОС).
|
||||
|
||||
История версий: [CHANGELOG.md](CHANGELOG.md). Финальный чек-лист тестирования:
|
||||
[docs/FINAL_CHECKLIST.md](docs/FINAL_CHECKLIST.md).
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
make test # pytest + coverage (порог 80%)
|
||||
make lint # ruff (flake8/isort/format)
|
||||
make typecheck # mypy (strict)
|
||||
make format # ruff format
|
||||
```
|
||||
|
||||
- Unit-тесты: config (приоритет env), storage, models (парсинг/форматирование),
|
||||
API-клиент (httpx.MockTransport: успех, 401, 429, 500, timeout, сетевые ошибки)
|
||||
- Интеграционный smoke-тест TUI: headless-запуск приложения через `App.run_test()`
|
||||
- Статический анализ настроен в `pyproject.toml` (`[tool.ruff]`, `[tool.mypy]`,
|
||||
`fail_under = 80`).
|
||||
|
||||
## FAQ и troubleshooting
|
||||
|
||||
**«Учётные данные не настроены» при запуске**
|
||||
Выполните `selectel-ml-tui config` — мастер настройки запросит токен
|
||||
(и опционально сервисного пользователя для метрик моделей). Проверка:
|
||||
`selectel-ml-tui doctor` → `[OK] token`.
|
||||
|
||||
**Ошибки авторизации (401 / 403)**
|
||||
- Статический токен истёк или неверен — получите новый в панели Selectel
|
||||
(профиль → API-токены) и обновите через `selectel-ml-tui config`.
|
||||
- IAM-доступ: сервисный пользователь должен иметь роль *reader* на нужном
|
||||
ML-проекте; проверьте `project_id` и `domain_id` (аккаунт). У IAM-токена
|
||||
TTL 24 ч — приложение обновляет его автоматически (за 5 минут до истечения).
|
||||
|
||||
**В дашборде нет метрик по моделям (Input/Output/Cache отсутствуют)**
|
||||
Gateway-данные (токены по моделям, каталог моделей) требуют IAM-токена
|
||||
сервисного пользователя. Если сервисный пользователь не создан, доступна
|
||||
только стоимость (billing) — на дашборде выводится пояснение
|
||||
«Токены и модели — только через IAM». Создать сервисного пользователя:
|
||||
my.selectel.ru → IAM → Сервисные пользователи (с областью доступа на нужный
|
||||
проект), затем `selectel-ml-tui config`.
|
||||
|
||||
**PermissionError при запуске (`credentials.json` не читается)**
|
||||
Файлы конфигурации должны принадлежать текущему пользователю (права 0600).
|
||||
Если запускать приложение от `root`/`sudo`, файлы создаются с владельцем root,
|
||||
и обычный запуск падает. Исправление (macOS/Linux):
|
||||
`sudo chown -R $USER ~/Library/Application\ Support/selectel-ml-tui`
|
||||
(на Linux путь `~/.config/selectel-ml-tui`). Не запускайте приложение от root.
|
||||
|
||||
**Ошибка 422 при запросе данных**
|
||||
API принимает только полные ISO-таймстампы (`YYYY-MM-DDTHH:MM:SS.000`).
|
||||
Приложение формирует их самостоятельно; если ошибка повторяется — проверьте
|
||||
часовой пояс (`doctor` → `LANG`/`locale`).
|
||||
|
||||
**«Краказябры»/битые символы в терминале**
|
||||
`LANG`/`locale` должны быть UTF-8 (`ru_RU.UTF-8`, `en_US.UTF-8`), `TERM` —
|
||||
цветной (`xterm-256color`). Запуск через `./scripts/run.sh run` подставит
|
||||
значения автоматически (полезно в tmux/ssh).
|
||||
|
||||
**Офлайн-режим**
|
||||
При недоступности API в заголовке появляется `offline`, а дашборд показывает
|
||||
последние снимки из SQLite-кэша. Данные обновятся автоматически при
|
||||
восстановлении сети (клавиша `r` — вручную).
|
||||
|
||||
**«Нет данных для графика» в истории**
|
||||
Кэш пуст — данные появляются после первого успешного обновления (каждая
|
||||
загрузка сохраняет точку за день). Очистить кэш можно в экране «Настройки»
|
||||
или через `export` (не очищает).
|
||||
@@ -0,0 +1,39 @@
|
||||
# Финальный чек-лист тестирования (релиз 1.0.0)
|
||||
|
||||
Прогон на каждой ОС (Ubuntu 22.04/24.04 и macOS 12+). Автоматические проверки
|
||||
выполняются в CI (GitHub Actions: `ubuntu-latest`, `macos-latest`); ручные —
|
||||
в терминале.
|
||||
|
||||
## Автоматически (CI: `make test lint typecheck`)
|
||||
|
||||
- [x] Установка: `python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"`
|
||||
- [x] CLI entry point: `selectel-ml-tui --version` → 1.0.0
|
||||
- [x] Первый запуск без токена: подсказка + exit code 1
|
||||
- [x] Линт: `ruff check src tests`
|
||||
- [x] Типы: `mypy src` (strict)
|
||||
- [x] Тесты: `pytest --cov=selectel_ml_tui` (порог 80%)
|
||||
- [x] Диагностика: `selectel-ml-tui doctor`
|
||||
|
||||
## Ручные проверки (обе ОС)
|
||||
|
||||
| # | Проверка | Ожидание |
|
||||
|---|---|---|
|
||||
| 1 | `./scripts/install_ubuntu.sh` / `install_macos.sh` | установка без ошибок |
|
||||
| 2 | `selectel-ml-tui config` | мастер сохраняет токен (права 0600) |
|
||||
| 3 | `selectel-ml-tui run` | запуск дашборда, статус `online` |
|
||||
| 4 | Данные: сумма за месяц | значение > 0, формат `1 234,56 ₽` |
|
||||
| 5 | Метрики/модели | таблица «По моделям», Enter → детали |
|
||||
| 6 | Кэш | история (`h`) показывает точки за дни |
|
||||
| 7 | Экспорт | `export --format csv/json` создаёт файл |
|
||||
| 8 | Офлайн (отключить сеть, `r`) | заголовок `offline`, данные из кэша |
|
||||
| 9 | Ресайз окна | интерфейс перерисовывается, нет обрезки |
|
||||
| 10 | Клавиши `r/h/s/q`, `Esc` | навигация работает |
|
||||
| 11 | Русский текст | корректное отображение (UTF-8) |
|
||||
| 12 | `doctor` | все `[OK]`, пути существуют |
|
||||
|
||||
## Отметка выполнения
|
||||
|
||||
- Ubuntu: ____
|
||||
- macOS: ____
|
||||
|
||||
Комментарии/проблемы: ____
|
||||
@@ -0,0 +1,13 @@
|
||||
[mypy]
|
||||
python_version = 3.10
|
||||
strict = true
|
||||
warn_unused_configs = true
|
||||
check_untyped_defs = true
|
||||
disallow_any_generics = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
|
||||
[[mypy.selectel_ml_tui.ui.*]]
|
||||
# Textual widget internals use dynamic attributes; relax strictness there.
|
||||
check_untyped_defs = false
|
||||
disallow_any_generics = false
|
||||
@@ -0,0 +1,89 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "selectel-ml-tui"
|
||||
version = "1.0.3"
|
||||
description = "TUI-приложение для отображения потребления ИИ-роутера Selectel"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Selectel ML Tokens" }]
|
||||
keywords = ["selectel", "ai-router", "tui", "textual", "billing"]
|
||||
classifiers = [
|
||||
"Environment :: Console",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Operating System :: MacOS",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Terminals :: Terminal Emulators/X Terminals",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"textual>=2.0",
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.7",
|
||||
"pydantic-settings>=2.3",
|
||||
"python-dotenv>=1.0",
|
||||
"click>=8.1",
|
||||
"platformdirs>=4.0",
|
||||
"tomli>=2.0",
|
||||
"tomli-w>=1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-cov>=5.0",
|
||||
"ruff>=0.5",
|
||||
"mypy>=1.10",
|
||||
]
|
||||
build = ["pyinstaller>=6.0"]
|
||||
|
||||
[project.scripts]
|
||||
selectel-ml-tui = "selectel_ml_tui.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://selectel.ru"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/selectel_ml_tui"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["selectel_ml_tui"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
fail_under = 80
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"if __name__ == .__main__.:",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 88
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["selectel_ml_tui"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
files = ["src"]
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = true
|
||||
@@ -0,0 +1,13 @@
|
||||
target-version = "py310"
|
||||
|
||||
[lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP"]
|
||||
ignore = []
|
||||
|
||||
[lint.isort]
|
||||
known-first-party = ["selectel_ml_tui"]
|
||||
|
||||
[format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
line-ending = "lf"
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Сборка однофайлового исполняемого файла selectel-ml-tui для macOS.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
make build
|
||||
echo "Готово: dist/selectel-ml-tui (запуск: ./dist/selectel-ml-tui doctor)"
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Сборка однофайлового исполняемого файла selectel-ml-tui для Linux (Ubuntu).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
make build
|
||||
echo "Готово: dist/selectel-ml-tui (запуск: ./dist/selectel-ml-tui doctor)"
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Установка selectel-ml-tui на macOS (Homebrew)
|
||||
set -euo pipefail
|
||||
|
||||
brew install python@3.11
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
make install
|
||||
|
||||
echo "Готово. Запуск: make run или .venv/bin/selectel-ml-tui run"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Установка selectel-ml-tui на Ubuntu (22.04+)
|
||||
set -euo pipefail
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-venv python3-pip
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
make install
|
||||
|
||||
echo "Готово. Запуск: make run или .venv/bin/selectel-ml-tui run"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Запуск selectel-ml-tui с корректным окружением терминала.
|
||||
# Использование: scripts/run.sh [doctor|run|...]
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Подстраховка для минималистичных терминалов (tmux/screen, ssh).
|
||||
if [[ -z "${TERM:-}" ]]; then
|
||||
export TERM=xterm-256color
|
||||
fi
|
||||
if [[ -z "${LANG:-}" ]]; then
|
||||
export LANG=C.UTF-8
|
||||
fi
|
||||
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
exec uv run selectel-ml-tui "$@"
|
||||
elif command -v selectel-ml-tui >/dev/null 2>&1; then
|
||||
exec selectel-ml-tui "$@"
|
||||
else
|
||||
echo "selectel-ml-tui не найден. Установите через 'pip install .' или 'uv sync'" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas = []
|
||||
binaries = []
|
||||
hiddenimports = []
|
||||
tmp_ret = collect_all('textual')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['src/selectel_ml_tui/__main__.py'],
|
||||
pathex=['src'],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='selectel-ml-tui',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""TUI-приложение для отображения потребления ИИ-роутера Selectel."""
|
||||
|
||||
__version__ = "1.0.3"
|
||||
@@ -0,0 +1,4 @@
|
||||
from selectel_ml_tui.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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", [])))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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="Необработанные данные ответа"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""TUI-интерфейс selectel-ml-tui."""
|
||||
|
||||
from selectel_ml_tui.ui.app import SelectelMLTUI, run_app
|
||||
|
||||
__all__ = ["SelectelMLTUI", "run_app"]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Unit-тесты API-клиентов с моками httpx.MockTransport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from selectel_ml_tui.api.billing import (
|
||||
BillingApiError,
|
||||
BillingClient,
|
||||
normalize_dt,
|
||||
)
|
||||
from selectel_ml_tui.api.client import SelectelApiError
|
||||
from selectel_ml_tui.api.gateway import GatewayApiError, GatewayClient
|
||||
|
||||
|
||||
class NoopSleep:
|
||||
"""Пауза-заглушка для ретраев (не ждём в тестах)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[float] = []
|
||||
|
||||
async def __call__(self, seconds: float) -> None:
|
||||
self.calls.append(seconds)
|
||||
|
||||
|
||||
def _transport(handler: Any) -> httpx.MockTransport:
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def _billing_client(
|
||||
handler: Any, max_retries: int = 2, sleep: NoopSleep | None = None
|
||||
) -> BillingClient:
|
||||
return BillingClient(
|
||||
api_base_url="https://api.selectel.ru",
|
||||
static_token="static-token",
|
||||
max_retries=max_retries,
|
||||
transport=_transport(handler),
|
||||
sleep=sleep or NoopSleep(),
|
||||
)
|
||||
|
||||
|
||||
def _ok_consumption() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": [
|
||||
{
|
||||
"account_id": "239633",
|
||||
"period": "2026-08-01",
|
||||
"provider_key": "aig",
|
||||
"metric": {
|
||||
"id": "air_tokens_cost",
|
||||
"name": "Стоимость токенов",
|
||||
"unit": "nanorub",
|
||||
"quantity": "85483000000",
|
||||
},
|
||||
"value": 854.83,
|
||||
"project": "7bf2d2a328494e25b13d05a1926ca75a",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_billing_normalize_dt() -> None:
|
||||
assert normalize_dt("2026-08-01") == "2026-08-01T00:00:00.000"
|
||||
assert normalize_dt("2026-08-01T10:30:00") == "2026-08-01T10:30:00"
|
||||
assert normalize_dt("2026-08-01T00:00:00.000") == "2026-08-01T00:00:00.000"
|
||||
|
||||
|
||||
async def test_billing_gets_normalized_dates() -> None:
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.update(dict(request.url.params))
|
||||
return httpx.Response(200, json=_ok_consumption())
|
||||
|
||||
client = _billing_client(handler)
|
||||
await client.get_consumption("2026-08-01", "2026-08-19")
|
||||
assert seen["start"] == "2026-08-01T00:00:00.000"
|
||||
assert seen["end"] == "2026-08-19T00:00:00.000"
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_billing_get_consumption() -> None:
|
||||
client = _billing_client(
|
||||
lambda request: httpx.Response(200, json=_ok_consumption())
|
||||
)
|
||||
items = await client.get_consumption("2026-08-01", "2026-08-19")
|
||||
assert len(items) == 1
|
||||
assert items[0].value == Decimal("854.83")
|
||||
assert items[0].metric is not None
|
||||
assert items[0].metric.id == "air_tokens_cost"
|
||||
assert items[0].project_id == "7bf2d2a328494e25b13d05a1926ca75a"
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_billing_get_summary_sums_values() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert "provider_keys=aig" in request.url.query.decode()
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"status": "success",
|
||||
"data": [
|
||||
{"value": "100.25"},
|
||||
{"value": 42.5},
|
||||
{"value": 0},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
client = _billing_client(handler)
|
||||
total = await client.get_summary("2026-08-01", "2026-08-19")
|
||||
assert total == Decimal("142.75")
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_billing_5xx_retries_then_success() -> None:
|
||||
sleep = NoopSleep()
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] == 1:
|
||||
return httpx.Response(500, text="internal error")
|
||||
return httpx.Response(200, json=_ok_consumption())
|
||||
|
||||
client = _billing_client(handler, sleep=sleep)
|
||||
items = await client.get_consumption("2026-08-01", "2026-08-19")
|
||||
assert attempts["count"] == 2
|
||||
assert len(items) == 1
|
||||
assert sleep.calls == [0.5]
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_billing_429_retries_then_raises() -> None:
|
||||
sleep = NoopSleep()
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
return httpx.Response(429, text="rate limit")
|
||||
|
||||
client = _billing_client(handler, max_retries=2, sleep=sleep)
|
||||
with pytest.raises(SelectelApiError) as exc_info:
|
||||
await client.get_consumption("2026-08-01", "2026-08-19")
|
||||
assert exc_info.value.status_code == 429
|
||||
assert attempts["count"] == 3
|
||||
assert sleep.calls == [0.5, 1.0]
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_billing_4xx_no_retry() -> None:
|
||||
sleep = NoopSleep()
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
return httpx.Response(400, text="bad request")
|
||||
|
||||
client = _billing_client(handler, sleep=sleep)
|
||||
with pytest.raises(BillingApiError) as exc_info:
|
||||
await client.get_consumption("2026-08-01", "2026-08-19")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "HTTP 400" in str(exc_info.value)
|
||||
assert attempts["count"] == 1
|
||||
assert sleep.calls == []
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_billing_network_error_retries() -> None:
|
||||
sleep = NoopSleep()
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] == 1:
|
||||
raise httpx.ConnectError("connection refused")
|
||||
return httpx.Response(200, json=_ok_consumption())
|
||||
|
||||
client = _billing_client(handler, sleep=sleep)
|
||||
items = await client.get_consumption("2026-08-01", "2026-08-19")
|
||||
assert attempts["count"] == 2
|
||||
assert len(items) == 1
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_billing_timeout_retries_then_raises() -> None:
|
||||
sleep = NoopSleep()
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
raise httpx.ReadTimeout("timed out")
|
||||
|
||||
client = _billing_client(handler, max_retries=2, sleep=sleep)
|
||||
with pytest.raises(SelectelApiError) as exc_info:
|
||||
await client.get_consumption("2026-08-01", "2026-08-19")
|
||||
assert exc_info.value.status_code is None
|
||||
assert "Ошибка сети" in str(exc_info.value)
|
||||
assert attempts["count"] == 3
|
||||
assert sleep.calls == [0.5, 1.0]
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_billing_non_success_status_raises() -> None:
|
||||
client = _billing_client(
|
||||
lambda request: httpx.Response(200, json={"status": "error", "data": []})
|
||||
)
|
||||
with pytest.raises(BillingApiError):
|
||||
await client.get_consumption("2026-08-01", "2026-08-19")
|
||||
await client.aclose()
|
||||
|
||||
|
||||
class FakeTokenProvider:
|
||||
"""Токен-провайдер для GatewayClient в тестах."""
|
||||
|
||||
def __init__(self, tokens: list[str] | None = None) -> None:
|
||||
self._tokens = tokens or ["token-1"]
|
||||
self._index = 0
|
||||
self.calls = 0
|
||||
|
||||
async def get_token(self) -> str:
|
||||
self.calls += 1
|
||||
token = self._tokens[min(self._index, len(self._tokens) - 1)]
|
||||
self._index += 1
|
||||
return token
|
||||
|
||||
def invalidate(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def test_gateway_list_gateways() -> None:
|
||||
provider = FakeTokenProvider()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["X-Auth-Token"] == "token-1"
|
||||
assert request.headers["X-Project-Id"] == "proj-1"
|
||||
assert request.headers["X-Roles"] == "reader"
|
||||
return httpx.Response(200, json={"data": [{"id": "gw-1", "name": "Router"}]})
|
||||
|
||||
client = GatewayClient(
|
||||
api_base_url="https://api.selectel.ru",
|
||||
token_provider=provider,
|
||||
project_id="proj-1",
|
||||
domain_id="239633",
|
||||
transport=_transport(handler),
|
||||
)
|
||||
gateways = await client.list_gateways()
|
||||
assert gateways == [{"id": "gw-1", "name": "Router"}]
|
||||
assert provider.calls == 1
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_gateway_401_refreshes_token() -> None:
|
||||
provider = FakeTokenProvider(tokens=["token-old", "token-new"])
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.headers["X-Auth-Token"] == "token-old":
|
||||
return httpx.Response(401, text="unauthorized")
|
||||
return httpx.Response(200, json={"data": [{"id": "gw-2"}]})
|
||||
|
||||
client = GatewayClient(
|
||||
api_base_url="https://api.selectel.ru",
|
||||
token_provider=provider,
|
||||
project_id="proj-1",
|
||||
domain_id="239633",
|
||||
transport=_transport(handler),
|
||||
)
|
||||
gateways = await client.list_gateways()
|
||||
assert gateways == [{"id": "gw-2"}]
|
||||
assert provider.calls == 2
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_gateway_401_without_refresh_raises() -> None:
|
||||
provider = FakeTokenProvider(tokens=["token-only"])
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, text="unauthorized")
|
||||
|
||||
client = GatewayClient(
|
||||
api_base_url="https://api.selectel.ru",
|
||||
token_provider=provider,
|
||||
project_id="proj-1",
|
||||
domain_id="239633",
|
||||
max_retries=0,
|
||||
transport=_transport(handler),
|
||||
)
|
||||
with pytest.raises(GatewayApiError) as exc_info:
|
||||
await client.list_gateways()
|
||||
assert exc_info.value.status_code == 401
|
||||
await client.aclose()
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Unit-тесты IAM-авторизации и кэша токена."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from selectel_ml_tui.auth import (
|
||||
IamAuthError,
|
||||
IamClient,
|
||||
IamToken,
|
||||
IamTokenManager,
|
||||
)
|
||||
from selectel_ml_tui.config import Credentials, Settings
|
||||
|
||||
|
||||
class FakeIamClient:
|
||||
"""Заглушка IamClient для проверки логики кэширования."""
|
||||
|
||||
def __init__(self, token: IamToken) -> None:
|
||||
self._token = token
|
||||
self.calls = 0
|
||||
|
||||
async def get_token(self) -> IamToken:
|
||||
self.calls += 1
|
||||
return self._token
|
||||
|
||||
|
||||
def _far_future() -> dt.datetime:
|
||||
return dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=23)
|
||||
|
||||
|
||||
def _expired() -> dt.datetime:
|
||||
return dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=1)
|
||||
|
||||
|
||||
async def test_iam_get_token_success() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = request.content.decode()
|
||||
assert '"name":"svc-user"' in body
|
||||
assert '"password":"secret"' in body
|
||||
assert '"id":"proj-1"' in body
|
||||
return httpx.Response(
|
||||
201,
|
||||
headers={"X-Subject-Token": "iam-token-abc"},
|
||||
json={
|
||||
"token": {
|
||||
"expires_at": "2026-08-20T10:00:00.000000Z",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
client = IamClient(
|
||||
Settings(),
|
||||
Credentials(
|
||||
service_user="svc-user",
|
||||
password="secret",
|
||||
domain_id="239633",
|
||||
project_id="proj-1",
|
||||
),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
token = await client.get_token()
|
||||
assert token.value == "iam-token-abc"
|
||||
assert token.expires_at == dt.datetime(2026, 8, 20, 10, 0, tzinfo=dt.timezone.utc)
|
||||
|
||||
|
||||
async def test_iam_get_token_missing_credentials() -> None:
|
||||
client = IamClient(Settings(), Credentials())
|
||||
with pytest.raises(IamAuthError, match="логин/пароль"):
|
||||
await client.get_token()
|
||||
|
||||
|
||||
async def test_iam_get_token_missing_subject_token() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
201, json={"token": {"expires_at": "2026-08-20T10:00:00Z"}}
|
||||
)
|
||||
|
||||
client = IamClient(
|
||||
Settings(),
|
||||
Credentials(
|
||||
service_user="svc", password="pwd", domain_id="239633", project_id="p"
|
||||
),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
with pytest.raises(IamAuthError, match="X-Subject-Token"):
|
||||
await client.get_token()
|
||||
|
||||
|
||||
async def test_iam_token_manager_caches() -> None:
|
||||
fake = FakeIamClient(IamToken(value="t1", expires_at=_far_future()))
|
||||
manager = IamTokenManager(fake) # type: ignore[arg-type]
|
||||
assert await manager.get_token() == "t1"
|
||||
assert await manager.get_token() == "t1"
|
||||
assert fake.calls == 1
|
||||
assert manager.is_valid()
|
||||
|
||||
|
||||
async def test_iam_token_manager_refreshes_after_expiry() -> None:
|
||||
fake = FakeIamClient(IamToken(value="t-expired", expires_at=_expired()))
|
||||
manager = IamTokenManager(fake) # type: ignore[arg-type]
|
||||
assert await manager.get_token() == "t-expired"
|
||||
assert not manager.is_valid()
|
||||
assert await manager.get_token() == "t-expired"
|
||||
assert fake.calls == 2
|
||||
|
||||
|
||||
async def test_iam_token_manager_invalidate() -> None:
|
||||
fake = FakeIamClient(IamToken(value="t1", expires_at=_far_future()))
|
||||
manager = IamTokenManager(fake) # type: ignore[arg-type]
|
||||
await manager.get_token()
|
||||
assert manager.is_valid()
|
||||
manager.invalidate()
|
||||
assert not manager.is_valid()
|
||||
await manager.get_token()
|
||||
assert fake.calls == 2
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Базовые тесты CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from selectel_ml_tui import __version__
|
||||
from selectel_ml_tui.cli import main
|
||||
from selectel_ml_tui.config import load_credentials, load_settings
|
||||
|
||||
|
||||
def test_version() -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert __version__ in result.output
|
||||
|
||||
|
||||
def test_main_module_help() -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "selectel_ml_tui", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "doctor" in result.stdout
|
||||
assert "run" in result.stdout
|
||||
|
||||
|
||||
def test_config_paths(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["config", "--path"])
|
||||
assert result.exit_code == 0
|
||||
assert str(tmp_path) in result.output
|
||||
|
||||
|
||||
def test_config_show_masks_secrets(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["config", "--show"])
|
||||
assert result.exit_code == 0
|
||||
assert "Static token" in result.output
|
||||
assert "секрет" not in result.output.lower()
|
||||
|
||||
|
||||
def test_config_wizard_saves(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path)
|
||||
input_data = "\n".join(
|
||||
[
|
||||
"https://api.example.test", # API base URL
|
||||
"30", # интервал обновления
|
||||
"gw-test", # ID роутера
|
||||
"ru-3", # регион
|
||||
"svc-user", # сервисный пользователь
|
||||
"239633", # домен (аккаунт)
|
||||
"7bf2d2a328494e25b13d05a1926ca75a", # project id
|
||||
"secret-pass", # пароль
|
||||
"secret-token", # статический токен
|
||||
]
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["config"], input=input_data)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Сохранено" in result.output
|
||||
|
||||
settings = load_settings()
|
||||
assert settings.router_id == "gw-test"
|
||||
assert settings.refresh_interval == 30
|
||||
assert settings.api_base_url == "https://api.example.test"
|
||||
assert settings.region == "ru-3"
|
||||
|
||||
creds = load_credentials()
|
||||
assert creds.service_user == "svc-user"
|
||||
assert creds.domain_id == "239633"
|
||||
assert creds.project_id == "7bf2d2a328494e25b13d05a1926ca75a"
|
||||
assert creds.password == "secret-pass"
|
||||
assert creds.static_token == "secret-token"
|
||||
|
||||
|
||||
def test_run_requires_credentials(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["run"])
|
||||
assert result.exit_code == 1
|
||||
assert "не настроены" in result.output
|
||||
|
||||
|
||||
def test_config_wizard_rejects_small_interval(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path)
|
||||
input_data = "\n".join(
|
||||
[
|
||||
"https://api.example.test", # API base URL
|
||||
"5", # интервал < 10 — будет отклонён
|
||||
"30", # повторный ввод, валидный
|
||||
"gw-test", # ID роутера
|
||||
"", # регион (оставить)
|
||||
"", # сервисный пользователь (оставить)
|
||||
"", # домен (оставить)
|
||||
"", # project id (оставить)
|
||||
"", # пароль (оставить)
|
||||
"", # статический токен (оставить)
|
||||
"", # хвостовая строка (CliRunner отбрасывает последний пустой элемент)
|
||||
]
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["config"], input=input_data)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "не может быть меньше 10" in result.output
|
||||
settings = load_settings()
|
||||
assert settings.refresh_interval == 30
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Тесты модуля конфигурации."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from selectel_ml_tui.config import (
|
||||
Credentials,
|
||||
Settings,
|
||||
load_credentials,
|
||||
load_settings,
|
||||
save_credentials,
|
||||
save_settings,
|
||||
)
|
||||
|
||||
|
||||
def test_settings_defaults() -> None:
|
||||
settings = load_settings()
|
||||
assert settings.api_base_url == "https://api.selectel.ru"
|
||||
assert settings.refresh_interval >= 10
|
||||
|
||||
|
||||
def test_save_settings_roundtrip(tmp_path, monkeypatch) -> None:
|
||||
cfg_file = tmp_path / "config.toml"
|
||||
monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file)
|
||||
settings = Settings(router_id="gw-test", refresh_interval=30)
|
||||
save_settings(settings)
|
||||
loaded = load_settings()
|
||||
assert loaded.router_id == "gw-test"
|
||||
assert loaded.refresh_interval == 30
|
||||
|
||||
|
||||
def test_load_settings_repairs_small_interval(tmp_path, monkeypatch) -> None:
|
||||
cfg_file = tmp_path / "config.toml"
|
||||
cfg_file.write_text("refresh_interval = 5\n", encoding="utf-8")
|
||||
monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file)
|
||||
settings = load_settings()
|
||||
assert settings.refresh_interval == 10
|
||||
|
||||
|
||||
def test_load_settings_repairs_garbage_interval(tmp_path, monkeypatch) -> None:
|
||||
cfg_file = tmp_path / "config.toml"
|
||||
cfg_file.write_text('refresh_interval = "abc"\n', encoding="utf-8")
|
||||
monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file)
|
||||
settings = load_settings()
|
||||
assert settings.refresh_interval == 60
|
||||
|
||||
|
||||
def test_load_settings_ignores_bad_env_interval(tmp_path, monkeypatch) -> None:
|
||||
cfg_file = tmp_path / "config.toml"
|
||||
monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file)
|
||||
monkeypatch.setenv("SELCTEL_REFRESH_INTERVAL", "5")
|
||||
settings = load_settings()
|
||||
assert settings.refresh_interval == 60
|
||||
|
||||
|
||||
def test_credentials_defaults() -> None:
|
||||
creds = Credentials()
|
||||
assert creds.service_user == ""
|
||||
assert creds.static_token == ""
|
||||
|
||||
|
||||
def test_settings_env_api_url(monkeypatch, tmp_path) -> None:
|
||||
cfg_file = tmp_path / "config.toml"
|
||||
monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file)
|
||||
monkeypatch.setenv("SELCTEL_API_URL", "https://api.example.test")
|
||||
settings = load_settings()
|
||||
assert settings.api_base_url == "https://api.example.test"
|
||||
|
||||
|
||||
def test_credentials_env(monkeypatch, tmp_path) -> None:
|
||||
cred_file = tmp_path / "credentials.json"
|
||||
monkeypatch.setattr("selectel_ml_tui.config._credentials_file", lambda: cred_file)
|
||||
monkeypatch.setenv("SELCTEL_TOKEN", "tok123")
|
||||
monkeypatch.setenv("SELCTEL_SERVICE_USER", "svc-user")
|
||||
monkeypatch.setenv("SELCTEL_DOMAIN_ID", "239633")
|
||||
creds = load_credentials()
|
||||
assert creds.static_token == "tok123"
|
||||
assert creds.service_user == "svc-user"
|
||||
assert creds.domain_id == "239633"
|
||||
|
||||
|
||||
def test_credentials_file_precedence(monkeypatch, tmp_path) -> None:
|
||||
cred_file = tmp_path / "credentials.json"
|
||||
monkeypatch.setattr("selectel_ml_tui.config._credentials_file", lambda: cred_file)
|
||||
save_credentials(Credentials(static_token="from-file", service_user="file-user"))
|
||||
monkeypatch.setenv("SELCTEL_TOKEN", "from-env")
|
||||
creds = load_credentials()
|
||||
# env имеет приоритет над файлом
|
||||
assert creds.static_token == "from-env"
|
||||
assert creds.service_user == "file-user"
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Unit-тесты pydantic-моделей данных."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from selectel_ml_tui.models import (
|
||||
Consumption,
|
||||
ConsumptionItem,
|
||||
HistoryPoint,
|
||||
Metrics,
|
||||
ModelMetrics,
|
||||
ProjectRef,
|
||||
format_percent,
|
||||
format_rub,
|
||||
format_tokens,
|
||||
)
|
||||
|
||||
|
||||
class TestConsumption:
|
||||
def test_parse_with_z_timestamp(self) -> None:
|
||||
item = Consumption.model_validate(
|
||||
{
|
||||
"period": "2026-08-01T00:00:00.000Z",
|
||||
"amount_rub": "854.83",
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_read_tokens": 10,
|
||||
"cache_write_tokens": 5,
|
||||
}
|
||||
)
|
||||
assert item.period == datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
assert item.amount_rub == Decimal("854.83")
|
||||
assert item.input_tokens == 100
|
||||
|
||||
def test_parse_naive_timestamp(self) -> None:
|
||||
item = Consumption.model_validate({"period": "2026-08-01T00:00:00"})
|
||||
assert item.period == datetime(2026, 8, 1)
|
||||
assert item.amount_rub == Decimal("0")
|
||||
assert item.input_tokens == 0
|
||||
|
||||
def test_negative_amount_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Consumption.model_validate(
|
||||
{"period": "2026-08-01T00:00:00", "amount_rub": "-1"}
|
||||
)
|
||||
|
||||
def test_negative_tokens_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Consumption.model_validate(
|
||||
{
|
||||
"period": "2026-08-01T00:00:00",
|
||||
"input_tokens": -5,
|
||||
}
|
||||
)
|
||||
|
||||
def test_invalid_date_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Consumption.model_validate({"period": "not-a-date"})
|
||||
|
||||
|
||||
class TestMetrics:
|
||||
def test_parse_metrics(self) -> None:
|
||||
metrics = Metrics.model_validate(
|
||||
{
|
||||
"availability_pct": 99.912,
|
||||
"avg_ttft_ms": 0.45,
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 900,
|
||||
"cache_read_tokens": 800,
|
||||
"cache_write_tokens": 700,
|
||||
}
|
||||
)
|
||||
assert metrics.availability_pct == 99.912
|
||||
assert metrics.avg_ttft_ms == 0.45
|
||||
assert metrics.input_tokens == 1000
|
||||
|
||||
def test_missing_fields_defaults(self) -> None:
|
||||
metrics = Metrics.model_validate({})
|
||||
assert metrics.availability_pct == 100.0
|
||||
assert metrics.input_tokens == 0
|
||||
assert metrics.avg_ttft_ms == 0.0
|
||||
|
||||
def test_availability_bounds_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Metrics.model_validate({"availability_pct": 101})
|
||||
with pytest.raises(ValidationError):
|
||||
Metrics.model_validate({"availability_pct": -1})
|
||||
|
||||
|
||||
class TestModelMetrics:
|
||||
def test_parse_model_metrics(self) -> None:
|
||||
model = ModelMetrics.model_validate(
|
||||
{
|
||||
"model_id": "gpt-4o",
|
||||
"model_name": "GPT-4o",
|
||||
"availability_pct": 99.5,
|
||||
"avg_ttft_ms": 0.3,
|
||||
}
|
||||
)
|
||||
assert model.model_id == "gpt-4o"
|
||||
assert model.model_name == "GPT-4o"
|
||||
assert model.availability_pct == 99.5
|
||||
|
||||
|
||||
class TestHistoryPoint:
|
||||
def test_parse(self) -> None:
|
||||
point = HistoryPoint.model_validate(
|
||||
{
|
||||
"timestamp": "2026-08-01T12:00:00Z",
|
||||
"amount_rub": "12.5",
|
||||
"tokens": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 200,
|
||||
"cache_read_tokens": 30,
|
||||
"cache_write_tokens": 40,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert point.timestamp == datetime(2026, 8, 1, 12, 0, tzinfo=timezone.utc)
|
||||
assert point.amount_rub == Decimal("12.5")
|
||||
assert point.tokens.output_tokens == 200
|
||||
|
||||
def test_missing_tokens_defaults(self) -> None:
|
||||
point = HistoryPoint.model_validate({"timestamp": "2026-08-01T00:00:00"})
|
||||
assert point.tokens.input_tokens == 0
|
||||
assert point.amount_rub == Decimal("0")
|
||||
|
||||
|
||||
class TestConsumptionItem:
|
||||
def _base(self, **overrides: object) -> dict[str, object]:
|
||||
data: dict[str, object] = {
|
||||
"account_id": "239633",
|
||||
"period": "2026-08-01T00:00:00",
|
||||
"provider_key": "aig",
|
||||
"value": 854.83,
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
def test_project_as_string(self) -> None:
|
||||
item = ConsumptionItem.model_validate(
|
||||
self._base(project="7bf2d2a328494e25b13d05a1926ca75a")
|
||||
)
|
||||
assert item.project_id == "7bf2d2a328494e25b13d05a1926ca75a"
|
||||
|
||||
def test_project_as_object(self) -> None:
|
||||
item = ConsumptionItem.model_validate(
|
||||
self._base(
|
||||
project={"id": "proj-1", "name": "239633"},
|
||||
)
|
||||
)
|
||||
assert isinstance(item.project, ProjectRef)
|
||||
assert item.project_id == "proj-1"
|
||||
assert item.project.name == "239633"
|
||||
|
||||
def test_project_null(self) -> None:
|
||||
item = ConsumptionItem.model_validate(self._base(project=None))
|
||||
assert item.project_id is None
|
||||
|
||||
def test_object_as_object(self) -> None:
|
||||
item = ConsumptionItem.model_validate(
|
||||
self._base(
|
||||
object={
|
||||
"id": "obj-1",
|
||||
"name": "key-1",
|
||||
"type": "ai_api_key",
|
||||
"type_name": "Api ключ",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert item.object_id == "obj-1"
|
||||
assert item.object.type == "ai_api_key" # type: ignore[union-attr]
|
||||
|
||||
def test_object_as_string(self) -> None:
|
||||
item = ConsumptionItem.model_validate(self._base(object="obj-1"))
|
||||
assert item.object_id == "obj-1"
|
||||
|
||||
def test_metric_info(self) -> None:
|
||||
item = ConsumptionItem.model_validate(
|
||||
self._base(
|
||||
metric={
|
||||
"id": "air_tokens_cost",
|
||||
"name": "Стоимость",
|
||||
"unit": "nanorub",
|
||||
"quantity": "854857248637",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert item.metric is not None
|
||||
assert item.metric.id == "air_tokens_cost"
|
||||
assert item.metric.quantity == Decimal("854857248637")
|
||||
|
||||
def test_value_decimal(self) -> None:
|
||||
item = ConsumptionItem.model_validate(self._base(value=42.5))
|
||||
assert item.value == Decimal("42.5")
|
||||
|
||||
|
||||
class TestFormatting:
|
||||
def test_format_rub(self) -> None:
|
||||
assert format_rub(Decimal("854.83")) == "854,83 ₽"
|
||||
assert format_rub(0) == "0,00 ₽"
|
||||
assert format_rub("1234.5") == "1 234,50 ₽"
|
||||
|
||||
def test_format_tokens(self) -> None:
|
||||
assert format_tokens(0) == "0"
|
||||
assert format_tokens(1234567) == "1 234 567"
|
||||
|
||||
def test_format_percent(self) -> None:
|
||||
assert format_percent(99.912) == "99,9%"
|
||||
assert format_percent(100.0) == "100,0%"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Тесты платформенно-зависимой диагностики (platform.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from selectel_ml_tui import platform as pl
|
||||
|
||||
|
||||
def test_platform_info_fields(monkeypatch) -> None:
|
||||
monkeypatch.setenv("TERM", "xterm-256color")
|
||||
monkeypatch.setenv("LANG", "ru_RU.UTF-8")
|
||||
info = pl.get_platform_info()
|
||||
assert info.system in ("Linux", "Darwin", "Windows")
|
||||
assert info.term == "xterm-256color"
|
||||
assert info.lang == "ru_RU.UTF-8"
|
||||
assert info.utf8 is True
|
||||
assert info.python_version
|
||||
|
||||
|
||||
def test_check_environment_ok(monkeypatch) -> None:
|
||||
monkeypatch.setenv("TERM", "tmux-256color")
|
||||
monkeypatch.setenv("LANG", "en_US.UTF-8")
|
||||
results = {c.name: c for c in pl.check_environment()}
|
||||
assert all(r.ok for r in results.values())
|
||||
assert results["python"].ok
|
||||
|
||||
|
||||
def test_check_environment_warns_without_term_and_lang(monkeypatch) -> None:
|
||||
monkeypatch.delenv("TERM", raising=False)
|
||||
monkeypatch.delenv("LANG", raising=False)
|
||||
results = {c.name: c for c in pl.check_environment()}
|
||||
assert not results["TERM"].ok
|
||||
assert not results["LANG"].ok
|
||||
|
||||
|
||||
def test_check_environment_warns_wrong_term(monkeypatch) -> None:
|
||||
monkeypatch.setenv("TERM", "dumb")
|
||||
monkeypatch.setenv("LANG", "C")
|
||||
results = {c.name: c for c in pl.check_environment()}
|
||||
assert not results["TERM"].ok
|
||||
|
||||
|
||||
def test_min_python_version() -> None:
|
||||
assert pl.MIN_PYTHON == (3, 10)
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Unit-тесты сервисного слоя."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from selectel_ml_tui.config import Credentials, Settings
|
||||
from selectel_ml_tui.models import Consumption, Metrics
|
||||
from selectel_ml_tui.services import (
|
||||
DashboardData,
|
||||
DataService,
|
||||
parse_keys,
|
||||
parse_metrics,
|
||||
parse_models,
|
||||
parse_overview,
|
||||
parse_tokens,
|
||||
)
|
||||
from selectel_ml_tui.storage import Storage
|
||||
|
||||
|
||||
def _summary_response(value: str = "85483") -> dict[str, Any]:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": [{"value": value}],
|
||||
}
|
||||
|
||||
|
||||
async def test_parse_tokens() -> None:
|
||||
tokens = parse_tokens(
|
||||
{"input_tokens": 10, "output_tokens": 20, "cache_read": 30, "cache_write": 40}
|
||||
)
|
||||
assert tokens.input_tokens == 10
|
||||
assert tokens.output_tokens == 20
|
||||
assert tokens.cache_read_tokens == 30
|
||||
assert tokens.cache_write_tokens == 40
|
||||
|
||||
|
||||
async def test_parse_tokens_missing_fields_defaults() -> None:
|
||||
tokens = parse_tokens({})
|
||||
assert tokens.input_tokens == 0
|
||||
assert tokens.output_tokens == 0
|
||||
assert tokens.cache_read_tokens == 0
|
||||
assert tokens.cache_write_tokens == 0
|
||||
|
||||
|
||||
async def test_parse_metrics() -> None:
|
||||
metrics = parse_metrics(
|
||||
{
|
||||
"global": {
|
||||
"requests": 1035,
|
||||
"success_rate_pct": 100,
|
||||
"avg_ttft_seconds": 3.67,
|
||||
"avg_latency_seconds": 11.85,
|
||||
"tokens": {
|
||||
"input": 228542299,
|
||||
"output": 747268,
|
||||
"cached_input": 222355840,
|
||||
"cache_write": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
assert metrics is not None
|
||||
assert metrics.availability_pct == 100
|
||||
assert metrics.avg_ttft_ms == 3670
|
||||
assert metrics.avg_latency_ms == 11850
|
||||
assert metrics.requests == 1035
|
||||
assert metrics.input_tokens == 228542299
|
||||
assert metrics.output_tokens == 747268
|
||||
assert metrics.cache_read_tokens == 222355840
|
||||
assert metrics.cache_write_tokens == 0
|
||||
|
||||
|
||||
async def test_parse_metrics_empty() -> None:
|
||||
assert parse_metrics({}) is None
|
||||
|
||||
|
||||
async def test_parse_models() -> None:
|
||||
models = parse_models(
|
||||
{
|
||||
"by_model": [
|
||||
{
|
||||
"model": "deepseek/deepseek-v4-flash",
|
||||
"requests": 69,
|
||||
"success_rate_pct": 100,
|
||||
"avg_ttft_seconds": 3.35,
|
||||
},
|
||||
{
|
||||
"model": "deepseek/deepseek-v4-pro",
|
||||
"requests": 966,
|
||||
"success_rate_pct": 100,
|
||||
"avg_ttft_seconds": 3.69,
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert len(models) == 2
|
||||
assert models[0].model_name == "deepseek/deepseek-v4-flash"
|
||||
assert models[0].requests == 69
|
||||
assert models[0].availability_pct == 100
|
||||
assert models[0].avg_ttft_ms == 3350
|
||||
assert models[1].model_id == "deepseek/deepseek-v4-pro"
|
||||
|
||||
|
||||
async def test_parse_overview() -> None:
|
||||
import datetime as _dt
|
||||
|
||||
consumption = parse_overview(
|
||||
{
|
||||
"totals": {
|
||||
"requests": 1034,
|
||||
"cost_rub_nano": "1075311673934",
|
||||
"tokens": {
|
||||
"input": 228314832,
|
||||
"output": 746702,
|
||||
"cached_input": 222128640,
|
||||
"cache_write": 0,
|
||||
},
|
||||
"tokens_total": {"total": 451607518},
|
||||
}
|
||||
},
|
||||
_dt.datetime(2026, 8, 19, tzinfo=_dt.timezone.utc),
|
||||
)
|
||||
assert consumption.amount_rub == Decimal("1075.311673934")
|
||||
assert consumption.requests == 1034
|
||||
assert consumption.tokens_total == 451607518
|
||||
assert consumption.input_tokens == 228314832
|
||||
assert consumption.cache_read_tokens == 222128640
|
||||
|
||||
|
||||
async def test_parse_keys() -> None:
|
||||
keys = parse_keys(
|
||||
[
|
||||
{
|
||||
"name": "Mei",
|
||||
"prefix": "sk-sl-v1-9dd61ec",
|
||||
"is_active": True,
|
||||
"budget": {
|
||||
"spend_limit_rub_nano": "1300000000000",
|
||||
"current_usage": {"spend_rub_nano": "1082949415164"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Ollama Mac Air",
|
||||
"prefix": "sk-sl-v1-65fdb2e",
|
||||
"is_active": False,
|
||||
"budget": {"spend_limit_rub_nano": "100000000000"},
|
||||
},
|
||||
]
|
||||
)
|
||||
assert len(keys) == 2
|
||||
assert keys[0].name == "Mei"
|
||||
assert keys[0].is_active is True
|
||||
assert keys[0].spend_limit_rub == Decimal("1300")
|
||||
assert keys[0].spend_rub == Decimal("1082.949415164")
|
||||
assert keys[0].remaining_rub == Decimal("217.050584836")
|
||||
assert keys[1].is_active is False
|
||||
assert keys[1].spend_limit_rub == Decimal("100")
|
||||
|
||||
|
||||
async def test_refresh_billing_only(tmp_path) -> None:
|
||||
"""Только billing (без IAM): стоимость за месяц есть, gateway отмечен."""
|
||||
settings = Settings()
|
||||
creds = Credentials(static_token="static-token")
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
seen_end: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if "summary_stats" in request.url.path:
|
||||
seen_end["summary"] = request.url.params.get("end", "")
|
||||
return httpx.Response(200, json=_summary_response())
|
||||
if "consumption" in request.url.path:
|
||||
seen_end["consumption"] = request.url.params.get("end", "")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"status": "success",
|
||||
"data": [{"period": "2026-08-19T00:00:00", "value": "7133"}],
|
||||
},
|
||||
)
|
||||
return httpx.Response(200, json=_summary_response())
|
||||
|
||||
service = DataService(
|
||||
settings, creds, storage, transport=httpx.MockTransport(handler)
|
||||
)
|
||||
data = await service.refresh()
|
||||
assert data.month_consumption is not None
|
||||
assert data.month_consumption.amount_rub == Decimal("854.83")
|
||||
# Пункт A: end должен содержать время (не усечённую дату) и быть naive
|
||||
# (без смещения часового пояса — иначе API отвечает 422).
|
||||
assert "T" in seen_end["summary"]
|
||||
assert "T" in seen_end["consumption"]
|
||||
assert "+" not in seen_end["summary"]
|
||||
# Пункт B: дневная стоимость сегодня выделена отдельно.
|
||||
assert data.today_amount_rub == Decimal("71.33")
|
||||
assert not data.offline
|
||||
assert data.error is not None
|
||||
assert "сервисный пользователь" in data.error
|
||||
assert storage.get_latest_consumption() is not None
|
||||
history = storage.get_history(days=30)
|
||||
assert len(history) == 1
|
||||
# История хранит дневную стоимость (не месячную сумму).
|
||||
assert history[0].amount_rub == Decimal("71.33")
|
||||
|
||||
|
||||
async def test_refresh_billing_failure_uses_cache(tmp_path) -> None:
|
||||
"""Сбой billing + кэш → офлайн-данные из Storage."""
|
||||
settings = Settings()
|
||||
creds = Credentials(static_token="static-token")
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.save_snapshot(
|
||||
consumption=Consumption(
|
||||
period=dt.datetime(2026, 8, 1, tzinfo=dt.timezone.utc),
|
||||
amount_rub=Decimal("10.00"),
|
||||
)
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500, text="boom")
|
||||
|
||||
service = DataService(
|
||||
settings, creds, storage, transport=httpx.MockTransport(handler)
|
||||
)
|
||||
data = await service.refresh()
|
||||
assert data.offline is True
|
||||
assert data.source == "cache"
|
||||
assert data.month_consumption is not None
|
||||
assert data.month_consumption.amount_rub == Decimal("10.00")
|
||||
assert data.error is not None
|
||||
|
||||
|
||||
async def test_refresh_with_gateway(tmp_path) -> None:
|
||||
"""Billing + gateway (IAM): заполнены метрики, модели, ключи, бюджет."""
|
||||
settings = Settings()
|
||||
creds = Credentials(
|
||||
static_token="static-token",
|
||||
service_user="svc",
|
||||
password="pwd",
|
||||
project_id="proj-1",
|
||||
domain_id="239633",
|
||||
)
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
if path.endswith("/auth/tokens"):
|
||||
return httpx.Response(
|
||||
201,
|
||||
headers={"X-Subject-Token": "iam-token"},
|
||||
json={"token": {"expires_at": "2030-01-01T00:00:00Z"}},
|
||||
)
|
||||
if path.endswith("/gateways"):
|
||||
return httpx.Response(200, json={"data": [{"id": "gw-1"}]})
|
||||
if path.endswith("/overview"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"totals": {
|
||||
"requests": 1034,
|
||||
"cost_rub_nano": "1075311673934",
|
||||
"tokens": {"input": 100, "output": 200},
|
||||
"tokens_total": {"total": 300},
|
||||
},
|
||||
"budget": {
|
||||
"spend_limit_rub_nano": "2000000000000",
|
||||
"spend_rub_nano": "1077874156239",
|
||||
},
|
||||
},
|
||||
)
|
||||
if path.endswith("/metrics"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"global": {"success_rate_pct": 99.5, "avg_ttft_seconds": 0.3},
|
||||
"by_model": [
|
||||
{"model": "deepseek/deepseek-v4-flash", "requests": 69}
|
||||
],
|
||||
},
|
||||
)
|
||||
if path.endswith("/keys"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": [
|
||||
{
|
||||
"name": "Mei",
|
||||
"prefix": "sk-sl-v1-9dd61ec",
|
||||
"is_active": True,
|
||||
"budget": {
|
||||
"spend_limit_rub_nano": "1300000000000",
|
||||
"current_usage": {"spend_rub_nano": "1082949415164"},
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
return httpx.Response(200, json=_summary_response())
|
||||
|
||||
service = DataService(
|
||||
settings, creds, storage, transport=httpx.MockTransport(handler)
|
||||
)
|
||||
data = await service.refresh()
|
||||
assert data.month_consumption is not None
|
||||
assert data.month_consumption.amount_rub == Decimal("1075.311673934")
|
||||
assert data.metrics is not None
|
||||
assert data.metrics.availability_pct == 99.5
|
||||
assert data.metrics.avg_ttft_ms == 300
|
||||
assert len(data.models) == 1
|
||||
assert data.models[0].model_name == "deepseek/deepseek-v4-flash"
|
||||
assert len(data.keys) == 1
|
||||
assert data.keys[0].name == "Mei"
|
||||
assert data.budget is not None
|
||||
assert data.budget.spend_limit_rub == Decimal("2000")
|
||||
|
||||
|
||||
async def test_dashboard_data_has_data() -> None:
|
||||
empty = DashboardData()
|
||||
assert not empty.has_data
|
||||
full = DashboardData(metrics=Metrics())
|
||||
assert full.has_data
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Unit-тесты SQLite-кэша и истории."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import sqlite3
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from selectel_ml_tui.models import (
|
||||
Consumption,
|
||||
HistoryPoint,
|
||||
Metrics,
|
||||
ModelMetrics,
|
||||
TokenUsage,
|
||||
)
|
||||
from selectel_ml_tui.storage import Storage, default_db_path
|
||||
|
||||
|
||||
def _consumption(period: str, amount: str) -> Consumption:
|
||||
return Consumption.model_validate(
|
||||
{
|
||||
"period": period,
|
||||
"amount_rub": amount,
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 200,
|
||||
"cache_read_tokens": 30,
|
||||
"cache_write_tokens": 40,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _metrics() -> Metrics:
|
||||
return Metrics.model_validate(
|
||||
{
|
||||
"availability_pct": 99.9,
|
||||
"avg_ttft_ms": 0.4,
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 900,
|
||||
"cache_read_tokens": 800,
|
||||
"cache_write_tokens": 700,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _models() -> list[ModelMetrics]:
|
||||
return [
|
||||
ModelMetrics.model_validate(
|
||||
{
|
||||
"model_id": "gpt-4o",
|
||||
"model_name": "GPT-4o",
|
||||
"availability_pct": 99.9,
|
||||
"avg_ttft_ms": 0.3,
|
||||
}
|
||||
),
|
||||
ModelMetrics.model_validate({"model_id": "llama-3", "model_name": "Llama 3"}),
|
||||
]
|
||||
|
||||
|
||||
def test_default_db_path(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr("selectel_ml_tui.storage.data_dir", lambda: tmp_path)
|
||||
assert default_db_path() == tmp_path / "cache.db"
|
||||
assert default_db_path().parent == tmp_path
|
||||
|
||||
|
||||
def test_save_and_get_latest_consumption(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
item = _consumption("2026-08-01T00:00:00", "854.83")
|
||||
storage.save_consumption(item)
|
||||
latest = storage.get_latest_consumption()
|
||||
assert latest is not None
|
||||
assert latest.amount_rub == Decimal("854.83")
|
||||
assert latest.period == dt.datetime(2026, 8, 1)
|
||||
assert latest.input_tokens == 100
|
||||
storage.close()
|
||||
|
||||
|
||||
def test_get_latest_empty(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
assert storage.get_latest_consumption() is None
|
||||
assert storage.get_latest_metrics() is None
|
||||
assert storage.get_latest_model_metrics() == []
|
||||
storage.close()
|
||||
|
||||
|
||||
def test_save_and_get_latest_metrics(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.save_metrics(_metrics())
|
||||
latest = storage.get_latest_metrics()
|
||||
assert latest is not None
|
||||
assert latest.availability_pct == 99.9
|
||||
assert latest.input_tokens == 1000
|
||||
storage.close()
|
||||
|
||||
|
||||
def test_save_and_get_latest_model_metrics(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.save_model_metrics(_models())
|
||||
latest = storage.get_latest_model_metrics()
|
||||
assert len(latest) == 2
|
||||
assert {m.model_id for m in latest} == {"gpt-4o", "llama-3"}
|
||||
gpt = next(m for m in latest if m.model_id == "gpt-4o")
|
||||
assert gpt.availability_pct == 99.9
|
||||
storage.close()
|
||||
|
||||
|
||||
def test_history_upsert_same_day(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
point = HistoryPoint(
|
||||
timestamp=dt.datetime(2026, 8, 1, 12, 0),
|
||||
amount_rub=Decimal("10.00"),
|
||||
tokens=TokenUsage(input_tokens=5),
|
||||
)
|
||||
storage.save_history(point)
|
||||
point2 = HistoryPoint(
|
||||
timestamp=dt.datetime(2026, 8, 1, 12, 0),
|
||||
amount_rub=Decimal("15.00"),
|
||||
tokens=TokenUsage(input_tokens=8),
|
||||
)
|
||||
storage.save_history(point2)
|
||||
history = storage.get_history(days=30)
|
||||
assert len(history) == 1 # upsert, без дублей
|
||||
assert history[0].amount_rub == Decimal("15.00")
|
||||
storage.close()
|
||||
|
||||
|
||||
def test_history_days_filter(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
storage.save_history(
|
||||
HistoryPoint(timestamp=now, amount_rub=Decimal("1"), tokens=TokenUsage())
|
||||
)
|
||||
storage.save_history(
|
||||
HistoryPoint(
|
||||
timestamp=now - dt.timedelta(days=30),
|
||||
amount_rub=Decimal("2"),
|
||||
tokens=TokenUsage(),
|
||||
)
|
||||
)
|
||||
assert len(storage.get_history(days=7)) == 1
|
||||
assert len(storage.get_history(days=60)) == 2
|
||||
storage.close()
|
||||
|
||||
|
||||
def test_save_snapshot_combined(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.save_snapshot(
|
||||
consumption=_consumption("2026-08-01T00:00:00", "854.83"),
|
||||
metrics=_metrics(),
|
||||
models=_models(),
|
||||
history_point=HistoryPoint(
|
||||
timestamp=dt.datetime(2026, 8, 1, 12, 0),
|
||||
amount_rub=Decimal("854.83"),
|
||||
tokens=TokenUsage(input_tokens=100),
|
||||
),
|
||||
)
|
||||
assert storage.get_latest_consumption() is not None
|
||||
assert storage.get_latest_metrics() is not None
|
||||
assert len(storage.get_latest_model_metrics()) == 2
|
||||
assert len(storage.get_history(days=30)) == 1
|
||||
storage.close()
|
||||
|
||||
|
||||
def test_cleanup_removes_old(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
old = dt.datetime(2020, 1, 1, 0, 0)
|
||||
storage.save_history(
|
||||
HistoryPoint(timestamp=old, amount_rub=Decimal("1"), tokens=TokenUsage())
|
||||
)
|
||||
storage.save_consumption(_consumption("2020-01-01T00:00:00", "1"))
|
||||
deleted = storage.cleanup(max_age_days=30)
|
||||
# history удаляется по timestamp; consumption_snapshots — по created_at (сейчас)
|
||||
assert deleted == 1
|
||||
assert storage.get_history(days=3650) == []
|
||||
assert storage.get_latest_consumption() is not None
|
||||
storage.close()
|
||||
|
||||
|
||||
def test_export_json(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.save_history(
|
||||
HistoryPoint(
|
||||
timestamp=dt.datetime(2026, 8, 1, 12, 0),
|
||||
amount_rub=Decimal("854.83"),
|
||||
tokens=TokenUsage(input_tokens=100, output_tokens=200),
|
||||
)
|
||||
)
|
||||
out = tmp_path / "history.json"
|
||||
storage.export_json(out)
|
||||
data = json.loads(out.read_text(encoding="utf-8"))
|
||||
assert len(data) == 1
|
||||
assert data[0]["amount_rub"] == "854.83"
|
||||
assert data[0]["input_tokens"] == 100
|
||||
storage.close()
|
||||
|
||||
|
||||
def test_export_csv(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.save_history(
|
||||
HistoryPoint(
|
||||
timestamp=dt.datetime(2026, 8, 1, 12, 0),
|
||||
amount_rub=Decimal("854.83"),
|
||||
tokens=TokenUsage(input_tokens=100, output_tokens=200),
|
||||
)
|
||||
)
|
||||
out = tmp_path / "history.csv"
|
||||
storage.export_csv(out)
|
||||
lines = out.read_text(encoding="utf-8").strip().splitlines()
|
||||
assert lines[0].startswith("timestamp,amount_rub")
|
||||
assert "854.83" in lines[1]
|
||||
assert "100" in lines[1]
|
||||
storage.close()
|
||||
|
||||
|
||||
def test_close_idempotent(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.close()
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
storage.get_history()
|
||||
|
||||
|
||||
def test_clear_removes_all_rows(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.save_history(
|
||||
HistoryPoint(
|
||||
timestamp=dt.datetime(2026, 8, 18),
|
||||
amount_rub=Decimal("1"),
|
||||
tokens=TokenUsage(input_tokens=1),
|
||||
)
|
||||
)
|
||||
storage.save_consumption(Consumption(period="2026-08-01T00:00:00"))
|
||||
assert storage.get_history() != []
|
||||
deleted = storage.clear()
|
||||
assert deleted >= 1
|
||||
assert storage.get_history() == []
|
||||
assert storage.get_latest_consumption() is None
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Headless-тесты TUI (Textual pilot)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
from textual.widgets import DataTable, Input, Static
|
||||
|
||||
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.services import DashboardData
|
||||
from selectel_ml_tui.storage import Storage
|
||||
from selectel_ml_tui.ui.app import SelectelMLTUI
|
||||
|
||||
|
||||
class FakeService:
|
||||
"""Сервис-заглушка: возвращает готовые данные."""
|
||||
|
||||
def __init__(self, data: DashboardData) -> None:
|
||||
self.data = data
|
||||
self.refresh_count = 0
|
||||
|
||||
async def refresh(self) -> DashboardData:
|
||||
self.refresh_count += 1
|
||||
return self.data
|
||||
|
||||
|
||||
def _dashboard_data() -> DashboardData:
|
||||
return DashboardData(
|
||||
month_consumption=Consumption.model_validate(
|
||||
{
|
||||
"period": "2026-08-01T00:00:00",
|
||||
"amount_rub": "854.83",
|
||||
"requests": 1034,
|
||||
"tokens_total": 451607518,
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 200,
|
||||
"cache_read_tokens": 30,
|
||||
"cache_write_tokens": 40,
|
||||
}
|
||||
),
|
||||
metrics=Metrics.model_validate(
|
||||
{
|
||||
"availability_pct": 99.9,
|
||||
"avg_ttft_ms": 0.4,
|
||||
"avg_latency_ms": 1.2,
|
||||
"requests": 1035,
|
||||
"input_tokens": 5,
|
||||
}
|
||||
),
|
||||
models=[
|
||||
ModelMetrics.model_validate(
|
||||
{
|
||||
"model_id": "gpt-4o",
|
||||
"model_name": "GPT-4o",
|
||||
"requests": 69,
|
||||
"availability_pct": 100.0,
|
||||
"avg_ttft_ms": 3.35,
|
||||
}
|
||||
)
|
||||
],
|
||||
keys=[
|
||||
ApiKeyUsage(
|
||||
name="Mei",
|
||||
prefix="sk-sl-v1-9dd61ec",
|
||||
is_active=True,
|
||||
spend_limit_rub=Decimal("1300"),
|
||||
spend_rub=Decimal("1082.95"),
|
||||
remaining_rub=Decimal("217.05"),
|
||||
)
|
||||
],
|
||||
budget=BudgetInfo(
|
||||
spend_limit_rub=Decimal("2000"),
|
||||
spend_rub=Decimal("1077.87"),
|
||||
remaining_rub=Decimal("922.13"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_app(tmp_path, data: DashboardData | None = None) -> SelectelMLTUI:
|
||||
settings = Settings(refresh_interval=3600)
|
||||
creds = Credentials(static_token="tok")
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
service = FakeService(data or _dashboard_data())
|
||||
return SelectelMLTUI(
|
||||
settings=settings,
|
||||
credentials=creds,
|
||||
storage=storage,
|
||||
service=service,
|
||||
)
|
||||
|
||||
|
||||
async def test_dashboard_renders_data(tmp_path) -> None:
|
||||
app = _make_app(tmp_path)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
month = app.screen.query_one("#month-panel", Static)
|
||||
rendered = str(month.render())
|
||||
assert "854,83" in rendered
|
||||
assert "Потребление за месяц" in rendered
|
||||
metrics = app.screen.query_one("#metrics-panel", Static)
|
||||
assert "99,9%" in str(metrics.render())
|
||||
table = app.screen.query_one("#models-table", DataTable)
|
||||
assert table.row_count == 1
|
||||
assert "GPT-4o" in table.get_row_at(0)
|
||||
|
||||
|
||||
async def test_refresh_hotkey_reruns_service(tmp_path) -> None:
|
||||
app = _make_app(tmp_path)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
service = app._service
|
||||
assert service.refresh_count == 1
|
||||
await pilot.press("r")
|
||||
await pilot.pause()
|
||||
assert service.refresh_count == 2
|
||||
|
||||
|
||||
async def test_navigate_to_history_and_settings(tmp_path) -> None:
|
||||
app = _make_app(tmp_path)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
# history
|
||||
await pilot.press("h")
|
||||
await pilot.pause()
|
||||
assert app.screen.__class__.__name__ == "HistoryScreen"
|
||||
# назад
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.screen.__class__.__name__ == "DashboardScreen"
|
||||
# settings
|
||||
await pilot.press("s")
|
||||
await pilot.pause()
|
||||
assert app.screen.__class__.__name__ == "SettingsScreen"
|
||||
info = app.screen.query_one("#settings-info", Static).render()
|
||||
assert "API base URL" in str(info)
|
||||
assert "tok" in str(info)
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.screen.__class__.__name__ == "DashboardScreen"
|
||||
|
||||
|
||||
async def test_history_screen_shows_points(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.save_history(
|
||||
HistoryPoint(
|
||||
timestamp=dt.datetime(2026, 8, 18, 0, 0),
|
||||
amount_rub=Decimal("854.83"),
|
||||
tokens=TokenUsage(input_tokens=100),
|
||||
)
|
||||
)
|
||||
app = _make_app(tmp_path)
|
||||
app._storage = storage
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("h")
|
||||
await pilot.pause()
|
||||
table = app.screen.query_one("#history-table", DataTable)
|
||||
assert table.row_count == 1
|
||||
cells = [str(cell) for cell in table.get_row_at(0)]
|
||||
assert any("854,83" in cell for cell in cells)
|
||||
bars = app.screen.query_one("#history-bars")
|
||||
assert bars.render() != "Нет данных для графика"
|
||||
|
||||
|
||||
async def test_history_period_switch(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
for day in range(20):
|
||||
storage.save_history(
|
||||
HistoryPoint(
|
||||
timestamp=dt.datetime(2026, 8, day + 1, 0, 0),
|
||||
amount_rub=Decimal(str(day + 1)),
|
||||
tokens=TokenUsage(input_tokens=day + 1),
|
||||
)
|
||||
)
|
||||
app = _make_app(tmp_path)
|
||||
app._storage = storage
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("h")
|
||||
await pilot.pause()
|
||||
title = app.screen.query_one("#history-title", Static)
|
||||
assert "30 дней" in str(title.render())
|
||||
table = app.screen.query_one("#history-table", DataTable)
|
||||
rows_30 = table.row_count
|
||||
await pilot.click("#period-7")
|
||||
await pilot.pause()
|
||||
title = app.screen.query_one("#history-title", Static)
|
||||
assert "7 дней" in str(title.render())
|
||||
table = app.screen.query_one("#history-table", DataTable)
|
||||
assert table.row_count < rows_30
|
||||
await pilot.click("#period-90")
|
||||
await pilot.pause()
|
||||
assert "90 дней" in str(app.screen.query_one("#history-title", Static).render())
|
||||
assert app.screen.query_one("#history-table", DataTable).row_count >= rows_30
|
||||
|
||||
|
||||
async def test_model_detail_on_enter(tmp_path) -> None:
|
||||
app = _make_app(tmp_path)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert app.screen.__class__.__name__ == "ModelDetailScreen"
|
||||
detail = app.screen.query_one("#model-detail", Static)
|
||||
rendered = str(detail.render())
|
||||
assert "GPT-4o" in rendered
|
||||
assert "Запросы" in rendered
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.screen.__class__.__name__ == "DashboardScreen"
|
||||
|
||||
|
||||
async def test_settings_save_interval(tmp_path, monkeypatch) -> None:
|
||||
saved = {}
|
||||
|
||||
def fake_save(settings) -> None:
|
||||
saved["interval"] = settings.refresh_interval
|
||||
|
||||
monkeypatch.setattr("selectel_ml_tui.ui.app.save_settings", fake_save)
|
||||
app = _make_app(tmp_path)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("s")
|
||||
await pilot.pause()
|
||||
inp = app.screen.query_one("#interval-input", Input)
|
||||
inp.value = "120"
|
||||
await pilot.click("#save-settings")
|
||||
await pilot.pause()
|
||||
assert app._settings.refresh_interval == 120
|
||||
assert saved["interval"] == 120
|
||||
|
||||
|
||||
async def test_settings_clear_cache(tmp_path) -> None:
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.save_history(
|
||||
HistoryPoint(
|
||||
timestamp=dt.datetime(2026, 8, 18, 0, 0),
|
||||
amount_rub=Decimal("854.83"),
|
||||
tokens=TokenUsage(input_tokens=100),
|
||||
)
|
||||
)
|
||||
app = _make_app(tmp_path)
|
||||
app._storage = storage
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("s")
|
||||
await pilot.pause()
|
||||
await pilot.click("#clear-cache")
|
||||
await pilot.pause()
|
||||
assert storage.get_history(days=30) == []
|
||||
|
||||
|
||||
async def test_offline_status_in_subtitle(tmp_path) -> None:
|
||||
data = _dashboard_data()
|
||||
data.offline = True
|
||||
app = _make_app(tmp_path, data)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert "offline" in app.sub_title
|
||||
|
||||
|
||||
async def test_window_resize(tmp_path) -> None:
|
||||
app = _make_app(tmp_path)
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
assert app.screen.__class__.__name__ == "DashboardScreen"
|
||||
await pilot.resize_terminal(80, 24)
|
||||
await pilot.pause()
|
||||
assert app.screen.__class__.__name__ == "DashboardScreen"
|
||||
month = app.screen.query_one("#month-panel", Static)
|
||||
assert "854,83" in str(month.render())
|
||||
|
||||
|
||||
async def test_keys_screen(tmp_path) -> None:
|
||||
app = _make_app(tmp_path)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("k")
|
||||
await pilot.pause()
|
||||
assert app.screen.__class__.__name__ == "KeysScreen"
|
||||
table = app.screen.query_one("#keys-table", DataTable)
|
||||
assert table.row_count == 1
|
||||
cells = [str(cell) for cell in table.get_row_at(0)]
|
||||
assert any("Mei" in cell for cell in cells)
|
||||
assert any("1 300" in cell for cell in cells)
|
||||
budget = app.screen.query_one("#keys-budget", Static)
|
||||
assert "Бюджет роутера" in str(budget.render())
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert app.screen.__class__.__name__ == "DashboardScreen"
|
||||
Reference in New Issue
Block a user