feat(opencode): add opencode_go and opencode_zen first-class providers

Adds OpenCode Zen and OpenCode Go as first-class LiteLLM providers,
each serving three wire formats: Chat Completions, Anthropic Messages,
and OpenAI Responses.

Routing between the three arms is decided in code, not from the runtime
cost map, because a published map predating this provider would send
every Messages-native model down the wrong wire. The model sets carry
the full forward-looking grid of model names so a gateway-side addition
routes correctly without a release; names without a bundled price simply
stay unpriced until a real one is published.

Cost resolution falls back to pricing bundled with the package when the
runtime cost map carries no usable entry -- the Router registers a bare
placeholder for every deployment at startup, so the guard asks for
pricing the cost calculator can actually use, not for the key's
presence.

The cost-map JSON schema gains a `messages` mode so the new entries
validate, and the provider tests set module-level configuration through
monkeypatch rather than writing process-wide globals directly.
This commit is contained in:
Sean Murphy 2026-09-03 23:37:13 -07:00
parent 56045503db
commit 35c021b2a3
34 changed files with 6804 additions and 0 deletions

View file

@ -0,0 +1,103 @@
# 0003 — OpenCode Go / Zen: one provider prefix, three wire formats
Status: accepted
Date: 2026-08-08
## Context
OpenCode offers two billing surfaces (Zen subscription, Go per-token) over a
single gateway. Each surface speaks three different wire formats depending on
the model: OpenAI Chat Completions for GPT-series, Anthropic Messages for
Claude and select Qwen models, and OpenAI Responses for newer responses-mode
models. Users address models with `opencode_go/…` or `opencode_zen/…`.
## Decision
We register **two** LiteLLM provider entries (`opencode_go`, `opencode_zen`)
that share one codebase and dispatch through a single `_complete_opencode()`
entry point in `litellm/main.py`. That function resolves the surface
(`go` / `zen`), picks the base URL, then routes to the correct wire format
handler at request time based on model classification.
The three wire formats live in separate files:
| Wire format | Provider code | Entry in `_complete_opencode` |
|---|---|---|
| OpenAI Chat Completions | `litellm/llms/opencode/chat/transformation.py` | default path |
| Anthropic Messages | `litellm/llms/opencode/chat/messages_transformation.py` | `is_messages_model()` |
| OpenAI Responses | `litellm/llms/opencode/{zen,go}/responses/transformation.py` | cost map `mode: responses` |
Model classification is distributed: messages models use frozensets in
`messages_transformation.py` keyed by `@ai-sdk/anthropic` npm field, while
responses models are marked `mode: responses` in the cost map and routed
through the built-in `responses_api_bridge_check` mechanism.
### Data-placement asymmetry
Responses routing is driven by the **cost map** (`model_prices_and_context_window_backup.json`) because LiteLLM has a built-in bridge (`responses_api_bridge_check`) that reads `mode: responses` entries and dispatches to the provider
config returned by `get_opencode_config()`. Messages routing lives inside
`_complete_opencode()` because no equivalent cost-map bridge exists for the
Anthropic Messages pass-through — the handler is called directly from the
opencode branch of the completion dispatch chain.
## Consequences
**Positive:**
- One provider prefix maps to three wire formats. Users only need to add one
set of credentials in the dashboard or config.
- Model classification lives next to the wire format that cares about it.
Changing a model's arm only touches one file.
- The cost-map bridge for responses means providers can add new responses-mode
models by editing the cost map without touching Python code.
**Negative:**
- The dispatch chain is hard to follow at a glance. A reader seeing
`opencode_zen/gpt-5.6-sol` must check three files (chat transformation,
messages transformation, responses transformation) plus the cost map to
determine which endpoint is hit.
- Messages classification requires regenerating frozensets from `models.dev`
when model classifications change.
- The asymmetry between cost-map-driven responses routing and hard-coded
messages routing makes the code harder to unify. A hypothetical messages
bridge would be a larger refactor.
## Rejected alternatives
### Per-endpoint prefixes
Use distinct provider slugs such as `opencode_chat`, `opencode_messages`,
`opencode_responses`. This would make the wire format explicit at call time
but fragments the provider across three prefixes, doubles the dashboard entries
on each surface, and forces users to maintain three credential sets.
### Client-side model allowlist
Require the user to declare which wire format each model uses in config
(`model_alias`, `litellm_params.mode_override`, etc.). This shifts
classification burden to the caller, defeats the value of LiteLLM's model
normalisation layer, and increases onboarding friction.
### Name heuristic ("model contains claude")
Detect the Anthropic Messages arm by inspecting the model name string. This
is brittle: most Go models are Qwen, not Claude, and the Qwen3 family straddles
both chat and messages arms. The `@ai-sdk/anthropic` npm classification from
`models.dev` is the authoritative source and is already embedded in the
frozensets.
### Extend the `openai_like` dynamic config mechanism
The `openai_like` provider (backed by `JSONProviderRegistry`) lets users add
new providers through JSON configuration without code changes. It was used as
a prototype when first exploring OpenCode support on the `litellm_opencode_zen`
branch, but it only supports OpenAI Chat Completions wire format and would
require a full handler rewrite to support Messages and Responses pass-throughs.
The dedicated provider package approach is cleaner and avoids coupling to a
mechanism not designed for multi-format providers.
## Related
- PRD `.scratch/opencode-go-zen-providers/PRD.md` — feature requirements

View file

