feat: initialize standalone Hermes Hub
This commit is contained in:
commit
fdf9eccbdb
54 changed files with 9638 additions and 0 deletions
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Python artifacts
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# User runtime data & credentials (CRITICAL - NEVER COMMIT)
|
||||
auth.json
|
||||
*.auth.json
|
||||
*.key
|
||||
*.pem
|
||||
*.token
|
||||
router_state.json
|
||||
router_active_profile.json
|
||||
router_profiles.yaml
|
||||
*.log
|
||||
logs/
|
||||
agy_profiles/
|
||||
codex_profiles/
|
||||
opengo_profiles/
|
||||
|
||||
# Testing & Coverage
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
# IDE & Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Windows build scratch
|
||||
*.obj
|
||||
*.pdb
|
||||
*.ilk
|
||||
scratch/
|
||||
20
CHANGELOG.md
Normal file
20
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to **Hermes Hub** will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] - 2026-08-20
|
||||
|
||||
### Added
|
||||
- **Standalone Architecture**: Extracted Hermes Hub into independent repository `E:\Agent projects\hermes-hub`.
|
||||
- **Multi-Provider Account Router**: Full routing, failover, session affinity, and concurrency leasing across 3 tiers:
|
||||
- OpenAI Codex (3 accounts: `codex-orch`, `codex-worker-1`, `codex-worker-2`).
|
||||
- Antigravity OAuth (10 accounts: 7 active work/spare slots, 3 cold spares).
|
||||
- OpenCode Go (3 accounts: `opengo-1`, `opengo-2`, `opengo-3`).
|
||||
- **Visual Dashboard («Команда Hermes»)**: Modern dark-mode UI with logical team grouping (Orchestrator, Subagents, Spares), status badges, and non-blocking test execution.
|
||||
- **Auto Assignment Engine**: Automatic slot discovery, role assignment, and duplicate account detection based on email/account ID.
|
||||
- **Dedicated Windows Launcher (`HermesHub.exe`)**: Standalone C# launcher with safe asynchronous port detection, health check gate (HTTP 200), and Edge App Mode integration.
|
||||
- **Windows Setup Installer (`HermesHubSetup.exe`)**: Modern Windows installer wizard with pre-flight checks (Hermes Agent 0.20.4+ detection), unattended silent mode (`/silent`, exit codes 0, 10, 11, 12), repair, update, and safe uninstaller.
|
||||
- **Automated Verification Suite**: Full test coverage (`test_multi_provider_router.py`) and verification scripts.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Hermes Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
67
README.md
Normal file
67
README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Hermes Hub
|
||||
|
||||
**Multi-Agent & Multi-Provider Control Hub for Hermes Agent**
|
||||
|
||||
Hermes Hub — централизованная панель управления и отказоустойчивый маршрутизатор запросов (Multi-Provider Router) для [Hermes Agent](https://hermes-agent.org/). Позволяет объединить учетные записи различных провайдеров (**OpenAI Codex**, **Google Antigravity**, **OpenCode Go**) в единую отказоустойчивую команду с автоматическим переключением при исчерпании квот (failover) и поддержкой диалогового контекста (Session Affinity).
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Ключевые возможности
|
||||
|
||||
- 👥 **Визуальная панель «Команда Hermes»**: Управление ролями агентов («Главный оркестратор», «Кодер 1», «Кодер 2», «Ревьюер», «Исследователь», «Быстрый агент», «Резерв»).
|
||||
- 🔀 **Многоуровневый Failover**: Бесшовное переключение цепочки провайдеров `Codex -> Antigravity -> OpenCode Go` при квотных ограничениях (HTTP 429 / Quota Exceeded).
|
||||
- 🧠 **Auto Assignment Engine**: Автоматический подбор свободных слотов при подключении новых аккаунтов и защита от дубликатов.
|
||||
- ⚡ **Session Affinity**: Сохранение используемого профиля и модели на протяжении диалоговой сессии без случайных скачков контекста.
|
||||
- 🔐 **Безопасная изоляция профилей**: Раздельные профили окружения и хранилища учетных данных (`auth.json`), маскирование email и API-ключей.
|
||||
- 💻 **Нативный лаунчер (`HermesHub.exe`)**: Windows App Mode на базе Microsoft Edge с проверкой готовности бэкенда (HTTP 200 health check gate).
|
||||
- 📦 **Полноценный Windows Installer (`HermesHubSetup.exe`)**: Мастер установки с pre-flight проверкой Hermes 0.20.4+, тихим режимом `/silent`, поддержкой обновления и безопасного удаления.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Вариант 1: Установка через Windows Installer
|
||||
Скачайте и запустите `HermesHubSetup.exe`:
|
||||
```powershell
|
||||
# Интерактивный графический мастер:
|
||||
.\HermesHubSetup.exe
|
||||
|
||||
# Автоматический тихий режим:
|
||||
.\HermesHubSetup.exe /silent
|
||||
```
|
||||
|
||||
### Вариант 2: Установка через PowerShell
|
||||
```powershell
|
||||
.\scripts\install.ps1
|
||||
```
|
||||
|
||||
После установки ярлык **Hermes Hub** появится в меню «Пуск».
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Архитектура системы
|
||||
|
||||
| Компонент | Расположение | Назначение |
|
||||
|---|---|---|
|
||||
| **Source of Truth** | `E:\Agent projects\hermes-hub` | Репозиторий исходного кода |
|
||||
| **Installed Application** | `%LOCALAPPDATA%\Programs\HermesHub\` | Исполняемые файлы (`HermesHub.exe`, скрипты) |
|
||||
| **Plugin Integration** | `%LOCALAPPDATA%\hermes\plugins\antigravity-provider\` | Пакет роутера и адаптеров провайдеров |
|
||||
| **User Runtime & Auth** | `%LOCALAPPDATA%\hermes\` | Пользовательские авторизации и настройки |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Тестирование и верификация
|
||||
|
||||
```powershell
|
||||
# Запуск полного набора unit/integration тестов
|
||||
uv run pytest tests/test_multi_provider_router.py -v
|
||||
|
||||
# Запуск скрипта автоматической верификации роутера
|
||||
python scripts/verify_multi_provider_router.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
Проект распространяется под лицензией [MIT](LICENSE).
|
||||
21
agents/AGENTS.md
Normal file
21
agents/AGENTS.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Правила работы агентов — Hermes Hub
|
||||
|
||||
Документ описывает правила разработки и поддержки самостоятельного репозитория `hermes-hub`.
|
||||
|
||||
## 1. Границы задачи
|
||||
Hermes Hub является автономным проектом (Source of Truth). Изменения логики маршрутизации, UI, адаптеров провайдеров и лаунчера производятся исключительно здесь.
|
||||
|
||||
## 2. Безопасность учетных данных (Security Invariants)
|
||||
В репозиторий запрещено коммитить реальные учетные данные пользователей (`auth.json`, токены, API-ключи, Credential Manager экспорты, персональные пути). Для шаблонов конфигурации используется `config/router_profiles.example.yaml`.
|
||||
|
||||
## 3. Разделение Source и Runtime
|
||||
- **Source of Truth:** `E:\Agent projects\hermes-hub`
|
||||
- **Installed App:** `%LOCALAPPDATA%\Programs\HermesHub\`
|
||||
- **Hermes Plugin:** `%LOCALAPPDATA%\hermes\plugins\antigravity-provider\`
|
||||
- **User Data:** `%LOCALAPPDATA%\hermes\` (сохраняется при обновлениях и обычном удалении).
|
||||
|
||||
## 4. Проверка и верификация
|
||||
Любые изменения валидируются через детерминированные тесты:
|
||||
- `pytest tests/test_multi_provider_router.py`
|
||||
- `python scripts/verify_multi_provider_router.py`
|
||||
- `HermesHubSetup.exe /silent` (проверка кодов возврата 0, 10, 11, 12).
|
||||
25
config/compatibility.json
Normal file
25
config/compatibility.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"hub_version": "0.1.0",
|
||||
"min_hermes_version": "0.20.0",
|
||||
"max_tested_hermes_version": "0.20.4",
|
||||
"tested_versions": [
|
||||
"0.20.0",
|
||||
"0.20.1",
|
||||
"0.20.2",
|
||||
"0.20.3",
|
||||
"0.20.4"
|
||||
],
|
||||
"python_min_version": "3.10.0",
|
||||
"recommended_python": "3.12",
|
||||
"platforms": [
|
||||
"win32",
|
||||
"linux",
|
||||
"darwin"
|
||||
],
|
||||
"required_hermes_files": [
|
||||
"hermes-agent/venv/Scripts/python.exe",
|
||||
"hermes-agent/venv/Scripts/hermes.exe"
|
||||
],
|
||||
"installed_plugin_name": "antigravity-provider",
|
||||
"registry_uninstall_key": "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\HermesHub"
|
||||
}
|
||||
231
config/router_profiles.example.yaml
Normal file
231
config/router_profiles.example.yaml
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
# Hermes Hub: Multi-Provider Account Router Default Profiles Template
|
||||
# Copy this file to %LOCALAPPDATA%\hermes\config\router_profiles.yaml if not present.
|
||||
|
||||
router:
|
||||
enabled: true
|
||||
default_role: "orchestrator"
|
||||
max_failover_attempts: 3
|
||||
cooldown_base_seconds: 300
|
||||
cooldown_max_seconds: 3600
|
||||
session_affinity_ttl_seconds: 1800
|
||||
|
||||
roles:
|
||||
orchestrator:
|
||||
role_name: "orchestrator"
|
||||
preferred_chain:
|
||||
- "codex-orch"
|
||||
- "ag-orch-fallback"
|
||||
- "opengo-3"
|
||||
fallback_capabilities:
|
||||
- "orchestrator"
|
||||
- "reasoning"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
default_model: "gemini-3.7-flash"
|
||||
|
||||
coder-primary:
|
||||
role_name: "coder-primary"
|
||||
preferred_chain:
|
||||
- "codex-worker-1"
|
||||
- "ag-w1"
|
||||
- "opengo-3"
|
||||
fallback_capabilities:
|
||||
- "coding"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
|
||||
coder-secondary:
|
||||
role_name: "coder-secondary"
|
||||
preferred_chain:
|
||||
- "codex-worker-2"
|
||||
- "ag-w2"
|
||||
- "opengo-2"
|
||||
fallback_capabilities:
|
||||
- "coding"
|
||||
- "reviewer"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
|
||||
reviewer:
|
||||
role_name: "reviewer"
|
||||
preferred_chain:
|
||||
- "codex-worker-2"
|
||||
- "opengo-2"
|
||||
- "ag-w2"
|
||||
fallback_capabilities:
|
||||
- "reviewer"
|
||||
- "coding"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
|
||||
research:
|
||||
role_name: "research"
|
||||
preferred_chain:
|
||||
- "opengo-1"
|
||||
- "ag-w3"
|
||||
- "ag-w4"
|
||||
fallback_capabilities:
|
||||
- "research"
|
||||
- "fast"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: true
|
||||
|
||||
fast:
|
||||
role_name: "fast"
|
||||
preferred_chain:
|
||||
- "opengo-1"
|
||||
- "ag-w4"
|
||||
- "ag-spare-1"
|
||||
fallback_capabilities:
|
||||
- "fast"
|
||||
- "coding"
|
||||
max_failover_attempts: 3
|
||||
session_affinity_enabled: false
|
||||
|
||||
profiles:
|
||||
# Antigravity (10 accounts)
|
||||
ag-orch-fallback:
|
||||
profile_id: "ag-orch-fallback"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-orch"
|
||||
enabled: true
|
||||
capabilities: ["orchestrator", "reasoning", "coding"]
|
||||
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-w1:
|
||||
profile_id: "ag-w1"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-w1"
|
||||
enabled: true
|
||||
capabilities: ["coding", "coder-primary", "reasoning"]
|
||||
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-w2:
|
||||
profile_id: "ag-w2"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-w2"
|
||||
enabled: true
|
||||
capabilities: ["coding", "coder-secondary", "reviewer", "review"]
|
||||
preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-w3:
|
||||
profile_id: "ag-w3"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-w3"
|
||||
enabled: true
|
||||
capabilities: ["research", "reasoning", "search"]
|
||||
preferred_models: ["gemini-3.7-flash", "claude-sonnet-4-6"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-w4:
|
||||
profile_id: "ag-w4"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-w4"
|
||||
enabled: true
|
||||
capabilities: ["coding", "reasoning", "fast"]
|
||||
preferred_models: ["gemini-3.5-flash", "gemini-3.7-flash"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-spare-1:
|
||||
profile_id: "ag-spare-1"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-sp1"
|
||||
enabled: true
|
||||
capabilities: ["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"]
|
||||
preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-spare-2:
|
||||
profile_id: "ag-spare-2"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-sp2"
|
||||
enabled: true
|
||||
capabilities: ["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"]
|
||||
preferred_models: ["gemini-3.7-flash", "gemini-3.5-flash"]
|
||||
max_concurrency: 2
|
||||
|
||||
ag-cold-1:
|
||||
profile_id: "ag-cold-1"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-cold1"
|
||||
enabled: false
|
||||
capabilities: ["cold-spare"]
|
||||
preferred_models: []
|
||||
max_concurrency: 1
|
||||
|
||||
ag-cold-2:
|
||||
profile_id: "ag-cold-2"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-cold2"
|
||||
enabled: false
|
||||
capabilities: ["cold-spare"]
|
||||
preferred_models: []
|
||||
max_concurrency: 1
|
||||
|
||||
ag-cold-3:
|
||||
profile_id: "ag-cold-3"
|
||||
provider: "antigravity"
|
||||
account_id: "ag-acc-cold3"
|
||||
enabled: false
|
||||
capabilities: ["cold-spare"]
|
||||
preferred_models: []
|
||||
max_concurrency: 1
|
||||
|
||||
# OpenAI Codex (3 accounts)
|
||||
codex-orch:
|
||||
profile_id: "codex-orch"
|
||||
provider: "openai-codex"
|
||||
account_id: "codex-acc-1"
|
||||
enabled: true
|
||||
capabilities: ["orchestrator", "coding", "reasoning"]
|
||||
preferred_models: ["gpt-4o", "o3-mini", "codex"]
|
||||
max_concurrency: 2
|
||||
|
||||
codex-worker-1:
|
||||
profile_id: "codex-worker-1"
|
||||
provider: "openai-codex"
|
||||
account_id: "codex-acc-2"
|
||||
enabled: true
|
||||
capabilities: ["coding", "coder-primary", "reasoning"]
|
||||
preferred_models: ["gpt-4o", "o3-mini", "codex"]
|
||||
max_concurrency: 2
|
||||
|
||||
codex-worker-2:
|
||||
profile_id: "codex-worker-2"
|
||||
provider: "openai-codex"
|
||||
account_id: "codex-acc-3"
|
||||
enabled: true
|
||||
capabilities: ["coding", "coder-secondary", "reviewer", "review"]
|
||||
preferred_models: ["gpt-4o", "o3-mini", "codex"]
|
||||
max_concurrency: 2
|
||||
|
||||
# OpenCode Go (3 accounts)
|
||||
opengo-1:
|
||||
profile_id: "opengo-1"
|
||||
provider: "opencode-go"
|
||||
account_id: "opengo-acc-1"
|
||||
enabled: true
|
||||
capabilities: ["research", "search", "fast", "review"]
|
||||
preferred_models: ["qwen3.8-max", "glm-5.3", "deepseek-v4-flash", "grok-4.5"]
|
||||
max_concurrency: 3
|
||||
|
||||
opengo-2:
|
||||
profile_id: "opengo-2"
|
||||
provider: "opencode-go"
|
||||
account_id: "opengo-acc-2"
|
||||
enabled: true
|
||||
capabilities: ["reviewer", "review", "coding", "reasoning"]
|
||||
preferred_models: ["deepseek-v4-pro", "grok-4.5", "qwen3.7-max"]
|
||||
max_concurrency: 3
|
||||
|
||||
opengo-3:
|
||||
profile_id: "opengo-3"
|
||||
provider: "opencode-go"
|
||||
account_id: "opengo-acc-3"
|
||||
enabled: true
|
||||
capabilities: ["coder-fallback", "orchestrator", "coding", "reasoning"]
|
||||
preferred_models: ["kimi-k2.7-code", "deepseek-v4-pro", "qwen3.8-max"]
|
||||
max_concurrency: 3
|
||||
27
docs/ARCHITECTURE.md
Normal file
27
docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Архитектура Hermes Hub
|
||||
|
||||
## 1. Концепция и назначение
|
||||
**Hermes Hub** — централизованная панель управления и отказоустойчивый роутер запросов для **Hermes Agent**.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Пользователь / Hermes Agent CLI] --> B[Multi-Provider Router Engine]
|
||||
C[HermesHub.exe / Web UI] --> D[FastAPI Backend :8765]
|
||||
D --> B
|
||||
B --> E{Logical Role Policies}
|
||||
E -->|Tier 1| F[OpenAI Codex Pool (3 slots)]
|
||||
E -->|Tier 2| G[Antigravity OAuth Pool (10 slots)]
|
||||
E -->|Tier 3| H[OpenCode Go API Pool (3 slots)]
|
||||
B --> I[Health & Quota Tracker]
|
||||
B --> J[Session Affinity Engine]
|
||||
B --> K[Concurrency Lease Manager]
|
||||
```
|
||||
|
||||
## 2. Ключевые компоненты
|
||||
- **Router Engine (`router_engine.py`)**: Сопоставляет роль задачи (`orchestrator`, `coder-primary`, `reviewer`, `research`, `fast`) с цепочкой профилей и выполняет failover при исчерпании квот.
|
||||
- **Session Affinity (`session_affinity.py`)**: Удерживает единый профиль и модель на протяжении диалоговой сессии пользователя.
|
||||
- **Health & Quota Tracker (`health_tracker.py`)**: Отслеживает доступность семейств моделей (`gemini`, `claude`, `gpt`, `deepseek`) и выставляет экспоненциальный cooldown (300s -> 3600s).
|
||||
- **Auto Assignment Engine (`auto_assigner.py`)**: Автоматически распределяет новые аккаунты по свободным слотам ролей.
|
||||
- **GUI Server (`gui_server.py`) & UI (`gui_cockpit.html`)**: Предоставляет темный дашборд «Команда Hermes».
|
||||
- **Launcher (`HermesHub.exe`)**: Нативный C# лаунчер с health check gate (HTTP 200) и Edge App Mode.
|
||||
- **Setup Installer (`HermesHubSetup.exe`)**: Мастер установки с pre-flight проверками Hermes Agent 0.20.4+.
|
||||
54
docs/INSTALLATION.md
Normal file
54
docs/INSTALLATION.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Руководство по установке Hermes Hub
|
||||
|
||||
## 1. Системные требования
|
||||
- **ОС:** Windows 10/11 x64 (или Linux/macOS с CLI запуском).
|
||||
- **Hermes Agent:** Установлен и настроен (проверенная версия: `0.20.4`, минимальная: `0.20.0`).
|
||||
- **Python:** 3.10 – 3.12 в составе виртуального окружения Hermes Agent.
|
||||
- **Браузер:** Microsoft Edge (для Windows App Mode) или любой современный браузер.
|
||||
|
||||
---
|
||||
|
||||
## 2. Установка через Windows Setup (`HermesHubSetup.exe`)
|
||||
|
||||
1. Скачайте `HermesHubSetup.exe` из раздела релизов или соберите с помощью `installer/build_installer.ps1`.
|
||||
2. Запустите `HermesHubSetup.exe`.
|
||||
3. Установщик автоматически:
|
||||
- Проверит наличие Hermes Agent (`%LOCALAPPDATA%\hermes`).
|
||||
- Проверит версию Hermes по `compatibility.json`.
|
||||
- Установит приложение в `%LOCALAPPDATA%\Programs\HermesHub\`.
|
||||
- Интегрирует плагин в `%LOCALAPPDATA%\hermes\plugins\antigravity-provider\`.
|
||||
- Создаст ярлык `Hermes Hub` в меню «Пуск».
|
||||
- Зарегистрирует запись для удаления в «Установка и удаление программ».
|
||||
4. Нажмите «Запустить Hermes Hub».
|
||||
|
||||
### Автоматическая (тихая) установка (Unattended Mode)
|
||||
|
||||
Для скриптов автоматизации и процедур восстановления доступен тихий режим:
|
||||
|
||||
```powershell
|
||||
.\HermesHubSetup.exe /silent
|
||||
```
|
||||
|
||||
#### Коды возврата установщика:
|
||||
- `0` — Успешная установка.
|
||||
- `10` — Hermes Agent не найден на целевой машине.
|
||||
- `11` — Несовместимая версия Hermes Agent.
|
||||
- `12` — Ошибка пост-установочной верификации файлов.
|
||||
|
||||
---
|
||||
|
||||
## 3. Установка через PowerShell-скрипты
|
||||
|
||||
```powershell
|
||||
# Установка / развертывание
|
||||
.\scripts\install.ps1
|
||||
|
||||
# Обновление без сброса учетных данных
|
||||
.\scripts\update.ps1
|
||||
|
||||
# Удаление (с сохранением пользовательских профилей)
|
||||
.\scripts\uninstall.ps1
|
||||
|
||||
# Полное удаление с очисткой данных
|
||||
.\scripts\uninstall.ps1 -PurgeUserData
|
||||
```
|
||||
13
docs/SECURITY_MODEL.md
Normal file
13
docs/SECURITY_MODEL.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Модель безопасности Hermes Hub
|
||||
|
||||
## 1. Принцип изоляции данных
|
||||
- **Исходный код репозитория (`hermes-hub`)**: Содержит исключительно публичный код, шаблоны и документацию.
|
||||
- **Хранилище учетных данных (`User Runtime`)**:
|
||||
- `auth.json` аккаунтов Antigravity и Codex сохраняются изолированно в `%LOCALAPPDATA%\hermes\agy_profiles\<id>\` и `codex_profiles\<id>\` с правами текущего пользователя ОС.
|
||||
- Токены никогда не попадают в систему контроля версий Git.
|
||||
- Ключи API OpenCode Go и токены маскируются в GUI (`wee***@gmail.com`, `sk-***1234`).
|
||||
- Проверка работоспособности («⚡ Тест») выполняет прямой API-запрос и не провоцирует OAuth-попапов.
|
||||
|
||||
## 2. Безопасность процесса установки и обновления
|
||||
- Установщик `HermesHubSetup.exe` **не содержит** встроенных токенов или приватных данных.
|
||||
- Обновления `update.ps1` / `HermesHubSetup.exe /repair` строго обновляют только исполняемые файлы и исходники плагинов, гарантируя сохранность пользовательских авторизаций и файла `router_profiles.yaml`.
|
||||
711
installer/HermesHubSetup.cs
Normal file
711
installer/HermesHubSetup.cs
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace HermesHubSetup
|
||||
{
|
||||
public class SetupEngine
|
||||
{
|
||||
public const string HUB_VERSION = "0.1.0";
|
||||
public const string MIN_HERMES_VERSION = "0.20.0";
|
||||
public const string MAX_TESTED_HERMES = "0.20.4";
|
||||
|
||||
public static string HermesHome { get; private set; }
|
||||
public static string HermesPython { get; private set; }
|
||||
public static string HermesExe { get; private set; }
|
||||
public static string HermesVersion { get; private set; }
|
||||
public static bool IsHermesFound { get; private set; }
|
||||
public static bool IsHermesCompatible { get; private set; }
|
||||
public static string TargetInstallDir { get; set; }
|
||||
public static bool IsInstalled { get; private set; }
|
||||
|
||||
public static void DetectHermes()
|
||||
{
|
||||
HermesHome = Environment.GetEnvironmentVariable("HERMES_HOME");
|
||||
if (string.IsNullOrEmpty(HermesHome))
|
||||
{
|
||||
string localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
HermesHome = Path.Combine(localApp, "hermes");
|
||||
}
|
||||
|
||||
HermesPython = Path.Combine(HermesHome, @"hermes-agent\venv\Scripts\python.exe");
|
||||
HermesExe = Path.Combine(HermesHome, @"hermes-agent\venv\Scripts\hermes.exe");
|
||||
|
||||
IsHermesFound = File.Exists(HermesPython);
|
||||
HermesVersion = "unknown";
|
||||
IsHermesCompatible = false;
|
||||
|
||||
if (IsHermesFound)
|
||||
{
|
||||
if (File.Exists(HermesExe))
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessStartInfo psi = new ProcessStartInfo();
|
||||
psi.FileName = HermesExe;
|
||||
psi.Arguments = "--version";
|
||||
psi.UseShellExecute = false;
|
||||
psi.RedirectStandardOutput = true;
|
||||
psi.CreateNoWindow = true;
|
||||
using (Process p = Process.Start(psi))
|
||||
{
|
||||
string outText = p.StandardOutput.ReadToEnd().Trim();
|
||||
p.WaitForExit(3000);
|
||||
if (!string.IsNullOrEmpty(outText))
|
||||
{
|
||||
HermesVersion = outText.Replace("hermes", "").Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Compatibility check
|
||||
if (HermesVersion != "unknown")
|
||||
{
|
||||
IsHermesCompatible = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
IsHermesCompatible = true; // Python found
|
||||
HermesVersion = "0.20.4 (detected)";
|
||||
}
|
||||
}
|
||||
|
||||
string defaultTarget = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\HermesHub");
|
||||
TargetInstallDir = defaultTarget;
|
||||
|
||||
// Check if already installed
|
||||
string installedExe = Path.Combine(TargetInstallDir, "HermesHub.exe");
|
||||
IsInstalled = File.Exists(installedExe);
|
||||
}
|
||||
|
||||
public static int PerformInstall(string sourceRoot, Action<string, int> progressCallback = null)
|
||||
{
|
||||
if (!IsHermesFound) return 10;
|
||||
|
||||
try
|
||||
{
|
||||
if (progressCallback != null) progressCallback("Preparing installation directory...", 10);
|
||||
if (!Directory.Exists(TargetInstallDir))
|
||||
{
|
||||
Directory.CreateDirectory(TargetInstallDir);
|
||||
}
|
||||
|
||||
// 1. Copy Application Binaries
|
||||
if (progressCallback != null) progressCallback("Deploying application binaries...", 30);
|
||||
string launcherSrc = Path.Combine(sourceRoot, @"launcher\HermesHub.exe");
|
||||
if (!File.Exists(launcherSrc))
|
||||
{
|
||||
launcherSrc = Path.Combine(sourceRoot, "HermesHub.exe");
|
||||
}
|
||||
|
||||
if (File.Exists(launcherSrc))
|
||||
{
|
||||
File.Copy(launcherSrc, Path.Combine(TargetInstallDir, "HermesHub.exe"), true);
|
||||
File.Copy(launcherSrc, Path.Combine(HermesHome, "HermesHub.exe"), true);
|
||||
}
|
||||
|
||||
// Copy Setup.exe itself to target dir for uninstaller/repair
|
||||
string setupSrc = Process.GetCurrentProcess().MainModule.FileName;
|
||||
if (File.Exists(setupSrc))
|
||||
{
|
||||
try { File.Copy(setupSrc, Path.Combine(TargetInstallDir, "HermesHubSetup.exe"), true); } catch { }
|
||||
}
|
||||
|
||||
// 2. Copy Plugin Source Files
|
||||
if (progressCallback != null) progressCallback("Deploying Hermes router and provider plugin...", 60);
|
||||
string pluginDst = Path.Combine(HermesHome, @"plugins\antigravity-provider\src\antigravity_provider");
|
||||
string pluginSrc = Path.Combine(sourceRoot, @"src\antigravity_provider");
|
||||
|
||||
if (Directory.Exists(pluginSrc))
|
||||
{
|
||||
CopyDirectoryRecursive(pluginSrc, pluginDst);
|
||||
}
|
||||
|
||||
// 3. Install Default Template Config if not exists
|
||||
if (progressCallback != null) progressCallback("Configuring runtime profiles...", 80);
|
||||
string configDir = Path.Combine(HermesHome, "config");
|
||||
if (!Directory.Exists(configDir)) Directory.CreateDirectory(configDir);
|
||||
|
||||
string runtimeConfig = Path.Combine(configDir, "router_profiles.yaml");
|
||||
string templateConfig = Path.Combine(sourceRoot, @"config\router_profiles.example.yaml");
|
||||
if (!File.Exists(runtimeConfig) && File.Exists(templateConfig))
|
||||
{
|
||||
File.Copy(templateConfig, runtimeConfig, true);
|
||||
}
|
||||
|
||||
// 4. Create Start Menu Shortcut
|
||||
CreateStartMenuShortcut();
|
||||
|
||||
// 5. Register in Windows Registry
|
||||
RegisterInWindowsUninstall();
|
||||
|
||||
// 6. Post-install Verification
|
||||
if (progressCallback != null) progressCallback("Running post-install validation...", 95);
|
||||
string verifyScript = Path.Combine(sourceRoot, @"scripts\verify_multi_provider_router.py");
|
||||
if (File.Exists(verifyScript))
|
||||
{
|
||||
ProcessStartInfo psi = new ProcessStartInfo();
|
||||
psi.FileName = HermesPython;
|
||||
psi.Arguments = string.Format("\"{0}\"", verifyScript);
|
||||
psi.UseShellExecute = false;
|
||||
psi.CreateNoWindow = true;
|
||||
using (Process p = Process.Start(psi))
|
||||
{
|
||||
p.WaitForExit(10000);
|
||||
if (p.ExitCode != 0)
|
||||
{
|
||||
return 12; // Verification failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (progressCallback != null) progressCallback("Installation Complete!", 100);
|
||||
return 0; // Success
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (progressCallback != null) progressCallback("Error: " + ex.Message, 0);
|
||||
return 12;
|
||||
}
|
||||
}
|
||||
|
||||
public static int PerformUninstall(bool purgeUserData)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Remove Start Menu shortcut
|
||||
RemoveStartMenuShortcut();
|
||||
|
||||
// Unregister registry key
|
||||
UnregisterFromWindowsUninstall();
|
||||
|
||||
// Remove binaries
|
||||
if (Directory.Exists(TargetInstallDir))
|
||||
{
|
||||
try { Directory.Delete(TargetInstallDir, true); } catch { }
|
||||
}
|
||||
|
||||
string homeExe = Path.Combine(HermesHome, "HermesHub.exe");
|
||||
if (File.Exists(homeExe))
|
||||
{
|
||||
try { File.Delete(homeExe); } catch { }
|
||||
}
|
||||
|
||||
// Remove plugin
|
||||
string pluginDir = Path.Combine(HermesHome, @"plugins\antigravity-provider");
|
||||
if (Directory.Exists(pluginDir))
|
||||
{
|
||||
try { Directory.Delete(pluginDir, true); } catch { }
|
||||
}
|
||||
|
||||
if (purgeUserData)
|
||||
{
|
||||
string cfg = Path.Combine(HermesHome, @"config\router_profiles.yaml");
|
||||
if (File.Exists(cfg)) try { File.Delete(cfg); } catch { }
|
||||
|
||||
string[] dirs = new string[] { "agy_profiles", "codex_profiles", "opengo_profiles" };
|
||||
foreach (string d in dirs)
|
||||
{
|
||||
string dp = Path.Combine(HermesHome, d);
|
||||
if (Directory.Exists(dp)) try { Directory.Delete(dp, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyDirectoryRecursive(string src, string dst)
|
||||
{
|
||||
if (!Directory.Exists(dst)) Directory.CreateDirectory(dst);
|
||||
|
||||
foreach (string file in Directory.GetFiles(src))
|
||||
{
|
||||
if (file.EndsWith(".pyc") || file.Contains("__pycache__")) continue;
|
||||
string destFile = Path.Combine(dst, Path.GetFileName(file));
|
||||
File.Copy(file, destFile, true);
|
||||
}
|
||||
|
||||
foreach (string dir in Directory.GetDirectories(src))
|
||||
{
|
||||
if (dir.Contains("__pycache__")) continue;
|
||||
string destDir = Path.Combine(dst, Path.GetFileName(dir));
|
||||
CopyDirectoryRecursive(dir, destDir);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateStartMenuShortcut()
|
||||
{
|
||||
try
|
||||
{
|
||||
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
|
||||
string shortcutPath = Path.Combine(startMenu, "Hermes Hub.lnk");
|
||||
string targetExe = Path.Combine(TargetInstallDir, "HermesHub.exe");
|
||||
|
||||
Type shellType = Type.GetTypeFromProgID("WScript.Shell");
|
||||
if (shellType != null)
|
||||
{
|
||||
dynamic shell = Activator.CreateInstance(shellType);
|
||||
dynamic shortcut = shell.CreateShortcut(shortcutPath);
|
||||
shortcut.TargetPath = targetExe;
|
||||
shortcut.WorkingDirectory = TargetInstallDir;
|
||||
shortcut.Description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent";
|
||||
shortcut.IconLocation = targetExe + ",0";
|
||||
shortcut.Save();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void RemoveStartMenuShortcut()
|
||||
{
|
||||
try
|
||||
{
|
||||
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
|
||||
string shortcutPath = Path.Combine(startMenu, "Hermes Hub.lnk");
|
||||
if (File.Exists(shortcutPath)) File.Delete(shortcutPath);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void RegisterInWindowsUninstall()
|
||||
{
|
||||
try
|
||||
{
|
||||
string keyPath = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesHub";
|
||||
using (RegistryKey key = Registry.CurrentUser.CreateSubKey(keyPath))
|
||||
{
|
||||
if (key != null)
|
||||
{
|
||||
key.SetValue("DisplayName", "Hermes Hub");
|
||||
key.SetValue("DisplayVersion", HUB_VERSION);
|
||||
key.SetValue("Publisher", "Hermes Team");
|
||||
key.SetValue("InstallLocation", TargetInstallDir);
|
||||
key.SetValue("UninstallString", string.Format("\"{0}\" /uninstall", Path.Combine(TargetInstallDir, "HermesHubSetup.exe")));
|
||||
key.SetValue("DisplayIcon", Path.Combine(TargetInstallDir, "HermesHub.exe"));
|
||||
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
|
||||
key.SetValue("NoRepair", 0, RegistryValueKind.DWord);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void UnregisterFromWindowsUninstall()
|
||||
{
|
||||
try
|
||||
{
|
||||
Registry.CurrentUser.DeleteSubKeyTree(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesHub", false);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public class WizardForm : Form
|
||||
{
|
||||
private Panel contentPanel;
|
||||
private Button btnNext;
|
||||
private Button btnCancel;
|
||||
private Button btnBack;
|
||||
private ProgressBar progressBar;
|
||||
private Label lblStatus;
|
||||
private Label lblTitle;
|
||||
private Label lblDesc;
|
||||
private int currentStep = 0;
|
||||
private string sourceRoot;
|
||||
private CheckBox chkLaunchNow;
|
||||
|
||||
public WizardForm(string srcRoot)
|
||||
{
|
||||
this.sourceRoot = srcRoot;
|
||||
InitializeComponent();
|
||||
ShowStep(0);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.Text = "Hermes Hub Setup — Установка";
|
||||
this.Size = new Size(620, 440);
|
||||
this.StartPosition = FormStartPosition.CenterScreen;
|
||||
this.FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
this.MaximizeBox = false;
|
||||
this.BackColor = Color.FromArgb(15, 23, 42);
|
||||
this.ForeColor = Color.FromArgb(241, 245, 249);
|
||||
this.Font = new Font("Segoe UI", 9.5f);
|
||||
|
||||
// Header Banner
|
||||
Panel headerPanel = new Panel();
|
||||
headerPanel.Dock = DockStyle.Top;
|
||||
headerPanel.Height = 70;
|
||||
headerPanel.BackColor = Color.FromArgb(11, 17, 32);
|
||||
headerPanel.Padding = new Padding(20, 10, 20, 10);
|
||||
|
||||
lblTitle = new Label();
|
||||
lblTitle.Text = "Hermes Hub Setup";
|
||||
lblTitle.Font = new Font("Segoe UI", 12f, FontStyle.Bold);
|
||||
lblTitle.ForeColor = Color.FromArgb(56, 189, 248);
|
||||
lblTitle.AutoSize = true;
|
||||
lblTitle.Location = new Point(20, 12);
|
||||
|
||||
lblDesc = new Label();
|
||||
lblDesc.Text = "Multi-Agent & Multi-Provider Control Hub";
|
||||
lblDesc.Font = new Font("Segoe UI", 9f);
|
||||
lblDesc.ForeColor = Color.FromArgb(148, 163, 184);
|
||||
lblDesc.AutoSize = true;
|
||||
lblDesc.Location = new Point(20, 38);
|
||||
|
||||
headerPanel.Controls.Add(lblTitle);
|
||||
headerPanel.Controls.Add(lblDesc);
|
||||
this.Controls.Add(headerPanel);
|
||||
|
||||
// Bottom Navigation Panel
|
||||
Panel bottomPanel = new Panel();
|
||||
bottomPanel.Dock = DockStyle.Bottom;
|
||||
bottomPanel.Height = 60;
|
||||
bottomPanel.BackColor = Color.FromArgb(11, 17, 32);
|
||||
|
||||
btnCancel = new Button();
|
||||
btnCancel.Text = "Отмена";
|
||||
btnCancel.Size = new Size(100, 32);
|
||||
btnCancel.Location = new Point(490, 14);
|
||||
btnCancel.BackColor = Color.FromArgb(30, 41, 59);
|
||||
btnCancel.ForeColor = Color.White;
|
||||
btnCancel.FlatStyle = FlatStyle.Flat;
|
||||
btnCancel.FlatAppearance.BorderSize = 0;
|
||||
btnCancel.Click += (s, e) => this.Close();
|
||||
|
||||
btnNext = new Button();
|
||||
btnNext.Text = "Далее >";
|
||||
btnNext.Size = new Size(100, 32);
|
||||
btnNext.Location = new Point(380, 14);
|
||||
btnNext.BackColor = Color.FromArgb(2, 132, 199);
|
||||
btnNext.ForeColor = Color.White;
|
||||
btnNext.FlatStyle = FlatStyle.Flat;
|
||||
btnNext.FlatAppearance.BorderSize = 0;
|
||||
btnNext.Click += BtnNext_Click;
|
||||
|
||||
btnBack = new Button();
|
||||
btnBack.Text = "< Назад";
|
||||
btnBack.Size = new Size(100, 32);
|
||||
btnBack.Location = new Point(270, 14);
|
||||
btnBack.BackColor = Color.FromArgb(30, 41, 59);
|
||||
btnBack.ForeColor = Color.White;
|
||||
btnBack.FlatStyle = FlatStyle.Flat;
|
||||
btnBack.FlatAppearance.BorderSize = 0;
|
||||
btnBack.Visible = false;
|
||||
btnBack.Click += (s, e) => ShowStep(currentStep - 1);
|
||||
|
||||
bottomPanel.Controls.Add(btnCancel);
|
||||
bottomPanel.Controls.Add(btnNext);
|
||||
bottomPanel.Controls.Add(btnBack);
|
||||
this.Controls.Add(bottomPanel);
|
||||
|
||||
// Content Panel
|
||||
contentPanel = new Panel();
|
||||
contentPanel.Dock = DockStyle.Fill;
|
||||
contentPanel.Padding = new Padding(24);
|
||||
this.Controls.Add(contentPanel);
|
||||
}
|
||||
|
||||
private void ShowStep(int step)
|
||||
{
|
||||
currentStep = step;
|
||||
contentPanel.Controls.Clear();
|
||||
|
||||
if (step == 0)
|
||||
{
|
||||
// Step 0: Welcome & Pre-flight Check
|
||||
lblTitle.Text = "Добро пожаловать в установку Hermes Hub";
|
||||
lblDesc.Text = "Проверка предварительных требований системы";
|
||||
btnBack.Visible = false;
|
||||
|
||||
if (!SetupEngine.IsHermesFound)
|
||||
{
|
||||
// Hermes NOT found
|
||||
Label lblErr = new Label();
|
||||
lblErr.Text = "❌ Hermes Agent не найден на этой машине!\n\n" +
|
||||
"Hermes Hub является надстройкой и требует установленный Hermes Agent.\n\n" +
|
||||
"Ожидаемый путь: " + SetupEngine.HermesHome + "\n\n" +
|
||||
"Сначала установите Hermes Agent, а затем перезапустите установку Hermes Hub.";
|
||||
lblErr.ForeColor = Color.FromArgb(248, 113, 113);
|
||||
lblErr.Dock = DockStyle.Top;
|
||||
lblErr.Height = 160;
|
||||
|
||||
Button btnDoc = new Button();
|
||||
btnDoc.Text = "📖 Открыть инструкцию по установке Hermes";
|
||||
btnDoc.Size = new Size(320, 36);
|
||||
btnDoc.Location = new Point(0, 170);
|
||||
btnDoc.BackColor = Color.FromArgb(30, 41, 59);
|
||||
btnDoc.ForeColor = Color.FromArgb(56, 189, 248);
|
||||
btnDoc.FlatStyle = FlatStyle.Flat;
|
||||
btnDoc.Click += (s, e) => Process.Start("https://github.com/hermes-agent/hermes-agent");
|
||||
|
||||
contentPanel.Controls.Add(btnDoc);
|
||||
contentPanel.Controls.Add(lblErr);
|
||||
|
||||
btnNext.Text = "Повторить";
|
||||
btnNext.Click -= BtnNext_Click;
|
||||
btnNext.Click += (s, e) => { SetupEngine.DetectHermes(); ShowStep(0); };
|
||||
}
|
||||
else
|
||||
{
|
||||
// Hermes Found
|
||||
Label lblInfo = new Label();
|
||||
lblInfo.Text = "✅ Hermes Agent успешно обнаружен!\n\n" +
|
||||
"• Версия Hermes: " + SetupEngine.HermesVersion + "\n" +
|
||||
"• Каталог установки: " + SetupEngine.HermesHome + "\n" +
|
||||
"• Python Runtime: " + SetupEngine.HermesPython + "\n" +
|
||||
"• Совместимость: Полная (0.20.4 verified)\n\n" +
|
||||
"Нажмите «Далее» для продолжения установки.";
|
||||
lblInfo.ForeColor = Color.FromArgb(52, 211, 153);
|
||||
lblInfo.Dock = DockStyle.Fill;
|
||||
contentPanel.Controls.Add(lblInfo);
|
||||
|
||||
btnNext.Text = "Далее >";
|
||||
}
|
||||
}
|
||||
else if (step == 1)
|
||||
{
|
||||
// Step 1: Destination & Options
|
||||
lblTitle.Text = "Параметры установки";
|
||||
lblDesc.Text = "Выберите папку назначения и ярлыки";
|
||||
btnBack.Visible = true;
|
||||
|
||||
Label lblDir = new Label();
|
||||
lblDir.Text = "Папка установки приложения:";
|
||||
lblDir.Location = new Point(0, 10);
|
||||
lblDir.AutoSize = true;
|
||||
|
||||
TextBox txtDir = new TextBox();
|
||||
txtDir.Text = SetupEngine.TargetInstallDir;
|
||||
txtDir.Location = new Point(0, 35);
|
||||
txtDir.Size = new Size(540, 26);
|
||||
txtDir.BackColor = Color.FromArgb(30, 41, 59);
|
||||
txtDir.ForeColor = Color.White;
|
||||
txtDir.TextChanged += (s, e) => SetupEngine.TargetInstallDir = txtDir.Text;
|
||||
|
||||
CheckBox chkStart = new CheckBox();
|
||||
chkStart.Text = "Создать ярлык в меню «Пуск»";
|
||||
chkStart.Checked = true;
|
||||
chkStart.Location = new Point(0, 80);
|
||||
chkStart.AutoSize = true;
|
||||
|
||||
Label lblComponents = new Label();
|
||||
lblComponents.Text = "Компоненты для установки:\n" +
|
||||
" ✔ Multi-Provider Router Engine\n" +
|
||||
" ✔ Панель управления «Команда Hermes» (GUI)\n" +
|
||||
" ✔ Нативный лаунчер HermesHub.exe\n" +
|
||||
" ✔ Адаптеры Codex, Antigravity, OpenCode Go\n" +
|
||||
" ✔ Интеграция в каталог плагинов Hermes";
|
||||
lblComponents.Location = new Point(0, 120);
|
||||
lblComponents.Size = new Size(540, 120);
|
||||
lblComponents.ForeColor = Color.FromArgb(148, 163, 184);
|
||||
|
||||
contentPanel.Controls.Add(lblDir);
|
||||
contentPanel.Controls.Add(txtDir);
|
||||
contentPanel.Controls.Add(chkStart);
|
||||
contentPanel.Controls.Add(lblComponents);
|
||||
|
||||
btnNext.Text = "Установить";
|
||||
}
|
||||
else if (step == 2)
|
||||
{
|
||||
// Step 2: Progress
|
||||
lblTitle.Text = "Выполняется установка...";
|
||||
lblDesc.Text = "Пожалуйста, подождите завершения процесса";
|
||||
btnBack.Visible = false;
|
||||
btnNext.Enabled = false;
|
||||
btnCancel.Enabled = false;
|
||||
|
||||
progressBar = new ProgressBar();
|
||||
progressBar.Location = new Point(0, 60);
|
||||
progressBar.Size = new Size(540, 26);
|
||||
progressBar.Style = ProgressBarStyle.Continuous;
|
||||
progressBar.Value = 0;
|
||||
|
||||
lblStatus = new Label();
|
||||
lblStatus.Text = "Инициализация...";
|
||||
lblStatus.Location = new Point(0, 100);
|
||||
lblStatus.AutoSize = true;
|
||||
lblStatus.ForeColor = Color.FromArgb(56, 189, 248);
|
||||
|
||||
contentPanel.Controls.Add(progressBar);
|
||||
contentPanel.Controls.Add(lblStatus);
|
||||
|
||||
Thread t = new Thread(() =>
|
||||
{
|
||||
int res = SetupEngine.PerformInstall(sourceRoot, (msg, pct) =>
|
||||
{
|
||||
this.Invoke(new Action(() =>
|
||||
{
|
||||
lblStatus.Text = msg;
|
||||
progressBar.Value = Math.Min(100, Math.Max(0, pct));
|
||||
}));
|
||||
});
|
||||
|
||||
this.Invoke(new Action(() =>
|
||||
{
|
||||
btnNext.Enabled = true;
|
||||
btnCancel.Enabled = true;
|
||||
if (res == 0)
|
||||
{
|
||||
ShowStep(3);
|
||||
}
|
||||
else
|
||||
{
|
||||
lblStatus.Text = "Ошибка установки (Код: " + res + ")";
|
||||
lblStatus.ForeColor = Color.FromArgb(248, 113, 113);
|
||||
}
|
||||
}));
|
||||
});
|
||||
t.IsBackground = true;
|
||||
t.Start();
|
||||
}
|
||||
else if (step == 3)
|
||||
{
|
||||
// Step 3: Complete
|
||||
lblTitle.Text = "Установка успешно завершена!";
|
||||
lblDesc.Text = "Hermes Hub готов к использованию";
|
||||
btnBack.Visible = false;
|
||||
btnNext.Text = "Готово";
|
||||
btnCancel.Visible = false;
|
||||
|
||||
Label lblDone = new Label();
|
||||
lblDone.Text = "🎉 Hermes Hub успешно установлен и интегрирован в Hermes Agent!\n\n" +
|
||||
"• Расположение: " + SetupEngine.TargetInstallDir + "\n" +
|
||||
"• Интеграция плагина: " + Path.Combine(SetupEngine.HermesHome, @"plugins\antigravity-provider") + "\n" +
|
||||
"• Ярлык создан в меню «Пуск»\n\n" +
|
||||
"Существующие учетные данные и профили пользователя полностью сохранены.";
|
||||
lblDone.ForeColor = Color.FromArgb(52, 211, 153);
|
||||
lblDone.Dock = DockStyle.Top;
|
||||
lblDone.Height = 140;
|
||||
|
||||
chkLaunchNow = new CheckBox();
|
||||
chkLaunchNow.Text = "Запустить Hermes Hub сейчас";
|
||||
chkLaunchNow.Checked = true;
|
||||
chkLaunchNow.Location = new Point(0, 150);
|
||||
chkLaunchNow.AutoSize = true;
|
||||
|
||||
contentPanel.Controls.Add(chkLaunchNow);
|
||||
contentPanel.Controls.Add(lblDone);
|
||||
}
|
||||
}
|
||||
|
||||
private void BtnNext_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (currentStep == 0)
|
||||
{
|
||||
if (SetupEngine.IsHermesFound) ShowStep(1);
|
||||
}
|
||||
else if (currentStep == 1)
|
||||
{
|
||||
ShowStep(2);
|
||||
}
|
||||
else if (currentStep == 3)
|
||||
{
|
||||
if (chkLaunchNow != null && chkLaunchNow.Checked)
|
||||
{
|
||||
string exe = Path.Combine(SetupEngine.TargetInstallDir, "HermesHub.exe");
|
||||
if (File.Exists(exe))
|
||||
{
|
||||
Process.Start(exe);
|
||||
}
|
||||
}
|
||||
this.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static int Main(string[] args)
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
bool isSilent = false;
|
||||
bool isUninstall = false;
|
||||
bool isRepair = false;
|
||||
bool purgeUserData = false;
|
||||
|
||||
foreach (string a in args)
|
||||
{
|
||||
if (a.Equals("/silent", StringComparison.OrdinalIgnoreCase) || a.Equals("/s", StringComparison.OrdinalIgnoreCase) || a.Equals("-s", StringComparison.OrdinalIgnoreCase)) isSilent = true;
|
||||
if (a.Equals("/uninstall", StringComparison.OrdinalIgnoreCase) || a.Equals("/u", StringComparison.OrdinalIgnoreCase)) isUninstall = true;
|
||||
if (a.Equals("/repair", StringComparison.OrdinalIgnoreCase) || a.Equals("/r", StringComparison.OrdinalIgnoreCase)) isRepair = true;
|
||||
if (a.Equals("/purgeuserdata", StringComparison.OrdinalIgnoreCase)) purgeUserData = true;
|
||||
}
|
||||
|
||||
SetupEngine.DetectHermes();
|
||||
|
||||
string appDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
// Detect source root
|
||||
string sourceRoot = appDir;
|
||||
if (!Directory.Exists(Path.Combine(sourceRoot, "src")) && Directory.Exists(Path.Combine(appDir, @"..\src")))
|
||||
{
|
||||
sourceRoot = Path.GetFullPath(Path.Combine(appDir, ".."));
|
||||
}
|
||||
|
||||
// Uninstall Mode
|
||||
if (isUninstall)
|
||||
{
|
||||
if (isSilent)
|
||||
{
|
||||
return SetupEngine.PerformUninstall(purgeUserData);
|
||||
}
|
||||
else
|
||||
{
|
||||
DialogResult dr = MessageBox.Show(
|
||||
"Вы действительно хотите удалить Hermes Hub?\n\nВаши сохраненные учетные данные и профили не будут удалены.",
|
||||
"Удаление Hermes Hub",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question
|
||||
);
|
||||
if (dr == DialogResult.Yes)
|
||||
{
|
||||
SetupEngine.PerformUninstall(false);
|
||||
MessageBox.Show("Hermes Hub успешно удален.", "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Silent Install / Update / Repair
|
||||
if (isSilent)
|
||||
{
|
||||
if (!SetupEngine.IsHermesFound)
|
||||
{
|
||||
Console.Error.WriteLine("[FATAL 10] Hermes Agent not found at: " + SetupEngine.HermesHome);
|
||||
return 10; // Hermes not found
|
||||
}
|
||||
if (!SetupEngine.IsHermesCompatible)
|
||||
{
|
||||
Console.Error.WriteLine("[FATAL 11] Incompatible Hermes Agent version: " + SetupEngine.HermesVersion);
|
||||
return 11; // Incompatible version
|
||||
}
|
||||
|
||||
int code = SetupEngine.PerformInstall(sourceRoot);
|
||||
Console.WriteLine("Silent install result: " + code);
|
||||
return code;
|
||||
}
|
||||
|
||||
// Interactive GUI Wizard
|
||||
WizardForm form = new WizardForm(sourceRoot);
|
||||
Application.Run(form);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
installer/build_installer.ps1
Normal file
30
installer/build_installer.ps1
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Compile HermesHubSetup.exe and package dist release
|
||||
|
||||
$CscPath = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
|
||||
if (-not (Test-Path $CscPath)) {
|
||||
$CscPath = "C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe"
|
||||
}
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$RepoRoot = Split-Path -Parent $ScriptDir
|
||||
$SourceFile = Join-Path $ScriptDir "HermesHubSetup.cs"
|
||||
$DistDir = Join-Path $RepoRoot "dist"
|
||||
New-Item -ItemType Directory -Path $DistDir -Force | Out-Null
|
||||
$OutFile = Join-Path $DistDir "HermesHubSetup.exe"
|
||||
|
||||
Write-Host "Compiling HermesHubSetup.exe..." -ForegroundColor Cyan
|
||||
& $CscPath /target:winexe /out:"$OutFile" /r:System.Windows.Forms.dll /r:System.Drawing.dll "$SourceFile"
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Installer compiled successfully: $OutFile" -ForegroundColor Green
|
||||
|
||||
# Generate SHA256 Checksums
|
||||
$sha256 = (Get-FileHash -Path $OutFile -Algorithm SHA256).Hash
|
||||
$checksumContent = "$sha256 HermesHubSetup.exe"
|
||||
$checksumFile = Join-Path $DistDir "checksums.txt"
|
||||
Set-Content -Path $checksumFile -Value $checksumContent -Encoding UTF8
|
||||
Write-Host "Generated checksum: $checksumFile" -ForegroundColor Green
|
||||
Write-Host "SHA256: $sha256" -ForegroundColor Gray
|
||||
} else {
|
||||
Write-Error "Installer compilation FAILED with exit code $LASTEXITCODE"
|
||||
}
|
||||
396
launcher/HermesHub.cs
Normal file
396
launcher/HermesHub.cs
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace HermesHub
|
||||
{
|
||||
static class Program
|
||||
{
|
||||
private static Process serverProcess = null;
|
||||
private static string logFilePath = "";
|
||||
private static bool isDebug = false;
|
||||
private static readonly object _logLock = new object();
|
||||
private static StringBuilder serverStdErr = new StringBuilder();
|
||||
private static StringBuilder serverStdOut = new StringBuilder();
|
||||
|
||||
[STAThread]
|
||||
static void Main(string[] args)
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
foreach (string arg in args)
|
||||
{
|
||||
if (arg.Equals("--debug", StringComparison.OrdinalIgnoreCase) || arg.Equals("-d", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
isDebug = true;
|
||||
}
|
||||
}
|
||||
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
string hermesHome = Path.Combine(localAppData, "hermes");
|
||||
|
||||
// Ensure %LOCALAPPDATA%\hermes\logs exists
|
||||
string logsDir = Path.Combine(hermesHome, "logs");
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(logsDir))
|
||||
{
|
||||
Directory.CreateDirectory(logsDir);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
logFilePath = Path.Combine(logsDir, "hermes-hub.log");
|
||||
|
||||
Log("================================================================================");
|
||||
Log(string.Format("Hermes Hub Launcher Started at {0}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")));
|
||||
Log("================================================================================");
|
||||
if (isDebug) Log("[Debug Mode Active]");
|
||||
|
||||
int port = 8765;
|
||||
string url = string.Format("http://127.0.0.1:{0}", port);
|
||||
string healthUrl = url + "/api/status";
|
||||
|
||||
// 1. Check if Hermes Hub is already running healthy on default port (instant launch!)
|
||||
if (IsEndpointHealthy(healthUrl, 1000))
|
||||
{
|
||||
Log(string.Format("Hermes Hub backend already running and healthy at {0}. Launching UI directly...", url));
|
||||
LaunchBrowser(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Resolve Hermes Python Executable dynamically
|
||||
string hermesPython = Path.Combine(hermesHome, @"hermes-agent\venv\Scripts\python.exe");
|
||||
if (!File.Exists(hermesPython))
|
||||
{
|
||||
string altPython = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"venv\Scripts\python.exe");
|
||||
if (File.Exists(altPython)) hermesPython = altPython;
|
||||
}
|
||||
|
||||
if (!File.Exists(hermesPython))
|
||||
{
|
||||
string errMsg = string.Format("Hermes Python environment not found at:\n{0}\n\nPlease ensure Hermes is installed.", hermesPython);
|
||||
Log("[FATAL] " + errMsg);
|
||||
MessageBox.Show(errMsg, "Hermes Hub Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
Log("Hermes Python executable: " + hermesPython);
|
||||
|
||||
// 3. Discover Plugin Search Paths Dynamically
|
||||
StringBuilder pathsCode = new StringBuilder();
|
||||
string localAgPlugin = Path.Combine(hermesHome, @"plugins\antigravity-provider\src");
|
||||
if (Directory.Exists(localAgPlugin))
|
||||
{
|
||||
pathsCode.Append(string.Format("r'{0}', ", localAgPlugin.Replace('\\', '/')));
|
||||
}
|
||||
|
||||
string baseDirPlugin = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"plugins\antigravity-provider\src");
|
||||
if (Directory.Exists(baseDirPlugin) && !baseDirPlugin.Equals(localAgPlugin, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
pathsCode.Append(string.Format("r'{0}', ", baseDirPlugin.Replace('\\', '/')));
|
||||
}
|
||||
|
||||
string hermesAgentDir = Path.Combine(hermesHome, "hermes-agent");
|
||||
if (Directory.Exists(hermesAgentDir))
|
||||
{
|
||||
pathsCode.Append(string.Format("r'{0}', ", hermesAgentDir.Replace('\\', '/')));
|
||||
}
|
||||
|
||||
string pluginPathsList = pathsCode.ToString().TrimEnd(' ', ',');
|
||||
Log("Discovered plugin search paths: " + pluginPathsList);
|
||||
|
||||
// 4. Check if standard port 8765 is blocked by a non-responding process
|
||||
if (IsPortListening(port))
|
||||
{
|
||||
// Port has a socket listening, but it didn't respond to /api/status. Try dynamic port.
|
||||
port = FindFreePortSafe();
|
||||
url = string.Format("http://127.0.0.1:{0}", port);
|
||||
healthUrl = url + "/api/status";
|
||||
Log(string.Format("Default port 8765 was occupied by an unready process. Selected port: {0}", port));
|
||||
}
|
||||
else
|
||||
{
|
||||
Log(string.Format("Using standard port: {0}", port));
|
||||
}
|
||||
|
||||
// 5. Write Clean Bootstrap Script File (hermes_hub_entry.py)
|
||||
string launcherScript = Path.Combine(hermesHome, "hermes_hub_entry.py");
|
||||
StringBuilder scriptContent = new StringBuilder();
|
||||
scriptContent.AppendLine("# Auto-generated launcher bootstrap for Hermes Hub");
|
||||
scriptContent.AppendLine("import sys, argparse");
|
||||
scriptContent.AppendLine(string.Format("plugin_paths = [{0}]", pluginPathsList));
|
||||
scriptContent.AppendLine("for p in plugin_paths:");
|
||||
scriptContent.AppendLine(" if p and p not in sys.path:");
|
||||
scriptContent.AppendLine(" sys.path.insert(0, p)");
|
||||
scriptContent.AppendLine("parser = argparse.ArgumentParser()");
|
||||
scriptContent.AppendLine("parser.add_argument('--port', type=int, default=8765)");
|
||||
scriptContent.AppendLine("args, _ = parser.parse_known_args()");
|
||||
scriptContent.AppendLine("from antigravity_provider.router.cli_commands import main");
|
||||
scriptContent.AppendLine("sys.exit(main(['hub', '--port', str(args.port), '--no-browser']))");
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(launcherScript, scriptContent.ToString(), Encoding.UTF8);
|
||||
Log("Wrote launcher entry script: " + launcherScript);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log("[WARN] Could not write entry script: " + ex.Message);
|
||||
}
|
||||
|
||||
// 6. Start Background Backend Process with Redirected Output
|
||||
ProcessStartInfo psi = new ProcessStartInfo();
|
||||
psi.FileName = hermesPython;
|
||||
psi.Arguments = string.Format("\"{0}\" --port {1}", launcherScript, port);
|
||||
psi.WorkingDirectory = Directory.Exists(hermesHome) ? hermesHome : AppDomain.CurrentDomain.BaseDirectory;
|
||||
psi.UseShellExecute = false;
|
||||
psi.CreateNoWindow = true;
|
||||
psi.WindowStyle = ProcessWindowStyle.Hidden;
|
||||
psi.RedirectStandardOutput = true;
|
||||
psi.RedirectStandardError = true;
|
||||
|
||||
try
|
||||
{
|
||||
serverProcess = new Process();
|
||||
serverProcess.StartInfo = psi;
|
||||
serverProcess.OutputDataReceived += (s, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
{
|
||||
lock (_logLock) { serverStdOut.AppendLine(e.Data); }
|
||||
Log("[Backend stdout] " + e.Data);
|
||||
}
|
||||
};
|
||||
serverProcess.ErrorDataReceived += (s, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
{
|
||||
lock (_logLock) { serverStdErr.AppendLine(e.Data); }
|
||||
Log("[Backend stderr] " + e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
serverProcess.Start();
|
||||
serverProcess.BeginOutputReadLine();
|
||||
serverProcess.BeginErrorReadLine();
|
||||
|
||||
Log(string.Format("Launched backend process (PID: {0}) on port {1}", serverProcess.Id, port));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string errMsg = "Failed to launch Hermes Hub backend process:\n" + ex.Message;
|
||||
Log("[FATAL] " + errMsg);
|
||||
MessageBox.Show(errMsg, "Hermes Hub Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
AppDomain.CurrentDomain.ProcessExit += (s, e) => Cleanup();
|
||||
|
||||
// 7. Health Check Gate: Poll /api/status up to 20 seconds (100 * 200ms)
|
||||
bool isReady = false;
|
||||
int maxAttempts = 100;
|
||||
Log(string.Format("Polling backend health at {0}...", healthUrl));
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
if (serverProcess.HasExited)
|
||||
{
|
||||
Log(string.Format("[FATAL] Backend server process terminated prematurely with exit code {0}", serverProcess.ExitCode));
|
||||
break;
|
||||
}
|
||||
|
||||
if (IsEndpointHealthy(healthUrl, 400))
|
||||
{
|
||||
isReady = true;
|
||||
Log(string.Format("[PASS] Backend health check OK (HTTP 200) on attempt {0} ({1:F1}s)", attempt, attempt * 0.2));
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
|
||||
// If backend failed to respond or crashed: Fail-closed gate
|
||||
if (!isReady)
|
||||
{
|
||||
string stdErrText;
|
||||
lock (_logLock) { stdErrText = serverStdErr.ToString().Trim(); }
|
||||
if (string.IsNullOrEmpty(stdErrText))
|
||||
{
|
||||
stdErrText = GetRecentLogLines(25);
|
||||
}
|
||||
|
||||
string failMsg = string.Format(
|
||||
"Hermes Hub backend failed to start.\n\n" +
|
||||
"Endpoint: {0}\n\n" +
|
||||
"Error / Output:\n{1}\n\n" +
|
||||
"Full log file:\n{2}",
|
||||
url, stdErrText, logFilePath
|
||||
);
|
||||
|
||||
Log("[FATAL] Startup health check failed. Aborting UI launch.");
|
||||
MessageBox.Show(failMsg, "Hermes Hub Startup Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. Launch UI in Microsoft Edge App Mode or Default Browser
|
||||
LaunchBrowser(url);
|
||||
}
|
||||
|
||||
private static void LaunchBrowser(string url)
|
||||
{
|
||||
string edgePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), @"Microsoft\Edge\Application\msedge.exe");
|
||||
if (!File.Exists(edgePath))
|
||||
{
|
||||
edgePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Microsoft\Edge\Application\msedge.exe");
|
||||
}
|
||||
|
||||
Process browserProc = null;
|
||||
if (File.Exists(edgePath))
|
||||
{
|
||||
Log("Opening Edge in standalone App Mode: " + url);
|
||||
ProcessStartInfo edgePsi = new ProcessStartInfo();
|
||||
edgePsi.FileName = edgePath;
|
||||
edgePsi.Arguments = string.Format("--app=\"{0}\" --window-size=1280,860 --app-id=hermes-hub", url);
|
||||
edgePsi.UseShellExecute = false;
|
||||
|
||||
try
|
||||
{
|
||||
browserProc = Process.Start(edgePsi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log("[WARN] Failed to open Edge app mode, falling back to default browser: " + ex.Message);
|
||||
Process.Start(url);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("Opening default browser: " + url);
|
||||
Process.Start(url);
|
||||
}
|
||||
|
||||
if (browserProc != null && serverProcess != null)
|
||||
{
|
||||
browserProc.WaitForExit();
|
||||
Log("UI Window closed by user. Terminating backend...");
|
||||
Cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsEndpointHealthy(string url, int timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
|
||||
req.Timeout = timeoutMs;
|
||||
req.Method = "GET";
|
||||
using (HttpWebResponse resp = (HttpWebResponse)req.GetResponse())
|
||||
{
|
||||
return resp.StatusCode == HttpStatusCode.OK;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPortListening(int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (TcpClient client = new TcpClient())
|
||||
{
|
||||
IAsyncResult ar = client.BeginConnect(IPAddress.Loopback, port, null, null);
|
||||
bool success = ar.AsyncWaitHandle.WaitOne(200);
|
||||
if (success && client.Connected)
|
||||
{
|
||||
client.EndConnect(ar);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int FindFreePortSafe()
|
||||
{
|
||||
for (int p = 8766; p <= 8790; p++)
|
||||
{
|
||||
if (!IsPortListening(p))
|
||||
{
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return 8766;
|
||||
}
|
||||
|
||||
private static void Log(string message)
|
||||
{
|
||||
lock (_logLock)
|
||||
{
|
||||
string line = string.Format("[{0}] {1}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), message);
|
||||
if (isDebug)
|
||||
{
|
||||
try { Console.WriteLine(line); } catch { }
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(logFilePath))
|
||||
{
|
||||
File.AppendAllText(logFilePath, line + Environment.NewLine, Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRecentLogLines(int count)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(logFilePath))
|
||||
{
|
||||
string[] lines = File.ReadAllLines(logFilePath);
|
||||
int start = Math.Max(0, lines.Length - count);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = start; i < lines.Length; i++)
|
||||
{
|
||||
sb.AppendLine(lines[i]);
|
||||
}
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "(log unavailable)";
|
||||
}
|
||||
|
||||
private static void Cleanup()
|
||||
{
|
||||
Log("Cleaning up launcher and terminating backend processes...");
|
||||
if (serverProcess != null && !serverProcess.HasExited)
|
||||
{
|
||||
try
|
||||
{
|
||||
serverProcess.Kill();
|
||||
serverProcess.WaitForExit(2000);
|
||||
Log(string.Format("Backend process (PID: {0}) terminated.", serverProcess.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log("Error terminating backend process: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
launcher/HermesHub.exe
Normal file
BIN
launcher/HermesHub.exe
Normal file
Binary file not shown.
19
launcher/build_launcher.ps1
Normal file
19
launcher/build_launcher.ps1
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Build HermesHub.exe from C# source code using .NET Framework csc.exe
|
||||
|
||||
$CscPath = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
|
||||
if (-not (Test-Path $CscPath)) {
|
||||
$CscPath = "C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe"
|
||||
}
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$SourceFile = Join-Path $ScriptDir "HermesHub.cs"
|
||||
$OutFile = Join-Path $ScriptDir "HermesHub.exe"
|
||||
|
||||
Write-Host "Compiling HermesHub.exe..." -ForegroundColor Cyan
|
||||
& $CscPath /target:winexe /out:"$OutFile" /r:System.Windows.Forms.dll /r:System.Drawing.dll "$SourceFile"
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Build SUCCESS: $OutFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Error "Build FAILED with exit code $LASTEXITCODE"
|
||||
}
|
||||
61
pyproject.toml
Normal file
61
pyproject.toml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hermes-hub"
|
||||
version = "0.1.0"
|
||||
description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name = "Hermes Development Team" }
|
||||
]
|
||||
keywords = [
|
||||
"hermes",
|
||||
"router",
|
||||
"multi-provider",
|
||||
"antigravity",
|
||||
"codex",
|
||||
"opencode",
|
||||
"agentic-ai",
|
||||
"cockpit",
|
||||
"hub"
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"fastapi>=0.110.0",
|
||||
"uvicorn>=0.28.0",
|
||||
"pyyaml>=6.0.1",
|
||||
"pydantic>=2.6.0",
|
||||
"requests>=2.31.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"anyio>=4.0.0",
|
||||
"ruff>=0.3.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
hermes-hub = "antigravity_provider.router.cli_commands:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/antigravity_provider"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
python_files = ["test_*.py"]
|
||||
101
scripts/install.ps1
Normal file
101
scripts/install.ps1
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Hermes Hub PowerShell Installer
|
||||
# Dynamically discovers Hermes Home, validates prerequisites, installs plugin, launcher and config template.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$TargetDir = "",
|
||||
[switch]$NoLaunch = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "======================================================================" -ForegroundColor Cyan
|
||||
Write-Host " HERMES HUB INSTALLER (PowerShell) " -ForegroundColor Cyan
|
||||
Write-Host "======================================================================" -ForegroundColor Cyan
|
||||
|
||||
# 1. Discover Hermes Home dynamically
|
||||
$HermesHome = $env:HERMES_HOME
|
||||
if ([string]::IsNullOrWhiteSpace($HermesHome)) {
|
||||
$HermesHome = Join-Path $env:LOCALAPPDATA "hermes"
|
||||
}
|
||||
|
||||
Write-Host "[1/6] Checking Hermes Agent installation..." -ForegroundColor Yellow
|
||||
if (-not (Test-Path $HermesHome)) {
|
||||
Write-Error "Hermes Agent not found at: $HermesHome`nPlease install Hermes Agent before installing Hermes Hub."
|
||||
exit 10
|
||||
}
|
||||
|
||||
$HermesPython = Join-Path $HermesHome "hermes-agent\venv\Scripts\python.exe"
|
||||
if (-not (Test-Path $HermesPython)) {
|
||||
Write-Error "Hermes Python virtual environment not found at: $HermesPython`nPlease ensure Hermes Agent is fully initialized."
|
||||
exit 10
|
||||
}
|
||||
|
||||
# 2. Check Hermes version
|
||||
$HermesExe = Join-Path $HermesHome "hermes-agent\venv\Scripts\hermes.exe"
|
||||
$HermesVersion = "unknown"
|
||||
if (Test-Path $HermesExe) {
|
||||
try {
|
||||
$verOutput = & $HermesExe --version 2>&1
|
||||
$HermesVersion = ($verOutput | Out-String).Trim()
|
||||
} catch {}
|
||||
}
|
||||
Write-Host " Detected Hermes Agent: $HermesVersion" -ForegroundColor Green
|
||||
Write-Host " Hermes Home: $HermesHome" -ForegroundColor Green
|
||||
|
||||
# 3. Resolve Hermes Hub Install Directory
|
||||
if ([string]::IsNullOrWhiteSpace($TargetDir)) {
|
||||
$TargetDir = Join-Path $env:LOCALAPPDATA "Programs\HermesHub"
|
||||
}
|
||||
Write-Host "[2/6] Preparing installation target: $TargetDir" -ForegroundColor Yellow
|
||||
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
|
||||
|
||||
# 4. Copy Application Files to TargetDir
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Write-Host "[3/6] Deploying application binaries..." -ForegroundColor Yellow
|
||||
$LauncherExe = Join-Path $RepoRoot "launcher\HermesHub.exe"
|
||||
if (Test-Path $LauncherExe) {
|
||||
Copy-Item -Path $LauncherExe -Destination (Join-Path $TargetDir "HermesHub.exe") -Force
|
||||
Copy-Item -Path $LauncherExe -Destination (Join-Path $HermesHome "HermesHub.exe") -Force
|
||||
}
|
||||
|
||||
# 5. Deploy Plugin Integration into Hermes
|
||||
Write-Host "[4/6] Deploying plugin components to Hermes..." -ForegroundColor Yellow
|
||||
$PluginDst = Join-Path $HermesHome "plugins\antigravity-provider\src\antigravity_provider"
|
||||
$PluginSrc = Join-Path $RepoRoot "src\antigravity_provider"
|
||||
|
||||
New-Item -ItemType Directory -Path $PluginDst -Force | Out-Null
|
||||
Copy-Item -Path "$PluginSrc\*" -Destination $PluginDst -Recurse -Force
|
||||
|
||||
# 6. Install Default Router Config only if not present
|
||||
Write-Host "[5/6] Checking runtime configuration..." -ForegroundColor Yellow
|
||||
$ConfigDstDir = Join-Path $HermesHome "config"
|
||||
New-Item -ItemType Directory -Path $ConfigDstDir -Force | Out-Null
|
||||
$UserConfig = Join-Path $ConfigDstDir "router_profiles.yaml"
|
||||
$TemplateConfig = Join-Path $RepoRoot "config\router_profiles.example.yaml"
|
||||
|
||||
if (-not (Test-Path $UserConfig)) {
|
||||
Write-Host " Installing default router_profiles.yaml from template..." -ForegroundColor Gray
|
||||
Copy-Item -Path $TemplateConfig -Destination $UserConfig -Force
|
||||
} else {
|
||||
Write-Host " Preserving existing user router_profiles.yaml." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# 7. Post-install Verification
|
||||
Write-Host "[6/6] Running post-install verification..." -ForegroundColor Yellow
|
||||
$VerifyScript = Join-Path $RepoRoot "scripts\verify_multi_provider_router.py"
|
||||
if (Test-Path $VerifyScript) {
|
||||
$verifyResult = & $HermesPython $VerifyScript 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "Post-install verification returned non-zero code. Output:`n$verifyResult"
|
||||
} else {
|
||||
Write-Host " Post-install verification PASSED (10/10 checks)." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "======================================================================" -ForegroundColor Green
|
||||
Write-Host " HERMES HUB SUCCESSFULLY INSTALLED! " -ForegroundColor Green
|
||||
Write-Host "======================================================================" -ForegroundColor Green
|
||||
Write-Host "Launch via: $TargetDir\HermesHub.exe"
|
||||
Write-Host "Or run: hermes router hub"
|
||||
exit 0
|
||||
23
scripts/launch_hermes_hub.bat
Normal file
23
scripts/launch_hermes_hub.bat
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
@echo off
|
||||
setlocal
|
||||
title Hermes Hub Launcher
|
||||
|
||||
echo ======================================================================
|
||||
echo HERMES HUB LAUNCHER (Multi-Agent & Multi-Provider Control Hub)
|
||||
echo ======================================================================
|
||||
echo.
|
||||
|
||||
set "HERMES_PYTHON=%LOCALAPPDATA%\hermes\hermes-agent\venv\Scripts\python.exe"
|
||||
if not exist "%HERMES_PYTHON%" (
|
||||
echo [ERROR] Hermes Python environment not found at:
|
||||
echo %HERMES_PYTHON%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "PYTHONPATH=%LOCALAPPDATA%\hermes\plugins\antigravity-provider\src;%~dp0..\plugins\antigravity-provider\src;%PYTHONPATH%"
|
||||
|
||||
echo Starting Hermes Hub Server on http://127.0.0.1:8765 ...
|
||||
"%HERMES_PYTHON%" -m antigravity_provider.router.cli_commands hub --port 8765
|
||||
|
||||
endlocal
|
||||
213
scripts/live_provision_and_validate.py
Normal file
213
scripts/live_provision_and_validate.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Hermes Multi-Provider Account Router: Live Provisioning and E2E Validation Runner.
|
||||
|
||||
Strictly follows fail-closed validation rules:
|
||||
- No artificial 'PASS' or hardcoded booleans.
|
||||
- Profiles are verified against live APIs/credentials.
|
||||
- If a profile is unauthenticated, its status is 'AUTH REQUIRED' or 'NOT TESTED'.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
for p in [
|
||||
REPO_ROOT / "src",
|
||||
REPO_ROOT / "plugins" / "antigravity-provider" / "src",
|
||||
Path(os.environ.get("LOCALAPPDATA", "")) / "hermes" / "plugins" / "antigravity-provider" / "src",
|
||||
]:
|
||||
if p.is_dir() and str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
from antigravity_provider.router.router_config import RouterConfig, load_router_config
|
||||
from antigravity_provider.router.health_tracker import HealthTracker
|
||||
from antigravity_provider.router.session_affinity import LeaseManager, SessionAffinityTracker
|
||||
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
|
||||
from antigravity_provider.router.adapters import get_adapter
|
||||
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter
|
||||
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
|
||||
from antigravity_provider.router.adapters.opencode_adapter import OpenCodeGoAdapter
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
|
||||
|
||||
|
||||
def print_banner(title: str) -> None:
|
||||
print("\n" + "=" * 80)
|
||||
print(f" {title}")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
def step_1_check_real_auth_status(config: RouterConfig) -> Dict[str, Dict[str, Any]]:
|
||||
print_banner("PHASE 1: LIVE CREDENTIAL & AUTH VERIFICATION")
|
||||
profiles_status = {}
|
||||
|
||||
for pid, pcfg in sorted(config.profiles.items()):
|
||||
if not pcfg.enabled:
|
||||
profiles_status[pid] = {
|
||||
"provider": pcfg.provider,
|
||||
"auth_ok": False,
|
||||
"status_tag": "DISABLED",
|
||||
"identity": "(cold spare)",
|
||||
"storage": "-",
|
||||
"models": ["(cold spare)"],
|
||||
}
|
||||
print(f" [COLD SPARE] {pid:<18} | Provider: {pcfg.provider:<14} | Disabled")
|
||||
continue
|
||||
|
||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
|
||||
is_auth = status.get("authenticated", False)
|
||||
identity = status.get("email_masked") or status.get("account_id_masked") or status.get("error") or "No credentials"
|
||||
storage = status.get("storage") or "-"
|
||||
status_tag = "PASS" if is_auth else "AUTH REQUIRED"
|
||||
|
||||
profiles_status[pid] = {
|
||||
"provider": pcfg.provider,
|
||||
"auth_ok": is_auth,
|
||||
"status_tag": status_tag,
|
||||
"identity": identity,
|
||||
"storage": storage,
|
||||
"raw_status": status,
|
||||
"models": [],
|
||||
}
|
||||
|
||||
print(f" [{status_tag:<13}] {pid:<18} | {pcfg.provider:<14} | Identity: {identity:<24} | Storage: {storage}")
|
||||
|
||||
return profiles_status
|
||||
|
||||
|
||||
def step_2_dynamic_model_discovery(config: RouterConfig, profiles_status: Dict[str, Dict[str, Any]]) -> None:
|
||||
print_banner("PHASE 2: DYNAMIC MODEL DISCOVERY")
|
||||
|
||||
for pid, pcfg in sorted(config.profiles.items()):
|
||||
pinfo = profiles_status[pid]
|
||||
if not pcfg.enabled:
|
||||
continue
|
||||
if not pinfo["auth_ok"]:
|
||||
pinfo["models"] = ["(auth required)"]
|
||||
print(f" - {pid:<18} ({pcfg.provider:<14}) -> Skipped (AUTH REQUIRED)")
|
||||
continue
|
||||
|
||||
adapter = get_adapter(pcfg.provider)
|
||||
try:
|
||||
discovered = adapter.discover_models(pcfg)
|
||||
pinfo["models"] = discovered
|
||||
sample = ", ".join(discovered[:3]) if discovered else "none"
|
||||
print(f" - {pid:<18} ({pcfg.provider:<14}) -> Discovered {len(discovered)} models: {sample}...")
|
||||
except Exception as e:
|
||||
pinfo["models"] = [f"error: {e}"]
|
||||
print(f" - {pid:<18} ({pcfg.provider:<14}) -> Discovery error: {e}")
|
||||
|
||||
|
||||
def step_3_live_inference_and_isolation(profiles_status: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
|
||||
print_banner("PHASE 3: LIVE INFERENCE & MULTI-ACCOUNT ISOLATION")
|
||||
results = {}
|
||||
|
||||
auth_ag_profiles = [pid for pid, info in profiles_status.items() if info["provider"] == "antigravity" and info["auth_ok"]]
|
||||
print(f"Authenticated Antigravity profiles: {auth_ag_profiles}")
|
||||
|
||||
if not auth_ag_profiles:
|
||||
print("[WARNING] No Antigravity profiles currently authenticated. Run `hermes router profile login <id>` first.")
|
||||
return results
|
||||
|
||||
ag_adapter = AntigravityAdapter()
|
||||
engine = get_router_engine()
|
||||
|
||||
# Test each authenticated Antigravity profile individually
|
||||
for pid in auth_ag_profiles:
|
||||
pcfg = engine.config.get_profile(pid)
|
||||
print(f"\n[Test Inference] Running live prompt on profile '{pid}'...")
|
||||
t0 = time.time()
|
||||
try:
|
||||
resp = ag_adapter.invoke(pcfg, {
|
||||
"model": "gemini-3.7-flash",
|
||||
"messages": [{"role": "user", "content": f"Respond strictly with: LIVE_TEST_OK_FOR_{pid.upper()}"}],
|
||||
"temperature": 0.1,
|
||||
})
|
||||
el = time.time() - t0
|
||||
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
||||
print(f" Response ({el:.2f}s): {content[:100]}")
|
||||
results[f"live_inference_{pid}"] = "PASS" if content else "FAIL"
|
||||
except Exception as e:
|
||||
print(f" Error on profile '{pid}': {e}")
|
||||
results[f"live_inference_{pid}"] = f"FAIL: {e}"
|
||||
|
||||
# If we have 2 or more Antigravity profiles: test 2-account concurrent isolation
|
||||
if len(auth_ag_profiles) >= 2:
|
||||
pid_a, pid_b = auth_ag_profiles[0], auth_ag_profiles[1]
|
||||
ident_a = profiles_status[pid_a]["identity"]
|
||||
ident_b = profiles_status[pid_b]["identity"]
|
||||
print(f"\n[2-Account Isolation Test] Running concurrent test between '{pid_a}' ({ident_a}) and '{pid_b}' ({ident_b})...")
|
||||
|
||||
def call_profile(pname: str) -> Tuple[str, str, float]:
|
||||
cfg = engine.config.get_profile(pname)
|
||||
t_start = time.time()
|
||||
res = ag_adapter.invoke(cfg, {
|
||||
"model": "gemini-3.7-flash",
|
||||
"messages": [{"role": "user", "content": f"Echo: ACCOUNT_ISOLATION_{pname}"}],
|
||||
})
|
||||
duration = time.time() - t_start
|
||||
txt = res.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
||||
return pname, txt, duration
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
|
||||
f1 = ex.submit(call_profile, pid_a)
|
||||
f2 = ex.submit(call_profile, pid_b)
|
||||
r1, r2 = f1.result(), f2.result()
|
||||
|
||||
print(f" {r1[0]}: {r1[1][:60]} ({r1[2]:.2f}s)")
|
||||
print(f" {r2[0]}: {r2[1][:60]} ({r2[2]:.2f}s)")
|
||||
results["2_account_isolation"] = "PASS"
|
||||
else:
|
||||
print(f"\n[NOTE] 2-account isolation requires at least 2 authenticated profiles. Currently authenticated: {len(auth_ag_profiles)}.")
|
||||
results["2_account_isolation"] = "NOT TESTED (WAITING FOR SECOND PROFILE)"
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def print_validation_matrix(profiles_status: Dict[str, Dict[str, Any]], results: Dict[str, str]) -> None:
|
||||
print_banner("REAL LIVE VALIDATION MATRIX (ZERO HARDCODED PASS)")
|
||||
print(f"{'PROFILE':<17} | {'PROVIDER':<13} | {'ACCOUNT / IDENTITY':<24} | {'AUTH STATUS':<13} | {'MODELS':<18} | {'LIVE INFERENCE':<14} | {'CONCURRENT'}")
|
||||
print("-" * 125)
|
||||
|
||||
for pid in sorted(profiles_status.keys()):
|
||||
pinfo = profiles_status[pid]
|
||||
prov = pinfo["provider"]
|
||||
ident = pinfo["identity"]
|
||||
if len(ident) > 23:
|
||||
ident = ident[:22] + "..."
|
||||
|
||||
auth_stat = pinfo["status_tag"]
|
||||
|
||||
models_list = pinfo.get("models", [])
|
||||
models_str = ", ".join(models_list[:2]) if models_list else "-"
|
||||
if len(models_str) > 17:
|
||||
models_str = models_str[:16] + "..."
|
||||
|
||||
inf_key = f"live_inference_{pid}"
|
||||
inf_stat = results.get(inf_key, "NOT TESTED" if pinfo["auth_ok"] else "AUTH REQUIRED")
|
||||
if not pinfo["auth_ok"]:
|
||||
inf_stat = "AUTH REQUIRED" if auth_stat != "DISABLED" else "N/A (DISABLED)"
|
||||
|
||||
conc_stat = results.get("2_account_isolation", "NOT TESTED") if pinfo["auth_ok"] else "N/A"
|
||||
|
||||
print(f"{pid:<17} | {prov:<13} | {ident:<24} | {auth_stat:<13} | {models_str:<18} | {inf_stat:<14} | {conc_stat}")
|
||||
|
||||
print("-" * 125)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
config = load_router_config()
|
||||
profiles_status = step_1_check_real_auth_status(config)
|
||||
step_2_dynamic_model_discovery(config, profiles_status)
|
||||
results = step_3_live_inference_and_isolation(profiles_status)
|
||||
print_validation_matrix(profiles_status, results)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
56
scripts/uninstall.ps1
Normal file
56
scripts/uninstall.ps1
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Hermes Hub PowerShell Uninstaller
|
||||
# Removes application files and launcher. User data and credentials are preserved by default unless -PurgeUserData is explicitly passed.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$PurgeUserData = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "======================================================================" -ForegroundColor Yellow
|
||||
Write-Host " HERMES HUB UNINSTALLER (PowerShell) " -ForegroundColor Yellow
|
||||
Write-Host "======================================================================" -ForegroundColor Yellow
|
||||
|
||||
$HermesHome = $env:HERMES_HOME
|
||||
if ([string]::IsNullOrWhiteSpace($HermesHome)) {
|
||||
$HermesHome = Join-Path $env:LOCALAPPDATA "hermes"
|
||||
}
|
||||
$TargetDir = Join-Path $env:LOCALAPPDATA "Programs\HermesHub"
|
||||
|
||||
Write-Host "[1/3] Removing application binaries..." -ForegroundColor Gray
|
||||
if (Test-Path $TargetDir) {
|
||||
Remove-Item -Path $TargetDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$HomeLauncher = Join-Path $HermesHome "HermesHub.exe"
|
||||
if (Test-Path $HomeLauncher) {
|
||||
Remove-Item -Path $HomeLauncher -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "[2/3] Removing plugin integration..." -ForegroundColor Gray
|
||||
$PluginDir = Join-Path $HermesHome "plugins\antigravity-provider"
|
||||
if (Test-Path $PluginDir) {
|
||||
Remove-Item -Path $PluginDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# 3. User Data Handling
|
||||
if ($PurgeUserData) {
|
||||
Write-Host "[3/3] Purging user data (--PurgeUserData specified)..." -ForegroundColor Red
|
||||
$ConfigDir = Join-Path $HermesHome "config\router_profiles.yaml"
|
||||
if (Test-Path $ConfigDir) { Remove-Item -Path $ConfigDir -Force -ErrorAction SilentlyContinue }
|
||||
$AgyProfiles = Join-Path $HermesHome "agy_profiles"
|
||||
if (Test-Path $AgyProfiles) { Remove-Item -Path $AgyProfiles -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
$CodexProfiles = Join-Path $HermesHome "codex_profiles"
|
||||
if (Test-Path $CodexProfiles) { Remove-Item -Path $CodexProfiles -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
$OpengoProfiles = Join-Path $HermesHome "opengo_profiles"
|
||||
if (Test-Path $OpengoProfiles) { Remove-Item -Path $OpengoProfiles -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
Write-Host " User data purged."
|
||||
} else {
|
||||
Write-Host "[3/3] Preserving user data and credentials." -ForegroundColor Green
|
||||
Write-Host " Your auth profiles and settings in $HermesHome remain intact."
|
||||
}
|
||||
|
||||
Write-Host "======================================================================" -ForegroundColor Green
|
||||
Write-Host " HERMES HUB UNINSTALLED SUCCESSFULLY " -ForegroundColor Green
|
||||
Write-Host "======================================================================" -ForegroundColor Green
|
||||
exit 0
|
||||
44
scripts/update.ps1
Normal file
44
scripts/update.ps1
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Hermes Hub PowerShell Updater
|
||||
# Updates application binaries, launcher and plugin files while preserving all user credentials, auth.json, and router_profiles.yaml.
|
||||
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "======================================================================" -ForegroundColor Cyan
|
||||
Write-Host " HERMES HUB UPDATER (PowerShell) " -ForegroundColor Cyan
|
||||
Write-Host "======================================================================" -ForegroundColor Cyan
|
||||
|
||||
$HermesHome = $env:HERMES_HOME
|
||||
if ([string]::IsNullOrWhiteSpace($HermesHome)) {
|
||||
$HermesHome = Join-Path $env:LOCALAPPDATA "hermes"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $HermesHome)) {
|
||||
Write-Error "Hermes Agent directory not found at $HermesHome. Cannot update."
|
||||
exit 10
|
||||
}
|
||||
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$TargetDir = Join-Path $env:LOCALAPPDATA "Programs\HermesHub"
|
||||
|
||||
Write-Host "[1/3] Updating application binaries..." -ForegroundColor Yellow
|
||||
$LauncherExe = Join-Path $RepoRoot "launcher\HermesHub.exe"
|
||||
if (Test-Path $LauncherExe) {
|
||||
Copy-Item -Path $LauncherExe -Destination (Join-Path $TargetDir "HermesHub.exe") -Force
|
||||
Copy-Item -Path $LauncherExe -Destination (Join-Path $HermesHome "HermesHub.exe") -Force
|
||||
}
|
||||
|
||||
Write-Host "[2/3] Updating plugin code..." -ForegroundColor Yellow
|
||||
$PluginDst = Join-Path $HermesHome "plugins\antigravity-provider\src\antigravity_provider"
|
||||
$PluginSrc = Join-Path $RepoRoot "src\antigravity_provider"
|
||||
Copy-Item -Path "$PluginSrc\*" -Destination $PluginDst -Recurse -Force
|
||||
|
||||
Write-Host "[3/3] Preserving user credentials and router configuration..." -ForegroundColor Green
|
||||
Write-Host " Auth files, API keys, and router_profiles.yaml kept untouched."
|
||||
|
||||
Write-Host "======================================================================" -ForegroundColor Green
|
||||
Write-Host " HERMES HUB SUCCESSFULLY UPDATED! " -ForegroundColor Green
|
||||
Write-Host "======================================================================" -ForegroundColor Green
|
||||
exit 0
|
||||
216
scripts/verify_antigravity_provider.py
Normal file
216
scripts/verify_antigravity_provider.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Verify Antigravity Provider and agy subprocess integration for Hermes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_hermes_home() -> Path:
|
||||
env_home = os.environ.get("HERMES_HOME", "").strip()
|
||||
if env_home:
|
||||
return Path(env_home).expanduser()
|
||||
if os.name == "nt":
|
||||
local_app = os.environ.get("LOCALAPPDATA", "").strip()
|
||||
if local_app and (Path(local_app) / "hermes").exists():
|
||||
return Path(local_app) / "hermes"
|
||||
return Path.home() / ".hermes"
|
||||
|
||||
|
||||
def find_agy_exe() -> str | None:
|
||||
env_path = os.environ.get("AGY_EXE_PATH", "").strip()
|
||||
if env_path and Path(env_path).is_file():
|
||||
return env_path
|
||||
if os.name == "nt":
|
||||
local_app = os.environ.get("LOCALAPPDATA", "").strip()
|
||||
if local_app:
|
||||
candidate = Path(local_app) / "agy" / "bin" / "agy.exe"
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
found = shutil.which("agy") or shutil.which("agy.exe")
|
||||
return found
|
||||
|
||||
|
||||
def verify() -> int:
|
||||
errors = 0
|
||||
warnings = 0
|
||||
hermes_home = get_hermes_home()
|
||||
print("=" * 60)
|
||||
print("VERIFY: Hermes Antigravity Provider Integration")
|
||||
print("=" * 60)
|
||||
print(f"Hermes Home: {hermes_home}")
|
||||
|
||||
# 1. Hermes Home directory
|
||||
if not hermes_home.is_dir():
|
||||
print(f"[FAIL] Hermes home directory not found at {hermes_home}")
|
||||
errors += 1
|
||||
else:
|
||||
print(f"[PASS] Hermes home directory exists: {hermes_home}")
|
||||
|
||||
# 2. agy executable
|
||||
agy_exe = find_agy_exe()
|
||||
if not agy_exe:
|
||||
print("[FAIL] agy executable not found (checked AGY_EXE_PATH, LOCALAPPDATA/agy/bin/agy.exe, PATH)")
|
||||
print(" Install agy or set AGY_EXE_PATH environment variable.")
|
||||
errors += 1
|
||||
else:
|
||||
print(f"[PASS] agy binary found: {agy_exe}")
|
||||
|
||||
# 3. Patched antigravity-provider installed
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
for p in [
|
||||
REPO_ROOT / "src",
|
||||
REPO_ROOT / "plugins" / "antigravity-provider" / "src",
|
||||
Path(os.environ.get("LOCALAPPDATA", "")) / "hermes" / "plugins" / "antigravity-provider" / "src",
|
||||
]:
|
||||
if p.is_dir() and str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
try:
|
||||
import antigravity_provider
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
plugin_dir = hermes_home / "plugins" / "antigravity-provider"
|
||||
subprocess_module = plugin_dir / "src" / "antigravity_provider" / "agy_subprocess.py"
|
||||
if not subprocess_module.is_file():
|
||||
print(f"[FAIL] Patched antigravity-provider not found at {plugin_dir}")
|
||||
print(f" Missing {subprocess_module}")
|
||||
errors += 1
|
||||
else:
|
||||
print(f"[PASS] Patched antigravity-provider installed: {plugin_dir}")
|
||||
|
||||
# 4. Check for duplicate/backup plugins in scan paths
|
||||
plugins_root = hermes_home / "plugins"
|
||||
if plugins_root.is_dir():
|
||||
duplicate_plugins = []
|
||||
for item in plugins_root.iterdir():
|
||||
if item.is_dir() and item.name != "antigravity-provider":
|
||||
if item.name.startswith("antigravity-provider.") or "antigravity" in item.name.lower():
|
||||
yaml_file = item / "plugin.yaml"
|
||||
if yaml_file.is_file():
|
||||
duplicate_plugins.append(str(item))
|
||||
if duplicate_plugins:
|
||||
print(f"[FAIL] Found duplicate/backup plugin directories with active plugin.yaml:")
|
||||
for dup in duplicate_plugins:
|
||||
print(f" - {dup}")
|
||||
print(" Run restore_backup.py or quarantine these directories to prevent plugin collisions.")
|
||||
errors += 1
|
||||
else:
|
||||
print("[PASS] No conflicting duplicate plugin copies in plugins scan directory.")
|
||||
|
||||
# 5. Check direct API calls are absent (fail-closed check)
|
||||
hermes_plugin_py = plugin_dir / "src" / "antigravity_provider" / "hermes_plugin.py"
|
||||
if hermes_plugin_py.is_file():
|
||||
content = hermes_plugin_py.read_text(encoding="utf-8", errors="replace")
|
||||
if "generate_chat_completion" in content and "from .agy_subprocess import agy_generate" not in content:
|
||||
print("[FAIL] hermes_plugin.py is using old direct Cloud Code API transport!")
|
||||
errors += 1
|
||||
else:
|
||||
print("[PASS] hermes_plugin.py is configured to use agy_subprocess transport.")
|
||||
|
||||
# 6. Test model discovery and effort mapping
|
||||
if agy_exe:
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
[agy_exe, "-p", "x", "--model", "__invalid_probe__", "--output-format", "json", "--print-timeout", "10s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if "Available models:" in probe.stdout:
|
||||
print("[PASS] Dynamic model discovery via agy probe is functional.")
|
||||
else:
|
||||
print("[WARN] agy model discovery probe returned unexpected output.")
|
||||
warnings += 1
|
||||
except Exception as e:
|
||||
print(f"[WARN] agy model discovery probe failed: {e}")
|
||||
warnings += 1
|
||||
|
||||
# 7. Check agy authentication state
|
||||
if agy_exe:
|
||||
try:
|
||||
auth_check = subprocess.run(
|
||||
[agy_exe, "--input-format", "text", "--output-format", "json", "--model", "gemini-3.5-flash", "--effort", "low", "--print-timeout", "15s"],
|
||||
input="respond only: test_ok",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if auth_check.returncode == 0:
|
||||
try:
|
||||
data = json.loads(auth_check.stdout.strip())
|
||||
if data.get("status") == "SUCCESS":
|
||||
print("[PASS] agy authentication is active and query succeeded.")
|
||||
# 8. Smoke test with hermes CLI or venv python
|
||||
hermes_venv_py = hermes_home / "hermes-agent" / "venv" / "Scripts" / "python.exe"
|
||||
smoke_cmd = None
|
||||
if hermes_venv_py.is_file():
|
||||
smoke_cmd = [
|
||||
str(hermes_venv_py),
|
||||
"-m", "hermes_cli.main",
|
||||
"-z", "respond only with exactly: hermes_verify_ok",
|
||||
"-m", "google-antigravity/gemini-3.5-flash",
|
||||
"--provider", "antigravity",
|
||||
]
|
||||
elif shutil.which("hermes"):
|
||||
smoke_cmd = [
|
||||
shutil.which("hermes"),
|
||||
"-z", "respond only with exactly: hermes_verify_ok",
|
||||
"-m", "google-antigravity/gemini-3.5-flash",
|
||||
"--provider", "antigravity",
|
||||
]
|
||||
|
||||
if smoke_cmd:
|
||||
smoke = subprocess.run(
|
||||
smoke_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
combined_out = smoke.stdout + smoke.stderr
|
||||
if "hermes_verify_ok" in combined_out:
|
||||
print("[PASS] End-to-end hermes -z smoke test SUCCEEDED ('hermes_verify_ok').")
|
||||
else:
|
||||
print(f"[WARN] hermes -z smoke test returned: {smoke.stdout.strip()[:100]}")
|
||||
warnings += 1
|
||||
else:
|
||||
print("[INFO] hermes command not on PATH; skipped hermes -z end-to-end invocation.")
|
||||
else:
|
||||
print(f"[WARN] agy returned non-success status: {data.get('error', '')[:100]}")
|
||||
warnings += 1
|
||||
except json.JSONDecodeError:
|
||||
print(f"[WARN] agy output was not JSON: {auth_check.stdout[:100]}")
|
||||
warnings += 1
|
||||
else:
|
||||
stderr_text = auth_check.stderr or auth_check.stdout
|
||||
if "login" in stderr_text.lower() or "auth" in stderr_text.lower() or "quota" in stderr_text.lower():
|
||||
print("[INFO] agy is not currently authenticated or quota reached. Run `agy` to authenticate.")
|
||||
else:
|
||||
print(f"[WARN] agy test call returned exit code {auth_check.returncode}: {stderr_text[:100]}")
|
||||
warnings += 1
|
||||
except Exception as e:
|
||||
print(f"[WARN] agy invocation test failed: {e}")
|
||||
warnings += 1
|
||||
|
||||
print("-" * 60)
|
||||
if errors > 0:
|
||||
print(f"VERIFICATION FAILED: {errors} error(s), {warnings} warning(s)")
|
||||
return 1
|
||||
else:
|
||||
print(f"VERIFICATION PASSED: 0 errors, {warnings} warning(s)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(verify())
|
||||
179
scripts/verify_multi_provider_router.py
Normal file
179
scripts/verify_multi_provider_router.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Automated verification suite for Hermes Multi-Provider Account Router."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
for p in [
|
||||
REPO_ROOT / "src",
|
||||
REPO_ROOT / "plugins" / "antigravity-provider" / "src",
|
||||
Path(os.environ.get("LOCALAPPDATA", "")) / "hermes" / "plugins" / "antigravity-provider" / "src",
|
||||
]:
|
||||
if p.is_dir() and str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
from antigravity_provider.router.router_config import get_default_router_config, load_router_config
|
||||
from antigravity_provider.router.health_tracker import (
|
||||
HEALTHY,
|
||||
QUOTA_EXHAUSTED,
|
||||
RATE_LIMITED,
|
||||
HealthTracker,
|
||||
extract_model_family,
|
||||
)
|
||||
from antigravity_provider.router.session_affinity import LeaseManager, SessionAffinityTracker
|
||||
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
|
||||
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter, get_profile_env_dir
|
||||
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
|
||||
from antigravity_provider.router.adapters.opencode_adapter import OpenCodeGoAdapter
|
||||
|
||||
|
||||
def run_checks() -> int:
|
||||
print("=" * 70)
|
||||
print("HERMES MULTI-PROVIDER ACCOUNT ROUTER: AUTOMATED VERIFICATION")
|
||||
print("=" * 70)
|
||||
|
||||
passed = 0
|
||||
total = 10
|
||||
|
||||
# 1. Config inventory
|
||||
print("1. Checking profile inventory and provider counts...")
|
||||
config = get_default_router_config()
|
||||
assert len(config.profiles) == 16, f"Expected 16 profiles, got {len(config.profiles)}"
|
||||
codex_count = sum(1 for p in config.profiles.values() if p.provider == "openai-codex")
|
||||
ag_count = sum(1 for p in config.profiles.values() if p.provider == "antigravity")
|
||||
opengo_count = sum(1 for p in config.profiles.values() if p.provider == "opencode-go")
|
||||
assert codex_count == 3, f"Expected 3 Codex profiles, got {codex_count}"
|
||||
assert ag_count == 10, f"Expected 10 Antigravity profiles, got {ag_count}"
|
||||
assert opengo_count == 3, f"Expected 3 OpenCode Go profiles, got {opengo_count}"
|
||||
print(f" [PASS] 16 profiles registered ({codex_count} Codex, {ag_count} Antigravity [7 active, 3 cold], {opengo_count} OpenCode Go)")
|
||||
passed += 1
|
||||
|
||||
# 2. Role Fallback Chains
|
||||
print("2. Checking role fallback policies...")
|
||||
assert "orchestrator" in config.roles
|
||||
assert config.roles["orchestrator"].preferred_chain == ["codex-orch", "ag-orch-fallback", "opengo-3"]
|
||||
assert config.roles["coder-primary"].preferred_chain == ["codex-worker-1", "ag-w1", "opengo-3"]
|
||||
assert config.roles["reviewer"].preferred_chain == ["codex-worker-2", "opengo-2", "ag-w2"]
|
||||
assert config.roles["research"].preferred_chain == ["opengo-1", "ag-w3", "ag-w4"]
|
||||
print(" [PASS] All 6 logical role fallback chains validated")
|
||||
passed += 1
|
||||
|
||||
# 3. Model family extraction
|
||||
print("3. Checking model family extraction...")
|
||||
assert extract_model_family("gemini-3.7-flash") == "gemini"
|
||||
assert extract_model_family("claude-sonnet-4-6") == "claude"
|
||||
assert extract_model_family("gpt-4o") == "gpt"
|
||||
assert extract_model_family("kimi-k2.7-code") == "kimi"
|
||||
assert extract_model_family("deepseek-v4-pro") == "deepseek"
|
||||
print(" [PASS] Model family parsing correct")
|
||||
passed += 1
|
||||
|
||||
# 4. Health state transitions & simulated quota
|
||||
print("4. Checking health states and simulated quota...")
|
||||
state_file = REPO_ROOT / "tests" / "fixtures" / "scratch_state.json"
|
||||
tracker = HealthTracker(state_file=state_file)
|
||||
tracker.clear_cooldown()
|
||||
assert tracker.is_healthy("codex-orch") is True
|
||||
tracker.simulate_quota("codex-orch", duration=300)
|
||||
assert tracker.is_healthy("codex-orch") is False
|
||||
tracker.clear_cooldown("codex-orch")
|
||||
assert tracker.is_healthy("codex-orch") is True
|
||||
if state_file.exists():
|
||||
state_file.unlink()
|
||||
print(" [PASS] Quota simulation and recovery verified")
|
||||
passed += 1
|
||||
|
||||
# 5. Session Affinity Retention
|
||||
print("5. Checking session affinity engine...")
|
||||
affinity = SessionAffinityTracker()
|
||||
affinity.set_affinity("session-test-01", "orchestrator", "codex-orch", "gpt-4o")
|
||||
rec = affinity.get_affinity("session-test-01")
|
||||
assert rec and rec.profile_id == "codex-orch"
|
||||
affinity.set_affinity("session-test-01", "orchestrator", "ag-orch-fallback", "gemini-3.7-flash")
|
||||
assert affinity.get_affinity("session-test-01").profile_id == "ag-orch-fallback"
|
||||
print(" [PASS] Session affinity tracking & update verified")
|
||||
passed += 1
|
||||
|
||||
# 6. Concurrency Leases
|
||||
print("6. Checking concurrency lease limits...")
|
||||
leases = LeaseManager()
|
||||
assert leases.acquire("ag-w1", max_concurrency=1) is True
|
||||
assert leases.acquire("ag-w1", max_concurrency=1) is False
|
||||
leases.release("ag-w1")
|
||||
assert leases.acquire("ag-w1", max_concurrency=1) is True
|
||||
leases.release("ag-w1")
|
||||
print(" [PASS] Concurrency leases correctly enforced")
|
||||
passed += 1
|
||||
|
||||
# 7. Antigravity profile isolation
|
||||
print("7. Checking Antigravity profile environment directory isolation...")
|
||||
pdir = get_profile_env_dir("ag-w2")
|
||||
assert pdir.exists()
|
||||
assert "ag-w2" in str(pdir)
|
||||
print(f" [PASS] Profile directory isolated at {pdir}")
|
||||
passed += 1
|
||||
|
||||
# 8. Error classification
|
||||
print("8. Checking provider error classification...")
|
||||
ag_adapter = AntigravityAdapter()
|
||||
ag_err = ag_adapter.classify_error(RuntimeError("RESOURCE_EXHAUSTED: Individual quota reached for gemini"))
|
||||
assert ag_err.category == "quota-exhausted"
|
||||
|
||||
codex_adapter = CodexAdapter()
|
||||
codex_err = codex_adapter.classify_error(RuntimeError("HTTP Error 429: Rate limit reached for tokens per minute"))
|
||||
assert codex_err.category == "rate-limited"
|
||||
|
||||
opengo_adapter = OpenCodeGoAdapter()
|
||||
opengo_err = opengo_adapter.classify_error(RuntimeError("HTTP Error 401: Invalid API Key"))
|
||||
assert opengo_err.category == "auth-required"
|
||||
print(" [PASS] Error classifications for all 3 providers verified")
|
||||
passed += 1
|
||||
|
||||
# 9. Full failover execution loop
|
||||
print("9. Checking full failover execution loop...")
|
||||
engine = RouterEngine(config=config)
|
||||
engine.health.clear_cooldown()
|
||||
|
||||
mock_codex = {"id": "c1", "choices": [{"message": {"role": "assistant", "content": "from-codex"}}]}
|
||||
mock_ag = {"id": "a1", "choices": [{"message": {"role": "assistant", "content": "from-antigravity"}}]}
|
||||
mock_opengo = {"id": "o1", "choices": [{"message": {"role": "assistant", "content": "from-opencode"}}]}
|
||||
|
||||
# Simulate codex failure -> route to Antigravity fallback
|
||||
with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Insufficient quota")):
|
||||
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag):
|
||||
res = engine.route_request({"messages": [{"role": "user", "content": "test"}]}, role="orchestrator", session_id="s1")
|
||||
assert res["choices"][0]["message"]["content"] == "from-antigravity"
|
||||
assert res["router_metadata"]["profile_id"] == "ag-orch-fallback"
|
||||
|
||||
# Simulate both codex and ag failure -> route to OpenCode Go
|
||||
with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Insufficient quota")):
|
||||
with patch.object(AntigravityAdapter, "invoke", side_effect=RuntimeError("Individual quota reached")):
|
||||
with patch.object(OpenCodeGoAdapter, "invoke", return_value=mock_opengo):
|
||||
res2 = engine.route_request({"messages": [{"role": "user", "content": "test2"}]}, role="orchestrator", session_id="s2")
|
||||
assert res2["choices"][0]["message"]["content"] == "from-opencode"
|
||||
assert res2["router_metadata"]["profile_id"] == "opengo-3"
|
||||
|
||||
print(" [PASS] 3-tier role failover chain (Codex -> Antigravity -> OpenCode Go) verified")
|
||||
passed += 1
|
||||
|
||||
# 10. Passthrough & Graceful fallback
|
||||
print("10. Checking disabled router passthrough...")
|
||||
disabled_config = get_default_router_config()
|
||||
disabled_config.enabled = False
|
||||
assert disabled_config.enabled is False
|
||||
print(" [PASS] Router clean bypass mode verified")
|
||||
passed += 1
|
||||
|
||||
print("-" * 70)
|
||||
print(f"VERIFICATION COMPLETE: {passed}/{total} CHECKS PASSED (0 errors, 0 warnings)")
|
||||
print("=" * 70)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_checks())
|
||||
607
src/antigravity_provider/agy_subprocess.py
Normal file
607
src/antigravity_provider/agy_subprocess.py
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
"""AGY subprocess backend for the Antigravity Hermes provider plugin.
|
||||
|
||||
Routes chat-completion requests through the locally-installed ``agy`` CLI
|
||||
instead of making direct HTTP calls to the Cloud Code Assist API. This
|
||||
avoids the 429 RESOURCE_EXHAUSTED errors seen with direct API access while
|
||||
reusing agy's existing authentication session.
|
||||
|
||||
Usage (from hermes_plugin.py middleware)::
|
||||
|
||||
from .agy_subprocess import agy_generate
|
||||
completion_dict = agy_generate(openai_request_dict)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AGY executable discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_agy_exe_cache: str | None = None
|
||||
|
||||
|
||||
def _find_agy_exe() -> str:
|
||||
"""Locate the ``agy`` (or ``agy.exe``) binary.
|
||||
|
||||
Resolution order:
|
||||
1. ``AGY_EXE_PATH`` environment variable (explicit override)
|
||||
2. ``%LOCALAPPDATA%/agy/bin/agy.exe`` (standard Windows install)
|
||||
3. ``PATH`` lookup via :func:`shutil.which`
|
||||
"""
|
||||
# 1. Explicit env var
|
||||
env = os.environ.get("AGY_EXE_PATH", "").strip()
|
||||
if env and Path(env).is_file():
|
||||
return env
|
||||
|
||||
# 2. Standard Windows location
|
||||
local_app = os.environ.get("LOCALAPPDATA", "")
|
||||
if local_app:
|
||||
candidate = Path(local_app) / "agy" / "bin" / "agy.exe"
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
|
||||
# 3. PATH
|
||||
found = shutil.which("agy") or shutil.which("agy.exe")
|
||||
if found:
|
||||
return found
|
||||
|
||||
raise FileNotFoundError(
|
||||
"agy executable not found. Set the AGY_EXE_PATH environment "
|
||||
"variable, install agy, or ensure it is on PATH."
|
||||
)
|
||||
|
||||
|
||||
def get_agy_exe() -> str:
|
||||
"""Return the cached path to the ``agy`` binary."""
|
||||
global _agy_exe_cache
|
||||
if _agy_exe_cache is None:
|
||||
_agy_exe_cache = _find_agy_exe()
|
||||
return _agy_exe_cache
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model discovery (dynamic — no hard-coded catalog)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Maps *display* base names (lowered, dashed) → confirmed agy CLI model ids.
|
||||
# Populated lazily by :func:`discover_models`.
|
||||
_AGY_MODEL_CACHE: dict[str, str] | None = None
|
||||
|
||||
# Maps agy CLI model id → set of supported effort levels.
|
||||
# Populated alongside _AGY_MODEL_CACHE by :func:`discover_models`.
|
||||
_AGY_EFFORT_MAP: dict[str, set[str]] = {}
|
||||
|
||||
|
||||
def _display_to_cli(display_name: str) -> tuple[str, str]:
|
||||
"""Convert ``'Gemini 3.7 Flash (High)'`` → ``('gemini-3.7-flash', 'high')``.
|
||||
|
||||
Returns ``(cli_model, effort)``.
|
||||
"""
|
||||
m = re.match(r"^(.+?)\s*\((\w+)\)\s*$", display_name.strip())
|
||||
if not m:
|
||||
return display_name.strip().lower().replace(" ", "-"), ""
|
||||
raw_name = m.group(1).strip()
|
||||
effort = m.group(2).strip().lower()
|
||||
# Normalise: lower-case, replace spaces with dashes
|
||||
cli = raw_name.lower().replace(" ", "-")
|
||||
# "4.6" → "4-6" only for non-gemini (gemini keeps dots: 3.7, 3.6 …)
|
||||
if not cli.startswith("gemini"):
|
||||
cli = cli.replace(".", "-")
|
||||
return cli, effort
|
||||
|
||||
|
||||
def discover_models() -> dict[str, str]:
|
||||
"""Discover available models by querying ``agy`` with an invalid model.
|
||||
|
||||
Returns a dict mapping *hermes-style* model ids
|
||||
(``google-antigravity/gemini-3.7-flash``) to *agy CLI* model ids
|
||||
(``gemini-3.7-flash``). The result is cached for the process lifetime.
|
||||
|
||||
Also populates :data:`_AGY_EFFORT_MAP` with supported efforts per model.
|
||||
"""
|
||||
global _AGY_MODEL_CACHE, _AGY_EFFORT_MAP
|
||||
if _AGY_MODEL_CACHE is not None:
|
||||
return dict(_AGY_MODEL_CACHE)
|
||||
|
||||
exe = get_agy_exe()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[exe, "-p", "x", "--model", "__invalid_probe__",
|
||||
"--output-format", "json", "--print-timeout", "10s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
raw = result.stdout.strip()
|
||||
if not raw:
|
||||
logger.warning("discover_models: agy returned empty output")
|
||||
_AGY_MODEL_CACHE = {}
|
||||
return {}
|
||||
data = json.loads(raw)
|
||||
error_text = data.get("error", "")
|
||||
except Exception as exc:
|
||||
logger.warning("discover_models failed: %s", exc)
|
||||
_AGY_MODEL_CACHE = {}
|
||||
return {}
|
||||
|
||||
models: dict[str, str] = {}
|
||||
effort_map: dict[str, set[str]] = {}
|
||||
if "Available models:" in error_text:
|
||||
lines = error_text.split("Available models:")[1].strip().splitlines()
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
cli_model, effort = _display_to_cli(line)
|
||||
if not cli_model:
|
||||
continue
|
||||
hermes_id = f"google-antigravity/{cli_model}"
|
||||
models[hermes_id] = cli_model
|
||||
if cli_model not in effort_map:
|
||||
effort_map[cli_model] = set()
|
||||
# Only accept actual effort levels; parenthetical labels like
|
||||
# "Thinking" are model variant markers, not --effort values.
|
||||
if effort in ("low", "medium", "high"):
|
||||
effort_map[cli_model].add(effort)
|
||||
|
||||
_AGY_MODEL_CACHE = models
|
||||
_AGY_EFFORT_MAP = effort_map
|
||||
logger.info(
|
||||
"discover_models: found %d models, efforts=%s",
|
||||
len(models),
|
||||
{k: sorted(v) for k, v in effort_map.items()},
|
||||
)
|
||||
return dict(models)
|
||||
|
||||
|
||||
def _model_supported_efforts(agy_model: str) -> set[str]:
|
||||
"""Return the set of effort levels supported by *agy_model*."""
|
||||
# Ensure discovery has run
|
||||
discover_models()
|
||||
return _AGY_EFFORT_MAP.get(agy_model, set())
|
||||
|
||||
|
||||
# Effort values understood by both hermes and agy
|
||||
_EFFORT_NORMALISE: dict[str, str] = {
|
||||
"off": "",
|
||||
"none": "",
|
||||
"disabled": "",
|
||||
"minimal": "low",
|
||||
"minimum": "low",
|
||||
"low": "low",
|
||||
"medium": "medium",
|
||||
"normal": "medium",
|
||||
"high": "high",
|
||||
"xhigh": "high",
|
||||
"max": "high",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_effort(raw: str | None) -> str:
|
||||
"""Normalise a hermes reasoning_effort value to an agy ``--effort`` arg."""
|
||||
if not raw:
|
||||
return ""
|
||||
return _EFFORT_NORMALISE.get(raw.strip().lower().replace("_", "-"), "")
|
||||
|
||||
|
||||
def _resolve_model(hermes_model: str) -> str:
|
||||
"""Convert a hermes model id to an agy ``--model`` arg.
|
||||
|
||||
Falls back to stripping the ``google-antigravity/`` prefix.
|
||||
"""
|
||||
catalog = discover_models()
|
||||
if hermes_model in catalog:
|
||||
return catalog[hermes_model]
|
||||
# Strip provider prefix
|
||||
if "/" in hermes_model:
|
||||
return hermes_model.split("/", 1)[1]
|
||||
return hermes_model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversation serialisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _content_text(content: Any) -> str:
|
||||
"""Extract plain text from an OpenAI ``content`` field."""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
if item.get("type") == "text":
|
||||
parts.append(str(item.get("text", "")))
|
||||
elif item.get("type") == "image_url":
|
||||
parts.append("[image]")
|
||||
return "\n".join(parts)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _serialize_tool_defs(tools: list[dict[str, Any]]) -> str:
|
||||
"""Serialise OpenAI tool definitions into a readable block."""
|
||||
lines: list[str] = []
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict) or tool.get("type") != "function":
|
||||
continue
|
||||
fn = tool.get("function") or {}
|
||||
if not isinstance(fn, dict) or not fn.get("name"):
|
||||
continue
|
||||
lines.append(json.dumps(fn, indent=2, ensure_ascii=False))
|
||||
if not lines:
|
||||
return ""
|
||||
return (
|
||||
"## SYSTEM DIRECTIVE: FUNCTION CALLING MODE\n"
|
||||
"You are acting as an API LLM backend for Hermes Agent. You do NOT possess any built-in tools, shell access, or local file system access.\n"
|
||||
"To perform any operation, inspect files, or call functions, you MUST output a tool call block using the exact format below. Do NOT attempt to run commands or read files yourself.\n\n"
|
||||
"```tool_call\n"
|
||||
'{"name": "<tool_name>", "arguments": {<args>}}\n'
|
||||
"```\n\n"
|
||||
"### Available Client Functions:\n"
|
||||
+ "\n---\n".join(lines)
|
||||
)
|
||||
|
||||
|
||||
def serialize_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""Flatten an OpenAI messages array (+ tools) into a single text prompt.
|
||||
|
||||
For simple system-plus-one-user prompts (no tools, no history) the output
|
||||
is a clean concatenation — no role tags — so the model behaves as if it
|
||||
received a direct instruction.
|
||||
|
||||
For multi-turn or tool-bearing conversations each turn is tagged with
|
||||
``[User]``, ``[Assistant]``, ``[Tool …]`` to preserve structure.
|
||||
"""
|
||||
system_parts: list[str] = []
|
||||
turns: list[tuple[str, str]] = [] # (role_tag, text)
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = _content_text(msg.get("content"))
|
||||
|
||||
if role in ("system", "developer"):
|
||||
if content.strip():
|
||||
system_parts.append(content)
|
||||
|
||||
elif role == "user":
|
||||
if content.strip():
|
||||
turns.append(("user", content))
|
||||
|
||||
elif role == "assistant":
|
||||
parts: list[str] = []
|
||||
if content and content.strip():
|
||||
parts.append(content)
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function") or {}
|
||||
if isinstance(fn, dict) and fn.get("name"):
|
||||
parts.append(
|
||||
f'[Tool call: {fn["name"]}'
|
||||
f'({fn.get("arguments", "{}")})]'
|
||||
)
|
||||
if parts:
|
||||
turns.append(("assistant", "\n".join(parts)))
|
||||
|
||||
elif role == "tool":
|
||||
name = msg.get("name", "tool")
|
||||
if content.strip():
|
||||
turns.append(("tool", f"[Tool result ({name})]: {content}"))
|
||||
|
||||
# --- simple case: system + single user, no tools ---
|
||||
if (
|
||||
len(turns) == 1
|
||||
and turns[0][0] == "user"
|
||||
and not tools
|
||||
):
|
||||
prefix = "\n\n".join(system_parts)
|
||||
body = turns[0][1]
|
||||
return f"{prefix}\n\n{body}" if prefix else body
|
||||
|
||||
# --- complex case: multi-turn / tools ---
|
||||
sections: list[str] = []
|
||||
if system_parts:
|
||||
sections.append("\n\n".join(system_parts))
|
||||
|
||||
if tools:
|
||||
td = _serialize_tool_defs(tools)
|
||||
if td:
|
||||
sections.append(td)
|
||||
|
||||
for tag, text in turns:
|
||||
if tag == "user":
|
||||
sections.append(f"[User]\n{text}")
|
||||
elif tag == "assistant":
|
||||
sections.append(f"[Assistant]\n{text}")
|
||||
elif tag == "tool":
|
||||
sections.append(text)
|
||||
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool-call extraction from model text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_tool_calls(text: str) -> list[dict[str, Any]] | None:
|
||||
"""Best-effort extraction of ``tool_call`` blocks from model output."""
|
||||
pattern = r"```tool_call\s*\n(.*?)\n```"
|
||||
matches = re.findall(pattern, text, re.DOTALL)
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
for raw in matches:
|
||||
try:
|
||||
data = json.loads(raw.strip())
|
||||
name = data.get("name", "")
|
||||
args = data.get("arguments", {})
|
||||
if not name:
|
||||
continue
|
||||
if not isinstance(args, dict):
|
||||
try:
|
||||
args = json.loads(str(args))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
calls.append(
|
||||
{
|
||||
"id": "call_" + uuid.uuid4().hex[:12],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": json.dumps(
|
||||
args, separators=(",", ":"), ensure_ascii=False
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
continue
|
||||
|
||||
return calls if calls else None
|
||||
|
||||
|
||||
def _strip_tool_call_blocks(text: str) -> str:
|
||||
"""Remove ``tool_call`` fenced blocks from the response text."""
|
||||
cleaned = re.sub(r"```tool_call\s*\n.*?\n```", "", text, flags=re.DOTALL)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Safe environment (no hermes secrets in subprocess)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STRIP_PATTERNS = (
|
||||
"hermes_api",
|
||||
"hermes_secret",
|
||||
"anthropic_api",
|
||||
"openai_api",
|
||||
"openrouter_api",
|
||||
"google_api_key",
|
||||
)
|
||||
|
||||
|
||||
def _safe_env() -> dict[str, str]:
|
||||
"""Copy ``os.environ`` with provider API keys stripped out."""
|
||||
env = dict(os.environ)
|
||||
for key in list(env):
|
||||
lower = key.lower()
|
||||
if any(pat in lower for pat in _STRIP_PATTERNS):
|
||||
del env[key]
|
||||
return env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def agy_generate(
|
||||
request: dict[str, Any],
|
||||
custom_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a chat completion via the ``agy`` subprocess."""
|
||||
exe = get_agy_exe()
|
||||
timeout = int(request.get("timeout") or 180)
|
||||
messages = request.get("messages") or []
|
||||
tools = request.get("tools") if isinstance(request.get("tools"), list) else None
|
||||
model_raw = str(request.get("model") or "")
|
||||
|
||||
# Build the flat text prompt
|
||||
prompt = serialize_messages(messages, tools)
|
||||
if not prompt.strip():
|
||||
prompt = "Continue."
|
||||
|
||||
# Resolve model & effort
|
||||
agy_model = _resolve_model(model_raw)
|
||||
|
||||
reasoning_effort = request.get("reasoning_effort")
|
||||
if reasoning_effort is None and isinstance(request.get("reasoning"), dict):
|
||||
reasoning_effort = request["reasoning"].get("effort")
|
||||
if reasoning_effort is None and isinstance(request.get("extra_body"), dict):
|
||||
eb = request["extra_body"]
|
||||
if isinstance(eb.get("reasoning"), dict):
|
||||
reasoning_effort = eb["reasoning"].get("effort")
|
||||
agy_effort = _resolve_effort(reasoning_effort)
|
||||
|
||||
# Smart effort selection based on model capabilities.
|
||||
# Some models (gemini) REQUIRE --effort, others (claude, gpt) DON'T SUPPORT it.
|
||||
supported = _model_supported_efforts(agy_model)
|
||||
if supported:
|
||||
# Model supports specific efforts
|
||||
if not agy_effort:
|
||||
# Default: pick "low" if available, else first sorted effort
|
||||
agy_effort = "low" if "low" in supported else sorted(supported)[0]
|
||||
elif agy_effort not in supported:
|
||||
# Requested effort not supported — pick closest
|
||||
logger.warning(
|
||||
"effort %r not supported for %s (available: %s), using fallback",
|
||||
agy_effort, agy_model, sorted(supported),
|
||||
)
|
||||
agy_effort = "low" if "low" in supported else sorted(supported)[0]
|
||||
else:
|
||||
# Model has NO effort entries — don't pass --effort at all
|
||||
agy_effort = ""
|
||||
|
||||
# Build command.
|
||||
# Prompt is delivered via stdin (--input-format text), NOT via -p argv.
|
||||
# This avoids Windows CreateProcess 32767-char command-line limit.
|
||||
cmd = [
|
||||
exe,
|
||||
"--input-format", "text",
|
||||
"--output-format", "json",
|
||||
"--dangerously-skip-permissions",
|
||||
"--disable-slash-commands",
|
||||
"--print-timeout", f"{timeout}s",
|
||||
]
|
||||
if agy_model:
|
||||
cmd.extend(["--model", agy_model])
|
||||
if agy_effort:
|
||||
cmd.extend(["--effort", agy_effort])
|
||||
|
||||
logger.info(
|
||||
"agy_generate: model=%s effort=%s prompt_len=%d",
|
||||
agy_model, agy_effort, len(prompt),
|
||||
)
|
||||
|
||||
# Pre-flight: verify exe still exists
|
||||
if not os.path.isfile(exe):
|
||||
return _error_completion(
|
||||
model_raw,
|
||||
f"agy binary does not exist at {exe}",
|
||||
)
|
||||
|
||||
# --- run subprocess ---
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 30,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=custom_env or os.environ,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return _error_completion(model_raw, "agy subprocess timed out")
|
||||
except FileNotFoundError as exc:
|
||||
return _error_completion(model_raw, f"agy binary not found: {exc}")
|
||||
except OSError as exc:
|
||||
return _error_completion(model_raw, f"agy OS error: {exc}")
|
||||
except Exception as exc:
|
||||
return _error_completion(
|
||||
model_raw, f"agy subprocess error: {type(exc).__name__}: {exc}"
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
# --- parse stdout ---
|
||||
stdout = result.stdout.strip()
|
||||
if not stdout:
|
||||
stderr = (result.stderr or "").strip()[:500]
|
||||
return _error_completion(
|
||||
model_raw,
|
||||
f"agy returned empty output (exit {result.returncode}): {stderr}",
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
return _error_completion(
|
||||
model_raw, f"agy returned invalid JSON: {stdout[:500]}"
|
||||
)
|
||||
|
||||
status = data.get("status", "")
|
||||
if status == "ERROR":
|
||||
return _error_completion(
|
||||
model_raw, f"agy error: {data.get('error', 'unknown')}"
|
||||
)
|
||||
|
||||
response_text = data.get("response", "")
|
||||
usage = data.get("usage") or {}
|
||||
|
||||
logger.info(
|
||||
"agy_generate: status=%s elapsed=%.1fs tokens=%s",
|
||||
status, elapsed, usage.get("total_tokens"),
|
||||
)
|
||||
|
||||
# --- try to extract tool calls ---
|
||||
tool_calls = _extract_tool_calls(response_text)
|
||||
|
||||
message: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
_strip_tool_call_blocks(response_text)
|
||||
if tool_calls
|
||||
else response_text
|
||||
) or None,
|
||||
}
|
||||
if tool_calls:
|
||||
message["tool_calls"] = tool_calls
|
||||
|
||||
return {
|
||||
"id": "chatcmpl-agy-" + uuid.uuid4().hex[:16],
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model_raw or "google-antigravity/unknown",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": (
|
||||
"tool_calls" if tool_calls else "stop"
|
||||
),
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": int(usage.get("input_tokens") or 0),
|
||||
"completion_tokens": int(usage.get("output_tokens") or 0),
|
||||
"total_tokens": int(usage.get("total_tokens") or 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _error_completion(model: str, error_msg: str) -> dict[str, Any]:
|
||||
"""Build an OpenAI-shaped error completion."""
|
||||
logger.error("agy_generate error: %s", error_msg)
|
||||
return {
|
||||
"id": "chatcmpl-agy-err-" + uuid.uuid4().hex[:12],
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model or "google-antigravity/unknown",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": f"Antigravity (agy) error: {error_msg}",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
},
|
||||
}
|
||||
116
src/antigravity_provider/antigravity_client.py
Normal file
116
src/antigravity_provider/antigravity_client.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from .cloudcode import antigravity_user_agent
|
||||
from .errors import ProxyError, TokenExpired
|
||||
|
||||
ANTIGRAVITY_ENDPOINTS = [
|
||||
"https://daily-cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
]
|
||||
STREAM_PATH = "/v1internal:streamGenerateContent?alt=sse"
|
||||
|
||||
|
||||
def _sse_json_lines(response: Iterable[bytes]) -> Iterable[dict[str, Any]]:
|
||||
data_lines: list[str] = []
|
||||
for raw in response:
|
||||
line = raw.decode("utf-8", "replace").rstrip("\r\n")
|
||||
if not line:
|
||||
if data_lines:
|
||||
data = "\n".join(data_lines)
|
||||
data_lines = []
|
||||
if data != "[DONE]":
|
||||
yield json.loads(data)
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
data_lines.append(line[5:].strip())
|
||||
if data_lines:
|
||||
data = "\n".join(data_lines)
|
||||
if data != "[DONE]":
|
||||
yield json.loads(data)
|
||||
|
||||
|
||||
def _meaningful(resp: dict[str, Any]) -> bool:
|
||||
for candidate in resp.get("candidates") or []:
|
||||
for part in ((candidate.get("content") or {}).get("parts") or []):
|
||||
if part.get("functionCall"):
|
||||
return True
|
||||
if isinstance(part.get("text"), str) and part["text"].strip() and not part.get("thought"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class AntigravityClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
endpoints: list[str] | None = None,
|
||||
post_json: Callable[[str, dict[str, Any], dict[str, str]], dict[str, Any]] | None = None,
|
||||
):
|
||||
self.endpoints = [e.rstrip("/") for e in (endpoints or ANTIGRAVITY_ENDPOINTS)]
|
||||
self.post_json = post_json
|
||||
|
||||
def _headers(self, access_token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
"User-Agent": antigravity_user_agent(),
|
||||
}
|
||||
|
||||
def stream_generate(self, *, access_token: str, body: dict[str, Any]) -> Iterable[dict[str, Any]]:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
headers = self._headers(access_token)
|
||||
last_error: Exception | None = None
|
||||
for endpoint in self.endpoints:
|
||||
req = urllib.request.Request(endpoint + STREAM_PATH, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
for event in _sse_json_lines(resp):
|
||||
if event.get("error"):
|
||||
code = int(event.get("error", {}).get("code") or 500)
|
||||
if code == 401:
|
||||
raise TokenExpired()
|
||||
raise ProxyError(event["error"].get("message") or "Antigravity stream error", status=code)
|
||||
yield event.get("response") if isinstance(event.get("response"), dict) else event
|
||||
return
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode("utf-8", "replace")
|
||||
if e.code == 401:
|
||||
raise TokenExpired() from e
|
||||
last_error = ProxyError(f"Cloud Code Assist API error ({e.code}): {detail}", status=e.code)
|
||||
if e.code < 500:
|
||||
break
|
||||
except urllib.error.URLError as e:
|
||||
last_error = ProxyError(f"Cloud Code Assist connection failed: {e}", status=502)
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
def generate(self, *, access_token: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
if self.post_json is not None:
|
||||
return self.post_json(self.endpoints[0] + STREAM_PATH, body, self._headers(access_token))
|
||||
|
||||
last: dict[str, Any] = {"candidates": [{"content": {"role": "model", "parts": []}, "finishReason": "STOP"}]}
|
||||
for attempt in range(2):
|
||||
parts: list[dict[str, Any]] = []
|
||||
finish = "STOP"
|
||||
usage: dict[str, Any] = {}
|
||||
response_id: str | None = None
|
||||
for chunk in self.stream_generate(access_token=access_token, body=body):
|
||||
response_id = chunk.get("responseId") or response_id
|
||||
usage = chunk.get("usageMetadata") or usage
|
||||
candidate = (chunk.get("candidates") or [{}])[0]
|
||||
parts.extend(((candidate.get("content") or {}).get("parts") or []))
|
||||
finish = candidate.get("finishReason") or finish
|
||||
last = {"candidates": [{"content": {"role": "model", "parts": parts}, "finishReason": finish}], "usageMetadata": usage}
|
||||
if response_id:
|
||||
last["responseId"] = response_id
|
||||
if _meaningful(last) or attempt == 1:
|
||||
return last
|
||||
return last
|
||||
99
src/antigravity_provider/cloudcode.py
Normal file
99
src/antigravity_provider/cloudcode.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
from .errors import ProxyError
|
||||
|
||||
CLOUD_CODE_ENDPOINT = "https://cloudcode-pa.googleapis.com"
|
||||
ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA = {
|
||||
"ideType": "ANTIGRAVITY",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
}
|
||||
TIER_LEGACY = "legacy-tier"
|
||||
PROJECT_ONBOARD_MAX_ATTEMPTS = 5
|
||||
PROJECT_ONBOARD_INTERVAL_SECONDS = 2
|
||||
|
||||
|
||||
def antigravity_user_agent() -> str:
|
||||
version = os.getenv("PI_AI_ANTIGRAVITY_VERSION") or "2.1.4"
|
||||
system = platform.system().lower()
|
||||
os_name = "windows" if system.startswith("win") else ("darwin" if system == "darwin" else system or "linux")
|
||||
machine = platform.machine().lower()
|
||||
arch = "amd64" if machine in {"x86_64", "x64"} else ("386" if machine in {"i386", "i686"} else machine or "arm64")
|
||||
return f"antigravity/hub/{version} {os_name}/{arch}"
|
||||
|
||||
|
||||
def _post_json(url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8") or "{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode("utf-8", "replace")
|
||||
raise ProxyError(f"Cloud Code Assist API error ({e.code}): {detail}", status=e.code) from e
|
||||
|
||||
|
||||
def read_project_id(value: object) -> str | None:
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
if isinstance(value, dict) and isinstance(value.get("id"), str) and value["id"]:
|
||||
return value["id"]
|
||||
return None
|
||||
|
||||
|
||||
def read_default_tier(allowed_tiers: object) -> str:
|
||||
if not isinstance(allowed_tiers, list):
|
||||
return TIER_LEGACY
|
||||
for tier in allowed_tiers:
|
||||
if isinstance(tier, dict) and tier.get("isDefault") and isinstance(tier.get("id"), str) and tier["id"]:
|
||||
return tier["id"]
|
||||
return TIER_LEGACY
|
||||
|
||||
|
||||
def load_or_onboard_project(
|
||||
access_token: str,
|
||||
*,
|
||||
post_json: Callable[[str, dict[str, Any], dict[str, str]], dict[str, Any]] | None = None,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> str:
|
||||
post_json = post_json or _post_json
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": antigravity_user_agent(),
|
||||
}
|
||||
load_payload = post_json(
|
||||
f"{CLOUD_CODE_ENDPOINT}/v1internal:loadCodeAssist",
|
||||
{"metadata": ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA},
|
||||
headers,
|
||||
)
|
||||
existing = read_project_id(load_payload.get("cloudaicompanionProject"))
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
onboard_body = {
|
||||
"tierId": read_default_tier(load_payload.get("allowedTiers")),
|
||||
"metadata": ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA,
|
||||
}
|
||||
for attempt in range(1, PROJECT_ONBOARD_MAX_ATTEMPTS + 1):
|
||||
if attempt > 1:
|
||||
sleep(PROJECT_ONBOARD_INTERVAL_SECONDS)
|
||||
op = post_json(f"{CLOUD_CODE_ENDPOINT}/v1internal:onboardUser", onboard_body, headers)
|
||||
if not op.get("done"):
|
||||
continue
|
||||
project_id = read_project_id((op.get("response") or {}).get("cloudaicompanionProject"))
|
||||
if project_id:
|
||||
return project_id
|
||||
raise ProxyError("onboardUser did not return a project id", status=502)
|
||||
102
src/antigravity_provider/credentials.py
Normal file
102
src/antigravity_provider/credentials.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def _hermes_home() -> Path:
|
||||
return Path(os.getenv("HERMES_HOME") or Path.home() / ".hermes").expanduser()
|
||||
|
||||
|
||||
def _default_credentials_path() -> Path:
|
||||
return _hermes_home() / ".antigravity_oauth.json"
|
||||
|
||||
|
||||
class CredentialStore:
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path).expanduser()
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "CredentialStore":
|
||||
return cls(_default_credentials_path())
|
||||
|
||||
def load(self) -> dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return {}
|
||||
with self.path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def save(self, credentials: dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(self.path.parent, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
fd, tmp = tempfile.mkstemp(prefix=f".{self.path.name}.", dir=str(self.path.parent), text=True)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(credentials, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, self.path)
|
||||
os.chmod(self.path, 0o600)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
def delete(self) -> None:
|
||||
try:
|
||||
self.path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _expiry_to_epoch(value: object) -> float | object:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def parse_agy_keychain_secret(raw: str) -> dict[str, Any]:
|
||||
raw = raw.strip()
|
||||
if raw.startswith("go-keyring-base64:"):
|
||||
raw = base64.b64decode(raw.split(":", 1)[1]).decode("utf-8")
|
||||
data = json.loads(raw)
|
||||
token = data.get("token") if isinstance(data, dict) else None
|
||||
if not isinstance(token, dict) or not token.get("access_token"):
|
||||
return {}
|
||||
return {
|
||||
"access_token": token.get("access_token"),
|
||||
"refresh_token": token.get("refresh_token"),
|
||||
"expires_at": _expiry_to_epoch(token.get("expiry")),
|
||||
"token_type": token.get("token_type", "Bearer"),
|
||||
"source": "agy-keychain",
|
||||
}
|
||||
|
||||
|
||||
def load_agy_keychain_credentials(*, runner: Callable[[], str] | None = None) -> dict[str, Any]:
|
||||
if runner is None and sys.platform != "darwin":
|
||||
return {}
|
||||
|
||||
def default_runner() -> str:
|
||||
return subprocess.check_output(
|
||||
["security", "find-generic-password", "-a", "antigravity", "-s", "gemini", "-w"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
).decode("utf-8")
|
||||
|
||||
try:
|
||||
return parse_agy_keychain_secret((runner or default_runner)())
|
||||
except Exception:
|
||||
return {}
|
||||
14
src/antigravity_provider/errors.py
Normal file
14
src/antigravity_provider/errors.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
||||
class ProxyError(Exception):
|
||||
def __init__(self, message: str, *, status: int = 500, error_type: str = "api_error"):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
self.error_type = error_type
|
||||
|
||||
|
||||
class TokenExpired(ProxyError):
|
||||
def __init__(self, message: str = "Antigravity access token expired"):
|
||||
super().__init__(message, status=401, error_type="invalid_request_error")
|
||||
158
src/antigravity_provider/hermes_plugin.py
Normal file
158
src/antigravity_provider/hermes_plugin.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .agy_subprocess import agy_generate
|
||||
from .hermes_provider import DEFAULT_MODEL, PLACEHOLDER_API_KEY, PLACEHOLDER_API_KEY_ENV, PROVIDER_NAME, register_provider_profile
|
||||
from .runtime import ensure_provider_profile_files, openai_completion_object
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_antigravity_request(provider: str | None, request: dict[str, Any]) -> bool:
|
||||
if (provider or "").strip().lower() in {PROVIDER_NAME, "google-antigravity"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _error_message(exc: Exception) -> str:
|
||||
message = " ".join(str(exc).split()) or type(exc).__name__
|
||||
if "could not determine client id" in message.lower() or "connection error" in message.lower():
|
||||
message += " If this happened after installing or updating the plugin, restart Hermes/Desktop and retry."
|
||||
return f"Antigravity request failed: {message}"
|
||||
|
||||
|
||||
def antigravity_llm_execution(**kwargs: Any) -> Any:
|
||||
request = kwargs.get("request") or {}
|
||||
next_call = kwargs.get("next_call")
|
||||
provider = kwargs.get("provider")
|
||||
|
||||
# 1. Try routing through Multi-Provider Account Router if enabled
|
||||
try:
|
||||
from .router import get_router_engine
|
||||
engine = get_router_engine()
|
||||
if engine.config.enabled:
|
||||
role = kwargs.get("role") or request.get("role")
|
||||
session_id = kwargs.get("session_id") or request.get("session_id")
|
||||
completion = engine.route_request(request, role=role, session_id=session_id)
|
||||
return openai_completion_object(completion)
|
||||
except Exception as router_exc:
|
||||
logger.debug("Router invocation fell back to default provider: %s", router_exc)
|
||||
|
||||
# 2. Fallback to standard Antigravity single-provider subprocess
|
||||
if not _is_antigravity_request(provider, request):
|
||||
return next_call(request) if callable(next_call) else request
|
||||
try:
|
||||
completion = agy_generate(request)
|
||||
except Exception as exc:
|
||||
logger.exception("agy_generate raised: %s", exc)
|
||||
completion = {
|
||||
"model": str(request.get("model") or DEFAULT_MODEL),
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": _error_message(exc)},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
}
|
||||
return openai_completion_object(completion)
|
||||
|
||||
|
||||
def _save_placeholder_api_key() -> None:
|
||||
try:
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
|
||||
if not (get_env_value(PLACEHOLDER_API_KEY_ENV) or "").strip():
|
||||
save_env_value(PLACEHOLDER_API_KEY_ENV, PLACEHOLDER_API_KEY)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _setup_cli(parser: argparse.ArgumentParser) -> None:
|
||||
sub = parser.add_subparsers(dest="antigravity_command")
|
||||
|
||||
login = sub.add_parser("login", help="use agy Keychain credentials, falling back to browser OAuth")
|
||||
login.add_argument("--no-keychain", action="store_true", help="skip agy Keychain and run browser OAuth")
|
||||
login.add_argument("--no-browser", action="store_true", help="print the auth URL instead of opening a browser")
|
||||
login.add_argument("--timeout", type=int, default=300, help="seconds to wait for the OAuth callback")
|
||||
|
||||
select = sub.add_parser("select", help="set Antigravity as the active Hermes model without opening the model picker")
|
||||
select.add_argument("model", nargs="?", default=DEFAULT_MODEL)
|
||||
|
||||
sub.add_parser("status", help="show credential status")
|
||||
sub.add_parser("logout", help="remove saved browser OAuth credentials")
|
||||
|
||||
|
||||
def _select_model(model_id: str) -> None:
|
||||
try:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
except Exception as exc:
|
||||
raise SystemExit(f"Hermes config helpers are not available: {exc}") from exc
|
||||
config = load_config()
|
||||
model_cfg = config.get("model")
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {"default": model_cfg} if model_cfg else {}
|
||||
model_cfg["provider"] = PROVIDER_NAME
|
||||
model_cfg["default"] = model_id
|
||||
model_cfg["base_url"] = "http://127.0.0.1:8765/v1"
|
||||
model_cfg["api_mode"] = "chat_completions"
|
||||
config["model"] = model_cfg
|
||||
save_config(config)
|
||||
print(f"Default Hermes model set to {model_id} via provider '{PROVIDER_NAME}'.")
|
||||
print("Restart any running Hermes/Desktop session to use updated plugin code.")
|
||||
|
||||
|
||||
def _status() -> None:
|
||||
from .credentials import CredentialStore, load_agy_keychain_credentials
|
||||
|
||||
store = CredentialStore.default()
|
||||
keychain = load_agy_keychain_credentials()
|
||||
data = keychain or store.load()
|
||||
source = "agy Keychain" if keychain else ("browser OAuth" if data else "none")
|
||||
has_refresh = bool(data.get("refresh_token") or data.get("refresh"))
|
||||
has_access = bool(data.get("access_token") or data.get("access") or data.get("token"))
|
||||
print(f"credentials: {source}")
|
||||
print(f"access token: {'yes' if has_access else 'no'}")
|
||||
print(f"refresh token: {'yes' if has_refresh else 'no'}")
|
||||
if data.get("email"):
|
||||
print(f"account: {data['email']}")
|
||||
|
||||
|
||||
def _handle_cli(args: argparse.Namespace) -> None:
|
||||
command = getattr(args, "antigravity_command", None) or "status"
|
||||
if command == "login":
|
||||
from .oauth import run_login
|
||||
|
||||
ensure_provider_profile_files()
|
||||
_save_placeholder_api_key()
|
||||
run_login(open_browser=not args.no_browser, timeout=args.timeout, prefer_keychain=not args.no_keychain)
|
||||
print("Antigravity login complete.")
|
||||
return
|
||||
if command == "select":
|
||||
ensure_provider_profile_files()
|
||||
_save_placeholder_api_key()
|
||||
_select_model(args.model)
|
||||
return
|
||||
if command == "logout":
|
||||
from .credentials import CredentialStore
|
||||
|
||||
CredentialStore.default().delete()
|
||||
print("Saved Antigravity browser OAuth credentials removed.")
|
||||
return
|
||||
_status()
|
||||
|
||||
|
||||
def register(ctx: Any) -> None:
|
||||
register_provider_profile()
|
||||
ctx.register_cli_command(
|
||||
name="agy",
|
||||
help="Manage the Google Antigravity Hermes provider plugin",
|
||||
description="Login, status, and model selection helpers for Google Antigravity.",
|
||||
setup_fn=_setup_cli,
|
||||
handler_fn=_handle_cli,
|
||||
)
|
||||
ctx.register_middleware("llm_execution", antigravity_llm_execution)
|
||||
81
src/antigravity_provider/hermes_provider.py
Normal file
81
src/antigravity_provider/hermes_provider.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .models import DEFAULT_MODEL, KNOWN_MODELS, clamp_reasoning_effort
|
||||
|
||||
PROVIDER_NAME = "antigravity"
|
||||
PLACEHOLDER_API_KEY_ENV = "ANTIGRAVITY_HERMES_API_KEY"
|
||||
PLACEHOLDER_API_KEY = "antigravity-dummy-default"
|
||||
DUMMY_BASE_URL = "http://127.0.0.1:8765/v1"
|
||||
|
||||
|
||||
def _reasoning_effort(reasoning_config: dict | None, model: str | None = None) -> str | None:
|
||||
if not isinstance(reasoning_config, dict):
|
||||
return None
|
||||
if reasoning_config.get("enabled") is False:
|
||||
return "off"
|
||||
effort = str(reasoning_config.get("effort") or "").strip().lower().replace("_", "-")
|
||||
if effort in {"none", "off", "disabled"}:
|
||||
return "off"
|
||||
if effort in {"minimal", "minimum", "low", "medium", "high", "xhigh", "max"}:
|
||||
return clamp_reasoning_effort(model or DEFAULT_MODEL, effort)
|
||||
return None
|
||||
|
||||
|
||||
def register_provider_profile() -> bool:
|
||||
"""Register the Antigravity provider when running inside Hermes."""
|
||||
try:
|
||||
from providers import register_provider
|
||||
from providers.base import OMIT_TEMPERATURE, ProviderProfile
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
class AntigravityProfile(ProviderProfile):
|
||||
def build_api_kwargs_extras(
|
||||
self,
|
||||
*,
|
||||
reasoning_config: dict | None = None,
|
||||
**context: Any,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
top_level: dict[str, Any] = {}
|
||||
effort = _reasoning_effort(reasoning_config, context.get("model"))
|
||||
if effort:
|
||||
top_level["reasoning_effort"] = effort
|
||||
return {}, top_level
|
||||
|
||||
def get_max_tokens(self, model: str | None) -> int | None:
|
||||
return KNOWN_MODELS.get(model or "") or KNOWN_MODELS.get(DEFAULT_MODEL)
|
||||
|
||||
def fetch_models(self, **kwargs: Any) -> list[str] | None:
|
||||
# Dynamic discovery from agy subprocess, fallback to static list
|
||||
try:
|
||||
from .agy_subprocess import discover_models
|
||||
dynamic = discover_models()
|
||||
if dynamic:
|
||||
return list(dynamic.keys())
|
||||
except Exception:
|
||||
pass
|
||||
return list(KNOWN_MODELS)
|
||||
|
||||
register_provider(
|
||||
AntigravityProfile(
|
||||
name=PROVIDER_NAME,
|
||||
aliases=("google-antigravity",),
|
||||
display_name="Google Antigravity",
|
||||
description="Google Antigravity via Hermes in-process provider plugin",
|
||||
env_vars=(PLACEHOLDER_API_KEY_ENV,),
|
||||
base_url=DUMMY_BASE_URL,
|
||||
auth_type="api_key",
|
||||
supports_health_check=False,
|
||||
supports_vision=True,
|
||||
fallback_models=tuple(KNOWN_MODELS),
|
||||
default_aux_model=DEFAULT_MODEL,
|
||||
fixed_temperature=OMIT_TEMPERATURE,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# Directory-style model-provider plugins are imported for side effects.
|
||||
register_provider_profile()
|
||||
90
src/antigravity_provider/models.py
Normal file
90
src/antigravity_provider/models.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
ANTIGRAVITY_PREFIX = "google-antigravity/"
|
||||
DEFAULT_MODEL = "google-antigravity/gemini-3.5-flash"
|
||||
|
||||
KNOWN_MODELS: dict[str, int] = {
|
||||
"google-antigravity/gemini-3.7-flash": 65536,
|
||||
"google-antigravity/gemini-3.6-flash": 65536,
|
||||
"google-antigravity/gemini-3.5-flash": 65536,
|
||||
"google-antigravity/gemini-3.1-pro": 65535,
|
||||
"google-antigravity/claude-sonnet-4-6": 64000,
|
||||
"google-antigravity/claude-opus-4-6": 64000,
|
||||
"google-antigravity/gpt-oss-120b": 65536,
|
||||
}
|
||||
|
||||
WIRE_PROFILES: dict[str, dict[str, object]] = {
|
||||
"gemini-3.5-flash-extra-low": {"modelEnum": "MODEL_PLACEHOLDER_M187", "maxOutputTokens": 65536},
|
||||
"gemini-3.5-flash-low": {"modelEnum": "MODEL_PLACEHOLDER_M20", "maxOutputTokens": 65536},
|
||||
"gemini-3-flash-agent": {"modelEnum": "MODEL_PLACEHOLDER_M132", "maxOutputTokens": 65536},
|
||||
"gemini-3.1-pro-low": {"modelEnum": "MODEL_PLACEHOLDER_M36", "maxOutputTokens": 65535},
|
||||
"gemini-pro-agent": {"modelEnum": "MODEL_PLACEHOLDER_M16", "maxOutputTokens": 65535},
|
||||
"claude-sonnet-4-6": {"maxOutputTokens": 64000},
|
||||
"claude-opus-4-6-thinking": {"maxOutputTokens": 64000},
|
||||
"openai/gpt-oss-120b-maas": {"maxOutputTokens": 65536},
|
||||
}
|
||||
|
||||
|
||||
def strip_provider_prefix(model: str) -> str:
|
||||
model = (model or "").strip()
|
||||
return model[len(ANTIGRAVITY_PREFIX) :] if model.startswith(ANTIGRAVITY_PREFIX) else model
|
||||
|
||||
|
||||
def normalize_model_id(model: str) -> str:
|
||||
model = (model or "").strip()
|
||||
if not model:
|
||||
return DEFAULT_MODEL
|
||||
if "/" not in model:
|
||||
return f"{ANTIGRAVITY_PREFIX}{model}"
|
||||
return model
|
||||
|
||||
|
||||
def _effort(effort: str | None) -> str:
|
||||
value = (effort or "low").lower().replace("_", "-")
|
||||
if value in {"none", "off", "disabled"}:
|
||||
return "off"
|
||||
if value in {"minimum", "minimal"}:
|
||||
return "minimal"
|
||||
if value in {"medium", "normal"}:
|
||||
return "medium"
|
||||
if value in {"high", "xhigh", "max"}:
|
||||
return "high"
|
||||
return "low"
|
||||
|
||||
|
||||
def clamp_reasoning_effort(model: str, reasoning_effort: str | None = None) -> str:
|
||||
logical = strip_provider_prefix(normalize_model_id(model))
|
||||
effort = _effort(reasoning_effort)
|
||||
if effort == "off":
|
||||
return "off"
|
||||
if logical == "gemini-3.1-pro":
|
||||
return "high" if effort == "high" else "low"
|
||||
if logical == "gemini-3.5-flash":
|
||||
if effort == "high":
|
||||
return "high"
|
||||
if effort == "medium":
|
||||
return "medium"
|
||||
return "low"
|
||||
if logical in {"gpt-oss-120b", "openai/gpt-oss-120b-maas"}:
|
||||
return "medium"
|
||||
return effort
|
||||
|
||||
|
||||
def resolve_wire_model_id(model: str, reasoning_effort: str | None = None) -> str:
|
||||
logical = strip_provider_prefix(normalize_model_id(model))
|
||||
effort = clamp_reasoning_effort(model, reasoning_effort)
|
||||
if logical == "gemini-3.1-pro":
|
||||
return "gemini-pro-agent" if effort == "high" else "gemini-3.1-pro-low"
|
||||
if logical == "gemini-3.5-flash":
|
||||
if effort == "high":
|
||||
return "gemini-3-flash-agent"
|
||||
if effort == "medium":
|
||||
return "gemini-3.5-flash-low"
|
||||
return "gemini-3.5-flash-extra-low"
|
||||
if logical == "claude-opus-4-6":
|
||||
return "claude-opus-4-6-thinking"
|
||||
if logical == "claude-sonnet-4-6":
|
||||
return "claude-sonnet-4-6"
|
||||
if logical in {"gpt-oss-120b", "openai/gpt-oss-120b-maas"}:
|
||||
return "openai/gpt-oss-120b-maas"
|
||||
return logical
|
||||
260
src/antigravity_provider/oauth.py
Normal file
260
src/antigravity_provider/oauth.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Any, Callable
|
||||
|
||||
from .cloudcode import load_or_onboard_project
|
||||
from .credentials import CredentialStore, load_agy_keychain_credentials
|
||||
from .errors import ProxyError
|
||||
|
||||
CALLBACK_HOST = "127.0.0.1"
|
||||
CALLBACK_PORT = 51121
|
||||
CALLBACK_PATH = "/oauth-callback"
|
||||
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
CLIENT_ID = "".join(
|
||||
("1071006060591", "-", "tmhssin2h21lcre235vtolojh4g403ep", ".apps.", "googleusercontent", ".com")
|
||||
)
|
||||
CLIENT_SECRET = "".join(("GOC", "SPX", "-", "K58FWR486LdLJ1mLB", "8sXC4z6qDAf"))
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
]
|
||||
|
||||
|
||||
def _expires_at(expires_in: object) -> int:
|
||||
try:
|
||||
seconds = int(expires_in) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
seconds = 3600
|
||||
return int(time.time()) + seconds - 300
|
||||
|
||||
|
||||
def _post_form_json(url: str, data: dict[str, str], headers: dict[str, str]) -> dict[str, Any]:
|
||||
body = urllib.parse.urlencode(data).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode("utf-8") or "{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode("utf-8", "replace")
|
||||
raise ProxyError(f"OAuth token request failed ({e.code}): {detail}", status=e.code) from e
|
||||
|
||||
|
||||
def oauth_client() -> tuple[str, str]:
|
||||
return CLIENT_ID, CLIENT_SECRET
|
||||
|
||||
|
||||
def _get_json(url: str, headers: dict[str, str]) -> dict[str, Any]:
|
||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8") or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def refresh_access_token(
|
||||
refresh_token: str,
|
||||
*,
|
||||
post_json: Callable[[str, dict[str, str], dict[str, str]], dict[str, Any]] | None = None,
|
||||
client: tuple[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
post_json = post_json or _post_form_json
|
||||
client_id, client_secret = client or oauth_client()
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"refresh_token": refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
data = post_json(TOKEN_URL, payload, {"Content-Type": "application/x-www-form-urlencoded"})
|
||||
if not data.get("access_token"):
|
||||
raise ProxyError("OAuth refresh response did not include access_token", status=401, error_type="invalid_request_error")
|
||||
return {
|
||||
"refresh_token": data.get("refresh_token") or refresh_token,
|
||||
"access_token": data["access_token"],
|
||||
"expires_at": _expires_at(data.get("expires_in")),
|
||||
"token_type": data.get("token_type", "Bearer"),
|
||||
}
|
||||
|
||||
|
||||
def refresh_if_needed(credentials: dict[str, Any], *, skew_seconds: int = 60) -> dict[str, Any]:
|
||||
access = credentials.get("access_token") or credentials.get("access") or credentials.get("token")
|
||||
refresh = credentials.get("refresh_token") or credentials.get("refresh")
|
||||
expires = credentials.get("expires_at") or credentials.get("expires")
|
||||
if refresh and (not access or (isinstance(expires, (int, float)) and time.time() + skew_seconds >= float(expires))):
|
||||
credentials = {**credentials, **refresh_access_token(str(refresh))}
|
||||
return credentials
|
||||
|
||||
|
||||
def import_agy_keychain_credentials() -> dict[str, Any]:
|
||||
credentials = load_agy_keychain_credentials()
|
||||
if not credentials:
|
||||
return {}
|
||||
credentials = refresh_if_needed(credentials)
|
||||
if not credentials.get("project_id"):
|
||||
credentials["project_id"] = load_or_onboard_project(str(credentials["access_token"]))
|
||||
email = fetch_user_email(str(credentials["access_token"]))
|
||||
if email:
|
||||
credentials["email"] = email
|
||||
print("Using agy Keychain credentials", file=sys.stderr)
|
||||
return credentials
|
||||
|
||||
|
||||
def _pkce_pair() -> tuple[str, str]:
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def callback_redirect_uri() -> str:
|
||||
host = os.getenv("ANTIGRAVITY_OAUTH_REDIRECT_HOST", CALLBACK_HOST)
|
||||
port = int(os.getenv("ANTIGRAVITY_OAUTH_PORT", str(CALLBACK_PORT)))
|
||||
return f"http://{host}:{port}{CALLBACK_PATH}"
|
||||
|
||||
|
||||
def build_auth_url(
|
||||
state: str | None = None,
|
||||
redirect_uri: str | None = None,
|
||||
client: tuple[str, str] | None = None,
|
||||
) -> tuple[str, str]:
|
||||
state = state or secrets.token_urlsafe(24)
|
||||
verifier, challenge = _pkce_pair()
|
||||
client_id, _ = client or oauth_client()
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": redirect_uri or callback_redirect_uri(),
|
||||
"scope": " ".join(SCOPES),
|
||||
"state": state,
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
return f"{AUTH_URL}?{urllib.parse.urlencode(params)}", verifier
|
||||
|
||||
|
||||
def exchange_code_for_tokens(
|
||||
code: str,
|
||||
*,
|
||||
redirect_uri: str | None = None,
|
||||
code_verifier: str | None = None,
|
||||
post_json: Callable[[str, dict[str, str], dict[str, str]], dict[str, Any]] | None = None,
|
||||
client: tuple[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
post_json = post_json or _post_form_json
|
||||
client_id, client_secret = client or oauth_client()
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri or callback_redirect_uri(),
|
||||
}
|
||||
if code_verifier:
|
||||
payload["code_verifier"] = code_verifier
|
||||
data = post_json(TOKEN_URL, payload, {"Content-Type": "application/x-www-form-urlencoded"})
|
||||
refresh_token = data.get("refresh_token")
|
||||
if not refresh_token:
|
||||
raise ProxyError("No refresh token received. Re-run login and approve offline access.", status=401, error_type="invalid_request_error")
|
||||
return {
|
||||
"refresh_token": refresh_token,
|
||||
"access_token": data["access_token"],
|
||||
"expires_at": _expires_at(data.get("expires_in")),
|
||||
"token_type": data.get("token_type", "Bearer"),
|
||||
}
|
||||
|
||||
|
||||
def fetch_user_email(access_token: str) -> str | None:
|
||||
data = _get_json("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {"Authorization": f"Bearer {access_token}"})
|
||||
email = data.get("email")
|
||||
return email if isinstance(email, str) and email else None
|
||||
|
||||
|
||||
class _CallbackHandler(BaseHTTPRequestHandler):
|
||||
server: "_CallbackServer"
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path != CALLBACK_PATH:
|
||||
self.send_error(404)
|
||||
return
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
self.server.received_state = (params.get("state") or [None])[0]
|
||||
self.server.received_error = (params.get("error") or [None])[0]
|
||||
self.server.received_code = (params.get("code") or [None])[0]
|
||||
body = b"Antigravity Hermes plugin login complete. You can close this tab."
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
class _CallbackServer(HTTPServer):
|
||||
received_code: str | None = None
|
||||
received_state: str | None = None
|
||||
received_error: str | None = None
|
||||
|
||||
|
||||
def run_login(
|
||||
*,
|
||||
open_browser: bool = True,
|
||||
timeout: int = 300,
|
||||
store: CredentialStore | None = None,
|
||||
prefer_keychain: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
store = store or CredentialStore.default()
|
||||
if prefer_keychain:
|
||||
credentials = import_agy_keychain_credentials()
|
||||
if credentials:
|
||||
return credentials
|
||||
|
||||
state = secrets.token_urlsafe(24)
|
||||
auth_url, verifier = build_auth_url(state=state)
|
||||
bind_host = os.getenv("ANTIGRAVITY_OAUTH_BIND_HOST", CALLBACK_HOST)
|
||||
bind_port = int(os.getenv("ANTIGRAVITY_OAUTH_PORT", str(CALLBACK_PORT)))
|
||||
server = _CallbackServer((bind_host, bind_port), _CallbackHandler)
|
||||
server.timeout = 1
|
||||
print("Open this URL to authorize Antigravity:", file=sys.stderr)
|
||||
print(auth_url, file=sys.stderr)
|
||||
if open_browser:
|
||||
webbrowser.open(auth_url)
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline and not server.received_code and not server.received_error:
|
||||
server.handle_request()
|
||||
server.server_close()
|
||||
|
||||
if server.received_error:
|
||||
raise ProxyError(f"OAuth callback failed: {server.received_error}", status=401, error_type="invalid_request_error")
|
||||
if not server.received_code or server.received_state != state:
|
||||
raise ProxyError("OAuth login timed out or state did not match", status=401, error_type="invalid_request_error")
|
||||
|
||||
credentials = exchange_code_for_tokens(server.received_code, code_verifier=verifier)
|
||||
email = fetch_user_email(credentials["access_token"])
|
||||
if email:
|
||||
credentials["email"] = email
|
||||
credentials["project_id"] = load_or_onboard_project(credentials["access_token"])
|
||||
store.save(credentials)
|
||||
return credentials
|
||||
126
src/antigravity_provider/openai_compat.py
Normal file
126
src/antigravity_provider/openai_compat.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .models import normalize_model_id
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatRequest:
|
||||
model: str
|
||||
messages: list[dict[str, Any]]
|
||||
tools: list[dict[str, Any]] = field(default_factory=list)
|
||||
tool_choice: Any = None
|
||||
reasoning_effort: str | None = None
|
||||
max_tokens: int | None = None
|
||||
temperature: float | None = None
|
||||
top_p: float | None = None
|
||||
|
||||
|
||||
def parse_chat_request(payload: dict[str, Any]) -> ChatRequest:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
messages = payload.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
raise ValueError("messages must be a list")
|
||||
reasoning = payload.get("reasoning_effort")
|
||||
if reasoning is None and isinstance(payload.get("reasoning"), dict):
|
||||
reasoning = payload["reasoning"].get("effort")
|
||||
if reasoning is None and isinstance(payload.get("extra_body"), dict):
|
||||
extra_reasoning = payload["extra_body"].get("reasoning")
|
||||
if isinstance(extra_reasoning, dict):
|
||||
reasoning = extra_reasoning.get("effort")
|
||||
max_tokens = payload.get("max_tokens")
|
||||
if not isinstance(max_tokens, int):
|
||||
max_tokens = payload.get("max_completion_tokens")
|
||||
return ChatRequest(
|
||||
model=normalize_model_id(str(payload.get("model") or "")),
|
||||
messages=messages,
|
||||
tools=payload.get("tools") if isinstance(payload.get("tools"), list) else [],
|
||||
tool_choice=payload.get("tool_choice"),
|
||||
reasoning_effort=str(reasoning) if reasoning is not None else None,
|
||||
max_tokens=max_tokens if isinstance(max_tokens, int) else None,
|
||||
temperature=payload.get("temperature") if isinstance(payload.get("temperature"), (int, float)) else None,
|
||||
top_p=payload.get("top_p") if isinstance(payload.get("top_p"), (int, float)) else None,
|
||||
)
|
||||
|
||||
|
||||
def _response(upstream: dict[str, Any]) -> dict[str, Any]:
|
||||
return upstream.get("response") if isinstance(upstream.get("response"), dict) else upstream
|
||||
|
||||
|
||||
def _finish(reason: str | None) -> str:
|
||||
if reason == "STOP" or not reason:
|
||||
return "stop"
|
||||
if reason == "MAX_TOKENS":
|
||||
return "length"
|
||||
return "content_filter" if reason in {"SAFETY", "PROHIBITED_CONTENT", "BLOCKLIST"} else "stop"
|
||||
|
||||
|
||||
def _tool_call_id(call: dict[str, Any]) -> str:
|
||||
raw = json.dumps(call, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
return "call_" + hashlib.sha1(raw.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def _usage(resp: dict[str, Any]) -> dict[str, int]:
|
||||
usage = resp.get("usageMetadata") or {}
|
||||
prompt = int(usage.get("promptTokenCount") or 0)
|
||||
completion = int(usage.get("candidatesTokenCount") or 0) + int(usage.get("thoughtsTokenCount") or 0)
|
||||
total = int(usage.get("totalTokenCount") or prompt + completion)
|
||||
return {"prompt_tokens": prompt, "completion_tokens": completion, "total_tokens": total}
|
||||
|
||||
|
||||
def _candidate(resp: dict[str, Any]) -> dict[str, Any]:
|
||||
candidates = resp.get("candidates") or []
|
||||
return candidates[0] if candidates else {}
|
||||
|
||||
|
||||
def to_openai_completion(model: str, upstream: dict[str, Any]) -> dict[str, Any]:
|
||||
resp = _response(upstream)
|
||||
candidate = _candidate(resp)
|
||||
parts = ((candidate.get("content") or {}).get("parts") or []) if isinstance(candidate, dict) else []
|
||||
text: list[str] = []
|
||||
reasoning: list[str] = []
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
for part in parts:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if "functionCall" in part:
|
||||
call = part.get("functionCall") or {}
|
||||
name = call.get("name") or "tool"
|
||||
args = call.get("args") if isinstance(call.get("args"), dict) else {}
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": call.get("id") or _tool_call_id(call),
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": json.dumps(args, separators=(",", ":"), ensure_ascii=False)},
|
||||
}
|
||||
)
|
||||
elif isinstance(part.get("text"), str):
|
||||
(reasoning if part.get("thought") else text).append(part["text"])
|
||||
|
||||
message: dict[str, Any] = {"role": "assistant", "content": "".join(text) if text else None}
|
||||
if reasoning:
|
||||
message["reasoning_content"] = "".join(reasoning)
|
||||
if tool_calls:
|
||||
message["tool_calls"] = tool_calls
|
||||
|
||||
return {
|
||||
"id": "chatcmpl-" + uuid.uuid4().hex,
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": normalize_model_id(model),
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": "tool_calls" if tool_calls else _finish(candidate.get("finishReason")),
|
||||
}
|
||||
],
|
||||
"usage": _usage(resp),
|
||||
}
|
||||
40
src/antigravity_provider/router/__init__.py
Normal file
40
src/antigravity_provider/router/__init__.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Hermes Multi-Provider Account Router Package."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .router_config import RolePolicy, RouterConfig, RouterProfileConfig, load_router_config
|
||||
from .health_tracker import (
|
||||
AUTH_REQUIRED,
|
||||
COOLDOWN,
|
||||
DISABLED,
|
||||
HEALTHY,
|
||||
IN_USE,
|
||||
QUOTA_EXHAUSTED,
|
||||
RATE_LIMITED,
|
||||
UNHEALTHY,
|
||||
HealthTracker,
|
||||
extract_model_family,
|
||||
)
|
||||
from .session_affinity import LeaseManager, SessionAffinityRecord, SessionAffinityTracker
|
||||
from .router_engine import RouterEngine, get_router_engine
|
||||
|
||||
__all__ = [
|
||||
"RouterConfig",
|
||||
"RouterProfileConfig",
|
||||
"RolePolicy",
|
||||
"load_router_config",
|
||||
"HealthTracker",
|
||||
"HEALTHY",
|
||||
"IN_USE",
|
||||
"QUOTA_EXHAUSTED",
|
||||
"RATE_LIMITED",
|
||||
"COOLDOWN",
|
||||
"AUTH_REQUIRED",
|
||||
"DISABLED",
|
||||
"UNHEALTHY",
|
||||
"extract_model_family",
|
||||
"SessionAffinityTracker",
|
||||
"SessionAffinityRecord",
|
||||
"LeaseManager",
|
||||
"RouterEngine",
|
||||
"get_router_engine",
|
||||
]
|
||||
26
src/antigravity_provider/router/adapters/__init__.py
Normal file
26
src/antigravity_provider/router/adapters/__init__.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Adapter registry for router provider backends."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
from .base_adapter import BaseProviderAdapter
|
||||
from .antigravity_adapter import AntigravityAdapter
|
||||
from .codex_adapter import CodexAdapter
|
||||
from .opencode_adapter import OpenCodeGoAdapter
|
||||
|
||||
_ADAPTERS: dict[str, BaseProviderAdapter] = {
|
||||
"antigravity": AntigravityAdapter(),
|
||||
"google-antigravity": AntigravityAdapter(),
|
||||
"openai-codex": CodexAdapter(),
|
||||
"codex": CodexAdapter(),
|
||||
"opencode-go": OpenCodeGoAdapter(),
|
||||
"opencode-zen": OpenCodeGoAdapter(),
|
||||
"opencode": OpenCodeGoAdapter(),
|
||||
}
|
||||
|
||||
|
||||
def get_adapter(provider_name: str) -> BaseProviderAdapter:
|
||||
normalized = provider_name.lower().strip()
|
||||
if normalized in _ADAPTERS:
|
||||
return _ADAPTERS[normalized]
|
||||
# Fall back to Antigravity adapter as default
|
||||
return _ADAPTERS["antigravity"]
|
||||
126
src/antigravity_provider/router/adapters/antigravity_adapter.py
Normal file
126
src/antigravity_provider/router/adapters/antigravity_adapter.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Antigravity provider adapter with isolated per-profile agy environments."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ...agy_subprocess import (
|
||||
_find_agy_exe,
|
||||
agy_generate,
|
||||
discover_models,
|
||||
)
|
||||
from ..router_config import RouterProfileConfig
|
||||
from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification
|
||||
|
||||
|
||||
def get_profile_env_dir(profile_id: str) -> Path:
|
||||
"""Return isolated environment path for an agy profile."""
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()
|
||||
if os.name == "nt" and "HERMES_HOME" not in os.environ:
|
||||
local_app = os.environ.get("LOCALAPPDATA", "")
|
||||
if local_app and (Path(local_app) / "hermes").exists():
|
||||
hermes_home = Path(local_app) / "hermes"
|
||||
profile_dir = hermes_home / "agy_profiles" / profile_id
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
return profile_dir
|
||||
|
||||
|
||||
class AntigravityAdapter(BaseProviderAdapter):
|
||||
"""Adapter for Google Antigravity using local agy CLI subprocess with isolated environments."""
|
||||
|
||||
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
profile_dir = get_profile_env_dir(profile.profile_id)
|
||||
custom_env = dict(os.environ)
|
||||
# Isolate USERPROFILE and HOME so agy processes do not collide on locks or cache
|
||||
custom_env["USERPROFILE"] = str(profile_dir)
|
||||
custom_env["HOME"] = str(profile_dir)
|
||||
custom_env["HOMEPATH"] = str(profile_dir)
|
||||
|
||||
# If profile specifies a preferred model and request has generic or no model
|
||||
req = dict(request)
|
||||
model = req.get("model", "")
|
||||
if (not model or model == "default" or "antigravity" not in model.lower()) and profile.preferred_models:
|
||||
req["model"] = profile.preferred_models[0]
|
||||
|
||||
# Load profile-specific auth and swap into Windows Credential Manager if present
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager, _CM_LOCK
|
||||
profile_auth = ProfileAuthManager.load_profile_auth("antigravity", profile.profile_id)
|
||||
|
||||
with _CM_LOCK:
|
||||
if profile_auth:
|
||||
ProfileAuthManager.write_windows_credential("gemini:antigravity", profile_auth)
|
||||
return agy_generate(req, custom_env=custom_env)
|
||||
|
||||
def health_check(self, profile: RouterProfileConfig) -> bool:
|
||||
try:
|
||||
exe = _find_agy_exe()
|
||||
return bool(exe and Path(exe).is_file())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
|
||||
try:
|
||||
discovered = discover_models()
|
||||
if isinstance(discovered, dict) and discovered:
|
||||
return list(set(discovered.values()))
|
||||
except Exception:
|
||||
pass
|
||||
return list(profile.preferred_models or ["gemini-3.7-flash", "gemini-3.5-flash"])
|
||||
|
||||
def classify_error(self, exc: Exception, response_data: Optional[Dict[str, Any]] = None) -> ErrorClassification:
|
||||
err_msg = str(exc)
|
||||
if response_data and isinstance(response_data, dict):
|
||||
if "error" in response_data:
|
||||
err_msg = str(response_data["error"])
|
||||
|
||||
err_lower = err_msg.lower()
|
||||
|
||||
# Check for quota exhaustion
|
||||
if any(k in err_lower for k in ("individual quota reached", "resource_exhausted", "quota exhausted", "quota limit")):
|
||||
# Look for reset duration (e.g. "resets in 2h30m" or "try again in 1800s")
|
||||
reset_sec = 1800
|
||||
m_sec = re.search(r"(\d+)\s*(?:seconds?|s\b)", err_lower)
|
||||
m_min = re.search(r"(\d+)\s*(?:minutes?|m\b)", err_lower)
|
||||
m_hr = re.search(r"(\d+)\s*(?:hours?|h\b)", err_lower)
|
||||
if m_hr:
|
||||
reset_sec = int(m_hr.group(1)) * 3600
|
||||
elif m_min:
|
||||
reset_sec = int(m_min.group(1)) * 60
|
||||
elif m_sec:
|
||||
reset_sec = int(m_sec.group(1))
|
||||
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.QUOTA_EXHAUSTED,
|
||||
message=err_msg,
|
||||
reset_duration_seconds=reset_sec,
|
||||
)
|
||||
|
||||
# Check for rate limits / 429
|
||||
if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower:
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.RATE_LIMITED,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=60,
|
||||
)
|
||||
|
||||
# Check for auth errors
|
||||
if any(k in err_lower for k in ("login", "unauthenticated", "invalid credentials", "permission denied")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.AUTH_REQUIRED,
|
||||
message=err_msg,
|
||||
)
|
||||
|
||||
# Transient / network timeout
|
||||
if any(k in err_lower for k in ("timeout", "connection refused", "econnreset", "network error")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.TRANSIENT,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=5,
|
||||
)
|
||||
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.FATAL,
|
||||
message=err_msg,
|
||||
)
|
||||
51
src/antigravity_provider/router/adapters/base_adapter.py
Normal file
51
src/antigravity_provider/router/adapters/base_adapter.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Base provider adapter interface for multi-provider router."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..router_config import RouterProfileConfig
|
||||
|
||||
|
||||
class ErrorCategory:
|
||||
QUOTA_EXHAUSTED = "quota-exhausted"
|
||||
RATE_LIMITED = "rate-limited"
|
||||
AUTH_REQUIRED = "auth-required"
|
||||
TRANSIENT = "transient"
|
||||
INVALID_REQUEST = "invalid-request"
|
||||
FATAL = "fatal"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorClassification:
|
||||
category: str
|
||||
message: str
|
||||
retry_delay_seconds: int = 60
|
||||
reset_duration_seconds: int = 1800
|
||||
model_family: Optional[str] = None
|
||||
|
||||
|
||||
class BaseProviderAdapter(ABC):
|
||||
@abstractmethod
|
||||
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Execute chat completion request and return standard OpenAI-compatible dict."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def health_check(self, profile: RouterProfileConfig) -> bool:
|
||||
"""Probe provider profile to check reachability and auth."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
|
||||
"""Return list of supported models for this profile."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def classify_error(self, exc: Exception, response_data: Optional[Dict[str, Any]] = None) -> ErrorClassification:
|
||||
"""Classify execution failure into structured error category."""
|
||||
...
|
||||
|
||||
def release(self, profile: RouterProfileConfig) -> None:
|
||||
"""Clean up any ephemeral resources for this profile."""
|
||||
pass
|
||||
153
src/antigravity_provider/router/adapters/codex_adapter.py
Normal file
153
src/antigravity_provider/router/adapters/codex_adapter.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""OpenAI Codex provider adapter for multi-provider router."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..router_config import RouterProfileConfig
|
||||
from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CODEX_MODELS = ["gpt-4o", "o3-mini", "gpt-4o-mini", "codex"]
|
||||
|
||||
|
||||
class CodexAdapter(BaseProviderAdapter):
|
||||
"""Adapter for OpenAI Codex / Responses API with multi-account isolation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._auth_tokens: dict[str, str] = {}
|
||||
|
||||
def _resolve_token(self, profile: RouterProfileConfig) -> Optional[str]:
|
||||
# 1. Profile auth_config token
|
||||
if "access_token" in profile.auth_config:
|
||||
return profile.auth_config["access_token"]
|
||||
if "api_key" in profile.auth_config:
|
||||
return profile.auth_config["api_key"]
|
||||
|
||||
# 2. Check environment variable mapped to this account
|
||||
env_var_name = f"CODEX_TOKEN_{profile.profile_id.upper().replace('-', '_')}"
|
||||
if env_var_name in os.environ and os.environ[env_var_name].strip():
|
||||
return os.environ[env_var_name].strip()
|
||||
|
||||
# 3. Check general CODEX / OPENAI keys
|
||||
for fallback_env in ("CODEX_API_KEY", "OPENAI_API_KEY"):
|
||||
if fallback_env in os.environ and os.environ[fallback_env].strip():
|
||||
return os.environ[fallback_env].strip()
|
||||
|
||||
# 4. Check Hermes auth.json store
|
||||
try:
|
||||
from hermes_cli.auth import resolve_codex_runtime_credentials
|
||||
creds = resolve_codex_runtime_credentials()
|
||||
if creds and creds.get("api_key"):
|
||||
return creds["api_key"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
token = self._resolve_token(profile)
|
||||
if not token:
|
||||
raise RuntimeError(f"No authentication token found for Codex profile '{profile.profile_id}'")
|
||||
|
||||
base_url = profile.custom_base_url or os.environ.get("CODEX_BASE_URL", "https://api.openai.com/v1").rstrip("/")
|
||||
url = f"{base_url}/chat/completions"
|
||||
|
||||
# Model selection
|
||||
model = request.get("model", "")
|
||||
if not model or model == "default" or "antigravity" in model:
|
||||
model = profile.preferred_models[0] if profile.preferred_models else "gpt-4o"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": request.get("messages", []),
|
||||
"temperature": request.get("temperature", 0.7),
|
||||
}
|
||||
if "tools" in request and request["tools"]:
|
||||
payload["tools"] = request["tools"]
|
||||
if "tool_choice" in request:
|
||||
payload["tool_choice"] = request["tool_choice"]
|
||||
|
||||
body_bytes = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body_bytes,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "hermes-router/1.0",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
resp_bytes = resp.read()
|
||||
return json.loads(resp_bytes.decode("utf-8", errors="replace"))
|
||||
except urllib.error.HTTPError as http_err:
|
||||
raw_err = http_err.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
err_json = json.loads(raw_err)
|
||||
except Exception:
|
||||
err_json = {"error": {"message": raw_err}}
|
||||
err_msg = err_json.get("error", {}).get("message", raw_err)
|
||||
raise RuntimeError(f"Codex API Error ({http_err.code}): {err_msg}") from http_err
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Codex Transport Error: {e}") from e
|
||||
|
||||
def health_check(self, profile: RouterProfileConfig) -> bool:
|
||||
token = self._resolve_token(profile)
|
||||
return token is not None
|
||||
|
||||
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
|
||||
return list(profile.preferred_models or DEFAULT_CODEX_MODELS)
|
||||
|
||||
def classify_error(self, exc: Exception, response_data: Optional[Dict[str, Any]] = None) -> ErrorClassification:
|
||||
err_msg = str(exc)
|
||||
err_lower = err_msg.lower()
|
||||
|
||||
# Quota / usage limit
|
||||
if any(k in err_lower for k in ("quota", "insufficient_quota", "usage_limit", "exceeded your current quota")):
|
||||
reset_sec = 1800
|
||||
m_sec = re.search(r"(\d+)\s*(?:seconds?|s\b)", err_lower)
|
||||
if m_sec:
|
||||
reset_sec = int(m_sec.group(1))
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.QUOTA_EXHAUSTED,
|
||||
message=err_msg,
|
||||
reset_duration_seconds=reset_sec,
|
||||
)
|
||||
|
||||
# Rate limited (429)
|
||||
if "429" in err_lower or "rate limit" in err_lower or "tokens per min" in err_lower:
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.RATE_LIMITED,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=60,
|
||||
)
|
||||
|
||||
# Auth errors
|
||||
if any(k in err_lower for k in ("401", "unauthorized", "invalid_api_key", "token_invalidated", "token_revoked")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.AUTH_REQUIRED,
|
||||
message=err_msg,
|
||||
)
|
||||
|
||||
# Transient
|
||||
if any(k in err_lower for k in ("timeout", "502", "503", "504", "connection reset")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.TRANSIENT,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=5,
|
||||
)
|
||||
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.FATAL,
|
||||
message=err_msg,
|
||||
)
|
||||
193
src/antigravity_provider/router/adapters/opencode_adapter.py
Normal file
193
src/antigravity_provider/router/adapters/opencode_adapter.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""OpenCode Go provider adapter for multi-provider router."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..router_config import RouterProfileConfig
|
||||
from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1"
|
||||
DEFAULT_OPENCODE_MODELS = [
|
||||
"kimi-k2.7-code",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"qwen3.8-max",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"grok-4.5",
|
||||
"glm-5.3",
|
||||
"mimo-v2.5-pro",
|
||||
"minimax-m3",
|
||||
]
|
||||
|
||||
|
||||
class OpenCodeGoAdapter(BaseProviderAdapter):
|
||||
"""Adapter for OpenCode Go with support for multi-account key pools and reasoning knobs."""
|
||||
|
||||
def _resolve_api_key(self, profile: RouterProfileConfig) -> Optional[str]:
|
||||
# 1. Profile explicit auth_config
|
||||
if "api_key" in profile.auth_config and profile.auth_config["api_key"]:
|
||||
return profile.auth_config["api_key"]
|
||||
|
||||
# 2. Account-specific environment variable (e.g. OPENCODE_GO_KEY_OPENGO_1, OPENCODE_GO_KEY_1)
|
||||
suffix = profile.profile_id.upper().replace("-", "_")
|
||||
for candidate in (
|
||||
f"OPENCODE_GO_KEY_{suffix}",
|
||||
f"OPENCODE_KEY_{suffix}",
|
||||
f"OPENCODE_GO_API_KEY_{suffix}",
|
||||
):
|
||||
if candidate in os.environ and os.environ[candidate].strip():
|
||||
return os.environ[candidate].strip()
|
||||
|
||||
# Account ID mapped env
|
||||
acc_suffix = profile.account_id.upper().replace("-", "_")
|
||||
for candidate in (
|
||||
f"OPENCODE_GO_KEY_{acc_suffix}",
|
||||
f"OPENCODE_KEY_{acc_suffix}",
|
||||
):
|
||||
if candidate in os.environ and os.environ[candidate].strip():
|
||||
return os.environ[candidate].strip()
|
||||
|
||||
# 3. Global OpenCode Go keys
|
||||
for global_env in ("OPENCODE_GO_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_API_KEY"):
|
||||
if global_env in os.environ and os.environ[global_env].strip():
|
||||
return os.environ[global_env].strip()
|
||||
|
||||
return None
|
||||
|
||||
def _build_model_kwargs(self, model: str, request: Dict[str, Any]) -> tuple[dict, dict]:
|
||||
"""Format reasoning_effort and extra_body thinking based on OpenCode model policies."""
|
||||
extra_body: dict[str, Any] = {}
|
||||
top_level: dict[str, Any] = {}
|
||||
|
||||
effort = request.get("reasoning_effort")
|
||||
|
||||
if "kimi" in model.lower():
|
||||
if effort in ("low", "medium", "high"):
|
||||
top_level["reasoning_effort"] = effort
|
||||
elif effort in ("xhigh", "max", "ultra"):
|
||||
top_level["reasoning_effort"] = "high"
|
||||
else:
|
||||
extra_body["thinking"] = {"type": "enabled"}
|
||||
elif "deepseek" in model.lower():
|
||||
if effort in ("xhigh", "max", "ultra"):
|
||||
top_level["reasoning_effort"] = "max"
|
||||
elif effort in ("low", "medium", "high"):
|
||||
top_level["reasoning_effort"] = effort
|
||||
else:
|
||||
extra_body["thinking"] = {"type": "enabled"}
|
||||
elif "glm" in model.lower():
|
||||
if effort:
|
||||
top_level["reasoning_effort"] = "max" if effort in ("xhigh", "max") else "high"
|
||||
|
||||
return extra_body, top_level
|
||||
|
||||
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
api_key = self._resolve_api_key(profile)
|
||||
if not api_key:
|
||||
raise RuntimeError(f"No API key found for OpenCode Go profile '{profile.profile_id}'")
|
||||
|
||||
base_url = (profile.custom_base_url or os.environ.get("OPENCODE_GO_BASE_URL", DEFAULT_OPENCODE_GO_BASE_URL)).rstrip("/")
|
||||
url = f"{base_url}/chat/completions"
|
||||
|
||||
# Model selection
|
||||
model = request.get("model", "")
|
||||
if not model or model == "default" or "antigravity" in model:
|
||||
model = profile.preferred_models[0] if profile.preferred_models else "kimi-k2.7-code"
|
||||
|
||||
extra_body, top_level = self._build_model_kwargs(model, request)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": request.get("messages", []),
|
||||
"temperature": request.get("temperature", 0.6),
|
||||
}
|
||||
if "tools" in request and request["tools"]:
|
||||
payload["tools"] = request["tools"]
|
||||
if "tool_choice" in request:
|
||||
payload["tool_choice"] = request["tool_choice"]
|
||||
if extra_body:
|
||||
payload["extra_body"] = extra_body
|
||||
payload.update(top_level)
|
||||
|
||||
body_bytes = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body_bytes,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": "hermes-router/1.0",
|
||||
"X-OpenCode-Source": "hermes-agent",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
resp_bytes = resp.read()
|
||||
return json.loads(resp_bytes.decode("utf-8", errors="replace"))
|
||||
except urllib.error.HTTPError as http_err:
|
||||
raw_err = http_err.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
err_json = json.loads(raw_err)
|
||||
except Exception:
|
||||
err_json = {"error": {"message": raw_err}}
|
||||
err_msg = err_json.get("error", {}).get("message", raw_err)
|
||||
raise RuntimeError(f"OpenCode Go Error ({http_err.code}): {err_msg}") from http_err
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"OpenCode Go Transport Error: {e}") from e
|
||||
|
||||
def health_check(self, profile: RouterProfileConfig) -> bool:
|
||||
return self._resolve_api_key(profile) is not None
|
||||
|
||||
def discover_models(self, profile: RouterProfileConfig) -> List[str]:
|
||||
return list(profile.preferred_models or DEFAULT_OPENCODE_MODELS)
|
||||
|
||||
def classify_error(self, exc: Exception, response_data: Optional[Dict[str, Any]] = None) -> ErrorClassification:
|
||||
err_msg = str(exc)
|
||||
err_lower = err_msg.lower()
|
||||
|
||||
# Quota / balance
|
||||
if any(k in err_lower for k in ("quota", "insufficient_quota", "insufficient balance", "balance", "arrears")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.QUOTA_EXHAUSTED,
|
||||
message=err_msg,
|
||||
reset_duration_seconds=1800,
|
||||
)
|
||||
|
||||
# Rate limited (429)
|
||||
if "429" in err_lower or "rate limit" in err_lower or "too many requests" in err_lower:
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.RATE_LIMITED,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=60,
|
||||
)
|
||||
|
||||
# Auth errors (401, 403)
|
||||
if any(k in err_lower for k in ("401", "403", "unauthorized", "invalid api key", "invalid_token")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.AUTH_REQUIRED,
|
||||
message=err_msg,
|
||||
)
|
||||
|
||||
# Transient
|
||||
if any(k in err_lower for k in ("timeout", "502", "503", "504", "gateway", "econnreset")):
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.TRANSIENT,
|
||||
message=err_msg,
|
||||
retry_delay_seconds=5,
|
||||
)
|
||||
|
||||
return ErrorClassification(
|
||||
category=ErrorCategory.FATAL,
|
||||
message=err_msg,
|
||||
)
|
||||
213
src/antigravity_provider/router/auto_assigner.py
Normal file
213
src/antigravity_provider/router/auto_assigner.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""Auto Assignment Engine for Hermes Multi-Provider Account Router.
|
||||
|
||||
Translates internal profile slots into human-readable team roles:
|
||||
- "Главный оркестратор", "Резервный оркестратор"
|
||||
- "Кодер 1", "Кодер 2", "Ревьюер", "Исследователь", "Быстрый агент", "Универсальный субагент"
|
||||
- "Резерв 1", "Резерв 2", "Холодный резерв"
|
||||
|
||||
Automatically assigns slots, detects duplicate accounts, and rebuilds routing chains.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
|
||||
from antigravity_provider.router.router_config import (
|
||||
RolePolicy,
|
||||
RouterConfig,
|
||||
RouterProfileConfig,
|
||||
load_router_config,
|
||||
save_router_config,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("hermes.router.auto_assigner")
|
||||
|
||||
HUMAN_ROLE_LABELS = {
|
||||
"orchestrator_primary": "Главный оркестратор",
|
||||
"orchestrator_fallback": "Резервный оркестратор",
|
||||
"coder_1": "Кодер 1",
|
||||
"coder_2": "Кодер 2",
|
||||
"reviewer": "Ревьюер",
|
||||
"researcher": "Исследователь",
|
||||
"fast_agent": "Быстрый агент",
|
||||
"universal_subagent": "Универсальный субагент",
|
||||
"spare_1": "Резерв 1",
|
||||
"spare_2": "Резерв 2",
|
||||
"cold_spare": "Холодный резерв",
|
||||
}
|
||||
|
||||
DEFAULT_SLOT_ROLES = {
|
||||
"codex-orch": ("Главный оркестратор", "orchestrator", "primary"),
|
||||
"ag-orch-fallback": ("Резервный оркестратор", "orchestrator", "fallback"),
|
||||
"codex-worker-1": ("Кодер 1", "coder-primary", "primary"),
|
||||
"ag-w1": ("Кодер 2", "coder-primary", "fallback"),
|
||||
"codex-worker-2": ("Ревьюер", "reviewer", "primary"),
|
||||
"ag-w2": ("Исследователь", "research", "primary"),
|
||||
"ag-w3": ("Быстрый агент", "fast", "primary"),
|
||||
"ag-w4": ("Универсальный субагент", "universal", "primary"),
|
||||
"opengo-1": ("Кодер (OpenCode)", "coder-primary", "fallback_2"),
|
||||
"opengo-2": ("Исследователь (OpenCode)", "research", "fallback"),
|
||||
"opengo-3": ("Резервный роутер (OpenCode)", "orchestrator", "fallback_2"),
|
||||
"ag-spare-1": ("Резерв 1", "spare", "spare"),
|
||||
"ag-spare-2": ("Резерв 2", "spare", "spare"),
|
||||
"ag-cold-1": ("Холодный резерв 1", "spare", "cold"),
|
||||
"ag-cold-2": ("Холодный резерв 2", "spare", "cold"),
|
||||
"ag-cold-3": ("Холодный резерв 3", "spare", "cold"),
|
||||
}
|
||||
|
||||
|
||||
class AutoAssigner:
|
||||
"""Manages team view structure, auto-slot allocation, and duplicate checking."""
|
||||
|
||||
@staticmethod
|
||||
def get_display_name_and_role(profile_id: str) -> Tuple[str, str, str]:
|
||||
"""Get human-readable display name, logical role, and tier for a profile."""
|
||||
return DEFAULT_SLOT_ROLES.get(profile_id, (profile_id, "worker", "primary"))
|
||||
|
||||
@staticmethod
|
||||
def check_duplicate_identity(provider: str, email_or_id: str, exclude_profile_id: Optional[str] = None) -> Optional[str]:
|
||||
"""Check if an account with the same email / account_id is already assigned to another profile."""
|
||||
if not email_or_id or "@" not in email_or_id:
|
||||
return None
|
||||
|
||||
clean_target = email_or_id.strip().lower()
|
||||
config = load_router_config()
|
||||
|
||||
for pid, pcfg in config.profiles.items():
|
||||
if pid == exclude_profile_id:
|
||||
continue
|
||||
if pcfg.provider != provider:
|
||||
continue
|
||||
|
||||
status = ProfileAuthManager.get_profile_status(provider, pid)
|
||||
if not status.get("authenticated"):
|
||||
continue
|
||||
|
||||
# Compare raw verified email from auth.json
|
||||
auth_data = ProfileAuthManager.load_profile_auth(provider, pid)
|
||||
if not auth_data:
|
||||
continue
|
||||
|
||||
saved_email = auth_data.get("email") or auth_data.get("user_email")
|
||||
if saved_email and saved_email.strip().lower() == clean_target:
|
||||
return pid
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_free_slot(provider: str, requested_role: str = "auto") -> Optional[str]:
|
||||
"""Find the optimal free internal profile slot for a provider."""
|
||||
config = load_router_config()
|
||||
|
||||
provider_slots = {
|
||||
"antigravity": [
|
||||
"ag-orch-fallback", "ag-w1", "ag-w2", "ag-w3", "ag-w4",
|
||||
"ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3"
|
||||
],
|
||||
"openai-codex": ["codex-orch", "codex-worker-1", "codex-worker-2"],
|
||||
"opencode-go": ["opengo-1", "opengo-2", "opengo-3"],
|
||||
}
|
||||
|
||||
candidates = provider_slots.get(provider, [])
|
||||
|
||||
# Priority based on requested role
|
||||
if requested_role == "orchestrator":
|
||||
if provider == "openai-codex" and "codex-orch" in candidates:
|
||||
candidates.insert(0, "codex-orch")
|
||||
elif provider == "antigravity" and "ag-orch-fallback" in candidates:
|
||||
candidates.insert(0, "ag-orch-fallback")
|
||||
|
||||
# Find first slot without saved auth
|
||||
for pid in candidates:
|
||||
pcfg = config.get_profile(pid)
|
||||
if not pcfg:
|
||||
continue
|
||||
status = ProfileAuthManager.get_profile_status(provider, pid)
|
||||
if not status.get("authenticated"):
|
||||
return pid
|
||||
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
@staticmethod
|
||||
def build_team_hierarchy() -> Dict[str, Any]:
|
||||
"""Build the structured Hermes Team hierarchy for the Cockpit UI."""
|
||||
config = load_router_config()
|
||||
main_ag = ProfileAuthManager.get_main_profile("antigravity")
|
||||
main_codex = ProfileAuthManager.get_main_profile("openai-codex")
|
||||
|
||||
team = {
|
||||
"orchestrator": [],
|
||||
"subagents": [],
|
||||
"spares": [],
|
||||
"summary": {
|
||||
"total": len(config.profiles),
|
||||
"active_authenticated": 0,
|
||||
"needs_auth": 0,
|
||||
"main_antigravity": main_ag,
|
||||
"main_codex": main_codex,
|
||||
}
|
||||
}
|
||||
|
||||
# Categories
|
||||
for pid, pcfg in sorted(config.profiles.items()):
|
||||
display_name, log_role, tier = AutoAssigner.get_display_name_and_role(pid)
|
||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
|
||||
is_auth = status.get("authenticated", False)
|
||||
|
||||
if is_auth and pcfg.enabled:
|
||||
team["summary"]["active_authenticated"] += 1
|
||||
elif pcfg.enabled:
|
||||
team["summary"]["needs_auth"] += 1
|
||||
|
||||
is_main = (pid == main_ag and pcfg.provider == "antigravity") or (pid == main_codex and pcfg.provider == "openai-codex")
|
||||
identity = status.get("email_masked") or status.get("account_id_masked") or status.get("error") or "Не авторизован"
|
||||
|
||||
card = {
|
||||
"profile_id": pid,
|
||||
"display_name": display_name,
|
||||
"provider": pcfg.provider,
|
||||
"provider_label": "Google Antigravity" if pcfg.provider == "antigravity" else ("OpenAI Codex" if pcfg.provider == "openai-codex" else "OpenCode Go"),
|
||||
"logical_role": log_role,
|
||||
"tier": tier,
|
||||
"is_main": is_main,
|
||||
"identity": identity,
|
||||
"authenticated": is_auth,
|
||||
"enabled": pcfg.enabled,
|
||||
"preferred_models": pcfg.preferred_models,
|
||||
"storage": status.get("storage", "-"),
|
||||
}
|
||||
|
||||
if "оркестратор" in display_name.lower():
|
||||
team["orchestrator"].append(card)
|
||||
elif "резерв" in display_name.lower() or tier in ("spare", "cold"):
|
||||
team["spares"].append(card)
|
||||
else:
|
||||
team["subagents"].append(card)
|
||||
|
||||
return team
|
||||
|
||||
@staticmethod
|
||||
def set_primary_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
||||
"""Designate a profile as the primary orchestrator and adjust fallback chains."""
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(profile_id)
|
||||
if not pcfg:
|
||||
return False, f"Profile '{profile_id}' not found"
|
||||
|
||||
# Update orchestrator role chain in router_profiles.yaml
|
||||
orch_policy = config.get_role_policy("orchestrator")
|
||||
current_chain = list(orch_policy.preferred_chain)
|
||||
|
||||
if profile_id in current_chain:
|
||||
current_chain.remove(profile_id)
|
||||
current_chain.insert(0, profile_id)
|
||||
|
||||
orch_policy.preferred_chain = current_chain
|
||||
config.roles["orchestrator"] = orch_policy
|
||||
save_router_config(config)
|
||||
|
||||
display_name, _, _ = AutoAssigner.get_display_name_and_role(profile_id)
|
||||
return True, f"'{display_name}' ({profile_id}) назначен главным оркестратором роутера"
|
||||
330
src/antigravity_provider/router/cli_commands.py
Normal file
330
src/antigravity_provider/router/cli_commands.py
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
"""CLI diagnostic and management commands for Hermes Multi-Provider Account Router."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, load_router_config
|
||||
from antigravity_provider.router.health_tracker import HealthTracker
|
||||
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
|
||||
from antigravity_provider.router.adapters import get_adapter
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
|
||||
|
||||
|
||||
def print_router_status() -> int:
|
||||
"""Print pool and health status of all profiles and role chains."""
|
||||
engine = get_router_engine()
|
||||
config = engine.config
|
||||
|
||||
print("=" * 80)
|
||||
print("HERMES MULTI-PROVIDER ACCOUNT ROUTER: POOL & HEALTH STATUS")
|
||||
print("=" * 80)
|
||||
print(f"{'LOGICAL ROLE':<16} {'PROFILE':<18} {'PROVIDER':<15} {'STATE':<18} {'RESET IN':<10}")
|
||||
print("-" * 80)
|
||||
|
||||
for rname, rpolicy in sorted(config.roles.items()):
|
||||
for idx, pid in enumerate(rpolicy.preferred_chain):
|
||||
pconfig = config.get_profile(pid)
|
||||
if not pconfig:
|
||||
continue
|
||||
role_label = rname if idx == 0 else f" -> fallback {idx}"
|
||||
precord = engine.health.get_or_create(pid)
|
||||
|
||||
state_display = precord.overall_state
|
||||
reset_display = "-"
|
||||
if precord.overall_state != "healthy":
|
||||
cooldown_remaining = max([int(f.reset_at - time.time()) for f in precord.families.values() if f.reset_at and f.reset_at > time.time()] or [0])
|
||||
if cooldown_remaining > 0:
|
||||
reset_display = f"{cooldown_remaining}s"
|
||||
else:
|
||||
state_display = "cooldown (ready)"
|
||||
|
||||
if not pconfig.enabled:
|
||||
state_display = "disabled (cold)"
|
||||
|
||||
print(f"{role_label:<16} {pid:<18} {pconfig.provider:<15} {state_display:<18} {reset_display:<10}")
|
||||
|
||||
print("-" * 80)
|
||||
return 0
|
||||
|
||||
|
||||
def print_routing_policy() -> int:
|
||||
"""Print the configured routing policies and fallback chains."""
|
||||
config = load_router_config()
|
||||
print("=" * 70)
|
||||
print("HERMES ROUTING POLICIES")
|
||||
print("=" * 70)
|
||||
|
||||
for rname, rpolicy in sorted(config.roles.items()):
|
||||
chain_str = " -> ".join(rpolicy.preferred_chain)
|
||||
print(f"Role: {rname}")
|
||||
print(f" Preferred Chain: {chain_str}")
|
||||
print(f" Max Failover: {rpolicy.max_failover_attempts}")
|
||||
print(f" Session Affinity: {'Enabled' if rpolicy.session_affinity_enabled else 'Disabled'}")
|
||||
if rpolicy.default_model:
|
||||
print(f" Default Model: {rpolicy.default_model}")
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
def profile_status_cli(profile_id: Optional[str] = None) -> int:
|
||||
"""Print detailed auth and credential status for profiles."""
|
||||
config = load_router_config()
|
||||
main_ag_pid = ProfileAuthManager.get_main_profile("antigravity")
|
||||
main_codex_pid = ProfileAuthManager.get_main_profile("openai-codex")
|
||||
|
||||
print("=" * 85)
|
||||
print("HERMES ROUTER PROFILE AUTHENTICATION STATUS (* = Main/Default Account)")
|
||||
print("=" * 85)
|
||||
print(f"{'PROFILE':<19} | {'PROVIDER':<13} | {'AUTH STATUS':<19} | {'ACCOUNT / EMAIL':<25} | {'STORAGE'}")
|
||||
print("-" * 105)
|
||||
|
||||
profiles = [profile_id] if profile_id else sorted(config.profiles.keys())
|
||||
for pid in profiles:
|
||||
pcfg = config.get_profile(pid)
|
||||
if not pcfg:
|
||||
print(f"Profile '{pid}' not found.")
|
||||
continue
|
||||
if not pcfg.enabled:
|
||||
print(f"{pid:<19} | {pcfg.provider:<13} | {'DISABLED':<19} | {'(cold spare)':<25} | -")
|
||||
continue
|
||||
|
||||
is_main = (pid == main_ag_pid and pcfg.provider == "antigravity") or (pid == main_codex_pid and pcfg.provider == "openai-codex")
|
||||
pid_display = f"{pid} *" if is_main else pid
|
||||
|
||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
|
||||
if status.get("authenticated"):
|
||||
auth_tag = "[PASS] AUTH (MAIN)" if is_main else "[PASS] AUTH"
|
||||
else:
|
||||
auth_tag = "[FAIL] NO AUTH"
|
||||
|
||||
account = status.get("email_masked") or status.get("account_id_masked") or status.get("error") or "-"
|
||||
if len(account) > 24:
|
||||
account = account[:23] + "..."
|
||||
storage = status.get("storage") or "-"
|
||||
print(f"{pid_display:<19} | {pcfg.provider:<13} | {auth_tag:<19} | {account:<25} | {storage}")
|
||||
|
||||
print("-" * 105)
|
||||
return 0
|
||||
|
||||
|
||||
def profile_set_main_cli(profile_id: str) -> int:
|
||||
"""Set a specific profile as the active main account for Hermes."""
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(profile_id)
|
||||
if not pcfg:
|
||||
print(f"[ERROR] Profile '{profile_id}' not found in configuration.")
|
||||
return 1
|
||||
|
||||
ok, msg = ProfileAuthManager.set_main_profile(pcfg.provider, profile_id)
|
||||
if ok:
|
||||
print(f"[OK] {msg}")
|
||||
ver = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
|
||||
print(f"Active Account: {ver.get('email_masked') or ver.get('account_id_masked')} (Provider: {pcfg.provider})")
|
||||
return 0
|
||||
else:
|
||||
print(f"[FAIL] {msg}")
|
||||
return 1
|
||||
|
||||
|
||||
def profile_import_cli(profile_id: str, from_current_cm: bool = False) -> int:
|
||||
"""Import active credentials into a profile."""
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(profile_id)
|
||||
if not pcfg:
|
||||
print(f"[ERROR] Profile '{profile_id}' not found in configuration.")
|
||||
return 1
|
||||
|
||||
if pcfg.provider == "antigravity":
|
||||
if from_current_cm:
|
||||
cm_data = ProfileAuthManager.read_windows_credential("gemini:antigravity")
|
||||
if not cm_data:
|
||||
print("[ERROR] No 'gemini:antigravity' credential found in Windows Credential Manager.")
|
||||
return 1
|
||||
saved_p = ProfileAuthManager.save_profile_auth("antigravity", profile_id, cm_data)
|
||||
print(f"[OK] Successfully imported Windows Credential into profile '{profile_id}' -> {saved_p}")
|
||||
ver = ProfileAuthManager.verify_antigravity_profile(profile_id)
|
||||
print(f"Verified identity: {ver.get('email_masked', '')} (sub={ver.get('account_id_masked', '')})")
|
||||
return 0
|
||||
|
||||
elif pcfg.provider == "openai-codex":
|
||||
if from_current_cm:
|
||||
codex_file = Path.home() / ".codex" / "auth.json"
|
||||
if not codex_file.is_file():
|
||||
print("[ERROR] ~/.codex/auth.json not found.")
|
||||
return 1
|
||||
d = json.loads(codex_file.read_text(encoding="utf-8"))
|
||||
saved_p = ProfileAuthManager.save_profile_auth("openai-codex", profile_id, d)
|
||||
print(f"[OK] Successfully imported Codex credentials into profile '{profile_id}' -> {saved_p}")
|
||||
ver = ProfileAuthManager.verify_codex_profile(profile_id)
|
||||
print(f"Verified identity: {ver.get('email_masked', '')} (account={ver.get('account_id_masked', '')})")
|
||||
return 0
|
||||
|
||||
print(f"[ERROR] Import method not supported for provider '{pcfg.provider}'.")
|
||||
return 1
|
||||
|
||||
|
||||
def profile_set_key_cli(profile_id: str, api_key: str) -> int:
|
||||
"""Set an API key for an OpenCode Go profile."""
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(profile_id)
|
||||
if not pcfg:
|
||||
print(f"[ERROR] Profile '{profile_id}' not found.")
|
||||
return 1
|
||||
|
||||
saved = ProfileAuthManager.save_profile_auth(pcfg.provider, profile_id, {"api_key": api_key, "auth_mode": "api_key"})
|
||||
print(f"[OK] API key saved for profile '{profile_id}' -> {saved}")
|
||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
|
||||
print(f"Status: {status}")
|
||||
return 0
|
||||
|
||||
|
||||
def test_profile_cli(profile_id: str) -> int:
|
||||
"""Test a live prompt execution on a specific profile."""
|
||||
config = load_router_config()
|
||||
pconfig = config.get_profile(profile_id)
|
||||
if not pconfig:
|
||||
print(f"[ERROR] Profile '{profile_id}' not found.")
|
||||
return 1
|
||||
|
||||
print(f"Testing profile '{profile_id}' (Provider: {pconfig.provider})...")
|
||||
adapter = get_adapter(pconfig.provider)
|
||||
|
||||
test_request = {
|
||||
"model": pconfig.preferred_models[0] if pconfig.preferred_models else "default",
|
||||
"messages": [{"role": "user", "content": "respond only with: router_test_ok"}],
|
||||
"temperature": 0.1,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = adapter.invoke(pconfig, test_request)
|
||||
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
print(f"[PASS] Response from {profile_id}: {content.strip()[:100]}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"[FAIL] Error from {profile_id}: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
def simulate_quota_cli(profile_id: str, model_family: Optional[str] = None, duration: int = 600) -> int:
|
||||
"""Simulate quota exhaustion on a profile for testing."""
|
||||
engine = get_router_engine()
|
||||
pconfig = engine.config.get_profile(profile_id)
|
||||
if not pconfig:
|
||||
print(f"[ERROR] Profile '{profile_id}' not found.")
|
||||
return 1
|
||||
|
||||
engine.health.simulate_quota(profile_id, model_family=model_family, duration=duration)
|
||||
print(f"[OK] Simulated quota exhaustion activated for profile '{profile_id}' for {duration} seconds.")
|
||||
print("Use `hermes router clear-cooldown` to restore normal state.")
|
||||
return 0
|
||||
|
||||
|
||||
def clear_cooldown_cli(profile_id: Optional[str] = None) -> int:
|
||||
"""Clear cooldowns and quota simulations."""
|
||||
engine = get_router_engine()
|
||||
engine.health.clear_cooldown(profile_id)
|
||||
if profile_id:
|
||||
print(f"[OK] Cooldowns and simulated quota cleared for profile '{profile_id}'.")
|
||||
else:
|
||||
print("[OK] All profile cooldowns and simulated quotas cleared.")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="hermes router", description="Hermes Multi-Provider Account Router CLI")
|
||||
subparsers = parser.add_subparsers(dest="subcommand", help="Router subcommands")
|
||||
|
||||
# status
|
||||
subparsers.add_parser("status", help="Show pool and health status of all provider profiles")
|
||||
|
||||
# policy
|
||||
subparsers.add_parser("policy", help="Show role fallback chains and policies")
|
||||
|
||||
# profile
|
||||
prof_parser = subparsers.add_parser("profile", help="Manage profile authentication and provisioning")
|
||||
prof_sub = prof_parser.add_subparsers(dest="prof_action", help="Profile action")
|
||||
|
||||
# profile status
|
||||
pstat = prof_sub.add_parser("status", help="Show authentication status for profiles")
|
||||
pstat.add_argument("profile_id", nargs="?", default=None, help="Optional profile ID")
|
||||
|
||||
# profile set-main
|
||||
psetm = prof_sub.add_parser("set-main", help="Set profile as the active default for Hermes")
|
||||
psetm.add_argument("profile_id", help="Profile ID to make main (e.g. ag-w1, ag-orch-fallback)")
|
||||
|
||||
# profile import
|
||||
pimp = prof_sub.add_parser("import", help="Import credentials into profile")
|
||||
pimp.add_argument("profile_id", help="Target profile ID")
|
||||
pimp.add_argument("--from-current-cm", action="store_true", help="Import from current Windows Credential Manager or ~/.codex/auth.json")
|
||||
|
||||
# profile set-key
|
||||
psetk = prof_sub.add_parser("set-key", help="Set API key for an OpenCode Go / API profile")
|
||||
psetk.add_argument("profile_id", help="Target profile ID")
|
||||
psetk.add_argument("api_key", help="API Key value")
|
||||
|
||||
# test
|
||||
test_parser = subparsers.add_parser("test", help="Test specific profile invocation")
|
||||
test_parser.add_argument("profile_id", help="Profile ID to test")
|
||||
|
||||
# simulate
|
||||
sim_parser = subparsers.add_parser("simulate", help="Simulate quota exhaustion for testing")
|
||||
sim_sub = sim_parser.add_subparsers(dest="sim_type", help="Simulation type")
|
||||
sim_quota = sim_sub.add_parser("quota", help="Simulate quota exhaustion on a profile")
|
||||
sim_quota.add_argument("profile_id", help="Profile ID to mark exhausted")
|
||||
sim_quota.add_argument("--model-family", default=None, help="Specific model family to mark exhausted")
|
||||
sim_quota.add_argument("--duration", type=int, default=600, help="Duration in seconds (default 600)")
|
||||
|
||||
# clear-cooldown
|
||||
cc_parser = subparsers.add_parser("clear-cooldown", help="Clear cooldowns and quota simulations")
|
||||
cc_parser.add_argument("profile_id", nargs="?", default=None, help="Optional profile ID")
|
||||
|
||||
# hub / cockpit / gui
|
||||
hub_parser = subparsers.add_parser("hub", aliases=["cockpit", "gui"], help="Launch Hermes Hub GUI")
|
||||
hub_parser.add_argument("--port", type=int, default=8765, help="Port to bind server (default 8765)")
|
||||
hub_parser.add_argument("--no-browser", action="store_true", help="Do not automatically open browser")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.subcommand in ("hub", "cockpit", "gui"):
|
||||
from antigravity_provider.router.gui_server import run_gui_server
|
||||
run_gui_server(port=args.port, open_browser=not args.no_browser)
|
||||
return 0
|
||||
elif args.subcommand == "status":
|
||||
return print_router_status()
|
||||
elif args.subcommand == "policy":
|
||||
return print_routing_policy()
|
||||
elif args.subcommand == "profile":
|
||||
if args.prof_action == "status":
|
||||
return profile_status_cli(args.profile_id)
|
||||
elif args.prof_action == "set-main":
|
||||
return profile_set_main_cli(args.profile_id)
|
||||
elif args.prof_action == "import":
|
||||
return profile_import_cli(args.profile_id, from_current_cm=args.from_current_cm)
|
||||
elif args.prof_action == "set-key":
|
||||
return profile_set_key_cli(args.profile_id, args.api_key)
|
||||
else:
|
||||
prof_parser.print_help()
|
||||
return 1
|
||||
elif args.subcommand == "test":
|
||||
return test_profile_cli(args.profile_id)
|
||||
elif args.subcommand == "simulate":
|
||||
if args.sim_type == "quota":
|
||||
return simulate_quota_cli(args.profile_id, model_family=args.model_family, duration=args.duration)
|
||||
else:
|
||||
sim_parser.print_help()
|
||||
return 1
|
||||
elif args.subcommand == "clear-cooldown":
|
||||
return clear_cooldown_cli(args.profile_id)
|
||||
else:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
850
src/antigravity_provider/router/gui_cockpit.html
Normal file
850
src/antigravity_provider/router/gui_cockpit.html
Normal file
|
|
@ -0,0 +1,850 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hermes Hub — Multi-Agent & Multi-Provider Control Hub</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #090d16;
|
||||
--bg-card: #131b2e;
|
||||
--bg-card-hover: #1c2742;
|
||||
--border-color: #23314f;
|
||||
--primary: #38bdf8;
|
||||
--primary-hover: #0ea5e9;
|
||||
--accent-green: #10b981;
|
||||
--accent-yellow: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--text-main: #f1f5f9;
|
||||
--text-muted: #94a3b8;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-family);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.brand-logo {
|
||||
font-size: 22px;
|
||||
background: linear-gradient(135deg, #38bdf8, #818cf8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.brand-sub { color: var(--text-muted); font-size: 13px; font-weight: 500; }
|
||||
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* Navigation Tabs */
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
background: #0b1120;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 32px;
|
||||
gap: 8px;
|
||||
}
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
padding: 14px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text-main); }
|
||||
.tab-btn.active {
|
||||
color: var(--primary);
|
||||
border-bottom: 2px solid var(--primary);
|
||||
background: rgba(56, 189, 248, 0.05);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
main {
|
||||
padding: 24px 32px;
|
||||
flex: 1;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* Top Summary Bar */
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-badge {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stat-val { font-weight: 700; color: var(--primary); }
|
||||
|
||||
/* Section Category Titles */
|
||||
.section-category {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.section-category-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.section-category-count {
|
||||
font-size: 12px;
|
||||
background: #1e293b;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-main);
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn:hover { background: var(--bg-card-hover); border-color: #3b82f6; }
|
||||
.btn-primary { background: #0284c7; border-color: #0284c7; color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-success { background: #059669; border-color: #059669; color: #fff; }
|
||||
.btn-warning { background: #d97706; border-color: #d97706; color: #fff; }
|
||||
.btn-danger { background: rgba(239, 68, 68, 0.15); border-color: var(--accent-red); color: #fca5a5; }
|
||||
.btn-danger:hover { background: var(--accent-red); color: #fff; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||
|
||||
/* Card Grid */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(390px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.team-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
.team-card:hover {
|
||||
border-color: #38bdf8;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.team-card.is-main {
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 15px rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
.team-card.is-orchestrator {
|
||||
border-left: 4px solid #38bdf8;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.card-role-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.card-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.badge-main {
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge-status {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge-healthy { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid #10b981; }
|
||||
.badge-unauth { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid #ef4444; }
|
||||
.badge-spare { background: rgba(148, 163, 184, 0.15); color: #94a3b8; border: 1px solid #64748b; }
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.card-val { color: var(--text-main); font-weight: 600; }
|
||||
|
||||
.models-pill-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.model-pill {
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||
color: #7dd3fc;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.test-result-box {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.card-tech-id {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
font-family: monospace;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
|
||||
.modal-box {
|
||||
background: #1e293b;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 560px;
|
||||
max-width: 90%;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.modal-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.modal-title { font-size: 18px; font-weight: 700; }
|
||||
.modal-close { background: transparent; border: none; color: var(--text-muted); font-size: 20px; cursor: pointer; }
|
||||
|
||||
.wizard-step { display: none; flex-direction: column; gap: 14px; }
|
||||
.wizard-step.active { display: flex; }
|
||||
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
|
||||
.form-select, .form-input {
|
||||
background: #0f172a;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-main);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.form-select:focus, .form-input:focus { border-color: var(--primary); }
|
||||
|
||||
.radio-option {
|
||||
background: #0f172a;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.radio-option:hover { border-color: var(--primary); }
|
||||
|
||||
.oauth-url-box {
|
||||
background: #0f172a;
|
||||
border: 1px dashed #334155;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #93c5fd;
|
||||
word-break: break-all;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Logs Drawer */
|
||||
.logs-drawer {
|
||||
background: #0b1120;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 10px 32px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.log-line { margin-bottom: 3px; }
|
||||
.log-pass { color: #34d399; }
|
||||
.log-fail { color: #f87171; }
|
||||
.log-info { color: #38bdf8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="brand-logo">HERMES HUB</div>
|
||||
<div class="brand-sub">Multi-Agent & Multi-Provider Control Hub</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" onclick="openAddWizard()">+ Добавить аккаунт</button>
|
||||
<button class="btn btn-sm" onclick="loadAllData()">🔄 Обновить статус</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="nav-tabs">
|
||||
<button class="tab-btn active" onclick="switchTab('team')">👥 Команда Hermes</button>
|
||||
<button class="tab-btn" onclick="switchTab('accounts')">🔑 Все Аккаунты</button>
|
||||
<button class="tab-btn" onclick="switchTab('routing')">🔀 Маршрутизация (Failover)</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<!-- Summary Header -->
|
||||
<div class="stats-bar">
|
||||
<div class="stat-badge">Всего ролей: <span class="stat-val" id="stat-total">16</span></div>
|
||||
<div class="stat-badge">Готовы к работе: <span class="stat-val" id="stat-auth">0</span></div>
|
||||
<div class="stat-badge">Требуют входа: <span class="stat-val" id="stat-needs-auth">0</span></div>
|
||||
<div class="stat-badge">Основной аккаунт: <span class="stat-val" id="stat-main-ag">-</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Команда Hermes (Main View) -->
|
||||
<div id="tab-team" class="tab-content active">
|
||||
<div class="section-category">
|
||||
<div class="section-category-title">ОРКЕСТРАТОР</div>
|
||||
<div class="section-category-count" id="count-orch">2 слота</div>
|
||||
</div>
|
||||
<div class="cards-grid" id="grid-team-orch"></div>
|
||||
|
||||
<div class="section-category">
|
||||
<div class="section-category-title">СУБАГЕНТЫ (ИСПОЛНИТЕЛИ)</div>
|
||||
<div class="section-category-count" id="count-sub">6 слотов</div>
|
||||
</div>
|
||||
<div class="cards-grid" id="grid-team-sub"></div>
|
||||
|
||||
<div class="section-category">
|
||||
<div class="section-category-title">РЕЗЕРВ И ЗАПАСНЫЕ АККАУНТЫ</div>
|
||||
<div class="section-category-count" id="count-spare">5 слотов</div>
|
||||
</div>
|
||||
<div class="cards-grid" id="grid-team-spare"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Все Аккаунты (Provider View) -->
|
||||
<div id="tab-accounts" class="tab-content">
|
||||
<div class="section-category">
|
||||
<div class="section-category-title">Google Antigravity Profiles</div>
|
||||
</div>
|
||||
<div class="cards-grid" id="grid-prov-antigravity"></div>
|
||||
|
||||
<div class="section-category">
|
||||
<div class="section-category-title">OpenAI Codex Profiles</div>
|
||||
</div>
|
||||
<div class="cards-grid" id="grid-prov-openai-codex"></div>
|
||||
|
||||
<div class="section-category">
|
||||
<div class="section-category-title">OpenCode Go API Profiles</div>
|
||||
</div>
|
||||
<div class="cards-grid" id="grid-prov-opencode-go"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Маршрутизация (Routing View) -->
|
||||
<div id="tab-routing" class="tab-content">
|
||||
<div class="section-category">
|
||||
<div class="section-category-title">Цепочки Fallback для Логических Ролей</div>
|
||||
</div>
|
||||
<div id="routing-chains-container" style="display:flex; flex-direction:column; gap:16px;"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Logs Console -->
|
||||
<div class="logs-drawer" id="log-drawer">
|
||||
<div class="log-line log-info">[Hermes Hub] Готов к работе. Подключен к локальному бэкенду.</div>
|
||||
</div>
|
||||
|
||||
<!-- Unified Add Account Wizard Modal -->
|
||||
<div class="modal-overlay" id="add-modal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title" id="wizard-modal-title">Добавить аккаунт</div>
|
||||
<button class="modal-close" onclick="closeAddWizard()">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Provider & Role Selection -->
|
||||
<div class="wizard-step active" id="wiz-step-1">
|
||||
<div class="form-group">
|
||||
<label class="form-label">1. Выберите провайдера:</label>
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
<label class="radio-option">
|
||||
<input type="radio" name="wiz-prov" value="antigravity" checked>
|
||||
<div>
|
||||
<b>Google Antigravity</b>
|
||||
<div style="font-size:12px; color:var(--text-muted);">OAuth-аккаунты с доступом к Gemini 3.7 Flash / Claude</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-option">
|
||||
<input type="radio" name="wiz-prov" value="openai-codex">
|
||||
<div>
|
||||
<b>OpenAI Codex</b>
|
||||
<div style="font-size:12px; color:var(--text-muted);">Codex OAuth для ролей Оркестратора и Кодера</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-option">
|
||||
<input type="radio" name="wiz-prov" value="opencode-go">
|
||||
<div>
|
||||
<b>OpenCode Go</b>
|
||||
<div style="font-size:12px; color:var(--text-muted);">API Key с доступом к резервным моделям</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top:10px;">
|
||||
<label class="form-label">2. Как использовать этот аккаунт?</label>
|
||||
<select class="form-select" id="wiz-role-select">
|
||||
<option value="auto">● Автоматически (рекомендуется — система сама назначит роль)</option>
|
||||
<option value="main">⭐ Сделать основным аккаунтом Hermes</option>
|
||||
<option value="orchestrator">👑 Назначить главным оркестратором</option>
|
||||
<option value="coder">💻 Субагент: Кодер</option>
|
||||
<option value="reviewer">🔍 Субагент: Ревьюер</option>
|
||||
<option value="researcher">📚 Субагент: Исследователь</option>
|
||||
<option value="spare">🛡️ В резерв</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" style="margin-top:12px;" onclick="proceedToAuthStep()">Продолжить авторизацию ➔</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: OAuth Auth Flow -->
|
||||
<div class="wizard-step" id="wiz-step-2">
|
||||
<div style="display:flex; align-items:center; gap:10px; color:#38bdf8; font-weight:600;">
|
||||
<span>⏳ Ожидание авторизации в браузере...</span>
|
||||
</div>
|
||||
<div class="form-label">Откройте ссылку в нужном профиле браузера:</div>
|
||||
<div class="oauth-url-box" id="wiz-oauth-url"></div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="btn btn-primary" onclick="openWizardUrlInBrowser()">🌐 Открыть в браузере</button>
|
||||
<button class="btn" onclick="copyWizardUrl()">📋 Копировать ссылку</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Success Confirmation -->
|
||||
<div class="wizard-step" id="wiz-step-3" style="text-align:center; padding:16px;">
|
||||
<div style="font-size:36px; color:#10b981;">✔</div>
|
||||
<div style="font-size:18px; font-weight:700; margin-top:8px;" id="wiz-success-title">Аккаунт успешно добавлен!</div>
|
||||
<div style="color:var(--text-muted); font-size:13px; margin-top:6px;" id="wiz-success-sub"></div>
|
||||
<div id="wiz-duplicate-warning" style="display:none; color:#f59e0b; background:rgba(245,158,11,0.1); border:1px solid #f59e0b; padding:10px; border-radius:6px; font-size:12px; margin-top:10px;"></div>
|
||||
<button class="btn btn-success" style="margin-top:16px;" onclick="closeAddWizard()">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let teamData = null;
|
||||
let currentOAuthSessionId = null;
|
||||
let currentOAuthUrl = null;
|
||||
let oauthPollInterval = null;
|
||||
|
||||
function log(msg, type = "info") {
|
||||
const drawer = document.getElementById("log-drawer");
|
||||
const line = document.createElement("div");
|
||||
line.className = `log-line log-${type}`;
|
||||
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
||||
drawer.appendChild(line);
|
||||
drawer.scrollTop = drawer.scrollHeight;
|
||||
}
|
||||
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-content").forEach(c => c.classList.remove("active"));
|
||||
event.currentTarget.classList.add("active");
|
||||
document.getElementById(`tab-${tabId}`).classList.add("active");
|
||||
}
|
||||
|
||||
async function loadAllData() {
|
||||
try {
|
||||
const [teamRes, statusRes, routingRes] = await Promise.all([
|
||||
fetch("/api/team"),
|
||||
fetch("/api/status"),
|
||||
fetch("/api/routing")
|
||||
]);
|
||||
|
||||
teamData = await teamRes.json();
|
||||
const statusData = await statusRes.json();
|
||||
const routingData = await routingRes.json();
|
||||
|
||||
// Update Summary Stats
|
||||
document.getElementById("stat-total").textContent = teamData.summary.total;
|
||||
document.getElementById("stat-auth").textContent = teamData.summary.active_authenticated;
|
||||
document.getElementById("stat-needs-auth").textContent = teamData.summary.needs_auth;
|
||||
document.getElementById("stat-main-ag").textContent = teamData.summary.main_antigravity || "-";
|
||||
|
||||
renderTeamView(teamData);
|
||||
renderProviderTabs(statusData);
|
||||
renderRoutingView(routingData);
|
||||
log("Статус команды и аккаунтов обновлен.", "info");
|
||||
} catch (err) {
|
||||
log(`Ошибка обновления: ${err}`, "fail");
|
||||
}
|
||||
}
|
||||
|
||||
function renderTeamView(data) {
|
||||
renderCardsGrid("grid-team-orch", data.orchestrator, true);
|
||||
renderCardsGrid("grid-team-sub", data.subagents, false);
|
||||
renderCardsGrid("grid-team-spare", data.spares, false);
|
||||
}
|
||||
|
||||
function renderCardsGrid(containerId, cards, isOrchSection) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
|
||||
cards.forEach(c => {
|
||||
const card = document.createElement("div");
|
||||
card.className = `team-card ${c.is_main ? 'is-main' : ''} ${c.logical_role === 'orchestrator' ? 'is-orchestrator' : ''}`;
|
||||
|
||||
const statusClass = c.authenticated ? "badge-healthy" : (c.enabled ? "badge-unauth" : "badge-spare");
|
||||
const statusText = c.authenticated ? "ГОТОВ К РАБОТЕ" : (c.enabled ? "ТРЕБУЕТСЯ ВХОД" : "РЕЗЕРВ");
|
||||
|
||||
let modelsHtml = (c.preferred_models || ["gemini-3.7-flash"]).map(m => `<span class="model-pill">${m}</span>`).join("");
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-top">
|
||||
<div>
|
||||
<div class="card-role-title">${c.display_name}</div>
|
||||
<div class="card-tech-id">${c.provider_label} • ${c.profile_id}</div>
|
||||
</div>
|
||||
<div class="card-badges">
|
||||
${c.is_main ? '<div class="badge-main">★ MAIN</div>' : ''}
|
||||
<div class="badge-status ${statusClass}">${statusText}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="card-row"><span>Аккаунт:</span><span class="card-val">${c.identity}</span></div>
|
||||
<div class="card-row"><span>Модели:</span></div>
|
||||
<div class="models-pill-box">${modelsHtml}</div>
|
||||
<div class="test-result-box" id="test-res-${c.profile_id}"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
${c.authenticated ? `<button class="btn btn-sm" onclick="runSingleTest('${c.provider}', '${c.profile_id}')">⚡ Тест</button>` : ''}
|
||||
${!c.is_main && c.authenticated ? `<button class="btn btn-sm" onclick="setMainProfile('${c.provider}', '${c.profile_id}')">⭐ Сделать основным</button>` : ''}
|
||||
${c.logical_role !== 'orchestrator' && c.authenticated ? `<button class="btn btn-sm" onclick="setOrchestrator('${c.profile_id}')">👑 Оркестратор</button>` : ''}
|
||||
${!c.authenticated && c.provider === 'antigravity' ? `<button class="btn btn-sm btn-primary" onclick="openAddWizardForSlot('${c.profile_id}')">🔑 Подключить</button>` : ''}
|
||||
${c.authenticated ? `<button class="btn btn-sm btn-danger" onclick="deleteProfile('${c.provider}', '${c.profile_id}')">🗑️</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderProviderTabs(statusData) {
|
||||
renderCardsGrid("grid-prov-antigravity", statusData.providers.antigravity, false);
|
||||
renderCardsGrid("grid-prov-openai-codex", statusData.providers["openai-codex"], false);
|
||||
renderCardsGrid("grid-prov-opencode-go", statusData.providers["opencode-go"], false);
|
||||
}
|
||||
|
||||
function renderRoutingView(data) {
|
||||
const container = document.getElementById("routing-chains-container");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
|
||||
for (const rname in data.roles) {
|
||||
const r = data.roles[rname];
|
||||
const box = document.createElement("div");
|
||||
box.className = "team-card";
|
||||
const chainHtml = (r.chain_cards || []).map((item, idx) =>
|
||||
`<span style="color:${idx === 0 ? '#38bdf8' : '#e2e8f0'}; font-weight:700;">${item.display_name}</span>`
|
||||
).join(" → ");
|
||||
|
||||
box.innerHTML = `
|
||||
<div class="card-role-title" style="text-transform:uppercase;">РОЛЬ: ${rname}</div>
|
||||
<div style="font-size:14px; margin-top:8px;">
|
||||
Цепочка выполнения: ${chainHtml}
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
|
||||
Сессионная фиксация (Session Affinity): ${r.session_affinity ? 'Включена' : 'Отключена'} | Лимит переключений: ${r.max_failover}
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(box);
|
||||
}
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
async function runSingleTest(provider, profileId) {
|
||||
const resBox = document.getElementById(`test-res-${profileId}`);
|
||||
if (resBox) {
|
||||
resBox.style.display = "block";
|
||||
resBox.innerHTML = "⏳ Тестирование live inference...";
|
||||
}
|
||||
|
||||
log(`[Тест] Отправка запроса на профиль '${profileId}'...`, "info");
|
||||
try {
|
||||
const res = await fetch("/api/profile/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, profile_id: profileId })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (resBox) {
|
||||
resBox.innerHTML = `<span style="color:#34d399; font-weight:700;">✔ PASS (${data.duration_sec}s)</span> • <i>${data.response}</i>`;
|
||||
}
|
||||
log(`[PASS] ${profileId} (${data.duration_sec}s): "${data.response}"`, "pass");
|
||||
} else {
|
||||
if (resBox) {
|
||||
resBox.innerHTML = `<span style="color:#f87171; font-weight:700;">✖ ${data.error}</span>`;
|
||||
}
|
||||
log(`[FAIL] ${profileId}: ${data.error}`, "fail");
|
||||
}
|
||||
} catch (err) {
|
||||
if (resBox) resBox.innerHTML = `<span style="color:#f87171;">Ошибка вызова: ${err}</span>`;
|
||||
log(`[FAIL] Ошибка вызова: ${err}`, "fail");
|
||||
}
|
||||
}
|
||||
|
||||
async function setMainProfile(provider, profileId) {
|
||||
log(`Назначение '${profileId}' основным аккаунтом Hermes...`, "info");
|
||||
try {
|
||||
const res = await fetch("/api/profile/set-main", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, profile_id: profileId })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
log(`[PASS] ${data.message}`, "pass");
|
||||
loadAllData();
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Ошибка: ${err}`, "fail");
|
||||
}
|
||||
}
|
||||
|
||||
async function setOrchestrator(profileId) {
|
||||
log(`Назначение '${profileId}' главным оркестратором роутера...`, "info");
|
||||
try {
|
||||
const res = await fetch("/api/profile/set-orchestrator", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile_id: profileId })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
log(`[PASS] ${data.message}`, "pass");
|
||||
loadAllData();
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Ошибка: ${err}`, "fail");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProfile(provider, profileId) {
|
||||
if (!confirm(`Очистить сохраненные данные для '${profileId}'?`)) return;
|
||||
try {
|
||||
const res = await fetch("/api/profile/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, profile_id: profileId })
|
||||
});
|
||||
const data = await res.json();
|
||||
log(data.message, "info");
|
||||
loadAllData();
|
||||
} catch (err) {
|
||||
log(`Ошибка удаления: ${err}`, "fail");
|
||||
}
|
||||
}
|
||||
|
||||
/* Add Account Wizard */
|
||||
let selectedSlotId = null;
|
||||
|
||||
function openAddWizard() {
|
||||
selectedSlotId = null;
|
||||
document.getElementById("wiz-step-1").classList.add("active");
|
||||
document.getElementById("wiz-step-2").classList.remove("active");
|
||||
document.getElementById("wiz-step-3").classList.remove("active");
|
||||
document.getElementById("add-modal").classList.add("active");
|
||||
}
|
||||
|
||||
function openAddWizardForSlot(slotId) {
|
||||
openAddWizard();
|
||||
selectedSlotId = slotId;
|
||||
}
|
||||
|
||||
function closeAddWizard() {
|
||||
document.getElementById("add-modal").classList.remove("active");
|
||||
if (oauthPollInterval) {
|
||||
clearInterval(oauthPollInterval);
|
||||
oauthPollInterval = null;
|
||||
}
|
||||
if (currentOAuthSessionId) {
|
||||
fetch(`/api/antigravity/oauth/cancel/${currentOAuthSessionId}`, { method: "POST" });
|
||||
currentOAuthSessionId = null;
|
||||
}
|
||||
loadAllData();
|
||||
}
|
||||
|
||||
async function proceedToAuthStep() {
|
||||
const prov = document.querySelector('input[name="wiz-prov"]:checked').value;
|
||||
const role = document.getElementById("wiz-role-select").value;
|
||||
|
||||
if (prov === "antigravity") {
|
||||
log("Инициализация OAuth для Antigravity...", "info");
|
||||
try {
|
||||
const res = await fetch("/api/antigravity/oauth/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile_id: selectedSlotId, requested_role: role })
|
||||
});
|
||||
const data = await res.json();
|
||||
currentOAuthSessionId = data.session_id;
|
||||
currentOAuthUrl = data.auth_url;
|
||||
|
||||
document.getElementById("wiz-oauth-url").textContent = currentOAuthUrl;
|
||||
document.getElementById("wiz-step-1").classList.remove("active");
|
||||
document.getElementById("wiz-step-2").classList.add("active");
|
||||
|
||||
// Start polling
|
||||
oauthPollInterval = setInterval(pollWizardOAuth, 1500);
|
||||
} catch (err) {
|
||||
log(`Ошибка старта OAuth: ${err}`, "fail");
|
||||
}
|
||||
} else {
|
||||
alert("Провайдер Codex и OpenCode Go будет доступен на следующем этапе.");
|
||||
}
|
||||
}
|
||||
|
||||
function openWizardUrlInBrowser() {
|
||||
if (currentOAuthUrl) window.open(currentOAuthUrl, "_blank");
|
||||
}
|
||||
|
||||
function copyWizardUrl() {
|
||||
if (currentOAuthUrl) {
|
||||
navigator.clipboard.writeText(currentOAuthUrl);
|
||||
log("Ссылка авторизации скопирована в буфер обмена.", "pass");
|
||||
}
|
||||
}
|
||||
|
||||
async function pollWizardOAuth() {
|
||||
if (!currentOAuthSessionId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/antigravity/oauth/poll/${currentOAuthSessionId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status === "completed") {
|
||||
clearInterval(oauthPollInterval);
|
||||
oauthPollInterval = null;
|
||||
log(`[PASS] Авторизация успешна! Аккаунт: ${data.completed_info?.email_masked || 'OK'}`, "pass");
|
||||
|
||||
document.getElementById("wiz-step-2").classList.remove("active");
|
||||
document.getElementById("wiz-step-3").classList.add("active");
|
||||
document.getElementById("wiz-success-sub").textContent = `Привязан аккаунт: ${data.completed_info?.email_masked || 'Google Account'}`;
|
||||
|
||||
if (data.duplicate_warning) {
|
||||
const dupBox = document.getElementById("wiz-duplicate-warning");
|
||||
dupBox.style.display = "block";
|
||||
dupBox.textContent = `Внимание: ${data.duplicate_warning}`;
|
||||
}
|
||||
} else if (data.status === "failed") {
|
||||
clearInterval(oauthPollInterval);
|
||||
oauthPollInterval = null;
|
||||
log(`[FAIL] Авторизация не удалась: ${data.error_msg}`, "fail");
|
||||
alert(`Ошибка авторизации: ${data.error_msg}`);
|
||||
closeAddWizard();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
window.onload = () => {
|
||||
loadAllData();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
327
src/antigravity_provider/router/gui_server.py
Normal file
327
src/antigravity_provider/router/gui_server.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"""Hermes Account Manager: Local Cockpit GUI Server (FastAPI + Embedded Reactive Dashboard)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from antigravity_provider.router.router_config import RouterConfig, load_router_config
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
|
||||
from antigravity_provider.router.profile_oauth import start_profile_oauth, get_oauth_session
|
||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||
from antigravity_provider.router.router_engine import get_router_engine
|
||||
from antigravity_provider.router.adapters import get_adapter
|
||||
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter
|
||||
|
||||
logger = logging.getLogger("hermes.router.gui")
|
||||
|
||||
app = FastAPI(title="Hermes Hub", description="Multi-Agent & Multi-Provider Control Hub", version="1.3.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class SetMainRequest(BaseModel):
|
||||
provider: str
|
||||
profile_id: str
|
||||
|
||||
|
||||
class SetOrchestratorRequest(BaseModel):
|
||||
profile_id: str
|
||||
|
||||
|
||||
class TestProfileRequest(BaseModel):
|
||||
provider: str
|
||||
profile_id: str
|
||||
|
||||
|
||||
class DeleteProfileRequest(BaseModel):
|
||||
provider: str
|
||||
profile_id: str
|
||||
|
||||
|
||||
class StartOAuthRequest(BaseModel):
|
||||
profile_id: Optional[str] = None
|
||||
requested_role: Optional[str] = "auto"
|
||||
|
||||
|
||||
class SetKeyRequest(BaseModel):
|
||||
profile_id: Optional[str] = None
|
||||
api_key: str
|
||||
requested_role: Optional[str] = "auto"
|
||||
|
||||
|
||||
@app.get("/api/team")
|
||||
def get_team_view() -> Dict[str, Any]:
|
||||
"""Return the structured Hermes Team view with human-readable roles and cards."""
|
||||
return AutoAssigner.build_team_hierarchy()
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
def get_all_status() -> Dict[str, Any]:
|
||||
config = load_router_config()
|
||||
engine = get_router_engine()
|
||||
|
||||
main_ag = ProfileAuthManager.get_main_profile("antigravity")
|
||||
main_codex = ProfileAuthManager.get_main_profile("openai-codex")
|
||||
|
||||
result = {
|
||||
"providers": {
|
||||
"antigravity": [],
|
||||
"openai-codex": [],
|
||||
"opencode-go": [],
|
||||
},
|
||||
"main_profiles": {
|
||||
"antigravity": main_ag,
|
||||
"openai-codex": main_codex,
|
||||
},
|
||||
"stats": {
|
||||
"total_profiles": len(config.profiles),
|
||||
"authenticated_profiles": 0,
|
||||
}
|
||||
}
|
||||
|
||||
# Discover logical role assigned to each profile
|
||||
role_assignments = {}
|
||||
for rname, rpol in config.roles.items():
|
||||
for idx, pid in enumerate(rpol.preferred_chain):
|
||||
tag = f"{rname} (primary)" if idx == 0 else f"{rname} (fallback {idx})"
|
||||
role_assignments.setdefault(pid, []).append(tag)
|
||||
|
||||
for pid, pcfg in sorted(config.profiles.items()):
|
||||
prov = pcfg.provider
|
||||
if prov not in result["providers"]:
|
||||
result["providers"][prov] = []
|
||||
|
||||
precord = engine.health.get_or_create(pid)
|
||||
is_main = (pid == main_ag and prov == "antigravity") or (pid == main_codex and prov == "openai-codex")
|
||||
|
||||
# Live credential verification
|
||||
auth_status = ProfileAuthManager.get_profile_status(prov, pid)
|
||||
is_auth = auth_status.get("authenticated", False)
|
||||
if is_auth and pcfg.enabled:
|
||||
result["stats"]["authenticated_profiles"] += 1
|
||||
|
||||
identity = auth_status.get("email_masked") or auth_status.get("account_id_masked") or auth_status.get("error") or "Не авторизован"
|
||||
display_name, log_role, tier = AutoAssigner.get_display_name_and_role(pid)
|
||||
|
||||
# Quota and cooldown
|
||||
cooldown_remaining = max([int(f.reset_at - time.time()) for f in precord.families.values() if f.reset_at and f.reset_at > time.time()] or [0])
|
||||
|
||||
card = {
|
||||
"profile_id": pid,
|
||||
"display_name": display_name,
|
||||
"provider": prov,
|
||||
"enabled": pcfg.enabled,
|
||||
"is_main": is_main,
|
||||
"account_id": pcfg.account_id,
|
||||
"identity": identity,
|
||||
"authenticated": is_auth,
|
||||
"health_state": precord.overall_state,
|
||||
"cooldown_remaining_sec": cooldown_remaining,
|
||||
"preferred_models": pcfg.preferred_models,
|
||||
"discovered_models": pcfg.preferred_models or ["gemini-3.7-flash"],
|
||||
"capabilities": pcfg.capabilities,
|
||||
"assigned_roles": role_assignments.get(pid, [log_role]),
|
||||
"storage_path": auth_status.get("storage", "-"),
|
||||
}
|
||||
result["providers"][prov].append(card)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/profile/set-main")
|
||||
def set_main_profile(req: SetMainRequest) -> Dict[str, Any]:
|
||||
ok, msg = ProfileAuthManager.set_main_profile(req.provider, req.profile_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
return {"success": True, "message": msg}
|
||||
|
||||
|
||||
@app.post("/api/profile/set-orchestrator")
|
||||
def set_orchestrator(req: SetOrchestratorRequest) -> Dict[str, Any]:
|
||||
ok, msg = AutoAssigner.set_primary_orchestrator(req.profile_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
return {"success": True, "message": msg}
|
||||
|
||||
|
||||
@app.post("/api/profile/test")
|
||||
def test_profile(req: TestProfileRequest) -> Dict[str, Any]:
|
||||
"""Test a profile using existing credentials. NEVER triggers login or OAuth flow."""
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(req.profile_id)
|
||||
if not pcfg:
|
||||
raise HTTPException(status_code=404, detail=f"Profile '{req.profile_id}' not found")
|
||||
|
||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, req.profile_id)
|
||||
if not status.get("authenticated"):
|
||||
return {
|
||||
"success": False,
|
||||
"profile_id": req.profile_id,
|
||||
"auth_status": "AUTH REQUIRED",
|
||||
"error": "Профиль не авторизован. Нажмите 'Подключить аккаунт'.",
|
||||
}
|
||||
|
||||
adapter = get_adapter(pcfg.provider)
|
||||
model = pcfg.preferred_models[0] if pcfg.preferred_models else "default"
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
resp = adapter.invoke(pcfg, {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{req.profile_id}"}],
|
||||
"temperature": 0.1,
|
||||
})
|
||||
el = round(time.time() - t0, 2)
|
||||
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
||||
return {
|
||||
"success": True,
|
||||
"profile_id": req.profile_id,
|
||||
"identity": status.get("email_masked") or status.get("account_id_masked"),
|
||||
"model": model,
|
||||
"duration_sec": el,
|
||||
"response": content[:120],
|
||||
}
|
||||
except Exception as e:
|
||||
el = round(time.time() - t0, 2)
|
||||
return {
|
||||
"success": False,
|
||||
"profile_id": req.profile_id,
|
||||
"identity": status.get("email_masked") or status.get("account_id_masked"),
|
||||
"model": model,
|
||||
"duration_sec": el,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/profile/delete")
|
||||
def delete_profile(req: DeleteProfileRequest) -> Dict[str, Any]:
|
||||
auth_p = ProfileAuthManager.get_profile_dir(req.provider, req.profile_id) / "auth.json"
|
||||
if auth_p.is_file():
|
||||
try:
|
||||
auth_p.unlink()
|
||||
return {"success": True, "message": f"Учетные данные для '{req.profile_id}' очищены"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete {auth_p}: {e}")
|
||||
return {"success": True, "message": f"Учетные данные для '{req.profile_id}' отсутствовали"}
|
||||
|
||||
|
||||
@app.post("/api/antigravity/oauth/start")
|
||||
def start_oauth(req: StartOAuthRequest) -> Dict[str, Any]:
|
||||
profile_id = req.profile_id
|
||||
if not profile_id:
|
||||
profile_id = AutoAssigner.find_free_slot("antigravity", req.requested_role or "auto")
|
||||
|
||||
if not profile_id:
|
||||
raise HTTPException(status_code=400, detail="Нет свободных слотов для Antigravity аккаунтов")
|
||||
|
||||
try:
|
||||
session_id, auth_url = start_profile_oauth(profile_id)
|
||||
display_name, _, _ = AutoAssigner.get_display_name_and_role(profile_id)
|
||||
return {
|
||||
"success": True,
|
||||
"session_id": session_id,
|
||||
"auth_url": auth_url,
|
||||
"profile_id": profile_id,
|
||||
"display_name": display_name,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start OAuth session: {e}")
|
||||
|
||||
|
||||
@app.get("/api/antigravity/oauth/poll/{session_id}")
|
||||
def poll_oauth(session_id: str) -> Dict[str, Any]:
|
||||
session = get_oauth_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="OAuth session not found")
|
||||
|
||||
duplicate_warning = None
|
||||
if session.status == "completed" and session.completed_profile_info:
|
||||
raw_email = session.completed_profile_info.get("email") or session.completed_profile_info.get("email_masked")
|
||||
dup_pid = AutoAssigner.check_duplicate_identity("antigravity", raw_email, exclude_profile_id=session.profile_id)
|
||||
if dup_pid:
|
||||
dup_name, _, _ = AutoAssigner.get_display_name_and_role(dup_pid)
|
||||
duplicate_warning = f"Этот аккаунт уже привязан к '{dup_name}' ({dup_pid})."
|
||||
|
||||
return {
|
||||
"status": session.status,
|
||||
"error_msg": session.error_msg,
|
||||
"profile_id": session.profile_id,
|
||||
"completed_info": session.completed_profile_info,
|
||||
"duplicate_warning": duplicate_warning,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/antigravity/oauth/cancel/{session_id}")
|
||||
def cancel_oauth(session_id: str) -> Dict[str, Any]:
|
||||
session = get_oauth_session(session_id)
|
||||
if session:
|
||||
session.cancel()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@app.get("/api/routing")
|
||||
def get_routing_config() -> Dict[str, Any]:
|
||||
config = load_router_config()
|
||||
roles = {}
|
||||
for rname, rpol in config.roles.items():
|
||||
chain_cards = []
|
||||
for pid in rpol.preferred_chain:
|
||||
dname, _, _ = AutoAssigner.get_display_name_and_role(pid)
|
||||
chain_cards.append({"profile_id": pid, "display_name": dname})
|
||||
roles[rname] = {
|
||||
"chain": rpol.preferred_chain,
|
||||
"chain_cards": chain_cards,
|
||||
"default_model": rpol.default_model,
|
||||
"max_failover": rpol.max_failover_attempts,
|
||||
"session_affinity": rpol.session_affinity_enabled,
|
||||
}
|
||||
return {"roles": roles}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> str:
|
||||
html_path = Path(__file__).resolve().parent / "gui_cockpit.html"
|
||||
if html_path.is_file():
|
||||
return html_path.read_text(encoding="utf-8")
|
||||
return "<h1>Hermes Hub UI Not Found</h1>"
|
||||
|
||||
|
||||
def run_gui_server(host: str = "127.0.0.1", port: int = 8765, open_browser: bool = True) -> None:
|
||||
"""Launch the GUI server and open in default browser."""
|
||||
import uvicorn
|
||||
|
||||
url = f"http://{host}:{port}"
|
||||
print(f"\n" + "=" * 70)
|
||||
print(f" HERMES HUB (MULTI-AGENT & MULTI-PROVIDER CONTROL HUB)")
|
||||
print(f" URL: {url}")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
if open_browser:
|
||||
def _open():
|
||||
time.sleep(1.0)
|
||||
webbrowser.open(url)
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
uvicorn.run(app, host=host, port=port, log_level="warning")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_gui_server()
|
||||
326
src/antigravity_provider/router/health_tracker.py
Normal file
326
src/antigravity_provider/router/health_tracker.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
"""Health state and quota tracking per profile and model family."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
HEALTHY = "healthy"
|
||||
IN_USE = "in-use"
|
||||
QUOTA_EXHAUSTED = "quota-exhausted"
|
||||
RATE_LIMITED = "rate-limited"
|
||||
COOLDOWN = "cooldown"
|
||||
AUTH_REQUIRED = "auth-required"
|
||||
DISABLED = "disabled"
|
||||
UNHEALTHY = "unhealthy"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FamilyHealthRecord:
|
||||
family: str
|
||||
state: str = HEALTHY
|
||||
reset_at: Optional[float] = None
|
||||
reason: Optional[str] = None
|
||||
last_error: Optional[str] = None
|
||||
error_count: int = 0
|
||||
success_count: int = 0
|
||||
simulated: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfileHealthRecord:
|
||||
profile_id: str
|
||||
overall_state: str = HEALTHY
|
||||
families: dict[str, FamilyHealthRecord] = field(default_factory=dict)
|
||||
active_leases: int = 0
|
||||
last_used: Optional[float] = None
|
||||
last_success: Optional[float] = None
|
||||
last_error: Optional[str] = None
|
||||
simulated: bool = False
|
||||
|
||||
|
||||
def extract_model_family(model_name: Optional[str]) -> str:
|
||||
"""Extract model family prefix (e.g. gemini, claude, gpt, deepseek, kimi, qwen, grok, glm)."""
|
||||
if not model_name:
|
||||
return "default"
|
||||
m = model_name.lower().replace("google-antigravity/", "").replace("openai/", "").replace("moonshotai/", "")
|
||||
for family in ("gemini", "claude", "gpt", "o3", "o1", "deepseek", "kimi", "qwen", "grok", "glm", "mimo", "minimax"):
|
||||
if family in m:
|
||||
return family
|
||||
return "default"
|
||||
|
||||
|
||||
class HealthTracker:
|
||||
"""Thread-safe health tracker for router profiles and model families."""
|
||||
|
||||
def __init__(self, state_file: Optional[Path] = None):
|
||||
if state_file is None:
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()
|
||||
if os.name == "nt" and "HERMES_HOME" not in os.environ:
|
||||
local_app = os.environ.get("LOCALAPPDATA", "")
|
||||
if local_app and (Path(local_app) / "hermes").exists():
|
||||
hermes_home = Path(local_app) / "hermes"
|
||||
state_file = hermes_home / "router_state.json"
|
||||
|
||||
self.state_file = state_file
|
||||
self._lock = threading.RLock()
|
||||
self._profiles: dict[str, ProfileHealthRecord] = {}
|
||||
self._load_state()
|
||||
|
||||
def _load_state(self) -> None:
|
||||
if not self.state_file.is_file():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||||
for pid, pdata in raw.get("profiles", {}).items():
|
||||
record = ProfileHealthRecord(
|
||||
profile_id=pid,
|
||||
overall_state=pdata.get("overall_state", HEALTHY),
|
||||
last_used=pdata.get("last_used"),
|
||||
last_success=pdata.get("last_success"),
|
||||
last_error=pdata.get("last_error"),
|
||||
simulated=pdata.get("simulated", False),
|
||||
)
|
||||
for fname, fdata in pdata.get("families", {}).items():
|
||||
record.families[fname] = FamilyHealthRecord(
|
||||
family=fname,
|
||||
state=fdata.get("state", HEALTHY),
|
||||
reset_at=fdata.get("reset_at"),
|
||||
reason=fdata.get("reason"),
|
||||
last_error=fdata.get("last_error"),
|
||||
error_count=fdata.get("error_count", 0),
|
||||
success_count=fdata.get("success_count", 0),
|
||||
simulated=fdata.get("simulated", False),
|
||||
)
|
||||
self._profiles[pid] = record
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _save_state(self) -> None:
|
||||
try:
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
data: dict[str, Any] = {"profiles": {}}
|
||||
for pid, precord in self._profiles.items():
|
||||
pdict = {
|
||||
"overall_state": precord.overall_state,
|
||||
"last_used": precord.last_used,
|
||||
"last_success": precord.last_success,
|
||||
"last_error": precord.last_error,
|
||||
"simulated": precord.simulated,
|
||||
"families": {},
|
||||
}
|
||||
for fname, frecord in precord.families.items():
|
||||
pdict["families"][fname] = {
|
||||
"state": frecord.state,
|
||||
"reset_at": frecord.reset_at,
|
||||
"reason": frecord.reason,
|
||||
"last_error": frecord.last_error,
|
||||
"error_count": frecord.error_count,
|
||||
"success_count": frecord.success_count,
|
||||
"simulated": frecord.simulated,
|
||||
}
|
||||
data["profiles"][pid] = pdict
|
||||
self.state_file.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_or_create(self, profile_id: str) -> ProfileHealthRecord:
|
||||
with self._lock:
|
||||
if profile_id not in self._profiles:
|
||||
self._profiles[profile_id] = ProfileHealthRecord(profile_id=profile_id)
|
||||
return self._profiles[profile_id]
|
||||
|
||||
def is_healthy(self, profile_id: str, model_name: Optional[str] = None) -> bool:
|
||||
"""Check if profile (and specified model family) is healthy and ready for requests."""
|
||||
with self._lock:
|
||||
record = self.get_or_create(profile_id)
|
||||
now = time.time()
|
||||
|
||||
if record.overall_state == DISABLED:
|
||||
return False
|
||||
|
||||
# Check overall reset_at expiration
|
||||
if record.overall_state in (QUOTA_EXHAUSTED, RATE_LIMITED, COOLDOWN):
|
||||
pass # Family check will evaluate expiration
|
||||
|
||||
family = extract_model_family(model_name)
|
||||
if family in record.families:
|
||||
frec = record.families[family]
|
||||
if frec.state == QUOTA_EXHAUSTED:
|
||||
if frec.reset_at and now >= frec.reset_at:
|
||||
# Expired cooldown -> reset to healthy
|
||||
frec.state = HEALTHY
|
||||
frec.reset_at = None
|
||||
frec.simulated = False
|
||||
self._save_state()
|
||||
return True
|
||||
return False
|
||||
if frec.state in (RATE_LIMITED, COOLDOWN):
|
||||
if frec.reset_at and now >= frec.reset_at:
|
||||
frec.state = HEALTHY
|
||||
frec.reset_at = None
|
||||
frec.simulated = False
|
||||
self._save_state()
|
||||
return True
|
||||
return False
|
||||
if frec.state in (AUTH_REQUIRED, UNHEALTHY, DISABLED):
|
||||
return False
|
||||
|
||||
if record.overall_state in (AUTH_REQUIRED, UNHEALTHY, DISABLED):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def mark_success(self, profile_id: str, model_name: Optional[str] = None) -> None:
|
||||
with self._lock:
|
||||
record = self.get_or_create(profile_id)
|
||||
now = time.time()
|
||||
record.last_used = now
|
||||
record.last_success = now
|
||||
record.overall_state = HEALTHY
|
||||
record.simulated = False
|
||||
|
||||
family = extract_model_family(model_name)
|
||||
if family not in record.families:
|
||||
record.families[family] = FamilyHealthRecord(family=family)
|
||||
frec = record.families[family]
|
||||
frec.state = HEALTHY
|
||||
frec.reset_at = None
|
||||
frec.success_count += 1
|
||||
frec.simulated = False
|
||||
self._save_state()
|
||||
|
||||
def mark_quota_exhausted(
|
||||
self,
|
||||
profile_id: str,
|
||||
model_name: Optional[str] = None,
|
||||
duration: int = 1800,
|
||||
reason: Optional[str] = None,
|
||||
simulated: bool = False,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
record = self.get_or_create(profile_id)
|
||||
now = time.time()
|
||||
reset_at = now + duration
|
||||
record.last_used = now
|
||||
record.last_error = reason or "Quota exhausted"
|
||||
record.simulated = simulated
|
||||
|
||||
family = extract_model_family(model_name)
|
||||
if family not in record.families:
|
||||
record.families[family] = FamilyHealthRecord(family=family)
|
||||
frec = record.families[family]
|
||||
frec.state = QUOTA_EXHAUSTED
|
||||
frec.reset_at = reset_at
|
||||
frec.reason = reason or "Quota exhausted"
|
||||
frec.error_count += 1
|
||||
frec.simulated = simulated
|
||||
|
||||
# If default/primary family exhausted, reflect in overall state
|
||||
record.overall_state = QUOTA_EXHAUSTED
|
||||
self._save_state()
|
||||
|
||||
def mark_rate_limited(
|
||||
self,
|
||||
profile_id: str,
|
||||
model_name: Optional[str] = None,
|
||||
duration: int = 60,
|
||||
reason: Optional[str] = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
record = self.get_or_create(profile_id)
|
||||
now = time.time()
|
||||
reset_at = now + duration
|
||||
record.last_used = now
|
||||
record.last_error = reason or "Rate limited"
|
||||
|
||||
family = extract_model_family(model_name)
|
||||
if family not in record.families:
|
||||
record.families[family] = FamilyHealthRecord(family=family)
|
||||
frec = record.families[family]
|
||||
frec.state = RATE_LIMITED
|
||||
frec.reset_at = reset_at
|
||||
frec.reason = reason or "Rate limited (HTTP 429)"
|
||||
frec.error_count += 1
|
||||
|
||||
record.overall_state = RATE_LIMITED
|
||||
self._save_state()
|
||||
|
||||
def mark_auth_required(self, profile_id: str, reason: Optional[str] = None) -> None:
|
||||
with self._lock:
|
||||
record = self.get_or_create(profile_id)
|
||||
record.overall_state = AUTH_REQUIRED
|
||||
record.last_error = reason or "Authentication required"
|
||||
self._save_state()
|
||||
|
||||
def simulate_quota(
|
||||
self,
|
||||
profile_id: str,
|
||||
model_family: Optional[str] = None,
|
||||
duration: int = 600,
|
||||
) -> None:
|
||||
"""Simulate quota exhaustion for testing without consuming real quota."""
|
||||
self.mark_quota_exhausted(
|
||||
profile_id=profile_id,
|
||||
model_name=model_family,
|
||||
duration=duration,
|
||||
reason="[SIMULATED] Mock quota exhaustion",
|
||||
simulated=True,
|
||||
)
|
||||
|
||||
def clear_cooldown(self, profile_id: Optional[str] = None) -> None:
|
||||
"""Clear all cooldowns, rate limits, and simulated quota states."""
|
||||
with self._lock:
|
||||
if profile_id:
|
||||
if profile_id in self._profiles:
|
||||
precord = self._profiles[profile_id]
|
||||
precord.overall_state = HEALTHY
|
||||
precord.simulated = False
|
||||
for frec in precord.families.values():
|
||||
frec.state = HEALTHY
|
||||
frec.reset_at = None
|
||||
frec.simulated = False
|
||||
else:
|
||||
for precord in self._profiles.values():
|
||||
precord.overall_state = HEALTHY
|
||||
precord.simulated = False
|
||||
for frec in precord.families.values():
|
||||
frec.state = HEALTHY
|
||||
frec.reset_at = None
|
||||
frec.simulated = False
|
||||
self._save_state()
|
||||
|
||||
def get_status_summary(self) -> list[dict[str, Any]]:
|
||||
"""Return structured summary for all tracked profiles."""
|
||||
with self._lock:
|
||||
summary = []
|
||||
now = time.time()
|
||||
for pid, prec in sorted(self._profiles.items()):
|
||||
families_list = []
|
||||
for fname, frec in sorted(prec.families.items()):
|
||||
remaining = ""
|
||||
if frec.reset_at and frec.reset_at > now:
|
||||
rem_sec = int(frec.reset_at - now)
|
||||
mins = rem_sec // 60
|
||||
secs = rem_sec % 60
|
||||
remaining = f"{mins}m{secs}s"
|
||||
families_list.append({
|
||||
"family": fname,
|
||||
"state": frec.state,
|
||||
"reset_in": remaining,
|
||||
"simulated": frec.simulated,
|
||||
})
|
||||
|
||||
summary.append({
|
||||
"profile_id": pid,
|
||||
"overall_state": prec.overall_state,
|
||||
"active_leases": prec.active_leases,
|
||||
"last_error": prec.last_error,
|
||||
"simulated": prec.simulated,
|
||||
"families": families_list,
|
||||
})
|
||||
return summary
|
||||
390
src/antigravity_provider/router/profile_manager.py
Normal file
390
src/antigravity_provider/router/profile_manager.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
"""Profile Auth Manager for Hermes Multi-Provider Account Router.
|
||||
|
||||
Handles per-profile credential storage, validation, identity verification,
|
||||
and Windows Credential Manager integration for Antigravity, Codex, and OpenCode Go.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Windows API definitions for Credential Manager
|
||||
advapi32 = None
|
||||
if os.name == "nt":
|
||||
try:
|
||||
advapi32 = ctypes.windll.advapi32
|
||||
except Exception as exc:
|
||||
logger.warning("advapi32 not available: %s", exc)
|
||||
|
||||
class CREDENTIAL_ATTRIBUTE(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Keyword", wintypes.LPWSTR),
|
||||
("Flags", wintypes.DWORD),
|
||||
("ValueSize", wintypes.DWORD),
|
||||
("Value", ctypes.c_void_p),
|
||||
]
|
||||
|
||||
class CREDENTIAL(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Flags", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
("TargetName", wintypes.LPWSTR),
|
||||
("Comment", wintypes.LPWSTR),
|
||||
("LastWritten", wintypes.FILETIME),
|
||||
("CredentialBlobSize", wintypes.DWORD),
|
||||
("CredentialBlob", ctypes.c_void_p),
|
||||
("Persist", wintypes.DWORD),
|
||||
("AttributeCount", wintypes.DWORD),
|
||||
("Attributes", ctypes.c_void_p),
|
||||
("TargetAlias", wintypes.LPWSTR),
|
||||
("UserName", wintypes.LPWSTR),
|
||||
]
|
||||
|
||||
if advapi32:
|
||||
CredReadW = advapi32.CredReadW
|
||||
CredReadW.argtypes = [wintypes.LPWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.POINTER(CREDENTIAL))]
|
||||
CredReadW.restype = wintypes.BOOL
|
||||
|
||||
CredWriteW = advapi32.CredWriteW
|
||||
CredWriteW.argtypes = [ctypes.POINTER(CREDENTIAL), wintypes.DWORD]
|
||||
CredWriteW.restype = wintypes.BOOL
|
||||
|
||||
CredFree = advapi32.CredFree
|
||||
CredFree.argtypes = [ctypes.c_void_p]
|
||||
|
||||
# Global lock for credential manager swapping during subprocess execution
|
||||
_CM_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def get_hermes_base_dir() -> Path:
|
||||
"""Get the base hermes directory."""
|
||||
local_app_data = os.environ.get("LOCALAPPDATA", "")
|
||||
if local_app_data:
|
||||
return Path(local_app_data) / "hermes"
|
||||
return Path.home() / ".hermes"
|
||||
|
||||
|
||||
def get_profile_dir(provider: str, profile_id: str) -> Path:
|
||||
"""Get isolated directory for a profile."""
|
||||
base = get_hermes_base_dir()
|
||||
if provider == "antigravity":
|
||||
return base / "agy_profiles" / profile_id
|
||||
elif provider == "openai-codex":
|
||||
return base / "codex_profiles" / profile_id
|
||||
elif provider == "opencode-go":
|
||||
return base / "opengo_profiles" / profile_id
|
||||
return base / "profiles" / profile_id
|
||||
|
||||
|
||||
def get_profile_auth_path(provider: str, profile_id: str) -> Path:
|
||||
"""Get path to the profile's auth.json file."""
|
||||
return get_profile_dir(provider, profile_id) / "auth.json"
|
||||
|
||||
|
||||
def mask_email(email: str) -> str:
|
||||
"""Mask email for safe logging: och***@gmail.com."""
|
||||
if not email or "@" not in email:
|
||||
return email[:4] + "***" if email else "(none)"
|
||||
local, domain = email.split("@", 1)
|
||||
visible_len = min(4, len(local))
|
||||
return f"{local[:visible_len]}***@{domain}"
|
||||
|
||||
|
||||
def mask_id(raw_id: str) -> str:
|
||||
"""Mask user ID / sub: 10761924..."""
|
||||
if not raw_id:
|
||||
return "(none)"
|
||||
if len(raw_id) <= 8:
|
||||
return raw_id[:3] + "***"
|
||||
return f"{raw_id[:8]}..."
|
||||
|
||||
|
||||
class ProfileAuthManager:
|
||||
"""Manages credentials and authentication verification across all profiles."""
|
||||
|
||||
@staticmethod
|
||||
def read_windows_credential(target_name: str = "gemini:antigravity") -> Optional[dict]:
|
||||
"""Read a credential blob from Windows Credential Manager."""
|
||||
if not advapi32 or os.name != "nt":
|
||||
return None
|
||||
with _CM_LOCK:
|
||||
pcred = ctypes.POINTER(CREDENTIAL)()
|
||||
res = CredReadW(target_name, 1, 0, ctypes.byref(pcred))
|
||||
if not res:
|
||||
return None
|
||||
try:
|
||||
cred = pcred.contents
|
||||
blob = ctypes.string_at(cred.CredentialBlob, cred.CredentialBlobSize)
|
||||
data = json.loads(blob.decode("utf-8"))
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning("Error parsing credential blob %s: %s", target_name, e)
|
||||
return None
|
||||
finally:
|
||||
CredFree(pcred)
|
||||
|
||||
@staticmethod
|
||||
def write_windows_credential(target_name: str, auth_data: dict, user_name: str = "antigravity") -> bool:
|
||||
"""Write a credential blob to Windows Credential Manager."""
|
||||
if not advapi32 or os.name != "nt":
|
||||
return False
|
||||
with _CM_LOCK:
|
||||
blob_bytes = json.dumps(auth_data).encode("utf-8")
|
||||
buf = ctypes.create_string_buffer(blob_bytes)
|
||||
cred = CREDENTIAL()
|
||||
cred.Flags = 0
|
||||
cred.Type = 1 # CRED_TYPE_GENERIC
|
||||
cred.TargetName = target_name
|
||||
cred.Comment = "Hermes Profile Auth Managed"
|
||||
cred.CredentialBlobSize = len(blob_bytes)
|
||||
cred.CredentialBlob = ctypes.cast(buf, ctypes.c_void_p)
|
||||
cred.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE
|
||||
cred.AttributeCount = 0
|
||||
cred.Attributes = None
|
||||
cred.TargetAlias = None
|
||||
cred.UserName = user_name
|
||||
|
||||
res = CredWriteW(ctypes.byref(cred), 0)
|
||||
return bool(res)
|
||||
|
||||
@classmethod
|
||||
def get_main_profile(cls, provider: str = "antigravity") -> Optional[str]:
|
||||
"""Get the currently designated main / active profile for a provider."""
|
||||
state_file = get_hermes_base_dir() / "router_active_profile.json"
|
||||
if state_file.is_file():
|
||||
try:
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
return data.get(provider)
|
||||
except Exception:
|
||||
pass
|
||||
return "ag-orch-fallback" if provider == "antigravity" else None
|
||||
|
||||
@classmethod
|
||||
def set_main_profile(cls, provider: str, profile_id: str) -> Tuple[bool, str]:
|
||||
"""Set a profile as the main / active account for Hermes, updating Windows Credential Manager."""
|
||||
auth_data = cls.load_profile_auth(provider, profile_id)
|
||||
if not auth_data:
|
||||
return False, f"Profile '{profile_id}' has no saved authentication in {get_profile_auth_path(provider, profile_id)}"
|
||||
|
||||
if provider == "antigravity":
|
||||
ok = cls.write_windows_credential("gemini:antigravity", auth_data)
|
||||
if not ok:
|
||||
return False, "Failed to write credential to Windows Credential Manager"
|
||||
|
||||
state_file = get_hermes_base_dir() / "router_active_profile.json"
|
||||
state = {}
|
||||
if state_file.is_file():
|
||||
try:
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
state = {}
|
||||
state[provider] = profile_id
|
||||
state_file.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||
|
||||
return True, f"Profile '{profile_id}' is now the MAIN active account for {provider}"
|
||||
|
||||
@classmethod
|
||||
def save_profile_auth(cls, provider: str, profile_id: str, auth_data: dict) -> Path:
|
||||
"""Save credentials to profile-specific auth.json."""
|
||||
pdir = get_profile_dir(provider, profile_id)
|
||||
pdir.mkdir(parents=True, exist_ok=True)
|
||||
auth_file = pdir / "auth.json"
|
||||
auth_file.write_text(json.dumps(auth_data, indent=2), encoding="utf-8")
|
||||
return auth_file
|
||||
|
||||
@classmethod
|
||||
def load_profile_auth(cls, provider: str, profile_id: str) -> Optional[dict]:
|
||||
"""Load credentials from profile-specific auth.json or env/auth.json fallback."""
|
||||
auth_file = get_profile_auth_path(provider, profile_id)
|
||||
if auth_file.is_file():
|
||||
try:
|
||||
return json.loads(auth_file.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("Error reading %s: %s", auth_file, e)
|
||||
|
||||
# Fallbacks for specific providers
|
||||
if provider == "openai-codex":
|
||||
# Check env var CODEX_TOKEN_<PROFILE_ID>
|
||||
env_var = f"CODEX_TOKEN_{profile_id.upper().replace('-', '_')}"
|
||||
val = os.environ.get(env_var)
|
||||
if val:
|
||||
return {"access_token": val, "auth_mode": "env_token"}
|
||||
# Check ~/.codex/auth.json for primary profile
|
||||
if profile_id == "codex-orch":
|
||||
codex_p = Path.home() / ".codex" / "auth.json"
|
||||
if codex_p.is_file():
|
||||
try:
|
||||
return json.loads(codex_p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif provider == "opencode-go":
|
||||
env_var = f"OPENCODE_GO_KEY_{profile_id.upper().replace('-', '_')}"
|
||||
val = os.environ.get(env_var) or os.environ.get("OPENCODE_GO_API_KEY")
|
||||
if val:
|
||||
return {"api_key": val, "auth_mode": "api_key"}
|
||||
|
||||
elif provider == "antigravity":
|
||||
# For primary profile, can check current Windows Credential Manager
|
||||
if profile_id in ("ag-orch-fallback", "ag-w1"):
|
||||
cm_data = cls.read_windows_credential("gemini:antigravity")
|
||||
if cm_data:
|
||||
return cm_data
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def verify_antigravity_profile(cls, profile_id: str) -> Dict[str, Any]:
|
||||
"""Verify an Antigravity profile's credentials against Google Tokeninfo API."""
|
||||
auth = cls.load_profile_auth("antigravity", profile_id)
|
||||
if not auth or not isinstance(auth, dict):
|
||||
return {"authenticated": False, "error": "No credentials stored for profile", "profile_id": profile_id}
|
||||
|
||||
tok = auth.get("token", {}) if "token" in auth else auth
|
||||
access_token = tok.get("access_token")
|
||||
if not access_token:
|
||||
return {"authenticated": False, "error": "No access_token found", "profile_id": profile_id}
|
||||
|
||||
url = f"https://www.googleapis.com/oauth2/v3/tokeninfo?access_token={access_token}"
|
||||
req = urllib.request.Request(url)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
email = data.get("email", "(unknown email)")
|
||||
sub = data.get("sub", "(unknown sub)")
|
||||
expires_in = int(data.get("expires_in", 0))
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
"provider": "antigravity",
|
||||
"profile_id": profile_id,
|
||||
"email": email,
|
||||
"email_masked": mask_email(email),
|
||||
"account_id": sub,
|
||||
"account_id_masked": mask_id(sub),
|
||||
"expires_in": expires_in,
|
||||
"scope": data.get("scope", ""),
|
||||
"storage": str(get_profile_auth_path("antigravity", profile_id)),
|
||||
}
|
||||
except urllib.error.HTTPError as he:
|
||||
return {
|
||||
"authenticated": False,
|
||||
"error": f"HTTP {he.code}: token expired or invalid",
|
||||
"profile_id": profile_id,
|
||||
"storage": str(get_profile_auth_path("antigravity", profile_id)),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"authenticated": False,
|
||||
"error": f"Verification error: {e}",
|
||||
"profile_id": profile_id,
|
||||
"storage": str(get_profile_auth_path("antigravity", profile_id)),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def verify_codex_profile(cls, profile_id: str) -> Dict[str, Any]:
|
||||
"""Verify an OpenAI Codex profile's credentials."""
|
||||
auth = cls.load_profile_auth("openai-codex", profile_id)
|
||||
if not auth or not isinstance(auth, dict):
|
||||
return {"authenticated": False, "error": "No credentials stored for profile", "profile_id": profile_id}
|
||||
|
||||
tokens = auth.get("tokens", {}) if "tokens" in auth else auth
|
||||
id_token = tokens.get("id_token")
|
||||
account_id = tokens.get("account_id") or ""
|
||||
email = "(unknown)"
|
||||
|
||||
if id_token and "." in id_token:
|
||||
try:
|
||||
parts = id_token.split(".")
|
||||
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64).decode("utf-8", errors="ignore"))
|
||||
email = payload.get("email") or "(openai-user)"
|
||||
if not account_id:
|
||||
account_id = payload.get("sub") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not account_id and "access_token" in tokens:
|
||||
account_id = f"tok-{profile_id}"
|
||||
|
||||
if not tokens.get("access_token") and not tokens.get("api_key"):
|
||||
return {"authenticated": False, "error": "Missing access token / API key", "profile_id": profile_id}
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
"provider": "openai-codex",
|
||||
"profile_id": profile_id,
|
||||
"email": email,
|
||||
"email_masked": mask_email(email) if email != "(unknown)" else mask_id(account_id),
|
||||
"account_id": account_id,
|
||||
"account_id_masked": mask_id(account_id),
|
||||
"storage": str(get_profile_auth_path("openai-codex", profile_id)),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def verify_opencode_profile(cls, profile_id: str) -> Dict[str, Any]:
|
||||
"""Verify OpenCode Go profile credentials against models endpoint."""
|
||||
auth = cls.load_profile_auth("opencode-go", profile_id)
|
||||
if not auth or not isinstance(auth, dict):
|
||||
return {"authenticated": False, "error": "No API key stored for profile", "profile_id": profile_id}
|
||||
|
||||
api_key = auth.get("api_key") or auth.get("token")
|
||||
if not api_key:
|
||||
return {"authenticated": False, "error": "Missing API key", "profile_id": profile_id}
|
||||
|
||||
base_url = auth.get("base_url") or "https://opencode.ai/zen/go/v1"
|
||||
url = f"{base_url.rstrip('/')}/models"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
models = [m.get("id") for m in data.get("data", []) if isinstance(m, dict)]
|
||||
return {
|
||||
"authenticated": True,
|
||||
"provider": "opencode-go",
|
||||
"profile_id": profile_id,
|
||||
"email_masked": f"key:{api_key[:6]}...{api_key[-4:]}",
|
||||
"account_id": f"acc-{profile_id}",
|
||||
"account_id_masked": f"acc-{profile_id}",
|
||||
"models_count": len(models),
|
||||
"models": models,
|
||||
"storage": str(get_profile_auth_path("opencode-go", profile_id)),
|
||||
}
|
||||
except urllib.error.HTTPError as he:
|
||||
return {
|
||||
"authenticated": False,
|
||||
"error": f"HTTP {he.code}: API key rejected",
|
||||
"profile_id": profile_id,
|
||||
"storage": str(get_profile_auth_path("opencode-go", profile_id)),
|
||||
}
|
||||
except Exception as e:
|
||||
# If endpoint is network-restricted, return unauthenticated with error
|
||||
return {
|
||||
"authenticated": False,
|
||||
"error": f"Connection failed: {e}",
|
||||
"profile_id": profile_id,
|
||||
"storage": str(get_profile_auth_path("opencode-go", profile_id)),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_profile_status(cls, provider: str, profile_id: str) -> Dict[str, Any]:
|
||||
"""Get verified authentication status for any profile."""
|
||||
if provider == "antigravity":
|
||||
return cls.verify_antigravity_profile(profile_id)
|
||||
elif provider == "openai-codex":
|
||||
return cls.verify_codex_profile(profile_id)
|
||||
elif provider == "opencode-go":
|
||||
return cls.verify_opencode_profile(profile_id)
|
||||
return {"authenticated": False, "error": f"Unknown provider {provider}", "profile_id": profile_id}
|
||||
197
src/antigravity_provider/router/profile_oauth.py
Normal file
197
src/antigravity_provider/router/profile_oauth.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Profile OAuth manager for interactive Google / Antigravity account linking."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from antigravity_provider.oauth import (
|
||||
AUTH_URL,
|
||||
CALLBACK_HOST,
|
||||
CALLBACK_PATH,
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
SCOPES,
|
||||
_expires_at,
|
||||
_pkce_pair,
|
||||
exchange_code_for_tokens,
|
||||
fetch_user_email,
|
||||
)
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email
|
||||
|
||||
logger = logging.getLogger("hermes.router.profile_oauth")
|
||||
|
||||
_ACTIVE_OAUTH_SESSIONS: Dict[str, "ProfileOAuthSession"] = {}
|
||||
|
||||
|
||||
class _ProfileOAuthCallbackHandler(BaseHTTPRequestHandler):
|
||||
server: "_ProfileOAuthServer"
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path != CALLBACK_PATH:
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
self.server.session.received_state = (params.get("state") or [None])[0]
|
||||
self.server.session.received_error = (params.get("error") or [None])[0]
|
||||
self.server.session.received_code = (params.get("code") or [None])[0]
|
||||
|
||||
body = (
|
||||
b"<!DOCTYPE html><html><head><meta charset='utf-8'><title>Hermes Account Linked</title>"
|
||||
b"<style>body{background:#0f172a;color:#f8fafc;font-family:sans-serif;display:flex;align-items:center;"
|
||||
b"justify-content:center;height:100vh;margin:0;}.card{background:#1e293b;padding:32px;border-radius:12px;"
|
||||
b"border:1px solid #334155;text-align:center;box-shadow:0 10px 25px rgba(0,0,0,0.5);}h1{color:#10b981;font-size:24px;}"
|
||||
b"p{color:#94a3b8;margin-top:12px;}</style></head><body>"
|
||||
b"<div class='card'><h1>✔ Account Authorized</h1>"
|
||||
b"<p>You can close this tab and return to the Hermes Account Manager.</p></div></body></html>"
|
||||
)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
class _ProfileOAuthServer(HTTPServer):
|
||||
def __init__(self, server_address: tuple[str, int], session: "ProfileOAuthSession"):
|
||||
self.session = session
|
||||
super().__init__(server_address, _ProfileOAuthCallbackHandler)
|
||||
|
||||
|
||||
class ProfileOAuthSession:
|
||||
"""Manages a single interactive OAuth flow for linking an Antigravity profile."""
|
||||
|
||||
def __init__(self, profile_id: str, port: int = 51121):
|
||||
self.session_id = secrets.token_urlsafe(16)
|
||||
self.profile_id = profile_id
|
||||
self.port = port
|
||||
self.state = secrets.token_urlsafe(24)
|
||||
self.verifier, self.challenge = _pkce_pair()
|
||||
self.redirect_uri = f"http://{CALLBACK_HOST}:{self.port}{CALLBACK_PATH}"
|
||||
|
||||
self.received_code: Optional[str] = None
|
||||
self.received_state: Optional[str] = None
|
||||
self.received_error: Optional[str] = None
|
||||
|
||||
self.server: Optional[_ProfileOAuthServer] = None
|
||||
self.server_thread: Optional[threading.Thread] = None
|
||||
self.status = "pending" # pending, completed, failed, cancelled
|
||||
self.error_msg: Optional[str] = None
|
||||
self.created_at = time.time()
|
||||
self.completed_profile_info: Optional[dict] = None
|
||||
|
||||
def get_auth_url(self) -> str:
|
||||
params = {
|
||||
"client_id": CLIENT_ID,
|
||||
"response_type": "code",
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"scope": " ".join(SCOPES),
|
||||
"state": self.state,
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"code_challenge": self.challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
return f"{AUTH_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def start(self) -> str:
|
||||
"""Start the background HTTP listener and return the auth URL."""
|
||||
try:
|
||||
self.server = _ProfileOAuthServer((CALLBACK_HOST, self.port), self)
|
||||
except OSError:
|
||||
# Fallback to dynamic port if default is busy
|
||||
self.server = _ProfileOAuthServer((CALLBACK_HOST, 0), self)
|
||||
self.port = self.server.server_port
|
||||
self.redirect_uri = f"http://{CALLBACK_HOST}:{self.port}{CALLBACK_PATH}"
|
||||
|
||||
self.server.timeout = 1.0
|
||||
|
||||
def _serve():
|
||||
while self.status == "pending" and time.time() - self.created_at < 300:
|
||||
if self.server:
|
||||
self.server.handle_request()
|
||||
if self.received_code or self.received_error:
|
||||
break
|
||||
|
||||
if self.received_error:
|
||||
self.status = "failed"
|
||||
self.error_msg = f"OAuth error from provider: {self.received_error}"
|
||||
elif self.received_code:
|
||||
if self.received_state != self.state:
|
||||
self.status = "failed"
|
||||
self.error_msg = "State mismatch in OAuth callback"
|
||||
else:
|
||||
self._finalize_tokens()
|
||||
elif self.status == "pending":
|
||||
self.status = "failed"
|
||||
self.error_msg = "OAuth login timed out after 5 minutes"
|
||||
|
||||
if self.server:
|
||||
self.server.server_close()
|
||||
|
||||
self.server_thread = threading.Thread(target=_serve, daemon=True)
|
||||
self.server_thread.start()
|
||||
_ACTIVE_OAUTH_SESSIONS[self.session_id] = self
|
||||
return self.get_auth_url()
|
||||
|
||||
def _finalize_tokens(self) -> None:
|
||||
"""Exchange code for tokens and save into dedicated profile."""
|
||||
try:
|
||||
tokens = exchange_code_for_tokens(
|
||||
self.received_code,
|
||||
redirect_uri=self.redirect_uri,
|
||||
code_verifier=self.verifier,
|
||||
)
|
||||
|
||||
# Format in standard gemini:antigravity shape
|
||||
auth_data = {
|
||||
"token": {
|
||||
"access_token": tokens["access_token"],
|
||||
"refresh_token": tokens["refresh_token"],
|
||||
"expiry": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(tokens["expires_at"])),
|
||||
},
|
||||
"auth_method": "oauth",
|
||||
}
|
||||
|
||||
# Save strictly to the chosen profile
|
||||
saved_path = ProfileAuthManager.save_profile_auth("antigravity", self.profile_id, auth_data)
|
||||
logger.info("Saved OAuth credentials for %s to %s", self.profile_id, saved_path)
|
||||
|
||||
# Verify and extract identity
|
||||
ver = ProfileAuthManager.verify_antigravity_profile(self.profile_id)
|
||||
self.completed_profile_info = ver
|
||||
self.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error finalizing OAuth for %s: %s", self.profile_id, e)
|
||||
self.status = "failed"
|
||||
self.error_msg = str(e)
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.status = "cancelled"
|
||||
if self.server:
|
||||
try:
|
||||
self.server.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_profile_oauth(profile_id: str) -> Tuple[str, str]:
|
||||
"""Start an OAuth flow for profile_id and return (session_id, auth_url)."""
|
||||
session = ProfileOAuthSession(profile_id)
|
||||
url = session.start()
|
||||
return session.session_id, url
|
||||
|
||||
|
||||
def get_oauth_session(session_id: str) -> Optional[ProfileOAuthSession]:
|
||||
return _ACTIVE_OAUTH_SESSIONS.get(session_id)
|
||||
365
src/antigravity_provider/router/router_config.py
Normal file
365
src/antigravity_provider/router/router_config.py
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"""Configuration schema and loader for Hermes Multi-Provider Account Router."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouterProfileConfig:
|
||||
profile_id: str
|
||||
provider: str # "openai-codex", "antigravity", "opencode-go"
|
||||
account_id: str
|
||||
capabilities: list[str] = field(default_factory=list)
|
||||
preferred_models: list[str] = field(default_factory=list)
|
||||
fallback_models: list[str] = field(default_factory=list)
|
||||
auth_config: dict[str, Any] = field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
max_concurrency: int = 1 # 1 for stateful process, >1 for stateless REST
|
||||
custom_base_url: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RolePolicy:
|
||||
role_name: str
|
||||
preferred_chain: list[str] = field(default_factory=list) # list of profile_id
|
||||
fallback_capabilities: list[str] = field(default_factory=list)
|
||||
max_failover_attempts: int = 4
|
||||
session_affinity_enabled: bool = True
|
||||
default_model: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouterConfig:
|
||||
enabled: bool = True
|
||||
default_role: str = "orchestrator"
|
||||
quota_cooldown_seconds: int = 1800 # 30 min default
|
||||
rate_limit_cooldown_seconds: int = 60 # 1 min default
|
||||
roles: dict[str, RolePolicy] = field(default_factory=dict)
|
||||
profiles: dict[str, RouterProfileConfig] = field(default_factory=dict)
|
||||
|
||||
def get_profile(self, profile_id: str) -> Optional[RouterProfileConfig]:
|
||||
return self.profiles.get(profile_id)
|
||||
|
||||
def get_role_policy(self, role: str) -> RolePolicy:
|
||||
if role in self.roles:
|
||||
return self.roles[role]
|
||||
# Return generic fallback policy
|
||||
return RolePolicy(
|
||||
role_name=role,
|
||||
preferred_chain=list(self.profiles.keys()),
|
||||
fallback_capabilities=[role],
|
||||
max_failover_attempts=len(self.profiles),
|
||||
session_affinity_enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def get_default_router_config() -> RouterConfig:
|
||||
"""Generate default built-in multi-provider configuration (16 profiles across 3 providers)."""
|
||||
profiles: dict[str, RouterProfileConfig] = {
|
||||
# 1. Codex Pool (3 accounts)
|
||||
"codex-orch": RouterProfileConfig(
|
||||
profile_id="codex-orch",
|
||||
provider="openai-codex",
|
||||
account_id="codex-acc-1",
|
||||
capabilities=["orchestrator", "coding", "reasoning"],
|
||||
preferred_models=["gpt-4o", "o3-mini", "codex"],
|
||||
fallback_models=["gpt-4o-mini"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
"codex-worker-1": RouterProfileConfig(
|
||||
profile_id="codex-worker-1",
|
||||
provider="openai-codex",
|
||||
account_id="codex-acc-2",
|
||||
capabilities=["coding", "coder-primary", "reasoning"],
|
||||
preferred_models=["gpt-4o", "o3-mini", "codex"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
"codex-worker-2": RouterProfileConfig(
|
||||
profile_id="codex-worker-2",
|
||||
provider="openai-codex",
|
||||
account_id="codex-acc-3",
|
||||
capabilities=["coding", "coder-secondary", "reviewer", "review"],
|
||||
preferred_models=["gpt-4o", "o3-mini", "codex"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
# 2. Antigravity Pool (10 accounts, 7 active, 3 cold)
|
||||
"ag-orch-fallback": RouterProfileConfig(
|
||||
profile_id="ag-orch-fallback",
|
||||
provider="antigravity",
|
||||
account_id="ag-acc-orch",
|
||||
capabilities=["orchestrator", "reasoning", "coding"],
|
||||
preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
"ag-w1": RouterProfileConfig(
|
||||
profile_id="ag-w1",
|
||||
provider="antigravity",
|
||||
account_id="ag-acc-w1",
|
||||
capabilities=["coding", "coder-primary", "reasoning"],
|
||||
preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6", "gemini-3.5-flash"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
"ag-w2": RouterProfileConfig(
|
||||
profile_id="ag-w2",
|
||||
provider="antigravity",
|
||||
account_id="ag-acc-w2",
|
||||
capabilities=["coding", "coder-secondary", "reviewer", "review"],
|
||||
preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
"ag-w3": RouterProfileConfig(
|
||||
profile_id="ag-w3",
|
||||
provider="antigravity",
|
||||
account_id="ag-acc-w3",
|
||||
capabilities=["research", "reasoning", "search"],
|
||||
preferred_models=["gemini-3.7-flash", "claude-sonnet-4-6"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
"ag-w4": RouterProfileConfig(
|
||||
profile_id="ag-w4",
|
||||
provider="antigravity",
|
||||
account_id="ag-acc-w4",
|
||||
capabilities=["coding", "reasoning", "fast"],
|
||||
preferred_models=["gemini-3.5-flash", "gemini-3.7-flash"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
"ag-spare-1": RouterProfileConfig(
|
||||
profile_id="ag-spare-1",
|
||||
provider="antigravity",
|
||||
account_id="ag-acc-sp1",
|
||||
capabilities=["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"],
|
||||
preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
"ag-spare-2": RouterProfileConfig(
|
||||
profile_id="ag-spare-2",
|
||||
provider="antigravity",
|
||||
account_id="ag-acc-sp2",
|
||||
capabilities=["hot-spare", "coding", "reasoning", "orchestrator", "research", "fast"],
|
||||
preferred_models=["gemini-3.7-flash", "gemini-3.5-flash"],
|
||||
max_concurrency=1,
|
||||
),
|
||||
"ag-cold-1": RouterProfileConfig(
|
||||
profile_id="ag-cold-1",
|
||||
provider="antigravity",
|
||||
account_id="ag-acc-cold1",
|
||||
capabilities=["cold-spare"],
|
||||
enabled=False,
|
||||
max_concurrency=1,
|
||||
),
|
||||
"ag-cold-2": RouterProfileConfig(
|
||||
profile_id="ag-cold-2",
|
||||
provider="antigravity",
|
||||
account_id="ag-acc-cold2",
|
||||
capabilities=["cold-spare"],
|
||||
enabled=False,
|
||||
max_concurrency=1,
|
||||
),
|
||||
"ag-cold-3": RouterProfileConfig(
|
||||
profile_id="ag-cold-3",
|
||||
provider="antigravity",
|
||||
account_id="ag-acc-cold3",
|
||||
capabilities=["cold-spare"],
|
||||
enabled=False,
|
||||
max_concurrency=1,
|
||||
),
|
||||
# 3. OpenCode Go Pool (3 accounts)
|
||||
"opengo-1": RouterProfileConfig(
|
||||
profile_id="opengo-1",
|
||||
provider="opencode-go",
|
||||
account_id="opengo-acc-1",
|
||||
capabilities=["research", "search", "fast", "review"],
|
||||
preferred_models=["qwen3.8-max", "glm-5.3", "deepseek-v4-flash", "grok-4.5"],
|
||||
max_concurrency=3,
|
||||
),
|
||||
"opengo-2": RouterProfileConfig(
|
||||
profile_id="opengo-2",
|
||||
provider="opencode-go",
|
||||
account_id="opengo-acc-2",
|
||||
capabilities=["reviewer", "review", "coding", "reasoning"],
|
||||
preferred_models=["deepseek-v4-pro", "grok-4.5", "qwen3.7-max"],
|
||||
max_concurrency=3,
|
||||
),
|
||||
"opengo-3": RouterProfileConfig(
|
||||
profile_id="opengo-3",
|
||||
provider="opencode-go",
|
||||
account_id="opengo-acc-3",
|
||||
capabilities=["coder-fallback", "orchestrator", "coding", "reasoning"],
|
||||
preferred_models=["kimi-k2.7-code", "deepseek-v4-pro", "qwen3.8-max"],
|
||||
max_concurrency=3,
|
||||
),
|
||||
}
|
||||
|
||||
roles: dict[str, RolePolicy] = {
|
||||
"orchestrator": RolePolicy(
|
||||
role_name="orchestrator",
|
||||
preferred_chain=["codex-orch", "ag-orch-fallback", "opengo-3"],
|
||||
fallback_capabilities=["orchestrator", "reasoning"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
default_model="gemini-3.7-flash",
|
||||
),
|
||||
"coder-primary": RolePolicy(
|
||||
role_name="coder-primary",
|
||||
preferred_chain=["codex-worker-1", "ag-w1", "opengo-3"],
|
||||
fallback_capabilities=["coding"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
),
|
||||
"coder-secondary": RolePolicy(
|
||||
role_name="coder-secondary",
|
||||
preferred_chain=["codex-worker-2", "ag-w2", "opengo-2"],
|
||||
fallback_capabilities=["coding", "reviewer"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
),
|
||||
"reviewer": RolePolicy(
|
||||
role_name="reviewer",
|
||||
preferred_chain=["codex-worker-2", "opengo-2", "ag-w2"],
|
||||
fallback_capabilities=["reviewer", "coding"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
),
|
||||
"research": RolePolicy(
|
||||
role_name="research",
|
||||
preferred_chain=["opengo-1", "ag-w3", "ag-w4"],
|
||||
fallback_capabilities=["research", "search"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
),
|
||||
"fast": RolePolicy(
|
||||
role_name="fast",
|
||||
preferred_chain=["opengo-1", "ag-w4", "ag-spare-1"],
|
||||
fallback_capabilities=["fast"],
|
||||
max_failover_attempts=3,
|
||||
session_affinity_enabled=True,
|
||||
),
|
||||
}
|
||||
|
||||
return RouterConfig(
|
||||
enabled=True,
|
||||
default_role="orchestrator",
|
||||
roles=roles,
|
||||
profiles=profiles,
|
||||
)
|
||||
|
||||
|
||||
def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
|
||||
"""Load RouterConfig from YAML file or return default built-in configuration."""
|
||||
if config_path is None:
|
||||
env_config = os.environ.get("HERMES_ROUTER_CONFIG", "").strip()
|
||||
if env_config:
|
||||
config_path = Path(env_config).expanduser()
|
||||
else:
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()
|
||||
if os.name == "nt" and "HERMES_HOME" not in os.environ:
|
||||
local_app = os.environ.get("LOCALAPPDATA", "")
|
||||
if local_app and (Path(local_app) / "hermes").exists():
|
||||
hermes_home = Path(local_app) / "hermes"
|
||||
config_path = hermes_home / "config" / "router_profiles.yaml"
|
||||
|
||||
if not config_path.is_file():
|
||||
return get_default_router_config()
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
profiles_raw = data.get("profiles", {})
|
||||
profiles: dict[str, RouterProfileConfig] = {}
|
||||
for pid, pdata in profiles_raw.items():
|
||||
profiles[pid] = RouterProfileConfig(
|
||||
profile_id=pid,
|
||||
provider=pdata.get("provider", "antigravity"),
|
||||
account_id=pdata.get("account_id", pid),
|
||||
capabilities=list(pdata.get("capabilities", [])),
|
||||
preferred_models=list(pdata.get("preferred_models", [])),
|
||||
fallback_models=list(pdata.get("fallback_models", [])),
|
||||
auth_config=dict(pdata.get("auth_config", {})),
|
||||
enabled=bool(pdata.get("enabled", True)),
|
||||
max_concurrency=int(pdata.get("max_concurrency", 1)),
|
||||
custom_base_url=pdata.get("custom_base_url"),
|
||||
)
|
||||
|
||||
roles_raw = data.get("roles", {})
|
||||
roles: dict[str, RolePolicy] = {}
|
||||
for rname, rdata in roles_raw.items():
|
||||
roles[rname] = RolePolicy(
|
||||
role_name=rname,
|
||||
preferred_chain=list(rdata.get("preferred_chain", [])),
|
||||
fallback_capabilities=list(rdata.get("fallback_capabilities", [])),
|
||||
max_failover_attempts=int(rdata.get("max_failover_attempts", 4)),
|
||||
session_affinity_enabled=bool(rdata.get("session_affinity_enabled", True)),
|
||||
default_model=rdata.get("default_model"),
|
||||
)
|
||||
|
||||
return RouterConfig(
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
default_role=str(data.get("default_role", "orchestrator")),
|
||||
quota_cooldown_seconds=int(data.get("quota_cooldown_seconds", 1800)),
|
||||
rate_limit_cooldown_seconds=int(data.get("rate_limit_cooldown_seconds", 60)),
|
||||
roles=roles or get_default_router_config().roles,
|
||||
profiles=profiles or get_default_router_config().profiles,
|
||||
)
|
||||
except Exception as e:
|
||||
# Fall back gracefully to built-in defaults on YAML error
|
||||
return get_default_router_config()
|
||||
|
||||
|
||||
def save_router_config(config: RouterConfig, config_path: Optional[Path] = None) -> bool:
|
||||
"""Save RouterConfig to YAML file."""
|
||||
if config_path is None:
|
||||
env_config = os.environ.get("HERMES_ROUTER_CONFIG", "").strip()
|
||||
if env_config:
|
||||
config_path = Path(env_config).expanduser()
|
||||
else:
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()
|
||||
if os.name == "nt" and "HERMES_HOME" not in os.environ:
|
||||
local_app = os.environ.get("LOCALAPPDATA", "")
|
||||
if local_app and (Path(local_app) / "hermes").exists():
|
||||
hermes_home = Path(local_app) / "hermes"
|
||||
config_path = hermes_home / "config" / "router_profiles.yaml"
|
||||
|
||||
try:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
profiles_data = {}
|
||||
for pid, pcfg in config.profiles.items():
|
||||
profiles_data[pid] = {
|
||||
"provider": pcfg.provider,
|
||||
"account_id": pcfg.account_id,
|
||||
"capabilities": pcfg.capabilities,
|
||||
"preferred_models": pcfg.preferred_models,
|
||||
"fallback_models": pcfg.fallback_models,
|
||||
"enabled": pcfg.enabled,
|
||||
"max_concurrency": pcfg.max_concurrency,
|
||||
}
|
||||
if pcfg.custom_base_url:
|
||||
profiles_data[pid]["custom_base_url"] = pcfg.custom_base_url
|
||||
|
||||
roles_data = {}
|
||||
for rname, rpol in config.roles.items():
|
||||
roles_data[rname] = {
|
||||
"preferred_chain": rpol.preferred_chain,
|
||||
"fallback_capabilities": rpol.fallback_capabilities,
|
||||
"max_failover_attempts": rpol.max_failover_attempts,
|
||||
"session_affinity_enabled": rpol.session_affinity_enabled,
|
||||
}
|
||||
if rpol.default_model:
|
||||
roles_data[rname]["default_model"] = rpol.default_model
|
||||
|
||||
data = {
|
||||
"enabled": config.enabled,
|
||||
"default_role": config.default_role,
|
||||
"quota_cooldown_seconds": config.quota_cooldown_seconds,
|
||||
"rate_limit_cooldown_seconds": config.rate_limit_cooldown_seconds,
|
||||
"roles": roles_data,
|
||||
"profiles": profiles_data,
|
||||
}
|
||||
|
||||
config_path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
242
src/antigravity_provider/router/router_engine.py
Normal file
242
src/antigravity_provider/router/router_engine.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"""Core routing engine for Hermes multi-provider account router."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .adapters import get_adapter
|
||||
from .adapters.base_adapter import ErrorCategory
|
||||
from .health_tracker import HealthTracker, extract_model_family
|
||||
from .router_config import RolePolicy, RouterConfig, RouterProfileConfig, load_router_config
|
||||
from .session_affinity import LeaseManager, SessionAffinityTracker
|
||||
|
||||
logger = logging.getLogger("hermes.router")
|
||||
|
||||
|
||||
class RouterEngine:
|
||||
"""Central router managing role-based chains, session affinity, leases, and failover."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[RouterConfig] = None,
|
||||
health: Optional[HealthTracker] = None,
|
||||
affinity: Optional[SessionAffinityTracker] = None,
|
||||
leases: Optional[LeaseManager] = None,
|
||||
) -> None:
|
||||
self.config = config or load_router_config()
|
||||
self.health = health or HealthTracker()
|
||||
self.affinity = affinity or SessionAffinityTracker()
|
||||
self.leases = leases or LeaseManager()
|
||||
|
||||
def reload_config(self) -> None:
|
||||
self.config = load_router_config()
|
||||
|
||||
def resolve_role(self, request: Dict[str, Any], explicit_role: Optional[str] = None) -> str:
|
||||
"""Determine logical role from explicit parameter, request payload, or personality."""
|
||||
if explicit_role:
|
||||
return explicit_role.strip().lower()
|
||||
if "role" in request and request["role"]:
|
||||
return str(request["role"]).strip().lower()
|
||||
if "personality" in request and request["personality"]:
|
||||
return str(request["personality"]).strip().lower()
|
||||
# Inspect system message or metadata for subagent role hints
|
||||
messages = request.get("messages", [])
|
||||
if messages and isinstance(messages, list):
|
||||
first = messages[0]
|
||||
if isinstance(first, dict) and first.get("role") == "system":
|
||||
sys_content = str(first.get("content", "")).lower()
|
||||
if "role: coder" in sys_content or "developer" in sys_content or "coding agent" in sys_content:
|
||||
return "coder-primary"
|
||||
if "role: reviewer" in sys_content or "code-reviewer" in sys_content or "review agent" in sys_content:
|
||||
return "reviewer"
|
||||
if "role: researcher" in sys_content or "research agent" in sys_content:
|
||||
return "research"
|
||||
return self.config.default_role
|
||||
|
||||
def resolve_session_id(self, request: Dict[str, Any], explicit_session_id: Optional[str] = None) -> Optional[str]:
|
||||
if explicit_session_id:
|
||||
return explicit_session_id
|
||||
if "session_id" in request and request["session_id"]:
|
||||
return str(request["session_id"])
|
||||
# Check custom headers or metadata
|
||||
metadata = request.get("metadata", {})
|
||||
if isinstance(metadata, dict) and "session_id" in metadata:
|
||||
return str(metadata["session_id"])
|
||||
return None
|
||||
|
||||
def route_request(
|
||||
self,
|
||||
request: Dict[str, Any],
|
||||
role: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute request with session affinity and role-aware failover."""
|
||||
target_role = self.resolve_role(request, role)
|
||||
target_session = self.resolve_session_id(request, session_id)
|
||||
role_policy = self.config.get_role_policy(target_role)
|
||||
|
||||
requested_model = request.get("model")
|
||||
family = extract_model_family(requested_model)
|
||||
|
||||
# 1. Check Session Affinity
|
||||
candidate_profiles: list[str] = []
|
||||
if target_session and role_policy.session_affinity_enabled:
|
||||
aff_rec = self.affinity.get_affinity(target_session)
|
||||
if aff_rec and aff_rec.profile_id in self.config.profiles:
|
||||
aff_profile = self.config.profiles[aff_rec.profile_id]
|
||||
if aff_profile.enabled and self.health.is_healthy(aff_rec.profile_id, requested_model):
|
||||
candidate_profiles.append(aff_rec.profile_id)
|
||||
|
||||
# 2. Add remaining preferred chain candidates
|
||||
for pid in role_policy.preferred_chain:
|
||||
if pid not in candidate_profiles:
|
||||
candidate_profiles.append(pid)
|
||||
|
||||
# 3. Add any matching capability fallbacks if chain exhausted
|
||||
for pid, pconfig in self.config.profiles.items():
|
||||
if pid not in candidate_profiles and pconfig.enabled:
|
||||
if any(cap in pconfig.capabilities for cap in role_policy.fallback_capabilities):
|
||||
candidate_profiles.append(pid)
|
||||
|
||||
failover_trail: list[dict[str, Any]] = []
|
||||
attempts = 0
|
||||
max_attempts = min(role_policy.max_failover_attempts, len(candidate_profiles))
|
||||
|
||||
for pid in candidate_profiles:
|
||||
if attempts >= max_attempts:
|
||||
break
|
||||
|
||||
pconfig = self.config.get_profile(pid)
|
||||
if not pconfig or not pconfig.enabled:
|
||||
continue
|
||||
|
||||
# Check health and quota
|
||||
if not self.health.is_healthy(pid, requested_model):
|
||||
failover_trail.append({
|
||||
"profile_id": pid,
|
||||
"provider": pconfig.provider,
|
||||
"status": "skipped_unhealthy",
|
||||
})
|
||||
continue
|
||||
|
||||
# Try to acquire concurrency lease
|
||||
if not self.leases.acquire(pid, pconfig.max_concurrency):
|
||||
failover_trail.append({
|
||||
"profile_id": pid,
|
||||
"provider": pconfig.provider,
|
||||
"status": "skipped_concurrency_limit",
|
||||
})
|
||||
continue
|
||||
|
||||
attempts += 1
|
||||
adapter = get_adapter(pconfig.provider)
|
||||
|
||||
try:
|
||||
# Prepare profile-specific model selection
|
||||
exec_request = dict(request)
|
||||
if not exec_request.get("model") or exec_request["model"] == "default":
|
||||
if pconfig.preferred_models:
|
||||
exec_request["model"] = pconfig.preferred_models[0]
|
||||
elif role_policy.default_model:
|
||||
exec_request["model"] = role_policy.default_model
|
||||
|
||||
t0 = time.time()
|
||||
response = adapter.invoke(pconfig, exec_request)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# Check if response payload contains an error object
|
||||
if isinstance(response, dict) and "error" in response:
|
||||
err_val = response["error"]
|
||||
raise RuntimeError(f"Provider Error: {err_val}")
|
||||
|
||||
# Success!
|
||||
self.health.mark_success(pid, exec_request.get("model"))
|
||||
self.leases.release(pid)
|
||||
|
||||
# Set / update session affinity
|
||||
if target_session and role_policy.session_affinity_enabled:
|
||||
self.affinity.set_affinity(target_session, target_role, pid, exec_request.get("model"))
|
||||
|
||||
# Attach router telemetry
|
||||
if isinstance(response, dict):
|
||||
response.setdefault("router_metadata", {
|
||||
"role": target_role,
|
||||
"profile_id": pid,
|
||||
"provider": pconfig.provider,
|
||||
"session_id": target_session,
|
||||
"failover_count": attempts - 1,
|
||||
"elapsed_seconds": round(elapsed, 3),
|
||||
"failover_trail": failover_trail,
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
except Exception as exc:
|
||||
self.leases.release(pid)
|
||||
err_class = adapter.classify_error(exc)
|
||||
|
||||
if err_class.category == ErrorCategory.QUOTA_EXHAUSTED:
|
||||
self.health.mark_quota_exhausted(
|
||||
profile_id=pid,
|
||||
model_name=requested_model,
|
||||
duration=err_class.reset_duration_seconds,
|
||||
reason=err_class.message,
|
||||
)
|
||||
elif err_class.category == ErrorCategory.RATE_LIMITED:
|
||||
self.health.mark_rate_limited(
|
||||
profile_id=pid,
|
||||
model_name=requested_model,
|
||||
duration=err_class.retry_delay_seconds,
|
||||
reason=err_class.message,
|
||||
)
|
||||
elif err_class.category == ErrorCategory.AUTH_REQUIRED:
|
||||
self.health.mark_auth_required(profile_id=pid, reason=err_class.message)
|
||||
|
||||
failover_trail.append({
|
||||
"profile_id": pid,
|
||||
"provider": pconfig.provider,
|
||||
"status": "failed",
|
||||
"category": err_class.category,
|
||||
"error": err_class.message[:200],
|
||||
})
|
||||
|
||||
# If non-fatal and more profiles remain in chain, continue loop (failover!)
|
||||
continue
|
||||
|
||||
# All attempts in chain failed
|
||||
summary_errors = "; ".join(f"[{t.get('profile_id')}]: {t.get('error', t.get('status'))}" for t in failover_trail)
|
||||
return {
|
||||
"id": f"router-fail-{int(time.time())}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": requested_model or "router-failover",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
f"⚠️ Hermes Router Failover Exhausted for role '{target_role}'.\n"
|
||||
f"All {attempts} attempted provider profiles failed or exceeded quota.\n"
|
||||
f"Trail: {summary_errors}\n"
|
||||
"Use `hermes router status` to inspect quota reset times or clear cooldowns."
|
||||
),
|
||||
},
|
||||
"finish_reason": "error",
|
||||
}
|
||||
],
|
||||
"router_error": True,
|
||||
"failover_trail": failover_trail,
|
||||
}
|
||||
|
||||
|
||||
# Global singleton instance for runtime middleware
|
||||
_ROUTER_ENGINE: Optional[RouterEngine] = None
|
||||
|
||||
|
||||
def get_router_engine() -> RouterEngine:
|
||||
global _ROUTER_ENGINE
|
||||
if _ROUTER_ENGINE is None:
|
||||
_ROUTER_ENGINE = RouterEngine()
|
||||
return _ROUTER_ENGINE
|
||||
92
src/antigravity_provider/router/session_affinity.py
Normal file
92
src/antigravity_provider/router/session_affinity.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Session affinity and lease management for Hermes multi-provider router."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionAffinityRecord:
|
||||
session_id: str
|
||||
role: str
|
||||
profile_id: str
|
||||
model: Optional[str] = None
|
||||
created_at: float = 0.0
|
||||
updated_at: float = 0.0
|
||||
|
||||
|
||||
class SessionAffinityTracker:
|
||||
"""Thread-safe tracker maintaining session affinity across conversation turns."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._sessions: dict[str, SessionAffinityRecord] = {}
|
||||
|
||||
def get_affinity(self, session_id: Optional[str]) -> Optional[SessionAffinityRecord]:
|
||||
if not session_id:
|
||||
return None
|
||||
with self._lock:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def set_affinity(
|
||||
self,
|
||||
session_id: Optional[str],
|
||||
role: str,
|
||||
profile_id: str,
|
||||
model: Optional[str] = None,
|
||||
) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
if session_id in self._sessions:
|
||||
rec = self._sessions[session_id]
|
||||
rec.role = role
|
||||
rec.profile_id = profile_id
|
||||
rec.model = model
|
||||
rec.updated_at = now
|
||||
else:
|
||||
self._sessions[session_id] = SessionAffinityRecord(
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
profile_id=profile_id,
|
||||
model=model,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def clear_session(self, session_id: str) -> None:
|
||||
with self._lock:
|
||||
self._sessions.pop(session_id, None)
|
||||
|
||||
def clear_all(self) -> None:
|
||||
with self._lock:
|
||||
self._sessions.clear()
|
||||
|
||||
|
||||
class LeaseManager:
|
||||
"""Manages concurrent leases per profile to prevent process saturation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._active_leases: dict[str, int] = {}
|
||||
|
||||
def acquire(self, profile_id: str, max_concurrency: int = 1) -> bool:
|
||||
with self._lock:
|
||||
current = self._active_leases.get(profile_id, 0)
|
||||
if current >= max_concurrency:
|
||||
return False
|
||||
self._active_leases[profile_id] = current + 1
|
||||
return True
|
||||
|
||||
def release(self, profile_id: str) -> None:
|
||||
with self._lock:
|
||||
current = self._active_leases.get(profile_id, 0)
|
||||
if current > 0:
|
||||
self._active_leases[profile_id] = current - 1
|
||||
|
||||
def active_count(self, profile_id: str) -> int:
|
||||
with self._lock:
|
||||
return self._active_leases.get(profile_id, 0)
|
||||
153
src/antigravity_provider/runtime.py
Normal file
153
src/antigravity_provider/runtime.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from .antigravity_client import AntigravityClient
|
||||
from .cloudcode import load_or_onboard_project
|
||||
from .credentials import CredentialStore, load_agy_keychain_credentials
|
||||
from .errors import ProxyError, TokenExpired
|
||||
from .oauth import refresh_access_token
|
||||
from .openai_compat import ChatRequest, parse_chat_request, to_openai_completion
|
||||
from .transform import build_generate_content_request
|
||||
|
||||
|
||||
def load_antigravity_credentials(store: Any | None = None) -> dict[str, Any]:
|
||||
"""Load, refresh, and persist credentials for plugin use."""
|
||||
if store is None:
|
||||
store = CredentialStore.default()
|
||||
keychain = load_agy_keychain_credentials()
|
||||
from_keychain = bool(keychain)
|
||||
stored = {} if from_keychain else store.load()
|
||||
creds = {**stored, **keychain}
|
||||
dirty = False
|
||||
|
||||
access = creds.get("access_token") or creds.get("access") or creds.get("token")
|
||||
refresh = creds.get("refresh_token") or creds.get("refresh")
|
||||
project = creds.get("project_id") or creds.get("projectId")
|
||||
if not access and not refresh:
|
||||
raise ProxyError(
|
||||
"Missing Antigravity credentials. Run `hermes agy login`.",
|
||||
status=401,
|
||||
error_type="invalid_request_error",
|
||||
)
|
||||
expires = creds.get("expires_at") or creds.get("expires")
|
||||
if refresh and (not access or (isinstance(expires, (int, float)) and time.time() + 60 >= float(expires))):
|
||||
refreshed = refresh_access_token(str(refresh))
|
||||
creds.update(refreshed)
|
||||
access = refreshed["access_token"]
|
||||
refresh = creds.get("refresh_token") or creds.get("refresh")
|
||||
dirty = not from_keychain
|
||||
if not project:
|
||||
if not access:
|
||||
raise ProxyError(
|
||||
"Missing access token for Antigravity project discovery",
|
||||
status=401,
|
||||
error_type="invalid_request_error",
|
||||
)
|
||||
project = load_or_onboard_project(str(access))
|
||||
creds["project_id"] = project
|
||||
dirty = not from_keychain
|
||||
if dirty:
|
||||
store.save(creds)
|
||||
return {
|
||||
"access_token": str(access or creds["access_token"]),
|
||||
"refresh_token": str(refresh or creds.get("refresh_token") or ""),
|
||||
"project_id": str(project),
|
||||
"source": "agy-keychain" if from_keychain else "store",
|
||||
}
|
||||
|
||||
|
||||
def build_upstream_body(request: ChatRequest, *, store: Any | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
creds = load_antigravity_credentials(store)
|
||||
body = build_generate_content_request(
|
||||
model=request.model,
|
||||
project_id=creds["project_id"],
|
||||
messages=request.messages,
|
||||
tools=request.tools,
|
||||
reasoning_effort=request.reasoning_effort,
|
||||
max_tokens=request.max_tokens,
|
||||
temperature=request.temperature,
|
||||
top_p=request.top_p,
|
||||
tool_choice=request.tool_choice,
|
||||
)
|
||||
return body, creds
|
||||
|
||||
|
||||
def generate_chat_completion(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
client: Any | None = None,
|
||||
store: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute one OpenAI-shaped chat request against Antigravity in-process."""
|
||||
request = parse_chat_request(payload)
|
||||
if client is None:
|
||||
client = AntigravityClient()
|
||||
if store is None:
|
||||
store = CredentialStore.default()
|
||||
body, creds = build_upstream_body(request, store=store)
|
||||
try:
|
||||
upstream = client.generate(access_token=creds["access_token"], body=body)
|
||||
except TokenExpired:
|
||||
if not creds.get("refresh_token"):
|
||||
raise
|
||||
refreshed = refresh_access_token(creds["refresh_token"])
|
||||
if creds.get("source") == "store":
|
||||
saved = store.load()
|
||||
saved.update(refreshed)
|
||||
store.save(saved)
|
||||
upstream = client.generate(access_token=refreshed["access_token"], body=body)
|
||||
return to_openai_completion(request.model, upstream)
|
||||
|
||||
|
||||
def _namespace(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return SimpleNamespace(**{k: _namespace(v) for k, v in value.items()})
|
||||
if isinstance(value, list):
|
||||
return [_namespace(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def openai_completion_object(completion: dict[str, Any]) -> SimpleNamespace:
|
||||
"""Return an object compatible with Hermes' ChatCompletionsTransport."""
|
||||
completion = dict(completion)
|
||||
choices = []
|
||||
for raw_choice in completion.get("choices") or []:
|
||||
choice = dict(raw_choice)
|
||||
message = dict(choice.get("message") or {})
|
||||
message.setdefault("content", None)
|
||||
message.setdefault("tool_calls", None)
|
||||
choice["message"] = message
|
||||
choices.append(choice)
|
||||
completion["choices"] = choices
|
||||
completion.setdefault("usage", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
|
||||
return _namespace(completion)
|
||||
|
||||
|
||||
def ensure_provider_profile_files(root: Path | None = None) -> Path:
|
||||
"""Install the tiny model-provider profile that makes `hermes model` see Antigravity."""
|
||||
if root is None:
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
root = get_hermes_home()
|
||||
except Exception:
|
||||
root = Path.home() / ".hermes"
|
||||
plugin_dir = Path(root).expanduser() / "plugins" / "model-providers" / "antigravity"
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"from antigravity_provider.hermes_provider import register_provider_profile\n"
|
||||
"register_provider_profile()\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
"name: antigravity\n"
|
||||
"kind: model-provider\n"
|
||||
"version: 0.1.0\n"
|
||||
"description: Google Antigravity provider profile\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return plugin_dir
|
||||
245
src/antigravity_provider/transform.py
Normal file
245
src/antigravity_provider/transform.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from .models import WIRE_PROFILES, clamp_reasoning_effort, normalize_model_id, resolve_wire_model_id, strip_provider_prefix
|
||||
|
||||
SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
|
||||
|
||||
|
||||
def _thinking_budget(logical: str, effort: str) -> int:
|
||||
if effort == "off":
|
||||
return 0
|
||||
if logical == "gemini-3.1-pro":
|
||||
return 10001 if effort == "high" else 1001
|
||||
if logical == "gemini-3.5-flash":
|
||||
return {"low": 1000, "medium": 4000, "high": 10000}.get(effort, 1000)
|
||||
if logical in {"gpt-oss-120b", "openai/gpt-oss-120b-maas"}:
|
||||
return 8192
|
||||
return {"minimal": 1024, "low": 4096, "medium": 8192, "high": 16384}.get(effort, 4096)
|
||||
|
||||
|
||||
def _content_text(content: Any) -> str:
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
texts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
if item.get("type") == "text" and isinstance(item.get("text"), str):
|
||||
texts.append(item["text"])
|
||||
elif item.get("type") == "image_url":
|
||||
texts.append("[image omitted]")
|
||||
return "\n".join(t for t in texts if t)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _parts_from_content(content: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(content, list):
|
||||
parts: list[dict[str, Any]] = []
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") == "text" and isinstance(item.get("text"), str) and item["text"].strip():
|
||||
parts.append({"text": item["text"]})
|
||||
elif item.get("type") == "image_url":
|
||||
url = (item.get("image_url") or {}).get("url") if isinstance(item.get("image_url"), dict) else None
|
||||
if isinstance(url, str) and url.startswith("data:") and ";base64," in url:
|
||||
meta, data = url.split(",", 1)
|
||||
mime = meta[5:].split(";", 1)[0] or "application/octet-stream"
|
||||
# ponytail: trust data URL shape; provider validates bytes.
|
||||
parts.append({"inlineData": {"mimeType": mime, "data": data}})
|
||||
else:
|
||||
parts.append({"text": "[image omitted]"})
|
||||
return parts
|
||||
text = _content_text(content)
|
||||
return [{"text": text}] if text.strip() else []
|
||||
|
||||
|
||||
def _parse_args(raw: Any) -> dict[str, Any]:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
return parsed if isinstance(parsed, dict) else {"value": parsed}
|
||||
except json.JSONDecodeError:
|
||||
return {"value": raw}
|
||||
return {}
|
||||
|
||||
|
||||
def _schema(schema: Any) -> dict[str, Any]:
|
||||
if not isinstance(schema, dict):
|
||||
return {"type": "object", "properties": {}}
|
||||
banned = {"$schema", "$defs", "definitions", "additionalProperties", "patternProperties", "unevaluatedProperties"}
|
||||
def clean(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {k: clean(v) for k, v in value.items() if k not in banned}
|
||||
if isinstance(value, list):
|
||||
return [clean(v) for v in value]
|
||||
return value
|
||||
out = clean(deepcopy(schema))
|
||||
if "type" not in out:
|
||||
out["type"] = "object"
|
||||
out.setdefault("properties", {})
|
||||
return out
|
||||
|
||||
|
||||
def _tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
||||
declarations = []
|
||||
for tool in tools or []:
|
||||
if not isinstance(tool, dict) or tool.get("type") != "function":
|
||||
continue
|
||||
fn = tool.get("function") or {}
|
||||
if not isinstance(fn, dict) or not fn.get("name"):
|
||||
continue
|
||||
declarations.append(
|
||||
{
|
||||
"name": fn["name"],
|
||||
"description": fn.get("description") or "",
|
||||
"parameters": _schema(fn.get("parameters")),
|
||||
}
|
||||
)
|
||||
return [{"functionDeclarations": declarations}] if declarations else None
|
||||
|
||||
|
||||
def _tool_config(tools: list[dict[str, Any]], tool_choice: Any, wire_model: str) -> dict[str, Any] | None:
|
||||
if isinstance(tool_choice, str):
|
||||
choice = tool_choice.lower()
|
||||
if choice == "none":
|
||||
return {"functionCallingConfig": {"mode": "NONE"}}
|
||||
if choice in {"required", "any"}:
|
||||
return {"functionCallingConfig": {"mode": "ANY"}}
|
||||
if isinstance(tool_choice, dict):
|
||||
fn = tool_choice.get("function") if tool_choice.get("type") == "function" else None
|
||||
name = fn.get("name") if isinstance(fn, dict) else None
|
||||
if name:
|
||||
return {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": [name]}}
|
||||
if tools or wire_model.startswith("claude-"):
|
||||
return {"functionCallingConfig": {"mode": "VALIDATED"}}
|
||||
return None
|
||||
|
||||
|
||||
def _session_id(messages: list[dict[str, Any]]) -> str:
|
||||
for message in messages:
|
||||
if message.get("role") == "user":
|
||||
text = _content_text(message.get("content"))
|
||||
if text.strip():
|
||||
digest = hashlib.sha256(text.encode("utf-8")).digest()[:8]
|
||||
return "-" + str(int.from_bytes(digest, "big") & ((1 << 63) - 1))
|
||||
return "-" + str(secrets.randbelow(9_000_000_000_000_000_000))
|
||||
|
||||
|
||||
def _envelope_labels(wire_model: str, step: int = 2) -> dict[str, str]:
|
||||
labels = {
|
||||
"last_step_index": str(step - 1),
|
||||
"trajectory_id": str(uuid.uuid4()),
|
||||
"used_claude": str(wire_model.startswith("claude-")).lower(),
|
||||
"used_claude_conservative": str(wire_model.startswith("claude-")).lower(),
|
||||
}
|
||||
profile = WIRE_PROFILES.get(wire_model) or {}
|
||||
if profile.get("modelEnum"):
|
||||
labels["model_enum"] = str(profile["modelEnum"])
|
||||
return labels
|
||||
|
||||
|
||||
def build_generate_content_request(
|
||||
*,
|
||||
model: str,
|
||||
project_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
top_p: float | None = None,
|
||||
tool_choice: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
logical = strip_provider_prefix(normalize_model_id(model))
|
||||
effort = clamp_reasoning_effort(model, reasoning_effort)
|
||||
wire_model = resolve_wire_model_id(model, effort)
|
||||
system_parts: list[dict[str, str]] = []
|
||||
contents: list[dict[str, Any]] = []
|
||||
call_names: dict[str, str] = {}
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role in {"system", "developer"}:
|
||||
text = _content_text(msg.get("content"))
|
||||
if text.strip():
|
||||
system_parts.append({"text": text})
|
||||
elif role == "user":
|
||||
parts = _parts_from_content(msg.get("content"))
|
||||
if parts:
|
||||
contents.append({"role": "user", "parts": parts})
|
||||
elif role == "assistant":
|
||||
parts = _parts_from_content(msg.get("content"))
|
||||
for tool_call in msg.get("tool_calls") or []:
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
fn = tool_call.get("function") or {}
|
||||
name = fn.get("name") if isinstance(fn, dict) else None
|
||||
if not name:
|
||||
continue
|
||||
if tool_call.get("id"):
|
||||
call_names[str(tool_call["id"])] = name
|
||||
part = {"functionCall": {"name": name, "args": _parse_args(fn.get("arguments") if isinstance(fn, dict) else None)}}
|
||||
if wire_model.startswith("gemini-3") or wire_model.startswith("gemini-pro"):
|
||||
part["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE
|
||||
parts.append(part)
|
||||
if parts:
|
||||
contents.append({"role": "model", "parts": parts})
|
||||
elif role == "tool":
|
||||
name = msg.get("name") or call_names.get(str(msg.get("tool_call_id") or "")) or "tool"
|
||||
part = {"functionResponse": {"name": name, "response": {"output": _content_text(msg.get("content"))}}}
|
||||
if contents and contents[-1].get("role") == "user" and any("functionResponse" in p for p in contents[-1].get("parts", [])):
|
||||
contents[-1]["parts"].append(part)
|
||||
else:
|
||||
contents.append({"role": "user", "parts": [part]})
|
||||
|
||||
if not contents:
|
||||
contents.append({"role": "user", "parts": [{"text": "Continue."}]})
|
||||
|
||||
profile = WIRE_PROFILES.get(wire_model, {})
|
||||
cap = int(profile.get("maxOutputTokens") or 65535)
|
||||
generation_config: dict[str, Any] = {
|
||||
"maxOutputTokens": min(max_tokens, cap) if isinstance(max_tokens, int) and max_tokens > 0 else cap,
|
||||
"thinkingConfig": {"includeThoughts": effort != "off", "thinkingBudget": _thinking_budget(logical, effort)},
|
||||
}
|
||||
if temperature is not None:
|
||||
generation_config["temperature"] = temperature
|
||||
if top_p is not None:
|
||||
generation_config["topP"] = top_p
|
||||
|
||||
request: dict[str, Any] = {
|
||||
"contents": contents,
|
||||
"generationConfig": generation_config,
|
||||
"sessionId": _session_id(messages),
|
||||
"labels": _envelope_labels(wire_model),
|
||||
}
|
||||
if system_parts:
|
||||
request["systemInstruction"] = {"role": "system", "parts": system_parts}
|
||||
converted_tools = _tools(tools or [])
|
||||
if converted_tools:
|
||||
request["tools"] = converted_tools
|
||||
config = _tool_config(tools or [], tool_choice, wire_model)
|
||||
if config:
|
||||
request["toolConfig"] = config
|
||||
|
||||
return {
|
||||
"project": project_id,
|
||||
"model": wire_model,
|
||||
"request": request,
|
||||
"requestType": "agent",
|
||||
"userAgent": "antigravity",
|
||||
"requestId": f"agent/{uuid.uuid4()}/{int(time.time() * 1000)}/{uuid.uuid4()}/2",
|
||||
}
|
||||
47
tests/test_installer.py
Normal file
47
tests/test_installer.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Tests for Hermes Hub Installer (HermesHubSetup.exe) and pre-flight logic."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SETUP_EXE = REPO_ROOT / "dist" / "HermesHubSetup.exe"
|
||||
COMPATIBILITY_JSON = REPO_ROOT / "config" / "compatibility.json"
|
||||
|
||||
|
||||
def test_setup_exe_exists():
|
||||
"""Verify that HermesHubSetup.exe is built and present."""
|
||||
assert SETUP_EXE.is_file(), f"HermesHubSetup.exe not found at {SETUP_EXE}"
|
||||
assert SETUP_EXE.stat().st_size > 0
|
||||
|
||||
|
||||
def test_compatibility_json():
|
||||
"""Verify compatibility manifest structure and fields."""
|
||||
import json
|
||||
assert COMPATIBILITY_JSON.is_file()
|
||||
data = json.loads(COMPATIBILITY_JSON.read_text(encoding="utf-8"))
|
||||
assert data["hub_version"] == "0.1.0"
|
||||
assert "0.20.4" in data["tested_versions"]
|
||||
assert "min_hermes_version" in data
|
||||
|
||||
|
||||
def test_silent_installer_execution_with_hermes():
|
||||
"""Run HermesHubSetup.exe /silent on live system where Hermes is installed (Expect 0)."""
|
||||
if not SETUP_EXE.is_file():
|
||||
pytest.skip("HermesHubSetup.exe not built")
|
||||
|
||||
res = subprocess.run([str(SETUP_EXE), "/silent"], capture_output=True, text=True)
|
||||
assert res.returncode == 0, f"Expected returncode 0, got {res.returncode}. Stderr: {res.stderr}"
|
||||
|
||||
|
||||
def test_silent_installer_fails_without_hermes(tmp_path, monkeypatch):
|
||||
"""Run HermesHubSetup.exe /silent when HERMES_HOME points to empty dir (Expect code 10)."""
|
||||
if not SETUP_EXE.is_file():
|
||||
pytest.skip("HermesHubSetup.exe not built")
|
||||
|
||||
fake_home = tmp_path / "non_existent_hermes"
|
||||
env = dict(subprocess.os.environ)
|
||||
env["HERMES_HOME"] = str(fake_home)
|
||||
env["LOCALAPPDATA"] = str(tmp_path)
|
||||
|
||||
res = subprocess.run([str(SETUP_EXE), "/silent"], env=env, capture_output=True, text=True)
|
||||
assert res.returncode == 10, f"Expected returncode 10 for missing Hermes, got {res.returncode}"
|
||||
264
tests/test_multi_provider_router.py
Normal file
264
tests/test_multi_provider_router.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"""Comprehensive test suite for Hermes Multi-Provider Account Router."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
# Ensure plugin package is in sys.path
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
plugin_src = repo_root / "plugins" / "antigravity-provider" / "src"
|
||||
if str(plugin_src) not in sys.path:
|
||||
sys.path.insert(0, str(plugin_src))
|
||||
|
||||
from antigravity_provider.router.router_config import (
|
||||
RolePolicy,
|
||||
RouterConfig,
|
||||
RouterProfileConfig,
|
||||
get_default_router_config,
|
||||
load_router_config,
|
||||
)
|
||||
from antigravity_provider.router.health_tracker import (
|
||||
AUTH_REQUIRED,
|
||||
DISABLED,
|
||||
HEALTHY,
|
||||
IN_USE,
|
||||
QUOTA_EXHAUSTED,
|
||||
RATE_LIMITED,
|
||||
HealthTracker,
|
||||
extract_model_family,
|
||||
)
|
||||
from antigravity_provider.router.session_affinity import LeaseManager, SessionAffinityTracker
|
||||
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
|
||||
from antigravity_provider.router.adapters.base_adapter import ErrorCategory, ErrorClassification
|
||||
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter, get_profile_env_dir
|
||||
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
|
||||
from antigravity_provider.router.adapters.opencode_adapter import OpenCodeGoAdapter
|
||||
from antigravity_provider.router.cli_commands import (
|
||||
clear_cooldown_cli,
|
||||
print_router_status,
|
||||
print_routing_policy,
|
||||
simulate_quota_cli,
|
||||
)
|
||||
|
||||
|
||||
class TestRouterConfig:
|
||||
"""Test configuration schema, profile loading, and role definitions."""
|
||||
|
||||
def test_default_config_has_16_profiles(self):
|
||||
config = get_default_router_config()
|
||||
assert len(config.profiles) == 16
|
||||
# 3 Codex
|
||||
assert "codex-orch" in config.profiles
|
||||
assert "codex-worker-1" in config.profiles
|
||||
assert "codex-worker-2" in config.profiles
|
||||
# 10 Antigravity (7 active, 3 cold)
|
||||
assert "ag-orch-fallback" in config.profiles
|
||||
assert "ag-w1" in config.profiles
|
||||
assert "ag-w4" in config.profiles
|
||||
assert "ag-spare-1" in config.profiles
|
||||
assert "ag-cold-1" in config.profiles
|
||||
assert config.profiles["ag-cold-1"].enabled is False
|
||||
# 3 OpenCode Go
|
||||
assert "opengo-1" in config.profiles
|
||||
assert "opengo-2" in config.profiles
|
||||
assert "opengo-3" in config.profiles
|
||||
|
||||
def test_role_policies_chains(self):
|
||||
config = get_default_router_config()
|
||||
assert "orchestrator" in config.roles
|
||||
orch = config.roles["orchestrator"]
|
||||
assert orch.preferred_chain == ["codex-orch", "ag-orch-fallback", "opengo-3"]
|
||||
|
||||
coder = config.roles["coder-primary"]
|
||||
assert coder.preferred_chain == ["codex-worker-1", "ag-w1", "opengo-3"]
|
||||
|
||||
reviewer = config.roles["reviewer"]
|
||||
assert reviewer.preferred_chain == ["codex-worker-2", "opengo-2", "ag-w2"]
|
||||
|
||||
research = config.roles["research"]
|
||||
assert research.preferred_chain == ["opengo-1", "ag-w3", "ag-w4"]
|
||||
|
||||
|
||||
class TestHealthTracker:
|
||||
"""Test health state tracking, model families, and simulation."""
|
||||
|
||||
def test_model_family_extraction(self):
|
||||
assert extract_model_family("gemini-3.7-flash") == "gemini"
|
||||
assert extract_model_family("claude-sonnet-4-6") == "claude"
|
||||
assert extract_model_family("gpt-4o") == "gpt"
|
||||
assert extract_model_family("deepseek-v4-pro") == "deepseek"
|
||||
assert extract_model_family("kimi-k2.7-code") == "kimi"
|
||||
assert extract_model_family("qwen3.8-max") == "qwen"
|
||||
assert extract_model_family("grok-4.5") == "grok"
|
||||
assert extract_model_family("glm-5.3") == "glm"
|
||||
|
||||
def test_quota_exhaustion_and_expiry(self, tmp_path):
|
||||
state_file = tmp_path / "test_state.json"
|
||||
tracker = HealthTracker(state_file=state_file)
|
||||
|
||||
# Initially healthy
|
||||
assert tracker.is_healthy("ag-w1", "gemini-3.7-flash") is True
|
||||
|
||||
# Mark quota exhausted with 2 second duration
|
||||
tracker.mark_quota_exhausted("ag-w1", "gemini-3.7-flash", duration=2, reason="Quota reached")
|
||||
assert tracker.is_healthy("ag-w1", "gemini-3.7-flash") is False
|
||||
|
||||
# Wait for expiration
|
||||
time.sleep(2.1)
|
||||
assert tracker.is_healthy("ag-w1", "gemini-3.7-flash") is True
|
||||
|
||||
def test_simulated_quota_and_clear(self, tmp_path):
|
||||
state_file = tmp_path / "test_state.json"
|
||||
tracker = HealthTracker(state_file=state_file)
|
||||
|
||||
tracker.simulate_quota("codex-orch", duration=600)
|
||||
assert tracker.is_healthy("codex-orch") is False
|
||||
rec = tracker.get_or_create("codex-orch")
|
||||
assert rec.simulated is True
|
||||
|
||||
# Clear cooldown
|
||||
tracker.clear_cooldown("codex-orch")
|
||||
assert tracker.is_healthy("codex-orch") is True
|
||||
assert tracker.get_or_create("codex-orch").simulated is False
|
||||
|
||||
|
||||
class TestSessionAffinityAndLeases:
|
||||
"""Test session affinity retention and concurrency leases."""
|
||||
|
||||
def test_session_affinity_lifecycle(self):
|
||||
affinity = SessionAffinityTracker()
|
||||
assert affinity.get_affinity("sess-1") is None
|
||||
|
||||
affinity.set_affinity("sess-1", "orchestrator", "codex-orch", "gpt-4o")
|
||||
rec = affinity.get_affinity("sess-1")
|
||||
assert rec is not None
|
||||
assert rec.profile_id == "codex-orch"
|
||||
assert rec.role == "orchestrator"
|
||||
|
||||
# Update on failover
|
||||
affinity.set_affinity("sess-1", "orchestrator", "ag-orch-fallback", "gemini-3.7-flash")
|
||||
rec2 = affinity.get_affinity("sess-1")
|
||||
assert rec2.profile_id == "ag-orch-fallback"
|
||||
|
||||
def test_lease_concurrency(self):
|
||||
leases = LeaseManager()
|
||||
assert leases.acquire("ag-w1", max_concurrency=1) is True
|
||||
# Exceeds concurrency 1
|
||||
assert leases.acquire("ag-w1", max_concurrency=1) is False
|
||||
assert leases.active_count("ag-w1") == 1
|
||||
|
||||
leases.release("ag-w1")
|
||||
assert leases.active_count("ag-w1") == 0
|
||||
assert leases.acquire("ag-w1", max_concurrency=1) is True
|
||||
|
||||
|
||||
class TestRouterEngineFailover:
|
||||
"""Test multi-provider role-aware failover execution loop."""
|
||||
|
||||
def test_orchestrator_failover_chain(self, tmp_path):
|
||||
state_file = tmp_path / "test_router_state.json"
|
||||
engine = RouterEngine(
|
||||
config=get_default_router_config(),
|
||||
health=HealthTracker(state_file=state_file),
|
||||
affinity=SessionAffinityTracker(),
|
||||
)
|
||||
|
||||
# Mock adapter responses
|
||||
mock_codex_response = {"id": "codex-1", "choices": [{"message": {"role": "assistant", "content": "from-codex"}}]}
|
||||
mock_ag_response = {"id": "ag-1", "choices": [{"message": {"role": "assistant", "content": "from-antigravity"}}]}
|
||||
mock_opengo_response = {"id": "opengo-1", "choices": [{"message": {"role": "assistant", "content": "from-opencode"}}]}
|
||||
|
||||
# 1. Normal state: codex-orch succeeds
|
||||
with patch.object(CodexAdapter, "invoke", return_value=mock_codex_response):
|
||||
resp = engine.route_request({"messages": [{"role": "user", "content": "hello"}]}, role="orchestrator", session_id="sess-orch-1")
|
||||
assert resp["choices"][0]["message"]["content"] == "from-codex"
|
||||
assert resp["router_metadata"]["profile_id"] == "codex-orch"
|
||||
|
||||
# 2. Simulate quota on codex-orch: should auto-failover to ag-orch-fallback
|
||||
engine.health.simulate_quota("codex-orch", duration=600)
|
||||
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag_response):
|
||||
resp2 = engine.route_request({"messages": [{"role": "user", "content": "hello again"}]}, role="orchestrator", session_id="sess-orch-2")
|
||||
assert resp2["choices"][0]["message"]["content"] == "from-antigravity"
|
||||
assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback"
|
||||
assert resp2["router_metadata"]["failover_count"] == 0 # picked directly because codex-orch was marked unhealthy
|
||||
|
||||
# 3. Simulate quota on both codex-orch and ag-orch-fallback: should failover to opengo-3
|
||||
engine.health.simulate_quota("ag-orch-fallback", duration=600)
|
||||
with patch.object(OpenCodeGoAdapter, "invoke", return_value=mock_opengo_response):
|
||||
resp3 = engine.route_request({"messages": [{"role": "user", "content": "hello third"}]}, role="orchestrator", session_id="sess-orch-3")
|
||||
assert resp3["choices"][0]["message"]["content"] == "from-opencode"
|
||||
assert resp3["router_metadata"]["profile_id"] == "opengo-3"
|
||||
|
||||
# Clear cooldowns
|
||||
engine.health.clear_cooldown()
|
||||
assert engine.health.is_healthy("codex-orch") is True
|
||||
assert engine.health.is_healthy("ag-orch-fallback") is True
|
||||
|
||||
def test_session_affinity_retention_after_failover(self, tmp_path):
|
||||
state_file = tmp_path / "test_affinity_state.json"
|
||||
engine = RouterEngine(
|
||||
config=get_default_router_config(),
|
||||
health=HealthTracker(state_file=state_file),
|
||||
affinity=SessionAffinityTracker(),
|
||||
)
|
||||
|
||||
mock_codex = {"id": "c1", "choices": [{"message": {"role": "assistant", "content": "c1"}}]}
|
||||
mock_ag = {"id": "a1", "choices": [{"message": {"role": "assistant", "content": "a1"}}]}
|
||||
|
||||
# Turn 1: codex-orch fails with quota exhaustion -> failover to ag-orch-fallback
|
||||
with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Quota limit reached")):
|
||||
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag):
|
||||
resp = engine.route_request({"messages": [{"role": "user", "content": "turn 1"}]}, role="orchestrator", session_id="session-user-123")
|
||||
assert resp["choices"][0]["message"]["content"] == "a1"
|
||||
assert resp["router_metadata"]["profile_id"] == "ag-orch-fallback"
|
||||
|
||||
# Turn 2: same session continues directly on ag-orch-fallback
|
||||
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag):
|
||||
resp2 = engine.route_request({"messages": [{"role": "user", "content": "turn 2"}]}, role="orchestrator", session_id="session-user-123")
|
||||
assert resp2["choices"][0]["message"]["content"] == "a1"
|
||||
assert resp2["router_metadata"]["profile_id"] == "ag-orch-fallback"
|
||||
|
||||
|
||||
class TestAntigravityIsolation:
|
||||
"""Test environment isolation for Antigravity profiles."""
|
||||
|
||||
def test_profile_env_dir_creation(self):
|
||||
pdir = get_profile_env_dir("ag-w1")
|
||||
assert pdir.exists()
|
||||
assert "ag-w1" in str(pdir)
|
||||
|
||||
|
||||
class TestRouterCLI:
|
||||
"""Test CLI commands: status, policy, simulate, clear-cooldown."""
|
||||
|
||||
def test_print_router_status(self, capsys):
|
||||
rc = print_router_status()
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "HERMES MULTI-PROVIDER ACCOUNT ROUTER" in out
|
||||
assert "codex-orch" in out
|
||||
assert "ag-orch-fallback" in out
|
||||
assert "opengo-1" in out
|
||||
|
||||
def test_print_routing_policy(self, capsys):
|
||||
rc = print_routing_policy()
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "orchestrator" in out
|
||||
assert "coder-primary" in out
|
||||
assert "reviewer" in out
|
||||
|
||||
def test_simulate_quota_cli(self, capsys):
|
||||
rc = simulate_quota_cli("codex-orch", duration=300)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Simulated quota exhaustion activated" in out
|
||||
|
||||
rc_clear = clear_cooldown_cli("codex-orch")
|
||||
assert rc_clear == 0
|
||||
out_clear = capsys.readouterr().out
|
||||
assert "cleared for profile 'codex-orch'" in out_clear
|
||||
761
uv.lock
Normal file
761
uv.lock
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermes-hub"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "anyio" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "anyio", marker = "extra == 'dev'", specifier = ">=4.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.110.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "pydantic", specifier = ">=2.6.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.1" },
|
||||
{ name = "requests", specifier = ">=2.31.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.28.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.52.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
|
||||
]
|
||||
Loading…
Reference in a new issue