unsloth/studio/backend/core/inference/codex_availability.py
Daniel Han b8cd677397 Studio: harden Codex provider against upstream CLI/SDK shape
Followups on the post-merge review pass for the Codex SDK chat
provider. Verified against codex-cli 0.133.0 + the upstream
`openai/codex` Rust + Python sources, then pinned each fix with
a regression test in `test_codex_provider.py` (24/24 passing).

* Probe both `openai_codex` (canonical upstream Python package at
  `openai/codex/sdk/python`) and the legacy `codex_app_server`
  alias. Without this the availability probe always reported
  `sdk_importable: false` even when the SDK was installed, so the
  provider was permanently hidden.
* Switch the device-auth and login-status invocations from
  `codex auth login --device-auth` / `codex auth status` to the
  real upstream subcommands `codex login --device-auth` and
  `codex login status`. The former path returns
  `unrecognized subcommand 'auth'` on a real CLI.
* Strip ANSI control sequences before extracting the device URL
  (upstream wraps the URL in `\x1b[34m...\x1b[0m`) and tighten the
  pattern to the canonical `.../codex/device` shape. Also surface
  the one-time code as a `device_code` SSE event so the UI can
  show it alongside the URL.
* Fix `_detect_logged_in` substring footgun: `"logged in" in
  combined` matched inside `"not logged in"`, flipping logged-out
  users to logged-in. Anchor on word boundaries with negative
  prefixes winning regardless of return code.
* Cancel in-flight fan-out workers on SSE disconnect. Previously
  every parallel Codex turn ran to completion against a
  disconnected client and burned quota; now `_stream_codex_parallel`
  cancels its worker + drain tasks in a try/finally on
  `CancelledError`/`GeneratorExit`.
* Tear down the device-login subprocess on disconnect via
  `start_new_session=True` + `os.killpg(SIGTERM)` (Unix) or
  `CREATE_NEW_PROCESS_GROUP` + `CTRL_BREAK_EVENT` (Windows), with
  a bounded `proc.wait()` and `proc.kill()` fallback. Previously
  `finally: await proc.wait()` blocked the SSE close path because
  `codex login --device-auth` only exits on user action.
* Render the full conversation transcript in `_last_user_prompt`
  instead of returning only the most recent user message. The PR
  opens a fresh thread per request so prior assistant turns were
  dropped, degrading multi-turn chats to single-shot prompts.
  Single-turn input is unchanged.
* Make `ChatCompletionRequest.parallel_calls` default to 1 (`int`
  with `ge=1, le=20`) instead of `Optional[int] = None`. The
  runtime already coerced `None` -> 1, but the schema now matches
  the documented `[1, 20]` range.
* Replace the registry's hardcoded `default_models` (which
  contained `o3`, not in the upstream catalog) with the current
  `gpt-5.5 / 5.4 / 5.4-mini / 5.3-codex / 5.2` set from
  `codex-rs/models-manager/models.json`.
* Stop echoing `str(exc)` in SSE error frames in both
  `routes/inference.py` and `routes/codex.py`. The Codex SDK can
  raise with local paths, env-var content, or traceback fragments
  (CodeQL `py/information-exposure-through-exception`). Surface a
  generic message + `exception_type` discriminator; log the full
  reason server-side via `logger.error(..., exc_type=..., error=...)`.

Doc / comment updates throughout to refer to `codex login` /
`openai_codex` rather than the older incorrect strings.

Tested: pytest 24 cases in `test_codex_provider.py` (the original
14 + 10 new `TestCodexHardenedRegressions`) plus the rest of the
Studio-backend test suite the PR touches (209 passing). Also
verified live against Studio launched from this branch on a
Blackwell B200 via `UNSLOTH_STUDIO_HOME=$WORKSPACE/temp/...
./install.sh --local` then a Playwright probe.
2026-05-24 14:13:03 +00:00

