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.