Coverage for src/ai_jury/adapters.py: 94%

528 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-17 21:31 +0000

1"""Agent adapters — each wraps one native coding-agent CLI in headless mode. 

2 

3Every adapter turns a prompt into a subprocess invocation and captures stdout as 

4the agent's response. Adapters are intentionally thin: the orchestrator owns the 

5prompt content and the round structure; an adapter only knows how to *invoke its 

6CLI*. 

7 

8Headless invocations (verified against installed CLIs, early 2026). The prompt 

9embeds the redacted diff, so it is delivered on STDIN (never argv) for every 

10real adapter so it is not exposed in the process list (issue #287): 

11 - Claude Code : ``claude -p --output-format text`` (prompt piped via stdin) 

12 - Codex CLI : ``codex exec <args>`` (prompt piped via stdin) 

13 - Antigravity : ``agy --print`` (prompt piped via stdin) 

14""" 

15 

16from __future__ import annotations 

17 

18import contextlib 

19import os 

20import re 

21import shutil 

22import signal 

23import subprocess 

24import time 

25from dataclasses import dataclass, field 

26 

27from . import privilege, redaction 

28from .config import AgentSpec 

29 

30# Cap on a single local-model HTTP response body (issue #293/F-9). A chat 

31# completion is small; an unbounded read from a malicious/buggy endpoint would 

32# let it OOM the process. 

33_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 

34 

35 

36def _kill_process_group(proc: subprocess.Popen) -> None: 

37 """Best-effort kill of the child's whole process group (issue #293/F-7).""" 

38 try: 

39 if hasattr(os, "killpg"): 39 ↛ 44line 39 didn't jump to line 44 because the condition on line 39 was always true

40 os.killpg(os.getpgid(proc.pid), signal.SIGKILL) 

41 return 

42 except (ProcessLookupError, PermissionError, OSError): 

43 pass 

44 with contextlib.suppress(OSError): 

45 proc.kill() 

46 

47 

48def _spawn(argv: list[str], stdin: str | None, timeout: int) -> subprocess.CompletedProcess: 

49 """Run a CLI with stdout/stderr captured, killing the whole group on timeout. 

50 

51 ``subprocess.run(timeout=…)`` SIGKILLs only the direct child, so an agent CLI 

52 that wraps node/python can leak orphaned grandchildren (issue #293/F-7). The 

53 child is started in its own session (process-group leader); on timeout the 

54 entire group is killed before re-raising ``TimeoutExpired`` so the caller's 

55 handling is unchanged. Returns a ``CompletedProcess``. 

56 """ 

57 popen_kwargs: dict = { 

58 "stdin": subprocess.PIPE if stdin is not None else None, 

59 "stdout": subprocess.PIPE, 

60 "stderr": subprocess.PIPE, 

61 "text": True, 

62 } 

63 if hasattr(os, "setsid"): 63 ↛ 65line 63 didn't jump to line 65 because the condition on line 63 was always true

64 popen_kwargs["start_new_session"] = True 

65 proc = subprocess.Popen(argv, **popen_kwargs) 

66 try: 

67 out, err = proc.communicate(input=stdin, timeout=timeout) 

68 except subprocess.TimeoutExpired: 

69 _kill_process_group(proc) 

70 with contextlib.suppress(Exception): 

71 proc.communicate() # reap the killed child 

72 raise 

73 return subprocess.CompletedProcess(argv, proc.returncode, out, err) 

74 

75 

76def _read_only_extra_args(spec: AgentSpec) -> list[str]: 

77 """The agent's ``extra_args`` with its mandatory read-only sandbox guaranteed. 

78 

79 Enforced at the adapter layer (issue #288) so a missing/misconfigured 

80 ``extra_args`` cannot strip the sandbox: a reviewer of an attacker-controlled 

81 diff is never write/tool-capable. Config may widen a codex sandbox knowingly, 

82 but never remove the restriction. 

83 """ 

84 return privilege.enforce_read_only(spec.vendor, spec.name, spec.extra_args) 

85 

86 

87# Short timeout for capability/version probes. Detection is best-effort and must 

88# never slow down or block a normal run, so probes are deliberately snappy. 

89_VERSION_PROBE_TIMEOUT = 10 

90 

91# Matches a version-looking token, e.g. "1.2", "1.2.3", "v0.45.1". 

92_VERSION_RE = re.compile(r"\d+\.\d+(?:\.\d+)?") 

93 

94# Capability/version probe statuses. 

95CAP_OK = "ok" 

96CAP_UNKNOWN_VERSION = "unknown_version" 

97CAP_UNAVAILABLE = "unavailable" 

98 

99# Stable, typed error taxonomy for failed agent executions. These codes let 

100# reports and CI/policy distinguish retryable from non-retryable failures 

101# instead of pattern-matching free-text error strings. 

102ERR_MISSING_CLI = "missing_cli" 

103ERR_AUTH_REQUIRED = "auth_required" 

104ERR_PERMISSION_PROMPT = "permission_prompt" 

105ERR_TIMEOUT = "timeout" 

106ERR_NONZERO_EXIT = "nonzero_exit" 

107ERR_EMPTY_OUTPUT = "empty_output" 

108ERR_SPAWN_FAILED = "spawn_failed" 

109ERR_RATE_LIMITED = "rate_limited" 

110# Local/HTTP adapter could not reach its server (issue #43): connection refused, 

111# DNS failure, or the local model server is not running. 

112ERR_CONNECTION = "connection_error" 

113# Hosted-API adapter (issue #430): the vendor's API key env var is unset. Distinct 

114# from ERR_AUTH_REQUIRED (a key was sent but the server rejected it) so a report 

115# can tell "never configured" apart from "misconfigured/expired/revoked". 

116ERR_MISSING_API_KEY = "missing_api_key" 

117# Hosted-API adapter (issue #430): the configured key contains a control 

118# character and was rejected BEFORE being sent as a header, rather than 

119# letting http.client raise (and risk echoing a transformed/escaped copy of 

120# the secret in its exception text — see _HostedApiAdapter._invalid_key_reason). 

121ERR_INVALID_API_KEY = "invalid_api_key" 

122ERR_UNKNOWN = "unknown" 

123 

124ERROR_CODES = frozenset( 

125 { 

126 ERR_MISSING_CLI, 

127 ERR_AUTH_REQUIRED, 

128 ERR_PERMISSION_PROMPT, 

129 ERR_TIMEOUT, 

130 ERR_NONZERO_EXIT, 

131 ERR_EMPTY_OUTPUT, 

132 ERR_SPAWN_FAILED, 

133 ERR_RATE_LIMITED, 

134 ERR_CONNECTION, 

135 ERR_MISSING_API_KEY, 

136 ERR_INVALID_API_KEY, 

137 ERR_UNKNOWN, 

138 } 

139) 

140 

141# Failures that are worth retrying because they are typically transient (issue 

142# #30): a timeout, a rate-limit, a process that failed to spawn, or a local 

143# server that was briefly unreachable (#43). Auth, missing-CLI, 

144# permission-prompt, empty-output, and generic nonzero-exit are treated as 

145# deterministic — retrying them just burns time and tokens. 

146RETRYABLE_ERROR_CODES = frozenset( 

147 { 

148 ERR_TIMEOUT, 

149 ERR_RATE_LIMITED, 

150 ERR_SPAWN_FAILED, 

151 ERR_CONNECTION, 

152 } 

153) 

154 

155 

156# Ordered keyword groups for classify_stderr. Each keyword is matched on word 

157# boundaries (\b...\b) so incidental substrings do NOT trigger a false 

158# classification: bare "auth" matches "auth error" but not "author identity", 

