fix(opencode): send x-opencode-session on every Go request

OpenCode Go now rejects chat, messages and responses requests that lack an x-opencode-session header, answering 400 MissingSessionID. Its docs ask clients to send a stable id per conversation so routing and prompt caching work

The header is added on the chat and messages dispatch, the Go responses config, and the standalone /v1/messages route. A session header the client already sent is kept as it is. Otherwise the id comes from litellm_session_id, which the proxy already derives from Claude Code, Codex and opencode session headers, and then from litellm_trace_id. When neither exists a random id is generated, which gets past the rejection but earns no caching. Zen does not require the header, so its requests are unchanged

The opencode tests now also clear the OPENCODE_* env vars. litellm loads .env at import, so on a machine with an OpenCode key five existing tests picked up that key instead of the one they set
This commit is contained in:
Sean Murphy 2026-09-12 19:05:58 -07:00
parent 35c021b2a3
commit 379af61b4f
8 changed files with 163 additions and 3 deletions

View file

@ -22,6 +22,7 @@ from litellm.llms.opencode.common_utils import (
cost_map_max_output_tokens,
resolve_opencode_api_base,
resolve_opencode_api_key,
with_opencode_session_header,
)
from litellm.types.router import GenericLiteLLMParams
@ -182,7 +183,7 @@ class OpenCodeMessagesConfig(AnthropicMessagesConfig):
api_key=key,
api_base=base_url,
)
return resolved_headers, resolved_base_url
return with_opencode_session_header(self.surface, resolved_headers, litellm_params), resolved_base_url
def get_error_class(
self,

View file

@ -1,3 +1,4 @@
import uuid
from collections.abc import Mapping
from functools import lru_cache
from types import MappingProxyType
@ -195,3 +196,24 @@ def resolve_opencode_api_base(surface: str, api_base: str | None = None) -> str
or litellm.opencode_api_base
or litellm.api_base
)
OPENCODE_SESSION_HEADER: Final = "x-opencode-session"
def with_opencode_session_header(
surface: str,
headers: Mapping[str, str],
litellm_params: Mapping[str, object],
) -> dict[str, str]: # mutable-ok: request handlers keep mutating the headers they are given
"""Return *headers* carrying the ``x-opencode-session`` id Go rejects requests without.
A header the client already sent wins. Otherwise the conversation's session id keeps
routing and prompt caching stable across turns, and a random id is the last resort:
it avoids the rejection but earns no caching.
"""
if surface != "go" or any(name.lower() == OPENCODE_SESSION_HEADER for name in headers):
return {**headers} # mutable-ok: request handlers keep mutating the headers they are given
known: Final = litellm_params.get("litellm_session_id") or litellm_params.get("litellm_trace_id")
session_id: Final = known if isinstance(known, str) else str(uuid.uuid4())
return {**headers, OPENCODE_SESSION_HEADER: session_id} # mutable-ok: request handlers keep mutating the headers

View file

