Files
hermes-agent/agent/served_model.py
teknium1 bafb778edc feat(gateway): opt-in served_model footer field shows the model that really answered
What: a new opt-in gateway runtime-footer field `served_model` rendered as
`alias → served`. It is populated from the `x-litellm-model-id` response header
(fallback `x-litellm-model-api-base`) that routing proxies send on every chat
completion, captured through an httpx response hook installed on the agent's
OpenAI client (agent/served_model.py, wired in create_openai_client), and from
Hermes' own provider fallback (primary runtime model → active model) when no
header is present. The turn result carries `requested_model` / `served_model`;
gateway/run_turn.py passes them to the footer. Off unless listed in
`display.runtime_footer.fields`; the default field set renders exactly as before.

Why: behind a routing proxy (or during a silent Hermes fallback) every reply
shows the configured alias, so operators cannot see which deployment actually
served a request (#54864). The SDK's parsed objects drop response headers, so
the capture has to sit on the transport.
2026-09-19 10:38:30 -07:00

69 lines
3.1 KiB
Python

"""Per-response *served model* capture for routing proxies (#54864).
A LiteLLM-style proxy answers with the configured alias in the body's ``model`` field and puts
the deployment it really routed to in a response header (``x-litellm-model-id``, else the
upstream ``x-litellm-model-api-base``). The OpenAI SDK's parsed objects drop headers, so the
capture rides an ``httpx`` response hook on the client Hermes builds for the agent; it stores
the header onto ``agent.last_served_model`` (``None`` when the response carried none, so a
value never outlives the request that produced it). Consumers: ``agent/turn_finalizer.py``
(result ``served_model`` / ``requested_model``) and the opt-in gateway footer field
``served_model`` (``gateway/runtime_footer.py``). Fail-open everywhere.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
SERVED_MODEL_HEADERS: tuple[str, ...] = ("x-litellm-model-id", "x-litellm-model-api-base")
_HOOK_MARK = "_hermes_served_model_hook"
def served_model_from_headers(headers: Any) -> Optional[str]:
"""First non-empty served-model header, or ``None``."""
if headers is None or not hasattr(headers, "get"):
return None
for name in SERVED_MODEL_HEADERS:
value = headers.get(name)
if value not in (None, ""):
return str(value).strip()
return None
def install_served_model_capture(agent: Any, client: Any) -> None:
"""Register the response hook on *client*'s ``httpx`` transport (idempotent per client)."""
http_client = getattr(client, "_client", None)
hooks = getattr(http_client, "event_hooks", None)
if not isinstance(hooks, dict):
return
if any(getattr(h, _HOOK_MARK, False) for h in hooks.get("response", ())):
return
def _on_response(response: Any) -> None:
try:
if 200 <= int(getattr(response, "status_code", 0)) < 300:
agent.last_served_model = served_model_from_headers(getattr(response, "headers", None))
except Exception:
logger.debug("served-model header capture skipped", exc_info=True)
setattr(_on_response, _HOOK_MARK, True)
try:
# httpx copies on assignment; rebuild the mapping instead of mutating the live list.
http_client.event_hooks = {**hooks, "response": [*hooks.get("response", ()), _on_response]}
except Exception:
logger.debug("served-model hook install skipped", exc_info=True)
def result_model_fields(agent: Any) -> dict[str, Optional[str]]:
"""``requested_model`` / ``served_model`` for the turn result: the proxy header when the
last response carried one, else Hermes' own fallback route (primary → active model)."""
served = getattr(agent, "last_served_model", None)
requested = agent.model
if not served and getattr(agent, "_fallback_activated", False):
primary = str((getattr(agent, "_primary_runtime", None) or {}).get("model") or "").strip()
if primary and primary != agent.model:
requested, served = primary, agent.model
return {"requested_model": requested, "served_model": served or None}