159# and "login" matches "login required" but not "login_attempts" ("_" is a word 

160# char, so there is no boundary inside "login_attempts"). Multi-word phrases 

161# tolerate a space OR "_" between tokens (e.g. "rate limit"/"rate_limit"). 

162def _keyword_pattern(*keywords: str) -> re.Pattern[str]: 

163 parts = [r"[ _]+".join(re.escape(tok) for tok in kw.split()) for kw in keywords] 

164 return re.compile(r"\b(?:" + "|".join(parts) + r")\b") 

165 

166 

167# Order matters: auth and rate-limit signals are checked before the generic 

168# permission and nonzero-exit fallbacks. 

169_AUTH_RE = _keyword_pattern( 

170 "not authenticated", 

171 "unauthenticated", 

172 "authentication", 

173 "unauthorized", 

174 "api key", 

175 "auth", 

176 "log in", 

177 "login", 

178 "credential", 

179 "credentials", 

180) 

181_RATE_LIMIT_RE = _keyword_pattern("rate limit", "429", "quota", "too many requests") 

182_PERMISSION_RE = _keyword_pattern( 

183 "permission", 

184 "permissions", 

185 "approve", 

186 "approval", 

187 "confirm", 

188 "confirmation", 

189) 

190 

191 

192def classify_stderr(returncode: int, stderr: str) -> str: 

193 """Classify a nonzero-exit failure into a typed error code from its stderr. 

194 

195 Token-aware matching against the lowercased stderr: each keyword group is a 

196 word-boundary regex, so incidental substrings (e.g. "author" containing 

197 "auth") never cause a misclassification. Ordering matters (auth and 

198 rate-limit signals are checked before the generic permission and 

199 nonzero-exit fallbacks). Returns one of the ``ERR_*`` codes. 

200 """ 

201 text = (stderr or "").lower() 

202 if _AUTH_RE.search(text): 

203 return ERR_AUTH_REQUIRED 

204 if _RATE_LIMIT_RE.search(text): 

205 return ERR_RATE_LIMITED 

206 if _PERMISSION_RE.search(text): 

207 return ERR_PERMISSION_PROMPT 

208 del returncode 

209 return ERR_NONZERO_EXIT 

210 

211 

212@dataclass 

213class AgentResult: 

214 agent: str 

215 vendor: str 

216 ok: bool 

217 output: str 

218 duration_s: float 

219 error: str | None = None 

220 findings: list = field(default_factory=list) 

221 warnings: list = field(default_factory=list) 

222 error_code: str | None = None 

223 # Number of attempts made for this result (issue #30): 1 means no retry. 

224 # >1 records that a transient failure was retried before this outcome. 

225 attempts: int = 1 

226 # Did this agent emit a structured findings block at all (issue #501)? A 

227 # reviewer that examined the diff and found nothing still emits `[]`; one that 

228 # produced no review emits prose and no block. Without this, both arrive as zero 

229 # findings and the panel reports the same size either way. 

230 structured: bool = False 

231 

232 

233class Adapter: 

234 """Base adapter. Subclasses build the argv for their CLI.""" 

235 

236 # Declarative capability metadata. Real coding-agent CLIs support a headless 

237 # (non-interactive) invocation and model selection; subclasses override where 

238 # this differs. ``MockAdapter`` reports synthetic capabilities. 

239 SUPPORTS_HEADLESS = True 

240 SUPPORTS_MODEL_SELECTION = True 

241 

242 # Args passed to the CLI to print its version. Subclasses override if the CLI 

243 # uses a different verb/flag (e.g. ``codex --version``). 

244 _VERSION_ARGS = ("--version",) 

245 

246 def __init__(self, spec: AgentSpec): 

247 self.spec = spec 

248 

249 @property 

250 def name(self) -> str: 

251 return self.spec.name 

252 

253 def available(self) -> bool: 

254 return shutil.which(self.spec.command) is not None 

255 

256 def build_argv(self, prompt: str) -> list[str]: # pragma: no cover - overridden 

257 raise NotImplementedError 

258 

259 def _stdin_for(self, prompt: str) -> str | None: 

260 """Prompt to feed on stdin, or None to pass it in argv (the default).""" 

261 del prompt 

262 return None 

263 

264 def _version_argv(self) -> list[str]: 

265 """Argv used to probe the CLI's version.""" 

266 return [self.spec.command, *self._VERSION_ARGS] 

267 

268 def detect_capabilities(self) -> dict: 

269 """Best-effort probe of this agent's version and capabilities. 

270 

271 Returns a dict shaped like:: 

272 

273 { 

274 "version": "<str|None>", 

275 "supports_headless": bool, 

276 "supports_model_selection": bool, 

277 "raw_version_output": "<short str>", 

278 "status": "ok|unknown_version|unavailable", 

279 "warnings": [...], 

280 } 

281 

282 This is intentionally fast and forgiving: it runs ``<command> --version`` 

283 with a SHORT timeout and swallows ALL errors (missing CLI, timeout, 

284 nonzero exit, garbage output). It NEVER raises, so it is safe to call 

285 from diagnostics without blocking or crashing a run. 

286 """ 

287 caps = { 

288 "version": None, 

289 "supports_headless": self.SUPPORTS_HEADLESS, 

290 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION, 

291 "raw_version_output": "", 

292 "status": CAP_UNAVAILABLE, 

293 "warnings": [], 

294 } 

295 

296 # Not on PATH: report unavailable without spawning a subprocess. 

297 if not self.available(): 

298 return caps 

299 

300 try: 

301 # Via _spawn so the probe also runs in its own process group and the 

302 # whole group is killed on timeout (issue #303/L-1) — matching the 

303 # main run path; a bare subprocess.run would orphan grandchildren. 

304 proc = _spawn(self._version_argv(), None, _VERSION_PROBE_TIMEOUT) 

305 except subprocess.TimeoutExpired: 

306 caps["status"] = CAP_UNKNOWN_VERSION 

307 caps["warnings"].append( 

308 f"version probe for '{self.spec.command}' timed out after {_VERSION_PROBE_TIMEOUT}s" 

309 ) 

310 return caps 

311 except Exception as exc: # noqa: BLE001 - swallow any spawn failure 

312 caps["status"] = CAP_UNKNOWN_VERSION 

313 caps["warnings"].append( 

314 f"version probe for '{self.spec.command}' failed: {redaction.redact(str(exc))[0]}" 

315 ) 

316 return caps 

317 

318 raw = ((proc.stdout or "") + (proc.stderr or "")).strip() 

319 caps["raw_version_output"] = redaction.redact(raw[:200])[0] 

320 match = _VERSION_RE.search(raw) 

321 if proc.returncode == 0 and match: 

322 caps["version"] = match.group(0) 

323 caps["status"] = CAP_OK 

324 else: 

325 caps["status"] = CAP_UNKNOWN_VERSION 

326 caps["warnings"].append( 

327 f"could not determine version of '{self.spec.command}' " 

328 f"(exit {proc.returncode}); capabilities assumed from vendor defaults" 

329 ) 

330 return caps 

331 

332 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult: 

333 del phase 

334 if not self.available(): 

335 return AgentResult( 

336 self.name, 

337 self.spec.vendor, 

338 False, 

339 "", 

340 0.0, 

341 f"command not found on PATH: {self.spec.command}", 

342 error_code=ERR_MISSING_CLI, 

343 ) 

344 # The effective timeout is the caller's override (the run budget, issue 

345 # #30) when smaller than the agent's own bound, else the agent timeout. 

346 effective_timeout = self.spec.timeout 

347 if timeout is not None: 

