From e15f12a3bd2ec2e0697829b893f8e5d1c93ef095 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Mon, 24 Aug 2026 08:51:01 +0700 Subject: [PATCH] =?UTF-8?q?fix(router):=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D0=B8=D0=BA=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BF=D0=B0=D0=B4=D0=B0=D0=BB=20=D1=81=D0=B0=D0=BC,?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=B2=D0=B5=D0=BD=D1=8C=20=D1=83=D1=81?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=D1=8F=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D1=8F=D0=BB=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено проверкой всех шести ролей на живой машине владельца. До правок работали три роли из шести. 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 --- src/antigravity_provider/agy_subprocess.py | 26 +++++++- .../router/adapters/antigravity_adapter.py | 7 ++- tests/test_router_error_path.py | 60 +++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 tests/test_router_error_path.py diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index 7aa46bd..4fd1231 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -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 diff --git a/src/antigravity_provider/router/adapters/antigravity_adapter.py b/src/antigravity_provider/router/adapters/antigravity_adapter.py index 0316f69..3bf94ff 100644 --- a/src/antigravity_provider/router/adapters/antigravity_adapter.py +++ b/src/antigravity_provider/router/adapters/antigravity_adapter.py @@ -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, ) diff --git a/tests/test_router_error_path.py b/tests/test_router_error_path.py new file mode 100644 index 0000000..e776d0c --- /dev/null +++ b/tests/test_router_error_path.py @@ -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()