From 379af61b4fcc750ff03eb773ca404a769c98a6dd Mon Sep 17 00:00:00 2001 From: Sean Murphy Date: Sat, 12 Sep 2026 19:05:58 -0700 Subject: [PATCH] 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 --- .../opencode/chat/messages_transformation.py | 3 +- litellm/llms/opencode/common_utils.py | 22 +++++++ .../opencode/go/responses/transformation.py | 3 +- litellm/main.py | 7 +- tests/test_litellm/llms/opencode/conftest.py | 15 +++++ .../llms/opencode/test_opencode_chat.py | 65 +++++++++++++++++++ .../opencode/test_opencode_go_responses.py | 16 +++++ .../llms/opencode/test_opencode_messages.py | 35 ++++++++++ 8 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 tests/test_litellm/llms/opencode/conftest.py diff --git a/litellm/llms/opencode/chat/messages_transformation.py b/litellm/llms/opencode/chat/messages_transformation.py index 996c19cd47d..c1cc56ed906 100644 --- a/litellm/llms/opencode/chat/messages_transformation.py +++ b/litellm/llms/opencode/chat/messages_transformation.py @@ -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, diff --git a/litellm/llms/opencode/common_utils.py b/litellm/llms/opencode/common_utils.py index 510c777d324..185f35eaadc 100644 --- a/litellm/llms/opencode/common_utils.py +++ b/litellm/llms/opencode/common_utils.py @@ -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 diff --git a/litellm/llms/opencode/go/responses/transformation.py b/litellm/llms/opencode/go/responses/transformation.py index dc636ec8d84..bdea49eb965 100644 --- a/litellm/llms/opencode/go/responses/transformation.py +++ b/litellm/llms/opencode/go/responses/transformation.py @@ -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, diff --git a/litellm/main.py b/litellm/main.py index ef111a5b0e8..38179a1c6a9 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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 diff --git a/tests/test_litellm/llms/opencode/conftest.py b/tests/test_litellm/llms/opencode/conftest.py new file mode 100644 index 00000000000..38ec985490e --- /dev/null +++ b/tests/test_litellm/llms/opencode/conftest.py @@ -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) diff --git a/tests/test_litellm/llms/opencode/test_opencode_chat.py b/tests/test_litellm/llms/opencode/test_opencode_chat.py index 219c1bd68b2..9fb3938997d 100644 --- a/tests/test_litellm/llms/opencode/test_opencode_chat.py +++ b/tests/test_litellm/llms/opencode/test_opencode_chat.py @@ -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( diff --git a/tests/test_litellm/llms/opencode/test_opencode_go_responses.py b/tests/test_litellm/llms/opencode/test_opencode_go_responses.py index cc4a5b275b8..8338656fd46 100644 --- a/tests/test_litellm/llms/opencode/test_opencode_go_responses.py +++ b/tests/test_litellm/llms/opencode/test_opencode_go_responses.py @@ -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( diff --git a/tests/test_litellm/llms/opencode/test_opencode_messages.py b/tests/test_litellm/llms/opencode/test_opencode_messages.py index ebaf63b665e..7a242361f47 100644 --- a/tests/test_litellm/llms/opencode/test_opencode_messages.py +++ b/tests/test_litellm/llms/opencode/test_opencode_messages.py @@ -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.