Files
hermes-agent/agent/nous_wire.py
Teknium 45646f4d09 feat(nous): anthropic_wire=auto decides a session's wire from its first response
Portal will serve anthropic/* from more than one upstream (OpenRouter
passthrough today; GMI/Vertex once it is back online). The native Messages
wire is the better transport but is only safe where the upstream keeps
prompt-cache routing sticky: measured false on the OpenRouter path (14-20% of
consecutive calls re-write the previous turn; #104284 moved the default to
chat), untested on GMI. Hermes cannot see the upstream in the request, only
in the response: OpenRouter stamps `provider` (chat wire) and mints
`gen-<unix>-<rand>` ids; GMI/Vertex returns Anthropic-native `msg_...` ids
and no provider.

`auto` therefore starts every session on chat (correct on both upstreams),
classifies the first response, and switches that session to native only
when the upstream is GMI AND `agent/nous_wire.py::GMI_NATIVE_WIRE_CLEARED`
is True. The switch is scheduled at response time and applied at the start
of the next iteration (turn_iteration_prep), so nothing is rebuilt while a
response is being consumed; it goes through switch_model so the client,
cache policy and _primary_runtime stay consistent. One decision per session,
call 1 only; unknown upstream never switches; a failed switch logs and stays.

GMI_NATIVE_WIRE_CLEARED is False: until the 20x6 concurrency probe
(evals/postmortem/live_ab) is clean on a GMI-served anthropic/* id on the
native wire, `auto` behaves exactly like `chat`. Flipping it is the whole
rollout once GMI is measured. Default stays `chat`.

Tests (17): classifier on real Portal response shapes from both wires and
both upstreams; chat for openrouter/unknown, GMI gated on the flag; one
decision per session, call 1 only, explicit chat/native never auto-switch,
other providers/models untouched, switch failure swallowed and final;
record_response_usage on a real AIAgent invokes the hook once.

Live (auto, real Portal, Fable 5.1): arm A, real classification
(OpenRouter today) - stays on chat through a tool loop and a second turn,
cache 97-99%. Arm B, classifier forced to gmi with the flag on - call 1 on
chat, switch applied before call 2, calls 2-3 on the native wire in the
same session, tool result and both turns correct, cache 97-99%. An earlier
shape that switched inside the response path broke call 1 (SimpleNamespace
has no .content); the scheduled apply is why.
2026-09-06 09:11:04 -07:00

113 lines
5.2 KiB
Python

"""Nous Portal ``anthropic/*`` wire selection when ``nous.anthropic_wire`` is ``auto``.
Portal serves Claude two ways and Hermes cannot tell which from the request: an OpenRouter
passthrough (today, for every ``anthropic/*`` id) or GMI/Vertex (planned once GMI is back). The
native Messages wire is the better transport, but on the OpenRouter path it re-writes the previous
turn's prompt cache on 14-20% of consecutive calls in concurrent tool loops (measured 2026-09-06;
NousResearch/api#227), so the session must ride chat/completions there. On GMI that is untested,
and until it is measured ``auto`` never promotes to native.
The upstream IS visible in the first RESPONSE: OpenRouter stamps ``provider`` (chat wire) and
mints ``gen-<unix>-<rand>`` ids; GMI/Vertex responses carry neither. So ``auto`` starts every
session on chat (safe on both upstreams), reads the first response, and switches the session to
native only when the upstream is GMI and native has been cleared for GMI. One decision per
session, at call 1, before there is a cache to lose; later calls never flip.
``classify_upstream`` is pure and unit-tested; ``maybe_switch_wire_after_first_response`` is
the single hook, called from the usage recorder.
"""
from __future__ import annotations
import logging
import re
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Flip to True only after the 20x6 concurrency probe (evals/postmortem/live_ab) is clean on a
# GMI-served anthropic/* id on the native wire. Until then ``auto`` is chat everywhere.
GMI_NATIVE_WIRE_CLEARED = False
_OPENROUTER_ID = re.compile(r"^gen-\d{9,}-[A-Za-z0-9_-]{8,}$")
def classify_upstream(response: Any) -> Optional[str]:
"""``"openrouter"`` / ``"gmi"`` / ``None`` (unknown) from a Portal response object.
Works on both wires: the OpenAI SDK object exposes ``.provider`` (OpenRouter's upstream name,
e.g. ``"Anthropic"``, ``"Amazon Bedrock"``) and an OpenRouter-minted ``.id``; the Anthropic SDK
object has ``.id`` only. GMI/Vertex responses have Anthropic-native ``msg_…`` ids and no
``provider``. Anything else is unknown, and unknown never triggers a switch.
"""
if response is None:
return None
if isinstance(getattr(response, "provider", None), str) and getattr(response, "provider"):
return "openrouter"
rid = getattr(response, "id", None)
if isinstance(rid, str):
if _OPENROUTER_ID.match(rid):
return "openrouter"
if rid.startswith("msg_"):
return "gmi"
return None
def wire_for_upstream(upstream: Optional[str]) -> str:
"""The api_mode ``auto`` wants once the upstream is known. Chat unless GMI and cleared."""
if upstream == "gmi" and GMI_NATIVE_WIRE_CLEARED:
return "anthropic_messages"
return "chat_completions"
def maybe_switch_wire_after_first_response(agent: Any, response: Any, api_call_count: int) -> bool:
"""Decide the session's wire from its first response; the switch itself is applied at the
start of the next iteration (``apply_pending_wire_switch``), never while a response is being
consumed. Returns True when a switch was scheduled.
Only for provider=nous, anthropic/* models, ``nous.anthropic_wire: auto``, and only on the
session's first API call.
"""
if api_call_count != 1 or getattr(agent, "_nous_wire_decided", False):
return False
if (getattr(agent, "provider", "") or "").lower() != "nous":
return False
model = str(getattr(agent, "model", "") or "")
if not model.lower().startswith("anthropic/"):
return False
try:
from hermes_cli.providers import _nous_anthropic_wire
if _nous_anthropic_wire() != "auto":
return False
except Exception:
return False
agent._nous_wire_decided = True # one decision per session, whatever it is
upstream = classify_upstream(response)
want = wire_for_upstream(upstream)
if want == getattr(agent, "api_mode", None):
logger.debug("nous wire auto: upstream=%s, staying on %s", upstream, want)
return False
agent._nous_wire_pending = (want, upstream)
return True
def apply_pending_wire_switch(agent: Any) -> bool:
"""At iteration start, with no response in flight: perform the switch scheduled by
``maybe_switch_wire_after_first_response``. Reuses ``switch_model`` (same model/provider, new
api_mode) so client rebuild, cache policy and ``_primary_runtime`` stay consistent. A failure
is logged and the session stays on its current wire."""
pending = getattr(agent, "_nous_wire_pending", None)
if not pending:
return False
agent._nous_wire_pending = None
want, upstream = pending
try:
from agent.agent_runtime_helpers import switch_model
switch_model(agent, agent.model, "nous", api_key=getattr(agent, "api_key", "") or "",
base_url=getattr(agent, "base_url", "") or "", api_mode=want)
except Exception as exc: # never let wire selection break a turn
logger.warning("nous wire auto: switch to %s failed (%s); staying on %s", want, exc, agent.api_mode)
return False
logger.info("nous wire auto: upstream=%s -> %s for the rest of session %s", upstream, want,
getattr(agent, "session_id", "?"))
return True