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>
63 lines
2.6 KiB
Python
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")
|