249 lines
9.2 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Codex CLI / SDK availability probe.
This module never imports the Codex Python SDK at module top level.
The SDK is optional and not pinned in pyproject.toml -- if it's
installed locally, we use it; if it isn't, the probe simply returns
``installed=False`` and the provider stays hidden in the frontend.
The frontend calls ``GET /api/codex/status`` at startup to decide
whether to surface the "codex" entry in the provider picker. Three
states matter:
* ``installed=False`` -- either the CLI is missing OR the SDK
(``openai_codex`` canonical, or ``codex_app_server`` legacy alias)
is not importable. The picker hides the entry entirely.
* ``installed=True, logged_in=False`` -- everything resolves on the
Python side but ``codex login status`` reports no active credentials.
The provider config dialog shows a ``Sign in to Codex`` button
instead of the regular API-key field.
* ``installed=True, logged_in=True`` -- ready to use; the picker
shows the regular model dropdown.
Detection is best-effort and cheap: we shell out to ``which codex``
plus ``codex --version`` for the CLI and use ``importlib.util.find_spec``
for the SDK. No long-running CLI commands are invoked here so the
status endpoint is safe to poll on every page load.
"""
from __future__ import annotations
import asyncio
import importlib.util
import os
import shutil
from typing import Any, Optional
import structlog
logger = structlog.get_logger(__name__)
# Default catalog of models surfaced in the picker when the CLI is
# present but doesn't advertise a list. The SDK accepts arbitrary model
# ids; this is purely a sensible default. Mirrored from upstream
# ``codex-rs/models-manager/models.json``.
_DEFAULT_SUPPORTED_MODELS: tuple[str, ...] = (
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex",
"gpt-5.2",
)
# Names the upstream Python SDK has shipped under. ``openai_codex`` is the
# canonical package at ``openai/codex/sdk/python``; ``codex_app_server`` is
# kept as a forward-compat alias because the Rust crate uses that name and
# an internal alpha may publish under it.
_SDK_MODULE_NAMES: tuple[str, ...] = ("openai_codex", "codex_app_server")
def _which_codex() -> Optional[str]:
"""Return absolute path to the ``codex`` CLI, or None if missing.
Uses :func:`shutil.which` so the lookup honours ``PATH`` exactly
the way the user's shell would. Returns ``None`` on any failure
so callers can treat "missing" and "broken probe" the same way.
"""
try:
return shutil.which("codex")
except Exception as exc:
# shutil.which itself is documented as raising only on
# genuinely unusual conditions, but a hardened wrapper costs
# nothing and keeps the status endpoint from 500'ing.
logger.warning("codex_availability.which_failed", error = str(exc))
return None
def _sdk_importable() -> bool:
"""True iff the Codex Python SDK is importable in this interpreter.
We deliberately use :func:`importlib.util.find_spec` instead of an
actual ``import`` so the import never runs -- that keeps the cost
negligible and avoids the SDK's own side effects (which include
reaching out to the CLI subprocess for an RPC ping) during a
simple availability check.
Probes both ``openai_codex`` (the canonical upstream package name
at ``openai/codex/sdk/python``) and ``codex_app_server`` (the Rust
crate name, kept as a forward-compat alias).
"""
for name in _SDK_MODULE_NAMES:
try:
if importlib.util.find_spec(name) is not None:
return True
except Exception as exc:
logger.warning(
"codex_availability.find_spec_failed",
module = name,
error = str(exc),
)
return False
async def _run_cli(args: list[str], *, timeout: float = 4.0) -> tuple[int, str, str]:
"""Run a short ``codex`` CLI command and return (rc, stdout, stderr).
The probe uses 4s as the wall-clock cap because ``codex --version``
and ``codex auth status`` both return in well under a second on a
healthy install. A longer probe would block the
``/api/codex/status`` route -- and that route fires on every chat
page load, so a tight cap matters.
"""
try:
proc = await asyncio.create_subprocess_exec(
"codex",
*args,
stdout = asyncio.subprocess.PIPE,
stderr = asyncio.subprocess.PIPE,
env = os.environ.copy(),
)
except FileNotFoundError:
return -1, "", "codex binary not on PATH"
except Exception as exc:
logger.warning(
"codex_availability.spawn_failed",
args = args,
error = str(exc),
)
return -1, "", str(exc)
try:
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout = timeout)
except asyncio.TimeoutError:
proc.kill()
try:
await proc.wait()
except Exception:
pass
return -1, "", f"codex {' '.join(args)} timed out after {timeout:.1f}s"
return (
proc.returncode if proc.returncode is not None else -1,
stdout_b.decode("utf-8", errors = "replace").strip(),
stderr_b.decode("utf-8", errors = "replace").strip(),
)
async def _detect_version() -> Optional[str]:
rc, stdout, stderr = await _run_cli(["--version"])
if rc != 0:
return None
# ``codex --version`` prints something like "codex-cli 0.133.0".
# Surface the whole line so the UI can show the exact build the
# user has installed -- it's useful when troubleshooting.
text = stdout or stderr
return text.split("\n", 1)[0].strip() if text else None
async def _detect_logged_in() -> bool:
"""Best-effort: parse ``codex login status`` output.
The upstream subcommand is ``codex login status`` (no ``auth``
parent). Output shapes seen in the wild:
* "Logged in using ChatGPT" / "Logged in as user@x.com" -> True
* "Not logged in. Run `codex login` ..." -> False
* "Not authenticated" -> False
Return code is the most stable signal but ``not logged in`` also
exits 0 on current releases, so we substring-check explicitly.
Note: a naive ``"logged in" in combined`` check is wrong because
the substring appears inside "not logged in" too -- we use an
explicit negative-prefix check first.
"""
import re
rc, stdout, stderr = await _run_cli(["login", "status"])
combined = f"{stdout}\n{stderr}".lower()
# Negative prefixes win, regardless of rc. We anchor on word
# boundaries so "not logged in" / "not authenticated" both match
# without being fooled by the substring "logged in" inside them.
negative = re.compile(r"\b(not logged in|not authenticated|please log in|run\s+`?codex login`?)\b")
if negative.search(combined):
return False
positive = re.compile(r"\b(logged in|authenticated as|signed in)\b")
if positive.search(combined):
return True
if rc == 0:
# rc=0 with nothing useful on either pipe: optimistic default,
# the user is probably authenticated and the CLI just stayed
# quiet (e.g. a future release).
if not combined.strip():
return True
return False
return False
async def probe_codex_availability() -> dict[str, Any]:
"""Return the full status payload consumed by ``GET /api/codex/status``.
Returns a dict with keys:
* ``installed`` (bool) -- True iff both CLI and SDK are present.
Note this is the gate the frontend uses to surface the provider
at all, so if EITHER is missing the picker hides the entry.
* ``cli_path`` (str | None) -- absolute path to the CLI, or None.
* ``sdk_importable`` (bool) -- the Python SDK is importable.
* ``logged_in`` (bool) -- best-effort auth check; meaningless when
``installed`` is False.
* ``version`` (str | None) -- the ``codex --version`` first line.
* ``supported_models`` (list[str]) -- default model id catalog.
"""
cli_path = _which_codex()
sdk_ok = _sdk_importable()
payload: dict[str, Any] = {
"installed": bool(cli_path) and sdk_ok,
"cli_path": cli_path,
"sdk_importable": sdk_ok,
"logged_in": False,
"version": None,
"supported_models": list(_DEFAULT_SUPPORTED_MODELS),
}
if cli_path:
# version + login probes only matter when the CLI is present;
# they would otherwise just churn subprocess errors. Run them
# in parallel because both are independent CLI invocations.
version, logged_in = await asyncio.gather(
_detect_version(),
_detect_logged_in(),
)
payload["version"] = version
payload["logged_in"] = bool(logged_in)
logger.info(
"codex_availability.probed",
installed = payload["installed"],
sdk_importable = payload["sdk_importable"],
cli_path = payload["cli_path"],
version = payload["version"],
logged_in = payload["logged_in"],
)
return payload