348 effective_timeout = max(1, min(self.spec.timeout, int(timeout))) 

349 argv = self.build_argv(prompt) 

350 stdin = self._stdin_for(prompt) 

351 start = time.monotonic() 

352 try: 

353 proc = _spawn(argv, stdin, effective_timeout) 

354 except subprocess.TimeoutExpired: 

355 return AgentResult( 

356 self.name, 

357 self.spec.vendor, 

358 False, 

359 "", 

360 time.monotonic() - start, 

361 f"timed out after {effective_timeout}s", 

362 error_code=ERR_TIMEOUT, 

363 ) 

364 except Exception as exc: # noqa: BLE001 - surface any spawn failure 

365 return AgentResult( 

366 self.name, 

367 self.spec.vendor, 

368 False, 

369 "", 

370 time.monotonic() - start, 

371 f"spawn failed: {redaction.redact(str(exc))[0]}", 

372 error_code=ERR_SPAWN_FAILED, 

373 ) 

374 dur = time.monotonic() - start 

375 out = (proc.stdout or "").strip() 

376 # A nonzero exit is ALWAYS a failure, even with stdout (issue #101): a 

377 # crashing CLI can still print partial or error output, and counting that 

378 # as a clean review would silently feed it into consensus, synthesis, and 

379 # the CI gate. We classify from stderr (falling back to any stdout) and 

380 # keep a short snippet in the error for debugging — but ok=False, so the 

381 # orchestrator excludes it. 

382 if proc.returncode != 0: 

383 stderr = (proc.stderr or "").strip() 

384 detail = stderr or out 

385 # Redact before embedding in the error: a crashing CLI can dump an 

386 # env var / token into its stderr, and this string is rendered into 

387 # the report and posted to the PR. Mirrors the LocalAdapter path 

388 # (#293/F-8); the asymmetry was a secret-leak vector (audit 

389 # 2026-06-13/N-1). Classify on the raw text (no secrets in codes). 

390 safe_detail = redaction.redact(detail)[0] 

391 return AgentResult( 

392 self.name, 

393 self.spec.vendor, 

394 False, 

395 "", 

396 dur, 

397 f"exit {proc.returncode}: {safe_detail[:500]}", 

398 error_code=classify_stderr(proc.returncode, stderr or out), 

399 ) 

400 if not out: 

401 # Exit 0 but nothing on stdout: the agent produced no usable review. 

402 return AgentResult( 

403 self.name, 

404 self.spec.vendor, 

405 False, 

406 "", 

407 dur, 

408 f"exit {proc.returncode}: empty output", 

409 error_code=ERR_EMPTY_OUTPUT, 

410 ) 

411 return AgentResult(self.name, self.spec.vendor, True, out, dur) 

412 

413 

414class ClaudeAdapter(Adapter): 

415 # The prompt embeds the (redacted) diff and PR/issue context; deliver it on 

416 # STDIN rather than as a process argument so it is not exposed in `ps` / 

417 # /proc/<pid>/cmdline to other local users (issue #287). `claude -p` reads 

418 # the prompt from stdin when no positional prompt is given. 

419 def build_argv(self, prompt: str) -> list[str]: 

420 del prompt 

421 argv = [self.spec.command, "-p"] 

422 if self.spec.model: 

423 argv += ["--model", self.spec.model] 

424 return argv + _read_only_extra_args(self.spec) 

425 

426 def _stdin_for(self, prompt: str) -> str | None: 

427 return prompt 

428 

429 

430class CodexAdapter(Adapter): 

431 # Pipe the prompt on stdin (not positionally) so ``codex exec`` never blocks 

432 # waiting for input in non-interactive runs. Sandbox flags live in extra_args; 

433 # the shipped default is ``-s read-only`` (secure by default, #100) — the 

434 # reviewer only reads its prompt, since the jury fetches the diff via ``gh``. 

435 def build_argv(self, prompt: str) -> list[str]: 

436 del prompt 

437 argv = [self.spec.command, "exec"] 

438 if self.spec.model: 

439 argv += ["-m", self.spec.model] 

440 return argv + _read_only_extra_args(self.spec) 

441 

442 def _stdin_for(self, prompt: str) -> str | None: 

443 return prompt 

444 

445 

446class AgyAdapter(Adapter): 

447 # Prompt on STDIN, not argv (issue #287): `agy --print` reads the prompt from 

448 # stdin when no positional prompt is given (verified against agy 1.0.6), so the 

449 # redacted diff is not exposed in the process list. 

450 def build_argv(self, prompt: str) -> list[str]: 

451 del prompt 

452 argv = [self.spec.command, "--print"] 

453 if self.spec.model: 

454 argv += ["--model", self.spec.model] 

455 return argv + _read_only_extra_args(self.spec) 

456 

457 def _stdin_for(self, prompt: str) -> str | None: 

458 return prompt 

459 

460 

461_DEFAULT_LOCAL_ENDPOINT = "http://localhost:11434/v1" 

462 

463 

464def _http_only_opener(): 

465 """An opener that handles ONLY http/https (issue #291, SSRF defense). 

466 

467 The default ``urllib`` opener honors ``file://`` and ``ftp://``, so an 

468 attacker-influenced ``endpoint`` could read local files or reach other 

469 schemes. This OpenerDirector registers no ``FileHandler``/``FTPHandler``, so 

470 any non-http(s) URL raises ``URLError("unknown url type")`` regardless of 

471 config validation — defense in depth alongside ``config._endpoint_issues``. 

472 

473 It also registers NO ``HTTPRedirectHandler`` (review of #291): otherwise a 

474 malicious/compromised endpoint could 302-redirect to an internal/metadata 

475 host (e.g. ``169.254.169.254``) and the opener would follow it, bypassing the 

476 configured-URL validation. Without the handler a 3xx surfaces as an 

477 ``HTTPError`` (a failed review) and is never followed. 

478 """ 

479 import urllib.request 

480 

481 opener = urllib.request.OpenerDirector() 

482 for handler in ( 

483 urllib.request.HTTPHandler, 

484 urllib.request.HTTPSHandler, 

485 urllib.request.HTTPDefaultErrorHandler, 

486 urllib.request.HTTPErrorProcessor, 

487 # UnknownHandler raises URLError("unknown url type: …") for any scheme 

488 # without a registered handler — so file://, ftp://, etc. fail loudly 

489 # instead of silently resolving to None. 

490 urllib.request.UnknownHandler, 

491 ): 

492 opener.add_handler(handler()) 

493 return opener 

494 

495 

496def _open(target, timeout): 

497 """Open an http/https URL or Request via the restricted opener (issue #291). 

498 

499 Single seam for every local-adapter HTTP call so the SSRF-safe opener (no 

500 file/ftp handlers) is always used. 

501 """ 

502 return _http_only_opener().open(target, timeout=timeout) 

503 

504 

505def list_local_models(endpoint: str = _DEFAULT_LOCAL_ENDPOINT) -> list[str]: 

506 """List model ids from a local OpenAI-compatible server (issue #109). 

507 

508 GETs ``{endpoint}/models`` (the OpenAI-compatible listing that Ollama, 

509 vLLM, LM Studio, etc. expose) and returns the model ids in their reported 

510 order. Best-effort and stdlib-only: any failure (server down, bad JSON) 

511 returns ``[]`` so callers can fall back gracefully. 

512 

513 The endpoint is validated here at the seam (issue #309) so EVERY caller — 

514 including the un-gated ``jury init --local-endpoint`` discovery path — gets 

515 the same SSRF gate that ``config._endpoint_issues`` enforces for config-file 

516 endpoints: a non-``http(s)`` scheme or a non-loopback host (without the 

517 ``JURY_ALLOW_REMOTE_ENDPOINT`` opt-in) yields ``[]`` without any network call. 

518 """ 

