tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged. Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals. Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
135 lines
5.9 KiB
Python
135 lines
5.9 KiB
Python
"""Codex CLI backend."""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
|
|
from tai_pkg.clirun import Cli
|
|
from tai_pkg.errors import BackendError
|
|
from tai_pkg.muse import Muse
|
|
from tai_pkg.opencode import Opencode
|
|
from tai_pkg.text import Text
|
|
|
|
__all__ = ["Codex"]
|
|
|
|
|
|
class Codex:
|
|
"""Codex."""
|
|
|
|
@staticmethod
|
|
def codex_binary():
|
|
return os.environ.get("CODEX_BIN") or shutil.which("codex")
|
|
|
|
@staticmethod
|
|
def codex_jsonl_texts(stdout):
|
|
texts = []
|
|
last_error = ""
|
|
for line in (stdout or "").splitlines():
|
|
line = line.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
event = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if not isinstance(event, dict):
|
|
continue
|
|
etype = event.get("type")
|
|
if etype == "item.completed":
|
|
item = event.get("item") or {}
|
|
if item.get("type") == "agent_message" and item.get("text"):
|
|
texts.append(item["text"])
|
|
elif etype in ("turn.failed", "error"):
|
|
msg = event.get("message") or event.get("error") or ""
|
|
if isinstance(msg, dict):
|
|
msg = msg.get("message") or json.dumps(msg)
|
|
if msg:
|
|
last_error = str(msg)[:300]
|
|
return texts, last_error
|
|
|
|
@staticmethod
|
|
def codex_parse_output(stdout, outfile_text="", used_schema=False):
|
|
if isinstance(outfile_text, str) and outfile_text.strip():
|
|
if used_schema:
|
|
try:
|
|
answer = json.loads(outfile_text)
|
|
except ValueError:
|
|
answer = None
|
|
if isinstance(answer, dict) and ("content" in answer or "tool_calls" in answer):
|
|
return answer
|
|
return Opencode.opencode_parse(outfile_text)
|
|
texts, last_error = Codex.codex_jsonl_texts(stdout)
|
|
if texts:
|
|
return Opencode.opencode_parse("".join(texts))
|
|
if last_error:
|
|
raise BackendError("codex: %s" % last_error)
|
|
return Opencode.opencode_parse(stdout)
|
|
|
|
@staticmethod
|
|
def codex_run(prompt, timeout):
|
|
binary = Codex.codex_binary()
|
|
if not binary:
|
|
raise BackendError("codex CLI not found (CODEX_BIN or PATH)")
|
|
schema = json.dumps(Muse.muse_output_schema())
|
|
with tempfile.TemporaryDirectory(prefix="tai-codex-") as folder:
|
|
schema_path = os.path.join(folder, "schema.json")
|
|
out_path = os.path.join(folder, "last.txt")
|
|
with open(schema_path, "w", encoding="utf-8") as handle:
|
|
handle.write(schema)
|
|
base = [binary, "exec", "--json", "--ephemeral", "--sandbox", "read-only", "--skip-git-repo-check"]
|
|
if len(prompt.encode("utf-8", "replace")) > 120000:
|
|
variants = [
|
|
(base + ["--output-schema", schema_path, "-o", out_path, "-"], prompt, True),
|
|
(base + ["-o", out_path, "-"], prompt, False),
|
|
]
|
|
else:
|
|
variants = [
|
|
(base + ["--output-schema", schema_path, "-o", out_path, prompt], None, True),
|
|
(base + ["-o", out_path, prompt], None, False),
|
|
(base + [prompt], None, False),
|
|
]
|
|
last_error = ""
|
|
for argv, prompt_input, used_schema in variants:
|
|
try:
|
|
done = subprocess.run(argv, input=prompt_input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, cwd=folder, timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
raise BackendError("codex run timed out after %ds" % timeout)
|
|
except OSError as exc:
|
|
raise BackendError("codex run failed: %s" % Text.short_error(exc))
|
|
if done.returncode != 0 and Cli._cli_unknown_flag(done.stderr) and (argv, prompt_input, used_schema) != variants[-1]:
|
|
last_error = (done.stderr or "").strip()[:200]
|
|
continue
|
|
outfile_text = ""
|
|
if "-o" in argv:
|
|
try:
|
|
with open(out_path, "r", encoding="utf-8", errors="replace") as handle:
|
|
outfile_text = handle.read()
|
|
except OSError:
|
|
outfile_text = ""
|
|
if done.returncode != 0 and not (outfile_text.strip() or (done.stdout or "").strip()):
|
|
blob = ((done.stderr or "") + "\n" + (done.stdout or "")).strip()
|
|
if Cli._cli_auth_failure(blob):
|
|
raise BackendError("codex not authenticated (run codex login): %s" % blob[:200])
|
|
raise BackendError("codex exit %d: %s" % (done.returncode, blob[:300] or last_error or "no output"))
|
|
if not (outfile_text.strip() or (done.stdout or "").strip()):
|
|
blob = (done.stderr or "").strip()
|
|
raise BackendError("codex returned no output: %s" % (blob[:300] or last_error or "empty stdout"))
|
|
return done.stdout or "", outfile_text, used_schema
|
|
raise BackendError("codex failed: %s" % (last_error or "no usable output"))
|
|
|
|
@staticmethod
|
|
def codex_health():
|
|
binary = Codex.codex_binary()
|
|
if not binary:
|
|
return "missing: codex CLI not found"
|
|
try:
|
|
done = subprocess.run([binary, "login", "status"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return "fail: %s" % Text.short_error(exc)[:60]
|
|
detail = Cli._cli_version_detail(done, "codex")
|
|
if done.returncode != 0:
|
|
return "not logged in: %s" % detail
|
|
return "ok, subscription (%s)" % detail
|