Spaces:
Paused
Paused
File size: 8,197 Bytes
bd0a5c7 da839c2 bd0a5c7 5b063c2 bd0a5c7 536e671 bd0a5c7 4508afe 536e671 bd0a5c7 5b063c2 52ba0e1 bd0a5c7 52ba0e1 bd0a5c7 52ba0e1 bd0a5c7 52ba0e1 bd0a5c7 da839c2 52ba0e1 bd0a5c7 52ba0e1 bd0a5c7 52ba0e1 bd0a5c7 52ba0e1 bd0a5c7 52ba0e1 bd0a5c7 52ba0e1 bd0a5c7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | """Run OpenCode headless in a throwaway workspace and collect the files it writes.
Each request gets its own temp directory::
<tmp>/ws/ OpenCode's project directory; everything written here is the output
<tmp>/state/ OpenCode's data/state (sessions), so --continue resumes *this* request only
<tmp>/context.md the RAG context, attached to the message with -f
OpenCode is configured entirely through OPENCODE_CONFIG_CONTENT: one
OpenAI-compatible provider (LLM_BASE_URL / LLM_MODEL, shared platform key), file edits allowed
inside the workspace, shell/web access and edits outside the workspace denied.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from .config import Settings
MAX_FILES = 60
MAX_FILE_BYTES = 200_000
IGNORED_DIRS = {".opencode", ".git", "__pycache__", "node_modules"}
class OpenCodeError(RuntimeError):
pass
@dataclass
class OpenCodeRun:
returncode: int
events: list[dict]
stderr_tail: str
@property
def error(self) -> str | None:
for e in self.events:
if e.get("type") == "error":
err = e.get("error", {})
return (err.get("data") or {}).get("message") or err.get("name") or "unknown OpenCode error"
if self.returncode != 0:
return f"opencode exited with {self.returncode}: {self.stderr_tail[-300:]}"
return None
def usage(self) -> dict:
"""Tokens and cost OpenCode reported for this run (its step_finish events), summed over its steps."""
tin = tout = 0
cost = None
for e in self.events:
part = e.get("part") or {}
tokens = part.get("tokens") or e.get("tokens")
if isinstance(tokens, dict):
cache = tokens.get("cache") or {}
tin += int(tokens.get("input") or 0) + int(cache.get("read") or 0)
tout += int(tokens.get("output") or 0) + int(tokens.get("reasoning") or 0)
c = part.get("cost", e.get("cost"))
if isinstance(c, (int, float)):
cost = (cost or 0.0) + float(c)
out = {"input_tokens": tin, "output_tokens": tout}
if cost is not None and (tin or tout):
out["cost"] = cost
return out
def opencode_config(settings: Settings) -> dict:
providers = {
"llm": {
"npm": "@ai-sdk/openai-compatible",
"name": "RosDiff LLM",
"options": {"baseURL": settings.llm_base_url, "apiKey": "{env:LLM_API_KEY}"},
# every model the router may pick, plus the configured default
"models": {m: {"name": m} for m in _router_models(settings)},
}
}
if settings.free_debug and settings.debug_model: # the free debugging model, on OpenCode's own Zen provider
zen, _, model = settings.debug_model.partition("/")
options = {"apiKey": "{env:ZEN_API_KEY}"} if settings.zen_api_key else {}
providers[zen] = {"options": options, "models": {model: {"name": model}}}
return {
"$schema": "https://opencode.ai/config.json",
"model": f"llm/{settings.code_llm_model}",
"provider": providers,
"permission": {
"edit": "allow",
"bash": "deny",
"webfetch": "deny",
"websearch": "deny",
"external_directory": "deny",
},
"autoupdate": False,
"share": "disabled",
}
def _router_models(settings: Settings) -> list[str]:
from .routing import load_registry
return list(dict.fromkeys([settings.code_llm_model, *(m.id for m in load_registry())]))
def find_opencode(settings: Settings) -> str | None:
return shutil.which(settings.opencode_bin)
class OpenCodeWorkspace:
"""One request's workspace. Use as a context manager so it is always cleaned up.
`config` replaces the code-generation config (the prompt enhancer uses a free Zen model with no tools)."""
def __init__(self, settings: Settings, config: dict | None = None, timeout: float | None = None):
self.settings = settings
self.config = config
self.timeout = timeout or settings.opencode_timeout
self.root = Path(tempfile.mkdtemp(prefix="rosdiff_opencode_"))
self.ws = self.root / "ws"
self.state = self.root / "state"
self.ws.mkdir()
self.state.mkdir()
self.runs = 0
def __enter__(self) -> OpenCodeWorkspace:
return self
def __exit__(self, *exc) -> None:
shutil.rmtree(self.root, ignore_errors=True)
def _env(self) -> dict[str, str]:
env = {k: v for k, v in os.environ.items() if not k.startswith("OPENCODE_")}
env.update(
LLM_API_KEY=self.settings.llm_api_key,
ZEN_API_KEY=self.settings.zen_api_key,
OPENCODE_CONFIG_CONTENT=json.dumps(self.config or opencode_config(self.settings)),
OPENCODE_DISABLE_PROJECT_CONFIG="1",
OPENCODE_DISABLE_AUTOUPDATE="1",
OPENCODE_DISABLE_SHARE="1",
OPENCODE_DISABLE_CLAUDE_CODE="1",
OPENCODE_DISABLE_LSP_DOWNLOAD="1",
OPENCODE_DISABLE_MODELS_FETCH="1",
OPENCODE_DISABLE_DEFAULT_PLUGINS="1",
OPENCODE_DISABLE_EXTERNAL_SKILLS="1",
DO_NOT_TRACK="1",
XDG_DATA_HOME=str(self.state),
XDG_STATE_HOME=str(self.state),
)
return env
def run(self, message: str, context: str, model: str | None = None, provider: str = "llm") -> OpenCodeRun:
"""First call starts a session; later calls continue it (used for repair rounds)."""
binary = shutil.which(self.settings.opencode_bin) or self.settings.opencode_bin
if not shutil.which(binary):
raise OpenCodeError(f"OpenCode binary {self.settings.opencode_bin!r} not found (npm i -g opencode-ai)")
cmd = [
binary,
"run",
message,
"--dir",
str(self.ws),
"--model",
f"{provider}/{model or self.settings.code_llm_model}",
"--format",
"json",
]
if self.runs:
cmd.append("--continue")
if context:
context_file = self.root / f"context_{self.runs}.md"
context_file.write_text(context)
cmd += ["-f", str(context_file)]
self.runs += 1
try:
proc = subprocess.run(
cmd,
cwd=self.ws,
env=self._env(),
capture_output=True,
text=True,
timeout=self.timeout,
)
except subprocess.TimeoutExpired as e:
raise OpenCodeError(f"OpenCode did not finish within {self.timeout:.0f}s") from e
events = []
for line in proc.stdout.splitlines():
line = line.strip()
if line.startswith("{"):
try:
events.append(json.loads(line))
except json.JSONDecodeError:
pass
return OpenCodeRun(proc.returncode, events, proc.stderr[-2000:])
def collect(self) -> dict[str, str]:
"""All text files OpenCode wrote, keyed by path relative to the workspace."""
files: dict[str, str] = {}
for p in sorted(self.ws.rglob("*")):
rel = p.relative_to(self.ws)
if not p.is_file() or p.is_symlink() or IGNORED_DIRS.intersection(rel.parts):
continue
if len(files) >= MAX_FILES:
break
if p.stat().st_size > MAX_FILE_BYTES:
continue
try:
files[rel.as_posix()] = p.read_text()
except UnicodeDecodeError:
continue
return files
def write(self, files: dict[str, str]) -> None:
"""Replace the workspace contents (unused by the default flow; handy for tests)."""
for rel, content in files.items():
dest = self.ws / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content)
|