@ -292,6 +292,12 @@ maritalk_key: Optional[str] = None
ai21_key: Optional[str] = None
ollama_key: Optional[str] = None
openrouter_key: Optional[str] = None
opencode_zen_api_key: Optional[str] = None
opencode_zen_api_base: Optional[str] = None
opencode_go_api_key: Optional[str] = None
opencode_go_api_base: Optional[str] = None
opencode_api_key: Optional[str] = None
opencode_api_base: Optional[str] = None
datarobot_key: Optional[str] = None
predibase_key: Optional[str] = None
huggingface_key: Optional[str] = None
@ -706,6 +712,8 @@ gigachat_models: Set = set()
llamagate_models: Set = set()
reducto_models: Set = set()
bedrock_mantle_models: Set = set()
opencode_zen_models: Set = set() # mutable-ok: populated at import from model cost map
opencode_go_models: Set = set() # mutable-ok: populated at import from model cost map
def is_bedrock_pricing_only_model(key: str) -> bool:
@ -989,6 +997,10 @@ def _populate_provider_model_sets(model_cost_map: Dict) -> None:
reducto_models.add(key)
elif value.get("litellm_provider") == "bedrock_mantle":
bedrock_mantle_models.add(key)
elif value.get("litellm_provider") == "opencode_zen":
opencode_zen_models.add(key)
elif value.get("litellm_provider") == "opencode_go":
opencode_go_models.add(key)
def add_known_models(model_cost_map: Optional[Dict] = None):
@ -1163,6 +1175,8 @@ def _build_models_by_provider() -> dict:
"text-completion-inception": text_completion_inception_models,
"xai": xai_models,
"zai": zai_models,
"opencode_zen": opencode_zen_models,
"opencode_go": opencode_go_models,
"fal_ai": fal_ai_models,
"deepseek": deepseek_models,
"tencent": tencent_models,
@ -1561,6 +1575,13 @@ if TYPE_CHECKING:
from .llms.openrouter.chat.transformation import (
OpenrouterConfig as OpenrouterConfig,
)
from .llms.opencode.chat.transformation import OpenCodeConfig as OpenCodeConfig
from .llms.opencode.chat.anthropic_transformation import (
OpenCodeAnthropicConfig as OpenCodeAnthropicConfig,
)
from .llms.opencode.chat.messages_transformation import (
OpenCodeMessagesConfig as OpenCodeMessagesConfig,
)
from .llms.datarobot.chat.transformation import DataRobotConfig as DataRobotConfig
from .llms.anthropic.chat.transformation import AnthropicConfig as AnthropicConfig
from .llms.bedrock.claude_platform.transformation import (
@ -1837,6 +1858,9 @@ if TYPE_CHECKING:
from .llms.openrouter.responses.transformation import (
OpenRouterResponsesAPIConfig as OpenRouterResponsesAPIConfig,
)
from .llms.opencode.zen.responses.transformation import (
OpenCodeZenResponsesAPIConfig as OpenCodeZenResponsesAPIConfig,
)
from .llms.bedrock_mantle.responses.transformation import (
BedrockMantleResponsesAPIConfig as BedrockMantleResponsesAPIConfig,
)
@ -2410,6 +2434,12 @@ def __getattr__(name: str) -> Any:
return locals()[name]
# Lazy load OpenCode provider config factory (needs model param)
if name in ("get_opencode_config",):
from .llms.opencode.config import get_opencode_config
return get_opencode_config
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View file

@ -131,6 +131,9 @@ LLM_CONFIG_NAMES: Final = (
"OobaboogaConfig",
"MaritalkConfig",
"OpenrouterConfig",
"OpenCodeConfig",
"OpenCodeAnthropicConfig",
"OpenCodeMessagesConfig",
"DataRobotConfig",
"AnthropicConfig",
"BedrockClaudePlatformConfig",
@ -242,6 +245,8 @@ LLM_CONFIG_NAMES: Final = (
"PerplexityResponsesConfig",
"DatabricksResponsesAPIConfig",
"OpenRouterResponsesAPIConfig",
"OpenCodeZenResponsesAPIConfig",
"OpenCodeGoResponsesAPIConfig",
"BedrockMantleResponsesAPIConfig",
"GoogleAIStudioInteractionsConfig",
"VertexAIInteractionsConfig",
@ -624,6 +629,15 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
"OobaboogaConfig": (".llms.oobabooga.chat.transformation", "OobaboogaConfig"),
"MaritalkConfig": (".llms.maritalk", "MaritalkConfig"),
"OpenrouterConfig": (".llms.openrouter.chat.transformation", "OpenrouterConfig"),
"OpenCodeConfig": (".llms.opencode.chat.transformation", "OpenCodeConfig"),
"OpenCodeAnthropicConfig": (
".llms.opencode.chat.anthropic_transformation",
"OpenCodeAnthropicConfig",
),
"OpenCodeMessagesConfig": (
".llms.opencode.chat.messages_transformation",
"OpenCodeMessagesConfig",
),
"DataRobotConfig": (".llms.datarobot.chat.transformation", "DataRobotConfig"),
"AnthropicConfig": (".llms.anthropic.chat.transformation", "AnthropicConfig"),
"BedrockClaudePlatformConfig": (
@ -982,6 +996,14 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
".llms.openrouter.responses.transformation",
"OpenRouterResponsesAPIConfig",
),
"OpenCodeZenResponsesAPIConfig": (
".llms.opencode.zen.responses.transformation",
"OpenCodeZenResponsesAPIConfig",
),
"OpenCodeGoResponsesAPIConfig": (
".llms.opencode.go.responses.transformation",
"OpenCodeGoResponsesAPIConfig",
),
"BedrockMantleResponsesAPIConfig": (
".llms.bedrock_mantle.responses.transformation",
"BedrockMantleResponsesAPIConfig",

View file

@ -716,6 +716,8 @@ LITELLM_CHAT_PROVIDERS: Final = [
"lemonade",
"docker_model_runner",
"amazon_nova",
"opencode_zen",
"opencode_go",
]
# Resolving these providers runs an OAuth device flow (their provider info IS the login), so any

View file

View file

View file

@ -0,0 +1,111 @@
"""
OpenCode Anthropic-wire chat config.
The gateway serves part of its catalogue over the Anthropic Messages wire
format. Those models still have to be reachable through ``litellm.completion()``
with OpenAI-shaped input and a ``ModelResponse`` back, so they route through
``AnthropicConfig`` (the same translation the first-party ``anthropic`` provider
uses) rather than the raw ``/v1/messages`` passthrough handler, which does no
translation in either direction.
Only auth differs from the base: OpenCode authenticates with ``x-api-key`` and
its own key, never ANTHROPIC_API_KEY.
"""
from typing import Final
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.llms.opencode.common_utils import (
cost_map_max_output_tokens,
resolve_opencode_api_key,
)
from litellm.types.llms.openai import AllMessageValues
from litellm.types.router import GenericLiteLLMParams
class OpenCodeAnthropicConfig(AnthropicConfig):
"""Anthropic wire format over the OpenCode gateway, via the chat path."""
def __init__(self, surface: str = "zen") -> None:
super().__init__()
self.surface: Final = surface
@property
def custom_llm_provider(self) -> str | None:
return f"opencode_{self.surface}"
def should_strip_billing_metadata(self) -> bool:
"""OpenCode is a third-party gateway, not the first-party Anthropic API;
x-anthropic-billing-header client attribution must not leak to it."""
return True
def map_openai_params(
self,
non_default_params: dict, # mutable-ok: signature must match AnthropicConfig
optional_params: dict, # mutable-ok: signature must match AnthropicConfig
model: str,
drop_params: bool,
) -> dict: # mutable-ok: signature must match AnthropicConfig
"""Default ``max_tokens`` from the cost map when the caller omitted it.
The Anthropic wire format requires ``max_tokens`` while
``litellm.completion()`` does not, so the base class substitutes a flat
DEFAULT_ANTHROPIC_CHAT_MAX_TOKENS. That lookup uses the bare model name,
which misses the surface-qualified cost-map entry
(``opencode_zen/claude-sonnet-4``), so the model's real
``max_output_tokens`` is resolved here instead.
"""
mapped: Final = super().map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=model,
drop_params=drop_params,
)
caller_set_max_tokens: Final = (
non_default_params.get("max_tokens") is not None
or non_default_params.get("max_completion_tokens") is not None
)
if caller_set_max_tokens:
return mapped
cost_map_max_tokens: Final = cost_map_max_output_tokens(surface=self.surface, model=model)
if cost_map_max_tokens is None:
return mapped
return { # mutable-ok: request params stay mutable for the base handler
**mapped,
"max_tokens": cost_map_max_tokens,
}
def validate_environment(
self,
headers: dict, # mutable-ok: signature must match AnthropicConfig
model: str,
messages: list[AllMessageValues], # mutable-ok: signature must match AnthropicConfig
optional_params: dict, # mutable-ok: signature must match AnthropicConfig
litellm_params: dict | GenericLiteLLMParams, # mutable-ok: signature must match AnthropicConfig
api_key: str | None = None,
api_base: str | None = None,
) -> dict: # mutable-ok: signature must match AnthropicConfig
"""Resolve the OpenCode key, then let the base class build the headers.
The base class resolves a missing key from ANTHROPIC_API_KEY, so the key
is resolved here and a missing one is rejected before that fallback can
send a first-party Anthropic credential to opencode.ai.
"""
resolved_key: Final = resolve_opencode_api_key(self.surface, api_key)
if resolved_key is None:
raise ValueError(
f"OpenCode API key is required. Set OPENCODE_{self.surface.upper()}_API_KEY "
f"or OPENCODE_API_KEY, or pass api_key."
)
params: Final = (
litellm_params if isinstance(litellm_params, dict) else litellm_params.model_dump(exclude_none=True)
)
return super().validate_environment(
headers=headers,
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=params,
api_key=resolved_key,
api_base=api_base,
)

View file

@ -0,0 +1,233 @@
"""
OpenCode Anthropic Messages wire-format config.
Routes models in the surface's messages-model set to ``{base}/v1/messages``
with Anthropic Messages body shape. Both Zen and Go authenticate
``/v1/messages`` with ``x-api-key`` (Anthropic default); verified live that
Bearer returns 401 "Missing API key" on both surfaces.
"""
from collections.abc import Mapping
from types import MappingProxyType
from typing import Any, Final # noqa: TID251 # Anthropic Messages wire format uses Any in param/return shapes
import httpx
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.opencode.common_utils import (
OpenCodeException,
cost_map_max_output_tokens,
resolve_opencode_api_base,
resolve_opencode_api_key,
)
from litellm.types.router import GenericLiteLLMParams
# ---------- surface base URL ( /v1/messages appended downstream ) ----------
ZEN_MESSAGES_BASE: Final = "https://opencode.ai/zen"
# ------------------------------------------------------------------- model sets
# Models the gateway serves via the Anthropic Messages wire format, per surface.
# Source: models.dev ``npm == @ai-sdk/anthropic`` classification.
#
# These live in code rather than being read from the cost map's ``mode`` field
# because ``litellm.model_cost`` is fetched from the published remote map at
# import, and a provider's entries only appear there once released. Routing that
# depended on them would send every model below to the chat arm — the wrong wire
# format — on any install whose cost map predates this provider.
OPENCODE_MESSAGES_MODELS: Final = MappingProxyType(
{
"zen": frozenset(
{
"claude-fable-5",
"claude-haiku-4-5",
"claude-opus-4-5",
"claude-opus-4-6",
"claude-opus-4-7",
"claude-opus-4-8",
"claude-opus-5",
"claude-sonnet-4",
"claude-sonnet-4-5",
"claude-sonnet-4-6",
"claude-sonnet-5",
"qwen3.5-plus",
"qwen3.6-plus",
}
),
# The qwen3.{5..8}-{plus,max} grid is listed in full rather than only the
# entries the cost map carries today, so a gateway-side addition keeps
# routing to the right wire format without waiting on a release.
"go": frozenset(
{
"minimax-m2.5",
"minimax-m2.7",
"minimax-m3",
"qwen3.8-flash",
*(f"qwen3.{n}-{tier}" for n in range(5, 9) for tier in ("plus", "max")),
}
),
}
)
def is_messages_model(surface: str, model: str) -> bool:
"""Return True when *model* belongs on the messages arm of *surface*.
Strips the ``opencode_{surface}/`` prefix when the caller passes the
fully-qualified model name (e.g. ``opencode_zen/claude-sonnet-4``). An
unrecognised model is not on the messages arm, so it falls through to chat
completions.
"""
bare: Final = model.rsplit("/", 1)[-1]
return bare in OPENCODE_MESSAGES_MODELS.get(surface, frozenset())
# ------------------------------------------------------------------ config class
class OpenCodeMessagesConfig(AnthropicMessagesConfig):
"""Anthropic Messages config for the OpenCode gateway.
Parameters
----------
surface :
``"zen"`` (default) or ``"go"``. Determines the base URL.
"""
def __init__(self, surface: str = "zen") -> None:
self.surface: Final = surface
@property
def custom_llm_provider(self) -> str:
return f"opencode_{self.surface}"
def should_strip_billing_metadata(self) -> bool:
"""OpenCode is a third-party gateway, not the first-party Anthropic API;
x-anthropic-billing-header client attribution must not leak to it."""
return True
def _base_url(self) -> str:
"""Surface default, used only when nothing is configured.
``/go`` is part of the Go surface's default host layout, so it belongs
on the default rather than being appended to whatever base the operator
configured (which would rewrite a private gateway to ``{gateway}/go``).
"""
return f"{ZEN_MESSAGES_BASE}/go" if self.surface == "go" else ZEN_MESSAGES_BASE
def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
optional_params: Mapping[str, Any],
litellm_params: Mapping[str, Any],
stream: bool | None = None,
) -> str:
"""Return ``{api_base}/v1/messages``."""
base: Final = (resolve_opencode_api_base(self.surface, api_base) or self._base_url()).rstrip("/")
if base.endswith("/v1/messages"):
return base
if base.endswith("/v1"):
return f"{base}/messages"
return f"{base}/v1/messages"
def validate_anthropic_messages_environment(
self,
headers: dict, # mutable-ok: signature must match AnthropicMessagesConfig
model: str,
messages: list[Any], # mutable-ok: signature must match AnthropicMessagesConfig
optional_params: dict, # mutable-ok: signature must match AnthropicMessagesConfig
litellm_params: dict, # mutable-ok: signature must match AnthropicMessagesConfig
api_key: str | None = None,
api_base: str | None = None,
) -> tuple[dict, str | None]: # mutable-ok: signature must match AnthropicMessagesConfig
"""Resolve key / base URL and let the base class inject the auth header.
Both surfaces authenticate ``/v1/messages`` with ``x-api-key``
(Anthropic default); Bearer returns 401 "Missing API key" on both.
"""
# -- key resolution (same chain as chat arm) ---------------------------
# A missing key is rejected here: the base class resolves one from
# ANTHROPIC_API_KEY, which would send a first-party Anthropic
# credential to opencode.ai.
key: Final = resolve_opencode_api_key(self.surface, api_key)
if key is None:
raise ValueError(
f"OpenCode API key is required. Set OPENCODE_{self.surface.upper()}_API_KEY "
f"or OPENCODE_API_KEY, or pass api_key."
)
base_url: Final = resolve_opencode_api_base(self.surface, api_base) or self._base_url()
# -- auth header per surface -------------------------------------------
# OpenCode does not support OAuth, so we can skip the OAuth check that
# the base class performs. Both surfaces authenticate /v1/messages with
# x-api-key (verified live: Bearer returns 401 "Missing API key" on both
# Zen and Go), so leave headers empty and let the base class inject
# x-api-key.
# NOTE: this intentionally diverges from the chat arm, which uses Bearer.
# -- base class handles defaults, beta headers, content-type ----------
resolved_headers, resolved_base_url = super().validate_anthropic_messages_environment(
headers=headers,
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=key,
api_base=base_url,
)
return resolved_headers, resolved_base_url
def get_error_class(
self,
error_message: str,
status_code: int,
headers: dict | httpx.Headers, # mutable-ok: signature must match AnthropicMessagesConfig
) -> BaseLLMException:
return OpenCodeException(message=error_message, status_code=status_code, headers=headers)
def transform_anthropic_messages_request(
self,
model: str,
messages: list[dict], # mutable-ok: signature must match AnthropicMessagesConfig
anthropic_messages_optional_request_params: dict, # mutable-ok: signature must match AnthropicMessagesConfig
litellm_params: GenericLiteLLMParams,
headers: dict, # mutable-ok: signature must match AnthropicMessagesConfig
) -> dict: # mutable-ok: signature must match AnthropicMessagesConfig
"""Default ``max_tokens`` from the cost map before the base class runs.
The Anthropic ``/v1/messages`` API requires ``max_tokens``, but the
messages arm receives ``optional_params`` straight from the caller
(e.g. a playground wildcard request with no explicit ``max_tokens``).
The base class raises if it is absent, so default it from the model's
cost-map ``max_output_tokens`` here. ``model`` arrives bare (e.g.
``qwen3.7-plus``), so qualify it with the surface prefix for the
``litellm.model_cost`` lookup.
"""
default_max_tokens: Final = (
cost_map_max_output_tokens(surface=self.surface, model=model)
if anthropic_messages_optional_request_params.get("max_tokens") is None
else None
)
params: Final = (
{ # mutable-ok: base config requires a mutable dict
**anthropic_messages_optional_request_params,
"max_tokens": default_max_tokens,
}
if default_max_tokens is not None
else anthropic_messages_optional_request_params
)
return super().transform_anthropic_messages_request(
model=model,
messages=messages,
anthropic_messages_optional_request_params=params,
litellm_params=litellm_params,
headers=headers,
)

View file

@ -0,0 +1,88 @@
"""
OpenCode chat-completions config.
Routes models to {base}/v1/chat/completions with Bearer auth.
Surface ('zen' | 'go') determines the default base URL.
"""
from collections.abc import Mapping
from typing import Any, Final # noqa: TID251 # OpenAI chat completions wire format uses Any in param/return shapes
import httpx
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.openai.chat.gpt_transformation import (
OpenAIGPTConfig,
)
from litellm.llms.opencode.common_utils import (
OpenCodeException,
resolve_opencode_api_base,
resolve_opencode_api_key,
)
from litellm.types.llms.openai import AllMessageValues
ZEN_BASE: Final = "https://opencode.ai/zen/v1"
GO_BASE: Final = "https://opencode.ai/zen/go/v1"
class OpenCodeConfig(OpenAIGPTConfig):
"""OpenAI chat-completions config for the OpenCode gateway."""
def __init__(self, surface: str = "zen") -> None:
self.surface: Final = surface
@property
def custom_llm_provider(self) -> str:
return f"opencode_{self.surface}"
def _base_url(self) -> str:
return GO_BASE if self.surface == "go" else ZEN_BASE
def validate_environment(
self,
headers: dict, # mutable-ok: signature must match OpenAIGPTConfig
model: str,
messages: list[AllMessageValues], # mutable-ok: signature must match OpenAIGPTConfig
optional_params: dict, # mutable-ok: signature must match OpenAIGPTConfig
litellm_params: dict, # mutable-ok: signature must match OpenAIGPTConfig
api_key: str | None = None,
api_base: str | None = None,
) -> dict: # mutable-ok: signature must match OpenAIGPTConfig
"""
Resolve api_key and api_base, inject Bearer auth into headers.
Key resolution is shared with the other arms; see
:func:`resolve_opencode_api_key`.
"""
key: Final = resolve_opencode_api_key(self.surface, api_key)
auth_header: Final = headers.get("Authorization")
content_type: Final = headers.get("Content-Type") or headers.get("content-type")
if key is not None and auth_header is None:
headers["Authorization"] = f"Bearer {key}" # rebind-ok: caller expects auth header injected
if content_type is None:
headers["Content-Type"] = "application/json" # rebind-ok: caller expects content-type set
return headers
def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
optional_params: Mapping[str, Any],
litellm_params: Mapping[str, Any],
stream: bool | None = None,
) -> str:
"""Return {api_base}/v1/chat/completions."""
base: Final = resolve_opencode_api_base(self.surface, api_base) or self._base_url()
return f"{base.rstrip('/')}/chat/completions"
def get_error_class(
self,
error_message: str,
status_code: int,
headers: dict | httpx.Headers, # mutable-ok: signature must match OpenAIGPTConfig
) -> BaseLLMException:
return OpenCodeException(message=error_message, status_code=status_code, headers=headers)

View file

@ -0,0 +1,197 @@
from collections.abc import Mapping
from functools import lru_cache
from types import MappingProxyType
from typing import Final
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.secret_managers.main import get_secret_str
class OpenCodeException(BaseLLMException):
"""Exception for OpenCode API errors."""
# Models the gateway serves over the OpenAI Responses wire format, per surface.
#
# The generic responses bridge decides takeover from the cost map's ``mode``
# field, but ``litellm.model_cost`` is fetched from the published remote map at
# import and a provider's entries only land there once released. Relying on it
# alone sends every model below to chat completions — the wrong endpoint — on
# any install whose cost map predates this provider. This table is the routing
# source; the cost map remains the source of pricing.
OPENCODE_RESPONSES_MODELS: Final = MappingProxyType(
{
"zen": frozenset(
{
"gemini-3-flash",
"gemini-3.1-pro",
"gemini-3.5-flash",
"gemini-3.5-flash-lite",
"gemini-3.6-flash",
"gpt-5",
"gpt-5-codex",
"gpt-5-nano",
"gpt-5.1",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.4-pro",
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.6-luna",
"gpt-5.6-sol",
"gpt-5.6-terra",
"grok-build-0.1",
}
),
"go": frozenset({"gpt-5.6-luna"}),
}
)
def opencode_surface(custom_llm_provider: str) -> str | None:
"""Return the OpenCode surface for *custom_llm_provider*, or None."""
if custom_llm_provider == "opencode_go":
return "go"
if custom_llm_provider == "opencode_zen":
return "zen"
return None
def is_responses_model(surface: str, model: str) -> bool:
"""Return True when *model* is served over the Responses wire format."""
bare: Final = model.rsplit("/", 1)[-1]
return bare in OPENCODE_RESPONSES_MODELS.get(surface, frozenset())
@lru_cache(maxsize=1)
def _bundled_opencode_pricing() -> Mapping[str, Mapping[str, object]]:
"""OpenCode entries from the cost map bundled with the package, parsed once.
Only the provider's own entries are kept, so the rest of the backup is not
held in memory.
"""
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
try:
backup: Final[Mapping[str, Mapping[str, object]]] = GetModelCostMap.load_local_model_cost_map()
except Exception: # noqa: BLE001 # an unreadable bundled map means "no pricing to add", never a failed call
return MappingProxyType({})
return MappingProxyType({k: v for k, v in backup.items() if k.startswith("opencode_")})
def _is_priced(value: object) -> bool:
"""True when *value* is a rate the cost calculator would bill against."""
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
def _carries_pricing(entry: object) -> bool:
"""True when *entry* holds pricing ``cost_per_token`` can actually use.
Mirrors that function's own test rather than asking whether the key exists.
``Router`` registers a bare placeholder for every deployment when it starts,
so a model in use through the proxy is already present in ``model_cost`` as
an empty entry before any request runs; a presence check would read that as
priced and skip the fallback entirely.
"""
if not isinstance(entry, dict):
return False
return (
_is_priced(entry.get("input_cost_per_token"))
or _is_priced(entry.get("output_cost_per_token"))
or entry.get("tiered_pricing") is not None
)
def ensure_opencode_pricing(custom_llm_provider: str, model: str) -> None:
"""Register bundled pricing for *model* when the runtime cost map lacks it.
``litellm.model_cost`` is fetched from the published remote map at import,
and a provider's entries only land there once released. Until then a call
resolves no pricing, ``response_cost`` is None, and a proxy recording spend
reads that as zero. The pricing shipped with the package covers the gap.
Registered with ``persist_across_reloads=False`` deliberately: a cost map
that later carries the model must win over the bundled copy, which can go
stale. If a refresh still lacks the entry, the next call registers it again.
"""
import litellm
key: Final = f"{custom_llm_provider}/{model.rsplit('/', 1)[-1]}"
if _carries_pricing(litellm.model_cost.get(key)):
return
entry: Final = _bundled_opencode_pricing().get(key)
if entry is not None:
litellm.register_model(
{key: entry}, # mutable-ok: register_model's contract takes a mutable dict
persist_across_reloads=False,
)
def resolve_opencode_api_key(surface: str, api_key: str | None = None) -> str | None:
"""Resolve the OpenCode key for *surface*, most specific source first.
Explicit argument, then the surface-specific module attribute and env var,
then the shared OpenCode module attribute and env var, and finally the
generic ``litellm.api_key``. Deliberately never falls back to
ANTHROPIC_API_KEY: the messages arm speaks the Anthropic wire format but
terminates at opencode.ai, so an Anthropic-shaped fallback would hand the
caller's first-party Anthropic key to a third party.
"""
import litellm
surface_upper: Final = surface.upper()
return (
api_key
or getattr(litellm, f"opencode_{surface}_api_key", None)
or get_secret_str(f"OPENCODE_{surface_upper}_API_KEY")
or litellm.opencode_api_key
or get_secret_str("OPENCODE_API_KEY")
or litellm.api_key
)
def cost_map_max_output_tokens(surface: str, model: str) -> int | None:
"""Return the cost-map ``max_output_tokens`` for an OpenCode model.
``model`` arrives bare (e.g. ``qwen3.7-plus``); qualify it with the surface
prefix for the ``litellm.model_cost`` lookup. Returns ``None`` when the
model has no cost-map entry.
"""
import litellm
qualified: Final = f"opencode_{surface}/{model}"
entry: Final = litellm.model_cost.get(qualified)
if entry is None:
return None
if "max_output_tokens" in entry:
return entry["max_output_tokens"]
if "max_tokens" in entry:
return entry["max_tokens"]
return None
def resolve_opencode_api_base(surface: str, api_base: str | None = None) -> str | None:
"""Resolve the configured OpenCode base URL for *surface*, or None.
Mirrors :func:`resolve_opencode_api_key`'s precedence so every arm honours
the same variables. Returns None when nothing is configured, leaving the
caller to apply its own surface default.
"""
import litellm
surface_upper: Final = surface.upper()
return (
api_base
or getattr(litellm, f"opencode_{surface}_api_base", None)
or get_secret_str(f"OPENCODE_{surface_upper}_API_BASE")
or litellm.opencode_api_base
or litellm.api_base
)

View file

@ -0,0 +1,26 @@
"""
Shared provider config for opencode surfaces.
Decides which wire format a model speaks and returns the matching config.
Both arms return a ``BaseConfig``, so callers that only know the provider (the
generic ``completion()`` preprocessing, ``get_supported_openai_params``, the
streaming wrappers) get a usable config either way.
"""
from litellm.llms.base_llm.chat.transformation import BaseConfig
from litellm.llms.opencode.chat.messages_transformation import is_messages_model
from .chat.anthropic_transformation import OpenCodeAnthropicConfig
from .chat.transformation import OpenCodeConfig
def get_opencode_config(surface: str, model: str) -> BaseConfig:
"""Return the right config for *surface* / *model*.
Models the gateway serves over the Anthropic Messages wire format get the
Anthropic-wire chat config; everything else falls through to
chat-completions.
"""
if is_messages_model(surface, model):
return OpenCodeAnthropicConfig(surface=surface)
return OpenCodeConfig(surface=surface)

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,96 @@
"""
OpenCode Go Responses API Configuration.
Routes models in the Go responses-model set to ``{base}/v1/responses``
with OpenAI-compatible request/response shape. Uses ``Bearer`` auth.
"""
from collections.abc import Mapping
from typing import (
TYPE_CHECKING,
Any, # noqa: TID251 # OpenAI Responses API wire format uses Any in param/return shapes
Final,
)
import litellm
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.responses.main import *
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
GO_MESSAGES_BASE: Final = "https://opencode.ai/zen/go"
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
LiteLLMLoggingObj: Final = _LiteLLMLoggingObj
else:
LiteLLMLoggingObj: Final = Any # mutable-ok: fallback placeholder for runtime
class OpenCodeGoResponsesAPIConfig(OpenAIResponsesAPIConfig):
"""
Configuration for OpenCode Go's Responses API.
Inherits from OpenAIResponsesAPIConfig since Go's Responses API
is compatible with OpenAI's Responses API specification.
Key differences from direct OpenAI:
- Uses ``{base}/v1/responses`` as the API base (Go gateway)
- Uses ``OPENCODE_GO_API_KEY`` for authentication
- Returns ``Bearer`` auth header
"""
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.OPENCODE_GO
def validate_environment(
self,
headers: dict, # mutable-ok: signature must match OpenAIResponsesAPIConfig
model: str,
litellm_params: GenericLiteLLMParams | None,
) -> dict: # mutable-ok: signature must match OpenAIResponsesAPIConfig
litellm_params = litellm_params or GenericLiteLLMParams() # rebind-ok: default to empty params
api_key: Final = (
litellm_params.api_key
or litellm.opencode_go_api_key
or get_secret_str("OPENCODE_GO_API_KEY")
or get_secret_str("OPENCODE_API_KEY")
or litellm.api_key
)
if not api_key:
raise ValueError(
"OpenCode Go API key is required. Set OPENCODE_GO_API_KEY environment variable or pass api_key parameter."
)
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
def get_complete_url(
self,
api_base: str | None,
litellm_params: Mapping[str, Any],
) -> str:
base: Final = (
api_base
or litellm.opencode_go_api_base
or get_secret_str("OPENCODE_GO_API_BASE")
or litellm.api_base
or GO_MESSAGES_BASE
).rstrip("/")
if base.endswith("/v1/responses"):
return base
if base.endswith("/v1"):
return f"{base}/responses"
if base.endswith("/responses"):
return base
return f"{base}/v1/responses"
def supports_native_websocket(self) -> bool:
"""OpenCode Go does not support native WebSocket for Responses API."""
return False

View file

View file

@ -0,0 +1,96 @@
"""
OpenCode Zen Responses API Configuration.
Routes models in the Zen responses-model set to ``{base}/v1/responses``
with OpenAI-compatible request/response shape. Uses ``Bearer`` auth.
"""
from collections.abc import Mapping
from typing import (
TYPE_CHECKING,
Any, # noqa: TID251 # OpenAI Responses API wire format uses Any in param/return shapes
Final,
)
import litellm
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.responses.main import *
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
ZEN_MESSAGES_BASE: Final = "https://opencode.ai/zen"
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
LiteLLMLoggingObj: Final = _LiteLLMLoggingObj
else:
LiteLLMLoggingObj: Final = Any # mutable-ok: fallback placeholder for runtime
class OpenCodeZenResponsesAPIConfig(OpenAIResponsesAPIConfig):
"""
Configuration for OpenCode Zen's Responses API.
Inherits from OpenAIResponsesAPIConfig since Zen's Responses API
is compatible with OpenAI's Responses API specification.
Key differences from direct OpenAI:
- Uses ``{base}/v1/responses`` as the API base (Zen gateway)
- Uses ``OPENCODE_ZEN_API_KEY`` for authentication
- Returns ``Bearer`` auth header (not ``x-api-key``)
"""
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.OPENCODE_ZEN
def validate_environment(
self,
headers: dict, # mutable-ok: signature must match OpenAIResponsesAPIConfig
model: str,
litellm_params: GenericLiteLLMParams | None,
) -> dict: # mutable-ok: signature must match OpenAIResponsesAPIConfig
litellm_params = litellm_params or GenericLiteLLMParams() # rebind-ok: default to empty params
api_key: Final = (
litellm_params.api_key
or litellm.opencode_zen_api_key
or get_secret_str("OPENCODE_ZEN_API_KEY")
or get_secret_str("OPENCODE_API_KEY")
or litellm.api_key
)
if not api_key:
raise ValueError(
"OpenCode Zen API key is required. Set OPENCODE_ZEN_API_KEY environment variable or pass api_key parameter."
)
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
def get_complete_url(
self,
api_base: str | None,
litellm_params: Mapping[str, Any],
) -> str:
base: Final = (
api_base
or litellm.opencode_zen_api_base
or get_secret_str("OPENCODE_ZEN_API_BASE")
or litellm.api_base
or ZEN_MESSAGES_BASE
).rstrip("/")
if base.endswith("/v1/responses"):
return base
if base.endswith("/v1"):
return f"{base}/responses"
if base.endswith("/responses"):
return base
return f"{base}/v1/responses"
def supports_native_websocket(self) -> bool:
"""OpenCode Zen does not support native WebSocket for Responses API."""
return False

View file

@ -1084,6 +1084,22 @@ def responses_api_bridge_check(
mode = "responses"
model_info["mode"] = mode
# OpenCode's entries only reach the published cost map once released, so an
# install whose map predates this provider resolves neither pricing nor the
# wire format: spend records as zero and every Responses model falls through
# to chat completions. Both are answered from what ships with the package.
from litellm.llms.opencode.common_utils import (
ensure_opencode_pricing,
is_responses_model,
opencode_surface,
)
opencode_surface_name: Final = opencode_surface(custom_llm_provider)
if opencode_surface_name is not None:
ensure_opencode_pricing(custom_llm_provider, model)
if model_info.get("mode") != "responses" and is_responses_model(opencode_surface_name, model):
model_info["mode"] = "responses"
# OpenAI/Azure GPT-5 chat-completions that need Responses-only fields (e.g.
# ``reasoningSummary`` in ``extra_body``) must be bridged; Chat Completions rejects
# those keys.
@ -3465,6 +3481,115 @@ def _complete_openrouter(ctx: _CompletionDispatchContext) -> _CompletionDispatch
return response
def _complete_opencode(
ctx: _CompletionDispatchContext,
) -> _CompletionDispatchResult:
acompletion: Final = ctx.acompletion
api_base = ctx.api_base # rebind-ok: resolved below from module/env fallbacks
api_key = ctx.api_key # rebind-ok: resolved below from module/env fallbacks
client: Final = ctx.client
custom_llm_provider: Final = ctx.custom_llm_provider
headers: Final = ctx.headers
litellm_params: Final = ctx.litellm_params
logging: Final = ctx.logging
messages: Final = ctx.messages
model: Final = ctx.model
model_response: Final = ctx.model_response
optional_params: Final = ctx.optional_params
shared_session: Final = ctx.shared_session
stream: Final = ctx.stream
timeout: Final = ctx.timeout
from litellm.llms.opencode.common_utils import (
resolve_opencode_api_base,
resolve_opencode_api_key,
)
surface: Final = "go" if custom_llm_provider == "opencode_go" else "zen"
_base_url: Final = "https://opencode.ai/zen/go/v1" if surface == "go" else "https://opencode.ai/zen/v1"
api_base = (
resolve_opencode_api_base(surface, api_base) or _base_url
) # rebind-ok: resolved from module/env fallbacks
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
_headers: Final = (
{**base_headers, "Authorization": f"Bearer {api_key}"} # mutable-ok: dict literal for request headers
if api_key is not None
else base_headers
)
# Part of the catalogue is served over the Anthropic Messages wire format.
# Those models go through the Anthropic chat handler, which translates
# OpenAI input to Anthropic and the Anthropic reply back to a ModelResponse
# (and handles streaming and acompletion natively) — the same path the
# first-party anthropic provider takes.
from litellm.llms.opencode.chat.messages_transformation import is_messages_model
if is_messages_model(surface, model):
# AnthropicChatCompletion builds its headers from AnthropicConfig
# directly, and that resolves a missing key from ANTHROPIC_API_KEY, so
# a missing OpenCode key has to be rejected here rather than sending a
# first-party Anthropic credential to opencode.ai.
if api_key is None:
raise ValueError(
f"OpenCode API key is required. Set OPENCODE_{surface.upper()}_API_KEY "
f"or OPENCODE_API_KEY, or pass api_key."
)
messages_base: Final = api_base.rstrip("/")
messages_url: Final = (
messages_base
if messages_base.endswith("/v1/messages")
else f"{messages_base}/messages"
if messages_base.endswith("/v1")
else f"{messages_base}/v1/messages"
)
return anthropic_chat_completions.completion(
model=model,
messages=messages,
api_base=messages_url,
acompletion=acompletion,
custom_prompt_dict=litellm.custom_prompt_dict,
model_response=model_response,
print_verbose=print_verbose,
optional_params=optional_params,
litellm_params=litellm_params,
logger_fn=None,
encoding=_get_encoding(),
api_key=api_key,
logging_obj=logging,
headers=base_headers, # Bearer is chat-arm only; the config injects x-api-key
timeout=timeout, # pyright: ignore[reportArgumentType] # ctx.timeout is str|None-widened same as every other _complete_* dispatch handler; narrowing is a pre-existing, repo-wide gap in _CompletionDispatchContext, not opencode-specific
client=client,
custom_llm_provider=f"opencode_{surface}",
)
## COMPLETION CALL (chat arm)
response = base_llm_http_handler.completion( # rebind-ok: chat arm result
model=model,
stream=stream,
messages=messages,
acompletion=acompletion,
api_base=api_base,
model_response=model_response,
optional_params=optional_params,
litellm_params=litellm_params,
shared_session=shared_session,
custom_llm_provider=f"opencode_{surface}",
timeout=timeout, # pyright: ignore[reportArgumentType] # ctx.timeout is str|None-widened same as every other _complete_* dispatch handler; narrowing is a pre-existing, repo-wide gap in _CompletionDispatchContext, not opencode-specific
headers=_headers,
encoding=_get_encoding(),
api_key=api_key,
logging_obj=logging,
client=client,
)
## LOGGING
logging.post_call(input=messages, api_key=api_key, original_response=response)
return response
def _complete_vercel_ai_gateway(
ctx: _CompletionDispatchContext,
) -> _CompletionDispatchResult:
@ -5746,6 +5871,8 @@ def completion(
response = _complete_minimax(_dispatch_ctx)
elif custom_llm_provider == "hosted_vllm":
response = _complete_hosted_vllm(_dispatch_ctx)
elif custom_llm_provider in ("opencode_zen", "opencode_go"):
response = _complete_opencode(_dispatch_ctx) # rebind-ok: dispatch chain rebinds response
elif (
# A known OpenAI model name only decides the route when nothing else
# resolved a provider. get_llm_provider() already maps these names to

File diff suppressed because it is too large Load diff

View file

@ -3448,5 +3448,41 @@
}
],
"default_model_placeholder": "cursor/claude-4-sonnet"
},
{
"provider": "OpenCode_Go",
"provider_display_name": "OpenCode Go",
"litellm_provider": "opencode_go",
"credential_fields": [
{
"key": "api_key",
"label": "API Key",
"placeholder": null,
"tooltip": null,
"required": true,
"field_type": "password",
"options": null,
"default_value": null
}
],
"default_model_placeholder": "opencode_go/gpt-5.6-luna"
},
{
"provider": "OpenCode_Zen",
"provider_display_name": "OpenCode Zen",
"litellm_provider": "opencode_zen",
"credential_fields": [
{
"key": "api_key",
"label": "API Key",
"placeholder": null,
"tooltip": null,
"required": true,
"field_type": "password",
"options": null,
"default_value": null
}
],
"default_model_placeholder": "opencode_zen/gpt-5.6-luna"
}
]

