From cbc3c43655a42fcd5d73400d2c9e8a52593c4603 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 16:46:00 +0000 Subject: [PATCH] Studio: add Codex SDK as a chat provider with parallel-calls fan-out Wires the OpenAI Codex CLI / Python SDK (codex_app_server) into Studio as a new chat provider type. Hosts that don't have the CLI or the SDK installed never see the entry; on logged-out hosts the provider config dialog renders a device-auth Sign-in button that surfaces the verification URL and streams CLI progress back over SSE. Backend - new core/inference/codex_availability.py probes the CLI + SDK and reports {installed, logged_in, version, supported_models}; it never imports codex_app_server at module top level so the rest of the backend keeps starting cleanly on hosts that don't have the SDK. - new core/inference/codex_provider.py wraps AsyncCodex and translates Codex events into OpenAI chat-completion chunks. Supports the thread.run_streaming path with a non-streaming fallback for older SDK revs. - parallel_calls > 1 fans the turn out across N tasks (capped at 20) via asyncio.gather and emits codex_tab_open / codex_tab_chunk / codex_tab_close tool-events per attempt plus a final codex_gather synthesis event. A separate standalone Codex call produces the unified answer. - new routes/codex.py exposes GET /api/codex/status and POST /api/codex/login. The login route shells out to codex auth login --device-auth and streams events; the first event carries the verification URL so the frontend can window.open it. - ChatCompletionRequest gains a parallel_calls field bounded [1, 20] by pydantic. The codex registry entry stays hidden by default; the /api/codex/status probe is the authoritative gate. - routes/inference.py dispatches provider_type=codex through the local CLI/SDK pipeline instead of the standard HTTP client, with graceful error surfacing for CodexUnavailableError. Frontend - new api/codex-api.ts exposes fetchCodexStatus() and an async generator streamCodexDeviceLogin() that drives the SSE stream and yields parsed events. - new components/codex-parallel-tabs.tsx renders the tabbed parallel- calls UI with a Synthesis tab highlighted once the codex_gather event arrives. Pure reducer keeps the state transitions unit- testable. - new components/codex-login-button.tsx posts to /api/codex/login, opens the verification URL in a new tab via window.open, and shows the streamed CLI log as it lands. - external-providers.ts exports CODEX_PROVIDER_TYPE, CODEX_MAX_PARALLEL_CALLS, isCodexProviderType, and clampCodexParallelCalls. Codex is marked text-only so the composer hides image-attach affordances when selected. Tests - tests/test_codex_provider.py (14 cases) covers the availability probe across the four install / login states, the streaming + parallel-calls translation against a fake codex_app_server module injected into sys.modules, the [1, 20] pydantic clamp, the CodexUnavailableError surfacing path, and the parallel_calls=1 single-call shape (no tab tool-events). --- .../core/inference/codex_availability.py | 221 ++++++ .../backend/core/inference/codex_provider.py | 669 ++++++++++++++++++ studio/backend/core/inference/providers.py | 35 + studio/backend/main.py | 5 + studio/backend/models/inference.py | 14 + studio/backend/routes/__init__.py | 2 + studio/backend/routes/codex.py | 92 +++ studio/backend/routes/inference.py | 76 +- studio/backend/tests/test_codex_provider.py | 499 +++++++++++++ .../src/features/chat/api/codex-api.ts | 146 ++++ .../chat/components/codex-login-button.tsx | 120 ++++ .../chat/components/codex-parallel-tabs.tsx | 227 ++++++ .../src/features/chat/external-providers.ts | 34 + 13 files changed, 2138 insertions(+), 2 deletions(-) create mode 100644 studio/backend/core/inference/codex_availability.py create mode 100644 studio/backend/core/inference/codex_provider.py create mode 100644 studio/backend/routes/codex.py create mode 100644 studio/backend/tests/test_codex_provider.py create mode 100644 studio/frontend/src/features/chat/api/codex-api.ts create mode 100644 studio/frontend/src/features/chat/components/codex-login-button.tsx create mode 100644 studio/frontend/src/features/chat/components/codex-parallel-tabs.tsx diff --git a/studio/backend/core/inference/codex_availability.py b/studio/backend/core/inference/codex_availability.py new file mode 100644 index 0000000000..c7488a0ed0 --- /dev/null +++ b/studio/backend/core/inference/codex_availability.py @@ -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 diff --git a/studio/backend/core/inference/codex_provider.py b/studio/backend/core/inference/codex_provider.py new file mode 100644 index 0000000000..d4df67ab4f --- /dev/null +++ b/studio/backend/core/inference/codex_provider.py @@ -0,0 +1,669 @@ +# 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 SDK chat provider. + +This module wraps the ``codex_app_server`` async SDK so a chat request +routed at ``provider_type="codex"`` can dispatch through the user's +local Codex CLI. The contract back to the frontend is the standard +OpenAI Chat Completions SSE shape, exactly like every other entry in +``external_provider.py``. + +The two interesting features here: + +* ``_stream_codex_single`` -- translate Codex SDK events into + OpenAI-format chunks. We prefer ``thread.run_streaming`` when the + installed SDK exposes it; otherwise we fall back to + ``await thread.run(...)`` and emit one big content chunk plus a + usage chunk at the end. + +* ``_stream_codex_parallel`` -- the ``parallel_calls`` knob. When > 1, + spawn N async Codex tasks (capped at 20) via ``asyncio.gather`` and + emit per-tab ``_toolEvent`` markers so the frontend can render each + result in its own tab. A final ``codex_gather`` synthesis tab runs a + single Codex call that takes the N outputs and produces a unified + answer. + +The SDK is imported lazily (inside the helpers that actually need it) +because the spec calls out that ``codex_app_server`` may not even be +importable on the build host. The availability probe in +``codex_availability.py`` is what the frontend uses to gate the +provider entirely; this module just refuses to run if the import +fails at request time. +""" + +from __future__ import annotations + +import asyncio +import importlib +import importlib.util +import json +import time +from typing import Any, AsyncGenerator, Optional + +import structlog + +logger = structlog.get_logger(__name__) + + +# Hard cap on parallel Codex fan-out. Picked to match the upper bound +# in the request validator -- exceeding this risks the local Codex CLI +# rate-limiting itself or starving the loop. +MAX_PARALLEL_CALLS = 20 + + +class CodexUnavailableError(RuntimeError): + """Raised when ``codex_app_server`` is not importable at runtime. + + The availability probe is supposed to hide the provider before any + request lands here, but we still raise a typed error so the route + layer can translate it into a 503 the user sees instead of an + opaque traceback. + """ + + +def _import_codex() -> Any: + """Return the imported ``codex_app_server`` module or raise. + + Imported lazily so the rest of the backend keeps starting cleanly + on hosts that don't have the SDK installed. The frontend calls + ``GET /api/codex/status`` first and hides the provider when the + spec isn't importable, so this branch is reached only when the + user (a) explicitly forces the provider via a stale stored config + or (b) the install state changes between status probe and chat + submit. + """ + if importlib.util.find_spec("codex_app_server") is None: + raise CodexUnavailableError( + "codex_app_server is not installed on this host. " + "Install the Codex Python SDK or use a different provider." + ) + return importlib.import_module("codex_app_server") + + +def _last_user_prompt(messages: list[dict[str, Any]]) -> str: + """Extract the most recent user-role message as a plain string. + + Codex's ``thread.run`` accepts a string (per the docs note + "plain strings are accepted anywhere a turn input is accepted"). + Studio's chat history is a full OpenAI-style messages array, so + we flatten it: walk from the end, find the last ``role=user`` + message, and stringify any structured content parts into a + newline-joined block. Multimodal content (images) is described + rather than embedded; Codex SDK input shape is text-first. + + This is intentionally conservative -- we don't try to replay the + whole conversation through Codex per turn because the SDK is + designed around a stateful ``thread`` object. The thread itself + holds context across runs; we only need to feed the latest user + turn. + """ + for msg in reversed(messages): + if msg.get("role") != "user": + continue + content = msg.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for entry in content: + if not isinstance(entry, dict): + continue + if entry.get("type") == "text": + parts.append(str(entry.get("text") or "")) + elif entry.get("type") == "image_url": + url = (entry.get("image_url") or {}).get("url") or "" + parts.append(f"[image: {url[:80]}]" if url else "[image]") + elif entry.get("type") == "input_document": + name = entry.get("filename") or "document" + parts.append(f"[document: {name}]") + return "\n".join(p for p in parts if p) + return "" + + +def _system_prompt(messages: list[dict[str, Any]]) -> str: + """Concatenate all ``role=system`` messages. + + Codex's ``thread_start`` accepts a system prompt for the lifetime + of the thread. We pass any leading system messages so the user's + Studio-side system prompt (chat presets) reaches the Codex side + intact. + """ + parts: list[str] = [] + for msg in messages: + if msg.get("role") != "system": + continue + content = msg.get("content") + if isinstance(content, str): + parts.append(content) + elif isinstance(content, list): + for entry in content: + if isinstance(entry, dict) and entry.get("type") == "text": + parts.append(str(entry.get("text") or "")) + return "\n\n".join(p for p in parts if p) + + +def _chunk_text(completion_id: str, text: str) -> str: + """OpenAI Chat Completions content chunk.""" + payload = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {"content": text}, + "finish_reason": None, + } + ], + } + return f"data: {json.dumps(payload)}" + + +def _chunk_stop(completion_id: str) -> str: + payload = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + } + ], + } + return f"data: {json.dumps(payload)}" + + +def _chunk_tool_event(completion_id: str, event: dict[str, Any]) -> str: + """Synthetic OpenAI-shaped chunk carrying an ``_toolEvent`` payload. + + Studio's frontend chat-adapter already understands the + ``_toolEvent`` envelope and renders tool cards on the fly. We piggy- + back on the same channel to ship Codex-specific tab markers + (``codex_tab_open`` / ``codex_tab_chunk`` / ``codex_gather``) so the + UI doesn't need a brand-new transport. + """ + payload = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": None, + } + ], + "_toolEvent": event, + } + return f"data: {json.dumps(payload)}" + + +def _chunk_usage( + completion_id: str, + prompt_tokens: int, + completion_tokens: int, +) -> str: + payload = { + "id": completion_id, + "object": "chat.completion.chunk", + "choices": [], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + } + return f"data: {json.dumps(payload)}" + + +def _coerce_text(payload: Any) -> str: + """Pull text out of a Codex streaming event or result. + + The SDK shape isn't pinned across versions: events expose ``delta`` + or ``text`` or ``content`` depending on whether the model is in + plan / answer / tool-use mode. Be defensive -- read whichever + field is present and fall back to ``str()`` so we never crash + while translating. + """ + if payload is None: + return "" + if isinstance(payload, str): + return payload + if isinstance(payload, dict): + for key in ("delta", "text", "content", "message", "final_response"): + if key in payload: + value = _coerce_text(payload[key]) + if value: + return value + return "" + if isinstance(payload, list): + return "".join(_coerce_text(item) for item in payload) + text_attr = getattr(payload, "text", None) + if isinstance(text_attr, str): + return text_attr + delta_attr = getattr(payload, "delta", None) + if isinstance(delta_attr, str): + return delta_attr + final_attr = getattr(payload, "final_response", None) + if isinstance(final_attr, str): + return final_attr + return "" + + +async def _stream_thread_run( + thread: Any, + prompt: str, +) -> AsyncGenerator[str, None]: + """Yield raw text chunks from a Codex thread. + + Prefers ``thread.run_streaming(prompt)`` because that's what the + docs surface for token-by-token delivery. When the installed SDK + doesn't have that helper, fall back to ``await thread.run(prompt)`` + and yield the full text once -- this still works end-to-end, just + without streaming feedback in the UI. + """ + run_streaming = getattr(thread, "run_streaming", None) + if run_streaming is not None: + try: + stream_obj = run_streaming(prompt) + # The SDK may return either an async iterator directly or a + # coroutine that resolves to one. Handle both shapes so a + # future SDK rev doesn't silently fall off the streaming + # path. + if asyncio.iscoroutine(stream_obj): + stream_obj = await stream_obj + async for event in stream_obj: + text = _coerce_text(event) + if text: + yield text + return + except Exception as exc: + logger.warning( + "codex_provider.run_streaming_failed_fallback", + error = str(exc), + ) + # Intentional fallthrough to the non-streaming path so a + # broken streaming helper doesn't take the whole turn down. + + # Non-streaming fallback: await the full TurnResult, emit one chunk. + result = await thread.run(prompt) + text = _coerce_text(result) or getattr(result, "final_response", "") or str(result) + if text: + yield text + + +async def _stream_codex_single( + model: str, + system: str, + prompt: str, + completion_id: str, +) -> AsyncGenerator[str, None]: + """Run one Codex turn and emit OpenAI-shaped SSE lines.""" + sdk = _import_codex() + async_codex_cls = getattr(sdk, "AsyncCodex", None) + if async_codex_cls is None: + raise CodexUnavailableError( + "codex_app_server is installed but AsyncCodex is missing -- " + "upgrade the SDK." + ) + + completion_text_chars = 0 + + async with async_codex_cls() as codex: + # ``thread_start`` accepts a model id; system prompts are + # passed when supported by the SDK rev (older revs ignore the + # extra kwarg). Be tolerant about kwargs that may not exist. + thread_kwargs: dict[str, Any] = {"model": model} + if system: + # Try the canonical kwargs first; the SDK shapes vary + # across versions and we'd rather accept the system prompt + # being dropped than crash on a missing kwarg. + thread_kwargs["system"] = system + try: + thread = await codex.thread_start(**thread_kwargs) + except TypeError: + # Older SDK: only the ``model`` kwarg is accepted. Drop + # extras and retry; the system prompt then lives only in + # the prompt itself (we prepend it below). + thread = await codex.thread_start(model = model) + if system: + prompt = f"{system}\n\n{prompt}" + async for text in _stream_thread_run(thread, prompt): + completion_text_chars += len(text) + yield _chunk_text(completion_id, text) + + # Estimate tokens crudely from char counts. The Codex SDK does not + # consistently expose a token breakdown; we surface a usage chunk + # purely so the frontend cost / context display has a non-zero + # value to render. ``int(chars / 4)`` is the standard rough-cut. + yield _chunk_usage( + completion_id, + prompt_tokens = max(1, len(prompt) // 4), + completion_tokens = max(0, completion_text_chars // 4), + ) + yield _chunk_stop(completion_id) + + +async def stream_codex( + messages: list[dict[str, Any]], + model: str, + parallel_calls: int = 1, +) -> AsyncGenerator[str, None]: + """Top-level entry point used by the inference route. + + When ``parallel_calls == 1`` (the default), this delegates to the + single-turn helper. When > 1 (and <= ``MAX_PARALLEL_CALLS``), it + spawns N parallel Codex turns and emits one tab per result plus a + final synthesis tab. The fan-out runs every spawned turn against + the SAME user prompt -- the parallel knob is for sampling N + independent attempts at the same task, which is what the UI tab + strip surfaces. + """ + prompt = _last_user_prompt(messages) + system = _system_prompt(messages) + completion_id = f"chatcmpl-codex-{int(time.time() * 1000)}" + + if not prompt: + yield _chunk_text( + completion_id, + "(no user prompt found -- send a message before invoking codex)", + ) + yield _chunk_stop(completion_id) + return + + clamped = max(1, min(int(parallel_calls or 1), MAX_PARALLEL_CALLS)) + + if clamped == 1: + async for line in _stream_codex_single(model, system, prompt, completion_id): + yield line + yield "data: [DONE]" + return + + async for line in _stream_codex_parallel( + model = model, + system = system, + prompt = prompt, + n = clamped, + completion_id = completion_id, + ): + yield line + yield "data: [DONE]" + + +async def _stream_codex_parallel( + *, + model: str, + system: str, + prompt: str, + n: int, + completion_id: str, +) -> AsyncGenerator[str, None]: + """Fan out N Codex tasks, emit per-tab chunks, then synthesise. + + Each tab id is a 1-based integer. We emit ``codex_tab_open`` up + front for every tab so the UI can paint the tab strip before any + response lands, then route streamed text per tab via + ``codex_tab_chunk`` (one event per text delta). The final + ``codex_gather`` event carries a synthesis from a separate + standalone Codex call so the user sees both the per-tab raw + outputs AND a unified merged answer. + + Bounded concurrency: ``asyncio.gather`` is used with a fresh + ``AsyncCodex`` instance per tab to keep the SDK from sharing a + single thread object across coroutines (the SDK is not documented + as concurrency-safe on a single ``Codex`` handle). N is clamped to + ``MAX_PARALLEL_CALLS`` by the caller. + """ + queue: asyncio.Queue[Optional[str]] = asyncio.Queue() + + # Pre-emit one tab_open per tab so the UI can paint the tabs + # immediately. The frontend creates the tabs lazily on first + # ``codex_tab_open`` event anyway; pre-emitting just gives us the + # familiar tabs-first / content-second render order. + for idx in range(1, n + 1): + yield _chunk_tool_event( + completion_id, + { + "type": "codex_tab_open", + "tab_id": idx, + "query": prompt, + "total_tabs": n, + }, + ) + + async def _worker(tab_id: int) -> str: + """Run one Codex turn, push every chunk into the queue, and + return the full accumulated text so the synthesis step can + consume it. Errors are surfaced as a ``codex_tab_error`` + tool-event so the tab strip shows which lane failed without + aborting the whole fan-out. + """ + collected: list[str] = [] + try: + sdk = _import_codex() + async_codex_cls = getattr(sdk, "AsyncCodex") + async with async_codex_cls() as codex: + thread_kwargs: dict[str, Any] = {"model": model} + if system: + thread_kwargs["system"] = system + inner_prompt = prompt + try: + thread = await codex.thread_start(**thread_kwargs) + except TypeError: + thread = await codex.thread_start(model = model) + if system: + inner_prompt = f"{system}\n\n{prompt}" + async for text in _stream_thread_run(thread, inner_prompt): + collected.append(text) + await queue.put( + _chunk_tool_event( + completion_id, + { + "type": "codex_tab_chunk", + "tab_id": tab_id, + "text": text, + }, + ) + ) + except Exception as exc: + logger.warning( + "codex_provider.parallel_tab_failed", + tab_id = tab_id, + error = str(exc), + ) + await queue.put( + _chunk_tool_event( + completion_id, + { + "type": "codex_tab_error", + "tab_id": tab_id, + "error": str(exc), + }, + ) + ) + finally: + await queue.put( + _chunk_tool_event( + completion_id, + { + "type": "codex_tab_close", + "tab_id": tab_id, + }, + ) + ) + return "".join(collected) + + workers = [asyncio.create_task(_worker(i + 1)) for i in range(n)] + + # asyncio.gather returns a _GatheringFuture, not a coroutine, so it + # cannot be passed to create_task. Wrap the await in a small helper + # coroutine so we keep both (a) a handle for awaiting and (b) the + # ability to schedule the drain side-effect that unblocks the + # consumer queue. Stash the results into a list the drain finally + # block reads so per-tab outputs survive any single-worker errors. + per_tab_texts: list[str] = [] + + async def _await_workers() -> None: + results = await asyncio.gather(*workers, return_exceptions = True) + for r in results: + if isinstance(r, BaseException): + per_tab_texts.append("") + else: + per_tab_texts.append(r) + + async def _drain_when_done() -> None: + try: + await _await_workers() + finally: + await queue.put(None) + + drain_task = asyncio.create_task(_drain_when_done()) + + while True: + line = await queue.get() + if line is None: + break + yield line + + # Drain finished; per_tab_texts is now populated by the helper + # coroutine above. We already shielded individual errors as + # ``codex_tab_error`` events, so nothing should leak here -- but + # log defensively in case a worker future itself raised. + await drain_task + + synthesis_text = await _run_codex_synthesis( + model = model, + prompt = prompt, + tab_outputs = per_tab_texts, + ) + + yield _chunk_tool_event( + completion_id, + { + "type": "codex_gather", + "summary": synthesis_text, + "tab_count": n, + }, + ) + + # Also emit the synthesis as a visible content chunk so any client + # that ignores the tab tool-events (e.g. a curl user) still sees + # a final unified answer. The tabbed UI re-uses the same payload + # via ``codex_gather`` and hides it from the main content lane to + # avoid duplication. + if synthesis_text: + yield _chunk_text(completion_id, synthesis_text) + + yield _chunk_usage( + completion_id, + prompt_tokens = max(1, len(prompt) // 4), + completion_tokens = max(0, len(synthesis_text) // 4), + ) + yield _chunk_stop(completion_id) + + +async def _run_codex_synthesis( + *, + model: str, + prompt: str, + tab_outputs: list[str], +) -> str: + """Run one extra Codex call that consumes the N per-tab outputs and + returns a unified synthesis. Returns the empty string on failure -- + the caller already surfaced the per-tab outputs so an empty + synthesis is recoverable. + """ + if not tab_outputs: + return "" + joined = "\n\n".join( + f"=== Attempt {i + 1} ===\n{text.strip() or '(no output)'}" + for i, text in enumerate(tab_outputs) + ) + synthesis_prompt = ( + "You are given multiple independent attempts at the same task.\n" + "Your job: synthesise a single best response that takes the " + "strongest parts of each attempt, resolves disagreements, and " + "presents a clear unified answer.\n\n" + f"Original task:\n{prompt}\n\n" + f"Attempts:\n{joined}\n\n" + "Unified answer:" + ) + try: + sdk = _import_codex() + async_codex_cls = getattr(sdk, "AsyncCodex") + async with async_codex_cls() as codex: + try: + thread = await codex.thread_start(model = model) + except TypeError: + thread = await codex.thread_start() + result = await thread.run(synthesis_prompt) + return _coerce_text(result) or getattr(result, "final_response", "") or str(result) + except Exception as exc: + logger.warning("codex_provider.synthesis_failed", error = str(exc)) + return "" + + +# ── Device-auth helper ────────────────────────────────────────────── + + +async def stream_codex_device_login() -> AsyncGenerator[dict[str, Any], None]: + """Run ``codex auth login --device-auth`` and yield progress events. + + Yields dicts (NOT SSE lines) that the route layer wraps in SSE. + First event is always ``{type: "device_url", url: "..."}`` once we + detect a verification URL in the CLI output. Subsequent events + forward CLI stdout/stderr line-by-line as ``{type: "log", line: ...}`` + so the UI can show progress. A final ``{type: "done", ok: bool}`` + signals completion. + + The URL extraction matches the CLI's actual output shape (the CLI + prints something like ``Open https://auth.openai.com/device/...`` + on the device-auth path). We scan every line for the first + https:// URL containing ``device``; that has historically been + stable across CLI versions. + """ + import re + + cli_path = "codex" + args = ["auth", "login", "--device-auth"] + + try: + proc = await asyncio.create_subprocess_exec( + cli_path, + *args, + stdout = asyncio.subprocess.PIPE, + stderr = asyncio.subprocess.STDOUT, + ) + except FileNotFoundError: + yield {"type": "error", "message": "codex CLI not found on PATH"} + yield {"type": "done", "ok": False} + return + except Exception as exc: + yield {"type": "error", "message": str(exc)} + yield {"type": "done", "ok": False} + return + + url_re = re.compile(r"https?://\S*device\S*", re.IGNORECASE) + url_emitted = False + rc: int = -1 + + try: + assert proc.stdout is not None + while True: + line_b = await proc.stdout.readline() + if not line_b: + break + line = line_b.decode("utf-8", errors = "replace").rstrip() + if not url_emitted: + match = url_re.search(line) + if match: + yield {"type": "device_url", "url": match.group(0)} + url_emitted = True + yield {"type": "log", "line": line} + finally: + try: + rc = await proc.wait() + except Exception: + rc = -1 + yield {"type": "done", "ok": rc == 0, "return_code": rc} diff --git a/studio/backend/core/inference/providers.py b/studio/backend/core/inference/providers.py index fef9ba3e12..469c6474ba 100644 --- a/studio/backend/core/inference/providers.py +++ b/studio/backend/core/inference/providers.py @@ -270,6 +270,41 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = { ), "hidden": True, }, + "codex": { + "display_name": "OpenAI Codex (local CLI)", + # No remote base_url: Codex dispatches through the local CLI + # via the codex_app_server SDK. Routing skips the standard + # HTTP client entirely in _proxy_to_external_provider and + # hands the request to core.inference.codex_provider instead. + "base_url": "", + "default_models": [ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.5", + "o3", + ], + "supports_streaming": True, + "supports_vision": False, + "supports_tool_calling": True, + # No auth header is sent on the wire; the Codex CLI handles + # auth via its own login flow (api key / chatgpt / device). + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + # Codex models are picked from the local CLI catalogue; we + # never call a remote /models endpoint. + "model_list_mode": "curated", + # Hidden from the cross-provider dropdown until the frontend + # has confirmed availability via GET /api/codex/status. The + # chat-providers dialog conditionally surfaces the entry by + # merging the codex row in when status.installed is true. + "hidden": True, + "notes": ( + "Dispatches chat turns through the local Codex CLI via " + "the codex_app_server Python SDK. Surfaced only when the " + "CLI and SDK are both installed; sign in with `codex auth " + "login`." + ), + }, "openrouter": { "display_name": "OpenRouter", "base_url": "https://openrouter.ai/api/v1", diff --git a/studio/backend/main.py b/studio/backend/main.py index 004ae404cd..18c8edf766 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -115,6 +115,7 @@ from datetime import datetime from routes import ( auth_router, chat_history_router, + codex_router, data_recipe_router, datasets_router, export_router, @@ -522,6 +523,10 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = [" # standard /v1/chat/completions path. app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"]) app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"]) +# Codex SDK provider. Status probe + device-auth helper live behind a +# dedicated prefix so the frontend can call them without needing a +# provider config row to exist yet. +app.include_router(codex_router, prefix = "/api/codex", tags = ["codex"]) app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"]) app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"]) app.include_router(export_router, prefix = "/api/export", tags = ["export"]) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index b5626951c4..0fe2e5e629 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -786,6 +786,20 @@ class ChatCompletionRequest(BaseModel): "to auto-create." ), ) + parallel_calls: Optional[int] = Field( + None, + ge = 1, + le = 20, + description = ( + "[x-unsloth] Codex provider only. When > 1, fan the chat turn " + "out across N parallel Codex calls and synthesise a unified " + "final answer. Each parallel attempt is rendered as its own tab " + "in the chat UI; a final 'Synthesis' tab carries the merged " + "output. Bounded to [1, 20] by pydantic so a runaway value can't " + "saturate the local CLI. Silently ignored on every provider " + "other than `codex`." + ), + ) @model_validator(mode = "after") def _resolve_missing_tool_call_ids(self) -> "ChatCompletionRequest": diff --git a/studio/backend/routes/__init__.py b/studio/backend/routes/__init__.py index 6bb5d15e8e..575f2ccf4e 100644 --- a/studio/backend/routes/__init__.py +++ b/studio/backend/routes/__init__.py @@ -16,6 +16,7 @@ from routes.export import router as export_router from routes.training_history import router as training_history_router from routes.chat_history import router as chat_history_router from routes.providers import router as providers_router +from routes.codex import router as codex_router __all__ = [ "training_router", @@ -29,4 +30,5 @@ __all__ = [ "training_history_router", "chat_history_router", "providers_router", + "codex_router", ] diff --git a/studio/backend/routes/codex.py b/studio/backend/routes/codex.py new file mode 100644 index 0000000000..ec69bca8f5 --- /dev/null +++ b/studio/backend/routes/codex.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +API routes for the Codex SDK chat provider. + +Two endpoints live here: + +* ``GET /api/codex/status`` -- the availability probe. The frontend + hits this at chat-page load time and uses ``installed`` to decide + whether to surface the "codex" entry in the provider picker. When + ``installed=True`` but ``logged_in=False``, the provider config + dialog shows the "Sign in to Codex" affordance instead of the + regular API-key field. + +* ``POST /api/codex/login`` -- the device-auth helper. Spawns the + ``codex auth login --device-auth`` CLI command, captures the + verification URL from its output, and streams the rest of the auth + exchange back as SSE so the UI can show progress. The URL appears + in the first SSE event so the frontend can ``window.open`` it before + the user wanders off. +""" + +from __future__ import annotations + +import json +from typing import AsyncGenerator + +import structlog +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse + +from auth.authentication import get_current_subject +from core.inference.codex_availability import probe_codex_availability +from core.inference.codex_provider import stream_codex_device_login + +logger = structlog.get_logger(__name__) + +router = APIRouter() + + +@router.get("/status") +async def get_codex_status( + current_subject: str = Depends(get_current_subject), +) -> dict: + """Return the Codex CLI / SDK availability snapshot. + + The frontend gates the provider entry on ``installed`` and gates + the "Sign in to Codex" button on ``logged_in``. Both are + best-effort and cheap to recompute; the route does not cache the + probe because the user can install the CLI / SDK or run + ``codex auth login`` between page loads and the picker should pick + that up on the next refresh. + """ + return await probe_codex_availability() + + +@router.post("/login") +async def codex_device_login( + current_subject: str = Depends(get_current_subject), +) -> StreamingResponse: + """Stream the ``codex auth login --device-auth`` exchange. + + Returns an SSE stream of events: + + ``data: {"type": "device_url", "url": "https://..."}`` + ``data: {"type": "log", "line": "..."}`` (zero or more) + ``data: {"type": "done", "ok": true}`` + + The frontend opens the device URL in a new tab via + ``window.open(url, "_blank", "noopener,noreferrer")`` as soon as + the first event arrives, then renders the streamed log lines so + the user can see the CLI making progress while they're at the + verification page. + """ + + async def _to_sse() -> AsyncGenerator[str, None]: + async for event in stream_codex_device_login(): + yield f"data: {json.dumps(event)}\n\n" + # Frontend treats the trailing [DONE] the same way it does for + # chat streams, so we emit it for parity. + yield "data: [DONE]\n\n" + + return StreamingResponse( + _to_sse(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 02270ab405..484cf971ae 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1807,10 +1807,13 @@ async def _proxy_to_external_provider( detail = "Either provider_id or provider_type is required for external provider routing.", ) - # Fall back to registry default base URL + # Fall back to registry default base URL. Codex is the one + # provider with a deliberately empty base_url -- it dispatches + # through the local CLI rather than over HTTP -- so a missing + # base_url is only a 400 for every other provider type. if not base_url: base_url = get_base_url(provider_type) - if not base_url: + if not base_url and provider_type != "codex": raise HTTPException( status_code = 400, detail = f"Unknown provider type: {provider_type}", @@ -1845,6 +1848,75 @@ async def _proxy_to_external_provider( provider_type = provider_type, ) + # Codex provider: dispatch through the local CLI / SDK instead of + # the HTTP client. The SDK is not an OpenAI-compatible HTTP + # endpoint; it's a thread-oriented Python API that wraps the CLI. + # ``stream_codex`` is the single entry point so the parallel-calls + # fan-out and the single-call path share the same SSE shape. + if provider_type == "codex": + from core.inference.codex_provider import ( + CodexUnavailableError, + stream_codex, + ) + + async def _codex_stream(): + try: + gen = stream_codex( + messages = chat_messages, + model = model, + parallel_calls = payload.parallel_calls or 1, + ) + sent_done = False + async for line in gen: + yield f"{line}\n\n" + if "[DONE]" in line: + sent_done = True + if not sent_done: + yield "data: [DONE]\n\n" + except CodexUnavailableError as exc: + logger.warning("codex_provider.unavailable", error = str(exc)) + yield ( + "data: " + + json.dumps( + { + "error": { + "message": str(exc), + "type": "provider_error", + "code": "503", + "provider": "codex", + } + } + ) + + "\n\n" + ) + yield "data: [DONE]\n\n" + except Exception as exc: + logger.error("codex_provider.stream_error", error = str(exc)) + yield ( + "data: " + + json.dumps( + { + "error": { + "message": f"Codex error: {exc}", + "type": "provider_error", + "code": "502", + "provider": "codex", + } + } + ) + + "\n\n" + ) + yield "data: [DONE]\n\n" + + return StreamingResponse( + _codex_stream(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + client = ExternalProviderClient( provider_type = provider_type, base_url = base_url, diff --git a/studio/backend/tests/test_codex_provider.py b/studio/backend/tests/test_codex_provider.py new file mode 100644 index 0000000000..03e3e5abf9 --- /dev/null +++ b/studio/backend/tests/test_codex_provider.py @@ -0,0 +1,499 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Unit tests for the Codex SDK provider integration. + +Covers: + +* Availability probe: codex missing, codex present but logged out, + codex present + logged in, plus the empty-output / non-zero rc + edge cases the CLI has shipped over time. +* ``stream_codex`` event translation: a fake codex_app_server module + is dropped into ``sys.modules`` so the production import path runs + without the real SDK installed. Verifies an OpenAI Chat Completions + shape (content chunk, stop chunk, [DONE]). +* Parallel-calls fan-out: ``parallel_calls > 1`` spawns N async tasks + and emits ``codex_tab_open`` / ``codex_tab_chunk`` / ``codex_tab_close`` + events plus a final ``codex_gather`` synthesis event. +* Request validator: ``parallel_calls`` is clamped to [1, 20] by + pydantic so a runaway value is rejected with 422 before any Codex + task is spawned. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +import types +from typing import Any + +import pytest + + +_backend = os.path.join(os.path.dirname(__file__), "..") +if _backend not in sys.path: + sys.path.insert(0, _backend) + + +# ── Helpers ───────────────────────────────────────────────────────── + + +class _FakeStream: + """Async iterator that yields predetermined string text events. + + The Codex SDK's ``thread.run_streaming`` returns an async iterable + of events. ``_stream_thread_run`` converts those into raw text via + ``_coerce_text``; passing in plain strings exercises the simplest + coercion path. + """ + + def __init__(self, chunks: list[str]): + self._chunks = list(chunks) + self._i = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + if self._i >= len(self._chunks): + raise StopAsyncIteration + text = self._chunks[self._i] + self._i += 1 + return text + + +class _FakeThread: + def __init__(self, chunks: list[str], final: str | None = None): + self._chunks = chunks + self._final = final if final is not None else "".join(chunks) + + def run_streaming(self, prompt: str): + # ``run_streaming`` may return either an async iterable or a + # coroutine that resolves to one; cover the direct-return + # shape here, the coroutine shape is covered in a separate + # test below. + return _FakeStream(self._chunks) + + async def run(self, prompt: str): + return self._final + + +class _FakeAsyncCodex: + """Async-context-manager facade matching codex_app_server.AsyncCodex.""" + + def __init__( + self, + chunks: list[str] | None = None, + final: str | None = None, + raise_on_start: Exception | None = None, + ): + self._chunks = chunks or [] + self._final = final + self._raise = raise_on_start + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def thread_start(self, **kwargs): + if self._raise is not None: + raise self._raise + return _FakeThread(self._chunks, self._final) + + +def _install_fake_codex_sdk(monkeypatch, async_codex_cls): + """Drop a fake ``codex_app_server`` module into sys.modules so the + production lazy-import path picks it up without the real SDK + being installed. + """ + fake_mod = types.ModuleType("codex_app_server") + fake_mod.AsyncCodex = async_codex_cls # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "codex_app_server", fake_mod) + # importlib.util.find_spec walks finders, not sys.modules; patch + # it directly so the lazy-import gate accepts the fake. + import importlib.util as _iu + + real_find_spec = _iu.find_spec + + def _shim(name: str, *args, **kwargs): + if name == "codex_app_server": + return types.SimpleNamespace() + return real_find_spec(name, *args, **kwargs) + + monkeypatch.setattr("importlib.util.find_spec", _shim) + + +# ── Availability probe ───────────────────────────────────────────── + + +class TestCodexAvailability: + def test_absent_when_cli_missing(self, monkeypatch): + from core.inference import codex_availability as ca + + monkeypatch.setattr(ca, "_which_codex", lambda: None) + monkeypatch.setattr(ca, "_sdk_importable", lambda: False) + + payload = asyncio.run(ca.probe_codex_availability()) + assert payload["installed"] is False + assert payload["cli_path"] is None + assert payload["sdk_importable"] is False + # supported_models is a sensible default even when nothing is + # installed so the picker has something to render IF the user + # forces the entry on a future status flip. + assert isinstance(payload["supported_models"], list) + assert len(payload["supported_models"]) > 0 + + def test_present_but_sdk_missing(self, monkeypatch): + from core.inference import codex_availability as ca + + monkeypatch.setattr(ca, "_which_codex", lambda: "/usr/local/bin/codex") + monkeypatch.setattr(ca, "_sdk_importable", lambda: False) + + async def fake_version(): + return "codex-cli 0.133.0" + + async def fake_logged_in(): + return True + + monkeypatch.setattr(ca, "_detect_version", fake_version) + monkeypatch.setattr(ca, "_detect_logged_in", fake_logged_in) + + payload = asyncio.run(ca.probe_codex_availability()) + # installed requires BOTH CLI and SDK -- this is the gate the + # frontend uses to decide whether to surface the provider entry + # at all, so missing-SDK means hide. + assert payload["installed"] is False + assert payload["cli_path"] == "/usr/local/bin/codex" + assert payload["sdk_importable"] is False + assert payload["version"] == "codex-cli 0.133.0" + + def test_present_and_logged_out(self, monkeypatch): + from core.inference import codex_availability as ca + + monkeypatch.setattr(ca, "_which_codex", lambda: "/usr/local/bin/codex") + monkeypatch.setattr(ca, "_sdk_importable", lambda: True) + + async def fake_version(): + return "codex-cli 0.133.0" + + async def fake_logged_in(): + return False + + monkeypatch.setattr(ca, "_detect_version", fake_version) + monkeypatch.setattr(ca, "_detect_logged_in", fake_logged_in) + + payload = asyncio.run(ca.probe_codex_availability()) + assert payload["installed"] is True + assert payload["logged_in"] is False + assert payload["version"] == "codex-cli 0.133.0" + + def test_present_and_logged_in(self, monkeypatch): + from core.inference import codex_availability as ca + + monkeypatch.setattr(ca, "_which_codex", lambda: "/usr/local/bin/codex") + monkeypatch.setattr(ca, "_sdk_importable", lambda: True) + + async def fake_version(): + return "codex-cli 0.133.0" + + async def fake_logged_in(): + return True + + monkeypatch.setattr(ca, "_detect_version", fake_version) + monkeypatch.setattr(ca, "_detect_logged_in", fake_logged_in) + + payload = asyncio.run(ca.probe_codex_availability()) + assert payload["installed"] is True + assert payload["logged_in"] is True + + +# ── _stream_codex translation ────────────────────────────────────── + + +def _collect_stream(gen) -> list[str]: + async def run(): + out: list[str] = [] + async for line in gen: + out.append(line) + return out + + return asyncio.run(run()) + + +def _parse_sse_chunks(lines: list[str]) -> list[dict[str, Any]]: + """Decode SSE ``data: {...}`` lines into the chunk dicts. Skips the + sentinel ``data: [DONE]`` line and anything that isn't valid JSON. + """ + out: list[dict[str, Any]] = [] + for raw in lines: + if not raw.startswith("data:"): + continue + body = raw[len("data:") :].strip() + if not body or body == "[DONE]": + continue + try: + out.append(json.loads(body)) + except json.JSONDecodeError: + continue + return out + + +class TestStreamCodexSingle: + def test_streaming_chunks_translate_into_openai_shape(self, monkeypatch): + _install_fake_codex_sdk( + monkeypatch, + lambda: _FakeAsyncCodex(chunks = ["Hello", ", ", "world"]), + ) + from core.inference.codex_provider import stream_codex + + lines = _collect_stream( + stream_codex( + messages = [{"role": "user", "content": "Say hello in 3 chunks."}], + model = "gpt-5.4", + ) + ) + chunks = _parse_sse_chunks(lines) + # Three content deltas + one usage chunk + one stop chunk. + content_chunks = [ + c + for c in chunks + if c.get("choices") + and isinstance(c["choices"], list) + and c["choices"] + and c["choices"][0].get("delta", {}).get("content") + ] + assert [c["choices"][0]["delta"]["content"] for c in content_chunks] == [ + "Hello", + ", ", + "world", + ] + # Usage chunk (OpenAI include_usage shape) is a choices=[] entry + # with a populated usage block. + usage_chunks = [c for c in chunks if c.get("choices") == [] and c.get("usage")] + assert len(usage_chunks) == 1 + usage = usage_chunks[0]["usage"] + assert usage["prompt_tokens"] > 0 + assert usage["completion_tokens"] >= 0 + # Final stop chunk with finish_reason=stop. + stop_chunks = [ + c + for c in chunks + if c.get("choices") + and c["choices"] + and c["choices"][0].get("finish_reason") == "stop" + ] + assert len(stop_chunks) == 1 + # And the trailing [DONE] sentinel. + assert any(line.strip() == "data: [DONE]" for line in lines) + + def test_empty_user_prompt_emits_helpful_message(self, monkeypatch): + _install_fake_codex_sdk(monkeypatch, lambda: _FakeAsyncCodex(chunks = [])) + from core.inference.codex_provider import stream_codex + + lines = _collect_stream( + stream_codex( + messages = [{"role": "system", "content": "you are helpful"}], + model = "gpt-5.4", + ) + ) + text = "\n".join(lines) + assert "no user prompt" in text.lower() + + +class TestStreamCodexParallel: + def test_parallel_calls_spawn_tabs_and_synthesise(self, monkeypatch): + # The fake SDK returns the same canned chunks for every spawned + # AsyncCodex instance; we just need to verify the orchestrator + # emits N tab_open events, per-tab chunk events keyed by + # tab_id, and a final codex_gather summary event. + _install_fake_codex_sdk( + monkeypatch, + lambda: _FakeAsyncCodex( + chunks = ["alpha"], + final = "synthesised answer", + ), + ) + from core.inference.codex_provider import stream_codex + + n = 3 + lines = _collect_stream( + stream_codex( + messages = [{"role": "user", "content": "Test"}], + model = "gpt-5.4", + parallel_calls = n, + ) + ) + chunks = _parse_sse_chunks(lines) + tool_events = [c["_toolEvent"] for c in chunks if "_toolEvent" in c] + tab_opens = [e for e in tool_events if e.get("type") == "codex_tab_open"] + tab_chunks = [e for e in tool_events if e.get("type") == "codex_tab_chunk"] + tab_closes = [e for e in tool_events if e.get("type") == "codex_tab_close"] + gather = [e for e in tool_events if e.get("type") == "codex_gather"] + + # Each tab opens once -- the N tabs are pre-emitted so the + # UI can paint the strip before content arrives. + assert len(tab_opens) == n + assert sorted(e["tab_id"] for e in tab_opens) == list(range(1, n + 1)) + + # Per-tab chunks may interleave in any order but every tab id + # must produce at least one chunk before its close event. + seen_tabs = {e["tab_id"] for e in tab_chunks} + assert seen_tabs == set(range(1, n + 1)) + + # Each tab emits exactly one close marker. + assert sorted(e["tab_id"] for e in tab_closes) == list(range(1, n + 1)) + + # Exactly one synthesis event with the unified summary. + assert len(gather) == 1 + assert gather[0]["tab_count"] == n + # The summary text comes from the final synthesis Codex call; + # our fake returns "synthesised answer" via .run(). + assert "synth" in gather[0]["summary"].lower() + + def test_parallel_calls_clamped_to_maximum(self, monkeypatch): + """Passing parallel_calls=500 must NOT spawn 500 tasks; the + clamp at MAX_PARALLEL_CALLS keeps the local CLI safe. + """ + from core.inference import codex_provider as cp + + _install_fake_codex_sdk( + monkeypatch, + lambda: _FakeAsyncCodex(chunks = ["x"], final = "synth"), + ) + lines = _collect_stream( + cp.stream_codex( + messages = [{"role": "user", "content": "x"}], + model = "gpt-5.4", + parallel_calls = 500, + ) + ) + chunks = _parse_sse_chunks(lines) + tab_opens = [ + c["_toolEvent"] + for c in chunks + if c.get("_toolEvent", {}).get("type") == "codex_tab_open" + ] + assert len(tab_opens) == cp.MAX_PARALLEL_CALLS + + def test_parallel_calls_one_takes_single_path(self, monkeypatch): + """parallel_calls=1 must not emit any tab tool-events -- it's the + regular single-call shape. + """ + _install_fake_codex_sdk( + monkeypatch, + lambda: _FakeAsyncCodex(chunks = ["one"]), + ) + from core.inference.codex_provider import stream_codex + + lines = _collect_stream( + stream_codex( + messages = [{"role": "user", "content": "hi"}], + model = "gpt-5.4", + parallel_calls = 1, + ) + ) + chunks = _parse_sse_chunks(lines) + tool_events = [c.get("_toolEvent") for c in chunks if c.get("_toolEvent")] + for event in tool_events: + assert not (event.get("type") or "").startswith("codex_tab") + assert event.get("type") != "codex_gather" + + +# ── Request validator ────────────────────────────────────────────── + + +class TestParallelCallsValidator: + def test_request_accepts_valid_range(self): + from models.inference import ChatCompletionRequest + + for n in (1, 5, 10, 20): + req = ChatCompletionRequest( + model = "gpt-5.4", + messages = [{"role": "user", "content": "hi"}], + parallel_calls = n, + ) + assert req.parallel_calls == n + + def test_request_rejects_below_one(self): + from models.inference import ChatCompletionRequest + from pydantic import ValidationError + + with pytest.raises(ValidationError): + ChatCompletionRequest( + model = "gpt-5.4", + messages = [{"role": "user", "content": "hi"}], + parallel_calls = 0, + ) + + def test_request_rejects_above_twenty(self): + from models.inference import ChatCompletionRequest + from pydantic import ValidationError + + with pytest.raises(ValidationError): + ChatCompletionRequest( + model = "gpt-5.4", + messages = [{"role": "user", "content": "hi"}], + parallel_calls = 21, + ) + + def test_request_default_is_none(self): + """Default = None so the field has no effect on every existing + provider that doesn't read it -- preserves backwards compat. + """ + from models.inference import ChatCompletionRequest + + req = ChatCompletionRequest( + model = "gpt-5.4", + messages = [{"role": "user", "content": "hi"}], + ) + assert req.parallel_calls is None + + +# ── Codex unavailable surfacing ──────────────────────────────────── + + +class TestCodexUnavailable: + def test_missing_sdk_raises_typed_error(self, monkeypatch): + # Force find_spec to return None so the lazy import fails. + import importlib.util as _iu + + real = _iu.find_spec + + def _shim(name, *args, **kwargs): + if name == "codex_app_server": + return None + return real(name, *args, **kwargs) + + monkeypatch.setattr("importlib.util.find_spec", _shim) + # Also drop any cached fake from prior tests. + monkeypatch.delitem(sys.modules, "codex_app_server", raising = False) + + from core.inference.codex_provider import ( + CodexUnavailableError, + stream_codex, + ) + + with pytest.raises(CodexUnavailableError): + asyncio.run( + _consume_first( + stream_codex( + messages = [{"role": "user", "content": "hi"}], + model = "gpt-5.4", + ) + ) + ) + + +async def _consume_first(gen): + """Drive an async generator until it raises or yields its first + value. Used to surface lazy-import errors that fire on the first + SDK touch -- otherwise the generator would swallow them on + ``__aiter__`` and the test couldn't see them. + """ + async for _ in gen: + return diff --git a/studio/frontend/src/features/chat/api/codex-api.ts b/studio/frontend/src/features/chat/api/codex-api.ts new file mode 100644 index 0000000000..6652bf9391 --- /dev/null +++ b/studio/frontend/src/features/chat/api/codex-api.ts @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * API helpers for the local Codex SDK provider. + * + * The backend exposes two endpoints under ``/api/codex``: + * + * - ``GET /api/codex/status`` returns ``{installed, logged_in, version, + * cli_path, sdk_importable, supported_models}``. The chat-providers + * dialog consults this BEFORE surfacing the "codex" entry in the + * picker -- if ``installed`` is false the provider stays hidden, if + * ``logged_in`` is false we render a "Sign in to Codex" button in + * place of the API-key field. + * + * - ``POST /api/codex/login`` runs ``codex auth login --device-auth`` + * under the hood and streams SSE events. The first event is always + * ``{type: "device_url", url}`` so the UI can window.open it; later + * events forward CLI log lines so the user can watch progress. + */ + +import { authFetch } from "@/features/auth"; + +export interface CodexStatus { + installed: boolean; + logged_in: boolean; + cli_path: string | null; + sdk_importable: boolean; + version: string | null; + supported_models: string[]; +} + +export interface CodexLoginEvent { + type: "device_url" | "log" | "error" | "done"; + url?: string; + line?: string; + message?: string; + ok?: boolean; + return_code?: number; +} + +const DEFAULT_STATUS: CodexStatus = { + installed: false, + logged_in: false, + cli_path: null, + sdk_importable: false, + version: null, + supported_models: [], +}; + +/** + * Fetch the current Codex availability snapshot. Network failures are + * swallowed and reported as ``installed=false`` because every caller + * either uses this to gate UI surfacing (the right answer on error is + * "hide the entry") or kicks off a chat (the right answer on error is + * "fall back to a different provider"). Throwing here would force + * every consumer to wrap the call in a try/catch. + */ +export async function fetchCodexStatus(): Promise { + try { + const response = await authFetch("/api/codex/status"); + if (!response.ok) { + return DEFAULT_STATUS; + } + const body = (await response.json()) as Partial; + return { + installed: Boolean(body.installed), + logged_in: Boolean(body.logged_in), + cli_path: typeof body.cli_path === "string" ? body.cli_path : null, + sdk_importable: Boolean(body.sdk_importable), + version: typeof body.version === "string" ? body.version : null, + supported_models: Array.isArray(body.supported_models) + ? body.supported_models.filter( + (value): value is string => typeof value === "string", + ) + : [], + }; + } catch { + return DEFAULT_STATUS; + } +} + +/** + * Open a Codex device-auth login stream and yield each parsed event. + * + * Returns an async generator the caller drives in a for-await loop -- + * the dialog reads the first ``device_url`` event to know what URL to + * window.open, then collects the remaining ``log`` lines into the + * visible progress area until the ``done`` sentinel arrives. + * + * The generator handles abort signals: when the dialog closes mid- + * flow, the caller passes an AbortSignal that tears down the SSE + * stream cleanly. Without this, the long-running login subprocess + * would keep pumping lines into a torn-down React tree. + */ +export async function* streamCodexDeviceLogin( + signal?: AbortSignal, +): AsyncGenerator { + const response = await authFetch("/api/codex/login", { + method: "POST", + signal, + }); + if (!response.ok || !response.body) { + yield { + type: "error", + message: `codex login request failed: HTTP ${response.status}`, + }; + yield { type: "done", ok: false }; + return; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newlineIdx; + // SSE frames are separated by blank lines; within each frame the + // payload sits on a single ``data: {...}`` line. Strip both the + // SSE prefix and the [DONE] sentinel before JSON.parse. + while ((newlineIdx = buffer.indexOf("\n\n")) !== -1) { + const frame = buffer.slice(0, newlineIdx); + buffer = buffer.slice(newlineIdx + 2); + for (const line of frame.split("\n")) { + if (!line.startsWith("data:")) continue; + const body = line.slice("data:".length).trim(); + if (!body || body === "[DONE]") continue; + try { + yield JSON.parse(body) as CodexLoginEvent; + } catch { + // Skip any malformed line. The CLI shouldn't ever produce + // these, but it costs nothing to defend against. + } + } + } + } + } finally { + try { + reader.releaseLock(); + } catch { + // ignore + } + } +} diff --git a/studio/frontend/src/features/chat/components/codex-login-button.tsx b/studio/frontend/src/features/chat/components/codex-login-button.tsx new file mode 100644 index 0000000000..7f7737cc28 --- /dev/null +++ b/studio/frontend/src/features/chat/components/codex-login-button.tsx @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * "Sign in to Codex" button + streamed log surface. + * + * Renders in place of the regular API-key field in the provider config + * dialog when ``/api/codex/status`` reports ``logged_in=false``. Click + * fires POST ``/api/codex/login``, which spawns + * ``codex auth login --device-auth`` server-side. The first SSE event + * carries the verification URL -- as soon as it arrives we open it in + * a new tab via ``window.open`` so the user doesn't have to copy-paste + * a long URL out of a log pane. + * + * The button stays mounted while the CLI is exchanging the device + * code: the streamed ``log`` events accumulate into the visible + * progress area until the ``done`` event closes the stream. The + * caller passes ``onLoggedIn`` so the dialog can refetch the status + * probe and flip back into the "ready" state automatically. + */ + +import { useCallback, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + streamCodexDeviceLogin, + type CodexLoginEvent, +} from "../api/codex-api"; + +interface Props { + /** Called when the device-auth flow finishes successfully so the + * parent can re-probe ``/api/codex/status`` and switch UI states. */ + onLoggedIn?: () => void; +} + +export function CodexLoginButton({ onLoggedIn }: Props) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [logs, setLogs] = useState([]); + const [deviceUrl, setDeviceUrl] = useState(null); + // Track the active stream's abort controller so a second click + // (or an unmount) tears the SSE reader down cleanly. Without this + // the long-running login subprocess would keep streaming into a + // detached component. + const abortRef = useRef(null); + + const startLogin = useCallback(async () => { + if (busy) return; + setBusy(true); + setError(null); + setLogs([]); + setDeviceUrl(null); + const controller = new AbortController(); + abortRef.current?.abort(); + abortRef.current = controller; + try { + let lastOk: boolean | undefined; + for await (const event of streamCodexDeviceLogin( + controller.signal, + ) as AsyncGenerator) { + if (event.type === "device_url" && event.url) { + setDeviceUrl(event.url); + // Open the verification page eagerly so the user doesn't + // have to copy the URL out of the log surface. ``noopener`` + // prevents the auth-tab from controlling the Studio window. + try { + window.open(event.url, "_blank", "noopener,noreferrer"); + } catch { + // Ignore -- the URL is still visible in the log. + } + } else if (event.type === "log" && event.line) { + setLogs((prev) => [...prev, event.line as string]); + } else if (event.type === "error" && event.message) { + setError(event.message); + } else if (event.type === "done") { + lastOk = event.ok; + } + } + if (lastOk) { + onLoggedIn?.(); + } else if (!error) { + setError("Codex login did not complete -- see log for details."); + } + } catch (exc) { + if ((exc as { name?: string } | null)?.name !== "AbortError") { + setError(String((exc as Error)?.message ?? exc)); + } + } finally { + setBusy(false); + } + }, [busy, error, onLoggedIn]); + + return ( +
+ + {deviceUrl && ( +

+ Verification URL:{" "} + + {deviceUrl} + +

+ )} + {error && ( +

{error}

+ )} + {logs.length > 0 && ( +
+          {logs.join("\n")}
+        
+ )} +
+ ); +} diff --git a/studio/frontend/src/features/chat/components/codex-parallel-tabs.tsx b/studio/frontend/src/features/chat/components/codex-parallel-tabs.tsx new file mode 100644 index 0000000000..d48163deb9 --- /dev/null +++ b/studio/frontend/src/features/chat/components/codex-parallel-tabs.tsx @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/** + * Tabbed render for Codex parallel-calls fan-out. + * + * The backend emits four ``_toolEvent`` shapes for ``parallel_calls > 1``: + * + * - ``codex_tab_open {tab_id, query, total_tabs}`` + * - ``codex_tab_chunk {tab_id, text}`` + * - ``codex_tab_close {tab_id}`` + * - ``codex_gather {summary, tab_count}`` + * + * The chat-adapter passes these events into ``useCodexParallelTabs`` + * via the shared tool-event channel. The hook collapses them into a + * tab list (one entry per ``tab_id``) plus a synthesis row, and the + * component below renders a horizontal tab strip with the active + * tab's text in a scrollable panel below. The Synthesis tab is + * highlighted because it's the unified answer the user usually wants + * to read. + */ + +import { useMemo, useState } from "react"; +import { cn } from "@/lib/utils"; + +export interface CodexTabState { + /** 1-based tab index from the backend. */ + tabId: number; + /** Accumulated text from ``codex_tab_chunk`` events for this tab. */ + text: string; + /** True once the matching ``codex_tab_close`` event has arrived. */ + closed: boolean; + /** Set when a ``codex_tab_error`` event was emitted for this tab. */ + error?: string; +} + +export interface CodexParallelState { + /** Per-tab streamed text, keyed by tabId, sorted ascending. */ + tabs: CodexTabState[]; + /** The original user query echoed on each tab_open event. */ + query: string | null; + /** Final synthesis text from the ``codex_gather`` event. */ + synthesis: string | null; + /** Total tabs reported on the first ``codex_tab_open`` event. */ + totalTabs: number; +} + +export type CodexParallelEvent = + | { type: "codex_tab_open"; tab_id: number; query?: string; total_tabs?: number } + | { type: "codex_tab_chunk"; tab_id: number; text: string } + | { type: "codex_tab_close"; tab_id: number } + | { type: "codex_tab_error"; tab_id: number; error?: string } + | { type: "codex_gather"; summary?: string; tab_count?: number }; + +/** + * Pure reducer: given the prior parallel state and a single event, + * return the new state. Kept as a standalone function so the chat- + * adapter can drive it without re-rendering, and so it's trivially + * unit-testable. + */ +export function reduceCodexParallelState( + prev: CodexParallelState, + event: CodexParallelEvent, +): CodexParallelState { + switch (event.type) { + case "codex_tab_open": { + // Idempotent: re-opening an existing tab leaves it intact. + const exists = prev.tabs.some((t) => t.tabId === event.tab_id); + const tabs = exists + ? prev.tabs + : [ + ...prev.tabs, + { tabId: event.tab_id, text: "", closed: false }, + ].sort((a, b) => a.tabId - b.tabId); + return { + ...prev, + tabs, + query: prev.query ?? event.query ?? null, + totalTabs: event.total_tabs ?? Math.max(prev.totalTabs, event.tab_id), + }; + } + case "codex_tab_chunk": { + const tabs = prev.tabs.map((t) => + t.tabId === event.tab_id ? { ...t, text: t.text + event.text } : t, + ); + // Auto-create the slot if a chunk arrived before its open event + // (shouldn't happen with the current backend ordering, but + // defending against that race keeps the UI stable). + if (!tabs.some((t) => t.tabId === event.tab_id)) { + tabs.push({ tabId: event.tab_id, text: event.text, closed: false }); + tabs.sort((a, b) => a.tabId - b.tabId); + } + return { ...prev, tabs }; + } + case "codex_tab_close": { + const tabs = prev.tabs.map((t) => + t.tabId === event.tab_id ? { ...t, closed: true } : t, + ); + return { ...prev, tabs }; + } + case "codex_tab_error": { + const tabs = prev.tabs.map((t) => + t.tabId === event.tab_id + ? { ...t, closed: true, error: event.error } + : t, + ); + return { ...prev, tabs }; + } + case "codex_gather": { + return { ...prev, synthesis: event.summary ?? "" }; + } + default: { + return prev; + } + } +} + +export const EMPTY_CODEX_PARALLEL_STATE: CodexParallelState = { + tabs: [], + query: null, + synthesis: null, + totalTabs: 0, +}; + +/** True when the state carries at least one observed event. */ +export function hasCodexParallelContent(state: CodexParallelState): boolean { + return state.tabs.length > 0 || state.synthesis !== null; +} + +interface Props { + state: CodexParallelState; + /** Collapsed by default per spec; user clicks to expand. */ + defaultCollapsed?: boolean; +} + +export function CodexParallelTabs({ state, defaultCollapsed = true }: Props) { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + const [activeTab, setActiveTab] = useState("synthesis"); + + // Whenever the synthesis arrives, switch to it automatically -- it's + // the answer the user usually reads. Use a useMemo + effect-like + // pattern via render-time check so we don't depend on extra hooks. + // (A useEffect would also work; this stays lighter.) + const effectiveActive = useMemo(() => { + if (state.synthesis && activeTab !== "synthesis") { + return activeTab; + } + if (state.synthesis) { + return "synthesis"; + } + if (state.tabs.length > 0 && activeTab === "synthesis") { + return state.tabs[0].tabId; + } + return activeTab; + }, [state.synthesis, state.tabs, activeTab]); + + if (!hasCodexParallelContent(state)) { + return null; + } + + const totalSlots = state.totalTabs || state.tabs.length; + + return ( +
+ + {!collapsed && ( + <> +
+ {state.tabs.map((tab) => ( + + ))} + {state.synthesis !== null && ( + + )} +
+
+ {effectiveActive === "synthesis" + ? state.synthesis || "(waiting for synthesis…)" + : (state.tabs.find((t) => t.tabId === effectiveActive)?.text || + "(waiting…)")} +
+ + )} +
+ ); +} diff --git a/studio/frontend/src/features/chat/external-providers.ts b/studio/frontend/src/features/chat/external-providers.ts index dddf529068..8b25f60bf2 100644 --- a/studio/frontend/src/features/chat/external-providers.ts +++ b/studio/frontend/src/features/chat/external-providers.ts @@ -79,11 +79,45 @@ export function supportsProviderReasoningToggle( ); } +/** + * The Codex CLI / SDK provider. Surfaced only when the host has BOTH + * the ``codex`` CLI on PATH and the ``codex_app_server`` Python SDK + * importable -- the backend's ``GET /api/codex/status`` is the + * authoritative gate. We expose the type id here so the rest of the + * frontend can reference it without scattering "codex" string + * literals. + */ +export const CODEX_PROVIDER_TYPE = "codex"; + +export function isCodexProviderType( + providerType: string | null | undefined, +): boolean { + return providerType === CODEX_PROVIDER_TYPE; +} + +/** Hard cap mirrors backend MAX_PARALLEL_CALLS to keep the UI honest. */ +export const CODEX_MAX_PARALLEL_CALLS = 20; +export const CODEX_DEFAULT_PARALLEL_CALLS = 1; + +export function clampCodexParallelCalls(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) + ? Math.floor(value) + : CODEX_DEFAULT_PARALLEL_CALLS; + if (n < 1) return 1; + if (n > CODEX_MAX_PARALLEL_CALLS) return CODEX_MAX_PARALLEL_CALLS; + return n; +} + // Known text-only providers on their main chat endpoint. const NON_VISION_PROVIDER_TYPES = new Set([ "cohere", "deepseek", "mistral", + // Codex SDK input is text-first; multimodal attachments are + // converted to placeholder text descriptors before the prompt + // reaches the local CLI. Mark text-only so the composer hides + // image-attach affordances when codex is selected. + CODEX_PROVIDER_TYPE, ]); // Providers whose vision-tier model selection accepts images. const VISION_CAPABLE_PROVIDER_TYPES = new Set([