From aa95b0d4657da014d06afad7e0413e9e49ee3ab5 Mon Sep 17 00:00:00 2001 From: Jonathan Singer Date: Tue, 25 Aug 2026 01:59:30 -0400 Subject: [PATCH] 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. --- strix/config/models.py | 31 +++++- strix/config/opencode.py | 97 +++++++++++++++--- strix/core/inputs.py | 9 +- strix/interface/environment.py | 7 +- strix/interface/utils.py | 32 +++++- .../frontend/src/components/RunDetails.tsx | 30 +++++- strix/report/state.py | 8 +- tests/test_cli_target_list.py | 5 +- tests/test_inputs.py | 18 +++- tests/test_opencode_auth.py | 99 +++++++++++++++++-- 10 files changed, 289 insertions(+), 47 deletions(-) diff --git a/strix/config/models.py b/strix/config/models.py index 8fe43e21..709ef267 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -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 diff --git a/strix/config/opencode.py b/strix/config/opencode.py index f7de0fe4..980d3c93 100644 --- a/strix/config/opencode.py +++ b/strix/config/opencode.py @@ -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): diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 1123be43..96676e39 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -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 diff --git a/strix/interface/environment.py b/strix/interface/environment.py index fb2d2d2b..ed01dc82 100644 --- a/strix/interface/environment.py +++ b/strix/interface/environment.py @@ -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: diff --git a/strix/interface/utils.py b/strix/interface/utils.py index a4fb55f8..f2d784d7 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -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") diff --git a/strix/interface/viewer/frontend/src/components/RunDetails.tsx b/strix/interface/viewer/frontend/src/components/RunDetails.tsx index c169d0de..33daf4a0 100644 --- a/strix/interface/viewer/frontend/src/components/RunDetails.tsx +++ b/strix/interface/viewer/frontend/src/components/RunDetails.tsx @@ -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) => ( ({formatNumber(n)} {word}) @@ -205,7 +217,21 @@ export function RunDetails({ )} {totalTokens != null && {formatNumber(totalTokens)}} - {subscription ? ( + {subscription && metered ? ( + + {cost != null && cost > 0 ? ( + <> + ${cost.toFixed(2)} + (Zen credits) + + ) : ( + <> + credits + (not priced locally) + + )} + + ) : subscription ? ( $0.00 (subscription) diff --git a/strix/report/state.py b/strix/report/state.py index 3ed2154c..a526fdec 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -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(), } diff --git a/tests/test_cli_target_list.py b/tests/test_cli_target_list.py index 6ba4ca22..d20230b1 100644 --- a/tests/test_cli_target_list.py +++ b/tests/test_cli_target_list.py @@ -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) diff --git a/tests/test_inputs.py b/tests/test_inputs.py index a99a3a47..365a87d7 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -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: diff --git a/tests/test_opencode_auth.py b/tests/test_opencode_auth.py index 3c120cd4..d05c12e8 100644 --- a/tests/test_opencode_auth.py +++ b/tests/test_opencode_auth.py @@ -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(