mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
Auxiliary LLM calls routed through OpenRouter now carry the `session_id`
sticky routing key, so they pin to the same provider endpoint as the
conversation they belong to instead of routing independently.
`OpenRouterProfile.build_extra_body` sourced the key only from its explicit
argument. Auxiliary call sites — compression, title generation, vision,
web_extract, session_search, MoA slots — funnel through
`agent.auxiliary_client`, which has no session handle and never passes one.
Those calls sent NO sticky key at all, so OpenRouter fell back to hashing
the opening messages and each aux call could land on a different provider
than the conversation it served. Per OpenRouter's prompt-caching docs a
present `session_id` is used directly as the routing key and activates
stickiness on the first successful request rather than only after a cache
hit, so the missing key also delayed pinning for the main loop.
Resolve it from the ambient conversation contextvar first, explicit argument
as fallback — the same resolution the Nous Portal profile uses (f2f4df064d)
and the same one `nous_portal_tags` already used for the `conversation=` tag.
The ambient value is the session-lineage ROOT, so the key also stays stable
for installs that opt out of the default `compression.in_place: true` and
across delegate-subagent trees.
The xAI `x-grok-conv-id` cache-affinity header in `build_api_kwargs_extras`
had the identical gap and is fixed the same way; it remains model-gated to
`x-ai/grok-*` / `xai/grok-*`.
Fixes #70820. Credit to @webtecnica, who reported it and submitted #70883
first; that PR threaded `session_id` through `call_llm` to reach the same
goal. This takes the ambient-contextvar route instead because none of the
34 `call_llm` call sites currently pass a `session_id`, so the parameter
alone would not have changed any live request.
Tests: 2 cases in tests/providers/test_provider_profiles.py, both verified
to fail against the pre-fix behavior via a sabotage run. E2E-validated on a
real AIAgent driving the out-of-turn compaction path.
212 lines
9.8 KiB
Python
212 lines
9.8 KiB
Python
"""OpenRouter provider profile."""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from agent.portal_tags import get_conversation_context
|
|
from providers import register_provider
|
|
from providers.base import ProviderProfile
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CACHE: list[str] | None = None
|
|
|
|
# Anthropic model families that still accept an explicit "disable thinking"
|
|
# request (the manual ``thinking: {type: "disabled"}`` form OpenRouter emits
|
|
# for ``reasoning: {enabled: false}``). Everything Claude 4.6 and newer —
|
|
# including future date-stamped / named models (fable, mythos-class, …) —
|
|
# mandates reasoning and returns HTTP 400 on any disable form. We therefore
|
|
# default *unknown* Anthropic models to "cannot disable" (the modern contract)
|
|
# and keep only this explicit legacy allowlist of models that can. Mirrors the
|
|
# default-to-newest philosophy in agent/anthropic_adapter._get_anthropic_max_output.
|
|
_ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS = (
|
|
"claude-3", # 3, 3.5, 3.7
|
|
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
|
|
"claude-sonnet-4-0", "claude-sonnet-4.0",
|
|
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
|
|
"claude-opus-4-5", "claude-opus-4.5",
|
|
"claude-sonnet-4-5", "claude-sonnet-4.5",
|
|
"claude-haiku-4-5", "claude-haiku-4.5",
|
|
)
|
|
|
|
|
|
def _anthropic_reasoning_is_mandatory(model: str | None) -> bool:
|
|
"""Return True for Anthropic models that reject any disable-thinking form.
|
|
|
|
Claude 4.6+ (adaptive thinking) and newer named models have no "off"
|
|
switch — sending ``reasoning: {enabled: false}`` makes OpenRouter emit
|
|
``thinking: {type: "disabled"}``, which these models 400 on. Unknown /
|
|
new Anthropic model names default to mandatory so the next un-numbered
|
|
release doesn't reintroduce the 400.
|
|
"""
|
|
m = (model or "").lower()
|
|
if not m.startswith(("anthropic/", "claude")) and "claude" not in m:
|
|
return False
|
|
return not any(sub in m for sub in _ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS)
|
|
|
|
|
|
class OpenRouterProfile(ProviderProfile):
|
|
"""OpenRouter aggregator — provider preferences, reasoning config passthrough."""
|
|
|
|
def fetch_models(
|
|
self,
|
|
*,
|
|
api_key: str | None = None,
|
|
base_url: str | None = None,
|
|
timeout: float = 8.0,
|
|
) -> list[str] | None:
|
|
"""Fetch from public OpenRouter catalog — no auth required.
|
|
|
|
Note: Tool-call capability filtering is applied by hermes_cli/models.py
|
|
via fetch_openrouter_models() → _openrouter_model_supports_tools(), not
|
|
here. The picker early-returns via the dedicated openrouter path before
|
|
reaching this method, so filtering here would be unreachable.
|
|
"""
|
|
global _CACHE # noqa: PLW0603
|
|
if _CACHE is not None:
|
|
return _CACHE
|
|
try:
|
|
result = super().fetch_models(api_key=None, base_url=base_url, timeout=timeout)
|
|
if result is not None:
|
|
_CACHE = result
|
|
return result
|
|
except Exception as exc:
|
|
logger.debug("fetch_models(openrouter): %s", exc)
|
|
return None
|
|
|
|
def build_extra_body(
|
|
self, *, session_id: str | None = None, **context: Any
|
|
) -> dict[str, Any]:
|
|
body: dict[str, Any] = {}
|
|
# Top-level session_id → OpenRouter's sticky routing key. Per their
|
|
# prompt-caching docs it is used directly as the routing key instead of
|
|
# hashing the opening messages, and it activates stickiness on the
|
|
# first successful request rather than only after a cache hit.
|
|
#
|
|
# Resolve it from the ambient conversation contextvar first, explicit
|
|
# argument as fallback. The gap this closes is the auxiliary call sites
|
|
# — compression, title generation, vision, web_extract, session_search,
|
|
# MoA slots — which funnel through ``agent.auxiliary_client``. That
|
|
# module has no session handle and passes no ``session_id``, so those
|
|
# calls sent NO sticky key at all and each routed independently of the
|
|
# conversation it belonged to (#70820).
|
|
#
|
|
# Mirrors the Nous Portal profile, which resolves the same way
|
|
# (f2f4df064d). The ambient value is the session-lineage ROOT, so it
|
|
# also stays stable for installs that opt out of the default
|
|
# ``compression.in_place: true`` and across delegate-subagent trees.
|
|
sticky_key = get_conversation_context() or session_id
|
|
if sticky_key:
|
|
body["session_id"] = sticky_key
|
|
prefs = context.get("provider_preferences")
|
|
if prefs:
|
|
body["provider"] = prefs
|
|
|
|
# Pareto Code router — model-gated. The plugins block is only
|
|
# meaningful for openrouter/pareto-code; sending it on any other
|
|
# model has no documented effect and would be confusing in logs.
|
|
# See: https://openrouter.ai/docs/guides/routing/routers/pareto-router
|
|
model = (context.get("model") or "")
|
|
if model == "openrouter/pareto-code":
|
|
score = context.get("openrouter_min_coding_score")
|
|
if score is not None and score != "":
|
|
try:
|
|
score_f = float(score)
|
|
except (TypeError, ValueError):
|
|
score_f = None
|
|
if score_f is not None and 0.0 <= score_f <= 1.0:
|
|
body["plugins"] = [
|
|
{"id": "pareto-router", "min_coding_score": score_f}
|
|
]
|
|
return body
|
|
|
|
def build_api_kwargs_extras(
|
|
self,
|
|
*,
|
|
reasoning_config: dict | None = None,
|
|
supports_reasoning: bool = False,
|
|
model: str | None = None,
|
|
session_id: str | None = None,
|
|
**context: Any,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""OpenRouter passes the full reasoning_config dict as extra_body.reasoning.
|
|
|
|
For xAI Grok models routed through OpenRouter, attach the
|
|
``x-grok-conv-id`` header so that xAI's prompt cache stays pinned to
|
|
the same backend server across turns.
|
|
"""
|
|
extra_body: dict[str, Any] = {}
|
|
top_level: dict[str, Any] = {}
|
|
extra_headers: dict[str, Any] = {}
|
|
if supports_reasoning:
|
|
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
|
|
# future named models) use *adaptive* thinking: the model decides
|
|
# how much to think, and OpenRouter ignores ``reasoning.effort`` for
|
|
# them entirely. Sending any ``reasoning`` field is therefore both
|
|
# pointless and actively harmful:
|
|
# - ``{enabled: false}`` → OpenRouter emits Anthropic's manual
|
|
# ``thinking: {type: "disabled"}``, which these models 400 on.
|
|
# - any enabled form, on a tool-continuation turn whose prior
|
|
# assistant tool_call carries no thinking block (chat_completions
|
|
# never replays signed thinking blocks), ALSO makes OpenRouter
|
|
# emit ``thinking: {type: "disabled"}`` → the same 400 on every
|
|
# turn after the first tool call.
|
|
# The only reliable behavior is to omit ``reasoning`` and let the
|
|
# model default to adaptive. See hermes-agent#42991 (disable case)
|
|
# and the tool-replay follow-up.
|
|
#
|
|
# ``reasoning.effort`` being ignored does NOT mean these models have
|
|
# no effort lever — OpenRouter honors the requested effort on the
|
|
# top-level ``verbosity`` field instead (it maps to Anthropic's
|
|
# ``output_config.effort``; ``reasoning.effort`` is accepted but
|
|
# ignored — confirmed by OpenRouter's Claude migration docs and a
|
|
# live token-spend probe in hermes-agent#43432). Route the existing
|
|
# ``reasoning_config["effort"]`` (sourced from
|
|
# ``agent.reasoning_effort``) onto ``verbosity`` so the knob the user
|
|
# already sets keeps working for these models. We still send NO
|
|
# ``reasoning`` field, preserving the #42991 400 fix.
|
|
if _anthropic_reasoning_is_mandatory(model):
|
|
cfg = reasoning_config or {}
|
|
effort = cfg.get("effort")
|
|
# Only emit when effort is actually requested and reasoning
|
|
# isn't explicitly disabled. Otherwise omit ``verbosity`` so the
|
|
# model keeps its own adaptive default (``high``).
|
|
if cfg.get("enabled", True) is not False and effort and effort != "none":
|
|
top_level["verbosity"] = effort
|
|
elif reasoning_config is not None:
|
|
extra_body["reasoning"] = dict(reasoning_config)
|
|
else:
|
|
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
|
|
|
|
# Same resolution as build_extra_body: xAI's prompt cache is pinned per
|
|
# backend server via this header, and aux calls pass no session_id, so
|
|
# reading the ambient conversation keeps compression/vision/MoA traffic
|
|
# on the same Grok backend as the conversation it belongs to.
|
|
grok_conv_id = get_conversation_context() or session_id
|
|
if grok_conv_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
|
|
extra_headers["x-grok-conv-id"] = grok_conv_id
|
|
if extra_headers:
|
|
top_level["extra_headers"] = extra_headers
|
|
|
|
return extra_body, top_level
|
|
|
|
|
|
openrouter = OpenRouterProfile(
|
|
name="openrouter",
|
|
aliases=("or",),
|
|
env_vars=("OPENROUTER_API_KEY",),
|
|
display_name="OpenRouter",
|
|
description="OpenRouter — unified API for 200+ models",
|
|
signup_url="https://openrouter.ai/keys",
|
|
base_url="https://openrouter.ai/api/v1",
|
|
models_url="https://openrouter.ai/api/v1/models",
|
|
fallback_models=(
|
|
"anthropic/claude-sonnet-4.6",
|
|
"openai/gpt-5.4",
|
|
"deepseek/deepseek-chat",
|
|
"google/gemini-3-flash-preview",
|
|
"qwen/qwen3-plus",
|
|
),
|
|
)
|
|
|
|
register_provider(openrouter)
|