View file

@ -1392,6 +1392,20 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
return value.model_dump()
return value
@field_validator("truncation", mode="before")
@classmethod
def validate_truncation(cls, value) -> Literal["auto", "disabled"] | None:
"""Normalize empty-string truncation to None.
Some providers (e.g. the OpenCode gateway) return ``"truncation": ""``
which is not a valid OpenAI literal. Treat it as unset rather than
failing validation (which would silently drop the response output via
the model_construct fallback).
"""
if value == "":
return None
return value
@field_validator("usage", mode="before")
@classmethod
def validate_usage(cls, value):

View file

@ -4052,6 +4052,8 @@ class LlmProviders(str, Enum):
CURSOR = "cursor"
BEDROCK_MANTLE = "bedrock_mantle"
GDC = "gdc"
OPENCODE_ZEN = "opencode_zen"
OPENCODE_GO = "opencode_go"
# Create a set of all provider values for quick lookup

View file

@ -8175,6 +8175,14 @@ class ProviderConfigManager:
LlmProviders.HUGGINGFACE: (lambda: litellm.HuggingFaceChatConfig(), False),
LlmProviders.TOGETHER_AI: (lambda: litellm.TogetherAIChatConfig(), False),
LlmProviders.OPENROUTER: (lambda: litellm.OpenrouterConfig(), False),
LlmProviders.OPENCODE_ZEN: (
lambda model: litellm.get_opencode_config("zen", model),
True,
),
LlmProviders.OPENCODE_GO: (
lambda model: litellm.get_opencode_config("go", model),
True,
),
LlmProviders.VERCEL_AI_GATEWAY: (
lambda: litellm.VercelAIGatewayConfig(),
False,
@ -8594,6 +8602,12 @@ class ProviderConfigManager:
)
return GithubCopilotAnthropicMessagesConfig()
elif provider in (litellm.LlmProviders.OPENCODE_ZEN, litellm.LlmProviders.OPENCODE_GO):
from litellm.llms.opencode.chat.messages_transformation import (
OpenCodeMessagesConfig,
)
return OpenCodeMessagesConfig(surface="go" if provider == litellm.LlmProviders.OPENCODE_GO else "zen")
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
@ -8810,6 +8824,18 @@ class ProviderConfigManager:
return litellm.BedrockMantleResponsesAPIConfig(
use_openai_path=mantle_base_segment(model, litellm.model_cost) == "openai/v1"
)
elif litellm.LlmProviders.OPENCODE_ZEN == provider:
from litellm.llms.opencode.zen.responses.transformation import (
OpenCodeZenResponsesAPIConfig,
)
return OpenCodeZenResponsesAPIConfig()
elif litellm.LlmProviders.OPENCODE_GO == provider:
from litellm.llms.opencode.go.responses.transformation import (
OpenCodeGoResponsesAPIConfig,
)
return OpenCodeGoResponsesAPIConfig()
return None
@staticmethod

File diff suppressed because it is too large Load diff

View file

@ -414,6 +414,7 @@
"guardrail",
"image_edit",
"image_generation",
"messages",
"moderation",
"ocr",
"realtime",

View file

