From 35c021b2a3442bf7e909dea56aeba0fbbc654272 Mon Sep 17 00:00:00 2001 From: Sean Murphy Date: Thu, 3 Sep 2026 23:37:13 -0700 Subject: [PATCH] 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. --- docs/adr/0003-opencode-polyglot-provider.md | 103 ++ litellm/__init__.py | 30 + litellm/_lazy_imports_registry.py | 22 + litellm/constants.py | 2 + litellm/llms/opencode/__init__.py | 0 litellm/llms/opencode/chat/__init__.py | 0 .../opencode/chat/anthropic_transformation.py | 111 ++ .../opencode/chat/messages_transformation.py | 233 +++ litellm/llms/opencode/chat/transformation.py | 88 ++ litellm/llms/opencode/common_utils.py | 197 +++ litellm/llms/opencode/config.py | 26 + .../llms/opencode/go/responses/__init__.py | 1 + .../opencode/go/responses/transformation.py | 96 ++ litellm/llms/opencode/zen/__init__.py | 0 .../llms/opencode/zen/responses/__init__.py | 0 .../opencode/zen/responses/transformation.py | 96 ++ litellm/main.py | 127 ++ ...odel_prices_and_context_window_backup.json | 1362 +++++++++++++++++ .../provider_create_fields.json | 36 + litellm/types/llms/openai.py | 14 + litellm/types/utils.py | 2 + litellm/utils.py | 26 + model_prices_and_context_window.json | 1362 +++++++++++++++++ model_prices_and_context_window.schema.json | 1 + provider_endpoints_support.json | 16 + .../llms/opencode/test_opencode_chat.py | 527 +++++++ .../opencode/test_opencode_go_responses.py | 516 +++++++ .../llms/opencode/test_opencode_messages.py | 906 +++++++++++ .../llms/opencode/test_opencode_responses.py | 803 ++++++++++ .../public_endpoints/test_public_endpoints.py | 35 + tests/test_litellm/test_utils.py | 1 + .../public/assets/logos/opencode.svg | 6 + .../components/provider_info_helpers.test.tsx | 50 + .../src/components/provider_info_helpers.tsx | 9 + 34 files changed, 6804 insertions(+) create mode 100644 docs/adr/0003-opencode-polyglot-provider.md create mode 100644 litellm/llms/opencode/__init__.py create mode 100644 litellm/llms/opencode/chat/__init__.py create mode 100644 litellm/llms/opencode/chat/anthropic_transformation.py create mode 100644 litellm/llms/opencode/chat/messages_transformation.py create mode 100644 litellm/llms/opencode/chat/transformation.py create mode 100644 litellm/llms/opencode/common_utils.py create mode 100644 litellm/llms/opencode/config.py create mode 100644 litellm/llms/opencode/go/responses/__init__.py create mode 100644 litellm/llms/opencode/go/responses/transformation.py create mode 100644 litellm/llms/opencode/zen/__init__.py create mode 100644 litellm/llms/opencode/zen/responses/__init__.py create mode 100644 litellm/llms/opencode/zen/responses/transformation.py create mode 100644 tests/test_litellm/llms/opencode/test_opencode_chat.py create mode 100644 tests/test_litellm/llms/opencode/test_opencode_go_responses.py create mode 100644 tests/test_litellm/llms/opencode/test_opencode_messages.py create mode 100644 tests/test_litellm/llms/opencode/test_opencode_responses.py create mode 100644 ui/litellm-dashboard/public/assets/logos/opencode.svg diff --git a/docs/adr/0003-opencode-polyglot-provider.md b/docs/adr/0003-opencode-polyglot-provider.md new file mode 100644 index 00000000000..a92e4c16de2 --- /dev/null +++ b/docs/adr/0003-opencode-polyglot-provider.md @@ -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 diff --git a/litellm/__init__.py b/litellm/__init__.py index ccfbf80369f..24fbfdd32c3 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -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}") diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index dc323c8cc15..c8a9dd62d5b 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -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", diff --git a/litellm/constants.py b/litellm/constants.py index 6b984c2673c..3154ec4cefd 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -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 diff --git a/litellm/llms/opencode/__init__.py b/litellm/llms/opencode/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/opencode/chat/__init__.py b/litellm/llms/opencode/chat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/opencode/chat/anthropic_transformation.py b/litellm/llms/opencode/chat/anthropic_transformation.py new file mode 100644 index 00000000000..d1eb6019700 --- /dev/null +++ b/litellm/llms/opencode/chat/anthropic_transformation.py @@ -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, + ) diff --git a/litellm/llms/opencode/chat/messages_transformation.py b/litellm/llms/opencode/chat/messages_transformation.py new file mode 100644 index 00000000000..996c19cd47d --- /dev/null +++ b/litellm/llms/opencode/chat/messages_transformation.py @@ -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, + ) diff --git a/litellm/llms/opencode/chat/transformation.py b/litellm/llms/opencode/chat/transformation.py new file mode 100644 index 00000000000..7660c8dedab --- /dev/null +++ b/litellm/llms/opencode/chat/transformation.py @@ -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) diff --git a/litellm/llms/opencode/common_utils.py b/litellm/llms/opencode/common_utils.py new file mode 100644 index 00000000000..510c777d324 --- /dev/null +++ b/litellm/llms/opencode/common_utils.py @@ -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 + ) diff --git a/litellm/llms/opencode/config.py b/litellm/llms/opencode/config.py new file mode 100644 index 00000000000..96bb645bf1d --- /dev/null +++ b/litellm/llms/opencode/config.py @@ -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) diff --git a/litellm/llms/opencode/go/responses/__init__.py b/litellm/llms/opencode/go/responses/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/litellm/llms/opencode/go/responses/__init__.py @@ -0,0 +1 @@ + diff --git a/litellm/llms/opencode/go/responses/transformation.py b/litellm/llms/opencode/go/responses/transformation.py new file mode 100644 index 00000000000..dc636ec8d84 --- /dev/null +++ b/litellm/llms/opencode/go/responses/transformation.py @@ -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 diff --git a/litellm/llms/opencode/zen/__init__.py b/litellm/llms/opencode/zen/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/opencode/zen/responses/__init__.py b/litellm/llms/opencode/zen/responses/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/opencode/zen/responses/transformation.py b/litellm/llms/opencode/zen/responses/transformation.py new file mode 100644 index 00000000000..220e2fb7437 --- /dev/null +++ b/litellm/llms/opencode/zen/responses/transformation.py @@ -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 diff --git a/litellm/main.py b/litellm/main.py index 17edafcdfca..ef111a5b0e8 100644 --- a/litellm/main.py +++ b/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 diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 06c7a6aa46e..d5e725c0edd 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -65122,5 +65122,1367 @@ "supports_tool_choice": false, "supports_response_schema": true, "supports_vision": false + }, + "opencode_zen/big-pickle": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/deepseek-v4-flash": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 2.8e-07, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.8e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/deepseek-v4-flash-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/deepseek-v4-pro": { + "input_cost_per_token": 1.74e-06, + "output_cost_per_token": 3.84e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.45e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/glm-5": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3.2000000000000003e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.0000000000000002e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/glm-5.1": { + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/glm-5.2": { + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/grok-4.5": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 6e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/kimi-k2.5": { + "input_cost_per_token": 6e-07, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 8e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/kimi-k2.6": { + "input_cost_per_token": 9.499999999999999e-07, + "output_cost_per_token": 4e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/kimi-k2.7-code": { + "input_cost_per_token": 9.499999999999999e-07, + "output_cost_per_token": 4e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.9e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/kimi-k3": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/laguna-s-2.1-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 256000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/ling-3.0-flash-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/longcat-2.0-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/mimo-v2.5-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/minimax-m2.5": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 6e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/minimax-m2.7": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 6e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/minimax-m3": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 512000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 6e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/nemotron-3-ultra-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/north-mini-code-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 256000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/gpt-5.6-sol": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 3e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.6-terra": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 1.2e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.6-luna": { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.5": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 3e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.5-pro": { + "input_cost_per_token": 3e-05, + "output_cost_per_token": 0.00018, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.4": { + "input_cost_per_token": 2.5e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.4-pro": { + "input_cost_per_token": 3e-05, + "output_cost_per_token": 0.00018, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.4-mini": { + "input_cost_per_token": 7.5e-07, + "output_cost_per_token": 4.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.4-nano": { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.3-codex-spark": { + "input_cost_per_token": 1.75e-06, + "output_cost_per_token": 1.4e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.75e-07, + "cache_creation_input_token_cost": 1.75e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.3-codex": { + "input_cost_per_token": 1.75e-06, + "output_cost_per_token": 1.4e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.75e-07, + "cache_creation_input_token_cost": 1.75e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.2": { + "input_cost_per_token": 1.75e-06, + "output_cost_per_token": 1.4e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.75e-07, + "cache_creation_input_token_cost": 1.75e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.2-codex": { + "input_cost_per_token": 1.75e-06, + "output_cost_per_token": 1.4e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.75e-07, + "cache_creation_input_token_cost": 1.75e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.1": { + "input_cost_per_token": 1.07e-06, + "output_cost_per_token": 8.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.07e-07, + "cache_creation_input_token_cost": 1.07e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.1-codex-max": { + "input_cost_per_token": 1.25e-06, + "output_cost_per_token": 1e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost": 1.25e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.1-codex": { + "input_cost_per_token": 1.07e-06, + "output_cost_per_token": 8.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.07e-07, + "cache_creation_input_token_cost": 1.07e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.1-codex-mini": { + "input_cost_per_token": 2.5e-07, + "output_cost_per_token": 1.25e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5-codex": { + "input_cost_per_token": 1.07e-06, + "output_cost_per_token": 8.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.07e-07, + "cache_creation_input_token_cost": 1.07e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5-nano": { + "input_cost_per_token": 5e-08, + "output_cost_per_token": 2.5e-07, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/grok-build-0.1": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "cache_read_input_token_cost": 2e-07, + "cache_creation_input_token_cost": 2e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gemini-3.6-flash": { + "input_cost_per_token": 1.5e-06, + "output_cost_per_token": 7.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 1.5e-07, + "cache_creation_input_token_cost": 1.5e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gemini-3.5-flash-lite": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 2.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3e-08, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gemini-3.5-flash": { + "input_cost_per_token": 1.5e-06, + "output_cost_per_token": 9e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 1.5e-07, + "cache_creation_input_token_cost": 1.5e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gemini-3.1-pro": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 1.2e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 2e-07, + "cache_creation_input_token_cost": 2e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gemini-3-flash": { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 5e-08, + "cache_creation_input_token_cost": 5e-08, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-fable-5": { + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1e-06, + "cache_creation_input_token_cost": 1.25e-05, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_sampling_params": false + }, + "opencode_zen/claude-haiku-4-5": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "cache_read_input_token_cost": 1e-07, + "cache_creation_input_token_cost": 1.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-opus-4-5": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 6.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-opus-4-6": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 6.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-opus-4-7": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 6.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_sampling_params": false + }, + "opencode_zen/claude-opus-4-8": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 6.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_adaptive_thinking": true + }, + "opencode_zen/claude-opus-5": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 6.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_adaptive_thinking": true, + "prompt_cache_min_tokens": 512, + "supports_sampling_params": false + }, + "opencode_zen/claude-sonnet-4": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-sonnet-4-5": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-sonnet-4-6": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-sonnet-5": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 1e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 2e-07, + "cache_creation_input_token_cost": 2.5e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_adaptive_thinking": true + }, + "opencode_zen/qwen3.5-plus": { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 2e-08, + "cache_creation_input_token_cost": 2.5e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/qwen3.6-plus": { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 5e-08, + "cache_creation_input_token_cost": 6.25e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/gpt-5.6-luna": { + "input_cost_per_token": 1e-07, + "output_cost_per_token": 6e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1e-08, + "cache_creation_input_token_cost": 1.25e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/minimax-m2.5": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 204800, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3e-08, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/minimax-m2.7": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "cache_read_input_token_cost": 6e-08, + "cache_creation_input_token_cost": 6e-08, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/minimax-m3": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "cache_read_input_token_cost": 6e-08, + "cache_creation_input_token_cost": 6e-08, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/qwen3.5-plus": { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 2e-08, + "cache_creation_input_token_cost": 2.5e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/qwen3.6-plus": { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 5e-08, + "cache_creation_input_token_cost": 6.25e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/qwen3.7-max": { + "input_cost_per_token": 2.5e-06, + "output_cost_per_token": 7.5e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 3.125e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/qwen3.7-plus": { + "input_cost_per_token": 4e-07, + "output_cost_per_token": 1.6e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 4e-08, + "cache_creation_input_token_cost": 5e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/qwen3.8-max": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 6e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "cache_read_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost": 2.5e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/deepseek-v4-flash": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 2.8e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.8e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/deepseek-v4-pro": { + "input_cost_per_token": 1.74e-06, + "output_cost_per_token": 3.84e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.45e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/glm-5": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3.2000000000000003e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.0000000000000002e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/glm-5.1": { + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/glm-5.2": { + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/grok-4.5": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 6e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/kimi-k2.5": { + "input_cost_per_token": 6e-07, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 8e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/kimi-k2.6": { + "input_cost_per_token": 9.499999999999999e-07, + "output_cost_per_token": 4e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/kimi-k2.7-code": { + "input_cost_per_token": 9.499999999999999e-07, + "output_cost_per_token": 4e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.9e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/kimi-k3": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_go", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/hy3": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 5.8e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 256000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 3.5e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/mimo-v2.5": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 2.8e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.8e-09, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/mimo-v2.5-pro": { + "input_cost_per_token": 4.35e-07, + "output_cost_per_token": 8.7e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 1048576, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 3.625e-09, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/mimo-v2-omni": { + "input_cost_per_token": 4e-07, + "output_cost_per_token": 2e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 262144, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 8e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/mimo-v2-pro": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1048576, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/ling-3.0-tiny-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/qwen3.8-flash": { + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 4.7e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "cache_read_input_token_cost": 1.6e-08, + "cache_creation_input_token_cost": 2e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true } } diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index cd781abee26..bdbebb74bc4 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -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" } ] diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index dfafe27e0a1..50803f51b36 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -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): diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 00c55b35182..cf5ca773761 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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 diff --git a/litellm/utils.py b/litellm/utils.py index c6386471f28..92d9a5ee712 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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 diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 06c7a6aa46e..d5e725c0edd 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -65122,5 +65122,1367 @@ "supports_tool_choice": false, "supports_response_schema": true, "supports_vision": false + }, + "opencode_zen/big-pickle": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/deepseek-v4-flash": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 2.8e-07, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.8e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/deepseek-v4-flash-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/deepseek-v4-pro": { + "input_cost_per_token": 1.74e-06, + "output_cost_per_token": 3.84e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.45e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/glm-5": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3.2000000000000003e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.0000000000000002e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/glm-5.1": { + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/glm-5.2": { + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/grok-4.5": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 6e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/kimi-k2.5": { + "input_cost_per_token": 6e-07, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 8e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/kimi-k2.6": { + "input_cost_per_token": 9.499999999999999e-07, + "output_cost_per_token": 4e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/kimi-k2.7-code": { + "input_cost_per_token": 9.499999999999999e-07, + "output_cost_per_token": 4e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.9e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/kimi-k3": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/laguna-s-2.1-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 256000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/ling-3.0-flash-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/longcat-2.0-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/mimo-v2.5-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/minimax-m2.5": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 6e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/minimax-m2.7": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 6e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/minimax-m3": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 512000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 6e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/nemotron-3-ultra-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/north-mini-code-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 256000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/gpt-5.6-sol": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 3e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.6-terra": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 1.2e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.6-luna": { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.5": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 3e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.5-pro": { + "input_cost_per_token": 3e-05, + "output_cost_per_token": 0.00018, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.4": { + "input_cost_per_token": 2.5e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.4-pro": { + "input_cost_per_token": 3e-05, + "output_cost_per_token": 0.00018, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.4-mini": { + "input_cost_per_token": 7.5e-07, + "output_cost_per_token": 4.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.4-nano": { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.3-codex-spark": { + "input_cost_per_token": 1.75e-06, + "output_cost_per_token": 1.4e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.75e-07, + "cache_creation_input_token_cost": 1.75e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.3-codex": { + "input_cost_per_token": 1.75e-06, + "output_cost_per_token": 1.4e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.75e-07, + "cache_creation_input_token_cost": 1.75e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.2": { + "input_cost_per_token": 1.75e-06, + "output_cost_per_token": 1.4e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.75e-07, + "cache_creation_input_token_cost": 1.75e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.2-codex": { + "input_cost_per_token": 1.75e-06, + "output_cost_per_token": 1.4e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.75e-07, + "cache_creation_input_token_cost": 1.75e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.1": { + "input_cost_per_token": 1.07e-06, + "output_cost_per_token": 8.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.07e-07, + "cache_creation_input_token_cost": 1.07e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.1-codex-max": { + "input_cost_per_token": 1.25e-06, + "output_cost_per_token": 1e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost": 1.25e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.1-codex": { + "input_cost_per_token": 1.07e-06, + "output_cost_per_token": 8.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.07e-07, + "cache_creation_input_token_cost": 1.07e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5.1-codex-mini": { + "input_cost_per_token": 2.5e-07, + "output_cost_per_token": 1.25e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5-codex": { + "input_cost_per_token": 1.07e-06, + "output_cost_per_token": 8.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1.07e-07, + "cache_creation_input_token_cost": 1.07e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gpt-5-nano": { + "input_cost_per_token": 5e-08, + "output_cost_per_token": 2.5e-07, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/grok-build-0.1": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "cache_read_input_token_cost": 2e-07, + "cache_creation_input_token_cost": 2e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gemini-3.6-flash": { + "input_cost_per_token": 1.5e-06, + "output_cost_per_token": 7.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 1.5e-07, + "cache_creation_input_token_cost": 1.5e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gemini-3.5-flash-lite": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 2.5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3e-08, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gemini-3.5-flash": { + "input_cost_per_token": 1.5e-06, + "output_cost_per_token": 9e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 1.5e-07, + "cache_creation_input_token_cost": 1.5e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gemini-3.1-pro": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 1.2e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 2e-07, + "cache_creation_input_token_cost": 2e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/gemini-3-flash": { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 5e-08, + "cache_creation_input_token_cost": 5e-08, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-fable-5": { + "input_cost_per_token": 1e-05, + "output_cost_per_token": 5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1e-06, + "cache_creation_input_token_cost": 1.25e-05, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_sampling_params": false + }, + "opencode_zen/claude-haiku-4-5": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 5e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "cache_read_input_token_cost": 1e-07, + "cache_creation_input_token_cost": 1.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-opus-4-5": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 6.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-opus-4-6": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 6.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-opus-4-7": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 6.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_sampling_params": false + }, + "opencode_zen/claude-opus-4-8": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 6.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_adaptive_thinking": true + }, + "opencode_zen/claude-opus-5": { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 6.25e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_adaptive_thinking": true, + "prompt_cache_min_tokens": 512, + "supports_sampling_params": false + }, + "opencode_zen/claude-sonnet-4": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-sonnet-4-5": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-sonnet-4-6": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/claude-sonnet-5": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 1e-05, + "litellm_provider": "opencode_zen", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 2e-07, + "cache_creation_input_token_cost": 2.5e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_adaptive_thinking": true + }, + "opencode_zen/qwen3.5-plus": { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 2e-08, + "cache_creation_input_token_cost": 2.5e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_zen/qwen3.6-plus": { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 5e-08, + "cache_creation_input_token_cost": 6.25e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/gpt-5.6-luna": { + "input_cost_per_token": 1e-07, + "output_cost_per_token": 6e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "cache_read_input_token_cost": 1e-08, + "cache_creation_input_token_cost": 1.25e-07, + "mode": "responses", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/minimax-m2.5": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 204800, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3e-08, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/minimax-m2.7": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "cache_read_input_token_cost": 6e-08, + "cache_creation_input_token_cost": 6e-08, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/minimax-m3": { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "cache_read_input_token_cost": 6e-08, + "cache_creation_input_token_cost": 6e-08, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/qwen3.5-plus": { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1.2e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 2e-08, + "cache_creation_input_token_cost": 2.5e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/qwen3.6-plus": { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 5e-08, + "cache_creation_input_token_cost": 6.25e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/qwen3.7-max": { + "input_cost_per_token": 2.5e-06, + "output_cost_per_token": 7.5e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 3.125e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/qwen3.7-plus": { + "input_cost_per_token": 4e-07, + "output_cost_per_token": 1.6e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "cache_read_input_token_cost": 4e-08, + "cache_creation_input_token_cost": 5e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/qwen3.8-max": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 6e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "cache_read_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost": 2.5e-06, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true + }, + "opencode_go/deepseek-v4-flash": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 2.8e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.8e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/deepseek-v4-pro": { + "input_cost_per_token": 1.74e-06, + "output_cost_per_token": 3.84e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.45e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/glm-5": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3.2000000000000003e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.0000000000000002e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/glm-5.1": { + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/glm-5.2": { + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/grok-4.5": { + "input_cost_per_token": 2e-06, + "output_cost_per_token": 6e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 5e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/kimi-k2.5": { + "input_cost_per_token": 6e-07, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 8e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/kimi-k2.6": { + "input_cost_per_token": 9.499999999999999e-07, + "output_cost_per_token": 4e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.6e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/kimi-k2.7-code": { + "input_cost_per_token": 9.499999999999999e-07, + "output_cost_per_token": 4e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 1.9e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/kimi-k3": { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "litellm_provider": "opencode_go", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/hy3": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 5.8e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 256000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 3.5e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/mimo-v2.5": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 2.8e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2.8e-09, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/mimo-v2.5-pro": { + "input_cost_per_token": 4.35e-07, + "output_cost_per_token": 8.7e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 1048576, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 3.625e-09, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/mimo-v2-omni": { + "input_cost_per_token": 4e-07, + "output_cost_per_token": 2e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 262144, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 8e-08, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/mimo-v2-pro": { + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3e-06, + "litellm_provider": "opencode_go", + "max_input_tokens": 1048576, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 2e-07, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_zen/ling-3.0-tiny-free": { + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + "litellm_provider": "opencode_zen", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "cache_read_input_token_cost": 0.0, + "cache_creation_input_token_cost": 0.0 + }, + "opencode_go/qwen3.8-flash": { + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 4.7e-07, + "litellm_provider": "opencode_go", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "cache_read_input_token_cost": 1.6e-08, + "cache_creation_input_token_cost": 2e-07, + "mode": "messages", + "supports_function_calling": true, + "supports_vision": false, + "supports_system_messages": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true } } diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index d1ac3e67b2b..6e3f521d346 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -414,6 +414,7 @@ "guardrail", "image_edit", "image_generation", + "messages", "moderation", "ocr", "realtime", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index c71f4a82a4a..20ec63b1146 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -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", diff --git a/tests/test_litellm/llms/opencode/test_opencode_chat.py b/tests/test_litellm/llms/opencode/test_opencode_chat.py new file mode 100644 index 00000000000..219c1bd68b2 --- /dev/null +++ b/tests/test_litellm/llms/opencode/test_opencode_chat.py @@ -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/ 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 diff --git a/tests/test_litellm/llms/opencode/test_opencode_go_responses.py b/tests/test_litellm/llms/opencode/test_opencode_go_responses.py new file mode 100644 index 00000000000..cc4a5b275b8 --- /dev/null +++ b/tests/test_litellm/llms/opencode/test_opencode_go_responses.py @@ -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']}" diff --git a/tests/test_litellm/llms/opencode/test_opencode_messages.py b/tests/test_litellm/llms/opencode/test_opencode_messages.py new file mode 100644 index 00000000000..ebaf63b665e --- /dev/null +++ b/tests/test_litellm/llms/opencode/test_opencode_messages.py @@ -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 diff --git a/tests/test_litellm/llms/opencode/test_opencode_responses.py b/tests/test_litellm/llms/opencode/test_opencode_responses.py new file mode 100644 index 00000000000..be2a4193614 --- /dev/null +++ b/tests/test_litellm/llms/opencode/test_opencode_responses.py @@ -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/ 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 diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index 0d82ed778f5..a14714699b2 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -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) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index fc4207fb26b..ee2992aad52 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -1071,6 +1071,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "embedding", "guardrail", "image_generation", + "messages", "video_generation", "moderation", "rerank", diff --git a/ui/litellm-dashboard/public/assets/logos/opencode.svg b/ui/litellm-dashboard/public/assets/logos/opencode.svg new file mode 100644 index 00000000000..8aeddf2c9f4 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/opencode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 4c68e302267..3b7107d0784 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -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/"); }); + 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" }, diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index de83cd00790..50d3d7958e3 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -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 = { 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> = { [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> = { [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",