Source code for open_atp.harness.opencode

"""OpenCode CLI harness."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from open_atp.harness._paths import _SCRIPTS
from open_atp.harness.base import (
    Harness,
    HarnessRunResult,
    _infer_provider,
)


[docs] class OpenCodeHarness(Harness): """OpenCode CLI, authenticated by a provider API key forwarded from the host. Parameters ---------- model : str Model id the agent runs. Default ``"claude-opus-4-8"``. effort : str Reasoning-effort level. Default ``"high"``. provider : str, optional API provider name. ``None`` infers it from the model prefix (``claude-*`` -> ``anthropic``, ``gpt-*`` -> ``openai``, ...). provider_api_key : str, optional The selected provider's API key, forwarded under its canonical env var (``ANTHROPIC_API_KEY`` / ``OPENAI_API_KEY`` / ...). ``None`` (default) reads that env var from the host; resolution fails if neither is set. The key is assumed to match :attr:`provider` (OpenAI and DeepSeek keys are indistinguishable, so no format check is done). Examples -------- The provider is inferred from the model prefix when not given explicitly: >>> from open_atp.harness import OpenCodeHarness >>> harness = OpenCodeHarness(model="gpt-5.5") >>> harness.name 'opencode' >>> harness.provider 'openai' With the provider key supplied explicitly, :meth:`agent_auth` forwards it under the provider's canonical env var without reading the host environment: >>> harness = OpenCodeHarness(model="claude-opus-4-8", provider_api_key="sk-fake") >>> harness.agent_auth().env {'ANTHROPIC_API_KEY': 'sk-fake'} """ name = "opencode" skills_dest = ".agents/skills" def __init__( self, *, model: str = "claude-opus-4-8", effort: str = "high", provider: str | None = None, provider_api_key: str | None = None, ) -> None: super().__init__(model=model, effort=effort) self._provider = provider self._provider_api_key = provider_api_key @property def provider(self) -> str: """API provider, taken from config or inferred from the model prefix.""" return self._provider or _infer_provider(self.model)
[docs] def stage_wd(self, wd: Path) -> None: super().stage_wd(wd) # opencode.json configures the model provider + MCP server. (wd / "opencode.json").write_text(json.dumps(self._opencode_config(), indent=2))
def _opencode_config(self) -> dict[str, Any]: options: dict[str, Any] if self.provider == "anthropic": options = { "thinking": {"type": "adaptive"}, "output_config": {"effort": self.effort}, } else: options = {"reasoningEffort": self.effort} return { "$schema": "https://opencode.ai/config.json", "provider": {self.provider: {"models": {self.model: {"options": options}}}}, "mcp": { "lean-lsp": { "type": "local", "command": ["lean-lsp-mcp"], "enabled": True, # The first lean_diagnostic_messages call starts `lake serve` and # loads the file's full import closure (Mathlib) into the LSP, which # routinely exceeds opencode's 60s default MCP request timeout on a # cold, few-CPU sandbox -- surfacing as `MCP error -32001: Request # timed out`. Raise it so the slow first diagnostic can return. "timeout": 180_000, } }, } def _required_env(self) -> dict[str, str]: return self._provider_key_env(self.provider, self._provider_api_key) def _agent_command(self) -> str: template = (_SCRIPTS / "opencode_agent.sh").read_text() return template.replace("<<PROVIDER>>", self.provider).replace( "<<MODEL>>", self.model ) def _parse_lines(self, lines: list[str]) -> HarnessRunResult: """Parse ``opencode run --format json`` output.""" def _as_int(x: Any) -> int: return x if isinstance(x, int) else 0 result = HarnessRunResult() for line in lines: line = line.strip() if not line: continue try: event = json.loads(line) except json.JSONDecodeError: continue if event.get("type") != "step_finish": continue part = event.get("part") or {} tokens = part.get("tokens") or {} cache = tokens.get("cache") or {} result.input_tokens += ( _as_int(tokens.get("input")) + _as_int(cache.get("write")) + _as_int(cache.get("read")) ) result.output_tokens += _as_int(tokens.get("output")) c = part.get("cost") if isinstance(c, (int, float)): result.cost_usd = (result.cost_usd or 0.0) + float(c) r = part.get("reason") if isinstance(r, str): result.stop_reason = r return result