hermes-hub/src/antigravity_provider/router/action_handler.py
Hermes Team 3374246941 Merge A17 (честный статус) и A18 (выбор модели)
A17 принято, проверено на живых профилях владельца: «Работает» больше не
ставится непроверенному профилю. Из 22 профилей теперь 1 «Работает»
(есть записанный успех), 7 «Не проверялся», 11 «Аккаунт не добавлен»,
3 «Отключён». Grok и opengo-1, на которые жаловался владелец, показаны
честно. Добавлено поле last_success_at.

A18 принято частично: действие set_model существует и валидирует модель
по списку провайдера. Но обнаружение моделей (P0-2) не сделано вовсе —
model_discovery_service и зонд не менялись, ручного обновления нет,
кэша на диске нет. Из-за этого set_model отклоняет ЛЮБУЮ модель, включая
настоящую: список провайдера пуст, и валидация не с чем сравнивать.

Правка при слиянии: discover_models запускался в ГЛОБАЛЬНОМ окружении,
где вход agy не выполнен, — при шести рабочих OAuth-профилях. Теперь
принимает profile_id и подменяет HOME/USERPROFILE на каталог профиля,
как это делает adapter.invoke. Таймаут поднят с 10 до 60 секунд.

Это не вылечило симптом, и причина оказалась глубже — см. отчёт.

Тесты: 336 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:24:22 +07:00

