-
-
Notifications
You must be signed in to change notification settings - Fork 6.3k
Studio: add Codex SDK as a chat provider with parallel-calls fan-out #5724
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
danielhanchen
wants to merge
48
commits into
main
Choose a base branch
from
feat/codex-provider
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 4 commits
Commits
Show all changes
48 commits
Select commit
Hold shift + click to select a range
cbc3c43
Studio: add Codex SDK as a chat provider with parallel-calls fan-out
danielhanchen ea7ae85
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 4188f91
wip: anthropic citation helper
danielhanchen 0a4309f
Studio: add codex_router to test_desktop_auth router stub
danielhanchen b8cd677
Studio: harden Codex provider against upstream CLI/SDK shape
danielhanchen 861da31
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] b6577a6
Studio: name the actual PyPI package in the Codex install hint
danielhanchen 0d904d6
Merge branch 'main' into feat/codex-provider
danielhanchen d6c47f6
Studio: wire Codex provider through the UI end-to-end
danielhanchen e2ac490
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 028b7b7
Studio: finish Codex UI flow (test, sign-in, parallel tabs)
danielhanchen 1f7cad4
Merge branch 'feat/codex-provider' of https://github.com/unslothai/un…
danielhanchen fd8f25f
Studio: scrub Codex subprocess env, guard partial-stream replay, abor…
danielhanchen 4b4c855
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 4be807b
Studio: round 4 hardening for the Codex provider
danielhanchen 67b837e
Merge branch 'feat/codex-provider' of https://github.com/unslothai/un…
danielhanchen 593fc9e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 1a10765
Studio: round 5 hardening for the Codex provider
danielhanchen c5289f0
Studio: pin Codex thread approvals + sandbox to safe defaults
danielhanchen 8c1c63a
Studio: surface Codex final agent message when no deltas stream
danielhanchen 9b4bd11
Studio: surface Codex parallel-calls in the connections dialog
danielhanchen f97a800
Studio: round 5e Codex hardening (4 follow-ups)
danielhanchen cb0680e
Studio: per-tab buffer for Codex fan-out so chunks cannot interleave
danielhanchen aa258b9
Studio: account for every Codex turn in fan-out usage chunk
danielhanchen 8ee6001
Studio: round 6 Codex hardening (5 follow-ups)
danielhanchen 2874abb
Studio: fail closed when Codex SDK cannot enforce safety pins
danielhanchen 2eaf1bb
Studio: pass codex_bin to AppServerConfig so PATH-only codex installs…
danielhanchen c755d00
Studio: cross-platform CI matrix for Codex provider tests
danielhanchen b786238
Studio: render Codex parallel-calls synthesis AFTER the per-tab blocks
danielhanchen 26799d9
Studio: pre-check Codex default models when creating the connection
danielhanchen f05517a
Studio: round 7 Codex hardening (cross-wrapper env scrub + device URL…
danielhanchen 8cef479
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] dee1b68
Studio: round 7b -- tighten device-login log filter + harden timeout …
danielhanchen be15c57
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] b17765b
Studio: round 8 -- replay guard on non-visible events + Add-flow Code…
danielhanchen 5185032
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] f29faef
Merge branch 'main' into feat/codex-provider
danielhanchen 3bbbd41
Merge branch 'feat/codex-provider' of https://github.com/unslothai/un…
danielhanchen e2b7f59
Studio: round 9 -- three P2 fixes from latest Codex bot review
danielhanchen dd0aeec
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] ecf8bc7
Merge remote-tracking branch 'origin/main' into feat/codex-provider
danielhanchen 3d1075f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] f01011e
Studio: real Codex parallel-call tab UI + in-process SDK spoof
danielhanchen 10d6b69
Merge branch 'feat/codex-provider' of https://github.com/unslothai/un…
danielhanchen cd8284d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 6867cfb
Merge remote-tracking branch 'origin/main' into feat/codex-provider
danielhanchen 6403846
Studio: spoof-aware Codex availability probe + autouse fixture
danielhanchen c716af4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| # 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 `codex_app_server` 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 | ||
| (``codex_app_server``) is not importable. The picker hides the | ||
| entry entirely. | ||
| * ``installed=True, logged_in=False`` -- everything resolves on the | ||
| Python side but ``codex auth status`` (or equivalent) 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. | ||
| _DEFAULT_SUPPORTED_MODELS: tuple[str, ...] = ( | ||
| "gpt-5.4", | ||
| "gpt-5.4-mini", | ||
| "gpt-5.5", | ||
| "o3", | ||
| ) | ||
|
|
||
|
|
||
| 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 ``codex_app_server`` is importable in this interpreter. | ||
|
|
||
| We deliberately use :func:`importlib.util.find_spec` instead of an | ||
| ``import codex_app_server`` so the import never actually 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. | ||
| """ | ||
| try: | ||
| return importlib.util.find_spec("codex_app_server") is not None | ||
| except Exception as exc: | ||
| logger.warning("codex_availability.find_spec_failed", 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: assume a non-zero ``codex auth status`` rc means | ||
| "not logged in". The CLI surface has shifted over releases (some | ||
| versions print to stderr, some to stdout, some use "Logged in as | ||
| ..." vs "Not authenticated"). The return code is the most stable | ||
| signal we have, so we lean on it and fall back to substring | ||
| inspection only when the rc itself is ambiguous (rc=0 but no | ||
| output, or rc=-1 from a timeout / crash). | ||
| """ | ||
| rc, stdout, stderr = await _run_cli(["auth", "status"]) | ||
| combined = f"{stdout}\n{stderr}".lower() | ||
| if rc == 0: | ||
| # Some 0.x releases exit 0 even when the user is logged out. | ||
| # If we got any output, look for the obvious "not logged in" | ||
| # signals; if there's nothing on either pipe at all, treat | ||
| # rc=0 as authenticated (the optimistic default). | ||
| if not combined.strip(): | ||
| return True | ||
| if "not authenticated" in combined or "not logged in" in combined: | ||
| return False | ||
| if "logged in" in combined or "authenticated" in combined: | ||
| return True | ||
| return True | ||
| # rc != 0: lean on substring matching one more time before | ||
| # defaulting to False, in case a future CLI uses rc=2 for the | ||
| # logged-out state instead of "no auth command exists". | ||
| if "logged in" in combined or "authenticated as" in combined: | ||
| return True | ||
| 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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The status probe reports
installed=truewhenever CLI+SDK are present, but the request path later hard-fails withCodexUnavailableErrorif the SDK cannot exposeApprovalMode/SandboxMode(unless an unsafe override env var is set). On those SDK builds, the UI will surface Codex as available and logged in, yet every chat request fails at runtime; the availability check should include this capability gate soinstalledmatches actual usability.Useful? React with 👍 / 👎.