fix(router): обработчик ошибок падал сам, уровень усилия не подставлялся

Найдено проверкой всех шести ролей на живой машине владельца. До правок
работали три роли из шести.

1. antigravity_adapter.classify_error возвращал ErrorCategory.UNKNOWN —
   значения с таким именем не существует, есть AUTH_REQUIRED, FATAL,
   INVALID_REQUEST, QUOTA_EXHAUSTED, RATE_LIMITED, TRANSIENT. Обращение
   роняло сам классификатор с AttributeError, то есть отказ происходил
   ровно там, где обрабатывался другой отказ, и маршрутизация обрывалась
   вместо перехода к резервному профилю. Заменено на FATAL по образцу
   codex и opencode: неразобранная ошибка не должна давать повторов.

2. Уровень усилия не подставлялся, если у конкретного профиля не выполнен
   вход agy: карта усилий строится обнаружением ЧЕРЕЗ этот профиль, и при
   неудаче оставалась пустой. Уровни же — свойство модели, а не аккаунта.
   Добавлен запасной источник: сохранённый на диске список моделей со
   склеенными идентификаторами вида gemini-3.7-flash-high, из которых
   уровни выводятся напрямую и переживают перезапуск.

После правок работают все шесть ролей, включая живое переключение
'fast': opengo-1 -> ag-w4.

Закрыто тестами, включая защиту от возврата несуществующей категории.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-24 08:51:01 +07:00
parent c626d5dd8d
commit e15f12a3bd
3 changed files with 91 additions and 2 deletions

View file

@ -349,7 +349,31 @@ def _model_supported_efforts(agy_model: str, profile_id: str | None = None) -> s
сама модель совершенно настоящая.
"""
discover_models(profile_id=profile_id)
return _AGY_EFFORT_MAP.get(agy_model, set())
efforts = _AGY_EFFORT_MAP.get(agy_model, set())
if efforts:
return efforts
# Запасной источник: сохранённый на диске список моделей. Обнаружение
# ходит через КОНКРЕТНЫЙ профиль, и если именно у него вход не сделан,
# карта усилий остаётся пустой — тогда уровень не подставляется и agy
# отвергает вызов, хотя модель настоящая. Уровни усилия — свойство
# модели, а не аккаунта, поэтому их можно взять из склеенных
# идентификаторов вида "gemini-3.7-flash-high", уже лежащих в кэше.
try:
from antigravity_provider.router.model_discovery import ModelDiscoveryService
cached = ModelDiscoveryService.get().get_models("antigravity") or []
except Exception:
return efforts
known = {"low", "medium", "high"}
derived = {
suffix
for model_id in cached
for base, _, suffix in [model_id.rpartition("-")]
if base == agy_model and suffix in known
}
return derived
# Effort values understood by both hermes and agy

View file

@ -195,7 +195,12 @@ class AntigravityAdapter(BaseProviderAdapter):
message=err_msg,
)
# ErrorCategory.UNKNOWN не существует — обращение к нему роняло сам
# классификатор, то есть отказ происходил ровно там, где обрабатывался
# другой отказ, и маршрутизация обрывалась вместо перехода к резерву.
# FATAL выбран по образцу codex и opencode: неразобранная ошибка не
# должна вызывать повторов на том же профиле.
return ErrorClassification(
category=ErrorCategory.UNKNOWN,
category=ErrorCategory.FATAL,
message=err_msg,
)

View file

@ -0,0 +1,60 @@
"""Путь обработки ошибок не должен падать сам.
Два дефекта, найденные проверкой всех шести ролей на живой машине:
1. antigravity_adapter.classify_error возвращал ErrorCategory.UNKNOWN
значения с таким именем не существует. Обращение к нему роняло сам
классификатор, то есть отказ случался ровно там, где обрабатывался
другой отказ, и маршрутизация обрывалась вместо перехода к резерву.
2. Уровень усилия не подставлялся, если у конкретного профиля не выполнен
вход agy: карта усилий строится обнаружением через этот профиль. Уровни
же свойство модели, а не аккаунта, и берутся из сохранённого списка.
"""
from __future__ import annotations
from antigravity_provider.router.adapters import get_adapter
from antigravity_provider.router.adapters.base_adapter import ErrorCategory
def test_error_category_has_no_phantom_members():
"""Классификаторы обязаны ссылаться только на существующие категории."""
valid = {m for m in dir(ErrorCategory) if not m.startswith("_")}
assert "UNKNOWN" not in valid, (
"если UNKNOWN добавили — обновите классификаторы, раньше его не было"
)
for name in ("AUTH_REQUIRED", "FATAL", "QUOTA_EXHAUSTED", "RATE_LIMITED", "TRANSIENT"):
assert name in valid
def test_unclassified_error_does_not_crash_classifier():
"""Неразобранная ошибка должна классифицироваться, а не ронять обработчик."""
for provider in ("antigravity", "openai-codex", "opencode-go", "grok", "claude"):
adapter = get_adapter(provider)
result = adapter.classify_error(Exception("нечто совершенно неразобранное"))
assert result.category, f"{provider}: классификатор вернул пустую категорию"
assert isinstance(result.category, str)
def test_effort_levels_derived_from_cached_model_ids(monkeypatch):
"""Уровни усилия берутся из склеенных идентификаторов, без обращения к профилю."""
import antigravity_provider.agy_subprocess as agy
from antigravity_provider.router import model_discovery
monkeypatch.setattr(agy, "discover_models", lambda profile_id=None: {})
monkeypatch.setattr(agy, "_AGY_EFFORT_MAP", {}, raising=False)
class _Svc:
@staticmethod
def get():
return _Svc()
def get_models(self, provider):
return ["gemini-3.7-flash-low", "gemini-3.7-flash-high", "claude-sonnet-4-6"]
monkeypatch.setattr(model_discovery, "ModelDiscoveryService", _Svc)
assert agy._model_supported_efforts("gemini-3.7-flash") == {"low", "high"}
# У модели без суффиксов уровней быть не должно — подставлять нечего.
assert agy._model_supported_efforts("claude-sonnet-4-6") == set()