272 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import time
import json
import os
import logging
import threading
from typing import Any, Dict, Tuple, Optional, Callable
from antigravity_provider.router.router_config import load_router_config, save_router_config
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.unified_health import EventLogService
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.scheduler import HermesRefreshScheduler
from antigravity_provider.updater import UpdateManager
from antigravity_provider import paths
from antigravity_provider.router.adapters import get_adapter
logger = logging.getLogger('hermes.router.actions')
def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]:
ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id)
if ok:
EventLogService.get().log(
'account', f'Профиль {profile_id} назначен основным аккаунтом Hermes ({provider}).', level='info'
)
return ok, msg
def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
ok, msg = AutoAssigner.set_primary_orchestrator(profile_id)
if ok:
EventLogService.get().log(
'routing', f'Профиль {profile_id} назначен главным оркестратором команды.', level='info'
)
return ok, msg
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
return {'success': False, 'error': f"Профиль '{profile_id}' не найден"}
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
if not status.get('authenticated'):
return {'success': False, 'error': 'Аккаунт не добавлен. Сначала выполните подключение.'}
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
model = pcfg.preferred_models[0] if pcfg.preferred_models else 'default'
t0 = time.time()
try:
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
if not auth_data and pcfg.provider != 'antigravity':
return {'success': False, 'error': 'Локальные данные авторизации не найдены'}
adapter = get_adapter(pcfg.provider)
req = {
"model": model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1
}
import threading
result_container = []
error_container = []
def _call_invoke():
try:
result_container.append(adapter.invoke(pcfg, req))
except Exception as e:
error_container.append(e)
t = threading.Thread(target=_call_invoke, daemon=True)
t.start()
t.join(timeout=10.0)
el = round(time.time() - t0, 2)
if t.is_alive():
return {
'success': False,
'duration_sec': el,
'error': 'Превышено время ожидания ответа от провайдера (таймаут 10с)',
}
if error_container:
raise error_container[0]
from .health_tracker import HealthTracker
ht = HealthTracker()
ht.mark_success(profile_id, model)
EventLogService.get().log(
'system', f'Успешная проверка подключения {profile_id} ({model}) за {el}s.', level='success'
)
return {
'success': True,
'model': model,
'duration_sec': el,
'response': 'Авторизация подтверждена, ответ провайдера получен',
}
except Exception as e:
EventLogService.get().log('system', f'Сбой проверки {profile_id} ({model}): {e}', level='error')
return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e)}
def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]:
auth_p = ProfileAuthManager.get_profile_dir(provider, profile_id) / 'auth.json'
if auth_p.is_file():
try:
auth_p.unlink()
EventLogService.get().log('account', f'Учетные данные для {profile_id} удалены.', level='warning')
return True, f"Учетные данные для '{profile_id}' удалены"
except Exception as e:
return False, f'Ошибка удаления: {e}'
return True, 'Учетные данные отсутствовали'
def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]:
settings_file = paths.get_hermes_home() / "hub_settings.json"
settings_file.parent.mkdir(parents=True, exist_ok=True)
existing: Dict[str, Any] = {}
if settings_file.exists():
try:
existing = json.loads(settings_file.read_text(encoding="utf-8"))
except Exception:
existing = {}
existing.update(settings)
try:
# Запись через временный файл и os.replace: обрыв на середине не
# должен оставить настройки битыми. В домашнем каталоге Hermes уже
# лежит config.yaml.corrupt.<дата>.bak — этот риск не теоретический.
temp_file = settings_file.with_suffix(".json.tmp")
temp_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
os.replace(temp_file, settings_file)
except Exception as e:
return False, f"Не удалось сохранить настройки: {e}"
from antigravity_provider.router.quota_collector import AccountQuotaService
AccountQuotaService.get().set_refresh_interval(int(settings.get("quota_refresh_interval_sec", 300)))
return True, "Настройки сохранены"
def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) -> Tuple[bool, str]:
if not model or not str(model).strip() or str(model).strip() == "Список моделей ещё не получен":
return False, "Не указана модель для установки"
model = str(model).strip()
config = load_router_config()
if not profile_id and role_id:
role = config.roles.get(role_id)
if role and role.preferred_chain:
profile_id = role.preferred_chain[0]
if not profile_id or profile_id not in config.profiles:
return False, f"Профиль '{profile_id}' не найден в конфигурации"
pcfg = config.profiles[profile_id]
provider = pcfg.provider
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
discovered = ModelDiscoveryService.get().get_models(provider)
if discovered is None:
return False, f"Список моделей провайдера '{provider}' ещё не получен. Сначала нажмите «Обновить список моделей»."
if model not in discovered:
return False, f"Модель '{model}' отсутствует в списке обнаруженных моделей провайдера '{provider}'"
updated = load_router_config()
target = updated.profiles[profile_id]
target.preferred_models = [model] + [m for m in target.preferred_models if m != model]
updated.profiles[profile_id] = target
if role_id and role_id in updated.roles:
updated.roles[role_id].default_model = model
if save_router_config(updated):
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass
EventLogService.get().log(
"model", f"Для профиля {profile_id} ({provider}) установлена модель '{model}'.", level="info"
)
return True, f"Модель '{model}' успешно сохранена для профиля {profile_id}"
return False, "Не удалось сохранить файл конфигурации"
class ActionExecutor:
"""Shared execution layer for Desktop and Web actions."""
@classmethod
def execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None) -> Dict[str, Any]:
"""
Execute the specified action.
If async_runner is provided, long actions will be dispatched to it.
async_runner should accept (func, name).
"""
pid = data.get('profile_id', '')
prov = data.get('provider', '')
# Purely UI navigation actions return True for Web API (no-op on server side).
if action in ['oauth', 'add_account', 'account_details', 'agent_settings', 'edit_route', 'open_routing', 'assign_role']:
return {'ok': True, 'message': 'Навигация'}
if action == 'set_main':
ok, msg = do_set_main(prov, pid)
return {'ok': ok, 'message': msg}
elif action == 'set_model':
model_name = data.get('model', '')
role_id = data.get('role_id', '')
ok, msg = do_set_model(pid, model_name, role_id=role_id)
return {'ok': ok, 'message': msg}
elif action == 'set_orchestrator':
ok, msg = do_set_orchestrator(pid)
return {'ok': ok, 'message': msg}
elif action == 'test':
if async_runner:
async_runner(lambda: do_test_profile(prov, pid), 'TestProfile')
return {'ok': True, 'message': 'запущено'}
else:
res = do_test_profile(prov, pid)
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
elif action == 'delete_credentials':
ok, msg = do_delete_credentials(prov, pid)
return {'ok': ok, 'message': msg}
elif action == 'auto_assign_all':
if async_runner:
async_runner(lambda: AutoAssigner.auto_assign_all(), 'AutoAssignAll')
return {'ok': True, 'message': 'запущено'}
else:
AutoAssigner.auto_assign_all()
return {'ok': True, 'message': 'Успешно'}
elif action == 'refresh_data':
return {'ok': True, 'message': 'Обновление данных'}
elif action == 'refresh_all':
if async_runner:
async_runner(lambda: HermesRefreshScheduler.get().trigger_refresh_all(), 'RefreshAll')
return {'ok': True, 'message': 'запущено'}
else:
HermesRefreshScheduler.get().trigger_refresh_all()
return {'ok': True, 'message': 'Успешно'}
elif action == 'refresh_account':
if async_runner:
async_runner(lambda: HermesRefreshScheduler.get().trigger_refresh_account(prov, pid), 'RefreshAccount')
return {'ok': True, 'message': 'запущено'}
else:
HermesRefreshScheduler.get().trigger_refresh_account(prov, pid)
return {'ok': True, 'message': 'Успешно'}
elif action == 'save_settings':
ok, msg = do_save_settings(data)
return {'ok': ok, 'message': msg}
elif action == 'check_updates':
if async_runner:
async_runner(lambda: UpdateManager().check_for_updates(), 'CheckUpdates')
return {'ok': True, 'message': 'запущено'}
else:
res = UpdateManager().check_for_updates()
return {'ok': True, 'message': 'Успешно', 'data': res}
else:
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}