fix(plugin): отказ роутера больше не подменяет ответ модели в Hermes

Hub подключён к Hermes как middleware llm_execution и срабатывает на
каждом обращении к модели. Но Hermes роль не передаёт: в kwargs есть
model, provider, session_id, task_id — role нет. resolve_role поэтому
сваливается в роль по умолчанию, и КАЖДЫЙ вызов Hermes маршрутизируется
как orchestrator.

Цепочка orchestrator у владельца исчерпана целиком:
  ag-orch-fallback  skipped_unhealthy
  codex-orch        429 «account is not active, check billing»
  opengo-3          No API key found
  ag-w1/ag-w3       agy authentication failed or timed out

Роутер возвращал «⚠️ Hermes Router Failover Exhausted» как ответ
ассистента, и Hermes показывал это вместо ответа модели, хотя его
собственный провайдер работал. Это и есть «основной оркестратор не
выбрался» из отчёта владельца.

Теперь при router_error вызов уходит дальше по цепочке (next_call),
а отказ пишется в журнал уровнем warning с полным следом. Плагин обязан
быть незаметным при отказе: он может улучшить маршрутизацию, но не имеет
права сделать Hermes хуже, чем без него.

Проверено исполнением: Hermes получает ответ провайдера, а не текст
ошибки. Тесты: 287 passed (падает только известный нестабильный Tk-тест,
воспроизводится на чистом main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-23 12:25:02 +07:00
parent 6494c5198a
commit 2d62d3973e
2 changed files with 84 additions and 0 deletions

View file

@ -37,6 +37,20 @@ def antigravity_llm_execution(**kwargs: Any) -> Any:
role = kwargs.get("role") or request.get("role") role = kwargs.get("role") or request.get("role")
session_id = kwargs.get("session_id") or request.get("session_id") session_id = kwargs.get("session_id") or request.get("session_id")
completion = engine.route_request(request, role=role, session_id=session_id) completion = engine.route_request(request, role=role, session_id=session_id)
# Исчерпанная цепочка — это отказ роутера, а не ответ модели.
# Возвращать её текст Гермесу нельзя: он подменит собой настоящий
# ответ провайдера, который Гермес выбрал бы сам, и пользователь
# получит «Failover Exhausted» вместо работы. Плагин обязан быть
# незаметным при отказе: пропускаем вызов дальше по цепочке.
if isinstance(completion, dict) and completion.get("router_error"):
logger.warning(
"Router failover exhausted for role %r; passing the call downstream to Hermes: %s",
role,
completion.get("failover_trail"),
)
if callable(next_call):
return next_call(request)
return openai_completion_object(completion)
if isinstance(completion, dict) and "error" in completion and not completion.get("choices"): if isinstance(completion, dict) and "error" in completion and not completion.get("choices"):
err_text = format_antigravity_error(completion.get("error")) err_text = format_antigravity_error(completion.get("error"))
completion = { completion = {

View file

@ -0,0 +1,70 @@
"""Плагин не должен делать Hermes хуже, чем без него.
Hermes вызывает Hub как middleware `llm_execution` на каждом обращении к
модели, но **роль не передаёт** в kwargs есть model, provider, session_id,
task_id, а `role` нет. Роутер поэтому сваливается в роль по умолчанию
(`orchestrator`), и если её цепочка исчерпана, раньше он возвращал текст
« Hermes Router Failover Exhausted» как ответ ассистента. Пользователь
видел это вместо ответа модели, хотя собственный провайдер Hermes работал.
"""
from __future__ import annotations
from typing import Any, Dict
import pytest
from antigravity_provider import hermes_plugin
class _ExhaustedEngine:
class config:
enabled = True
def route_request(self, request: Dict[str, Any], role: Any = None, session_id: Any = None) -> Dict[str, Any]:
return {
"id": "router-fail-1",
"model": "router-failover",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "⚠️ Hermes Router Failover Exhausted"},
"finish_reason": "error",
}
],
"router_error": True,
"failover_trail": [{"profile_id": "codex-orch", "status": "failed"}],
}
@pytest.fixture
def exhausted_router(monkeypatch):
monkeypatch.setattr(hermes_plugin, "get_router_engine", lambda: _ExhaustedEngine(), raising=False)
import antigravity_provider.router as router_pkg
monkeypatch.setattr(router_pkg, "get_router_engine", lambda: _ExhaustedEngine())
def test_exhausted_failover_passes_call_downstream(exhausted_router):
downstream_calls = []
def next_call(payload=None):
downstream_calls.append(payload)
return {
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "настоящий ответ"}, "finish_reason": "stop"}
]
}
result = hermes_plugin.antigravity_llm_execution(
request={"messages": [{"role": "user", "content": "ping"}]},
next_call=next_call,
provider="gemini",
model="gemini-3.7-flash",
session_id="s1",
)
assert len(downstream_calls) == 1, "отказ роутера обязан уходить вниз по цепочке, а не подменять ответ"
content = result["choices"][0]["message"]["content"]
assert content == "настоящий ответ"
assert "Failover Exhausted" not in content