@ -1932,6 +1932,22 @@
"assistants": true
}
},
"opencode": {
"display_name": "OpenCode (`opencode`)",
"url": "https://docs.litellm.ai/docs/providers/opencode",
"endpoints": {
"chat_completions": true,
"messages": true,
"responses": true,
"embeddings": false,
"image_generations": false,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false
}
},
"openrouter": {
"display_name": "OpenRouter (`openrouter`)",
"url": "https://docs.litellm.ai/docs/providers/openrouter",

View file

@ -0,0 +1,527 @@
"""
Tests for OpenCode provider registration (litellm/llms/opencode/).
These tests fail before the feature exists and fail if the dispatch
mapping, auth header selection, or URL construction are mutated.
"""
import json
import respx # noqa: F401 # required for pytest-respx fixture
from httpx import Response
import litellm
import pytest
from litellm.llms.openai.chat.gpt_transformation import (
OpenAIChatCompletionStreamingHandler,
)
from litellm.llms.opencode.chat.transformation import OpenCodeConfig
from litellm.llms.opencode.common_utils import OpenCodeException
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_response(model: str, content: str, **usage_kwargs) -> dict:
"""Build a standard chat-completion response body."""
prompt = usage_kwargs.get("prompt_tokens", 1)
completion = usage_kwargs.get("completion_tokens", 1)
return {
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1700000000,
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": prompt + completion,
},
}
# ---------------------------------------------------------------------------
# Provider config
# ---------------------------------------------------------------------------
class TestOpenCodeConfig:
"""Tests for the OpenCodeConfig class itself."""
def test_zen_surface_custom_llm_provider(self):
cfg = OpenCodeConfig(surface="zen")
assert cfg.custom_llm_provider == "opencode_zen"
def test_go_surface_custom_llm_provider(self):
cfg = OpenCodeConfig(surface="go")
assert cfg.custom_llm_provider == "opencode_go"
def test_zen_base_url(self):
cfg = OpenCodeConfig(surface="zen")
assert cfg._base_url() == "https://opencode.ai/zen/v1"
def test_go_base_url(self):
cfg = OpenCodeConfig(surface="go")
assert cfg._base_url() == "https://opencode.ai/zen/go/v1"
def test_get_complete_url_zen_default(self):
cfg = OpenCodeConfig(surface="zen")
url = cfg.get_complete_url(None, None, "gpt-5.1", {}, {})
assert url == "https://opencode.ai/zen/v1/chat/completions"
def test_get_complete_url_go_default(self):
cfg = OpenCodeConfig(surface="go")
url = cfg.get_complete_url(None, None, "gpt-5.1", {}, {})
assert url == "https://opencode.ai/zen/go/v1/chat/completions"
def test_get_complete_url_api_base_override(self):
cfg = OpenCodeConfig(surface="zen")
url = cfg.get_complete_url("http://localhost:4000", None, "gpt-5.1", {}, {})
assert url == "http://localhost:4000/chat/completions"
def test_get_complete_url_api_base_trailing_slash(self):
cfg = OpenCodeConfig(surface="zen")
url = cfg.get_complete_url("http://localhost:4000/", None, "gpt-5.1", {}, {})
assert url == "http://localhost:4000/chat/completions"
def test_error_class(self):
cfg = OpenCodeConfig(surface="zen")
err = cfg.get_error_class("bad request", 400, {})
assert isinstance(err, OpenCodeException)
assert err.status_code == 400
assert err.message == "bad request"
# ---------------------------------------------------------------------------
# validate_environment — Bearer header injection
# ---------------------------------------------------------------------------
class TestValidateEnvironment:
"""Tests for header injection in validate_environment."""
def setup_method(self):
self.cfg = OpenCodeConfig(surface="zen")
def test_bearer_header_with_explicit_key(self):
headers: dict = {}
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.1",
messages=[],
optional_params={},
litellm_params={},
api_key="sk-test-123",
)
assert result["Authorization"] == "Bearer sk-test-123"
def test_content_type_default(self):
headers: dict = {}
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.1",
messages=[],
optional_params={},
litellm_params={},
api_key="sk-test",
)
assert "Content-Type" in result
assert result["Content-Type"] == "application/json"
def test_no_key_no_auth_header(self, monkeypatch):
# Isolate from any OPENCODE_*_API_KEY present in the shell env so the
# shared fallback cannot inject an Authorization header.
monkeypatch.delenv("OPENCODE_API_KEY", raising=False)
monkeypatch.delenv("OPENCODE_ZEN_API_KEY", raising=False)
monkeypatch.delenv("OPENCODE_GO_API_KEY", raising=False)
headers: dict = {}
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.1",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
assert "Authorization" not in result
def test_env_var_key_resolution(self, monkeypatch):
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "sk-env-123")
headers: dict = {}
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.1",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
assert result["Authorization"] == "Bearer sk-env-123"
monkeypatch.delenv("OPENCODE_ZEN_API_KEY")
def test_shared_fallback_key(self, monkeypatch):
monkeypatch.setenv("OPENCODE_API_KEY", "sk-shared-456")
headers: dict = {}
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.1",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
assert result["Authorization"] == "Bearer sk-shared-456"
monkeypatch.delenv("OPENCODE_API_KEY")
def test_module_var_takes_precedence_over_env(self, monkeypatch):
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "sk-env")
monkeypatch.setattr(litellm, "opencode_zen_api_key", "sk-module")
headers: dict = {}
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.1",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
assert result["Authorization"] == "Bearer sk-module"
def test_explicit_key_takes_precedence_over_module_var(self, monkeypatch):
monkeypatch.setattr(litellm, "opencode_zen_api_key", "sk-module")
headers: dict = {}
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.1",
messages=[],
optional_params={},
litellm_params={},
api_key="sk-explicit",
)
assert result["Authorization"] == "Bearer sk-explicit"
def test_global_api_key_does_not_override_opencode_key(self, monkeypatch):
"""A process-wide litellm.api_key must not win over an OpenCode key.
Regression guard for the cross-provider credential-disclosure claim: a
mixed-provider process sets litellm.api_key for some other provider, and
that unrelated credential must never be sent to opencode.ai when an
OpenCode-specific key is configured.
"""
monkeypatch.setattr(litellm, "api_key", "sk-global-other-provider")
monkeypatch.setattr(litellm, "opencode_zen_api_key", "sk-opencode")
headers: dict = {}
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.1",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
assert result["Authorization"] == "Bearer sk-opencode"
def test_go_surface_uses_go_env_var(self, monkeypatch):
monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-go-789")
go_cfg = OpenCodeConfig(surface="go")
headers: dict = {}
result = go_cfg.validate_environment(
headers=headers,
model="gpt-5.1",
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
assert result["Authorization"] == "Bearer sk-go-789"
monkeypatch.delenv("OPENCODE_GO_API_KEY")
# ---------------------------------------------------------------------------
# Integration — mocked completion call
# ---------------------------------------------------------------------------
class TestMockedCompletion:
"""End-to-end tests using mocked HTTP transport."""
@pytest.fixture(autouse=True)
def _cleanup(self, monkeypatch):
"""Ensure module-level keys and flags are clean after each test."""
monkeypatch.setattr(litellm, "opencode_zen_api_key", None)
monkeypatch.setattr(litellm, "opencode_go_api_key", None)
monkeypatch.setattr(litellm, "opencode_api_key", None)
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", False)
litellm.in_memory_llm_clients_cache.flush_cache()
def test_dispatch_sends_to_chat_completions_url(self, respx_mock, monkeypatch):
"""Model opencode_zen/<model> reaches /chat/completions endpoint."""
respx_mock.post("https://opencode.ai/zen/v1/chat/completions").mock(
return_value=Response(200, json=_make_response("grok-4.5", "Hello"))
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/grok-4.5",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
)
assert result is not None
assert result.model == "grok-4.5"
assert result.choices[0].message.content == "Hello"
assert len(respx_mock.calls) > 0
request = respx_mock.calls[0].request
assert request.headers["Authorization"] == "Bearer sk-fake"
body = json.loads(request.read())
assert body["messages"] == [{"role": "user", "content": "hi"}]
def test_go_dispatch_custom_llm_provider(self, respx_mock, monkeypatch):
"""opencode_go models use the opencode_go custom_llm_provider."""
respx_mock.post("https://opencode.ai/zen/go/v1/chat/completions").mock(
return_value=Response(200, json=_make_response("grok-4.5", "Go works"))
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_go/grok-4.5",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_go",
)
assert result is not None
assert result.choices[0].message.content == "Go works"
assert len(respx_mock.calls) > 0
request = respx_mock.calls[0].request
assert "/zen/go/" in request.url.path
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(
return_value=Response(200, json=_make_response("brand-new-model", "I am new"))
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/brand-new-model",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
)
assert result is not None
assert result.model == "brand-new-model"
assert len(respx_mock.calls) > 0
def test_upstream_error_surfaces_as_connection_error(self, respx_mock, monkeypatch):
"""A non-2xx upstream response surfaces as litellm's public
APIConnectionError, not a TypeError from mis-constructing the error
class. Regression: get_error_class returned the class instead of an
instance, so raising it crashed with
``BaseLLMException.__init__() missing 2 required positional arguments``."""
respx_mock.post("https://opencode.ai/zen/v1/chat/completions").mock(
return_value=Response(503, json={"error": {"message": "Service Unavailable"}})
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
with pytest.raises(
(litellm.exceptions.APIConnectionError, litellm.exceptions.ServiceUnavailableError)
) as excinfo:
litellm.completion(
model="opencode_zen/grok-4.5",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
)
assert "503" in str(excinfo.value) or "Service Unavailable" in str(excinfo.value)
def test_api_base_override(self, respx_mock, monkeypatch):
"""Explicit api_base overrides the default gateway URL."""
respx_mock.post("http://localhost:4000/chat/completions").mock(
return_value=Response(200, json=_make_response("grok-4.5", "local"))
)
monkeypatch.setattr(litellm, "api_base", "http://localhost:4000")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/grok-4.5",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
)
assert result is not None
assert result.choices[0].message.content == "local"
assert len(respx_mock.calls) > 0
def test_bearer_auth_from_surface_key(self, respx_mock, monkeypatch):
"""Surface-specific env var provides the Bearer token."""
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "sk-surface-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
respx_mock.post("https://opencode.ai/zen/v1/chat/completions").mock(
return_value=Response(200, json=_make_response("grok-4.5", "ok"))
)
result = litellm.completion(
model="opencode_zen/grok-4.5",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
)
assert result is not None
auth = respx_mock.calls[0].request.headers["Authorization"]
assert auth == "Bearer sk-surface-key"
monkeypatch.delenv("OPENCODE_ZEN_API_KEY")
def test_global_api_key_not_sent_on_dispatch(self, respx_mock, monkeypatch):
"""main.py builds the Bearer header from the OpenCode key, not the global.
Regression guard for the credential-disclosure claim: this header is
constructed in the completion dispatcher rather than in
validate_environment, so the precedence ordering is asserted end-to-end
with a process-wide litellm.api_key also configured.
"""
respx_mock.post("https://opencode.ai/zen/v1/chat/completions").mock(
return_value=Response(200, json=_make_response("grok-4.5", "ok"))
)
monkeypatch.setattr(litellm, "api_key", "sk-global-other-provider")
monkeypatch.setattr(litellm, "opencode_zen_api_key", "sk-opencode")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/grok-4.5",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
)
assert result is not None
auth = respx_mock.calls[0].request.headers["Authorization"]
assert auth == "Bearer sk-opencode"
assert "sk-global-other-provider" not in auth
# ---------------------------------------------------------------------------
# Cost map
# ---------------------------------------------------------------------------
class TestCostMap:
"""Cost-map entries for OpenCode models."""
@pytest.fixture(autouse=True)
def _load_cost_map(self, monkeypatch):
"""Ensure litellm.model_cost is populated before each test."""
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
def test_cost_map_entry_exists(self):
"""A cost-map entry exists for opencode_zen/grok-4.5."""
assert "opencode_zen/grok-4.5" in litellm.model_cost
entry = litellm.model_cost["opencode_zen/grok-4.5"]
assert entry["litellm_provider"] == "opencode_zen"
assert entry["mode"] == "chat"
assert entry["max_input_tokens"] == 500000
assert entry["max_output_tokens"] == 500000
def test_cost_map_entry_has_pricing(self):
"""Cost-map entry carries the correct pricing."""
entry = litellm.model_cost["opencode_zen/grok-4.5"]
assert entry["input_cost_per_token"] == 2e-06
assert entry["output_cost_per_token"] == 6e-06
def test_cost_map_entry_for_free_model(self):
"""Free models have zero pricing but still have a cost-map entry."""
entry = litellm.model_cost["opencode_zen/big-pickle"]
assert entry["input_cost_per_token"] == 0.0
assert entry["output_cost_per_token"] == 0.0
# ---------------------------------------------------------------------------
# Streaming
# ---------------------------------------------------------------------------
class TestStreaming:
"""Tests for streaming response handler."""
def test_streaming_handler_available(self):
"""OpenCodeConfig returns a streaming handler."""
cfg = OpenCodeConfig(surface="zen")
handler = cfg.get_model_response_iterator(
streaming_response=None,
sync_stream=False,
)
assert handler is not None
# ---------------------------------------------------------------------------
# Wildcard model-list registration
# ---------------------------------------------------------------------------
class TestWildcardModelRegistration:
"""OpenCode providers are registered in models_by_provider so wildcard
routes (e.g. ``opencode_go/*``) expand to the cost-map models in the
playground and /model_group/info."""
@pytest.fixture(autouse=True)
def _load_cost_map(self, monkeypatch):
"""Ensure litellm.model_cost is populated before each test.
The module-level ``opencode_go_models`` / ``opencode_zen_models`` sets
are filled at import time from the remote cost map, which predates the
un-merged opencode feature. Re-run ``add_known_models`` against the
local backup so the wildcard expansion sees the reconciled roster.
"""
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
litellm.add_known_models()
def test_opencode_providers_registered_in_models_by_provider(self):
"""Both surfaces are keys in models_by_provider."""
assert "opencode_go" in litellm.models_by_provider
assert "opencode_zen" in litellm.models_by_provider
def test_opencode_models_populated_from_cost_map(self):
"""The model sets are populated from the cost map, not empty."""
assert len(litellm.opencode_go_models) > 0
assert len(litellm.opencode_zen_models) > 0
assert "opencode_go/deepseek-v4-flash" in litellm.opencode_go_models
assert "opencode_zen/claude-sonnet-5" in litellm.opencode_zen_models
def test_get_provider_models_expands_wildcard(self):
"""get_provider_models returns the cost-map models for both surfaces."""
from litellm.proxy.auth.model_checks import get_provider_models
go_models = get_provider_models(provider="opencode_go")
zen_models = get_provider_models(provider="opencode_zen")
assert go_models is not None
assert zen_models is not None
assert any(m.startswith("opencode_go/") for m in go_models)
assert any(m.startswith("opencode_zen/") for m in zen_models)
def test_get_known_models_from_wildcard_expands(self):
"""A wildcard route expands to the full model list for the surface."""
from litellm.proxy.auth.model_checks import get_known_models_from_wildcard
go_models = get_known_models_from_wildcard("opencode_go/*")
zen_models = get_known_models_from_wildcard("opencode_zen/*")
assert len(go_models) > 0
assert len(zen_models) > 0
assert all(m.startswith("opencode_go/") for m in go_models)
assert all(m.startswith("opencode_zen/") for m in zen_models)
assert "opencode_go/*" not in go_models
assert "opencode_zen/*" not in zen_models

View file