519 import json as _json 

520 

521 from .config import _endpoint_issues 

522 

523 base = (endpoint or _DEFAULT_LOCAL_ENDPOINT).rstrip("/") 

524 try: 

525 # SSRF gate INSIDE the try (review of #309): `_endpoint_issues` calls 

526 # urlsplit, which raises ValueError on a malformed URL (e.g. `http://[::1`); 

527 # keep the best-effort "any failure -> []" contract rather than crashing. 

528 if _endpoint_issues(base, "local-endpoint")[0]: # hard-error issues -> refuse 

529 return [] 

530 url = base if base.endswith("/models") else f"{base}/models" 

531 with _open(url, _VERSION_PROBE_TIMEOUT) as resp: # noqa: S310 

532 data = _json.loads(resp.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")) 

533 except Exception: # noqa: BLE001 - discovery is best-effort 

534 return [] 

535 models = data.get("data") if isinstance(data, dict) else None 

536 if not isinstance(models, list): 

537 return [] 

538 ids = [m.get("id") for m in models if isinstance(m, dict) and m.get("id")] 

539 return [str(i) for i in ids] 

540 

541 

542class LocalAdapter(Adapter): 

543 """Open-weight / local-model reviewer over an OpenAI-compatible API (issue #43). 

544 

545 Targets the ``/v1/chat/completions`` endpoint exposed by common local servers 

546 (Ollama, llama.cpp ``llama-server``, vLLM, LM Studio). It talks plain HTTP via 

547 the stdlib (``urllib``) — no new dependencies and no subprocess — so one panel 

548 seat can run free and fully offline, adding model diversity (the load-bearing 

549 advantage) at zero marginal cost. 

550 

551 Configure as a normal ``[[agent]]`` with ``vendor = "local"``, an 

552 ``endpoint`` (base URL, default ``http://localhost:11434/v1``), and a 

553 ``model``. ``extra_args`` is unused. An unreachable server fails with the 

554 typed ``connection_error`` code (issue #29) rather than a crash. 

555 """ 

556 

557 SUPPORTS_HEADLESS = True 

558 SUPPORTS_MODEL_SELECTION = True 

559 

560 @property 

561 def endpoint(self) -> str: 

562 return (self.spec.endpoint or _DEFAULT_LOCAL_ENDPOINT).rstrip("/") 

563 

564 def completions_url(self) -> str: 

565 """Resolve the chat-completions URL from the configured base endpoint. 

566 

567 Accepts either a base URL (``…/v1``) or a full completions URL; pure so it 

568 can be unit-tested without network. 

569 """ 

570 base = self.endpoint 

571 if base.endswith("/chat/completions"): 

572 return base 

573 return f"{base}/chat/completions" 

574 

575 def build_payload(self, prompt: str) -> dict: 

576 """Build the OpenAI-compatible chat-completions request body (pure).""" 

577 return { 

578 "model": self.spec.model or "", 

579 "messages": [{"role": "user", "content": prompt}], 

580 "stream": False, 

581 "temperature": 0, 

582 } 

583 

584 @staticmethod 

585 def parse_content(data: dict) -> str: 

586 """Extract the assistant message text from a chat-completions response.""" 

587 choices = data.get("choices") or [] 

588 if not choices: 

589 return "" 

590 message = choices[0].get("message") or {} 

591 return (message.get("content") or "").strip() 

592 

593 @staticmethod 

594 def classify_http_status(status: int) -> str: 

595 """Map an HTTP error status to a typed error code (issue #29).""" 

596 if status in (401, 403): 

597 return ERR_AUTH_REQUIRED 

598 if status == 429: 

599 return ERR_RATE_LIMITED 

600 return ERR_NONZERO_EXIT 

601 

602 def available(self) -> bool: 

603 """A local agent is 'available' when its server answers a quick probe. 

604 

605 Probes the OpenAI-compatible ``/v1/models`` (or the endpoint root) with a 

606 short timeout. Network-only; never raises. 

607 """ 

608 import urllib.error 

609 import urllib.request 

610 

611 url = f"{self.endpoint}/models" 

612 try: 

613 with _open(url, _VERSION_PROBE_TIMEOUT) as resp: # noqa: S310 

614 return 200 <= resp.status < 500 

615 except urllib.error.HTTPError as exc: 

616 # A 4xx (e.g. 404 on /models) still means the server is up. 

617 return exc.code < 500 

618 except Exception: # noqa: BLE001 - unreachable server -> not available 

619 return False 

620 

621 def detect_capabilities(self) -> dict: 

622 reachable = self.available() 

623 return { 

624 "version": None, 

625 "supports_headless": self.SUPPORTS_HEADLESS, 

626 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION, 

627 "raw_version_output": f"local endpoint {self.endpoint}", 

628 "status": CAP_OK if reachable else CAP_UNAVAILABLE, 

629 "warnings": ([] if reachable else [f"local server unreachable at {self.endpoint}"]), 

630 } 

631 

632 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult: 

633 import json as _json 

634 import urllib.error 

635 import urllib.request 

636 

637 del phase 

638 effective_timeout = self.spec.timeout 

639 if timeout is not None: 

640 effective_timeout = max(1, min(self.spec.timeout, int(timeout))) 

641 body = _json.dumps(self.build_payload(prompt)).encode("utf-8") 

642 req = urllib.request.Request( 

643 self.completions_url(), 

644 data=body, 

645 headers={"Content-Type": "application/json"}, 

646 method="POST", 

647 ) 

648 start = time.monotonic() 

649 try: 

650 with _open(req, effective_timeout) as resp: # noqa: S310 

651 raw = resp.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace") 

652 data = _json.loads(raw) 

653 except urllib.error.HTTPError as exc: 

654 detail = "" 

655 try: 

656 detail = exc.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")[:300] 

657 except Exception: # noqa: BLE001 

658 detail = exc.reason or "" 

659 # The body is from a possibly-untrusted endpoint and is surfaced in 

660 # the report; redact recognized secrets before embedding (#293/F-8). 

661 detail = redaction.redact(detail)[0] 

662 return AgentResult( 

663 self.name, 

664 self.spec.vendor, 

665 False, 

666 "", 

667 time.monotonic() - start, 

668 f"HTTP {exc.code}: {detail}", 

669 error_code=self.classify_http_status(exc.code), 

670 ) 

671 except TimeoutError: 

672 return AgentResult( 

673 self.name, 

674 self.spec.vendor, 

675 False, 

676 "", 

677 time.monotonic() - start, 

678 f"timed out after {effective_timeout}s", 

679 error_code=ERR_TIMEOUT, 

680 ) 

681 except urllib.error.URLError as exc: 

682 return AgentResult( 

683 self.name, 

684 self.spec.vendor, 

685 False, 

686 "", 

687 time.monotonic() - start, 

688 f"could not reach local server at {self.endpoint}: {redaction.redact(str(exc.reason))[0]}", 

689 error_code=ERR_CONNECTION, 

690 ) 

691 except Exception as exc: # noqa: BLE001 - surface any other failure 

692 return AgentResult( 

693 self.name, 

694 self.spec.vendor, 

695 False, 

696 "", 

697 time.monotonic() - start, 

698 f"local request failed: {redaction.redact(str(exc))[0]}", 

699 error_code=ERR_UNKNOWN, 

700 ) 

701 dur = time.monotonic() - start 

702 content = self.parse_content(data) 

703 if not content: 

704 return AgentResult( 

705 self.name, 

706 self.spec.vendor, 

707 False, 

708 "", 

709 dur, 

710 "local model returned empty content", 

711 error_code=ERR_EMPTY_OUTPUT, 

712 ) 

713 return AgentResult(self.name, self.spec.vendor, True, content, dur) 

714 

715 

716# Hosted vendor API endpoints (issue #430). Fixed, not configurable: unlike 

717# `local`'s user-supplied `endpoint` (which needs the SSRF validation in 

718# config._endpoint_issues), a hosted vendor's URL is a known constant, not an 

719# attacker- or operator-influenceable value, so there is nothing to validate. 

720_ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages" 

721_ANTHROPIC_API_VERSION = "2023-06-01" 

722_OPENAI_API_URL = "https://api.openai.com/v1/chat/completions" 

723# The Anthropic Messages API requires max_tokens on every request; there is no 

724# server-side default. Generous enough for a review response, small enough to 

725# bound cost/latency if a run is ever misconfigured to loop. 

726_HOSTED_API_MAX_TOKENS = 4096 

727 

728 

729def _hosted_api_status_code(status: int) -> str: 

730 """Map a hosted-API HTTP status to a typed error code (issue #430). 

731 

732 Shared by every hosted-API adapter — identical mapping to 

733 ``LocalAdapter.classify_http_status`` (401/403 → auth, 429 → rate limit), 

734 kept as a free function since it has no per-adapter state. 

735 """ 

736 if status in (401, 403): 

737 return ERR_AUTH_REQUIRED 

738 if status == 429: 

739 return ERR_RATE_LIMITED 

740 return ERR_NONZERO_EXIT 

741 

742 

743def _post_json( 

744 url: str, payload: dict, headers: dict[str, str], timeout: int 

745) -> tuple[dict | None, str | None, str | None]: 

746 """POST a JSON body and parse a JSON response (issue #430). 

747 

748 Shared HTTP mechanics for the hosted-API adapters: build the request, 

749 route it through the SSRF-safe opener (``_open``, no file/ftp handlers, no 

750 redirect following — the same seam ``LocalAdapter`` uses), cap the response 

751 read at ``_MAX_RESPONSE_BYTES``, and classify any failure into a typed 

752 error code. Returns ``(response_dict, None, None)`` on success or 

753 ``(None, error_message, error_code)`` on failure — exactly one shape. 

754 Response bodies are redacted before being returned in an error message 

755 since they originate from the network and are surfaced in the report. 

756 """ 

757 import json as _json 

758 import urllib.error 

759 import urllib.request 

760 

761 body = _json.dumps(payload).encode("utf-8") 

762 req = urllib.request.Request(url, data=body, headers=headers, method="POST") 

763 try: 

764 with _open(req, timeout) as resp: # noqa: S310 

765 raw = resp.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace") 

766 return _json.loads(raw), None, None 

767 except urllib.error.HTTPError as exc: 

768 detail = "" 

769 try: 

770 detail = exc.read(_MAX_RESPONSE_BYTES).decode("utf-8", errors="replace")[:300] 

771 except Exception: # noqa: BLE001 - reading the error body is best-effort 

772 detail = exc.reason or "" 

773 detail = redaction.redact(detail)[0] 

774 return None, f"HTTP {exc.code}: {detail}", _hosted_api_status_code(exc.code) 

775 except TimeoutError: 

776 return None, f"timed out after {timeout}s", ERR_TIMEOUT 

777 except urllib.error.URLError as exc: 

778 return ( 

779 None, 

780 f"could not reach {url}: {redaction.redact(str(exc.reason))[0]}", 

781 ERR_CONNECTION, 

782 ) 

783 except Exception as exc: # noqa: BLE001 - surface any other failure 

784 return None, f"request failed: {redaction.redact(str(exc))[0]}", ERR_UNKNOWN 

785 

786 

787class _HostedApiAdapter(Adapter): 

788 """Shared base for hosted-vendor-API reviewers keyed by an env-var API key. 

789 

790 No CLI install, no interactive login, no subprocess: just an HTTP call 

791 over stdlib ``urllib`` to the vendor's real hosted API (issue #430), the 

792 same no-subprocess/no-new-dependency design as ``LocalAdapter`` but 

793 pointed at a hosted endpoint instead of a local server. The API key is 

794 read from the environment ONLY, never from ``jury.toml``, so it cannot 

795 leak into a checked-in config; the endpoint is a fixed per-vendor 

796 constant, not a config value, so there is no SSRF surface to guard the 

797 way `local`'s `endpoint` needs. 

798 """ 

799 

800 SUPPORTS_HEADLESS = True 

801 SUPPORTS_MODEL_SELECTION = True 

802 

803 # Subclasses override. 

804 _API_KEY_ENV: str = "" 

805 

806 def _api_key_env(self) -> str: 

807 return (getattr(self.spec, "api_key_env", None) or self._API_KEY_ENV) or "OPENAI_API_KEY" 

808 

809 def _api_key(self) -> str: 

810 return os.environ.get(self._api_key_env(), "") 

811 

812 def _api_url(self) -> str: # pragma: no cover - overridden 

813 raise NotImplementedError 

814 

815 def _invalid_key_reason(self) -> str | None: 

816 """None if the key is safe to use as an HTTP header value; else why not. 

817 

818 A key containing a control character (most plausibly a stray 

819 trailing ``\\n`` from a file/k8s-secret/`.env` mount) trips CPython's 

820 ``http.client`` header-injection guard. That guard reports the 

821 rejected value via ``repr()`` (e.g. an embedded newline becomes the 

822 two literal characters ``\\`` ``n``), which does **not** byte-for-byte 

823 match the raw key — so a literal substring scrub of the exception 

824 text (see :meth:`_scrub_secret`) cannot reliably catch it; the 

825 transformed text is no longer equal to the original secret. Validate 

826 and reject *before* the key ever reaches a header instead of trying 

827 to scrub it back out afterward. 

828 """ 

829 if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in self._api_key()): 

