File size: 6,241 Bytes
1727091
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Codex inference backend for SupraDashboard — LOCAL computing via the Codex CLI.

Mirror of :mod:`inference.openrouter` (same ``run_inference`` / ``available_models`` /
``default_model`` / ``healthcheck`` surface) but backed by ``codex exec``, billed to the
ChatGPT **subscription** the local ``codex`` CLI is logged into (no API key needed).

Intended for LOCAL runs only — a Hugging Face Space cannot hold codex's interactive
OAuth and the codex CLI isn't installed there, so :mod:`inference` forces OpenRouter
whenever it detects a Space. Adapted from Supra-R1 ``supra_r1/inference/codex.py``.

Closed-book sandbox per call: ``codex exec -s read-only --skip-git-repo-check
--ephemeral -C <fresh temp dir> --json``. The working root is an empty temp dir (NOT this
repo), so the model can't read our code/data; ``--json`` carries the final agent message.

CAVEAT: Codex is an agent, not a bare LLM — it prepends a large system prompt, so token
counts and latency are higher than a raw API call. Cost is ChatGPT-subscription quota; when
exhausted, ``codex exec`` exits 1 and emits a JSON ``error`` event on STDOUT (empty stderr),
which is surfaced here as a RuntimeError so the UI shows a readable message.

Config (env; see .env.example):
    CODEX_MODEL              default model selection (e.g. gpt-5.5)
    CODEX_MODELS             comma-separated allowlist for the UI model picker
    CODEX_REASONING_EFFORT   minimal|low|medium|high (default high)
    CODEX_TIMEOUT            per-call seconds (default 900)
    CODEX_BIN                codex executable name/path (default "codex")

Requires the Codex CLI installed and logged in (``codex login``; ``codex login status``).
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
import tempfile

_DEFAULT_MODEL = "gpt-5.5"
_DEFAULT_MODELS = "gpt-5.5,gpt-5"


def _codex_bin() -> str:
    return os.environ.get("CODEX_BIN", "codex")


def available_models() -> list[str]:
    """Models offered in the UI selector (env CODEX_MODELS allowlist)."""
    raw = os.environ.get("CODEX_MODELS", _DEFAULT_MODELS)
    models = [m.strip() for m in raw.split(",") if m.strip()]
    return models or [_DEFAULT_MODEL]


def default_model() -> str:
    """Default UI selection: CODEX_MODEL if it's in the allowlist, else the first."""
    avail = available_models()
    env = os.environ.get("CODEX_MODEL")
    return env if env in avail else avail[0]


def run_inference(
    system_prompt: str, user_prompt: str, *, model: str | None = None, timeout: float | None = None
) -> str:
    """Return the model completion text for one host-guest prediction, via ``codex exec``.

    Codex has no system role, so the system text is prepended to the user prompt. Raises
    RuntimeError (with the codex error message) on timeout, missing CLI, non-zero exit, or
    empty output, so the UI surfaces a readable message instead of a silent blank.
    """
    model = model or default_model()
    effort = os.environ.get("CODEX_REASONING_EFFORT", "high")
    if timeout is None:
        timeout = float(os.environ.get("CODEX_TIMEOUT", "900"))
    prompt = f"{system_prompt}\n\n{user_prompt}".strip() if system_prompt else user_prompt

    workdir = tempfile.mkdtemp(prefix="codex_cb_")  # empty root, not this repo
    last_path = os.path.join(workdir, "_last.txt")
    cmd = [
        _codex_bin(), "exec", "--json", "--skip-git-repo-check", "--ephemeral",
        "-s", "read-only", "-m", model, "-C", workdir,
        "-c", f'model_reasoning_effort="{effort}"',
        "-o", last_path, "-",
    ]
    # Force ChatGPT-subscription auth: strip API keys from the child env so codex can NEVER
    # silently fall back to metered API billing (it uses the OAuth tokens in ~/.codex/auth.json).
    env = {k: v for k, v in os.environ.items() if k not in ("OPENAI_API_KEY", "CODEX_API_KEY")}
    try:
        proc = subprocess.run(
            cmd, input=prompt, capture_output=True, text=True, timeout=timeout, env=env
        )
    except subprocess.TimeoutExpired as exc:
        shutil.rmtree(workdir, ignore_errors=True)
        raise RuntimeError(f"codex exec timed out after {timeout}s") from exc
    except FileNotFoundError as exc:
        shutil.rmtree(workdir, ignore_errors=True)
        raise RuntimeError(
            f"codex CLI not found ({_codex_bin()}); install Codex and run `codex login`"
        ) from exc

    content, codex_err = "", ""
    for line in (proc.stdout or "").splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            ev = json.loads(line)
        except json.JSONDecodeError:
            continue
        etype = ev.get("type")
        if etype == "item.completed":
            item = ev.get("item") or {}
            if item.get("type") == "agent_message" and item.get("text"):
                content = item["text"]
        elif etype in ("error", "turn.failed"):
            codex_err = ev.get("message") or (ev.get("error") or {}).get("message") or codex_err
    if os.path.exists(last_path):
        txt = open(last_path).read().strip()
        if txt:
            content = txt
    shutil.rmtree(workdir, ignore_errors=True)

    if proc.returncode != 0:
        detail = codex_err or (proc.stderr or "")[-400:].strip() or f"exit {proc.returncode}"
        raise RuntimeError(f"codex exec failed: {detail}")
    if not content:
        raise RuntimeError("codex exec returned empty output" + (f": {codex_err}" if codex_err else ""))
    return content


def healthcheck() -> tuple[bool, str]:
    """Lightweight readiness probe used by the UI; never raises."""
    if not shutil.which(_codex_bin()):
        return False, f"codex CLI not found ({_codex_bin()}); install Codex and run `codex login`"
    try:
        r = subprocess.run(
            [_codex_bin(), "login", "status"], capture_output=True, text=True, timeout=10
        )
    except Exception as exc:  # noqa: BLE001 — surfaced to the UI as a string
        return False, f"codex status check failed: {exc}"
    if r.returncode == 0 and "logged in" in (r.stdout + r.stderr).lower():
        return True, f"Codex configured (model={default_model()})"
    return False, "codex not logged in; run `codex login`"