Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,32 @@ def _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs: dict) -> dic
return anthropic_kwargs


def _merge_opencode_go_session_header(agent, kwargs: dict) -> dict:
"""Merge OpenCode Go's ``x-opencode-session`` header onto non-OpenAI-wire kwargs.

``OpenCodeGoProfile.build_api_kwargs_extras`` is only consulted by the
chat_completions transport; the anthropic_messages route opencode-go's
MiniMax/Qwen routing uses (and the codex_responses route, for parity)
must merge the session-affinity header themselves. See #81584.
"""
if getattr(agent, "provider", None) != "opencode-go":
return kwargs
try:
from providers import get_provider_profile

profile = get_provider_profile("opencode-go")
if profile is not None:
_, top_level = profile.build_api_kwargs_extras(
model=agent.model, session_id=getattr(agent, "session_id", None)
)
extra_headers = top_level.get("extra_headers")
if extra_headers:
kwargs.setdefault("extra_headers", {}).update(extra_headers)
except Exception as exc: # noqa: BLE001 — never block a turn on a header
logger.debug("OpenCode Go session-header merge failed: %s", exc)
return kwargs


def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
Expand Down Expand Up @@ -1358,7 +1384,8 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
# the profile hook that produces them is only consulted by the
# OpenAI-wire transport. Merge them here so Messages traffic keeps
# product attribution and sticky routing.
return _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs)
anthropic_kwargs = _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs)
return _merge_opencode_go_session_header(agent, anthropic_kwargs)

# AWS Bedrock native Converse API — bypasses the OpenAI client entirely.
# The adapter handles message/tool conversion and boto3 calls directly.
Expand Down Expand Up @@ -1423,7 +1450,7 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
getattr(agent, "log_prefix", ""), exc,
)

return _ct.build_kwargs(
codex_kwargs = _ct.build_kwargs(
model=agent.model,
messages=_msgs_for_codex,
tools=tools_for_api,
Expand All @@ -1442,6 +1469,7 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
getattr(agent, "_codex_reasoning_replay_enabled", True)
),
)
return _merge_opencode_go_session_header(agent, codex_kwargs)

# ── chat_completions (default) ─────────────────────────────────────
_ct = agent._get_transport()
Expand Down
32 changes: 31 additions & 1 deletion plugins/model-providers/opencode-zen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from typing import Any

from agent.portal_tags import get_conversation_context
from agent.transports.codex import _cache_scope_from_session_id
from providers import register_provider
from providers.base import ProviderProfile

Expand Down Expand Up @@ -56,7 +58,35 @@ def get_max_tokens(self, model: str | None) -> int | None:
return self.default_max_tokens

def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
self,
*,
reasoning_config: dict | None = None,
model: str | None = None,
session_id: str | None = None,
**context,
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body, top_level = self._reasoning_api_kwargs_extras(
reasoning_config=reasoning_config, model=model
)

# OpenCode Go's chat_completions relay 400s "Model is unavailable" for
# some backends (e.g. deepseek-v4-flash) unless a stable per-conversation
# session-affinity header is present. Reuse the same session_id
# resolution as OpenRouter's x-grok-conv-id (ambient conversation
# context first, falling back to the explicit session_id, so
# auxiliary calls with no session handle still get one) so the value
# stays stable across turns and rotates when the conversation does,
# without leaking to other providers. See hermes-agent#81584.
session_key = _cache_scope_from_session_id(get_conversation_context() or session_id)
if session_key:
extra_headers = dict(top_level.get("extra_headers") or {})
extra_headers["x-opencode-session"] = session_key
top_level["extra_headers"] = extra_headers

return extra_body, top_level

def _reasoning_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
Expand Down
116 changes: 116 additions & 0 deletions tests/plugins/model_providers/test_opencode_go_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from types import SimpleNamespace

import pytest


Expand Down Expand Up @@ -129,6 +131,120 @@ def test_non_target_models_emit_nothing(self, opencode_go_profile, model):
assert top_level == {}


class TestOpenCodeGoSessionAffinityHeader:
"""OpenCode Go's chat_completions relay 400s some backends (e.g.
deepseek-v4-flash) without a stable per-conversation session-affinity
header (#81584)."""

def test_session_id_sets_affinity_header(self, opencode_go_profile):
_, top_level = opencode_go_profile.build_api_kwargs_extras(
model="deepseek-v4-flash",
session_id="sess-abc123",
)
assert top_level["extra_headers"]["x-opencode-session"] == "sess-abc123"