830 return ( 

831 f"{self._api_key_env()} contains a control character (e.g. a stray " 

832 f"trailing newline from how the secret was loaded) and cannot be " 

833 f"used as an HTTP header value" 

834 ) 

835 return None 

836 

837 def _scrub_secret(self, text: str) -> str: 

838 """Strip the literal API key value from an error message (issue #430). 

839 

840 Defense-in-depth alongside ``redaction.redact()`` (which only 

841 recognizes known vendor-token *shapes* via regex) for any leak path 

842 NOT already ruled out by :meth:`_invalid_key_reason` — e.g. a 

843 well-formed key that still ends up quoted in some other library's 

844 error text. Not a substitute for that check: once a value contains 

845 control characters, downstream formatting (``repr()``, percent- 

846 encoding, ...) can transform it before it reaches an error message, 

847 and a literal match against the *original* key would then silently 

848 miss it — which is exactly why control characters are rejected 

849 upfront in :meth:`run` instead of relying on this alone. 

850 """ 

851 key = self._api_key() 

852 if key and key in text: 

853 return text.replace(key, "[REDACTED]") 

854 return text 

855 

856 def available(self) -> bool: 

857 """Available when a *usable* API key is set — a fast, network-free check. 

858 

859 Unlike ``LocalAdapter.available()`` (which probes the server, since a 

860 local endpoint's reachability is genuinely uncertain), a hosted 

861 vendor's API is assumed reachable; the two real unknowns locally are 

862 whether the operator configured a key at all, and whether it's 

863 actually usable as a header value (see :meth:`_invalid_key_reason`) — 

864 a key that will be rejected by :meth:`run` should not report as 

865 available here either, or a capability check (``jury --doctor``) 

866 would give a falsely reassuring answer. 

867 """ 