@ -0,0 +1,516 @@
"""
Tests for the OpenCode Go responses arm.
These tests verify the Go responses config class, validate_environment,
resolver, mocked completion, and cost-map entry for gpt-5.6-luna.
Acceptance criteria from Issue 04:
- opencode_go/gpt-5.6-luna is taken over by the responses bridge
- Takes over before any chat or messages dispatch
- responses-config resolver returns Go Responses config
- Cost-map entry carries "mode": "responses"
- Bearer auth works from explicit key, module var, env var, shared fallback
"""
import json
import respx # noqa: F401 # required for pytest-respx fixture
from httpx import Response
import litellm
import pytest
from litellm.llms.opencode.go.responses.transformation import (
OpenCodeGoResponsesAPIConfig,
)
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
GO_RESPONSE_ENDPOINT = "https://opencode.ai/zen/go/v1/responses"
def _make_responses_response(model: str, content: str, **usage_kwargs) -> dict:
"""Build a standard Responses API response body."""
prompt = usage_kwargs.get("prompt_tokens", 1)
completion = usage_kwargs.get("completion_tokens", 1)
return {
"id": "resp-123",
"object": "response",
"created_at": 1700000000,
"model": model,
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": content}],
}
],
"usage": {
"input_tokens": prompt,
"output_tokens": completion,
"total_tokens": prompt + completion,
},
}
# ---------------------------------------------------------------------------
# Responses config class
# ---------------------------------------------------------------------------
class TestOpenCodeGoResponsesAPIConfig:
"""Tests for the OpenCodeGoResponsesAPIConfig class itself."""
def test_custom_llm_provider(self):
"""custom_llm_provider should return OPENCODE_GO."""
cfg = OpenCodeGoResponsesAPIConfig()
assert cfg.custom_llm_provider == LlmProviders.OPENCODE_GO
def test_get_complete_url_default(self):
"""Default URL should point to Go Responses API endpoint."""
cfg = OpenCodeGoResponsesAPIConfig()
url = cfg.get_complete_url(api_base=None, litellm_params={})
assert url == GO_RESPONSE_ENDPOINT
def test_get_complete_url_custom_base_with_v1(self):
"""Custom api_base ending in /v1 should append /responses."""
cfg = OpenCodeGoResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="http://localhost:4000/v1",
litellm_params={},
)
assert url == "http://localhost:4000/v1/responses"
def test_get_complete_url_custom_base_with_v1_trailing_slash(self):
"""Trailing slash on /v1 should be stripped before appending /responses."""
cfg = OpenCodeGoResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="http://localhost:4000/v1/",
litellm_params={},
)
assert url == "http://localhost:4000/v1/responses"
def test_get_complete_url_custom_base_with_responses(self):
"""api_base already ending in /responses should pass through."""
cfg = OpenCodeGoResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="https://my-gateway.example.com/v1/responses",
litellm_params={},
)
assert url == "https://my-gateway.example.com/v1/responses"
def test_get_complete_url_custom_base_no_suffix(self):
"""Base without /v1 should get /v1/responses appended."""
cfg = OpenCodeGoResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="http://localhost:4000",
litellm_params={},
)
assert url == "http://localhost:4000/v1/responses"
def test_get_complete_url_custom_base_trailing_slash(self):
"""Trailing slash on bare base should be stripped."""
cfg = OpenCodeGoResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="http://localhost:4000/",
litellm_params={},
)
assert url == "http://localhost:4000/v1/responses"
def test_get_complete_url_env_var_base(self, monkeypatch):
"""OPENCODE_GO_API_BASE env var should be used as fallback base."""
monkeypatch.setenv("OPENCODE_GO_API_BASE", "http://env-gateway.example.com")
cfg = OpenCodeGoResponsesAPIConfig()
url = cfg.get_complete_url(api_base=None, litellm_params={})
assert url == "http://env-gateway.example.com/v1/responses"
def test_get_complete_url_module_var_base(self, monkeypatch):
"""Module-level opencode_go_api_base should override env var."""
monkeypatch.setenv("OPENCODE_GO_API_BASE", "http://env-gateway.example.com")
monkeypatch.setattr(litellm, "opencode_go_api_base", "http://module-gateway.example.com")
cfg = OpenCodeGoResponsesAPIConfig()
url = cfg.get_complete_url(api_base=None, litellm_params={})
assert url == "http://module-gateway.example.com/v1/responses"
def test_no_native_websocket(self):
"""OpenCode Go does not support native WebSocket for Responses API."""
cfg = OpenCodeGoResponsesAPIConfig()
assert cfg.supports_native_websocket() is False
# ---------------------------------------------------------------------------
# validate_environment — Bearer header injection
# ---------------------------------------------------------------------------
class TestGoValidateEnvironment:
"""Tests for header injection in OpenCodeGoResponsesAPIConfig.validate_environment."""
def setup_method(self):
self.cfg = OpenCodeGoResponsesAPIConfig()
def test_bearer_header_with_explicit_key(self):
headers: dict = {}
from litellm.types.router import GenericLiteLLMParams
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.6-luna",
litellm_params=GenericLiteLLMParams(api_key="sk-test-123"),
)
assert result["Authorization"] == "Bearer sk-test-123"
assert result["Content-Type"] == "application/json"
def test_bearer_header_from_env_var(self, monkeypatch):
monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-env-123")
headers: dict = {}
from litellm.types.router import GenericLiteLLMParams
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.6-luna",
litellm_params=GenericLiteLLMParams(),
)
assert result["Authorization"] == "Bearer sk-env-123"
monkeypatch.delenv("OPENCODE_GO_API_KEY")
def test_bearer_header_from_module_key(self, monkeypatch):
"""Module-level opencode_go_api_key should be used."""
monkeypatch.setattr(litellm, "opencode_go_api_key", "sk-module-456")
headers: dict = {}
from litellm.types.router import GenericLiteLLMParams
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.6-luna",
litellm_params=GenericLiteLLMParams(),
)
assert result["Authorization"] == "Bearer sk-module-456"
monkeypatch.setattr(litellm, "opencode_go_api_key", None)
def test_shared_fallback_key(self, monkeypatch):
monkeypatch.setenv("OPENCODE_API_KEY", "sk-shared-789")
headers: dict = {}
from litellm.types.router import GenericLiteLLMParams
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.6-luna",
litellm_params=GenericLiteLLMParams(),
)
assert result["Authorization"] == "Bearer sk-shared-789"
monkeypatch.delenv("OPENCODE_API_KEY")
def test_raises_without_any_key(self, monkeypatch):
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.delenv("OPENCODE_GO_API_KEY", raising=False)
monkeypatch.delenv("OPENCODE_API_KEY", raising=False)
from litellm.types.router import GenericLiteLLMParams
with pytest.raises(ValueError, match="OpenCode Go API key is required"):
self.cfg.validate_environment(
headers={},
model="gpt-5.6-luna",
litellm_params=GenericLiteLLMParams(),
)
# ---------------------------------------------------------------------------
# Responses-config resolver
# ---------------------------------------------------------------------------
class TestGoResponsesConfigResolver:
"""Test that the responses-config resolver returns the correct config for Go."""
def test_provider_config_manager_returns_go_config(self):
config = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENCODE_GO,
)
assert config is not None, "OpenCode Go must be registered in the responses-config resolver"
assert isinstance(config, OpenCodeGoResponsesAPIConfig)
def test_resolver_returns_different_config_than_zen(self):
"""Go resolver should not return Zen config."""
zen_config = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENCODE_ZEN,
)
go_config = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENCODE_GO,
)
assert zen_config is not None
assert go_config is not None
assert type(zen_config) is not type(go_config)
assert isinstance(go_config, OpenCodeGoResponsesAPIConfig)
def test_go_config_url_is_go(self):
"""The resolver config URL should point to Go, not Zen."""
config = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENCODE_GO,
)
url = config.get_complete_url(api_base=None, litellm_params={})
assert url == GO_RESPONSE_ENDPOINT
assert "opencode.ai/zen/go" in url
def test_go_config_not_chat_config(self):
"""Verify the responses config is distinct from the chat config."""
from litellm.llms.opencode.chat.transformation import OpenCodeConfig
responses_cfg = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENCODE_GO,
)
assert responses_cfg is not None
assert type(responses_cfg) is not OpenCodeConfig
url = responses_cfg.get_complete_url(api_base=None, litellm_params={})
assert "/responses" in url
assert "/chat/completions" not in url
# ---------------------------------------------------------------------------
# Integration — mocked completion call hits /v1/responses
# ---------------------------------------------------------------------------
class TestGoMockedCompletion:
"""Tests using mocked HTTP transport to verify the Go responses bridge."""
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch):
"""Clean state for every test."""
monkeypatch.setattr(litellm, "opencode_zen_api_key", None)
monkeypatch.setattr(litellm, "opencode_go_api_key", None)
monkeypatch.setattr(litellm, "opencode_api_key", None)
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", False)
litellm.in_memory_llm_clients_cache.flush_cache()
def test_responses_bridge_hits_responses_endpoint(self, respx_mock, monkeypatch):
"""opencode_go/gpt-5.6-luna is routed to /v1/responses, not /v1/chat/completions."""
respx_mock.post(GO_RESPONSE_ENDPOINT).mock(
return_value=Response(200, json=_make_responses_response("gpt-5.6-luna", "go responses work"))
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_go/gpt-5.6-luna",
messages=[{"role": "user", "content": "hi"}],
)
assert result is not None
assert result.model == "gpt-5.6-luna"
output = result.choices[0].message.content
assert output is not None
assert len(respx_mock.calls) > 0
request = respx_mock.calls[0].request
assert "/v1/responses" in str(request.url)
assert request.headers["Authorization"] == "Bearer sk-fake"
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(
return_value=Response(200, json=_make_responses_response("gpt-5.6-luna", "auth ok"))
)
monkeypatch.setattr(litellm, "opencode_go_api_key", "sk-module-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_go/gpt-5.6-luna",
messages=[{"role": "user", "content": "test"}],
)
assert result is not None
auth = respx_mock.calls[0].request.headers["Authorization"]
assert auth == "Bearer sk-module-key"
monkeypatch.setattr(litellm, "opencode_go_api_key", None)
def test_responses_bridge_sends_correct_body(self, respx_mock, monkeypatch):
"""The request body should use the responses API format."""
def capture_request(request):
body = json.loads(request.read())
assert "model" in body
return Response(200, json=_make_responses_response(body.get("model", "gpt-5.6-luna"), "ok"))
respx_mock.post(GO_RESPONSE_ENDPOINT).mock(side_effect=capture_request)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_go/gpt-5.6-luna",
messages=[{"role": "user", "content": "check body shape"}],
)
assert result is not None
def test_non_responses_model_does_not_use_responses_endpoint(self, respx_mock, monkeypatch):
"""A Go chat model must hit /v1/chat/completions, not /v1/responses."""
chat_url = "https://opencode.ai/zen/go/v1/chat/completions"
respx_mock.post(chat_url).mock(
return_value=Response(
200,
json={
"choices": [{"message": {"role": "assistant", "content": "chat ok"}}],
"model": "deepseek-v4-pro",
},
)
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_go/deepseek-v4-pro",
messages=[{"role": "user", "content": "hi"}],
)
assert result is not None
assert len(respx_mock.calls) > 0
call_path = respx_mock.calls[0].request.url.path
assert "/chat/completions" in call_path
assert "/responses" not in call_path
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _load_cost_map(monkeypatch):
"""Ensure the local model_cost map is loaded for every test."""
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
# ---------------------------------------------------------------------------
# Cost-map entries
# ---------------------------------------------------------------------------
class TestGoCostMap:
"""Cost-map entry for opencode_go gpt-5.6-luna."""
def _check_base_entry(self, model_key):
"""Return the cost-map entry for a model, asserting it exists."""
assert model_key in litellm.model_cost, f"{model_key} must be in cost-map"
entry = litellm.model_cost[model_key]
return entry
def test_gpt_5_6_luna_responses(self):
entry = self._check_base_entry("opencode_go/gpt-5.6-luna")
assert entry["mode"] == "responses"
assert entry["litellm_provider"] == "opencode_go"
assert entry["input_cost_per_token"] == 1e-07
assert entry["output_cost_per_token"] == 6e-07
def test_go_messages_models_stay_on_messages(self):
"""Go messages models must remain on messages mode."""
for model in [
"opencode_go/qwen3.8-max",
"opencode_go/minimax-m3",
"opencode_go/qwen3.5-plus",
]:
entry = self._check_base_entry(model)
assert entry["mode"] == "messages", f"{model} mode must be messages, got {entry['mode']}"
def test_go_chat_models_stay_on_chat(self):
"""Go chat models must remain on chat mode."""
for model in [
"opencode_go/deepseek-v4-pro",
"opencode_go/grok-4.5",
"opencode_go/mimo-v2.5",
]:
entry = self._check_base_entry(model)
assert entry["mode"] == "chat", f"{model} mode must be chat, got {entry['mode']}"
def test_go_gpt_5_6_luna_not_on_zen_responses(self):
"""The Go gpt-5.6-luna entry must exist as its own opencode_go key."""
go_entry = self._check_base_entry("opencode_go/gpt-5.6-luna")
assert go_entry["mode"] == "responses"
assert go_entry["litellm_provider"] == "opencode_go"
# Zen gpt-5.6-luna is a separate entry
zen_entry = self._check_base_entry("opencode_zen/gpt-5.6-luna")
assert zen_entry["mode"] == "responses"
assert zen_entry["litellm_provider"] == "opencode_zen"
# They should have different pricing
assert (
go_entry["input_cost_per_token"] != zen_entry["input_cost_per_token"]
or go_entry["output_cost_per_token"] != zen_entry["output_cost_per_token"]
)
def test_go_cost_map_matches_live_roster(self):
"""The Go cost map must exactly match the live /v1/models roster.
Regression guard: Zen-only crossover models (gpt-5.x family, the
*-free models, grok-build-0.1, big-pickle) were leaking into the Go
cost map and showing up in the wildcard model list even though they
are not callable on Go. The cost map must contain exactly the models
served by https://opencode.ai/zen/go/v1/models.
"""
live_go_models = {
"opencode_go/deepseek-v4-flash",
"opencode_go/deepseek-v4-pro",
"opencode_go/glm-5",
"opencode_go/glm-5.1",
"opencode_go/glm-5.2",
"opencode_go/gpt-5.6-luna",
"opencode_go/grok-4.5",
"opencode_go/hy3",
"opencode_go/kimi-k2.5",
"opencode_go/kimi-k2.6",
"opencode_go/kimi-k2.7-code",
"opencode_go/kimi-k3",
"opencode_go/mimo-v2-omni",
"opencode_go/mimo-v2-pro",
"opencode_go/mimo-v2.5",
"opencode_go/mimo-v2.5-pro",
"opencode_go/minimax-m2.5",
"opencode_go/minimax-m2.7",
"opencode_go/minimax-m3",
"opencode_go/qwen3.5-plus",
"opencode_go/qwen3.6-plus",
"opencode_go/qwen3.7-max",
"opencode_go/qwen3.7-plus",
"opencode_go/qwen3.8-flash",
"opencode_go/qwen3.8-max",
}
actual_go = {k for k in litellm.model_cost if k.startswith("opencode_go/")}
assert actual_go == live_go_models, (
f"Go cost map diverged from live roster. "
f"extra={sorted(actual_go - live_go_models)} "
f"missing={sorted(live_go_models - actual_go)}"
)
def test_go_crossover_models_removed(self):
"""Zen-only crossover models must not appear under the Go provider."""
for model in [
"opencode_go/gpt-5.6-sol",
"opencode_go/gpt-5",
"opencode_go/grok-build-0.1",
"opencode_go/big-pickle",
"opencode_go/mimo-v2.5-free",
]:
assert model not in litellm.model_cost, f"{model} must not be in Go cost map"
def test_new_go_models_present(self):
"""The 5 live Go models added to the gateway must have cost-map entries."""
for model in [
"opencode_go/hy3",
"opencode_go/mimo-v2.5",
"opencode_go/mimo-v2.5-pro",
"opencode_go/mimo-v2-omni",
"opencode_go/mimo-v2-pro",
]:
entry = self._check_base_entry(model)
assert entry["mode"] == "chat", f"{model} mode must be chat, got {entry['mode']}"

View file

