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.
113 lines
5.2 KiB
Python
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
|