feat(opencode): send x-opencode-session on every OpenCode request for backend affinity

OpenCode pins requests sharing an x-opencode-session value to one upstream
backend, which is what keeps its prompt cache warm across a conversation.
Hermes never sent it, so cache ratios on OpenCode traffic were poor.

- agent/opencode_affinity.py: single owner of the header — target detection
  (built-in zen/go/free, custom opencode-* providers, any opencode.ai URL)
  and the key (affinity scope → conversation root → session id, cron
  timestamp stripped), same resolution as OpenRouter/xAI affinity hints.
- build_api_kwargs: merged once after the per-mode builder, so
  chat_completions, codex_responses and anthropic_messages all carry it.
- auxiliary _build_call_kwargs: same key from the runtime-main session so
  compression/title/vision calls stay on the conversation's backend; the
  aux Codex and Anthropic adapters now forward extra_headers.

Closes #81584, #81832 (deepseek-v4-flash 400 without the header).
This commit is contained in:
Teknium
2026-09-02 21:27:38 -07:00
parent db23c79bbe
commit 139396995a
5 changed files with 185 additions and 2 deletions
+18 -1
View File
@@ -1800,6 +1800,10 @@ class _CodexCompletionsAdapter:
timeout = kwargs.get("timeout")
if timeout is not None:
resp_kwargs["timeout"] = timeout
# Per-request HTTP headers (OpenCode session affinity, Copilot
# x-initiator) map to real headers via the SDK kwarg — forward them.
if isinstance(kwargs.get("extra_headers"), dict) and kwargs["extra_headers"]:
resp_kwargs["extra_headers"] = dict(kwargs["extra_headers"])
# Note: the Codex endpoint (chatgpt.com/backend-api/codex) does NOT
# support max_output_tokens or temperature — omit to avoid 400 errors.
@@ -2553,6 +2557,13 @@ class _AnthropicCompletionsAdapter:
from agent.anthropic_adapter import _forbids_sampling_params
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
# Per-request HTTP headers (OpenCode session affinity) — the Anthropic
# SDK accepts ``extra_headers`` on messages.create/stream too.
if isinstance(kwargs.get("extra_headers"), dict) and kwargs["extra_headers"]:
anthropic_kwargs["extra_headers"] = {
**(anthropic_kwargs.get("extra_headers") or {}),
**kwargs["extra_headers"],
}
# Pass through caller-supplied extra_body so providers behind
# Anthropic-compatible gateways receive their per-vendor request
@@ -9563,7 +9574,13 @@ def _build_call_kwargs(
):
kwargs["_reasoning_config"] = dict(reasoning_config)
return kwargs
# OpenCode relay session affinity — same key as the main turn so
# compression/title/vision calls stay on the conversation's warm backend.
from agent.opencode_affinity import merge_opencode_session_headers
return merge_opencode_session_headers(
kwargs, provider, base_url, _runtime_main_value("session_id") or None
)
def _validate_llm_response(
+19 -1
View File
@@ -1980,7 +1980,25 @@ def _reasoning_config_for_wire(agent):
def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = None) -> dict:
"""Build the keyword arguments dict for the active API mode."""
"""Build the keyword arguments dict for the active API mode.
Wraps the per-api_mode builder so the OpenCode ``x-opencode-session``
affinity header rides on every OpenCode request regardless of transport
(chat_completions / codex_responses / anthropic_messages all route
OpenCode models). No-op for every other provider.
"""
from agent.opencode_affinity import merge_opencode_session_headers
kwargs = _build_api_kwargs_for_mode(agent, api_messages, tools_for_api)
return merge_opencode_session_headers(
kwargs,
getattr(agent, "provider", None),
getattr(agent, "base_url", None),
getattr(agent, "session_id", None),
)
def _build_api_kwargs_for_mode(agent, api_messages: list, tools_for_api: list | None = None) -> dict:
# One-shot continuation override — consumed exactly once, on the FIRST
# request this call builds (only one api_mode branch runs per invocation).
_wire_reasoning_config = _reasoning_config_for_wire(agent)
+84
View File
@@ -0,0 +1,84 @@
"""``x-opencode-session`` — OpenCode relay session-affinity header.
OpenCode (opencode.ai Zen/Go/free relay) pins requests that share an
``x-opencode-session`` value to the same upstream backend, which is what
keeps its prompt cache warm across the turns of one conversation. The value
only has to be opaque and consistent per conversation, so it is derived the
same way as the other conversation-affinity hints Hermes already sends
(OpenRouter's sticky ``session_id``, xAI's ``x-grok-conv-id``): the
host-declared routing scope first, then the ambient conversation root, then
the physical session id — normalized through ``_cache_scope_from_session_id``
so cron fires of one job share a scope.
Every OpenCode request — main turn on any transport, auxiliary calls
(compression, titles, vision, MoA) — goes through :func:`opencode_session_headers`
so the header cannot drift per code path.
"""
from __future__ import annotations
from typing import Any, Optional
OPENCODE_SESSION_HEADER = "x-opencode-session"
def is_opencode_target(provider: Optional[str], base_url: Optional[str]) -> bool:
"""True when *provider* or *base_url* addresses the OpenCode relay.
Matches the built-in opencode-zen/go/free providers, custom
``opencode-<family>-*`` providers, and any base_url hosted on opencode.ai.
"""
try:
from hermes_cli.models import opencode_provider_family
if opencode_provider_family(provider) is not None:
return True
except Exception:
pass
try:
from agent.anthropic_endpoints import _is_opencode_endpoint
return _is_opencode_endpoint(str(base_url or ""))
except Exception:
return False
def opencode_session_headers(
provider: Optional[str],
base_url: Optional[str],
session_id: Optional[str] = None,
) -> dict[str, str]:
"""Return ``{"x-opencode-session": <key>}`` for OpenCode targets, else ``{}``."""
if not is_opencode_target(provider, base_url):
return {}
try:
from agent.portal_tags import get_affinity_scope, get_conversation_context
from agent.transports.codex import _cache_scope_from_session_id
key = _cache_scope_from_session_id(
get_affinity_scope() or get_conversation_context() or session_id
)
except Exception:
key = str(session_id or "")
return {OPENCODE_SESSION_HEADER: key} if key else {}
def merge_opencode_session_headers(
kwargs: dict[str, Any],
provider: Optional[str],
base_url: Optional[str],
session_id: Optional[str] = None,
) -> dict[str, Any]:
"""Merge the affinity header into ``kwargs["extra_headers"]`` (in place).
Existing per-request headers win, so a caller-pinned value is preserved.
Non-OpenCode targets are left untouched.
"""
headers = opencode_session_headers(provider, base_url, session_id)
if headers:
existing = kwargs.get("extra_headers")
merged = dict(existing) if isinstance(existing, dict) else {}
for key, value in headers.items():
merged.setdefault(key, value)
kwargs["extra_headers"] = merged
return kwargs
@@ -0,0 +1,62 @@
"""x-opencode-session rides on every OpenCode request, on every transport."""
from __future__ import annotations
import pytest
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": "hi"}]
def _agent(provider, model, base_url, api_mode=None):
agent = AIAgent(
api_key="test-key",
base_url=base_url,
model=model,
provider=provider,
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
session_id="sess-affinity-1",
)
if api_mode:
agent.api_mode = api_mode
agent._transport = None
agent._anthropic_base_url = base_url
return agent
@pytest.mark.parametrize(
"provider, model, base_url, api_mode",
[
("opencode-go", "glm-5", "https://opencode.ai/zen/go/v1", None), # chat_completions
("opencode-go", "gpt-5.6-luna", "https://opencode.ai/zen/go/v1", None), # codex_responses
("opencode-go", "minimax-m2.7", "https://opencode.ai/zen/go/v1", "anthropic_messages"),
("opencode-free", "laguna-s-2.1-free", "https://opencode.ai/zen/v1", None),
("custom", "glm-5", "https://opencode.ai/zen/go/v1", None), # URL-only detection
],
)
def test_main_turn_sends_stable_session_header_on_every_transport(provider, model, base_url, api_mode):
agent = _agent(provider, model, base_url, api_mode)
first = build_api_kwargs(agent, _MSGS)["extra_headers"]["x-opencode-session"]
second = build_api_kwargs(agent, _MSGS)["extra_headers"]["x-opencode-session"]
assert first == second == "sess-affinity-1"
other = _agent("openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1")
assert "x-opencode-session" not in (build_api_kwargs(other, _MSGS).get("extra_headers") or {})
def test_auxiliary_calls_share_the_main_turn_session_key():
token = aux.set_runtime_main(
"opencode-go", "glm-5", base_url="https://opencode.ai/zen/go/v1", session_id="sess-affinity-1"
)
try:
kwargs = aux._build_call_kwargs("opencode-go", "glm-5", _MSGS, base_url="https://opencode.ai/zen/go/v1")
assert kwargs["extra_headers"]["x-opencode-session"] == "sess-affinity-1"
other = aux._build_call_kwargs("openrouter", "x", _MSGS, base_url="https://openrouter.ai/api/v1")
assert "x-opencode-session" not in (other.get("extra_headers") or {})
finally:
aux._RUNTIME_MAIN_CONTEXT.reset(token)
+2
View File
@@ -61,6 +61,8 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
| **LM Studio** | `hermes model` → "LM Studio" (provider: `lmstudio`, optional `LM_API_KEY`) |
| **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) |
All three OpenCode providers send an opaque, per-conversation `x-opencode-session` header on every request (main turns on every transport plus auxiliary calls such as compression and titles). OpenCode uses it to pin a conversation to one backend so its prompt cache stays warm; the value is derived from the Hermes session id and carries no personal data.
For the official API-key path, see the dedicated [Google Gemini guide](/guides/google-gemini).
:::tip Model key alias