@ -14,6 +14,7 @@ from typing import (
import litellm
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.llms.opencode.common_utils import with_opencode_session_header
from litellm.secret_managers.main import get_secret_str
from litellm.types.responses.main import *
from litellm.types.router import GenericLiteLLMParams
@ -68,7 +69,7 @@ class OpenCodeGoResponsesAPIConfig(OpenAIResponsesAPIConfig):
headers["Content-Type"] = "application/json" # rebind-ok: caller expects auth header injected
headers["Authorization"] = f"Bearer {api_key}" # rebind-ok: caller expects auth header injected
return headers
return with_opencode_session_header("go", headers, litellm_params.model_dump())
def get_complete_url(
self,

View file

@ -3503,6 +3503,7 @@ def _complete_opencode(
from litellm.llms.opencode.common_utils import (
resolve_opencode_api_base,
resolve_opencode_api_key,
with_opencode_session_header,
)
surface: Final = "go" if custom_llm_provider == "opencode_go" else "zen"
@ -3513,7 +3514,11 @@ def _complete_opencode(
api_key = resolve_opencode_api_key(surface, api_key) # rebind-ok: resolved from module/env fallbacks
base_headers: Final = headers or litellm.headers or {} # mutable-ok: empty dict fallback for headers
base_headers: Final = with_opencode_session_header(
surface,
headers or litellm.headers or {}, # mutable-ok: empty dict fallback for headers
litellm_params,
)
_headers: Final = (
{**base_headers, "Authorization": f"Bearer {api_key}"} # mutable-ok: dict literal for request headers
if api_key is not None

View file

@ -0,0 +1,15 @@
import pytest
_OPENCODE_ENV_VARS = (
"OPENCODE_API_KEY",
"OPENCODE_GO_API_KEY",
"OPENCODE_ZEN_API_KEY",
"OPENCODE_GO_API_BASE",
"OPENCODE_ZEN_API_BASE",
)
@pytest.fixture(autouse=True)
def _no_ambient_opencode_env(monkeypatch):
for name in _OPENCODE_ENV_VARS:
monkeypatch.delenv(name, raising=False)

View file

@ -6,6 +6,7 @@ mapping, auth header selection, or URL construction are mutated.
"""
import json
import uuid
import respx # noqa: F401 # required for pytest-respx fixture
@ -308,6 +309,70 @@ class TestMockedCompletion:
request = respx_mock.calls[0].request
assert "/zen/go/" in request.url.path
def test_go_chat_sends_session_id_from_litellm_session_id(self, respx_mock, monkeypatch):
"""Go rejects requests without x-opencode-session, so the conversation's session id fills it."""
respx_mock.post("https://opencode.ai/zen/go/v1/chat/completions").mock(
return_value=Response(200, json=_make_response("deepseek-v4-flash", "ok"))
)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_go/deepseek-v4-flash",
messages=[{"role": "user", "content": "hi"}],
api_key="sk-fake",
litellm_session_id="sess-123",
)
assert respx_mock.calls[0].request.headers["x-opencode-session"] == "sess-123"
def test_go_chat_keeps_caller_session_header(self, respx_mock, monkeypatch):
"""A session header the client already sent is forwarded as-is, not replaced or duplicated."""
respx_mock.post("https://opencode.ai/zen/go/v1/chat/completions").mock(
return_value=Response(200, json=_make_response("deepseek-v4-flash", "ok"))
)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_go/deepseek-v4-flash",
messages=[{"role": "user", "content": "hi"}],
api_key="sk-fake",
headers={"X-OpenCode-Session": "from-client"},
litellm_session_id="sess-123",
)
assert respx_mock.calls[0].request.headers["x-opencode-session"] == "from-client"
def test_go_chat_generates_session_id_when_none_is_known(self, respx_mock, monkeypatch):
"""With no session to reuse, a generated id still keeps the request from being rejected."""
respx_mock.post("https://opencode.ai/zen/go/v1/chat/completions").mock(
return_value=Response(200, json=_make_response("deepseek-v4-flash", "ok"))
)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_go/deepseek-v4-flash",
messages=[{"role": "user", "content": "hi"}],
api_key="sk-fake",
)
uuid.UUID(respx_mock.calls[0].request.headers["x-opencode-session"])
def test_zen_chat_sends_no_session_header(self, respx_mock, monkeypatch):
"""Only the Go surface requires the header."""
respx_mock.post("https://opencode.ai/zen/v1/chat/completions").mock(
return_value=Response(200, json=_make_response("deepseek-v4-flash", "ok"))
)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_zen/deepseek-v4-flash",
messages=[{"role": "user", "content": "hi"}],
api_key="sk-fake",
litellm_session_id="sess-123",
)
assert "x-opencode-session" not in respx_mock.calls[0].request.headers
def test_unknown_model_routes_to_chat_arm(self, respx_mock, monkeypatch):
"""Unknown models still route to the chat arm."""
respx_mock.post("https://opencode.ai/zen/v1/chat/completions").mock(

View file

@ -315,6 +315,22 @@ class TestGoMockedCompletion:
assert "/v1/responses" in str(request.url)
assert request.headers["Authorization"] == "Bearer sk-fake"
def test_responses_bridge_sends_session_header(self, respx_mock, monkeypatch):
"""Go rejects /v1/responses without x-opencode-session."""
respx_mock.post(GO_RESPONSE_ENDPOINT).mock(
return_value=Response(200, json=_make_responses_response("gpt-5.6-luna", "ok"))
)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_go/gpt-5.6-luna",
messages=[{"role": "user", "content": "hi"}],
api_key="sk-fake",
litellm_session_id="sess-123",
)
assert respx_mock.calls[0].request.headers["x-opencode-session"] == "sess-123"
def test_bearer_auth_from_module_key(self, respx_mock, monkeypatch):
"""Module-level opencode_go_api_key provides the Bearer token."""
respx_mock.post(GO_RESPONSE_ENDPOINT).mock(

View file

@ -494,6 +494,41 @@ class TestMockedMessagesCompletion:
# Bearer should NOT be present for go messages arm
assert "Authorization" not in request.headers
def test_go_messages_model_sends_session_header(self, respx_mock, monkeypatch):
"""Go rejects /v1/messages without x-opencode-session."""
respx_mock.post("https://opencode.ai/zen/go/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("go messages"))
)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_go/minimax-m3",
api_key="sk-go-123",
litellm_session_id="sess-123",
**self._make_completion_kwargs(),
)
assert respx_mock.calls[0].request.headers["x-opencode-session"] == "sess-123"
def test_go_messages_passthrough_sends_session_header(self, respx_mock, monkeypatch):
"""The standalone /v1/messages route needs the header on Go too."""
respx_mock.post("https://opencode.ai/zen/go/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("go messages"))
)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
asyncio.run(
litellm.anthropic_messages(
model="opencode_go/minimax-m3",
messages=[{"role": "user", "content": "hi"}],
max_tokens=16,
api_key="sk-go-123",
litellm_session_id="sess-123",
)
)
assert respx_mock.calls[0].request.headers["x-opencode-session"] == "sess-123"
def test_global_api_key_not_sent_on_messages_dispatch(self, respx_mock, monkeypatch):
"""Messages arm uses the OpenCode key, not a process-wide litellm.api_key.