868 return bool(self._api_key()) and self._invalid_key_reason() is None 

869 

870 def detect_capabilities(self) -> dict: 

871 key_set = bool(self._api_key()) 

872 invalid_reason = self._invalid_key_reason() if key_set else None 

873 has_key = key_set and invalid_reason is None 

874 if not key_set: 

875 warnings = [f"{self._api_key_env()} is not set in the environment"] 

876 elif invalid_reason: 

877 warnings = [invalid_reason] 

878 else: 

879 warnings = [] 

880 return { 

881 "version": None, 

882 "supports_headless": self.SUPPORTS_HEADLESS, 

883 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION, 

884 "raw_version_output": f"hosted API {self._api_url()}", 

885 "status": CAP_OK if has_key else CAP_UNAVAILABLE, 

886 "warnings": warnings, 

887 } 

888 

889 def build_payload(self, prompt: str) -> dict: # pragma: no cover - overridden 

890 raise NotImplementedError 

891 

892 def _headers(self) -> dict[str, str]: # pragma: no cover - overridden 

893 raise NotImplementedError 

894 

895 @staticmethod 

896 def parse_content(data: dict) -> str: # pragma: no cover - overridden 

897 raise NotImplementedError 

898 

899 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult: 

900 del phase 

901 # Checked independently of available() (not just "not available()"): 

902 # available() now also returns False for a key that IS set but 

903 # invalid, and that case needs its own distinct error_code/message 

904 # below rather than the misleading "is not set" one. 

905 if not self._api_key(): 

906 return AgentResult( 

907 self.name, 

908 self.spec.vendor, 

909 False, 

910 "", 

911 0.0, 

912 f"{self._api_key_env()} is not set in the environment", 

913 error_code=ERR_MISSING_API_KEY, 

914 ) 

915 invalid_reason = self._invalid_key_reason() 

916 if invalid_reason is not None: 

917 # Reject before the key ever reaches a header — see 

918 # _invalid_key_reason for why post-hoc scrubbing can't be trusted 

919 # here. This message never echoes the key itself. 

920 return AgentResult( 

921 self.name, 

922 self.spec.vendor, 

923 False, 

924 "", 

925 0.0, 

926 invalid_reason, 

927 error_code=ERR_INVALID_API_KEY, 

928 ) 

929 effective_timeout = self.spec.timeout 

930 if timeout is not None: 

931 effective_timeout = max(1, min(self.spec.timeout, int(timeout))) 

932 start = time.monotonic() 

933 data, err_msg, err_code = _post_json( 

934 self._api_url(), self.build_payload(prompt), self._headers(), effective_timeout 

935 ) 

936 dur = time.monotonic() - start 

937 if err_msg is not None: 

938 return AgentResult( 

939 self.name, 

940 self.spec.vendor, 

941 False, 

942 "", 

943 dur, 

944 self._scrub_secret(err_msg), 

945 error_code=err_code, 

946 ) 

947 content = self.parse_content(data or {}) 

948 if not content: 

949 return AgentResult( 

950 self.name, 

951 self.spec.vendor, 

952 False, 

953 "", 

954 dur, 

955 "hosted API returned empty content", 

956 error_code=ERR_EMPTY_OUTPUT, 

957 ) 

958 return AgentResult(self.name, self.spec.vendor, True, content, dur) 

959 

960 

961class AnthropicApiAdapter(_HostedApiAdapter): 

962 """Hosted Anthropic Messages API reviewer, keyed by ``ANTHROPIC_API_KEY`` (issue #430). 

963 

964 Configure as a normal ``[[agent]]`` with ``vendor = "anthropic-api"`` and a 

965 ``model`` (e.g. a current Claude model id) — no ``command``, no ``claude`` 

966 CLI install or interactive login needed. 

967 """ 

968 

969 _API_KEY_ENV = "ANTHROPIC_API_KEY" 

970 

971 def _api_url(self) -> str: 

972 return _ANTHROPIC_API_URL 

973 

974 def build_payload(self, prompt: str) -> dict: 

975 """Build the Anthropic Messages API request body (pure).""" 

976 return { 

977 "model": self.spec.model or "", 

978 "max_tokens": _HOSTED_API_MAX_TOKENS, 

979 "messages": [{"role": "user", "content": prompt}], 

980 } 

981 

982 def _headers(self) -> dict[str, str]: 

983 return { 

984 "Content-Type": "application/json", 

985 "x-api-key": self._api_key(), 

986 "anthropic-version": _ANTHROPIC_API_VERSION, 

987 } 

988 

989 @staticmethod 

990 def parse_content(data: dict) -> str: 

991 """Extract the assistant text from a Messages API response.""" 

992 if not isinstance(data, dict): 

993 return "" 

994 blocks = data.get("content") or [] 

995 texts = [ 

996 block.get("text", "") 

997 for block in blocks 

998 if isinstance(block, dict) and block.get("type") == "text" 

999 ] 

1000 return "".join(texts).strip() 

1001 

1002 

1003class OpenAiApiAdapter(_HostedApiAdapter): 

1004 """Hosted OpenAI Chat Completions API reviewer, keyed by ``OPENAI_API_KEY`` (issue #430). 

1005 

1006 Configure as a normal ``[[agent]]`` with ``vendor = "openai-api"`` and a 

1007 ``model`` (e.g. a current GPT model id) — no ``command``, no ``codex`` CLI 

1008 install or interactive login needed. Same request/response shape as 

1009 ``LocalAdapter`` (both are OpenAI-compatible chat completions), just 

1010 against the real hosted API with an ``Authorization`` header. 

1011 """ 

1012 

1013 _API_KEY_ENV = "OPENAI_API_KEY" 

1014 

1015 def _api_url(self) -> str: 

1016 return _OPENAI_API_URL 

1017 

1018 def build_payload(self, prompt: str) -> dict: 

1019 """Build the OpenAI chat-completions request body (pure).""" 

1020 return { 

1021 "model": self.spec.model or "", 

1022 "messages": [{"role": "user", "content": prompt}], 

1023 } 

1024 

1025 def _headers(self) -> dict[str, str]: 

1026 return { 

1027 "Content-Type": "application/json", 

1028 "Authorization": f"Bearer {self._api_key()}", 

1029 } 

1030 

1031 @staticmethod 

1032 def parse_content(data: dict) -> str: 

1033 """Extract the assistant message text from a chat-completions response.""" 

1034 if not isinstance(data, dict): 

1035 return "" 

1036 choices = data.get("choices") or [] 

1037 if not choices: 

1038 return "" 

1039 message = choices[0].get("message") or {} 

1040 return (message.get("content") or "").strip() 

1041 

1042 

1043_GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models" 

1044 

1045 

1046class GoogleApiAdapter(_HostedApiAdapter): 

1047 """Hosted Google Gemini API reviewer, keyed by ``GEMINI_API_KEY`` (issue #432). 

1048 

1049 Configure as a normal ``[[agent]]`` with ``vendor = "google-api"`` and a 

1050 ``model`` (e.g. a current Gemini model id) — no ``command``, no ``agy`` 

1051 CLI install or interactive login needed. 

1052 

1053 Two differences from the other two hosted adapters: 

1054 

1055 - The Gemini API embeds the model id in the URL **path** 

1056 (``.../models/{model}:generateContent``), not the request body, so 

1057 ``_api_url()`` is built from ``self.spec.model`` on every call rather 

1058 than returning a fixed constant like the other two adapters. 

1059 - The key is sent via the ``x-goog-api-key`` header. Gemini also accepts 

1060 the key as a ``?key=...`` query parameter, but a query-string key is a 

1061 much easier accidental-leak vector (proxy/access logs, anything that 

1062 prints the request URL) than a header — deliberately not supported. 

1063 

1064 A prompt blocked by Gemini's safety filters comes back with an empty 

1065 ``candidates`` list (and a ``promptFeedback.blockReason``); this is not 

1066 distinguished from a genuinely empty response and both currently surface 

1067 as the same generic ``ERR_EMPTY_OUTPUT`` — a possible future refinement, 

1068 not required for parity with the other two adapters. 

1069 """ 