@ -0,0 +1,906 @@
"""
Tests for OpenCode Anthropic Messages wire-format arm (Issue 02).
These tests fail before the feature exists and fail if the dispatch
mapping, auth header selection, or URL construction are mutated.
"""
import asyncio
import json
from datetime import datetime
from httpx import Response
import litellm
import pytest
from litellm.llms.opencode.chat.messages_transformation import (
OpenCodeMessagesConfig,
OPENCODE_MESSAGES_MODELS,
is_messages_model,
)
from litellm.types.completion import _CompletionDispatchContext
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import ModelResponse
ZEN_MESSAGES_ENDPOINT = "https://opencode.ai/zen/v1/messages"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _anthropic_response(content: str, **usage_kwargs) -> dict:
"""Build a standard Anthropic Messages response body."""
prompt = usage_kwargs.get("prompt_tokens", 1)
completion = usage_kwargs.get("completion_tokens", 1)
return {
"id": "msg_123",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4",
"content": [{"type": "text", "text": content}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": prompt,
"output_tokens": completion,
},
}
# ---------------------------------------------------------------------------
# Messages-model set as data
# ---------------------------------------------------------------------------
class TestMessagesModelSet:
"""The zen messages-model set is immutable data — mutations fail tests."""
def test_all_zen_claude_models_present(self):
"""Every live claude model from Issue 02 is in the set.
claude-opus-4-1 is excluded: it is not served by the live Zen
gateway roster, so it was removed from the messages set and cost map.
"""
expected_claude = {
"claude-fable-5",
"claude-haiku-4-5",
"claude-opus-4-5",
"claude-opus-4-6",
"claude-opus-4-7",
"claude-opus-4-8",
"claude-opus-5",
"claude-sonnet-4",
"claude-sonnet-4-5",
"claude-sonnet-4-6",
"claude-sonnet-5",
}
assert expected_claude <= OPENCODE_MESSAGES_MODELS["zen"]
assert "claude-opus-4-1" not in OPENCODE_MESSAGES_MODELS["zen"]
def test_qwen_models_in_set(self):
"""qwen3.5-plus and qwen3.6-plus are in the zen messages set."""
assert "qwen3.5-plus" in OPENCODE_MESSAGES_MODELS["zen"]
assert "qwen3.6-plus" in OPENCODE_MESSAGES_MODELS["zen"]
def test_set_size(self):
"""Exactly 13 models in the zen messages set."""
assert len(OPENCODE_MESSAGES_MODELS["zen"]) == 13
def test_non_claude_model_not_in_set(self):
"""gpt-5 models are NOT in the messages set (they belong on chat)."""
assert "gpt-5.1" not in OPENCODE_MESSAGES_MODELS["zen"]
assert "gpt-5.6-luna" not in OPENCODE_MESSAGES_MODELS["zen"]
assert "grok-4.5" not in OPENCODE_MESSAGES_MODELS["zen"]
def test_set_is_frozenset(self):
"""The set is immutable — mutation raises."""
with pytest.raises(AttributeError):
OPENCODE_MESSAGES_MODELS["zen"].add("brand-new-model")
# ---------------------------------------------------------------------------
# is_messages_model routing
# ---------------------------------------------------------------------------
class TestIsMessagesModel:
"""Model-to-arm dispatch decision."""
def test_claude_model_routes_to_messages(self):
"""claude-sonnet-4 routes to the messages arm on zen."""
assert is_messages_model("zen", "claude-sonnet-4") is True
assert is_messages_model("zen", "claude-opus-4-5") is True
def test_qwen_model_routes_to_messages(self):
"""qwen3.5-plus routes to the messages arm on zen."""
assert is_messages_model("zen", "qwen3.5-plus") is True
assert is_messages_model("zen", "qwen3.6-plus") is True
def test_non_messages_model_routes_to_chat(self):
"""gpt-5.1 does NOT route to messages (it goes to chat)."""
assert is_messages_model("zen", "gpt-5.1") is False
assert is_messages_model("zen", "grok-4.5") is False
def test_unknown_model_does_not_route_to_messages(self):
"""Unknown/new models fall through to chat, not messages."""
assert is_messages_model("zen", "brand-new-model") is False
def test_go_minimax_routes_to_messages(self):
"""minimax-m2.5 is a messages model on go."""
assert is_messages_model("go", "minimax-m2.5") is True
assert is_messages_model("go", "minimax-m3") is True
def test_go_qwen_routes_to_messages(self):
"""qwen3.5-max routes to messages on go."""
assert is_messages_model("go", "qwen3.5-max") is True
assert is_messages_model("go", "qwen3.8-max") is True
def test_go_qwen_plus_routes_to_messages(self):
"""qwen3.6-plus routes to messages on go too."""
assert is_messages_model("go", "qwen3.6-plus") is True
def test_go_qwen_flash_routes_to_messages(self):
"""qwen3.8-flash is off the {plus,max} grid but serves the Anthropic wire."""
assert is_messages_model("go", "qwen3.8-flash") is True
def test_go_non_messages_model(self):
"""gpt-5.5 is chat-only on go."""
assert is_messages_model("go", "gpt-5.5") is False
def test_go_grok_not_messages(self):
"""gpt-5.6-luna is not a messages model on go."""
assert is_messages_model("go", "gpt-5.6-luna") is False
@pytest.mark.parametrize("number", [5, 6, 7, 8])
@pytest.mark.parametrize("tier", ["plus", "max"])
def test_go_covers_the_whole_qwen_grid(self, number, tier):
"""Every qwen3.{5..8}-{plus,max} routes to messages on go.
The set covers the full grid rather than only the entries the cost map
carries today, so a model the gateway adds keeps reaching the Anthropic
wire instead of silently degrading to chat completions.
"""
assert is_messages_model("go", f"qwen3.{number}-{tier}") is True
def test_go_qwen_outside_the_grid_is_not_messages(self):
"""The grid is bounded — neighbouring versions are not assumed."""
assert is_messages_model("go", "qwen3.4-plus") is False
assert is_messages_model("go", "qwen3.9-plus") is False
assert is_messages_model("go", "qwen3.5-turbo") is False
# ---------------------------------------------------------------------------
# OpenCodeMessagesConfig — URL and headers
# ---------------------------------------------------------------------------
class TestMessagesConfig:
"""Tests for the OpenCodeMessagesConfig class."""
def test_zen_custom_llm_provider(self):
cfg = OpenCodeMessagesConfig(surface="zen")
assert cfg.custom_llm_provider == "opencode_zen"
def test_go_custom_llm_provider(self):
cfg = OpenCodeMessagesConfig(surface="go")
assert cfg.custom_llm_provider == "opencode_go"
def test_zen_base_url(self):
cfg = OpenCodeMessagesConfig(surface="zen")
assert cfg._base_url() == "https://opencode.ai/zen"
def test_get_complete_url_zen(self):
cfg = OpenCodeMessagesConfig(surface="zen")
url = cfg.get_complete_url(None, None, "claude-sonnet-4", {}, {})
assert url == "https://opencode.ai/zen/v1/messages"
def test_get_complete_url_trailing_slash(self):
cfg = OpenCodeMessagesConfig(surface="zen")
url = cfg.get_complete_url("http://localhost:4000/", None, "claude-sonnet-4", {}, {})
assert url == "http://localhost:4000/v1/messages"
def test_error_class(self):
cfg = OpenCodeMessagesConfig(surface="zen")
assert cfg.get_error_class("bad", 400, {}) is not None
class TestBillingMetadataNotLeaked:
"""OpenCode is a third-party gateway, not the first-party Anthropic API, so
x-anthropic-billing-header client attribution blocks must be dropped before
the request leaves. The base AnthropicMessagesConfig keeps them (correct for
api.anthropic.com), so a missing override silently forwards Claude Code
billing attribution to a third party."""
@pytest.mark.parametrize("surface", ["zen", "go"])
def test_billing_header_system_block_is_stripped(self, surface):
cfg = OpenCodeMessagesConfig(surface=surface)
optional_params = {
"max_tokens": 16,
"system": [
{"type": "text", "text": "x-anthropic-billing-header: user_id=abc123"},
{"type": "text", "text": "You are a helpful assistant."},
],
}
cfg.transform_anthropic_messages_request(
model="claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
anthropic_messages_optional_request_params=optional_params,
litellm_params=GenericLiteLLMParams(),
headers={},
)
remaining = optional_params.get("system") or []
texts = [block.get("text", "") for block in remaining if isinstance(block, dict)]
assert not any(text.startswith("x-anthropic-billing-header:") for text in texts)
assert "You are a helpful assistant." in texts
# ---------------------------------------------------------------------------
# x-api-key vs Bearer — regression at the auth seam
# ---------------------------------------------------------------------------
class TestAuthHeader:
"""Zen uses x-api-key; Go uses Bearer on the messages arm."""
def _make_cfg(self, surface: str):
return OpenCodeMessagesConfig(surface=surface)
def test_zen_uses_x_api_key(self):
"""Zen /v1/messages sends x-api-key, NOT Bearer."""
cfg = self._make_cfg("zen")
headers: dict = {}
result, _ = cfg.validate_anthropic_messages_environment(
headers=headers,
model="claude-sonnet-4",
messages=[],
optional_params={},
litellm_params={},
api_key="sk-zen-key",
)
assert "x-api-key" in result
assert result["x-api-key"] == "sk-zen-key"
assert "Authorization" not in result
def test_go_uses_x_api_key(self):
"""Go /v1/messages sends x-api-key, NOT Bearer.
Regression test: live verification showed Bearer on Go /v1/messages
returns 401 "Missing API key"; x-api-key returns 200.
"""
cfg = self._make_cfg("go")
headers: dict = {}
result, _ = cfg.validate_anthropic_messages_environment(
headers=headers,
model="minimax-m2.5",
messages=[],
optional_params={},
litellm_params={},
api_key="sk-go-key",
)
assert "x-api-key" in result
assert result["x-api-key"] == "sk-go-key"
assert "Authorization" not in result
def test_zen_anthropic_version_set(self):
"""The anthropic-version header is present on zen."""
cfg = self._make_cfg("zen")
headers: dict = {}
result, _ = cfg.validate_anthropic_messages_environment(
headers=headers,
model="claude-sonnet-4",
messages=[],
optional_params={},
litellm_params={},
api_key="sk-key",
)
assert result["anthropic-version"] == "2023-06-01"
def test_zen_content_type_set(self):
"""content-type is application/json."""
cfg = self._make_cfg("zen")
headers: dict = {}
result, _ = cfg.validate_anthropic_messages_environment(
headers=headers,
model="claude-sonnet-4",
messages=[],
optional_params={},
litellm_params={},
api_key="sk-key",
)
assert result["content-type"] == "application/json"
# ---------------------------------------------------------------------------
# Integration — mocked messages completion call
# ---------------------------------------------------------------------------
class TestMockedMessagesCompletion:
"""Messages arm models hit /v1/messages with Anthropic body + x-api-key."""
@pytest.fixture(autouse=True)
def _defaults(self, monkeypatch):
"""Provide max_tokens on every integration test — Anthropic requires it."""
monkeypatch.setattr(litellm, "opencode_zen_api_key", None)
monkeypatch.setattr(litellm, "opencode_go_api_key", None)
monkeypatch.setattr(litellm, "opencode_api_key", None)
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", False)
# The import-time cost map comes from remote main and predates the
# un-merged opencode feature. Load the local backup so the max_tokens
# default (read from the cost map) resolves for opencode models.
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
litellm.in_memory_llm_clients_cache.flush_cache()
def _make_completion_kwargs(self, **overrides):
"""Return default completion kwargs with a sensible max_tokens."""
kwargs = {
"messages": [{"role": "user", "content": "say hi"}],
"max_tokens": 256,
}
kwargs.update(overrides)
return kwargs
def test_messages_model_hits_v1_messages(self, respx_mock, monkeypatch):
"""
A messages-model reaches {base}/v1/messages, not /chat/completions.
This is the core acceptance test for the messages arm.
"""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("Claude speaks"))
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "say hi"}],
custom_llm_provider="opencode_zen",
max_tokens=256,
)
assert result is not None
assert len(respx_mock.calls) > 0
request = respx_mock.calls[0].request
assert "/v1/messages" in request.url.path
assert "/chat/completions" not in request.url.path
def test_messages_model_sends_x_api_key(self, respx_mock, monkeypatch):
"""
Zen /v1/messages sends x-api-key header, not Bearer.
Regression test: Bearer on Zen /v1/messages returns 401.
"""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("ok"))
)
monkeypatch.setattr(litellm, "api_key", "sk-zen-123")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
max_tokens=256,
)
request = respx_mock.calls[0].request
assert request.headers.get("x-api-key") == "sk-zen-123"
# Bearer should NOT be present for zen messages arm
assert "Authorization" not in request.headers
def test_messages_model_uses_anthropic_body_shape(self, respx_mock, monkeypatch):
"""The request body uses Anthropic Messages format, not OpenAI."""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("anthropic body"))
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "test body shape"}],
custom_llm_provider="opencode_zen",
max_tokens=256,
)
request = respx_mock.calls[0].request
body = json.loads(request.read())
# Anthropic messages uses "messages" with "role" and "content"
# but the outer shape is Anthropic, not OpenAI
assert "messages" in body
def test_unknown_model_still_routes_to_chat_arm(self, respx_mock, monkeypatch):
"""
Models outside the messages set still route to /chat/completions.
Dispatch precedence: messages-model check happens first;
non-matching models fall through to chat.
"""
respx_mock.post("https://opencode.ai/zen/v1/chat/completions").mock(
return_value=Response(
200,
json={"choices": [{"message": {"role": "assistant", "content": "chat works"}}]},
)
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/grok-4.5",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
)
assert result is not None
request = respx_mock.calls[0].request
assert "/chat/completions" in request.url.path
assert "/v1/messages" not in request.url.path
def test_messages_dispatch_precedence_over_chat(self, respx_mock, monkeypatch):
"""
A messages-model is dispatched to /v1/messages, NOT /chat/completions.
If dispatch precedence is broken, the model would hit /chat/completions.
"""
messages_endpoint = respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("messages arm"))
)
chat_endpoint = respx_mock.post("https://opencode.ai/zen/v1/chat/completions").mock(
return_value=Response(200, json={"choices": [{"message": {"role": "assistant", "content": "wrong"}}]})
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
max_tokens=256,
)
assert messages_endpoint.call_count == 1
assert chat_endpoint.call_count == 0
def test_go_messages_model_sends_x_api_key(self, respx_mock, monkeypatch):
"""Go messages models send x-api-key, not Bearer.
Regression test: live verification showed Bearer on Go /v1/messages
returns 401 "Missing API key"; x-api-key returns 200.
"""
respx_mock.post("https://opencode.ai/zen/go/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("go messages"))
)
monkeypatch.setattr(litellm, "api_key", "sk-go-123")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_go/minimax-m2.5",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_go",
max_tokens=256,
)
request = respx_mock.calls[0].request
assert "/v1/messages" in request.url.path
assert request.headers.get("x-api-key") == "sk-go-123"
# Bearer should NOT be present for go messages arm
assert "Authorization" not in request.headers
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.
Regression guard for the credential-disclosure claim on the messages
path: an unrelated global credential must never reach opencode.ai when a
surface-specific OpenCode key is configured.
"""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("ok"))
)
monkeypatch.setattr(litellm, "api_key", "sk-global-other-provider")
monkeypatch.setattr(litellm, "opencode_zen_api_key", "sk-opencode")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
max_tokens=256,
)
assert result is not None
api_key_header = respx_mock.calls[0].request.headers.get("x-api-key")
assert api_key_header == "sk-opencode"
assert "sk-global-other-provider" not in api_key_header
def test_env_var_key_resolution_messages_arm(self, respx_mock, monkeypatch):
"""Messages arm resolves api_key from OPENCODE_ZEN_API_KEY."""
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "sk-env-messages")
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("env key"))
)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_zen/claude-fable-5",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
max_tokens=256,
)
request = respx_mock.calls[0].request
assert request.headers.get("x-api-key") == "sk-env-messages"
def test_messages_model_qwen(self, respx_mock, monkeypatch):
"""qwen3.5-plus is also dispatched to messages arm."""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("qwen messages"))
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/qwen3.5-plus",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
max_tokens=256,
)
assert result is not None
request = respx_mock.calls[0].request
assert "/v1/messages" in request.url.path
def test_max_tokens_defaulted_from_cost_map(self, respx_mock, monkeypatch):
"""A messages-model request with no max_tokens still succeeds.
Regression: the Anthropic /v1/messages API requires max_tokens, and
the messages arm passes optional_params straight through. A playground
wildcard request (no explicit max_tokens) previously failed with
``max_tokens is required for Anthropic /v1/messages API``. The config
now defaults it from the model's cost-map ``max_output_tokens``.
"""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("defaulted"))
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
)
assert result is not None
request = respx_mock.calls[0].request
body = json.loads(request.read())
# claude-sonnet-4 cost-map max_output_tokens is 64000
assert body["max_tokens"] == 64000
def test_max_tokens_defaulted_on_go_surface(self, respx_mock, monkeypatch):
"""Go messages models default max_tokens from the go cost-map entry."""
respx_mock.post("https://opencode.ai/zen/go/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("go defaulted"))
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_go/qwen3.7-plus",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_go",
)
assert result is not None
request = respx_mock.calls[0].request
body = json.loads(request.read())
# qwen3.7-plus cost-map max_output_tokens is 65536
assert body["max_tokens"] == 65536
def test_explicit_max_tokens_not_overridden(self, respx_mock, monkeypatch):
"""An explicit max_tokens is preserved, not replaced by the default."""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("explicit"))
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
max_tokens=128,
)
assert result is not None
request = respx_mock.calls[0].request
body = json.loads(request.read())
assert body["max_tokens"] == 128
# ---------------------------------------------------------------------------
# Streaming test
# ---------------------------------------------------------------------------
def _sse_body(content: str, **usage_kwargs) -> str:
"""Build a single SSE event line for a messages response."""
prompt = usage_kwargs.get("prompt_tokens", 1)
completion = usage_kwargs.get("completion_tokens", 1)
data = {
"type": "message",
"id": "msg_123",
"role": "assistant",
"model": "claude-sonnet-4",
"stop_reason": "end_turn",
"content": [{"type": "text", "text": content}],
"usage": {"input_tokens": prompt, "output_tokens": completion},
}
return f"data: {json.dumps(data)}\n\ndata: [DONE]\n"
class TestMessagesArmStreaming:
"""Streaming returns the Anthropic SSE iterator on the messages arm."""
@pytest.fixture(autouse=True)
def _disable_aiohttp(self, monkeypatch):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
def test_streaming_sends_to_messages_url(self, respx_mock, monkeypatch):
"""stream=True routes to /v1/messages with Anthropic body shape."""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(
200,
text=_sse_body("streamed answer"),
headers={"content-type": "text/event-stream"},
)
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
result = litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
stream=True,
max_tokens=256,
)
assert result is not None
# The result must be an async iterator (StreamingGenerator)
assert hasattr(result, "__aiter__")
request = respx_mock.calls[0].request
assert "/v1/messages" in request.url.path
def test_streaming_body_includes_anthropic_params(self, respx_mock, monkeypatch):
"""Streaming request carries anthropic-version header."""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(
200,
text=_sse_body("streamed"),
headers={"content-type": "text/event-stream"},
)
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
stream=True,
max_tokens=256,
)
request = respx_mock.calls[0].request
assert request.headers.get("anthropic-version") == "2023-06-01"
# ---------------------------------------------------------------------------
# acompletion coverage on the messages arm
# ---------------------------------------------------------------------------
class TestMessagesArmAcompletion:
"""acompletion dispatches to the messages arm for messages models."""
@pytest.fixture(autouse=True)
def _disable_aiohttp(self, monkeypatch):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
@pytest.mark.asyncio
async def test_acompletion_sends_to_messages_url(self, respx_mock, monkeypatch):
"""acompletion for a messages model hits /v1/messages."""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("async"))
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
result = await litellm.acompletion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
max_tokens=512,
)
assert result is not None
request = respx_mock.calls[0].request
assert "/v1/messages" in request.url.path
@pytest.mark.asyncio
async def test_acompletion_max_tokens_reaches_request_body(self, respx_mock, monkeypatch):
"""acompletion max_tokens is serialized into the request body."""
respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(200, json=_anthropic_response("async"))
)
monkeypatch.setattr(litellm, "api_key", "sk-key")
await litellm.acompletion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
custom_llm_provider="opencode_zen",
max_tokens=512,
)
request = respx_mock.calls[0].request
body = json.loads(request.content)
assert body["max_tokens"] == 512
# ---------------------------------------------------------------------------
# Sync and acompletion paths both return an OpenAI-shaped result
# ---------------------------------------------------------------------------
class TestMessagesArmReturnShape:
"""``completion()`` promises a ``ModelResponse`` regardless of arm.
The messages arm speaks the Anthropic wire format upstream, so the reply has
to be translated back before it reaches the caller. Returning the raw
Anthropic body means ``response.choices[0]`` raises ``AttributeError`` for
every model on this arm.
"""
@pytest.fixture(autouse=True)
def _defaults(self, monkeypatch):
monkeypatch.setattr(litellm, "opencode_zen_api_key", None)
monkeypatch.setattr(litellm, "opencode_go_api_key", None)
monkeypatch.setattr(litellm, "opencode_api_key", None)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.in_memory_llm_clients_cache.flush_cache()
def test_sync_path_returns_model_response(self, respx_mock):
respx_mock.post(ZEN_MESSAGES_ENDPOINT).mock(return_value=Response(200, json=_anthropic_response("hi there")))
result = litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
max_tokens=16,
)
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "hi there"
def test_acompletion_path_returns_model_response(self, respx_mock):
respx_mock.post(ZEN_MESSAGES_ENDPOINT).mock(return_value=Response(200, json=_anthropic_response("async hi")))
result = asyncio.run(
litellm.acompletion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
max_tokens=16,
)
)
assert isinstance(result, ModelResponse)
assert result.choices[0].message.content == "async hi"
def test_openai_request_is_translated_to_anthropic_wire_format(self, respx_mock):
"""A ``system`` message and ``stop`` must not reach Anthropic verbatim.
Anthropic takes ``system`` as a top-level parameter and rejects
``role: "system"`` inside ``messages``, and names the stop list
``stop_sequences``. Forwarding the OpenAI body unchanged 400s upstream.
"""
route = respx_mock.post(ZEN_MESSAGES_ENDPOINT).mock(return_value=Response(200, json=_anthropic_response("ok")))
litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[
{"role": "system", "content": "be terse"},
{"role": "user", "content": "hi"},
],
stop=["END"],
max_tokens=16,
)
body = json.loads(route.calls[0].request.read())
assert body["system"] == [{"type": "text", "text": "be terse"}]
assert "system" not in {m["role"] for m in body["messages"]}
assert body["stop_sequences"] == ["END"]
assert "stop" not in body
@pytest.mark.parametrize(
"configured_base, expected_url",
[
("https://gw.internal", "https://gw.internal/v1/messages"),
("https://gw.internal/v1", "https://gw.internal/v1/messages"),
("https://gw.internal/v1/messages", "https://gw.internal/v1/messages"),
("https://gw.internal/", "https://gw.internal/v1/messages"),
],
)
def test_configured_api_base_is_honoured(self, respx_mock, configured_base, expected_url):
"""An operator-configured gateway must be reached, whatever suffix it carries.
Silently falling back to the public endpoint would send the operator's
key and prompts to opencode.ai instead of their own gateway.
"""
route = respx_mock.post(expected_url).mock(return_value=Response(200, json=_anthropic_response("ok")))
litellm.completion(
model="opencode_zen/claude-sonnet-4",
messages=[{"role": "user", "content": "hi"}],
api_base=configured_base,
max_tokens=16,
)
assert route.called
assert str(route.calls[0].request.url) == expected_url
# ---------------------------------------------------------------------------
# Cost-map entries for messages models
# ---------------------------------------------------------------------------
class TestMessagesCostMap:
"""Cost-map entries for the 13 Zen messages models."""
@pytest.fixture(autouse=True)
def _load_cost_map(self, monkeypatch):
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
def test_claude_sonnet_4_entry(self):
entry = litellm.model_cost["opencode_zen/claude-sonnet-4"]
assert entry["litellm_provider"] == "opencode_zen"
assert entry["max_input_tokens"] == 1000000
assert entry["max_output_tokens"] == 64000
def test_claude_opus_4_1_not_in_cost_map(self):
"""claude-opus-4-1 is not served by the live Zen roster."""
assert "opencode_zen/claude-opus-4-1" not in litellm.model_cost
def test_claude_fable_5_entry(self):
entry = litellm.model_cost["opencode_zen/claude-fable-5"]
assert entry["max_input_tokens"] == 1000000
assert entry["input_cost_per_token"] == 1e-05
def test_qwen3_plus_entries(self):
entry = litellm.model_cost["opencode_zen/qwen3.5-plus"]
assert entry["litellm_provider"] == "opencode_zen"
assert entry["max_input_tokens"] == 262144
entry_qwen36 = litellm.model_cost["opencode_zen/qwen3.6-plus"]
assert entry_qwen36["max_input_tokens"] == 262144
def test_all_14_messages_models_have_cost_entries(self):
"""Every model in the messages set has a cost-map entry."""
for model_name in OPENCODE_MESSAGES_MODELS["zen"]:
key = f"opencode_zen/{model_name}"
assert key in litellm.model_cost, f"{key} missing from cost map"
assert litellm.model_cost[key]["litellm_provider"] == "opencode_zen"
def test_cost_entries_have_pricing(self):
"""All messages models have nonzero pricing."""
for model_name in OPENCODE_MESSAGES_MODELS["zen"]:
key = f"opencode_zen/{model_name}"
entry = litellm.model_cost[key]
assert entry["input_cost_per_token"] >= 0
assert entry["output_cost_per_token"] >= 0

View file

@ -0,0 +1,803 @@
"""
Tests for the OpenCode Zen responses arm.
These tests fail before the feature exists and only pass when the cost-map
entries, responses config class, and resolver branch are all in place.
Acceptance criteria from Issue 03:
- opencode_zen/<model> is taken over by the responses bridge
- Takes over before any chat or messages dispatch
- responses-config resolver returns Zen Responses config
- All 26 models carry "mode": "responses"
- Streaming and acompletion work on the responses arm
"""
import json
import respx # noqa: F401 # required for pytest-respx fixture
from httpx import Response
import litellm
import pytest
from litellm.llms.opencode.zen.responses.transformation import (
OpenCodeZenResponsesAPIConfig,
)
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
ZEN_RESPONSE_ENDPOINT = "https://opencode.ai/zen/v1/responses"
def _make_responses_response(model: str, content: str, **usage_kwargs) -> dict:
"""Build a standard Responses API response body."""
prompt = usage_kwargs.get("prompt_tokens", 1)
completion = usage_kwargs.get("completion_tokens", 1)
return {
"id": "resp-123",
"object": "response",
"created_at": 1700000000,
"model": model,
"status": "completed",
"output": [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": content}]}],
"usage": {
"input_tokens": prompt,
"output_tokens": completion,
"total_tokens": prompt + completion,
},
}
# ---------------------------------------------------------------------------
# Responses config class
# ---------------------------------------------------------------------------
class TestOpenCodeZenResponsesAPIConfig:
"""Tests for the OpenCodeZenResponsesAPIConfig class itself."""
def test_custom_llm_provider(self):
"""custom_llm_provider should return OPENCODE_ZEN."""
cfg = OpenCodeZenResponsesAPIConfig()
assert cfg.custom_llm_provider == LlmProviders.OPENCODE_ZEN
def test_get_complete_url_default(self):
"""Default URL should point to Zen Responses API endpoint."""
cfg = OpenCodeZenResponsesAPIConfig()
url = cfg.get_complete_url(api_base=None, litellm_params={})
assert url == ZEN_RESPONSE_ENDPOINT
def test_get_complete_url_custom_base_with_v1(self):
"""Custom api_base ending in /v1 should append /responses."""
cfg = OpenCodeZenResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="http://localhost:4000/v1",
litellm_params={},
)
assert url == "http://localhost:4000/v1/responses"
def test_get_complete_url_custom_base_with_v1_trailing_slash(self):
"""Trailing slash on /v1 should be stripped before appending /responses."""
cfg = OpenCodeZenResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="http://localhost:4000/v1/",
litellm_params={},
)
assert url == "http://localhost:4000/v1/responses"
def test_get_complete_url_custom_base_with_responses(self):
"""api_base already ending in /responses should pass through."""
cfg = OpenCodeZenResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="https://my-gateway.example.com/v1/responses",
litellm_params={},
)
assert url == "https://my-gateway.example.com/v1/responses"
def test_get_complete_url_custom_base_no_suffix(self):
"""Base without /v1 should get /v1/responses appended."""
cfg = OpenCodeZenResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="http://localhost:4000",
litellm_params={},
)
assert url == "http://localhost:4000/v1/responses"
def test_get_complete_url_custom_base_trailing_slash(self):
"""Trailing slash on bare base should be stripped."""
cfg = OpenCodeZenResponsesAPIConfig()
url = cfg.get_complete_url(
api_base="http://localhost:4000/",
litellm_params={},
)
assert url == "http://localhost:4000/v1/responses"
def test_get_complete_url_env_var_base(self, monkeypatch):
"""OPENCODE_ZEN_API_BASE env var should be used as fallback base."""
monkeypatch.setenv("OPENCODE_ZEN_API_BASE", "http://env-gateway.example.com")
cfg = OpenCodeZenResponsesAPIConfig()
url = cfg.get_complete_url(api_base=None, litellm_params={})
assert url == "http://env-gateway.example.com/v1/responses"
def test_get_complete_url_module_var_base(self, monkeypatch):
"""Module-level opencode_zen_base_url should override env var."""
monkeypatch.setenv("OPENCODE_ZEN_API_BASE", "http://env-gateway.example.com")
monkeypatch.setattr(litellm, "opencode_zen_api_base", "http://module-gateway.example.com")
cfg = OpenCodeZenResponsesAPIConfig()
url = cfg.get_complete_url(api_base=None, litellm_params={})
assert url == "http://module-gateway.example.com/v1/responses"
def test_no_native_websocket(self):
"""OpenCode Zen does not support native WebSocket for Responses API."""
cfg = OpenCodeZenResponsesAPIConfig()
assert cfg.supports_native_websocket() is False
# ---------------------------------------------------------------------------
# validate_environment — Bearer header injection
# ---------------------------------------------------------------------------
class TestValidateEnvironment:
"""Tests for header injection in validate_environment."""
def setup_method(self):
self.cfg = OpenCodeZenResponsesAPIConfig()
def test_bearer_header_with_explicit_key(self):
headers: dict = {}
from litellm.types.router import GenericLiteLLMParams
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.5",
litellm_params=GenericLiteLLMParams(api_key="sk-test-123"),
)
assert result["Authorization"] == "Bearer sk-test-123"
assert result["Content-Type"] == "application/json"
def test_bearer_header_from_env_var(self, monkeypatch):
monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "sk-env-123")
headers: dict = {}
from litellm.types.router import GenericLiteLLMParams
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.5",
litellm_params=GenericLiteLLMParams(),
)
assert result["Authorization"] == "Bearer sk-env-123"
monkeypatch.delenv("OPENCODE_ZEN_API_KEY")
def test_shared_fallback_key(self, monkeypatch):
monkeypatch.setenv("OPENCODE_API_KEY", "sk-shared-456")
headers: dict = {}
from litellm.types.router import GenericLiteLLMParams
result = self.cfg.validate_environment(
headers=headers,
model="gpt-5.5",
litellm_params=GenericLiteLLMParams(),
)
assert result["Authorization"] == "Bearer sk-shared-456"
monkeypatch.delenv("OPENCODE_API_KEY")
def test_raises_without_any_key(self, monkeypatch):
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.delenv("OPENCODE_ZEN_API_KEY", raising=False)
monkeypatch.delenv("OPENCODE_API_KEY", raising=False)
from litellm.types.router import GenericLiteLLMParams
with pytest.raises(ValueError, match="OpenCode Zen API key is required"):
self.cfg.validate_environment(
headers={},
model="gpt-5.5",
litellm_params=GenericLiteLLMParams(),
)
# ---------------------------------------------------------------------------
# Responses-config resolver
# ---------------------------------------------------------------------------
class TestResponsesConfigResolver:
"""Test that the responses-config resolver returns the correct config."""
def test_provider_config_manager_returns_zen_config(self):
"""
ProviderConfigManager.get_provider_responses_api_config should return
OpenCodeZenResponsesAPIConfig for OPENCODE_ZEN, not None.
"""
config = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENCODE_ZEN,
)
assert config is not None, "OpenCode Zen must be registered in the responses-config resolver"
assert isinstance(config, OpenCodeZenResponsesAPIConfig)
def test_resolver_returns_different_config_than_openrouter(self):
"""OpenCode Zen resolver should not return OpenRouter config."""
from litellm.llms.openrouter.responses.transformation import (
OpenRouterResponsesAPIConfig,
)
zen_config = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENCODE_ZEN,
)
router_config = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENROUTER,
)
assert zen_config is not None
assert router_config is not None
assert type(zen_config) is not type(router_config)
assert isinstance(zen_config, OpenCodeZenResponsesAPIConfig)
assert isinstance(router_config, OpenRouterResponsesAPIConfig)
def test_zen_config_url_is_zens(self):
"""The resolver config URL should point to Zen, not OpenRouter."""
config = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENCODE_ZEN,
)
url = config.get_complete_url(api_base=None, litellm_params={})
assert url == ZEN_RESPONSE_ENDPOINT
def test_non_responses_provider_does_not_raise(self):
"""Calling get_provider_responses_api_config for a non-responses provider must not raise."""
from litellm.types.utils import LlmProviders as LP
# Anthropic uses chat, not responses — should fall through gracefully
result = ProviderConfigManager.get_provider_responses_api_config(
provider=LP.ANTHROPIC,
)
# Accept None or any fallback handler — just no exceptions
assert result is None or hasattr(result, "get_complete_url")
def test_responses_config_not_chat_config(self):
"""Verify the responses config is distinct from the chat config."""
from litellm.llms.opencode.chat.transformation import OpenCodeConfig
responses_cfg = ProviderConfigManager.get_provider_responses_api_config(
provider=LlmProviders.OPENCODE_ZEN,
)
assert responses_cfg is not None
assert type(responses_cfg) is not OpenCodeConfig
# The URL should contain /responses/, not /chat/completions/
url = responses_cfg.get_complete_url(api_base=None, litellm_params={})
assert "/responses" in url
assert "/chat/completions" not in url
# ---------------------------------------------------------------------------
# Integration — mocked completion call hits /v1/responses
# ---------------------------------------------------------------------------
class TestMockedCompletion:
"""Tests using mocked HTTP transport to verify the responses bridge."""
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch):
"""Clean state for every test."""
monkeypatch.setattr(litellm, "opencode_zen_api_key", None)
monkeypatch.setattr(litellm, "opencode_go_api_key", None)
monkeypatch.setattr(litellm, "opencode_api_key", None)
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", False)
litellm.in_memory_llm_clients_cache.flush_cache()
def test_responses_bridge_hits_responses_endpoint(self, respx_mock, monkeypatch):
"""opencode_zen/gpt-5.5 is routed to /v1/responses, not /v1/chat/completions."""
respx_mock.post(ZEN_RESPONSE_ENDPOINT).mock(
return_value=Response(200, json=_make_responses_response("gpt-5.5", "responses work"))
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/gpt-5.5",
messages=[{"role": "user", "content": "hi"}],
)
assert result is not None
assert result.model == "gpt-5.5"
# Responses API returns nested content
output = result.choices[0].message.content
assert output is not None
assert len(respx_mock.calls) > 0
request = respx_mock.calls[0].request
assert "/v1/responses" in str(request.url)
assert request.headers["Authorization"] == "Bearer sk-fake"
def test_bearer_auth_from_module_key(self, respx_mock, monkeypatch):
"""Module-level api_key provides the Bearer token."""
respx_mock.post(ZEN_RESPONSE_ENDPOINT).mock(
return_value=Response(200, json=_make_responses_response("gpt-5.4", "auth ok"))
)
monkeypatch.setattr(litellm, "opencode_zen_api_key", "sk-module-key")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/gpt-5.4",
messages=[{"role": "user", "content": "test"}],
)
assert result is not None
auth = respx_mock.calls[0].request.headers["Authorization"]
assert auth == "Bearer sk-module-key"
monkeypatch.setattr(litellm, "opencode_zen_api_key", None)
def test_responses_bridge_sends_correct_body(self, respx_mock, monkeypatch):
"""The request body should use the responses API format."""
def capture_request(request):
body = json.loads(request.read())
# Responses API uses "input" not "messages" for new models
# but the bridge converts between formats
assert "model" in body
return Response(200, json=_make_responses_response(body.get("model", "gpt-5.5"), "ok"))
respx_mock.post(ZEN_RESPONSE_ENDPOINT).mock(side_effect=capture_request)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/gpt-5.4-pro",
messages=[{"role": "user", "content": "check body shape"}],
)
assert result is not None
def test_non_responses_model_does_not_use_responses_endpoint(self, respx_mock, monkeypatch):
"""A model on the chat arm (grok-4.5) should hit /v1/chat/completions, not /v1/responses."""
chat_url = "https://opencode.ai/zen/v1/chat/completions"
respx_mock.post(chat_url).mock(
return_value=Response(
200, json={"choices": [{"message": {"role": "assistant", "content": "chat ok"}}], "model": "grok-4.5"}
)
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/grok-4.5",
messages=[{"role": "user", "content": "hi"}],
)
assert result is not None
# Verify it went to chat, not responses
assert len(respx_mock.calls) > 0
call_path = respx_mock.calls[0].request.url.path
assert "/chat/completions" in call_path
assert "/responses" not in call_path
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _load_cost_map(monkeypatch):
"""Ensure the local model_cost map is loaded for every test."""
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
# ---------------------------------------------------------------------------
# Cost-map entries
# ---------------------------------------------------------------------------
class TestCostMap:
"""Cost-map entries for 26 opencode_zen responses models."""
def _check_base_entry(self, model_key):
"""Return the cost-map entry for a model, asserting it exists."""
assert model_key in litellm.model_cost, f"{model_key} must be in cost-map"
entry = litellm.model_cost[model_key]
return entry
# --- gpt-5.6 series ---
def test_gpt_5_6_sol(self):
entry = self._check_base_entry("opencode_zen/gpt-5.6-sol")
assert entry["mode"] == "responses"
assert entry["litellm_provider"] == "opencode_zen"
assert entry["input_cost_per_token"] == 5e-06
assert entry["output_cost_per_token"] == 3e-05
def test_gpt_5_6_terra(self):
entry = self._check_base_entry("opencode_zen/gpt-5.6-terra")
assert entry["mode"] == "responses"
assert entry["input_cost_per_token"] == 2e-06
assert entry["output_cost_per_token"] == 1.2e-05
def test_gpt_5_6_luna(self):
entry = self._check_base_entry("opencode_zen/gpt-5.6-luna")
assert entry["mode"] == "responses"
assert entry["input_cost_per_token"] == 2e-07
assert entry["output_cost_per_token"] == 1.2e-06
# --- gpt-5.5 series ---
def test_gpt_5_5(self):
entry = self._check_base_entry("opencode_zen/gpt-5.5")
assert entry["mode"] == "responses"
assert entry["input_cost_per_token"] == 5e-06
assert entry["output_cost_per_token"] == 3e-05
def test_gpt_5_5_pro(self):
entry = self._check_base_entry("opencode_zen/gpt-5.5-pro")
assert entry["mode"] == "responses"
assert entry["input_cost_per_token"] == 3e-05
assert entry["output_cost_per_token"] == 0.00018
# --- gpt-5.4 series ---
def test_gpt_5_4(self):
entry = self._check_base_entry("opencode_zen/gpt-5.4")
assert entry["mode"] == "responses"
assert entry["input_cost_per_token"] == 2.5e-06
assert entry["output_cost_per_token"] == 1.5e-05
def test_gpt_5_4_pro(self):
entry = self._check_base_entry("opencode_zen/gpt-5.4-pro")
assert entry["mode"] == "responses"
assert entry["input_cost_per_token"] == 3e-05
assert entry["output_cost_per_token"] == 0.00018
def test_gpt_5_4_mini(self):
entry = self._check_base_entry("opencode_zen/gpt-5.4-mini")
assert entry["mode"] == "responses"
assert entry["input_cost_per_token"] == 7.5e-07
def test_gpt_5_4_nano(self):
entry = self._check_base_entry("opencode_zen/gpt-5.4-nano")
assert entry["mode"] == "responses"
assert entry["input_cost_per_token"] == 2e-07
# --- gpt-5.3 series ---
def test_gpt_5_3_codex_spark(self):
entry = self._check_base_entry("opencode_zen/gpt-5.3-codex-spark")
assert entry["mode"] == "responses"
def test_gpt_5_3_codex(self):
entry = self._check_base_entry("opencode_zen/gpt-5.3-codex")
assert entry["mode"] == "responses"
# --- gpt-5.2 series ---
def test_gpt_5_2(self):
entry = self._check_base_entry("opencode_zen/gpt-5.2")
assert entry["mode"] == "responses"
def test_gpt_5_2_codex(self):
entry = self._check_base_entry("opencode_zen/gpt-5.2-codex")
assert entry["mode"] == "responses"
# --- gpt-5.1 series ---
def test_gpt_5_1(self):
entry = self._check_base_entry("opencode_zen/gpt-5.1")
assert entry["mode"] == "responses"
def test_gpt_5_1_codex_max(self):
entry = self._check_base_entry("opencode_zen/gpt-5.1-codex-max")
assert entry["mode"] == "responses"
def test_gpt_5_1_codex(self):
entry = self._check_base_entry("opencode_zen/gpt-5.1-codex")
assert entry["mode"] == "responses"
def test_gpt_5_1_codex_mini(self):
entry = self._check_base_entry("opencode_zen/gpt-5.1-codex-mini")
assert entry["mode"] == "responses"
assert entry["input_cost_per_token"] == 2.5e-07
# --- gpt-5 base ---
def test_gpt_5(self):
entry = self._check_base_entry("opencode_zen/gpt-5")
assert entry["mode"] == "responses"
def test_gpt_5_codex(self):
entry = self._check_base_entry("opencode_zen/gpt-5-codex")
assert entry["mode"] == "responses"
def test_gpt_5_nano(self):
entry = self._check_base_entry("opencode_zen/gpt-5-nano")
assert entry["mode"] == "responses"
assert entry["input_cost_per_token"] == 5e-08
# --- grok ---
def test_grok_build_0_1(self):
entry = self._check_base_entry("opencode_zen/grok-build-0.1")
assert entry["mode"] == "responses"
# --- gemini 3.6 ---
def test_gemini_3_6_flash(self):
entry = self._check_base_entry("opencode_zen/gemini-3.6-flash")
assert entry["mode"] == "responses"
# --- gemini 3.5 ---
def test_gemini_3_5_flash_lite(self):
entry = self._check_base_entry("opencode_zen/gemini-3.5-flash-lite")
assert entry["mode"] == "responses"
def test_gemini_3_5_flash(self):
entry = self._check_base_entry("opencode_zen/gemini-3.5-flash")
assert entry["mode"] == "responses"
# --- gemini 3.1 ---
def test_gemini_3_1_pro(self):
entry = self._check_base_entry("opencode_zen/gemini-3.1-pro")
assert entry["mode"] == "responses"
# --- gemini 3.0 ---
def test_gemini_3_flash(self):
entry = self._check_base_entry("opencode_zen/gemini-3-flash")
assert entry["mode"] == "responses"
# --- count ---
def test_all_26_models_have_responses_mode(self):
"""All 26 models must have mode=responses."""
responses_models = [
"opencode_zen/gpt-5.6-sol",
"opencode_zen/gpt-5.6-terra",
"opencode_zen/gpt-5.6-luna",
"opencode_zen/gpt-5.5",
"opencode_zen/gpt-5.5-pro",
"opencode_zen/gpt-5.4",
"opencode_zen/gpt-5.4-pro",
"opencode_zen/gpt-5.4-mini",
"opencode_zen/gpt-5.4-nano",
"opencode_zen/gpt-5.3-codex-spark",
"opencode_zen/gpt-5.3-codex",
"opencode_zen/gpt-5.2",
"opencode_zen/gpt-5.2-codex",
"opencode_zen/gpt-5.1",
"opencode_zen/gpt-5.1-codex-max",
"opencode_zen/gpt-5.1-codex",
"opencode_zen/gpt-5.1-codex-mini",
"opencode_zen/gpt-5",
"opencode_zen/gpt-5-codex",
"opencode_zen/gpt-5-nano",
"opencode_zen/grok-build-0.1",
"opencode_zen/gemini-3.6-flash",
"opencode_zen/gemini-3.5-flash-lite",
"opencode_zen/gemini-3.5-flash",
"opencode_zen/gemini-3.1-pro",
"opencode_zen/gemini-3-flash",
]
for model in responses_models:
entry = self._check_base_entry(model)
assert entry["mode"] == "responses", f"{model} mode must be responses, got {entry['mode']}"
assert entry["litellm_provider"] == "opencode_zen"
assert len(responses_models) == 26
def test_grok_4_5_stays_on_chat_arm(self):
"""grok-4.5 must remain on chat arm, not be taken over by responses bridge."""
entry = self._check_base_entry("opencode_zen/grok-4.5")
assert entry["mode"] == "chat", (
"grok-4.5 must remain on chat arm; responses arm is only for the 26 models above"
)
def test_chat_models_stay_on_chat(self):
"""Non-responses opencode_zen models should retain mode=chat."""
for model in [
"opencode_zen/glm-5",
"opencode_zen/deepseek-v4-pro",
"opencode_zen/minimax-m3",
]:
entry = self._check_base_entry(model)
assert entry["mode"] == "chat"
# ---------------------------------------------------------------------------
# Takeover-before-dispatch — ensure dispatch is NOT consulted
# ---------------------------------------------------------------------------
class TestTakeoverBeforeDispatch:
"""Verify the responses bridge takes over before provider dispatch."""
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch):
"""Clean state for every test."""
monkeypatch.setattr(litellm, "opencode_zen_api_key", None)
monkeypatch.setattr(litellm, "opencode_go_api_key", None)
monkeypatch.setattr(litellm, "opencode_api_key", None)
monkeypatch.setattr(litellm, "api_key", None)
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", False)
litellm.in_memory_llm_clients_cache.flush_cache()
def test_responses_bridge_takes_over_before_dispatch(self, respx_mock, monkeypatch):
"""
opencode_zen/gpt-5.5 must hit /v1/responses and NEVER reach the
chat-completions dispatch chain.
"""
responses_called = respx_mock.post(ZEN_RESPONSE_ENDPOINT).mock(
return_value=Response(200, json=_make_responses_response("gpt-5.5", "bridge"))
)
# Set up a guard: if the chat completions endpoint is hit, fail the test
chat_guard = respx_mock.post("https://opencode.ai/zen/v1/chat/completions").mock(
return_value=Response(500, json={"error": "DISPATCH_SHOULD_NOT_BE_REACHED"})
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/gpt-5.5",
messages=[{"role": "user", "content": "hi"}],
)
assert result is not None
# The responses endpoint was called
assert responses_called.called
# The chat guard was NOT called
assert not chat_guard.called
def test_responses_bridge_takes_over_before_messages_dispatch(self, respx_mock, monkeypatch):
"""
Verify the responses bridge takes over before the messages dispatch.
If the messages endpoint is hit, the test fails.
"""
responses_called = respx_mock.post(ZEN_RESPONSE_ENDPOINT).mock(
return_value=Response(200, json=_make_responses_response("gpt-5.4", "bridge"))
)
messages_guard = respx_mock.post("https://opencode.ai/zen/v1/messages").mock(
return_value=Response(500, json={"error": "MESSAGES_DISPATCH_SHOULD_NOT_BE_REACHED"})
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/gpt-5.4",
messages=[{"role": "user", "content": "test"}],
)
assert result is not None
assert responses_called.called
assert not messages_guard.called
def test_takeover_survives_a_cost_map_without_the_entry(self, monkeypatch, respx_mock):
"""Routing must not depend on the model being in the runtime cost map.
``litellm.model_cost`` is fetched from the published remote map at
import, and a provider's entries only appear there once released, so an
install whose map predates this provider has no ``mode`` to read. When
takeover depended on that field alone, every Responses model silently
fell through to ``/v1/chat/completions`` the wrong endpoint, and a
wrong-wire-format response rather than a clean error.
"""
cost_map_without_opencode = {k: v for k, v in litellm.model_cost.items() if not k.startswith("opencode_")}
monkeypatch.setattr(litellm, "model_cost", cost_map_without_opencode)
responses_mock = respx_mock.post(ZEN_RESPONSE_ENDPOINT).mock(
return_value=Response(200, json=_make_responses_response("gpt-5.5", "bridged"))
)
chat_mock = respx_mock.post("https://opencode.ai/zen/v1/chat/completions").mock(
return_value=Response(
200,
json={"choices": [{"message": {"role": "assistant", "content": "chat fallback"}}], "model": "gpt-5.5"},
)
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.completion(
model="opencode_zen/gpt-5.5",
messages=[{"role": "user", "content": "hi"}],
)
assert responses_mock.called
assert not chat_mock.called
def test_pricing_survives_a_cost_map_without_the_entry(self, monkeypatch, respx_mock):
"""Spend must be tracked even when the runtime cost map has no entry.
A model the runtime map does not price yields ``response_cost = None``,
which a proxy recording spend reads as zero, so paid calls would not
draw down a budget until the map publishes. The pricing bundled with the
package covers that window.
"""
cost_map_without_opencode = {k: v for k, v in litellm.model_cost.items() if not k.startswith("opencode_")}
monkeypatch.setattr(litellm, "model_cost", cost_map_without_opencode)
respx_mock.post(ZEN_RESPONSE_ENDPOINT).mock(
return_value=Response(
200,
json=_make_responses_response("gpt-5.5", "priced", prompt_tokens=100, completion_tokens=50),
)
)
monkeypatch.setattr(litellm, "api_key", "sk-fake")
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
result = litellm.completion(
model="opencode_zen/gpt-5.5",
messages=[{"role": "user", "content": "hi"}],
)
# 100 * 5e-06 + 50 * 3e-05, the bundled rates for this model
assert result._hidden_params["response_cost"] == pytest.approx(0.002)
def test_a_priced_cost_map_is_not_overridden_by_the_bundled_copy(self, monkeypatch):
"""A published entry must win: the bundled copy can go stale."""
from litellm.llms.opencode.common_utils import ensure_opencode_pricing
published = {**litellm.model_cost["opencode_zen/gpt-5.5"], "input_cost_per_token": 1.23}
monkeypatch.setattr(litellm, "model_cost", {**litellm.model_cost, "opencode_zen/gpt-5.5": published})
ensure_opencode_pricing("opencode_zen", "gpt-5.5")
assert litellm.model_cost["opencode_zen/gpt-5.5"]["input_cost_per_token"] == 1.23
def test_an_empty_placeholder_entry_does_not_block_the_fallback(self, monkeypatch):
"""A key already in the cost map is not proof that it is priced.
Router registers a bare placeholder for every deployment when it starts,
so through the proxy the model is present in ``model_cost`` as an empty
entry before any request runs. Skipping on presence alone left every
call through Router unpriced while direct calls looked fine.
"""
from litellm.llms.opencode.common_utils import ensure_opencode_pricing
monkeypatch.setattr(litellm, "model_cost", {**litellm.model_cost, "opencode_zen/gpt-5.5": {}})
ensure_opencode_pricing("opencode_zen", "gpt-5.5")
assert litellm.model_cost["opencode_zen/gpt-5.5"]["input_cost_per_token"] > 0
@pytest.mark.asyncio
async def test_router_deployments_are_priced(self, monkeypatch):
"""End to end through Router, which is the path the proxy takes.
The runtime map is stripped of this provider first, reproducing an
install whose published cost map predates it. Router then registers its
own empty placeholder for the deployment, which is the combination that
left every proxied call unpriced.
"""
from litellm import Router
cost_map_without_opencode = {k: v for k, v in litellm.model_cost.items() if not k.startswith("opencode_")}
monkeypatch.setattr(litellm, "model_cost", cost_map_without_opencode)
router = Router(
model_list=[
{
"model_name": "oc-messages",
"litellm_params": {"model": "opencode_go/minimax-m3", "api_key": "sk-fake"},
}
]
)
assert litellm.model_cost.get("opencode_go/minimax-m3") == {}, "expected Router's empty placeholder"
response = await router.acompletion(
model="oc-messages",
messages=[{"role": "user", "content": "hi"}],
mock_response="ok",
)
assert response._hidden_params["response_cost"] > 0

View file

@ -62,6 +62,41 @@ def test_get_provider_create_fields():
), "Expected at least one provider to have detailed credential fields"
def test_provider_create_fields_contains_opencode_providers():
"""Assert that both OpenCode Go and OpenCode Zen appear in the
provider_create_fields JSON so the dashboard renders them in
the Add Model dropdown (Issue 05 of the OpenCode providers feature)."""
app_instance = FastAPI()
app_instance.include_router(router)
client = TestClient(app_instance)
response = client.get("/public/providers/fields")
assert response.status_code == 200
providers = response.json()
slugs = {p["litellm_provider"] for p in providers}
assert "opencode_go" in slugs
assert "opencode_zen" in slugs
go_entry = next(p for p in providers if p["litellm_provider"] == "opencode_go")
zen_entry = next(p for p in providers if p["litellm_provider"] == "opencode_zen")
# ``provider`` holds the provider_map key (underscore) that the dashboard
# uses to resolve the litellm provider slug; ``provider_display_name`` is
# the human-readable label rendered in the dropdown.
for entry, provider_key, display_name in (
(go_entry, "OpenCode_Go", "OpenCode Go"),
(zen_entry, "OpenCode_Zen", "OpenCode Zen"),
):
assert entry["provider"] == provider_key
assert entry["provider_display_name"] == display_name
assert isinstance(entry["credential_fields"], list)
assert len(entry["credential_fields"]) > 0
assert any(f["key"] == "api_key" for f in entry["credential_fields"])
assert "default_model_placeholder" in entry
def test_get_litellm_model_cost_map_returns_cost_map():
app = FastAPI()
app.include_router(router)

View file

@ -1071,6 +1071,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"embedding",
"guardrail",
"image_generation",
"messages",
"video_generation",
"moderation",
"rerank",

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" fill="none">
<rect width="300" height="300" rx="48" fill="#211E1E"/>
<path d="M80 100 L150 60 L220 100 L150 140Z" stroke="#CFCECD" stroke-width="8" fill="none"/>
<path d="M80 200 L150 240 L220 200 L150 160Z" stroke="#CFCECD" stroke-width="8" fill="none"/>
<path d="M150 100 L150 200" stroke="#CFCECD" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 420 B

View file

@ -140,6 +140,18 @@ describe("provider_info_helpers", () => {
expect(result.logo).toBe("");
});
it("should map opencode_go provider value to OpenCode Go display name and logo", () => {
const result = getProviderLogoAndName("opencode_go");
expect(result.displayName).toBe(Providers.OpenCode_Go);
expect(result.logo).toBe(providerLogoMap[Providers.OpenCode_Go]);
});
it("should map opencode_zen provider value to OpenCode Zen display name and logo", () => {
const result = getProviderLogoAndName("opencode_zen");
expect(result.displayName).toBe(Providers.OpenCode_Zen);
expect(result.logo).toBe(providerLogoMap[Providers.OpenCode_Zen]);
});
it("should map provider_map values to valid display names", () => {
const uniqueProviderValues = new Set(Object.values(provider_map));
uniqueProviderValues.forEach((providerValue) => {
@ -253,6 +265,14 @@ describe("provider_info_helpers", () => {
expect(getPlaceholder(Providers.DeepInfra)).toBe("deepinfra/<any-model-on-deepinfra>");
});
it("should return opencode_go/gpt-5.6-luna placeholder for OpenCode Go provider", () => {
expect(getPlaceholder(Providers.OpenCode_Go)).toBe("opencode_go/gpt-5.6-luna");
});
it("should return opencode_zen/gpt-5.6-sol placeholder for OpenCode Zen provider", () => {
expect(getPlaceholder(Providers.OpenCode_Zen)).toBe("opencode_zen/gpt-5.6-sol");
});
it("should return fal_ai placeholder for FalAI provider", () => {
expect(getPlaceholder(Providers.FalAI)).toBe("fal_ai/fal-ai/flux-pro/v1.1-ultra");
});
@ -424,6 +444,36 @@ describe("provider_info_helpers", () => {
expect(result).not.toContain("openai-model");
});
it("should include opencode_go models when called with 'OpenCode_Go' provider key", () => {
// The backend provider_create_fields JSON carries the provider_map key
// ("OpenCode_Go"), not the display value, so the dropdown populates.
const modelMap = {
"opencode_go/gpt-5.6-luna": { litellm_provider: "opencode_go" },
"opencode_go/glm-5": { litellm_provider: "opencode_go" },
"opencode_zen/gpt-5.6-sol": { litellm_provider: "opencode_zen" },
"openai-model": { litellm_provider: "openai" },
};
const result = getProviderModels("OpenCode_Go" as Providers, modelMap);
expect(result).toContain("opencode_go/gpt-5.6-luna");
expect(result).toContain("opencode_go/glm-5");
expect(result).not.toContain("opencode_zen/gpt-5.6-sol");
expect(result).not.toContain("openai-model");
});
it("should include opencode_zen models when called with 'OpenCode_Zen' provider key", () => {
const modelMap = {
"opencode_zen/gpt-5.6-sol": { litellm_provider: "opencode_zen" },
"opencode_zen/claude-sonnet-5": { litellm_provider: "opencode_zen" },
"opencode_go/gpt-5.6-luna": { litellm_provider: "opencode_go" },
"openai-model": { litellm_provider: "openai" },
};
const result = getProviderModels("OpenCode_Zen" as Providers, modelMap);
expect(result).toContain("opencode_zen/gpt-5.6-sol");
expect(result).toContain("opencode_zen/claude-sonnet-5");
expect(result).not.toContain("opencode_go/gpt-5.6-luna");
expect(result).not.toContain("openai-model");
});
it("should filter out models with null values", () => {
const modelMap = {
"gpt-3.5-turbo": { litellm_provider: "openai" },

View file

@ -64,6 +64,7 @@ import voyageLogo from "../../public/assets/logos/voyage.webp";
import watsonxLogo from "../../public/assets/logos/watsonx.svg";
import xaiLogo from "../../public/assets/logos/xai.svg";
import xinferenceLogo from "../../public/assets/logos/xinference.svg";
import opencodeLogo from "../../public/assets/logos/opencode.svg";
export enum Providers {
A2A_Agent = "A2A Agent",
@ -139,6 +140,8 @@ export enum Providers {
Ollama = "Ollama",
OLLAMA_CHAT = "Ollama Chat",
OOBABOOGA = "Oobabooga",
OpenCode_Go = "OpenCode Go",
OpenCode_Zen = "OpenCode Zen",
OpenAI = "OpenAI",
OPENAI_LIKE = "Openai Like",
OpenAI_Compatible = "OpenAI-Compatible Chat Completions (Together AI, vLLM, etc.)",
@ -254,6 +257,8 @@ export const provider_map: Record<string, string> = {
Ollama: "ollama",
OLLAMA_CHAT: "ollama_chat",
OOBABOOGA: "oobabooga",
OpenCode_Go: "opencode_go",
OpenCode_Zen: "opencode_zen",
OpenAI: "openai",
OPENAI_LIKE: "openai_like",
OpenAI_Compatible: "openai",
@ -356,6 +361,8 @@ export const providerLogoMap: Partial<Record<Providers, string>> = {
[Providers.Ollama]: ollamaLogo.src,
[Providers.OLLAMA_CHAT]: ollamaLogo.src,
[Providers.OOBABOOGA]: openaiSmallLogo.src,
[Providers.OpenCode_Go]: opencodeLogo.src,
[Providers.OpenCode_Zen]: opencodeLogo.src,
[Providers.OpenAI]: openaiSmallLogo.src,
[Providers.OPENAI_LIKE]: openaiSmallLogo.src,
[Providers.OpenAI_Text]: openaiSmallLogo.src,
@ -436,6 +443,8 @@ const providerPlaceholderMap: Partial<Record<Providers, string>> = {
[Providers.Google_AI_Studio]: "gemini-pro",
[Providers.JinaAI]: "jina_ai/",
[Providers.NVIDIA_RIVA]: "nvidia_riva/nvidia/parakeet-ctc-1_1b-asr",
[Providers.OpenCode_Go]: "opencode_go/gpt-5.6-luna",
[Providers.OpenCode_Zen]: "opencode_zen/gpt-5.6-sol",
[Providers.Oracle]: "oci/xai.grok-4",
[Providers.RunwayML]: "runwayml/gen4_turbo",
[Providers.SageMaker]: "sagemaker/jumpstart-dft-meta-textgeneration-llama-2-7b",