def test_header_normalizes_cron_timestamp(self, opencode_go_profile):
_, first = opencode_go_profile.build_api_kwargs_extras(
model="deepseek-v4-flash", session_id="cron_job42_20260801_090000",
)
_, second = opencode_go_profile.build_api_kwargs_extras(
model="deepseek-v4-flash", session_id="cron_job42_20260802_090000",
)
assert first["extra_headers"]["x-opencode-session"] == "cron_job42"
assert (
first["extra_headers"]["x-opencode-session"]
== second["extra_headers"]["x-opencode-session"]
)

def test_no_session_id_omits_header(self, opencode_go_profile):
_, top_level = opencode_go_profile.build_api_kwargs_extras(
model="deepseek-v4-flash",
)
assert "extra_headers" not in top_level

def test_header_coexists_with_reasoning_top_level_kwargs(self, opencode_go_profile):
_, top_level = opencode_go_profile.build_api_kwargs_extras(
model="kimi-k2.6",
reasoning_config={"enabled": True, "effort": "high"},
session_id="sess-xyz",
)
assert top_level["reasoning_effort"] == "high"
assert top_level["extra_headers"]["x-opencode-session"] == "sess-xyz"


class TestOpenCodeGoSessionHeaderOtherTransports:
"""MiniMax/Qwen on OpenCode Go route through anthropic_messages, and (for
parity) codex_responses is covered too — build_api_kwargs_extras is only
consulted by the chat_completions transport, so build_api_kwargs must
merge the header itself on those other routes (#81584)."""

def _build_anthropic_kwargs(self, session_id="sess-abc123"):
from agent.chat_completion_helpers import build_api_kwargs
from agent.transports.anthropic import AnthropicTransport

transport = AnthropicTransport()
agent = SimpleNamespace(
api_mode="anthropic_messages",
provider="opencode-go",
model="minimax-m2.7",
session_id=session_id,
tools=None,
max_tokens=1024,
reasoning_config=None,
request_overrides={},
context_compressor=None,
_ephemeral_max_output_tokens=None,
_is_anthropic_oauth=False,
_anthropic_base_url="https://opencode.ai/zen/go/v1",
_oauth_1m_beta_disabled=False,
_get_transport=lambda: transport,
_prepare_anthropic_messages_for_api=lambda msgs: msgs,
_anthropic_preserve_dots=lambda: False,
)
return build_api_kwargs(agent, [{"role": "user", "content": "hi"}])

def _build_codex_kwargs(self, session_id="sess-abc123"):
from agent.chat_completion_helpers import build_api_kwargs
from agent.transports.codex import ResponsesApiTransport

transport = ResponsesApiTransport()
agent = SimpleNamespace(
api_mode="codex_responses",
provider="opencode-go",
model="glm-5",
session_id=session_id,
tools=None,
base_url="https://opencode.ai/zen/go/v1",
_base_url_hostname="opencode.ai",
_base_url_lower="https://opencode.ai/zen/go/v1",
max_tokens=1024,
reasoning_config=None,
request_overrides={},
_get_transport=lambda: transport,
_prepare_messages_for_non_vision_model=lambda msgs: msgs,
_resolved_api_call_timeout=lambda: 30.0,
_github_models_reasoning_extra_body=lambda: None,
_codex_reasoning_replay_enabled=True,
)
return build_api_kwargs(agent, [{"role": "user", "content": "hi"}])

def test_anthropic_messages_route_gets_the_affinity_header(self):
kwargs = self._build_anthropic_kwargs(session_id="sess-abc123")
assert kwargs["extra_headers"]["x-opencode-session"] == "sess-abc123"

def test_codex_responses_route_gets_the_affinity_header(self):
kwargs = self._build_codex_kwargs(session_id="sess-abc123")
assert kwargs["extra_headers"]["x-opencode-session"] == "sess-abc123"

def test_non_opencode_go_provider_is_untouched_on_anthropic_messages(self):
from agent.chat_completion_helpers import _merge_opencode_go_session_header

kwargs = {"model": "claude-opus-4-8"}
agent = SimpleNamespace(provider="nous", model="claude-opus-4-8", session_id="s")
assert _merge_opencode_go_session_header(agent, kwargs) is kwargs
assert "extra_headers" not in kwargs


class TestOpenCodeGoFullKwargsIntegration:
"""End-to-end transport kwargs include the profile-provided controls."""

Expand Down
Loading