Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/agent/opencode_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?"
Expand Down
23 changes: 15 additions & 8 deletions src/agent/opencode_agent/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -395,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 (
Expand Down Expand Up @@ -543,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
Expand Down
60 changes: 43 additions & 17 deletions src/agent/opencode_agent/tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,45 @@ 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):
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():
Expand Down Expand Up @@ -239,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",
Expand All @@ -259,8 +286,8 @@ def _tool_part(part_id, tool, *, input=None, output=None):
}
},
}


def _step_finish(input_tokens, output_tokens):
return {
"type": "step_finish",
Expand All @@ -269,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 = [
Expand All @@ -286,17 +313,17 @@ 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"),
_text_part("t1", "The pump is healthy."),
]
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 = [
Expand All @@ -308,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 "),
Expand All @@ -321,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."