1070 

1071 _API_KEY_ENV = "GEMINI_API_KEY" 

1072 

1073 def _api_url(self) -> str: 

1074 # Escape the model id as a single path segment (issue #432 review): an 

1075 # operator-configured model containing reserved URL characters 

1076 # (`/`, `?`, `#`, ...) would otherwise change the request's path/query 

1077 # semantics instead of staying a single `{model}` segment. 

1078 import urllib.parse 

1079 

1080 model = urllib.parse.quote(self.spec.model or "", safe="") 

1081 return f"{_GEMINI_API_BASE}/{model}:generateContent" 

1082 

1083 def build_payload(self, prompt: str) -> dict: 

1084 """Build the Gemini ``generateContent`` request body (pure).""" 

1085 return {"contents": [{"parts": [{"text": prompt}]}]} 

1086 

1087 def _headers(self) -> dict[str, str]: 

1088 return { 

1089 "Content-Type": "application/json", 

1090 "x-goog-api-key": self._api_key(), 

1091 } 

1092 

1093 @staticmethod 

1094 def parse_content(data: dict) -> str: 

1095 """Extract the assistant text from a ``generateContent`` response.""" 

1096 if not isinstance(data, dict): 

1097 return "" 

1098 candidates = data.get("candidates") or [] 

1099 if not candidates or not isinstance(candidates[0], dict): 

1100 return "" 

1101 content = candidates[0].get("content") 

1102 if not isinstance(content, dict): 

1103 return "" 

1104 parts = content.get("parts") or [] 

1105 texts = [ 

1106 part.get("text", "") 

1107 for part in parts 

1108 if isinstance(part, dict) and isinstance(part.get("text", ""), str) 

1109 ] 

1110 return "".join(texts).strip() 

1111 

1112 

1113class MockAdapter(Adapter): 

1114 """Offline adapter for tests and ``--mock`` runs. 

1115 

1116 Produces deterministic, phase-aware text so the full orchestration pipeline 

1117 can run end-to-end without live CLIs, auth, or token spend. 

1118 """ 

1119 

1120 # Synthetic capabilities: the mock is offline and runs no real CLI. 

1121 SUPPORTS_HEADLESS = True 

1122 SUPPORTS_MODEL_SELECTION = False 

1123 

1124 def available(self) -> bool: 

1125 return True 

1126 

1127 def detect_capabilities(self) -> dict: 

1128 """Deterministic fake capabilities so doctor/tests stay stable offline.""" 

1129 return { 

1130 "version": "mock-1.0", 

1131 "supports_headless": self.SUPPORTS_HEADLESS, 

1132 "supports_model_selection": self.SUPPORTS_MODEL_SELECTION, 

1133 "raw_version_output": "mock-1.0", 

1134 "status": CAP_OK, 

1135 "warnings": [], 

1136 } 

1137 

1138 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult: 

1139 del prompt, timeout 

1140 n = self.name 

1141 if phase == "review": 

1142 body = ( 

1143 f"- **[major]** `src/example.py:42` — {n}: unchecked return value " 

1144 f"may swallow an error.\n" 

1145 f"- **[minor]** `src/example.py:7` — {n}: missing docstring.\n\n" 

1146 "```json\n" 

1147 "[\n" 

1148 ' {"severity": "major", "file": "src/example.py", "line": 42, ' 

1149 f'"claim": "{n}: unchecked return value may swallow an error", ' 

1150 '"evidence": "the added code ignores the return value of int(x)", ' 

1151 '"suggested_fix": "check the result and raise on failure", ' 

1152 f'"confidence": "high", "reviewer": "{n}"}},\n' 

1153 ' {"severity": "minor", "file": "src/example.py", "line": 7, ' 

1154 f'"claim": "{n}: missing docstring", ' 

1155 '"evidence": "the new function parse() has no docstring", ' 

1156 '"suggested_fix": "add a one-line docstring", ' 

1157 f'"confidence": "medium", "reviewer": "{n}"}}\n' 

1158 "]\n" 

1159 "```" 

1160 ) 

1161 elif phase == "debate": 

1162 body = ( 

1163 f"## AGREE\n- {n}: confirm the unchecked-return finding at " 

1164 f"`src/example.py:42`.\n" 

1165 f"## DISPUTE\n- {n}: the missing-docstring finding is a nit, not blocking.\n" 

1166 f"## MISSED\n- {n}: no test covers the error branch." 

1167 ) 

1168 elif phase == "verify": 

1169 body = ( 

1170 "Verification: confirming the unchecked-return finding at " 

1171 "`src/example.py:42`; the missing-docstring claim at `:7` is a nit " 

1172 "not supported as blocking.\n\n" 

1173 "```json\n" 

1174 "[\n" 

1175 ' {"file": "src/example.py", "line": 42, ' 

1176 '"claim": "unchecked return value may swallow an error", ' 

1177 '"status": "verified", ' 

1178 '"reasoning": "the added code ignores the return value of int(x)"},\n' 

1179 ' {"file": "src/example.py", "line": 7, ' 

1180 '"claim": "missing docstring", ' 

1181 '"status": "unsupported", ' 

1182 '"reasoning": "a missing docstring is not a defect the diff introduces"}\n' 

1183 "]\n" 

1184 "```" 

1185 ) 

1186 else: # synthesis 

1187 body = ( 

1188 "## Verdict\nREQUEST CHANGES — one confirmed major issue.\n\n" 

1189 "## Consensus findings\n- **[major]** `src/example.py:42` — unchecked " 

1190 "return value (raised by all reviewers).\n\n" 

1191 "## Disputed findings\n- Missing docstring: ruled non-blocking.\n\n" 

1192 "## Notable single-reviewer findings\n- Missing test for the error branch." 

1193 ) 

1194 return AgentResult(n, self.spec.vendor, True, body, 0.0) 

1195 

1196 

1197class GenericOpenAICompatibleAdapter(_HostedApiAdapter): 

1198 """Hosted OpenAI-compatible API reviewer (OpenRouter, DeepSeek, Groq, Mistral API, LiteLLM, etc.). 

1199 

1200 Supports custom ``endpoint``, custom ``api_key_env``, and extra HTTP ``headers``. 

1201 """ 

1202 

1203 _API_KEY_ENV = "OPENAI_API_KEY" 

1204 

1205 def _api_url(self) -> str: 

1206 endpoint = (self.spec.endpoint or _OPENAI_API_URL).rstrip("/") 

1207 if endpoint.endswith("/chat/completions"): 1207 ↛ 1208line 1207 didn't jump to line 1208 because the condition on line 1207 was never true

1208 return endpoint 

1209 return f"{endpoint}/chat/completions" 

1210 

1211 def build_payload(self, prompt: str) -> dict: 

1212 return { 

1213 "model": self.spec.model or "", 

1214 "messages": [{"role": "user", "content": prompt}], 

1215 } 

1216 

1217 def _headers(self) -> dict[str, str]: 

1218 hdrs = { 

1219 "Content-Type": "application/json", 

1220 "Authorization": f"Bearer {self._api_key()}", 

1221 } 

1222 if self.spec.headers: 1222 ↛ 1224line 1222 didn't jump to line 1224 because the condition on line 1222 was always true

