From e51bc13335050c5b082d31c3a940112665dfbd87 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Wed, 8 Jul 2026 00:13:30 -0400 Subject: [PATCH 1/2] fix: use Anthropic protocol for TokenRouter Claude Refs #426 Signed-off-by: Shuxin Lin --- src/agent/opencode_agent/cli.py | 2 +- src/agent/opencode_agent/runner.py | 12 +++++++++--- src/agent/opencode_agent/tests/test_runner.py | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/agent/opencode_agent/cli.py b/src/agent/opencode_agent/cli.py index ddd91206..ef4ca6ad 100644 --- a/src/agent/opencode_agent/cli.py +++ b/src/agent/opencode_agent/cli.py @@ -35,7 +35,7 @@ def _build_parser() -> argparse.ArgumentParser: LITELLM_API_KEY LiteLLM router key for litellm_proxy/* models LITELLM_BASE_URL LiteLLM OpenAI-compatible base URL TOKENROUTER_API_KEY TokenRouter key for tokenrouter/* models - TOKENROUTER_BASE_URL TokenRouter OpenAI-compatible base URL + TOKENROUTER_BASE_URL TokenRouter base URL; Claude models use Anthropic protocol examples: opencode-agent "What assets are at site MAIN?" diff --git a/src/agent/opencode_agent/runner.py b/src/agent/opencode_agent/runner.py index 9df56973..18f0f149 100644 --- a/src/agent/opencode_agent/runner.py +++ b/src/agent/opencode_agent/runner.py @@ -115,8 +115,9 @@ def _resolve_opencode_model_and_provider( """Translate AssetOpsBench router model IDs into OpenCode config. OpenCode wants ``provider/model``. For AssetOpsBench router prefixes such - as ``litellm_proxy/`` and ``tokenrouter/``, declare a custom - OpenAI-compatible provider and register the requested model explicitly. + as ``litellm_proxy/`` and ``tokenrouter/``, declare a custom provider and + register the requested model explicitly. TokenRouter Claude models need the + Anthropic protocol so OpenCode preserves native Anthropic message handling. """ creds = resolve_router_creds(model_id, strict=True) if creds is None: @@ -126,9 +127,14 @@ def _resolve_opencode_model_and_provider( provider_name = "TokenRouter" if provider_id == "tokenrouter" else "LiteLLM Proxy" model_name = resolve_model(model_id) opencode_model = f"{provider_id}/{model_name}" + provider_npm = ( + "@ai-sdk/anthropic" + if provider_id == "tokenrouter" and model_name.startswith("anthropic/") + else "@ai-sdk/openai-compatible" + ) provider = { provider_id: { - "npm": "@ai-sdk/openai-compatible", + "npm": provider_npm, "name": provider_name, "options": { "baseURL": creds.base_url, diff --git a/src/agent/opencode_agent/tests/test_runner.py b/src/agent/opencode_agent/tests/test_runner.py index f6c8e6a3..9ca14850 100644 --- a/src/agent/opencode_agent/tests/test_runner.py +++ b/src/agent/opencode_agent/tests/test_runner.py @@ -123,6 +123,22 @@ def test_resolve_tokenrouter_model(monkeypatch): assert env["ASSETOPSBENCH_OPENCODE_API_KEY"] == "tr-test" # pragma: allowlist secret +def test_resolve_tokenrouter_anthropic_model(monkeypatch): + monkeypatch.setenv("TOKENROUTER_BASE_URL", "https://router.example/v1") + monkeypatch.setenv("TOKENROUTER_API_KEY", "tr-test") + model, provider, env = _resolve_opencode_model_and_provider( + "tokenrouter/anthropic/claude-opus-4.8" + ) + assert model == "tokenrouter/anthropic/claude-opus-4.8" + assert provider["tokenrouter"]["npm"] == "@ai-sdk/anthropic" + assert provider["tokenrouter"]["options"]["baseURL"] == "https://router.example/v1" + assert ( + provider["tokenrouter"]["models"]["anthropic/claude-opus-4.8"]["name"] + == "anthropic/claude-opus-4.8" + ) + assert env["ASSETOPSBENCH_OPENCODE_API_KEY"] == "tr-test" # pragma: allowlist secret + + def test_build_opencode_config_includes_agent_and_mcp(): config, env, opencode_model = _build_opencode_config( model="opencode/gpt-5", From c87300ed7155130feee8d2882f5a667102404b53 Mon Sep 17 00:00:00 2001 From: Shuxin Lin Date: Wed, 8 Jul 2026 00:17:51 -0400 Subject: [PATCH 2/2] chore: format opencode protocol tests Signed-off-by: Shuxin Lin --- src/agent/opencode_agent/runner.py | 11 +++-- src/agent/opencode_agent/tests/test_runner.py | 46 +++++++++++-------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/src/agent/opencode_agent/runner.py b/src/agent/opencode_agent/runner.py index 18f0f149..f69d8692 100644 --- a/src/agent/opencode_agent/runner.py +++ b/src/agent/opencode_agent/runner.py @@ -401,7 +401,9 @@ def _merge_text(existing: str, new: str) -> str: return existing + new -def _is_step_finish(event: dict[str, Any], part: dict[str, Any], part_type: str) -> bool: +def _is_step_finish( + event: dict[str, Any], part: dict[str, Any], part_type: str +) -> bool: """True for OpenCode step-finish boundaries.""" event_type = str(event.get("type") or "").lower() return ("step" in part_type and "finish" in part_type) or ( @@ -549,10 +551,9 @@ def _close_step(input_tokens: int, output_tokens: int) -> None: output_tokens=step["output_tokens"], ) ) - if ( - sum(step["input_tokens"] + step["output_tokens"] for step in steps) == 0 - and (total_input or total_output) - ): + if sum( + step["input_tokens"] + step["output_tokens"] for step in steps + ) == 0 and (total_input or total_output): trajectory.turns[-1].input_tokens = total_input trajectory.turns[-1].output_tokens = total_output trajectory.turns[-1].duration_ms = duration_ms diff --git a/src/agent/opencode_agent/tests/test_runner.py b/src/agent/opencode_agent/tests/test_runner.py index 9ca14850..b823dd88 100644 --- a/src/agent/opencode_agent/tests/test_runner.py +++ b/src/agent/opencode_agent/tests/test_runner.py @@ -108,19 +108,27 @@ def test_resolve_litellm_model(monkeypatch): assert model == "litellm-proxy/azure/gpt-5.4" assert provider["litellm-proxy"]["npm"] == "@ai-sdk/openai-compatible" assert provider["litellm-proxy"]["options"]["baseURL"] == "http://localhost:4000" - assert provider["litellm-proxy"]["models"]["azure/gpt-5.4"]["name"] == "azure/gpt-5.4" - assert env["ASSETOPSBENCH_OPENCODE_API_KEY"] == "sk-test" # pragma: allowlist secret + assert ( + provider["litellm-proxy"]["models"]["azure/gpt-5.4"]["name"] == "azure/gpt-5.4" + ) + assert ( + env["ASSETOPSBENCH_OPENCODE_API_KEY"] == "sk-test" + ) # pragma: allowlist secret def test_resolve_tokenrouter_model(monkeypatch): monkeypatch.setenv("TOKENROUTER_BASE_URL", "https://router.example/v1") monkeypatch.setenv("TOKENROUTER_API_KEY", "tr-test") - model, provider, env = _resolve_opencode_model_and_provider("tokenrouter/MiniMax-M3") + model, provider, env = _resolve_opencode_model_and_provider( + "tokenrouter/MiniMax-M3" + ) assert model == "tokenrouter/MiniMax-M3" assert provider["tokenrouter"]["npm"] == "@ai-sdk/openai-compatible" assert provider["tokenrouter"]["options"]["baseURL"] == "https://router.example/v1" assert provider["tokenrouter"]["models"]["MiniMax-M3"]["name"] == "MiniMax-M3" - assert env["ASSETOPSBENCH_OPENCODE_API_KEY"] == "tr-test" # pragma: allowlist secret + assert ( + env["ASSETOPSBENCH_OPENCODE_API_KEY"] == "tr-test" + ) # pragma: allowlist secret def test_resolve_tokenrouter_anthropic_model(monkeypatch): @@ -136,7 +144,9 @@ def test_resolve_tokenrouter_anthropic_model(monkeypatch): provider["tokenrouter"]["models"]["anthropic/claude-opus-4.8"]["name"] == "anthropic/claude-opus-4.8" ) - assert env["ASSETOPSBENCH_OPENCODE_API_KEY"] == "tr-test" # pragma: allowlist secret + assert ( + env["ASSETOPSBENCH_OPENCODE_API_KEY"] == "tr-test" + ) # pragma: allowlist secret def test_build_opencode_config_includes_agent_and_mcp(): @@ -255,13 +265,14 @@ def test_runner_workspace_mode(tmp_path): assert runner._run_dir == workspace.resolve() assert runner._config["agent"]["assetops"]["permission"]["read"] == "allow" + def _text_part(part_id, text, *, message_id=None, part_type="text"): part = {"id": part_id, "type": part_type, "text": text} if message_id is not None: part["messageID"] = message_id return {"type": "message.part.updated", "properties": {"part": part}} - - + + def _tool_part(part_id, tool, *, input=None, output=None): return { "type": "message.part.updated", @@ -275,8 +286,8 @@ def _tool_part(part_id, tool, *, input=None, output=None): } }, } - - + + def _step_finish(input_tokens, output_tokens): return { "type": "step_finish", @@ -285,8 +296,8 @@ def _step_finish(input_tokens, output_tokens): "tokens": {"input": input_tokens, "output": output_tokens}, }, } - - + + def test_answer_excludes_intermediate_narration(): """Only the final assistant message is the answer, not scratch talk.""" events = [ @@ -302,8 +313,8 @@ def test_answer_excludes_intermediate_narration(): assert len(trajectory.all_tool_calls) == 1 assert trajectory.total_input_tokens == 110 assert trajectory.total_output_tokens == 13 - - + + def test_answer_excludes_reasoning_parts(): events = [ _text_part("r1", "(internal) suspect bearing wear", part_type="reasoning"), @@ -311,8 +322,8 @@ def test_answer_excludes_reasoning_parts(): ] answer, _ = _build_trajectory_from_events(events, []) assert answer == "The pump is healthy." - - + + def test_parallel_tool_calls_keep_their_own_outputs(): """Two tool calls emitted before their outputs must not be mismatched.""" events = [ @@ -324,8 +335,8 @@ def test_parallel_tool_calls_keep_their_own_outputs(): by_name = {tc.name: tc.output for tc in trajectory.all_tool_calls} assert by_name["get_temp"] == "temp=42" assert by_name["get_vibration"] == "vib=0.3" - - + + def test_text_deltas_are_reconstructed(): snapshot = [ _text_part("t1", "Chiller 6 "), @@ -337,4 +348,3 @@ def test_text_deltas_are_reconstructed(): ] assert _build_trajectory_from_events(snapshot, [])[0] == "Chiller 6 has 5 sensors." assert _build_trajectory_from_events(delta, [])[0] == "Chiller 6 has 5 sensors." -