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

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