Support local OpenCode Sol provider

This commit is contained in:
Daniel Maddern 2026-08-15 14:16:27 +07:00
parent d123659bd2
commit e11bcd1e59
2 changed files with 50 additions and 29 deletions

View file

@ -27,7 +27,12 @@ class Command(BaseCommand):
"provider": "opencode", "provider": "opencode",
"compute": spark, "compute": spark,
"roles": ["PROJECT_BRAIN", "PLANNING", "ARCHAEOLOGY_INTERPRETATION"], "roles": ["PROJECT_BRAIN", "PLANNING", "ARCHAEOLOGY_INTERPRETATION"],
"config": {"command": os.environ.get("ARTIFEX_SOL_OPENCODE_COMMAND", "opencode run --json --no-repo --stdin")}, "config": {
"transport": os.environ.get("ARTIFEX_SOL_TRANSPORT", "local"),
"command": os.environ.get("ARTIFEX_SOL_OPENCODE_COMMAND", "/home/daniel/.opencode/bin/opencode run"),
"use_pty": os.environ.get("ARTIFEX_SOL_USE_PTY", "1") == "1",
"timeout_seconds": int(os.environ.get("ARTIFEX_SOL_TIMEOUT_SECONDS", "120")),
},
}, },
) )
Resource.objects.update_or_create( Resource.objects.update_or_create(

View file

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
import shlex
import subprocess import subprocess
import urllib.error import urllib.error
import urllib.request import urllib.request
@ -36,6 +38,17 @@ def extract_json_object(text: str) -> dict[str, Any]:
return _extract_json(text[start : end + 1]) return _extract_json(text[start : end + 1])
def _clean_opencode_output(text: str) -> str:
text = re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", text)
lines = []
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("> build"):
continue
lines.append(line)
return "\n".join(lines).strip()
@dataclass @dataclass
class SolProvider: class SolProvider:
resource: Resource resource: Resource
@ -44,44 +57,47 @@ class SolProvider:
def complete(self, request: ModelRequestContract) -> ModelResponseContract: def complete(self, request: ModelRequestContract) -> ModelResponseContract:
config = self.resource.config config = self.resource.config
compute = self.resource.compute compute = self.resource.compute
ssh_alias = (compute.config if compute else {}).get("ssh_alias", config.get("ssh_alias", "spark"))
timeout = int(config.get("timeout_seconds", 120)) timeout = int(config.get("timeout_seconds", 120))
remote_command = config.get("command", "opencode run --json --no-repo --stdin") command = str(config.get("command", "opencode run"))
payload = { transport = str(config.get("transport", "ssh"))
"mode": "reasoning_only", use_pty = bool(config.get("use_pty", False))
"output_schema": "json_object", if transport == "local":
"prompt": request.prompt, argv = [*shlex.split(command), request.prompt]
"token_budget": request.token_budget, if use_pty:
} shell_command = " ".join(shlex.quote(part) for part in argv)
completed = subprocess.run( argv = ["script", "-q", "-e", "-c", shell_command, "/dev/null"]
["ssh", str(ssh_alias), str(remote_command)], completed = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False)
input=json.dumps(payload), else:
capture_output=True, ssh_alias = (compute.config if compute else {}).get("ssh_alias", config.get("ssh_alias", "spark"))
text=True, remote_command = " ".join(shlex.quote(part) for part in [*shlex.split(command), request.prompt])
timeout=timeout, completed = subprocess.run(
check=False, ["ssh", "-tt", str(ssh_alias), remote_command],
) capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if completed.returncode != 0: if completed.returncode != 0:
raise ProviderError(completed.stderr.strip() or "Sol provider failed") raise ProviderError(completed.stderr.strip() or "Sol provider failed")
data = _extract_json(completed.stdout) content = _clean_opencode_output(completed.stdout)
content = data.get("content") or data.get("response") or data.get("plan") if not content:
if isinstance(content, (dict, list)): raise ProviderError("Sol provider response missing content")
content = json.dumps(content)
if not isinstance(content, str):
raise ProviderError("Sol provider response missing string content")
return ModelResponseContract( return ModelResponseContract(
model=self.resource.name, model=self.resource.name,
content=content, content=content,
metadata={"provider": self.provider_name, "usage": data.get("usage", {})}, metadata={"provider": self.provider_name, "usage": {}},
) )
def health(self) -> str: def health(self) -> str:
compute = self.resource.compute config = self.resource.config
ssh_alias = (compute.config if compute else {}).get("ssh_alias", self.resource.config.get("ssh_alias", "spark"))
try: try:
completed = subprocess.run( if config.get("transport") == "local":
["ssh", str(ssh_alias), "true"], capture_output=True, text=True, timeout=10, check=False command = shlex.split(str(config.get("command", "opencode")))[0]
) completed = subprocess.run([command, "--version"], capture_output=True, text=True, timeout=10, check=False)
else:
compute = self.resource.compute
ssh_alias = (compute.config if compute else {}).get("ssh_alias", self.resource.config.get("ssh_alias", "spark"))
completed = subprocess.run(["ssh", str(ssh_alias), "true"], capture_output=True, text=True, timeout=10, check=False)
except Exception: except Exception:
return "UNAVAILABLE" return "UNAVAILABLE"
return "AVAILABLE" if completed.returncode == 0 else "UNAVAILABLE" return "AVAILABLE" if completed.returncode == 0 else "UNAVAILABLE"