1223 hdrs.update(self.spec.headers) 

1224 return hdrs 

1225 

1226 @staticmethod 

1227 def parse_content(data: dict) -> str: 

1228 if not isinstance(data, dict): 1228 ↛ 1229line 1228 didn't jump to line 1229 because the condition on line 1228 was never true

1229 return "" 

1230 choices = data.get("choices") or [] 

1231 if not choices or not isinstance(choices, list): 

1232 return "" 

1233 first = choices[0] 

1234 if not isinstance(first, dict): 1234 ↛ 1235line 1234 didn't jump to line 1235 because the condition on line 1234 was never true

1235 return "" 

1236 msg = first.get("message") or {} 

1237 if not isinstance(msg, dict): 1237 ↛ 1238line 1237 didn't jump to line 1238 because the condition on line 1237 was never true

1238 return "" 

1239 raw_content = msg.get("content") 

1240 if isinstance(raw_content, str): 

1241 return raw_content.strip() 

1242 if isinstance(raw_content, list): 1242 ↛ 1253line 1242 didn't jump to line 1253 because the condition on line 1242 was always true

1243 parts = [] 

1244 for item in raw_content: 

1245 if isinstance(item, dict): 1245 ↛ 1250line 1245 didn't jump to line 1250 because the condition on line 1245 was always true

1246 if (item.get("type") == "text" or "text" in item) and isinstance( 1246 ↛ 1244line 1246 didn't jump to line 1244 because the condition on line 1246 was always true

1247 item.get("text"), str 

1248 ): 

1249 parts.append(item["text"]) 

1250 elif isinstance(item, str): 

1251 parts.append(item) 

1252 return "".join(parts).strip() 

1253 return "" 

1254 

1255 

1256class GenericCLIAdapter(Adapter): 

1257 """Generic CLI adapter for arbitrary coding-agent CLIs (Aider, Goose, OpenHands, Copilot CLI, etc.). 

1258 

1259 Supports configurable prompt delivery modes: 

1260 - ``prompt_mode = "stdin"`` (default): prompt passed via STDIN 

1261 - ``prompt_mode = "arg"``: prompt passed as positional argument on argv 

1262 """ 

1263 

1264 def available(self) -> bool: 

1265 command = self.spec.command or "" 

1266 if not command: 1266 ↛ 1267line 1266 didn't jump to line 1267 because the condition on line 1266 was never true

1267 return False 

1268 return shutil.which(command) is not None 

1269 

1270 def detect_capabilities(self) -> dict: 

1271 if not self.available(): 

1272 return { 

1273 "version": None, 

1274 "supports_headless": None, 

1275 "supports_model_selection": None, 

1276 "raw_version_output": "", 

1277 "status": CAP_UNAVAILABLE, 

1278 "warnings": [f"command '{self.spec.command}' not found on PATH"], 

1279 } 

1280 return { 

1281 "version": "generic-cli", 

1282 "supports_headless": True, 

1283 "supports_model_selection": bool(self.spec.model), 

1284 "raw_version_output": "generic-cli", 

1285 "status": CAP_OK, 

1286 "warnings": [], 

1287 } 

1288 

1289 def run(self, prompt: str, phase: str = "review", timeout: int | None = None) -> AgentResult: 

1290 del phase 

1291 if not self.available(): 1291 ↛ 1292line 1291 didn't jump to line 1292 because the condition on line 1291 was never true

1292 return AgentResult.failed( 

1293 self.spec.name, 

1294 self.spec.vendor, 

1295 ERR_MISSING_CLI, 

1296 f"command '{self.spec.command}' not found on PATH.", 

1297 ) 

1298 effective_timeout = timeout if timeout is not None else self.spec.timeout 

1299 extra_args = _read_only_extra_args(self.spec) 

1300 argv = [self.spec.command, *extra_args] 

1301 

1302 mode = (self.spec.prompt_mode or "stdin").lower() 

1303 stdin_content = None 

1304 

1305 if mode == "arg": 

1306 argv.append(prompt) 

1307 else: 

1308 stdin_content = prompt 

1309 

1310 start = time.monotonic() 

1311 try: 

1312 res = _spawn(argv, stdin_content, timeout=effective_timeout) 

1313 except subprocess.TimeoutExpired: 

1314 return AgentResult( 

1315 self.spec.name, 

1316 self.spec.vendor, 

1317 False, 

1318 "", 

1319 effective_timeout, 

1320 f"execution timed out after {effective_timeout}s.", 

1321 error_code=ERR_TIMEOUT, 

1322 ) 

1323 except Exception as exc: 

1324 duration = time.monotonic() - start 

1325 return AgentResult( 

1326 self.spec.name, 

1327 self.spec.vendor, 

1328 False, 

1329 "", 

1330 duration, 

1331 f"failed to spawn '{self.spec.command}': {redaction.redact(str(exc))[0]}", 

1332 error_code=ERR_SPAWN_FAILED, 

1333 ) 

1334 

1335 duration = time.monotonic() - start 

1336 out = (res.stdout or "").strip() 

1337 err = (res.stderr or "").strip() 

1338 

1339 if res.returncode != 0: 

1340 detail = err or out or f"exited with code {res.returncode}" 

1341 safe_detail = redaction.redact(detail)[0] 

1342 err_code = classify_stderr(res.returncode, err or out) 

1343 return AgentResult( 

1344 self.spec.name, 

1345 self.spec.vendor, 

1346 False, 

1347 "", 

1348 duration, 

1349 f"exit {res.returncode}: {safe_detail[:500]}", 

1350 error_code=err_code, 

1351 ) 

1352 

1353 if not out: 1353 ↛ 1354line 1353 didn't jump to line 1354 because the condition on line 1353 was never true

1354 return AgentResult( 

1355 self.spec.name, 

1356 self.spec.vendor, 

1357 False, 

1358 "", 

1359 duration, 

1360 "agent produced empty output", 

1361 error_code=ERR_EMPTY_OUTPUT, 

1362 ) 

1363 

1364 return AgentResult(self.spec.name, self.spec.vendor, True, out, duration) 

1365 

1366 

1367_VENDOR_ADAPTERS: dict[str, type[Adapter]] = { 

1368 "anthropic": ClaudeAdapter, 

1369 "openai": CodexAdapter, 

1370 "google": AgyAdapter, 

1371 "local": LocalAdapter, 

1372 "anthropic-api": AnthropicApiAdapter, 

1373 "openai-api": OpenAiApiAdapter, 

1374 "google-api": GoogleApiAdapter, 

1375 "openai-compatible": GenericOpenAICompatibleAdapter, 

1376 "cli": GenericCLIAdapter, 

1377} 

1378 

1379 

1380def register_adapter(vendor: str, adapter_cls: type[Adapter]) -> None: 

1381 """Register a custom adapter class for a vendor string.""" 

1382 _VENDOR_ADAPTERS[vendor.lower()] = adapter_cls 

1383 

1384 

1385def make_adapter(spec: AgentSpec, mock: bool = False) -> Adapter: 

1386 if mock: 

1387 return MockAdapter(spec) 

1388 cls = _VENDOR_ADAPTERS.get((spec.vendor or "").lower()) 

1389 if cls is not None: 

1390 return cls(spec) 

1391 if spec.endpoint or (spec.api_key_env and not spec.command): 

1392 return GenericOpenAICompatibleAdapter(spec) 

1393 if spec.command: 1393 ↛ 1395line 1393 didn't jump to line 1395 because the condition on line 1393 was always true

1394 return GenericCLIAdapter(spec) 

1395 return AgyAdapter(spec)