stx / services /opencode.py
kevinkal's picture
Update services/opencode.py
4819e4b verified
Raw History Blame Contribute Delete
5.56 kB
"""Local OpenCode CLI access for the LLM test endpoints.
Configuration via environment variables:
OPENCODE_BIN: executable name or path (default: "opencode")
OPENCODE_TIMEOUT: per-invocation timeout in seconds (default: 120)
"""
import asyncio
import json
import os
import shutil
import signal
import subprocess
from fastapi import HTTPException
DEFAULT_TIMEOUT = 120.0
MAX_ERROR_CHARS = 4000
def _resolve_binary() -> str:
configured = os.environ.get("OPENCODE_BIN", "").strip()
binary = configured or "opencode"
resolved = shutil.which(binary)
if not resolved:
raise HTTPException(
status_code=500,
detail=f"OpenCode CLI not found ({binary!r}); install it or set OPENCODE_BIN",
)
return resolved
def _timeout_seconds() -> float:
raw = os.environ.get("OPENCODE_TIMEOUT", "").strip()
if not raw:
return DEFAULT_TIMEOUT
try:
value = float(raw)
except ValueError:
return DEFAULT_TIMEOUT
return value if value > 0 else DEFAULT_TIMEOUT
def _subprocess_options() -> dict:
if os.name == "nt":
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
return {"start_new_session": True}
def _kill(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
try:
if os.name == "nt":
process.kill()
else:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
def _json_events(stdout: str) -> list[dict]:
events: list[dict] = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(event, dict):
events.append(event)
return events
def _extract_text(stdout: str) -> str:
texts: list[str] = []
for event in _json_events(stdout):
if event.get("type") != "text":
continue
part = event.get("part")
if not isinstance(part, dict):
continue
text = part.get("text")
if isinstance(text, str) and text.strip():
texts.append(text.strip())
return "\n".join(texts).strip()
def _event_error(stdout: str) -> str:
for event in _json_events(stdout):
if event.get("type") != "error":
continue
error = event.get("error")
if isinstance(error, dict):
message = error.get("message")
if isinstance(message, str) and message.strip():
return message.strip()
if isinstance(error, str) and error.strip():
return error.strip()
return ""
def _detail(stdout: str, stderr: str) -> str:
detail = stderr.strip() or _event_error(stdout) or stdout.strip() or "no output"
if len(detail) > MAX_ERROR_CHARS:
detail = detail[:MAX_ERROR_CHARS].rstrip() + "..."
return detail
async def _run(args: list[str]) -> tuple[int, str, str]:
binary = _resolve_binary()
timeout = _timeout_seconds()
env = os.environ.copy()
env.setdefault("NO_COLOR", "1")
try:
process = await asyncio.create_subprocess_exec(
binary,
*args,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
**_subprocess_options(),
)
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Could not start OpenCode CLI: {exc}",
) from exc
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(), timeout=timeout
)
except asyncio.TimeoutError:
_kill(process)
await process.wait()
raise HTTPException(
status_code=504,
detail=f"OpenCode CLI timed out after {timeout:g} seconds",
) from None
return (
process.returncode if process.returncode is not None else -1,
stdout_bytes.decode("utf-8", errors="replace"),
stderr_bytes.decode("utf-8", errors="replace"),
)
async def run_opencode(prompt: str, model: str | None = None) -> str:
"""Run one prompt through OpenCode's plan agent, optionally with a model."""
if not prompt.strip():
raise HTTPException(status_code=400, detail="prompt must not be empty")
args = ["run", "--standalone", "--agent", "plan", "--format", "json"]
if model and model.strip():
args.extend(["--model", model.strip()])
args.extend(["--", prompt])
returncode, stdout, stderr = await _run(args)
if returncode != 0:
raise HTTPException(
status_code=502,
detail=f"OpenCode CLI failed: {_detail(stdout, stderr)}",
)
content = _extract_text(stdout)
if not content:
raise HTTPException(
status_code=502,
detail=f"OpenCode CLI returned an empty response: {_detail(stdout, stderr)}",
)
return content
async def list_opencode_models() -> list[str]:
"""Return the model selectors available to the local OpenCode CLI."""
returncode, stdout, stderr = await _run(["models", "--standalone"])
if returncode != 0:
raise HTTPException(
status_code=502,
detail=f"OpenCode CLI failed: {_detail(stdout, stderr)}",
)
return [line.strip() for line in stdout.splitlines() if line.strip()]