Files
hermes-agent/tests/agent/test_session_affinity_header.py
teknium1 2828e19a02 feat: slim the per-provider session header to the route seam and document it
Trim of the cherry-picked #104566 so it clears the salvage bar and covers #86241:

- The config getter matches entries the way every sibling per-provider knob does
  (`_entries_for_route`, route identity only) instead of a second provider-name axis;
  returns "" like `get_custom_provider_extra_headers` returns {}.
- `merge_opencode_session_headers` becomes `merge_session_affinity_headers` at both
  call sites (main `build_api_kwargs`, auxiliary `_build_call_kwargs`); the alias line
  is gone. Both sources merge (an OpenCode target that also declares a header gets both).
- Tests cut from six to two invariants: configured header carries one value per
  conversation on chat_completions, anthropic_messages and auxiliary kwargs (different
  for another session, caller-pinned wins); unconfigured → no header on any path.
- Docs: `configuring-models.md` per-provider options, `providers.md` entry key list,
  `cli-config.yaml.example` — the key is opt-in, default off, so DEFAULT_CONFIG is unchanged.

Why: a session-aware proxy classifies a request with no session id whose last message is a
tool_result as a NEW conversation and re-sends the whole history upstream (cache_write ≈
cache_read). Hermes already derives a rotation-stable conversation key for OpenCode; naming
the header per provider lets any proxy receive it without shipping an identifier by default.

Co-authored-by: 0xAlyDev <agentai891@gmail.com>
2026-09-19 01:02:31 -07:00

63 lines
2.6 KiB
Python

"""Per-provider ``session_affinity_header`` (#86241, #104449).
A custom provider entry may name a header that carries Hermes' conversation id so a
session-aware proxy can correlate the requests of one agent loop. Off unless configured.
"""
from __future__ import annotations
from unittest.mock import patch
from agent import auxiliary_client as aux
from agent.chat_completion_helpers import build_api_kwargs
from run_agent import AIAgent
_MSGS = [{"role": "user", "content": "hello"}]
_BASE = "http://localhost:4000/v1"
_HEADER = "x-litellm-session-id"
def _agent(session_id, api_mode="chat_completions"):
agent = AIAgent(
api_key="test-key", base_url=_BASE, model="claude-3-5-sonnet", provider="litellm-lan",
api_mode=api_mode, quiet_mode=True, skip_context_files=True, skip_memory=True,
session_id=session_id,
)
agent._anthropic_base_url = _BASE
return agent
def _providers(with_header: bool):
entry = {"name": "litellm-lan", "provider_key": "litellm-lan", "base_url": _BASE}
if with_header:
entry["session_affinity_header"] = _HEADER
return patch("hermes_cli.config.get_compatible_custom_providers", return_value=[entry])
def _aux_headers(session_id):
token = aux.set_runtime_main("litellm-lan", "claude-3-5-sonnet", base_url=_BASE, session_id=session_id)
try:
return aux._build_call_kwargs("litellm-lan", "claude-3-5-sonnet", _MSGS, base_url=_BASE).get("extra_headers") or {}
finally:
aux._RUNTIME_MAIN_CONTEXT.reset(token)
def test_configured_header_carries_one_value_per_conversation_on_every_path():
with _providers(True):
chat = build_api_kwargs(_agent("sess-A"), _MSGS)["extra_headers"][_HEADER]
anthropic = build_api_kwargs(_agent("sess-A", api_mode="anthropic_messages"), _MSGS)["extra_headers"][_HEADER]
assert chat == anthropic == _aux_headers("sess-A")[_HEADER] == "sess-A"
assert build_api_kwargs(_agent("sess-B"), _MSGS)["extra_headers"][_HEADER] == "sess-B"
# A caller-pinned value is preserved.
pinned = _agent("sess-A")
pinned.request_overrides = {"extra_headers": {_HEADER: "pinned-by-caller"}}
assert build_api_kwargs(pinned, _MSGS)["extra_headers"][_HEADER] == "pinned-by-caller"
def test_unconfigured_provider_sends_no_session_header():
with _providers(False):
for agent in (_agent("sess-A"), _agent("sess-A", api_mode="anthropic_messages")):
headers = build_api_kwargs(agent, _MSGS).get("extra_headers") or {}
assert _HEADER not in headers and "x-opencode-session" not in headers
assert _HEADER not in _aux_headers("sess-A")