mirror of
https://github.com/usestrix/strix.git
synced 2026-09-07 08:25:56 +00:00
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:
parent
8ed2433574
commit
aa95b0d465
10 changed files with 289 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue