330 lines
11 KiB
Python
330 lines
11 KiB
Python
# OpenAI Codex CLI bridge
|
|
#
|
|
# Runs the locally installed `codex` binary headlessly (`codex exec --json`)
|
|
# and hands back its answer, so the H3 nodes can write with a ChatGPT / Codex
|
|
# subscription seat the same way they write with a Claude Code seat.
|
|
#
|
|
# Differences from the Claude Code bridge that shape this file:
|
|
#
|
|
# * Codex has no system-prompt flag, so the specification is folded into the
|
|
# user message under the same framing run_claude_code uses for oversized
|
|
# system prompts. The prompt itself travels over stdin (`codex exec -`),
|
|
# which also dodges the Windows 32,767-character argv limit.
|
|
# * Images have no inline transport; they are written as PNGs to the scratch
|
|
# folder and attached with `-i`.
|
|
# * Sessions are Codex "threads": `codex exec resume <id>` continues one.
|
|
# The ids handed back to the graph are prefixed `codex-` so a Claude Code
|
|
# session id can never be fed to Codex by accident (and vice versa).
|
|
# * The final answer is read from `--output-last-message` first - the event
|
|
# stream has changed shape between Codex releases, so the file is the
|
|
# stable contract and the parsed events are the fallback.
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import queue
|
|
|
|
from .claude_code import (
|
|
_KILL_GRACE_SECONDS,
|
|
_TICK_SECONDS,
|
|
_model_management,
|
|
_processing_interrupted,
|
|
_pump,
|
|
_stop,
|
|
_subprocess_flags,
|
|
)
|
|
|
|
_EMBEDDED_SYSTEM_FRAMING = (
|
|
"=== BEGIN SPECIFICATION - follow it exactly ===\n"
|
|
"{system_prompt}\n"
|
|
"=== END SPECIFICATION ===\n\n"
|
|
"{prompt}"
|
|
)
|
|
|
|
_cli_cache = {"path": None, "checked": False}
|
|
|
|
|
|
class CodexCLIError(RuntimeError):
|
|
"""The Codex CLI is missing, timed out, or reported a failure."""
|
|
|
|
|
|
def find_cli(refresh=False):
|
|
"""Absolute path to the codex binary, or None when it is not installed."""
|
|
if _cli_cache["checked"] and not refresh:
|
|
return _cli_cache["path"]
|
|
|
|
candidates = []
|
|
override = os.environ.get("CODEX_CLI_PATH")
|
|
if override:
|
|
candidates.append(override)
|
|
|
|
found = shutil.which("codex")
|
|
if found:
|
|
candidates.append(found)
|
|
|
|
home = os.path.expanduser("~")
|
|
candidates.extend(
|
|
[
|
|
os.path.join(home, ".local", "bin", "codex.exe"),
|
|
os.path.join(home, ".local", "bin", "codex"),
|
|
os.path.join(os.environ.get("APPDATA", ""), "npm", "codex.cmd"),
|
|
os.path.join(home, ".bun", "bin", "codex.exe"),
|
|
os.path.join(home, ".volta", "bin", "codex.exe"),
|
|
"/usr/local/bin/codex",
|
|
"/opt/homebrew/bin/codex",
|
|
]
|
|
)
|
|
|
|
path = next((c for c in candidates if c and os.path.isfile(c)), None)
|
|
_cli_cache["path"] = path
|
|
_cli_cache["checked"] = True
|
|
return path
|
|
|
|
|
|
def is_available():
|
|
return find_cli() is not None
|
|
|
|
|
|
def _child_env(use_subscription):
|
|
"""
|
|
Environment for the child process.
|
|
|
|
OPENAI_API_KEY is dropped by default so Codex uses the ChatGPT login in
|
|
~/.codex/auth.json (the subscription seat) instead of billing the API key.
|
|
"""
|
|
env = os.environ.copy()
|
|
if use_subscription:
|
|
env.pop("OPENAI_API_KEY", None)
|
|
return env
|
|
|
|
|
|
def _describe_item(item):
|
|
"""A short progress line for one event item, or None when not worth showing."""
|
|
kind = item.get("type")
|
|
if kind == "command_execution":
|
|
command = " ".join((item.get("command") or "").split())
|
|
return f"running {command[:90]}" if command else "running a command"
|
|
if kind == "web_search":
|
|
q = (item.get("query") or "").strip()
|
|
return f"searching the web{f': {q[:70]}' if q else ''}"
|
|
if kind == "mcp_tool_call":
|
|
return f"using {item.get('tool') or 'a tool'}"
|
|
if kind == "agent_message":
|
|
snippet = " ".join((item.get("text") or "").split())
|
|
if snippet:
|
|
return snippet[:110] + ("..." if len(snippet) > 110 else "")
|
|
return None
|
|
|
|
|
|
def run_codex(
|
|
prompt,
|
|
system_prompt=None,
|
|
images=None,
|
|
model=None,
|
|
timeout=600,
|
|
research=False,
|
|
working_dir=None,
|
|
resume_session_id=None,
|
|
use_subscription=True,
|
|
on_progress=None,
|
|
):
|
|
"""
|
|
Run one headless Codex turn and return its result plus telemetry.
|
|
|
|
Mirrors run_claude_code's contract: `images` is a list of PIL images,
|
|
`model` is the Codex model id (None/"" = whatever ~/.codex/config.toml
|
|
picks), `research` turns the web-search tool on, `resume_session_id` is a
|
|
BARE Codex thread id (no `codex-` prefix). Returns a dict with `text`,
|
|
`session_id` (bare thread id), `duration_ms`, `num_turns`, `model`,
|
|
`cost_usd` (always 0.0 - the CLI does not report cost). Raises
|
|
CodexCLIError on failure, and lets a ComfyUI cancellation propagate.
|
|
"""
|
|
cli = find_cli()
|
|
if not cli:
|
|
raise CodexCLIError(
|
|
"The Codex CLI was not found. Install it with `npm install -g @openai/codex` "
|
|
"and log in once with `codex login`, or set CODEX_CLI_PATH to the binary."
|
|
)
|
|
|
|
body = prompt
|
|
if system_prompt:
|
|
body = _EMBEDDED_SYSTEM_FRAMING.format(system_prompt=system_prompt, prompt=prompt)
|
|
|
|
scratch = tempfile.mkdtemp(prefix="apnext_codex_")
|
|
cwd = working_dir if working_dir and os.path.isdir(working_dir) else scratch
|
|
last_message_path = os.path.join(scratch, "last_message.txt")
|
|
|
|
args = [cli, "exec"]
|
|
if resume_session_id:
|
|
args += ["resume", resume_session_id]
|
|
args += [
|
|
"--json",
|
|
"--skip-git-repo-check",
|
|
"--sandbox", "read-only",
|
|
"--output-last-message", last_message_path,
|
|
]
|
|
if model and model not in ("default", "codex"):
|
|
args += ["--model", model]
|
|
if research:
|
|
# Web search is a config key rather than a stable flag across releases.
|
|
args += ["-c", "tools.web_search=true"]
|
|
for i, image in enumerate(images or [], 1):
|
|
image_path = os.path.join(scratch, f"reference_{i}.png")
|
|
image.convert("RGB").save(image_path, format="PNG")
|
|
args += ["-i", image_path]
|
|
args.append("-") # read the prompt from stdin
|
|
|
|
events = queue.Queue()
|
|
thread_id = resume_session_id or ""
|
|
agent_text = ""
|
|
error_detail = ""
|
|
num_turns = 0
|
|
stderr_lines = []
|
|
open_streams = 2
|
|
started = time.monotonic()
|
|
|
|
try:
|
|
try:
|
|
process = subprocess.Popen(
|
|
args,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
bufsize=1,
|
|
cwd=cwd,
|
|
env=_child_env(use_subscription),
|
|
**_subprocess_flags(),
|
|
)
|
|
except OSError as exc:
|
|
raise CodexCLIError(f"Could not start the Codex CLI at {cli}: {exc}")
|
|
|
|
def _write_stdin():
|
|
try:
|
|
process.stdin.write(body)
|
|
process.stdin.flush()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
process.stdin.close()
|
|
except Exception:
|
|
pass
|
|
|
|
threading.Thread(target=_write_stdin, daemon=True).start()
|
|
threading.Thread(target=_pump, args=(process.stdout, events, "out"), daemon=True).start()
|
|
threading.Thread(target=_pump, args=(process.stderr, events, "err"), daemon=True).start()
|
|
|
|
deadline = time.monotonic() + timeout
|
|
|
|
while open_streams:
|
|
try:
|
|
tag, line = events.get(timeout=_TICK_SECONDS)
|
|
except queue.Empty:
|
|
if _processing_interrupted():
|
|
_stop(process)
|
|
_model_management.throw_exception_if_processing_interrupted()
|
|
if time.monotonic() > deadline:
|
|
_stop(process)
|
|
raise CodexCLIError(
|
|
f"Codex did not finish within {timeout}s. Raise the timeout, "
|
|
"or use a faster model."
|
|
)
|
|
continue
|
|
|
|
if line is None:
|
|
open_streams -= 1
|
|
continue
|
|
|
|
if tag == "err":
|
|
text = line.strip()
|
|
if text:
|
|
stderr_lines.append(text)
|
|
continue
|
|
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
event = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
|
|
kind = event.get("type") or ""
|
|
|
|
if kind == "thread.started":
|
|
thread_id = event.get("thread_id") or thread_id
|
|
if on_progress and thread_id:
|
|
on_progress(f"session {thread_id[:8]} ready")
|
|
continue
|
|
if kind == "turn.completed":
|
|
num_turns += 1
|
|
continue
|
|
if kind in ("turn.failed", "error"):
|
|
err = event.get("error") or {}
|
|
error_detail = (
|
|
(err.get("message") if isinstance(err, dict) else None)
|
|
or event.get("message") or error_detail or kind
|
|
)
|
|
continue
|
|
if kind.startswith("item."):
|
|
item = event.get("item") or {}
|
|
if item.get("type") == "agent_message":
|
|
agent_text = item.get("text") or agent_text
|
|
if on_progress:
|
|
description = _describe_item(item)
|
|
if description:
|
|
on_progress(description)
|
|
continue
|
|
|
|
# Pre-0.44 event shape: {"id": ..., "msg": {"type": ..., ...}}
|
|
msg = event.get("msg")
|
|
if isinstance(msg, dict):
|
|
msg_kind = msg.get("type") or ""
|
|
if msg_kind == "session_configured":
|
|
thread_id = msg.get("session_id") or thread_id
|
|
elif msg_kind == "agent_message":
|
|
agent_text = msg.get("message") or agent_text
|
|
elif msg_kind in ("error", "turn_failed"):
|
|
error_detail = msg.get("message") or error_detail or msg_kind
|
|
|
|
process.wait(timeout=_KILL_GRACE_SECONDS)
|
|
|
|
text = ""
|
|
try:
|
|
with open(last_message_path, encoding="utf-8") as handle:
|
|
text = handle.read().strip()
|
|
except OSError:
|
|
pass
|
|
text = text or agent_text.strip()
|
|
|
|
if not text:
|
|
detail = error_detail or " ".join(stderr_lines[-5:]) or f"exit code {process.returncode}"
|
|
if "login" in detail.lower() or "auth" in detail.lower():
|
|
detail += (
|
|
" - run `codex login` once in a terminal, or untick the "
|
|
"subscription switch to use OPENAI_API_KEY."
|
|
)
|
|
raise CodexCLIError(f"Codex returned no result: {detail}")
|
|
|
|
if error_detail and process.returncode not in (0, None):
|
|
raise CodexCLIError(f"Codex reported an error: {error_detail}")
|
|
|
|
return {
|
|
"text": text,
|
|
"session_id": thread_id or "",
|
|
"cost_usd": 0.0,
|
|
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
"num_turns": max(1, num_turns),
|
|
"model": model or "codex-default",
|
|
}
|
|
|
|
finally:
|
|
shutil.rmtree(scratch, ignore_errors=True)
|