Run Claude on OpenCode's Anthropic endpoint, and name the plan in the UI

Claude models are served on /messages, which the OpenAI SDK can't speak, so
those runs go through LiteLLM's Anthropic route instead. Prompt caching moves
with them, since LiteLLM consumes the injection points the raw SDK rejects.

Zen and Go now show up by name instead of both reading 'OpenCode
subscription', and Zen keeps its cost tracked: it bills prepaid credits per
request, so those runs were never actually free.
This commit is contained in:
Jonathan Singer 2026-08-25 01:59:30 -04:00 committed by yoni
parent 8ed2433574
commit aa95b0d465
10 changed files with 289 additions and 47 deletions

View file

@ -537,12 +537,28 @@ class StrixProvider(MultiProvider):
codex.get_subscription_client(),
reasoning_effort=llm.reasoning_effort,
)
elif oc and oc.uses_responses:
elif oc and oc.protocol == opencode.PROTOCOL_RESPONSES:
model = _CodexResponsesModel(
oc.slug,
opencode.get_subscription_client(oc.base_url),
reasoning_effort=llm.reasoning_effort,
)
elif oc and oc.protocol == opencode.PROTOCOL_MESSAGES:
# Claude models are served on Anthropic's ``/messages``, which the
# OpenAI SDK cannot speak: it has no Messages method and sends the
# key as a bearer token rather than ``x-api-key``. LiteLLM's
# Anthropic route handles both, so the gateway becomes an Anthropic
# base URL with the subscription key.
from agents.extensions.models.litellm_model import LitellmModel
model = LitellmModel(
model=f"anthropic/{oc.slug}",
base_url=oc.messages_url,
api_key=opencode.get_api_key(),
)
if llm.disable_streaming:
model = _NonStreamingModel(model)
idle_timeout = 0.0
elif oc:
model = OpenAIChatCompletionsModel(
oc.slug, opencode.get_subscription_client(oc.base_url)
@ -634,7 +650,14 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
"""Apply Strix config to SDK-native defaults."""
llm = settings.llm
set_tracing_disabled(True)
if codex.subscription_model(llm.model) or opencode.subscription_model(llm.model):
oc = opencode.subscription_model(llm.model)
if codex.subscription_model(llm.model) or oc:
# A subscription run carries its own client and credentials, so none of
# the api_key/api_base defaults below apply. The Anthropic route is the
# exception: it goes through LiteLLM, which still needs the
# compatibility flags and the cost callback.
if oc is not None and oc.protocol == opencode.PROTOCOL_MESSAGES:
_configure_litellm_compatibility()
return
_configure_litellm_compatibility()
_configure_openrouter_attribution(llm.model)
@ -821,7 +844,9 @@ def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bo
return False
oc = opencode.subscription_model(model_name)
if oc:
return not oc.uses_responses
# Chat Completions takes JSON function tools; so does the LiteLLM
# Anthropic route, which translates them to Anthropic tool blocks.
return oc.protocol != opencode.PROTOCOL_RESPONSES
model = model_name.strip().lower()
if "/" in model and not model.startswith("openai/"):
return True

View file

@ -1,15 +1,18 @@
"""OpenCode subscription auth: API-key sign-in and the OpenAI clients that
route inference through the OpenCode gateway.
"""OpenCode subscription auth: API-key sign-in and the clients that route
inference through the OpenCode gateway.
Covers both OpenCode offerings Zen (pay-as-you-go credits) and Go (the
monthly subscription) which share one account and API key but live behind
Covers both OpenCode offerings, Zen (pay-as-you-go credits) and Go (the
monthly subscription), which share one account and API key but live behind
different gateway base URLs. Unlike the ChatGPT subscription there is no
OAuth: the user copies a plain API key from https://opencode.ai/auth, and
using the gateway from other agents is officially supported.
Model routing follows the endpoint each model is served on (see
https://opencode.ai/docs/zen/): GPT models use the Responses API, everything
else the OpenAI-compatible Chat Completions API.
The gateway speaks three protocols and serves each model family on exactly
one of them (see https://opencode.ai/docs/zen/), answering a request sent to
the wrong one with an unhandled 500 rather than a 404. ``_protocol()`` holds
the mapping; ``SubscriptionModel.protocol`` carries the result. Claude runs on
Anthropic's ``/messages``, which the OpenAI SDK cannot speak, so that route
goes through LiteLLM instead of the clients built here.
"""
from __future__ import annotations
@ -45,31 +48,85 @@ class OpencodeAuthError(Exception):
super().__init__(message or code)
PROTOCOL_CHAT = "chat"
PROTOCOL_RESPONSES = "responses"
PROTOCOL_MESSAGES = "messages"
PLAN_ZEN = "zen"
PLAN_GO = "go"
_PLAN_LABELS = {PLAN_ZEN: "OpenCode Zen", PLAN_GO: "OpenCode Go"}
@dataclass(frozen=True)
class SubscriptionModel:
slug: str
base_url: str
uses_responses: bool
protocol: str
plan: str
@property
def uses_responses(self) -> bool:
return self.protocol == PROTOCOL_RESPONSES
@property
def messages_url(self) -> str:
"""Anthropic-protocol endpoint for this gateway, e.g. ``.../zen/v1/messages``."""
return f"{self.base_url}/messages"
@property
def label(self) -> str:
return _PLAN_LABELS[self.plan]
@property
def metered(self) -> bool:
"""Whether a run spends money per request.
Zen bills prepaid credits per request, so its runs cost real money and
must not be reported as free. Go is a flat monthly fee, where a run's
marginal cost genuinely is zero.
"""
return self.plan == PLAN_ZEN
def _uses_responses(slug: str, base_url: str) -> bool:
def _protocol(slug: str, base_url: str) -> str:
"""Which wire protocol the gateway serves *slug* on.
The gateway routes by model family and answers a request sent to the wrong
protocol with an unhandled 500 rather than a 404, so the mapping has to be
right. Probed against both gateways per family:
* Claude on Anthropic's ``/messages``
* GPT, Grok (Zen) and Muse on OpenAI's ``/responses``
* DeepSeek, MiniMax, Kimi, GLM and Qwen on Chat Completions
Grok is absent from the Go catalog, so its Zen-only Responses route costs
nothing there. Kimi and Qwen also answer on ``/messages``, but Chat
Completions works for them on both plans and stays the single mapping.
"""
lowered = slug.lower()
if lowered.startswith("gpt-"):
return True
# Grok is served via Responses on Zen but Chat Completions on Go.
return lowered.startswith("grok") and base_url == ZEN_BASE_URL
if lowered.startswith("claude-"):
return PROTOCOL_MESSAGES
if lowered.startswith(("gpt-", "muse-")):
return PROTOCOL_RESPONSES
if lowered.startswith("grok") and base_url == ZEN_BASE_URL:
return PROTOCOL_RESPONSES
return PROTOCOL_CHAT
def subscription_model(model_name: str | None) -> SubscriptionModel | None:
"""The gateway model behind an ``opencode/`` or ``opencode-go/`` STRIX_LLM."""
name = (model_name or "").strip()
lowered = name.lower()
for prefix, base_url in ((GO_PREFIX, GO_BASE_URL), (ZEN_PREFIX, ZEN_BASE_URL)):
for prefix, base_url, plan in (
(GO_PREFIX, GO_BASE_URL, PLAN_GO),
(ZEN_PREFIX, ZEN_BASE_URL, PLAN_ZEN),
):
if lowered.startswith(prefix):
slug = name[len(prefix) :]
if not slug:
return None
return SubscriptionModel(slug, base_url, _uses_responses(slug, base_url))
return SubscriptionModel(slug, base_url, _protocol(slug, base_url), plan)
return None
@ -149,6 +206,16 @@ def auth_mode(model_name: str | None) -> str:
return "api_key"
def subscription_plan(model_name: str | None) -> str | None:
"""Which OpenCode plan STRIX_LLM runs on: "zen", "go", or None.
Recorded alongside ``subscription_provider`` rather than folded into it, so
consumers that compare the provider against "opencode" keep working.
"""
oc = subscription_model(model_name)
return oc.plan if oc else None
def subscription_provider(model_name: str | None) -> str | None:
"""The subscription behind STRIX_LLM: "opencode", "chatgpt", or None."""
if subscription_model(model_name):

View file

@ -326,9 +326,12 @@ def _prompt_cache_extra_args(model_name: str) -> dict[str, Any] | None:
"""
if not is_claude_model(model_name) or not routes_through_litellm(model_name):
return None
# OpenCode routes use the raw OpenAI SDK, which rejects this LiteLLM-only
# argument; the gateway applies Anthropic prompt caching itself.
if opencode.subscription_model(model_name):
# OpenCode's Chat Completions and Responses routes use the raw OpenAI SDK,
# which rejects this LiteLLM-only argument. Its Anthropic route does go
# through LiteLLM, so the injection points apply there as they would for a
# direct Anthropic key.
oc = opencode.subscription_model(model_name)
if oc is not None and oc.protocol != opencode.PROTOCOL_MESSAGES:
return None
if is_bedrock_route(model_name) and not bedrock_route_supports_prompt_caching(model_name):
return None

View file

@ -37,14 +37,15 @@ def validate_environment() -> None:
logger.info("Environment OK (ChatGPT subscription)")
return
if opencode.subscription_model(settings.llm.model):
oc = opencode.subscription_model(settings.llm.model)
if oc:
if not opencode.is_authenticated():
console.print(
f"[red]STRIX_LLM={settings.llm.model} uses your OpenCode subscription, "
f"[red]STRIX_LLM={settings.llm.model} runs on {oc.label}, "
"but you're not signed in.[/] Run [cyan]strix auth login opencode[/] first."
)
sys.exit(1)
logger.info("Environment OK (OpenCode subscription)")
logger.info("Environment OK (%s)", oc.label)
return
if not settings.llm.model:

View file

@ -290,12 +290,20 @@ def subscription_label() -> str:
"""Display name of the subscription behind the configured model."""
from strix.config import opencode
model = load_settings().llm.model
if opencode.subscription_model(model):
return "OpenCode subscription"
oc = opencode.subscription_model(load_settings().llm.model)
if oc:
return oc.label
return "ChatGPT subscription"
def subscription_is_metered() -> bool:
"""Whether the run spends per-request credits rather than a flat plan."""
from strix.config import opencode
oc = opencode.subscription_model(load_settings().llm.model)
return oc is not None and oc.metered
def _int_stat(usage: dict[str, Any], key: str) -> int:
try:
return max(0, int(usage.get(key) or 0))
@ -336,7 +344,9 @@ def _build_llm_usage_stats(
if not usage or _int_stat(usage, "requests") <= 0:
stats_text.append("\n")
stats_text.append("Cost ", style="dim")
if subscription:
if subscription and subscription_is_metered():
stats_text.append("credits ", style="#22c55e")
elif subscription:
stats_text.append("$0.00 ", style="#22c55e")
stats_text.append("(subscription) ", style="dim")
else:
@ -365,7 +375,19 @@ def _build_llm_usage_stats(
stats_text.append("Output Tokens ", style="dim")
stats_text.append(format_token_count(output_tokens), style="white")
if subscription:
if subscription and subscription_is_metered():
# Zen spends prepaid credits per request, so a run is not free. Its
# Anthropic route runs through LiteLLM and yields a real charge; the
# OpenAI-SDK routes report none, and an unpriced run says so rather
# than claiming $0.00.
stats_text.append(" · ", style="dim white")
stats_text.append("Cost ", style="dim")
if cost > 0:
stats_text.append(f"${cost:.4f}", style="#22c55e")
stats_text.append(" (credits)", style="dim")
else:
stats_text.append("credits", style="#22c55e")
elif subscription:
stats_text.append(" · ", style="dim white")
stats_text.append("Cost ", style="dim")
stats_text.append("$0.00", style="#22c55e")

View file

@ -104,8 +104,20 @@ export function RunDetails({
const subscriptionProvider =
str(raw.subscription_provider) ??
(models.some((m) => m.toLowerCase().startsWith("opencode")) ? "opencode" : "chatgpt");
// Runs recorded before subscription_plan existed still carry the model string,
// whose prefix names the plan.
const subscriptionPlan =
str(raw.subscription_plan) ??
(models.some((m) => m.toLowerCase().startsWith("opencode-go/")) ? "go" : "zen");
const subscriptionLabel =
subscriptionProvider === "opencode" ? "OpenCode subscription" : "ChatGPT subscription";
subscriptionProvider === "opencode"
? subscriptionPlan === "go"
? "OpenCode Go"
: "OpenCode Zen"
: "ChatGPT subscription";
// Zen bills prepaid credits per request, so its runs are not free and there is
// no price table to estimate them from. Go is a flat monthly plan.
const metered = subscriptionProvider === "opencode" && subscriptionPlan === "zen";
const sub = (n: number, word: string) => (
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
@ -205,7 +217,21 @@ export function RunDetails({
</Field>
)}
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
{subscription ? (
{subscription && metered ? (
<Field label="Cost">
{cost != null && cost > 0 ? (
<>
<span className="text-[#22c55e]">${cost.toFixed(2)}</span>
<span className="text-[#666]"> (Zen credits)</span>
</>
) : (
<>
<span className="text-[#22c55e]">credits</span>
<span className="text-[#666]"> (not priced locally)</span>
</>
)}
</Field>
) : subscription ? (
<Field label="Cost">
<span className="text-[#22c55e]">$0.00</span>
<span className="text-[#666]"> (subscription)</span>

View file

@ -153,7 +153,12 @@ class ReportState:
self._llm_usage = LLMUsageLedger()
self._telemetry_llm_usage_baseline: dict[str, Any] = {}
auth_mode = opencode.auth_mode(load_settings().llm.model)
self._llm_usage.zero_cost = auth_mode == "subscription"
oc = opencode.subscription_model(load_settings().llm.model)
# A flat subscription has no per-run charge to report. Zen bills prepaid
# credits per request, so its cost is real and stays tracked.
self._llm_usage.zero_cost = auth_mode == "subscription" and not (
oc is not None and oc.metered
)
self.run_record: dict[str, Any] = {
"run_id": self.run_id,
"run_name": self.run_name,
@ -162,6 +167,7 @@ class ReportState:
"status": "running",
"auth_mode": auth_mode,
"subscription_provider": opencode.subscription_provider(load_settings().llm.model),
"subscription_plan": opencode.subscription_plan(load_settings().llm.model),
"targets_info": [],
"llm_usage": self._build_llm_usage_record(),
}

View file

@ -228,7 +228,10 @@ def test_resume_still_requires_targets_or_a_workspace(
assert "has no targets_info" in capsys.readouterr().err
def test_resume_non_object_run_json_exits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
def test_resume_non_object_run_json_exits(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.chdir(tmp_path)
run_dir = tmp_path / "strix_runs" / "pentest_abcd"
run_dir.mkdir(parents=True)

View file

@ -123,10 +123,20 @@ def test_make_model_settings_no_prompt_cache_for_non_claude(model_name: str) ->
@pytest.mark.parametrize("model_name", ["opencode/claude-sonnet-5", "opencode-go/claude-sonnet-5"])
def test_no_prompt_cache_for_opencode_claude(model_name: str) -> None:
# The OpenCode route uses the raw OpenAI SDK, whose create() rejects the
# LiteLLM-only cache_control_injection_points argument.
assert _cache_points(model_name) is None
def test_prompt_cache_for_opencode_claude(model_name: str) -> None:
# Claude on OpenCode runs through LiteLLM's Anthropic route, which consumes
# cache_control_injection_points. The gateway's other two routes use the raw
# OpenAI SDK, whose create() rejects this LiteLLM-only argument.
assert _cache_points(model_name) == [
{"location": "message", "role": "system"},
{"location": "message", "index": -1},
]
def test_no_prompt_cache_for_opencode_openai_routes() -> None:
# A "claude" substring cannot smuggle the LiteLLM-only argument onto a route
# that is served by the raw OpenAI SDK.
assert _cache_points("opencode/gpt-5.4-claude-tuned") is None
def test_no_prompt_cache_for_unmapped_bedrock_claude_model(monkeypatch: Any) -> None:

View file

@ -23,25 +23,104 @@ def _tmp_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
@pytest.mark.parametrize(
("model", "slug", "base_url", "uses_responses"),
("model", "slug", "base_url", "protocol"),
[
("opencode/claude-sonnet-5", "claude-sonnet-5", opencode.ZEN_BASE_URL, False),
("opencode/gpt-5.4", "gpt-5.4", opencode.ZEN_BASE_URL, True),
("opencode/grok-4.5", "grok-4.5", opencode.ZEN_BASE_URL, True),
("OpenCode/Kimi-K3", "Kimi-K3", opencode.ZEN_BASE_URL, False),
("opencode-go/kimi-k3", "kimi-k3", opencode.GO_BASE_URL, False),
("opencode-go/gpt-5.6-luna", "gpt-5.6-luna", opencode.GO_BASE_URL, True),
("opencode-go/grok-4.5", "grok-4.5", opencode.GO_BASE_URL, False),
(
"opencode/claude-sonnet-5",
"claude-sonnet-5",
opencode.ZEN_BASE_URL,
opencode.PROTOCOL_MESSAGES,
),
("opencode/gpt-5.4", "gpt-5.4", opencode.ZEN_BASE_URL, opencode.PROTOCOL_RESPONSES),
("opencode/grok-4.5", "grok-4.5", opencode.ZEN_BASE_URL, opencode.PROTOCOL_RESPONSES),
("OpenCode/Kimi-K3", "Kimi-K3", opencode.ZEN_BASE_URL, opencode.PROTOCOL_CHAT),
(
"OpenCode/Claude-Opus-5",
"Claude-Opus-5",
opencode.ZEN_BASE_URL,
opencode.PROTOCOL_MESSAGES,
),
("opencode-go/kimi-k3", "kimi-k3", opencode.GO_BASE_URL, opencode.PROTOCOL_CHAT),
(
"opencode-go/gpt-5.6-luna",
"gpt-5.6-luna",
opencode.GO_BASE_URL,
opencode.PROTOCOL_RESPONSES,
),
("opencode-go/grok-4.5", "grok-4.5", opencode.GO_BASE_URL, opencode.PROTOCOL_CHAT),
# Probed per family against both gateways; a wrong protocol 500s.
(
"opencode/muse-spark-1.2",
"muse-spark-1.2",
opencode.ZEN_BASE_URL,
opencode.PROTOCOL_RESPONSES,
),
(
"opencode/deepseek-v4-pro",
"deepseek-v4-pro",
opencode.ZEN_BASE_URL,
opencode.PROTOCOL_CHAT,
),
("opencode/minimax-m3", "minimax-m3", opencode.ZEN_BASE_URL, opencode.PROTOCOL_CHAT),
("opencode/qwen3.6-plus", "qwen3.6-plus", opencode.ZEN_BASE_URL, opencode.PROTOCOL_CHAT),
("opencode/glm-5.2", "glm-5.2", opencode.ZEN_BASE_URL, opencode.PROTOCOL_CHAT),
("opencode/grok-4.6", "grok-4.6", opencode.ZEN_BASE_URL, opencode.PROTOCOL_RESPONSES),
(
"opencode/gpt-5.6-luna",
"gpt-5.6-luna",
opencode.ZEN_BASE_URL,
opencode.PROTOCOL_RESPONSES,
),
(
"opencode/claude-opus-5",
"claude-opus-5",
opencode.ZEN_BASE_URL,
opencode.PROTOCOL_MESSAGES,
),
],
)
def test_subscription_model_parses_prefixes(
model: str, slug: str, base_url: str, uses_responses: bool
model: str, slug: str, base_url: str, protocol: str
) -> None:
parsed = opencode.subscription_model(model)
assert parsed is not None
assert parsed.slug == slug
assert parsed.base_url == base_url
assert parsed.uses_responses == uses_responses
assert parsed.protocol == protocol
assert parsed.uses_responses is (protocol == opencode.PROTOCOL_RESPONSES)
def test_claude_route_targets_the_anthropic_endpoint() -> None:
parsed = opencode.subscription_model("opencode/claude-sonnet-5")
assert parsed is not None
assert parsed.messages_url == "https://opencode.ai/zen/v1/messages"
@pytest.mark.parametrize(
("model", "plan", "label", "metered"),
[
("opencode/claude-sonnet-5", opencode.PLAN_ZEN, "OpenCode Zen", True),
("opencode/kimi-k3", opencode.PLAN_ZEN, "OpenCode Zen", True),
("opencode-go/kimi-k3", opencode.PLAN_GO, "OpenCode Go", False),
("OpenCode-Go/GPT-5.6-Luna", opencode.PLAN_GO, "OpenCode Go", False),
],
)
def test_plan_is_labelled_and_metered_per_prefix(
model: str, plan: str, label: str, metered: bool
) -> None:
parsed = opencode.subscription_model(model)
assert parsed is not None
assert parsed.plan == plan
assert parsed.label == label
# Zen bills prepaid credits per request; Go is a flat monthly plan.
assert parsed.metered is metered
assert opencode.subscription_plan(model) == plan
def test_subscription_plan_is_none_off_opencode() -> None:
assert opencode.subscription_plan("chatgpt/gpt-5.4") is None
assert opencode.subscription_plan("anthropic/claude-sonnet-5") is None
assert opencode.subscription_plan(None) is None
@pytest.mark.parametrize(