mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
parent
35c021b2a3
commit
379af61b4f
8 changed files with 163 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
tests/test_litellm/llms/opencode/conftest.py
Normal file
15
tests/test_litellm/llms/opencode/conftest.py
Normal 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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue