mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
parent
56045503db
commit
35c021b2a3
34 changed files with 6804 additions and 0 deletions
103
docs/adr/0003-opencode-polyglot-provider.md
Normal file
103
docs/adr/0003-opencode-polyglot-provider.md
Normal 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
|
||||
|
|
@ -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}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
0
litellm/llms/opencode/__init__.py
Normal file
0
litellm/llms/opencode/__init__.py
Normal file
0
litellm/llms/opencode/chat/__init__.py
Normal file
0
litellm/llms/opencode/chat/__init__.py
Normal file
111
litellm/llms/opencode/chat/anthropic_transformation.py
Normal file
111
litellm/llms/opencode/chat/anthropic_transformation.py
Normal 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,
|
||||
)
|
||||
233
litellm/llms/opencode/chat/messages_transformation.py
Normal file
233
litellm/llms/opencode/chat/messages_transformation.py
Normal 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,
|
||||
)
|
||||
88
litellm/llms/opencode/chat/transformation.py
Normal file
88
litellm/llms/opencode/chat/transformation.py
Normal 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)
|
||||
197
litellm/llms/opencode/common_utils.py
Normal file
197
litellm/llms/opencode/common_utils.py
Normal 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
|
||||
)
|
||||
26
litellm/llms/opencode/config.py
Normal file
26
litellm/llms/opencode/config.py
Normal 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)
|
||||
1
litellm/llms/opencode/go/responses/__init__.py
Normal file
1
litellm/llms/opencode/go/responses/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
96
litellm/llms/opencode/go/responses/transformation.py
Normal file
96
litellm/llms/opencode/go/responses/transformation.py
Normal 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
|
||||
0
litellm/llms/opencode/zen/__init__.py
Normal file
0
litellm/llms/opencode/zen/__init__.py
Normal file
0
litellm/llms/opencode/zen/responses/__init__.py
Normal file
0
litellm/llms/opencode/zen/responses/__init__.py
Normal file
96
litellm/llms/opencode/zen/responses/transformation.py
Normal file
96
litellm/llms/opencode/zen/responses/transformation.py
Normal 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
|
||||
127
litellm/main.py
127
litellm/main.py
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -414,6 +414,7 @@
|
|||
"guardrail",
|
||||
"image_edit",
|
||||
"image_generation",
|
||||
"messages",
|
||||
"moderation",
|
||||
"ocr",
|
||||
"realtime",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
527
tests/test_litellm/llms/opencode/test_opencode_chat.py
Normal file
527
tests/test_litellm/llms/opencode/test_opencode_chat.py
Normal 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
|
||||
516
tests/test_litellm/llms/opencode/test_opencode_go_responses.py
Normal file
516
tests/test_litellm/llms/opencode/test_opencode_go_responses.py
Normal 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']}"
|
||||
906
tests/test_litellm/llms/opencode/test_opencode_messages.py
Normal file
906
tests/test_litellm/llms/opencode/test_opencode_messages.py
Normal 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
|
||||
803
tests/test_litellm/llms/opencode/test_opencode_responses.py
Normal file
803
tests/test_litellm/llms/opencode/test_opencode_responses.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1071,6 +1071,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
|
|||
"embedding",
|
||||
"guardrail",
|
||||
"image_generation",
|
||||
"messages",
|
||||
"video_generation",
|
||||
"moderation",
|
||||
"rerank",
|
||||
|
|
|
|||
6
ui/litellm-dashboard/public/assets/logos/opencode.svg
Normal file
6
ui/litellm-dashboard/public/assets/logos/opencode.svg
Normal 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 |
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue