Compare commits
48 commits
main
...
feat/codex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c716af442f | ||
|
|
6403846bbe | ||
|
|
6867cfbd6d | ||
|
|
cd8284d5cd | ||
|
|
10d6b6939e | ||
|
|
f01011e4dd | ||
|
|
3d1075f6fd | ||
|
|
ecf8bc767d | ||
|
|
dd0aeec388 | ||
|
|
e2b7f5958b | ||
|
|
3bbbd41227 | ||
|
|
f29faefb98 | ||
|
|
5185032b28 | ||
|
|
b17765b5ed | ||
|
|
be15c57fee | ||
|
|
dee1b68b6d | ||
|
|
8cef479423 | ||
|
|
f05517ac79 | ||
|
|
26799d9a18 | ||
|
|
b7862388fa | ||
|
|
c755d00c1f | ||
|
|
2eaf1bbd31 | ||
|
|
2874abbfec | ||
|
|
8ee60019a4 | ||
|
|
aa258b983d | ||
|
|
cb0680ebaa | ||
|
|
f97a800d5b | ||
|
|
9b4bd11c9f | ||
|
|
8c1c63a64d | ||
|
|
c5289f0249 | ||
|
|
1a107651c4 | ||
|
|
593fc9edba | ||
|
|
67b837e9f1 | ||
|
|
4be807bbd8 | ||
|
|
4b4c8553f8 | ||
|
|
fd8f25f507 | ||
|
|
1f7cad4ccf | ||
|
|
028b7b7187 | ||
|
|
e2ac4907bf | ||
|
|
d6c47f6664 | ||
|
|
0d904d615c | ||
|
|
b6577a6287 | ||
|
|
861da31fcf | ||
|
|
b8cd677397 | ||
|
|
0a4309fefd | ||
|
|
4188f916f4 | ||
|
|
ea7ae85562 | ||
|
|
cbc3c43655 |
21 changed files with 6207 additions and 33 deletions
81
.github/workflows/studio-codex-cross-platform-ci.yml
vendored
Normal file
81
.github/workflows/studio-codex-cross-platform-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||||
|
|
||||||
|
# Cross-platform CI for the OpenAI Codex chat-provider work (PR #5724).
|
||||||
|
#
|
||||||
|
# The main studio-backend-ci.yml runs the full backend test suite on
|
||||||
|
# ubuntu-latest across Python 3.10/3.11/3.12/3.13, which already
|
||||||
|
# exercises tests/test_codex_provider.py. This file adds Codex-only
|
||||||
|
# matrix runs on macos-14 (Apple Silicon, MLX-relevant for Studio
|
||||||
|
# users) and windows-latest (Studio ships a Windows desktop build)
|
||||||
|
# so the codex_bin / sys.modules / importlib gates that Codex relies
|
||||||
|
# on are validated on all three platforms with a small (~1 min)
|
||||||
|
# cycle time. Paths filter keeps it from re-running on unrelated
|
||||||
|
# code changes.
|
||||||
|
|
||||||
|
name: Studio Codex Cross-Platform CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'studio/backend/core/inference/codex_provider.py'
|
||||||
|
- 'studio/backend/core/inference/codex_availability.py'
|
||||||
|
- 'studio/backend/routes/codex.py'
|
||||||
|
- 'studio/backend/tests/test_codex_provider.py'
|
||||||
|
- '.github/workflows/studio-codex-cross-platform-ci.yml'
|
||||||
|
push:
|
||||||
|
branches: [main, pip, feat/codex-provider]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
codex-tests:
|
||||||
|
name: Codex tests (${{ matrix.os }}, Py ${{ matrix.python }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 10
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-14, windows-latest]
|
||||||
|
python: ['3.11', '3.13']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
cache: 'pip'
|
||||||
|
|
||||||
|
- name: Install minimal deps for Codex tests
|
||||||
|
# Codex provider tests inject a fake SDK via sys.modules + patch
|
||||||
|
# importlib.util.find_spec, so the real openai-codex package is
|
||||||
|
# not required. structlog / fastapi / pydantic / httpx come from
|
||||||
|
# the production import chain that codex_provider.py walks at
|
||||||
|
# module load.
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install \
|
||||||
|
pytest pytest-asyncio httpx \
|
||||||
|
'pydantic>=2,<3' \
|
||||||
|
structlog \
|
||||||
|
fastapi \
|
||||||
|
python-multipart aiofiles sqlalchemy cryptography \
|
||||||
|
pyyaml jinja2 requests \
|
||||||
|
'numpy<3'
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Run codex tests
|
||||||
|
working-directory: studio/backend
|
||||||
|
run: |
|
||||||
|
python -m pytest \
|
||||||
|
tests/test_codex_provider.py \
|
||||||
|
-q --tb=short
|
||||||
|
shell: bash
|
||||||
399
studio/backend/core/inference/codex_availability.py
Normal file
399
studio/backend/core/inference/codex_availability.py
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
# Safe-list of environment variables forwarded to the codex subprocess.
|
||||||
|
# Studio's parent env contains secrets (HF_TOKEN, GH_TOKEN, WANDB_API_KEY,
|
||||||
|
# OPENAI key for non-codex providers, etc.); a malicious or shimmed codex
|
||||||
|
# binary earlier on PATH would receive all of them via plain os.environ
|
||||||
|
# inheritance. We pass only what codex needs to spawn its own helpers
|
||||||
|
# (PATH), resolve its auth/config dir (HOME / USER / Windows equivalents
|
||||||
|
# plus CODEX_HOME), and emit log output in the user's locale.
|
||||||
|
#
|
||||||
|
# OPENAI_API_KEY is DELIBERATELY excluded. The codex CLI authenticates
|
||||||
|
# via its own `codex login --device-auth` ChatGPT flow or via stdin
|
||||||
|
# (`--with-api-key`); Studio's stored OpenAI key belongs to the OpenAI
|
||||||
|
# provider, not Codex. Forwarding it would let a shimmed `codex` binary
|
||||||
|
# on PATH exfiltrate the user's OpenAI credential. Users who want to
|
||||||
|
# wire the same key into Codex should set CODEX_OPENAI_API_KEY or feed
|
||||||
|
# the key via `codex login --with-api-key` themselves.
|
||||||
|
_SAFE_CODEX_ENV_KEYS: tuple[str, ...] = (
|
||||||
|
"PATH",
|
||||||
|
"HOME",
|
||||||
|
"USER",
|
||||||
|
"USERNAME",
|
||||||
|
"SHELL",
|
||||||
|
"LANG",
|
||||||
|
"LC_ALL",
|
||||||
|
"TMPDIR",
|
||||||
|
"TEMP",
|
||||||
|
"TMP",
|
||||||
|
"SYSTEMROOT",
|
||||||
|
"WINDIR",
|
||||||
|
"APPDATA",
|
||||||
|
"LOCALAPPDATA",
|
||||||
|
"PROGRAMDATA",
|
||||||
|
"CODEX_HOME",
|
||||||
|
"CODEX_OPENAI_API_KEY",
|
||||||
|
# Studio-internal override for the round 6b fail-closed safety
|
||||||
|
# pin gate. Kept in the safe-list so the round 6 SDK env-scrub
|
||||||
|
# wrapper does not delete it from `os.environ` before
|
||||||
|
# `_start_thread_with_system` checks it. The variable is not a
|
||||||
|
# secret; the codex subprocess receiving it is harmless.
|
||||||
|
"UNSLOTH_CODEX_ALLOW_UNSAFE_DEFAULTS",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_subprocess_env() -> dict[str, str]:
|
||||||
|
"""Return a scrubbed env mapping for codex subprocess spawning.
|
||||||
|
|
||||||
|
Forwards only keys from `_SAFE_CODEX_ENV_KEYS` that are actually set
|
||||||
|
in the parent environment, so secrets from other providers never
|
||||||
|
reach the codex CLI.
|
||||||
|
"""
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
for key in _SAFE_CODEX_ENV_KEYS:
|
||||||
|
value = os.environ.get(key)
|
||||||
|
if value is not None:
|
||||||
|
env[key] = value
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
When ``UNSLOTH_CODEX_SPOOF=1`` is set we report importable=True so
|
||||||
|
the frontend exposes the Codex provider in dev / CI without a real
|
||||||
|
SDK install. The spoof module gets swapped into ``sys.modules`` on
|
||||||
|
first ``_import_codex`` call, so any downstream consumer that
|
||||||
|
actually imports also succeeds.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from core.inference import codex_spoof
|
||||||
|
|
||||||
|
if codex_spoof.is_spoof_enabled():
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
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 login 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.
|
||||||
|
|
||||||
|
Subprocess lifecycle: detached into its own process group on Unix
|
||||||
|
via ``start_new_session=True`` (matching ``stream_codex_device_login``)
|
||||||
|
so a hung child cannot survive ``proc.kill()`` on timeout. Without
|
||||||
|
this, a shimmed ``codex login status`` that forks a helper then
|
||||||
|
blocks would leave the helper running after we killed the parent.
|
||||||
|
Windows uses ``CREATE_NEW_PROCESS_GROUP`` for the analogous
|
||||||
|
isolation. Round 6 reviewer caught the asymmetry with the
|
||||||
|
device-login path that already had this guard.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
|
||||||
|
spawn_kwargs: dict[str, Any] = {
|
||||||
|
"stdout": asyncio.subprocess.PIPE,
|
||||||
|
"stderr": asyncio.subprocess.PIPE,
|
||||||
|
"env": _codex_subprocess_env(),
|
||||||
|
}
|
||||||
|
if os.name == "posix":
|
||||||
|
spawn_kwargs["start_new_session"] = True
|
||||||
|
elif os.name == "nt":
|
||||||
|
spawn_kwargs["creationflags"] = 0x00000200 # CREATE_NEW_PROCESS_GROUP
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec("codex", *args, **spawn_kwargs)
|
||||||
|
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:
|
||||||
|
# Kill the whole process group, not just the parent, so any
|
||||||
|
# child the codex CLI forked also dies.
|
||||||
|
if os.name == "posix":
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
proc.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# proc.kill() can race with the process-group SIGTERM above:
|
||||||
|
# if the child has already been reaped between the killpg and
|
||||||
|
# this line, proc.kill() raises ProcessLookupError on POSIX
|
||||||
|
# and turns /api/codex/status into a 500 during a timeout.
|
||||||
|
# Match the broader exception guard already used in the
|
||||||
|
# device-login cleanup path.
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"codex_availability.kill_failed",
|
||||||
|
args = args,
|
||||||
|
exc_type = type(exc).__name__,
|
||||||
|
error = str(exc),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), timeout = 1.0)
|
||||||
|
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.
|
||||||
|
# Covers the variants seen across CLI releases and locales.
|
||||||
|
negative = re.compile(
|
||||||
|
r"\b(not\s+(?:logged|signed)\s+in|"
|
||||||
|
r"not\s+authenticated|"
|
||||||
|
r"please\s+(?:log|sign)\s+in|"
|
||||||
|
r"run\s+`?codex\s+login`?)\b"
|
||||||
|
)
|
||||||
|
if negative.search(combined):
|
||||||
|
return False
|
||||||
|
|
||||||
|
positive = re.compile(
|
||||||
|
r"\b("
|
||||||
|
r"logged in|"
|
||||||
|
r"authenticated as|"
|
||||||
|
r"authenticated:\s*yes|"
|
||||||
|
r"signed in"
|
||||||
|
r")\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 Studio can actually drive Codex
|
||||||
|
end-to-end: BOTH the Python SDK (for chat) AND a `codex`
|
||||||
|
executable on PATH (for the device-auth login flow). The
|
||||||
|
canonical `openai-codex` package depends on `openai-codex-cli-bin`
|
||||||
|
which installs the `codex` shim into the venv's `bin/`, so the
|
||||||
|
common SDK-only install in fact gets the CLI on PATH for free
|
||||||
|
and this gate triggers correctly. Hosts that import the SDK
|
||||||
|
from a wheel without that runtime dep stay hidden because the
|
||||||
|
login flow would otherwise fail with "codex CLI not found on
|
||||||
|
PATH" after the user clicked Sign in.
|
||||||
|
* ``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()
|
||||||
|
|
||||||
|
# Spoof mode also fakes the CLI half of the install signal so the
|
||||||
|
# frontend stops hiding the Codex provider in dev / CI. ``installed``
|
||||||
|
# gates on the spoof being explicitly opted in, so production hosts
|
||||||
|
# without the flag still see the real CLI / SDK gating intact.
|
||||||
|
spoof_active = False
|
||||||
|
try:
|
||||||
|
from core.inference import codex_spoof
|
||||||
|
|
||||||
|
spoof_active = codex_spoof.is_spoof_enabled()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
# Gate on BOTH because the login flow shells out to `codex`.
|
||||||
|
# Round 5 briefly set this to `sdk_ok` alone, but round 6
|
||||||
|
# caught that the login route would then fail with
|
||||||
|
# `codex CLI not found on PATH` after the user clicked
|
||||||
|
# Sign in, leaving them with an unusable provider row.
|
||||||
|
"installed": (bool(cli_path) and sdk_ok) or spoof_active,
|
||||||
|
"cli_path": cli_path or ("<spoof>" if spoof_active else None),
|
||||||
|
"sdk_importable": sdk_ok,
|
||||||
|
"logged_in": spoof_active,
|
||||||
|
"version": "spoof" if spoof_active else 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
|
||||||
1631
studio/backend/core/inference/codex_provider.py
Normal file
1631
studio/backend/core/inference/codex_provider.py
Normal file
File diff suppressed because it is too large
Load diff
271
studio/backend/core/inference/codex_spoof.py
Normal file
271
studio/backend/core/inference/codex_spoof.py
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||||
|
"""Local-only Codex SDK spoof for credit-free dev / CI runs.
|
||||||
|
|
||||||
|
Activated by ``UNSLOTH_CODEX_SPOOF=1`` -- the gate in ``codex_provider``
|
||||||
|
swaps in this module's symbols for ``openai_codex`` so the rest of the
|
||||||
|
provider can run end-to-end (thread_start, turn().stream(), run_streaming,
|
||||||
|
run(), AppServerConfig, ApprovalMode.deny_all, SandboxMode.read_only)
|
||||||
|
without ever touching the real CLI or upstream API.
|
||||||
|
|
||||||
|
The fake stream emits one ``message.delta`` per visible token plus a
|
||||||
|
trailing ``ItemCompletedNotification(item=agentMessage)`` so both the
|
||||||
|
delta path and the completion-only fallback in
|
||||||
|
``_stream_thread_run`` exercise their real branches.
|
||||||
|
|
||||||
|
The replies are deterministic and tagged with the model + tab index so
|
||||||
|
the parallel-calls fan-out shows visibly distinct text per tab, which
|
||||||
|
is the point of the tab UI demo. The spoof intentionally does NOT
|
||||||
|
emit command / file / tool deltas -- those would be denylisted by
|
||||||
|
``_coerce_text`` and never reach the user, and we want the demo to
|
||||||
|
show the same shape Codex normally streams: pure agent text.
|
||||||
|
|
||||||
|
This file is import-safe: it has no side effects on import. It MUST
|
||||||
|
never be selected unless the env flag is set explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, AsyncIterator, Optional
|
||||||
|
|
||||||
|
|
||||||
|
SPOOF_ENV_VAR = "UNSLOTH_CODEX_SPOOF"
|
||||||
|
|
||||||
|
|
||||||
|
def is_spoof_enabled() -> bool:
|
||||||
|
"""Return True when the env flag is set to an explicit truthy value."""
|
||||||
|
return os.environ.get(SPOOF_ENV_VAR, "").strip().lower() in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalMode(str, Enum):
|
||||||
|
deny_all = "deny_all"
|
||||||
|
auto_review = "auto_review"
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxMode(str, Enum):
|
||||||
|
read_only = "read_only"
|
||||||
|
workspace_write = "workspace_write"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppServerConfig:
|
||||||
|
env: Optional[dict[str, str]] = None
|
||||||
|
codex_bin: Optional[str] = None
|
||||||
|
extra: dict[str, Any] = field(default_factory = dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _AgentMessage:
|
||||||
|
type: str = "agentMessage"
|
||||||
|
text: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ItemRoot:
|
||||||
|
root: _AgentMessage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ItemCompletedNotification:
|
||||||
|
"""Mirrors openai_codex.api.ItemCompletedNotification shape.
|
||||||
|
|
||||||
|
The class name is matched verbatim by ``_completed_agent_message_text``
|
||||||
|
so the completion-only fallback in ``_stream_thread_run`` recognises
|
||||||
|
these payloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
item: _ItemRoot
|
||||||
|
type: str = "ItemCompletedNotification"
|
||||||
|
|
||||||
|
|
||||||
|
def _tab_id_from_system(system: Optional[str]) -> int:
|
||||||
|
"""Pull the synthetic ``[tab N]`` marker the provider prepends to
|
||||||
|
each parallel worker's system prompt (when present), else 0."""
|
||||||
|
if not system:
|
||||||
|
return 0
|
||||||
|
for line in system.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("[tab ") and line.endswith("]"):
|
||||||
|
try:
|
||||||
|
return int(line[len("[tab ") : -1].split("/")[0])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _spoof_response_text(model: str, prompt: str, tab_id: int) -> str:
|
||||||
|
"""Deterministic but visibly per-tab response.
|
||||||
|
|
||||||
|
Format keeps each parallel worker's reply distinct so when the user
|
||||||
|
clicks between tabs they see different text -- the whole point of
|
||||||
|
the tab UI demo.
|
||||||
|
"""
|
||||||
|
prompt_clean = (prompt or "").strip().replace("\n", " ")
|
||||||
|
if len(prompt_clean) > 120:
|
||||||
|
prompt_clean = prompt_clean[:117] + "..."
|
||||||
|
tab_suffix = f" (worker {tab_id})" if tab_id else ""
|
||||||
|
return (
|
||||||
|
f"[spoof reply from {model}{tab_suffix}] "
|
||||||
|
f"You said: {prompt_clean!r}. "
|
||||||
|
f"This response is generated by the local Codex spoof "
|
||||||
|
f"(UNSLOTH_CODEX_SPOOF=1) -- no upstream tokens were used."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _TurnStream:
|
||||||
|
"""Async iterator returned by ``Turn.stream()``.
|
||||||
|
|
||||||
|
Emits a sequence of dict-shaped ``message.delta`` events (one word at
|
||||||
|
a time, so the chat-adapter's streaming surface gets exercised) and
|
||||||
|
closes with an ``ItemCompletedNotification`` carrying the same final
|
||||||
|
text. Matches the dual delta + completion shape the real upstream
|
||||||
|
SDK emits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, text: str, delay_s: float = 0.01) -> None:
|
||||||
|
self._text = text
|
||||||
|
self._delay_s = delay_s
|
||||||
|
self._iter: Optional[AsyncIterator[Any]] = None
|
||||||
|
|
||||||
|
def __aiter__(self) -> "_TurnStream":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def _generate(self) -> AsyncIterator[Any]:
|
||||||
|
# One word at a time gives a visible streaming effect in the UI
|
||||||
|
# without flooding the SSE channel.
|
||||||
|
words = self._text.split(" ")
|
||||||
|
for i, word in enumerate(words):
|
||||||
|
chunk = (" " + word) if i > 0 else word
|
||||||
|
yield {"type": "message.delta", "delta": chunk}
|
||||||
|
if self._delay_s > 0:
|
||||||
|
await asyncio.sleep(self._delay_s)
|
||||||
|
# Final completion event -- the canonical SDK always emits this,
|
||||||
|
# and ``_stream_thread_run`` uses it as its fallback when no
|
||||||
|
# deltas arrived (so worth keeping even when deltas did stream).
|
||||||
|
yield _ItemCompletedNotification(
|
||||||
|
item = _ItemRoot(root = _AgentMessage(text = self._text))
|
||||||
|
)
|
||||||
|
|
||||||
|
async def __anext__(self) -> Any:
|
||||||
|
if self._iter is None:
|
||||||
|
self._iter = self._generate()
|
||||||
|
return await self._iter.__anext__()
|
||||||
|
|
||||||
|
# Some SDK revs let callers ``async with stream:``. Treat as a no-op.
|
||||||
|
async def __aenter__(self) -> "_TurnStream":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_exc: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _Turn:
|
||||||
|
def __init__(self, text: str) -> None:
|
||||||
|
self._text = text
|
||||||
|
|
||||||
|
def stream(self) -> _TurnStream:
|
||||||
|
return _TurnStream(self._text)
|
||||||
|
|
||||||
|
|
||||||
|
class _Thread:
|
||||||
|
def __init__(self, model: str, system: Optional[str]) -> None:
|
||||||
|
self._model = model
|
||||||
|
self._system = system
|
||||||
|
self._tab_id = _tab_id_from_system(system)
|
||||||
|
|
||||||
|
# Canonical path: ``thread.turn(prompt).stream()``.
|
||||||
|
def turn(self, prompt: str) -> _Turn:
|
||||||
|
text = _spoof_response_text(self._model, prompt, self._tab_id)
|
||||||
|
return _Turn(text)
|
||||||
|
|
||||||
|
# Legacy path: ``async for event in thread.run_streaming(prompt)``.
|
||||||
|
def run_streaming(self, prompt: str) -> _TurnStream:
|
||||||
|
text = _spoof_response_text(self._model, prompt, self._tab_id)
|
||||||
|
return _TurnStream(text)
|
||||||
|
|
||||||
|
# Buffered fallback: ``await thread.run(prompt)`` returning a result
|
||||||
|
# whose ``.text`` (or ``.final_response``) is the answer.
|
||||||
|
async def run(self, prompt: str) -> Any:
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
text = _spoof_response_text(self._model, prompt, self._tab_id)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return SimpleNamespace(text = text, final_response = text)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncCodex:
|
||||||
|
"""Spoof drop-in for ``openai_codex.AsyncCodex``.
|
||||||
|
|
||||||
|
Accepts the same ``config=AppServerConfig(...)`` constructor signature
|
||||||
|
Studio passes through. ``thread_start`` returns a ``_Thread`` whose
|
||||||
|
turn / run / run_streaming methods emit deterministic streams.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[AppServerConfig] = None, **_kw: Any) -> None:
|
||||||
|
self._config = config or AppServerConfig()
|
||||||
|
self._started_at = time.time()
|
||||||
|
|
||||||
|
async def thread_start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
base_instructions: Optional[str] = None,
|
||||||
|
system: Optional[str] = None,
|
||||||
|
approval_mode: Optional[ApprovalMode] = None,
|
||||||
|
sandbox: Optional[SandboxMode] = None,
|
||||||
|
**_extra: Any,
|
||||||
|
) -> _Thread:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
# Either kwarg path is accepted -- the real provider tries
|
||||||
|
# ``base_instructions`` first then falls back to ``system``.
|
||||||
|
sys_text = base_instructions if base_instructions is not None else system
|
||||||
|
return _Thread(model = model, system = sys_text)
|
||||||
|
|
||||||
|
|
||||||
|
def install_as_openai_codex() -> None:
|
||||||
|
"""Insert this module into ``sys.modules`` under the names the real
|
||||||
|
SDK would use, so ``importlib.util.find_spec`` succeeds and the
|
||||||
|
provider's existing import path picks it up unchanged.
|
||||||
|
|
||||||
|
Idempotent: a second call is a no-op. Called from ``codex_provider``
|
||||||
|
inside ``_import_codex`` when the env flag is set.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
for name in ("openai_codex", "codex_app_server"):
|
||||||
|
if name in sys.modules:
|
||||||
|
continue
|
||||||
|
sys.modules[name] = _build_module_alias(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_module_alias(name: str) -> Any:
|
||||||
|
"""Build a module-like object exposing the same public symbols as
|
||||||
|
this file, under the requested import name. Using a fresh module
|
||||||
|
object (rather than aliasing ``codex_spoof`` directly) means the
|
||||||
|
SDK's ``__name__`` lookups (e.g. for ``ImportError`` messages) get
|
||||||
|
the real upstream-style name.
|
||||||
|
"""
|
||||||
|
import types
|
||||||
|
import importlib.machinery
|
||||||
|
|
||||||
|
mod = types.ModuleType(name)
|
||||||
|
# ``importlib.util.find_spec(name)`` walks ``sys.modules[name].__spec__``
|
||||||
|
# first, so an empty spec is required for the provider's existing
|
||||||
|
# availability probe to recognise the spoof.
|
||||||
|
mod.__spec__ = importlib.machinery.ModuleSpec(name, loader = None)
|
||||||
|
mod.AsyncCodex = AsyncCodex # type: ignore[attr-defined]
|
||||||
|
mod.AppServerConfig = AppServerConfig # type: ignore[attr-defined]
|
||||||
|
mod.ApprovalMode = ApprovalMode # type: ignore[attr-defined]
|
||||||
|
mod.SandboxMode = SandboxMode # type: ignore[attr-defined]
|
||||||
|
mod.__spoof__ = True # marker -- tests can assert this
|
||||||
|
return mod
|
||||||
|
|
@ -319,6 +319,49 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
||||||
),
|
),
|
||||||
"hidden": True,
|
"hidden": True,
|
||||||
},
|
},
|
||||||
|
"codex": {
|
||||||
|
"display_name": "OpenAI Codex (local CLI)",
|
||||||
|
# No remote base_url: Codex dispatches through the local CLI
|
||||||
|
# via the openai_codex Python SDK (legacy alias: codex_app_server).
|
||||||
|
# Routing skips the standard HTTP client entirely in
|
||||||
|
# _proxy_to_external_provider and hands the request to
|
||||||
|
# core.inference.codex_provider instead.
|
||||||
|
"base_url": "",
|
||||||
|
# Mirrored from upstream ``codex-rs/models-manager/models.json``.
|
||||||
|
# We deliberately drop ``o3`` (not in the upstream catalog) and
|
||||||
|
# add ``gpt-5.3-codex`` + ``gpt-5.2``. Once the SDK exposes a
|
||||||
|
# runtime ``Codex.models()`` call the dynamic catalog will
|
||||||
|
# replace this hardcoded default.
|
||||||
|
"default_models": [
|
||||||
|
"gpt-5.5",
|
||||||
|
"gpt-5.4",
|
||||||
|
"gpt-5.4-mini",
|
||||||
|
"gpt-5.3-codex",
|
||||||
|
"gpt-5.2",
|
||||||
|
],
|
||||||
|
"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 OpenAI Codex Python SDK (pip install `openai-codex`, "
|
||||||
|
"imports as `openai_codex`; legacy alias `codex_app_server` "
|
||||||
|
"is accepted). Surfaced only when the CLI and SDK are both "
|
||||||
|
"installed; sign in with `codex login`."
|
||||||
|
),
|
||||||
|
},
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"display_name": "OpenRouter",
|
"display_name": "OpenRouter",
|
||||||
"base_url": "https://openrouter.ai/api/v1",
|
"base_url": "https://openrouter.ai/api/v1",
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ from datetime import datetime
|
||||||
from routes import (
|
from routes import (
|
||||||
auth_router,
|
auth_router,
|
||||||
chat_history_router,
|
chat_history_router,
|
||||||
|
codex_router,
|
||||||
data_recipe_router,
|
data_recipe_router,
|
||||||
datasets_router,
|
datasets_router,
|
||||||
export_router,
|
export_router,
|
||||||
|
|
@ -536,6 +537,10 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = ["
|
||||||
# standard /v1/chat/completions path.
|
# standard /v1/chat/completions path.
|
||||||
app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
|
app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
|
||||||
app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"])
|
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(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"])
|
app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"])
|
||||||
app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"])
|
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(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"])
|
||||||
|
|
|
||||||
|
|
@ -863,6 +863,22 @@ class ChatCompletionRequest(BaseModel):
|
||||||
"to auto-create."
|
"to auto-create."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parallel_calls: int = Field(
|
||||||
|
default = 1,
|
||||||
|
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. Silently clamped to [1, 20] by `_clamp_parallel_calls` "
|
||||||
|
"so a runaway value cannot saturate the local CLI -- using a "
|
||||||
|
"validator (rather than `ge=1, le=20`) keeps backwards "
|
||||||
|
"compatibility with pre-PR clients that sent the field as a "
|
||||||
|
"stray OpenAI extra (e.g. `0` for 'no fan-out') and would "
|
||||||
|
"otherwise hit a 422. Defaults to 1 (single-call shape). "
|
||||||
|
"Silently ignored on every provider other than `codex`."
|
||||||
|
),
|
||||||
|
)
|
||||||
fast_mode: Optional[bool] = Field(
|
fast_mode: Optional[bool] = Field(
|
||||||
None,
|
None,
|
||||||
description = (
|
description = (
|
||||||
|
|
@ -874,6 +890,30 @@ class ChatCompletionRequest(BaseModel):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator("parallel_calls", mode = "before")
|
||||||
|
@classmethod
|
||||||
|
def _clamp_parallel_calls(cls, value: Any) -> int:
|
||||||
|
"""Coerce ``parallel_calls`` to [1, 20] without rejecting weird inputs.
|
||||||
|
|
||||||
|
Pre-PR behaviour was to silently ignore unknown / out-of-range
|
||||||
|
OpenAI extras; using ``ge=1, le=20`` on the Field would have
|
||||||
|
regressed that by returning a 422 to any non-Codex client that
|
||||||
|
happened to set the field to 0 or omit it as ``None``. Coerce
|
||||||
|
the value here instead so the schema stays self-documenting
|
||||||
|
([1, 20]) while accepting legacy inputs.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
n = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1
|
||||||
|
if n < 1:
|
||||||
|
return 1
|
||||||
|
if n > 20:
|
||||||
|
return 20
|
||||||
|
return n
|
||||||
|
|
||||||
@model_validator(mode = "after")
|
@model_validator(mode = "after")
|
||||||
def _resolve_missing_tool_call_ids(self) -> "ChatCompletionRequest":
|
def _resolve_missing_tool_call_ids(self) -> "ChatCompletionRequest":
|
||||||
"""Fill missing tool_call_id by walking back to the preceding assistant.
|
"""Fill missing tool_call_id by walking back to the preceding assistant.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from routes.export import router as export_router
|
||||||
from routes.training_history import router as training_history_router
|
from routes.training_history import router as training_history_router
|
||||||
from routes.chat_history import router as chat_history_router
|
from routes.chat_history import router as chat_history_router
|
||||||
from routes.providers import router as providers_router
|
from routes.providers import router as providers_router
|
||||||
|
from routes.codex import router as codex_router
|
||||||
from routes.mcp_servers import router as mcp_servers_router
|
from routes.mcp_servers import router as mcp_servers_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -30,5 +31,6 @@ __all__ = [
|
||||||
"training_history_router",
|
"training_history_router",
|
||||||
"chat_history_router",
|
"chat_history_router",
|
||||||
"providers_router",
|
"providers_router",
|
||||||
|
"codex_router",
|
||||||
"mcp_servers_router",
|
"mcp_servers_router",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
115
studio/backend/routes/codex.py
Normal file
115
studio/backend/routes/codex.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# 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 login --device-auth`` CLI command, captures the verification
|
||||||
|
URL (and one-time code) 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 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 login --device-auth`` exchange.
|
||||||
|
|
||||||
|
Returns an SSE stream of events:
|
||||||
|
|
||||||
|
``data: {"type": "device_url", "url": "https://..."}``
|
||||||
|
``data: {"type": "device_code", "code": "ABCD-EFGH"}``
|
||||||
|
``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]:
|
||||||
|
try:
|
||||||
|
async for event in stream_codex_device_login():
|
||||||
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
|
except Exception as exc:
|
||||||
|
# CodeQL: never echo str(exc) verbatim. Log full reason
|
||||||
|
# server-side and surface a generic error to the client so
|
||||||
|
# local paths / env vars from the CLI traceback don't leak.
|
||||||
|
logger.error(
|
||||||
|
"codex_device_login.stream_error",
|
||||||
|
exc_type = type(exc).__name__,
|
||||||
|
error = str(exc),
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"data: "
|
||||||
|
+ json.dumps(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"message": "Codex login failed",
|
||||||
|
"exception_type": type(exc).__name__,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n\n"
|
||||||
|
)
|
||||||
|
yield "data: " + json.dumps({"type": "done", "ok": False}) + "\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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -2077,10 +2077,13 @@ async def _proxy_to_external_provider(
|
||||||
detail = "Either provider_id or provider_type is required for external provider routing.",
|
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:
|
if not base_url:
|
||||||
base_url = get_base_url(provider_type)
|
base_url = get_base_url(provider_type)
|
||||||
if not base_url:
|
if not base_url and provider_type != "codex":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code = 400,
|
status_code = 400,
|
||||||
detail = f"Unknown provider type: {provider_type}",
|
detail = f"Unknown provider type: {provider_type}",
|
||||||
|
|
@ -2116,6 +2119,94 @@ async def _proxy_to_external_provider(
|
||||||
base_url = base_url,
|
base_url = base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
# Match the SSE sentinel exactly. The earlier
|
||||||
|
# substring check (`"[DONE]" in line`) would flip
|
||||||
|
# the flag when a normal `delta.content` carried
|
||||||
|
# the literal text "[DONE]" (e.g. an explanation
|
||||||
|
# of OpenAI's stream terminator), and suppress the
|
||||||
|
# real `data: [DONE]` frame. OpenAI-compatible
|
||||||
|
# clients that finalise on the sentinel would
|
||||||
|
# then hang on stream close.
|
||||||
|
if line.strip() == "data: [DONE]":
|
||||||
|
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:
|
||||||
|
# CodeQL: never echo str(exc) -- the Codex SDK can raise
|
||||||
|
# with local paths, env-var content, or traceback fragments.
|
||||||
|
# Log the full reason server-side; surface a generic message
|
||||||
|
# plus an exception_type discriminator to the client so the
|
||||||
|
# UI can show "Codex provider error" without leaking host
|
||||||
|
# internals.
|
||||||
|
logger.error(
|
||||||
|
"codex_provider.stream_error",
|
||||||
|
exc_type = type(exc).__name__,
|
||||||
|
error = str(exc),
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"data: "
|
||||||
|
+ json.dumps(
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "Codex provider error",
|
||||||
|
"type": "provider_error",
|
||||||
|
"exception_type": type(exc).__name__,
|
||||||
|
"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(
|
client = ExternalProviderClient(
|
||||||
provider_type = provider_type,
|
provider_type = provider_type,
|
||||||
base_url = base_url,
|
base_url = base_url,
|
||||||
|
|
|
||||||
2493
studio/backend/tests/test_codex_provider.py
Normal file
2493
studio/backend/tests/test_codex_provider.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -63,16 +63,18 @@ def test_cpu_thread_cap_is_opt_in(raw):
|
||||||
|
|
||||||
|
|
||||||
# Anything that is not a positive integer raises a clear ValueError.
|
# Anything that is not a positive integer raises a clear ValueError.
|
||||||
@pytest.mark.parametrize("raw", ["zero", "0", "-3", "1.5", "abc", "8a", "0x4", "1e3", "4 0"])
|
@pytest.mark.parametrize(
|
||||||
|
"raw", ["zero", "0", "-3", "1.5", "abc", "8a", "0x4", "1e3", "4 0"]
|
||||||
|
)
|
||||||
def test_cpu_thread_cap_requires_positive_integer(raw):
|
def test_cpu_thread_cap_requires_positive_integer(raw):
|
||||||
with pytest.raises(ValueError, match="must be a positive integer"):
|
with pytest.raises(ValueError, match = "must be a positive integer"):
|
||||||
configure_cpu_threads({"UNSLOTH_CPU_THREADS": raw})
|
configure_cpu_threads({"UNSLOTH_CPU_THREADS": raw})
|
||||||
|
|
||||||
|
|
||||||
# env=None path uses real os.environ (production call from run.py / main.py).
|
# env=None path uses real os.environ (production call from run.py / main.py).
|
||||||
def test_cpu_thread_cap_uses_os_environ_when_env_is_none(monkeypatch):
|
def test_cpu_thread_cap_uses_os_environ_when_env_is_none(monkeypatch):
|
||||||
for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"):
|
for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"):
|
||||||
monkeypatch.delenv(variable, raising=False)
|
monkeypatch.delenv(variable, raising = False)
|
||||||
monkeypatch.setenv("UNSLOTH_CPU_THREADS", "3")
|
monkeypatch.setenv("UNSLOTH_CPU_THREADS", "3")
|
||||||
|
|
||||||
configure_cpu_threads()
|
configure_cpu_threads()
|
||||||
|
|
@ -84,7 +86,7 @@ def test_cpu_thread_cap_uses_os_environ_when_env_is_none(monkeypatch):
|
||||||
# Calling twice must not flip any seeded value.
|
# Calling twice must not flip any seeded value.
|
||||||
def test_cpu_thread_cap_idempotent(monkeypatch):
|
def test_cpu_thread_cap_idempotent(monkeypatch):
|
||||||
for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"):
|
for variable in (*_THREAD_POOL_ENV_VARS, "UNSLOTH_CPU_THREADS"):
|
||||||
monkeypatch.delenv(variable, raising=False)
|
monkeypatch.delenv(variable, raising = False)
|
||||||
monkeypatch.setenv("UNSLOTH_CPU_THREADS", "5")
|
monkeypatch.setenv("UNSLOTH_CPU_THREADS", "5")
|
||||||
|
|
||||||
configure_cpu_threads()
|
configure_cpu_threads()
|
||||||
|
|
@ -138,9 +140,9 @@ def test_invalid_cpu_thread_cap_exits_without_traceback(entry_point):
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, str(entry_point)],
|
[sys.executable, str(entry_point)],
|
||||||
env=env,
|
env = env,
|
||||||
capture_output=True,
|
capture_output = True,
|
||||||
text=True,
|
text = True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.returncode == 1
|
assert result.returncode == 1
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,7 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch):
|
||||||
router_stub = SimpleNamespace(
|
router_stub = SimpleNamespace(
|
||||||
auth_router = APIRouter(),
|
auth_router = APIRouter(),
|
||||||
chat_history_router = APIRouter(),
|
chat_history_router = APIRouter(),
|
||||||
|
codex_router = APIRouter(),
|
||||||
data_recipe_router = APIRouter(),
|
data_recipe_router = APIRouter(),
|
||||||
datasets_router = APIRouter(),
|
datasets_router = APIRouter(),
|
||||||
export_router = APIRouter(),
|
export_router = APIRouter(),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||||
import { ToolGroup } from "@/components/assistant-ui/tool-group";
|
import { ToolGroup } from "@/components/assistant-ui/tool-group";
|
||||||
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
|
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
|
||||||
|
import { CodexParallelToolUI } from "@/components/assistant-ui/tool-ui-codex-parallel";
|
||||||
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
|
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
|
||||||
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
|
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
|
||||||
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
|
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
|
||||||
|
|
@ -1315,6 +1316,7 @@ const AssistantMessage: FC = () => {
|
||||||
python: PythonToolUI,
|
python: PythonToolUI,
|
||||||
terminal: TerminalToolUI,
|
terminal: TerminalToolUI,
|
||||||
code_execution: CodeExecutionToolUI,
|
code_execution: CodeExecutionToolUI,
|
||||||
|
codex_parallel: CodexParallelToolUI,
|
||||||
image_generation: ImageGenerationToolUI,
|
image_generation: ImageGenerationToolUI,
|
||||||
},
|
},
|
||||||
Fallback: ToolFallback,
|
Fallback: ToolFallback,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool-call renderer for Codex parallel-calls fan-out.
|
||||||
|
*
|
||||||
|
* Driven by the chat-adapter pushing a tool-call part with
|
||||||
|
* ``toolName === "codex_parallel"`` whose ``args.state`` is a
|
||||||
|
* ``CodexParallelState`` value. We just unpack the state and hand it
|
||||||
|
* to the existing ``CodexParallelTabs`` component. Mounted via the
|
||||||
|
* ``tools.by_name`` map on ``MessagePrimitive.Parts`` in
|
||||||
|
* ``thread.tsx`` so it renders inline above the assistant's prose.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||||
|
import { memo } from "react";
|
||||||
|
import {
|
||||||
|
CodexParallelTabs,
|
||||||
|
EMPTY_CODEX_PARALLEL_STATE,
|
||||||
|
type CodexParallelState,
|
||||||
|
} from "@/features/chat/components/codex-parallel-tabs";
|
||||||
|
|
||||||
|
const CodexParallelToolUIImpl: ToolCallMessagePartComponent = ({ args }) => {
|
||||||
|
const state = (args as { state?: CodexParallelState } | undefined)?.state;
|
||||||
|
return <CodexParallelTabs state={state ?? EMPTY_CODEX_PARALLEL_STATE} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodexParallelToolUI = memo(
|
||||||
|
CodexParallelToolUIImpl,
|
||||||
|
) as ToolCallMessagePartComponent;
|
||||||
|
CodexParallelToolUI.displayName = "CodexParallelToolUI";
|
||||||
|
|
@ -7,7 +7,10 @@ import { toast } from "@/lib/toast";
|
||||||
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
|
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
|
||||||
import type { ChatModelAdapter } from "@assistant-ui/react";
|
import type { ChatModelAdapter } from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
|
CODEX_DEFAULT_PARALLEL_CALLS,
|
||||||
|
clampCodexParallelCalls,
|
||||||
getExternalProviderApiKey,
|
getExternalProviderApiKey,
|
||||||
|
isCodexProviderType,
|
||||||
isCustomProviderType,
|
isCustomProviderType,
|
||||||
isPromptCacheTtl,
|
isPromptCacheTtl,
|
||||||
loadExternalProviders,
|
loadExternalProviders,
|
||||||
|
|
@ -55,6 +58,13 @@ import {
|
||||||
hasClosedThinkTag,
|
hasClosedThinkTag,
|
||||||
parseAssistantContent,
|
parseAssistantContent,
|
||||||
} from "../utils/parse-assistant-content";
|
} from "../utils/parse-assistant-content";
|
||||||
|
import {
|
||||||
|
EMPTY_CODEX_PARALLEL_STATE,
|
||||||
|
hasCodexParallelContent,
|
||||||
|
reduceCodexParallelState,
|
||||||
|
type CodexParallelEvent,
|
||||||
|
type CodexParallelState,
|
||||||
|
} from "../components/codex-parallel-tabs";
|
||||||
import {
|
import {
|
||||||
generateAudio,
|
generateAudio,
|
||||||
listCachedGguf,
|
listCachedGguf,
|
||||||
|
|
@ -1337,10 +1347,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
||||||
clearSelectedImageEditReference();
|
clearSelectedImageEditReference();
|
||||||
throw new Error("Connection not found.");
|
throw new Error("Connection not found.");
|
||||||
}
|
}
|
||||||
// Local providers and custom Gemini bases allow an empty key.
|
// Local providers, custom Gemini bases, and Codex (local CLI / SDK) all allow an empty key.
|
||||||
const externalProviderIsCustom = externalProvider
|
const externalProviderIsCustom = externalProvider
|
||||||
? isCustomProviderType(externalProvider.providerType)
|
? isCustomProviderType(externalProvider.providerType)
|
||||||
: false;
|
: false;
|
||||||
|
const externalProviderIsCodex = externalProvider
|
||||||
|
? isCodexProviderType(externalProvider.providerType)
|
||||||
|
: false;
|
||||||
const externalProviderIsGeminiCustomBase = Boolean(
|
const externalProviderIsGeminiCustomBase = Boolean(
|
||||||
externalProvider &&
|
externalProvider &&
|
||||||
externalProvider.providerType === "gemini" &&
|
externalProvider.providerType === "gemini" &&
|
||||||
|
|
@ -1350,10 +1363,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
||||||
isExternalRequest &&
|
isExternalRequest &&
|
||||||
!externalApiKey &&
|
!externalApiKey &&
|
||||||
!externalProviderIsCustom &&
|
!externalProviderIsCustom &&
|
||||||
|
!externalProviderIsCodex &&
|
||||||
!externalProviderIsGeminiCustomBase
|
!externalProviderIsGeminiCustomBase
|
||||||
) {
|
) {
|
||||||
toast.error("Missing API key for selected connection.", {
|
toast.error("Missing API key for selected connection.", {
|
||||||
description: "Open Settings → Connections and set the API key again.",
|
description: "Open Settings > Connections and set the API key again.",
|
||||||
});
|
});
|
||||||
clearSelectedImageEditReference();
|
clearSelectedImageEditReference();
|
||||||
throw new Error("Missing connection API key.");
|
throw new Error("Missing connection API key.");
|
||||||
|
|
@ -1702,9 +1716,85 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
||||||
let cumulativeText = "";
|
let cumulativeText = "";
|
||||||
let reasoningStartAt: number | null = null;
|
let reasoningStartAt: number | null = null;
|
||||||
let reasoningDuration = 0;
|
let reasoningDuration = 0;
|
||||||
// True while wrapping a `delta.reasoning_content` stream in
|
// Per-tab buffer for Codex parallel-calls fan-out. The backend
|
||||||
// <think>...</think> for parseAssistantContent. Lives outside
|
// emits N independent streams concurrently, so chunks for tab 2
|
||||||
// the SSE loop because the close tag fires when content arrives.
|
// can land between chunks for tab 1 in arrival order. Keeping a
|
||||||
|
// dict keyed by tab_id and re-assembling cumulativeText from
|
||||||
|
// scratch on every codex event puts each tab's text under its
|
||||||
|
// own header regardless of arrival interleaving.
|
||||||
|
// Per-tab Codex fan-out state. Each codex_* SSE event is folded
|
||||||
|
// into ``codexParallelState`` via the pure reducer in
|
||||||
|
// ``components/codex-parallel-tabs``. The state is re-published
|
||||||
|
// on every yield as the ``args`` of a single tool-call part with
|
||||||
|
// ``toolName === "codex_parallel"`` so the assistant-ui surface
|
||||||
|
// can render real clickable tabs (one per worker plus a
|
||||||
|
// Synthesis tab) instead of inline ``[Codex tab N]`` headings.
|
||||||
|
// The stable toolCallId keeps assistant-ui updating the same
|
||||||
|
// part across stream yields rather than spawning new cards.
|
||||||
|
let codexParallelState: CodexParallelState = EMPTY_CODEX_PARALLEL_STATE;
|
||||||
|
let codexGatherEmitted = false;
|
||||||
|
const CODEX_PARALLEL_TOOL_ID = "codex_parallel_main";
|
||||||
|
|
||||||
|
function upsertCodexParallelToolPart(): void {
|
||||||
|
if (!hasCodexParallelContent(codexParallelState)) return;
|
||||||
|
const args = { state: codexParallelState };
|
||||||
|
const argsText = "";
|
||||||
|
const idx = toolCallParts.findIndex(
|
||||||
|
(p) => p.toolCallId === CODEX_PARALLEL_TOOL_ID,
|
||||||
|
);
|
||||||
|
const part: ToolCallMessagePart = {
|
||||||
|
type: "tool-call" as const,
|
||||||
|
toolCallId: CODEX_PARALLEL_TOOL_ID,
|
||||||
|
toolName: "codex_parallel",
|
||||||
|
argsText,
|
||||||
|
args: args as unknown as ToolCallMessagePart["args"],
|
||||||
|
};
|
||||||
|
if (idx === -1) {
|
||||||
|
toolCallParts.push(part);
|
||||||
|
} else {
|
||||||
|
toolCallParts[idx] = part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No inline `[Codex tab N]` block in the message body any more --
|
||||||
|
// the tab UI is mounted as a tool-call part above. The function
|
||||||
|
// is kept (returning the empty string) so the rest of the
|
||||||
|
// adapter's renderFullContent() / pin signature paths are
|
||||||
|
// unchanged across the file.
|
||||||
|
function renderCodexTabsBlock(): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex parallel-calls fan-out renders the labeled tab outputs
|
||||||
|
// first, then a "--- Synthesis ---" divider, then the synthesis
|
||||||
|
// text the backend streams as plain content deltas after the
|
||||||
|
// `codex_gather` event. Earlier the synthesis came BEFORE the
|
||||||
|
// tabs (since cumulativeText was prepended) which left the
|
||||||
|
// trailing "--- Synthesis ---" line orphaned at the bottom with
|
||||||
|
// no synthesis text under it, confusing users. When there is no
|
||||||
|
// Codex fan-out (single-tab Codex turn or any other provider)
|
||||||
|
// the function falls back to the plain cumulativeText.
|
||||||
|
function renderFullContent(): string {
|
||||||
|
const tabsBlock = renderCodexTabsBlock();
|
||||||
|
if (!tabsBlock && !codexGatherEmitted) {
|
||||||
|
return cumulativeText;
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (tabsBlock) parts.push(tabsBlock);
|
||||||
|
if (codexGatherEmitted) parts.push("\n\n--- Synthesis ---\n\n");
|
||||||
|
if (cumulativeText) parts.push(cumulativeText);
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
// Tracks whether we are currently inside a `<think>` block opened by
|
||||||
|
// a `delta.reasoning_content` chunk. Kimi (kimi-k2.6, kimi-k2-thinking)
|
||||||
|
// and DeepSeek's reasoner stream their thinking as a separate
|
||||||
|
// `reasoning_content` field on the chat-completion delta — not as
|
||||||
|
// `content`, not as a structured part. We wrap those chunks with
|
||||||
|
// inline `<think>...</think>` so the existing parseAssistantContent
|
||||||
|
// lifts them into the reasoning panel the same way it does for
|
||||||
|
// local Harmony models. State has to live outside the SSE loop
|
||||||
|
// because the close tag fires when the next chunk carries content
|
||||||
|
// (or when the stream ends).
|
||||||
let reasoningContentOpen = false;
|
let reasoningContentOpen = false;
|
||||||
// Tool call parts, cumulative; result lands on tool_end.
|
// Tool call parts, cumulative; result lands on tool_end.
|
||||||
const toolCallParts: ToolCallMessagePart[] = [];
|
const toolCallParts: ToolCallMessagePart[] = [];
|
||||||
|
|
@ -2066,6 +2156,19 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
||||||
}
|
}
|
||||||
: { enable_thinking: reasoningEnabled }
|
: { enable_thinking: reasoningEnabled }
|
||||||
: {}),
|
: {}),
|
||||||
|
// Codex provider only: ask the backend to fan the turn out
|
||||||
|
// across N parallel Codex tasks and synthesise a unified
|
||||||
|
// answer. The picker UI uses the provider config's
|
||||||
|
// `codexParallelCalls` field; default of 1 keeps the
|
||||||
|
// single-call path. Backend clamps to [1, 20].
|
||||||
|
...(externalProviderIsCodex
|
||||||
|
? {
|
||||||
|
parallel_calls: clampCodexParallelCalls(
|
||||||
|
externalProvider.codexParallelCalls ??
|
||||||
|
CODEX_DEFAULT_PARALLEL_CALLS,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2146,7 +2249,82 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
||||||
chunk as unknown as { _toolEvent?: Record<string, unknown> }
|
chunk as unknown as { _toolEvent?: Record<string, unknown> }
|
||||||
)._toolEvent;
|
)._toolEvent;
|
||||||
if (toolEvent !== undefined) {
|
if (toolEvent !== undefined) {
|
||||||
// Persist container_id onto the thread (OpenAI / Anthropic).
|
// Codex parallel-calls fan-out events. Each event is
|
||||||
|
// folded into ``codexParallelState`` and re-published
|
||||||
|
// as the ``args.state`` of the ``codex_parallel`` tool-
|
||||||
|
// call part, so the assistant-ui surface renders one
|
||||||
|
// tab per worker plus a Synthesis tab the user can
|
||||||
|
// click between. ``codex_gather`` flips the flag so
|
||||||
|
// ``renderFullContent`` knows the synthesis stream is
|
||||||
|
// about to arrive on the regular content-delta path.
|
||||||
|
if (typeof toolEvent.type === "string" && toolEvent.type.startsWith("codex_")) {
|
||||||
|
const evType = toolEvent.type;
|
||||||
|
const tabId = Number(toolEvent.tab_id);
|
||||||
|
let reduced: CodexParallelEvent | null = null;
|
||||||
|
if (evType === "codex_tab_open" && Number.isFinite(tabId)) {
|
||||||
|
const total = Number(toolEvent.total_tabs);
|
||||||
|
reduced = {
|
||||||
|
type: "codex_tab_open",
|
||||||
|
tab_id: tabId,
|
||||||
|
query:
|
||||||
|
typeof toolEvent.query === "string"
|
||||||
|
? toolEvent.query
|
||||||
|
: undefined,
|
||||||
|
total_tabs: Number.isFinite(total) ? total : undefined,
|
||||||
|
};
|
||||||
|
} else if (evType === "codex_tab_chunk" && Number.isFinite(tabId)) {
|
||||||
|
const text =
|
||||||
|
typeof toolEvent.text === "string" ? toolEvent.text : "";
|
||||||
|
if (text) {
|
||||||
|
reduced = {
|
||||||
|
type: "codex_tab_chunk",
|
||||||
|
tab_id: tabId,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (evType === "codex_tab_error" && Number.isFinite(tabId)) {
|
||||||
|
reduced = {
|
||||||
|
type: "codex_tab_error",
|
||||||
|
tab_id: tabId,
|
||||||
|
error:
|
||||||
|
typeof toolEvent.error === "string"
|
||||||
|
? toolEvent.error
|
||||||
|
: "error",
|
||||||
|
};
|
||||||
|
} else if (evType === "codex_tab_close" && Number.isFinite(tabId)) {
|
||||||
|
reduced = { type: "codex_tab_close", tab_id: tabId };
|
||||||
|
} else if (evType === "codex_gather") {
|
||||||
|
codexGatherEmitted = true;
|
||||||
|
reduced = {
|
||||||
|
type: "codex_gather",
|
||||||
|
summary:
|
||||||
|
typeof toolEvent.summary === "string"
|
||||||
|
? toolEvent.summary
|
||||||
|
: undefined,
|
||||||
|
tab_count:
|
||||||
|
typeof toolEvent.tab_count === "number"
|
||||||
|
? toolEvent.tab_count
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (reduced) {
|
||||||
|
codexParallelState = reduceCodexParallelState(
|
||||||
|
codexParallelState,
|
||||||
|
reduced,
|
||||||
|
);
|
||||||
|
upsertCodexParallelToolPart();
|
||||||
|
}
|
||||||
|
const codexParts = parseAssistantContent(renderFullContent());
|
||||||
|
yield {
|
||||||
|
content: [...toolCallParts, ...codexParts],
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// OpenAI shell-tool container persistence — see
|
||||||
|
// ThreadRecord.openaiCodeExecContainerId. The backend
|
||||||
|
// emits these synthetic events on the OpenAI Responses
|
||||||
|
// SSE stream after capturing the container_id from a
|
||||||
|
// response, or detecting an expired-container error.
|
||||||
if (toolEvent.type === "container_ready") {
|
if (toolEvent.type === "container_ready") {
|
||||||
const newContainerId = toolEvent.container_id as
|
const newContainerId = toolEvent.container_id as
|
||||||
| string
|
| string
|
||||||
|
|
@ -2364,10 +2542,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cumulative yield. orderAssistantContent puts search/
|
// Cumulative yield so tool UI updates. orderAssistantContent
|
||||||
// code before text and generated images after.
|
// puts search / code before text and generated images after.
|
||||||
|
// renderFullContent() preserves any Codex per-tab text from
|
||||||
|
// earlier _toolEvent frames; pinTextThoughtSignature attaches
|
||||||
|
// Gemini thoughtSignature onto the final text part.
|
||||||
const textParts = pinTextThoughtSignature(
|
const textParts = pinTextThoughtSignature(
|
||||||
parseAssistantContent(cumulativeText),
|
parseAssistantContent(renderFullContent()),
|
||||||
);
|
);
|
||||||
yield {
|
yield {
|
||||||
content: orderAssistantContent(textParts),
|
content: orderAssistantContent(textParts),
|
||||||
|
|
@ -2628,8 +2809,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// renderFullContent() preserves any Codex per-tab text the
|
||||||
|
// fan-out branch accumulated into codexTabBuffers;
|
||||||
|
// pinTextThoughtSignature attaches Gemini thoughtSignature.
|
||||||
const parts = pinTextThoughtSignature(
|
const parts = pinTextThoughtSignature(
|
||||||
parseAssistantContent(cumulativeText),
|
parseAssistantContent(renderFullContent()),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -2746,8 +2930,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
content: [
|
content: [
|
||||||
|
// renderFullContent() ensures the Codex per-tab text is in
|
||||||
|
// the FINAL message too -- otherwise the synthesis delta on
|
||||||
|
// the regular content path would have erased it.
|
||||||
|
// pinTextThoughtSignature attaches Gemini thoughtSignature.
|
||||||
...orderAssistantContent(
|
...orderAssistantContent(
|
||||||
pinTextThoughtSignature(parseAssistantContent(cumulativeText)),
|
pinTextThoughtSignature(parseAssistantContent(renderFullContent())),
|
||||||
),
|
),
|
||||||
...sourceParts,
|
...sourceParts,
|
||||||
...documentCitationParts,
|
...documentCitationParts,
|
||||||
|
|
|
||||||
150
studio/frontend/src/features/chat/api/codex-api.ts
Normal file
150
studio/frontend/src/features/chat/api/codex-api.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
// 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 {
|
||||||
|
// `device_code` is the one-time code the verification page asks for
|
||||||
|
// (separate from the URL); the backend extracts it from the CLI
|
||||||
|
// stdout via a dedicated regex and emits it as a structured event.
|
||||||
|
type: "device_url" | "device_code" | "log" | "error" | "done";
|
||||||
|
url?: string;
|
||||||
|
code?: 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<CodexStatus> {
|
||||||
|
try {
|
||||||
|
const response = await authFetch("/api/codex/status");
|
||||||
|
if (!response.ok) {
|
||||||
|
return DEFAULT_STATUS;
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as Partial<CodexStatus>;
|
||||||
|
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<CodexLoginEvent, void, void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,14 +49,19 @@ import {
|
||||||
} from "./api/providers-api";
|
} from "./api/providers-api";
|
||||||
import type { ExternalProviderConfig } from "./external-providers";
|
import type { ExternalProviderConfig } from "./external-providers";
|
||||||
import {
|
import {
|
||||||
|
CODEX_PROVIDER_TYPE,
|
||||||
CUSTOM_BACKEND_PROVIDER_TYPE,
|
CUSTOM_BACKEND_PROVIDER_TYPE,
|
||||||
CUSTOM_PROVIDER_PRESETS,
|
CUSTOM_PROVIDER_PRESETS,
|
||||||
allowsManualModelIdsWithCatalog,
|
allowsManualModelIdsWithCatalog,
|
||||||
|
CODEX_DEFAULT_PARALLEL_CALLS,
|
||||||
|
CODEX_MAX_PARALLEL_CALLS,
|
||||||
|
clampCodexParallelCalls,
|
||||||
customProviderBaseUrlPlaceholder,
|
customProviderBaseUrlPlaceholder,
|
||||||
customProviderDisplayName,
|
customProviderDisplayName,
|
||||||
customProviderModelIdsPlaceholder,
|
customProviderModelIdsPlaceholder,
|
||||||
customPresetSkipsApiKeyField,
|
customPresetSkipsApiKeyField,
|
||||||
getExternalProviderApiKey,
|
getExternalProviderApiKey,
|
||||||
|
isCodexProviderType,
|
||||||
isCustomProviderType,
|
isCustomProviderType,
|
||||||
LEGACY_CUSTOM_PROVIDER_TYPE,
|
LEGACY_CUSTOM_PROVIDER_TYPE,
|
||||||
removeExternalProviderApiKey,
|
removeExternalProviderApiKey,
|
||||||
|
|
@ -66,6 +71,8 @@ import {
|
||||||
supportsRemoteModelCatalog,
|
supportsRemoteModelCatalog,
|
||||||
toExternalBackendProviderType,
|
toExternalBackendProviderType,
|
||||||
} from "./external-providers";
|
} from "./external-providers";
|
||||||
|
import { fetchCodexStatus, type CodexStatus } from "./api/codex-api";
|
||||||
|
import { CodexLoginButton } from "./components/codex-login-button";
|
||||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||||
|
|
||||||
/** Matches navbar / thread layout easing (see index.css --ease-out-quart) */
|
/** Matches navbar / thread layout easing (see index.css --ease-out-quart) */
|
||||||
|
|
@ -221,6 +228,20 @@ export function ChatProvidersSettings({
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [registry, setRegistry] = useState<ProviderRegistryEntry[]>([]);
|
const [registry, setRegistry] = useState<ProviderRegistryEntry[]>([]);
|
||||||
|
// Codex CLI / SDK availability snapshot. Used to (a) decide whether
|
||||||
|
// to render the synthetic Codex registry row, and (b) drive the
|
||||||
|
// sign-in button when the host is installed but logged out.
|
||||||
|
const [codexStatus, setCodexStatus] = useState<CodexStatus | null>(null);
|
||||||
|
const refreshCodexStatus = async () => {
|
||||||
|
try {
|
||||||
|
const next = await fetchCodexStatus();
|
||||||
|
setCodexStatus(next);
|
||||||
|
return next;
|
||||||
|
} catch {
|
||||||
|
setCodexStatus(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
|
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
|
||||||
const [syncingProviders, setSyncingProviders] = useState(false);
|
const [syncingProviders, setSyncingProviders] = useState(false);
|
||||||
|
|
@ -231,6 +252,14 @@ export function ChatProvidersSettings({
|
||||||
const [modelSearchQuery, setModelSearchQuery] = useState("");
|
const [modelSearchQuery, setModelSearchQuery] = useState("");
|
||||||
const [customProviderName, setCustomProviderName] = useState("Custom");
|
const [customProviderName, setCustomProviderName] = useState("Custom");
|
||||||
const [isReasoningModel, setIsReasoningModel] = useState(false);
|
const [isReasoningModel, setIsReasoningModel] = useState(false);
|
||||||
|
// Per-Codex-connection fan-out width. Stored on the provider so
|
||||||
|
// restoring it after a refresh / page reload does not collapse back
|
||||||
|
// to single-call. Clamped to [1, MAX] at every write because the
|
||||||
|
// input is a plain `<input type="number">` and a hand-edited
|
||||||
|
// localStorage entry could otherwise overflow.
|
||||||
|
const [codexParallelCalls, setCodexParallelCalls] = useState<number>(
|
||||||
|
CODEX_DEFAULT_PARALLEL_CALLS,
|
||||||
|
);
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const connectionsEnabled = useExternalProvidersStore(
|
const connectionsEnabled = useExternalProvidersStore(
|
||||||
(s) => s.connectionsEnabled,
|
(s) => s.connectionsEnabled,
|
||||||
|
|
@ -239,9 +268,15 @@ export function ChatProvidersSettings({
|
||||||
(s) => s.setConnectionsEnabled,
|
(s) => s.setConnectionsEnabled,
|
||||||
);
|
);
|
||||||
const isCustomProvider = isCustomProviderType(providerType);
|
const isCustomProvider = isCustomProviderType(providerType);
|
||||||
|
const isCodexProvider = isCodexProviderType(providerType);
|
||||||
// Local presets (Ollama, llama.cpp) never use API keys — hide the field.
|
// Local presets (Ollama, llama.cpp) never use API keys — hide the field.
|
||||||
// vLLM may optionally use a bearer token on secured deployments.
|
// vLLM may optionally use a bearer token on secured deployments. Codex
|
||||||
const showApiKeyField = !customPresetSkipsApiKeyField(providerType);
|
// dispatches via the local CLI / SDK, no HTTP API key either.
|
||||||
|
const showApiKeyField =
|
||||||
|
!customPresetSkipsApiKeyField(providerType) && !isCodexProvider;
|
||||||
|
// Codex behaves like a "custom" provider for the gate logic below: the
|
||||||
|
// backend skips the api_key requirement entirely for `provider_type=codex`.
|
||||||
|
const providerSkipsApiKey = isCustomProvider || isCodexProvider;
|
||||||
const showReasoningToggle = supportsProviderReasoningToggle(providerType);
|
const showReasoningToggle = supportsProviderReasoningToggle(providerType);
|
||||||
|
|
||||||
const registryByType = useMemo(
|
const registryByType = useMemo(
|
||||||
|
|
@ -279,7 +314,7 @@ export function ChatProvidersSettings({
|
||||||
const missingModelCatalogBaseUrl =
|
const missingModelCatalogBaseUrl =
|
||||||
supportsRemoteModelCatalog(providerType) && baseUrlDraft.trim().length === 0;
|
supportsRemoteModelCatalog(providerType) && baseUrlDraft.trim().length === 0;
|
||||||
const missingModelCatalogApiKey =
|
const missingModelCatalogApiKey =
|
||||||
!isCustomProvider && !isCuratedModelList && apiKey.trim().length === 0;
|
!providerSkipsApiKey && !isCuratedModelList && apiKey.trim().length === 0;
|
||||||
const loadModelsDisabled =
|
const loadModelsDisabled =
|
||||||
modelsLoading ||
|
modelsLoading ||
|
||||||
mutatingProvider ||
|
mutatingProvider ||
|
||||||
|
|
@ -330,8 +365,18 @@ export function ChatProvidersSettings({
|
||||||
// providers and local OpenAI-compat presets stay empty until the user
|
// providers and local OpenAI-compat presets stay empty until the user
|
||||||
// clicks "Load available models".
|
// clicks "Load available models".
|
||||||
const seedDefaults = entry.model_list_mode === "curated";
|
const seedDefaults = entry.model_list_mode === "curated";
|
||||||
setAvailableModels(seedDefaults ? [...entry.default_models] : []);
|
const defaults = seedDefaults ? [...entry.default_models] : [];
|
||||||
setSelectedModelIds([]);
|
setAvailableModels(defaults);
|
||||||
|
// Codex is a local CLI, not a metered cloud account, so checking all of
|
||||||
|
// the SDK's default model ids by default is safe and avoids the
|
||||||
|
// first-run UX trap where users create the connection, never check any
|
||||||
|
// model, and then the "Connected" tab silently never appears in the
|
||||||
|
// chat model picker. Anthropic / OpenAI / etc. still need explicit
|
||||||
|
// model selection because the choice has billing and capability
|
||||||
|
// consequences.
|
||||||
|
setSelectedModelIds(
|
||||||
|
providerType === CODEX_PROVIDER_TYPE ? defaults : [],
|
||||||
|
);
|
||||||
setManualModelIds("");
|
setManualModelIds("");
|
||||||
setModelSearchQuery("");
|
setModelSearchQuery("");
|
||||||
setBaseUrlDraft("");
|
setBaseUrlDraft("");
|
||||||
|
|
@ -354,12 +399,42 @@ export function ChatProvidersSettings({
|
||||||
}
|
}
|
||||||
let syncSucceeded = false;
|
let syncSucceeded = false;
|
||||||
try {
|
try {
|
||||||
const [registryRows, configRows] = await Promise.all([
|
// Probe Codex availability in parallel with the registry / configs.
|
||||||
listProviderRegistry(),
|
// Codex stays `hidden:true` in the backend registry so it is filtered
|
||||||
listProviderConfigs(),
|
// out of `/api/providers/registry`; we synthesise a row here when
|
||||||
]);
|
// the host has both the CLI and the SDK installed.
|
||||||
|
const [registryRowsRaw, configRows, codexStatusRaw] = await Promise.all(
|
||||||
|
[
|
||||||
|
listProviderRegistry(),
|
||||||
|
listProviderConfigs(),
|
||||||
|
fetchCodexStatus().catch(() => null),
|
||||||
|
],
|
||||||
|
);
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
syncSucceeded = true;
|
syncSucceeded = true;
|
||||||
|
setCodexStatus(codexStatusRaw);
|
||||||
|
const registryRows: ProviderRegistryEntry[] =
|
||||||
|
codexStatusRaw && codexStatusRaw.installed &&
|
||||||
|
!registryRowsRaw.some(
|
||||||
|
(entry) => entry.provider_type === CODEX_PROVIDER_TYPE,
|
||||||
|
)
|
||||||
|
? [
|
||||||
|
...registryRowsRaw,
|
||||||
|
{
|
||||||
|
provider_type: CODEX_PROVIDER_TYPE,
|
||||||
|
display_name: "OpenAI Codex (local CLI)",
|
||||||
|
base_url: "",
|
||||||
|
default_models: codexStatusRaw.supported_models ?? [],
|
||||||
|
supports_streaming: true,
|
||||||
|
supports_vision: false,
|
||||||
|
supports_tool_calling: true,
|
||||||
|
model_list_mode: "curated",
|
||||||
|
notes: codexStatusRaw.logged_in
|
||||||
|
? "Dispatches chat turns through the local Codex CLI."
|
||||||
|
: "Sign in with `codex login` before chatting.",
|
||||||
|
} as ProviderRegistryEntry,
|
||||||
|
]
|
||||||
|
: registryRowsRaw;
|
||||||
setRegistry(registryRows);
|
setRegistry(registryRows);
|
||||||
setProviderType((current) => {
|
setProviderType((current) => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -408,6 +483,15 @@ export function ChatProvidersSettings({
|
||||||
isReasoningModel: supportsProviderReasoningToggle(uiProviderType)
|
isReasoningModel: supportsProviderReasoningToggle(uiProviderType)
|
||||||
? existing?.isReasoningModel === true
|
? existing?.isReasoningModel === true
|
||||||
: undefined,
|
: undefined,
|
||||||
|
// Preserve the per-Codex fan-out width on sync. The
|
||||||
|
// backend provider row does not carry it (it lives in
|
||||||
|
// localStorage only), so we read it from `existing` and
|
||||||
|
// skip the field entirely for non-Codex providers.
|
||||||
|
codexParallelCalls: isCodexProviderType(uiProviderType)
|
||||||
|
? clampCodexParallelCalls(
|
||||||
|
existing?.codexParallelCalls ?? CODEX_DEFAULT_PARALLEL_CALLS,
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
createdAt: existing?.createdAt ?? createdAt,
|
createdAt: existing?.createdAt ?? createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
};
|
};
|
||||||
|
|
@ -465,13 +549,27 @@ export function ChatProvidersSettings({
|
||||||
setModelSearchQuery("");
|
setModelSearchQuery("");
|
||||||
setCustomProviderName(customProviderDisplayName(providerType));
|
setCustomProviderName(customProviderDisplayName(providerType));
|
||||||
setIsReasoningModel(false);
|
setIsReasoningModel(false);
|
||||||
|
setCodexParallelCalls(CODEX_DEFAULT_PARALLEL_CALLS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddProvider() {
|
function openAddProvider() {
|
||||||
resetForm();
|
resetForm();
|
||||||
const entry = providerType ? registryByType.get(providerType) : null;
|
const entry = providerType ? registryByType.get(providerType) : null;
|
||||||
if (entry?.model_list_mode === "curated") {
|
if (entry?.model_list_mode === "curated") {
|
||||||
setAvailableModels([...entry.default_models]);
|
const defaults = [...entry.default_models];
|
||||||
|
setAvailableModels(defaults);
|
||||||
|
// Mirror the providerType-change effect's first-run behavior:
|
||||||
|
// Codex is the local CLI so pre-checking the default models lets
|
||||||
|
// the user click Save without re-ticking anything. Without this
|
||||||
|
// the resetForm above would zero selectedModelIds and the form
|
||||||
|
// would fail the "Add at least one model ID" save guard even
|
||||||
|
// though the round 7 Codex auto-enable effect would have
|
||||||
|
// populated them. Triggered when the user clicks Add connection
|
||||||
|
// while Codex was already the providerType (e.g. after closing
|
||||||
|
// and reopening the form).
|
||||||
|
setSelectedModelIds(
|
||||||
|
providerType === CODEX_PROVIDER_TYPE ? defaults : [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setPage("form");
|
setPage("form");
|
||||||
}
|
}
|
||||||
|
|
@ -553,7 +651,7 @@ export function ChatProvidersSettings({
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isCustomProvider && !apiKey.trim()) {
|
if (!providerSkipsApiKey && !apiKey.trim()) {
|
||||||
toast.error("Add an API key first.");
|
toast.error("Add an API key first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -625,7 +723,7 @@ export function ChatProvidersSettings({
|
||||||
const displayName = isCustomProvider
|
const displayName = isCustomProvider
|
||||||
? customProviderName.trim() || customProviderDisplayName(providerType)
|
? customProviderName.trim() || customProviderDisplayName(providerType)
|
||||||
: (selectedRegistryEntry?.display_name ?? providerType);
|
: (selectedRegistryEntry?.display_name ?? providerType);
|
||||||
if (!isCustomProvider && !apiKey.trim()) {
|
if (!providerSkipsApiKey && !apiKey.trim()) {
|
||||||
toast.error("API key is required.");
|
toast.error("API key is required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -702,6 +800,11 @@ export function ChatProvidersSettings({
|
||||||
isReasoningModel: supportsProviderReasoningToggle(uiProviderType)
|
isReasoningModel: supportsProviderReasoningToggle(uiProviderType)
|
||||||
? isReasoningModel
|
? isReasoningModel
|
||||||
: undefined,
|
: undefined,
|
||||||
|
// Persist the fan-out width on the Codex provider only; other
|
||||||
|
// providers must not carry the field through normalization.
|
||||||
|
codexParallelCalls: isCodexProviderType(uiProviderType)
|
||||||
|
? clampCodexParallelCalls(codexParallelCalls)
|
||||||
|
: undefined,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
};
|
};
|
||||||
|
|
@ -734,7 +837,9 @@ export function ChatProvidersSettings({
|
||||||
}
|
}
|
||||||
const isEditingCustomProvider =
|
const isEditingCustomProvider =
|
||||||
isCustomProviderType(existing.providerType);
|
isCustomProviderType(existing.providerType);
|
||||||
if (!isEditingCustomProvider && !apiKey.trim()) {
|
const editingProviderSkipsApiKey =
|
||||||
|
isEditingCustomProvider || isCodexProviderType(existing.providerType);
|
||||||
|
if (!editingProviderSkipsApiKey && !apiKey.trim()) {
|
||||||
toast.error("API key is required.");
|
toast.error("API key is required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -820,6 +925,12 @@ export function ChatProvidersSettings({
|
||||||
)
|
)
|
||||||
? isReasoningModel
|
? isReasoningModel
|
||||||
: undefined,
|
: undefined,
|
||||||
|
// Carry through the fan-out width for Codex; clear it on
|
||||||
|
// every other provider type so a left-over value cannot
|
||||||
|
// hitchhike on the persisted record.
|
||||||
|
codexParallelCalls: isCodexProviderType(existing.providerType)
|
||||||
|
? clampCodexParallelCalls(codexParallelCalls)
|
||||||
|
: undefined,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
}
|
}
|
||||||
: provider,
|
: provider,
|
||||||
|
|
@ -852,6 +963,13 @@ export function ChatProvidersSettings({
|
||||||
? provider.isReasoningModel === true
|
? provider.isReasoningModel === true
|
||||||
: false,
|
: false,
|
||||||
);
|
);
|
||||||
|
setCodexParallelCalls(
|
||||||
|
isCodexProviderType(provider.providerType)
|
||||||
|
? clampCodexParallelCalls(
|
||||||
|
provider.codexParallelCalls ?? CODEX_DEFAULT_PARALLEL_CALLS,
|
||||||
|
)
|
||||||
|
: CODEX_DEFAULT_PARALLEL_CALLS,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
isCustomProviderType(provider.providerType) &&
|
isCustomProviderType(provider.providerType) &&
|
||||||
!supportsRemoteModelCatalog(provider.providerType)
|
!supportsRemoteModelCatalog(provider.providerType)
|
||||||
|
|
@ -924,6 +1042,30 @@ export function ChatProvidersSettings({
|
||||||
|
|
||||||
async function testProvider(provider: ExternalProviderConfig) {
|
async function testProvider(provider: ExternalProviderConfig) {
|
||||||
const savedKey = getExternalProviderApiKey(provider.id).trim();
|
const savedKey = getExternalProviderApiKey(provider.id).trim();
|
||||||
|
// Codex dispatches via the local CLI / SDK -- there is no remote
|
||||||
|
// endpoint to ping. Reuse `/api/codex/status` as the test result.
|
||||||
|
if (isCodexProviderType(provider.providerType)) {
|
||||||
|
try {
|
||||||
|
const status = await fetchCodexStatus();
|
||||||
|
if (!status.installed) {
|
||||||
|
toast.error("Codex CLI or SDK is not available on this host.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!status.logged_in) {
|
||||||
|
toast.info("Sign in to Codex before testing this connection.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
status.version
|
||||||
|
? `Codex is available (${status.version}).`
|
||||||
|
: "Codex is available.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
toast.error(`Codex status check failed: ${message}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Local OpenAI-compat presets skip API keys — run the connection check.
|
// Local OpenAI-compat presets skip API keys — run the connection check.
|
||||||
if (!savedKey && !supportsRemoteModelCatalog(provider.providerType)) {
|
if (!savedKey && !supportsRemoteModelCatalog(provider.providerType)) {
|
||||||
if (isCustomProviderType(provider.providerType)) {
|
if (isCustomProviderType(provider.providerType)) {
|
||||||
|
|
@ -1075,6 +1217,66 @@ export function ChatProvidersSettings({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCodexProvider &&
|
||||||
|
codexStatus?.installed &&
|
||||||
|
!codexStatus.logged_in ? (
|
||||||
|
<div className="grid grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] items-center gap-4 px-4 py-3 max-sm:grid-cols-1">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
Codex sign-in
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs leading-snug text-muted-foreground">
|
||||||
|
Authenticate the local Codex CLI before chatting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CodexLoginButton
|
||||||
|
onLoggedIn={() => {
|
||||||
|
void refreshCodexStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isCodexProvider ? (
|
||||||
|
<div className="grid grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] items-center gap-4 px-4 py-3 max-sm:grid-cols-1">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="codex-parallel-calls"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
Parallel calls
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs leading-snug text-muted-foreground">
|
||||||
|
Fan-out width. Each call runs the same prompt against
|
||||||
|
Codex and the results are unified in a final synthesis
|
||||||
|
tab. 1-{CODEX_MAX_PARALLEL_CALLS}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Input
|
||||||
|
id="codex-parallel-calls"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={CODEX_MAX_PARALLEL_CALLS}
|
||||||
|
step={1}
|
||||||
|
value={codexParallelCalls}
|
||||||
|
onChange={(event) => {
|
||||||
|
const raw = Number(event.target.value);
|
||||||
|
setCodexParallelCalls(
|
||||||
|
clampCodexParallelCalls(
|
||||||
|
Number.isFinite(raw)
|
||||||
|
? raw
|
||||||
|
: CODEX_DEFAULT_PARALLEL_CALLS,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showApiKeyField ? (
|
{showApiKeyField ? (
|
||||||
<div className="grid grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] items-center gap-4 px-4 py-3 max-sm:grid-cols-1">
|
<div className="grid grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] items-center gap-4 px-4 py-3 max-sm:grid-cols-1">
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
// 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, useEffect, 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<string | null>(null);
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [deviceUrl, setDeviceUrl] = useState<string | null>(null);
|
||||||
|
const [deviceCode, setDeviceCode] = useState<string | null>(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<AbortController | null>(null);
|
||||||
|
|
||||||
|
const startLogin = useCallback(async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setLogs([]);
|
||||||
|
setDeviceUrl(null);
|
||||||
|
setDeviceCode(null);
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current?.abort();
|
||||||
|
abortRef.current = controller;
|
||||||
|
// Track the specific backend error inside the closure so the
|
||||||
|
// generic fallback message does not overwrite it: setError is
|
||||||
|
// async and reading `error` after `setError(event.message)` would
|
||||||
|
// still see the stale pre-stream value.
|
||||||
|
let lastStreamError: string | null = null;
|
||||||
|
try {
|
||||||
|
let lastOk: boolean | undefined;
|
||||||
|
for await (const event of streamCodexDeviceLogin(
|
||||||
|
controller.signal,
|
||||||
|
) as AsyncGenerator<CodexLoginEvent>) {
|
||||||
|
if (event.type === "device_url" && event.url) {
|
||||||
|
setDeviceUrl(event.url);
|
||||||
|
// Do NOT auto-open the verification URL with `window.open`.
|
||||||
|
// The click handler that started this flow has already
|
||||||
|
// awaited an SSE event, so the call is no longer in a user
|
||||||
|
// gesture and most browsers (Firefox, Safari, Chrome with
|
||||||
|
// strict popup settings) will silently block the popup.
|
||||||
|
// The URL is rendered as a prominent link below so the
|
||||||
|
// user can open it in one click without depending on the
|
||||||
|
// popup heuristic.
|
||||||
|
} else if (event.type === "device_code" && event.code) {
|
||||||
|
setDeviceCode(event.code);
|
||||||
|
} else if (event.type === "log" && event.line) {
|
||||||
|
setLogs((prev) => [...prev, event.line as string]);
|
||||||
|
} else if (event.type === "error" && event.message) {
|
||||||
|
lastStreamError = event.message;
|
||||||
|
setError(event.message);
|
||||||
|
} else if (event.type === "done") {
|
||||||
|
lastOk = event.ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastOk) {
|
||||||
|
onLoggedIn?.();
|
||||||
|
} else if (!lastStreamError) {
|
||||||
|
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, onLoggedIn]);
|
||||||
|
|
||||||
|
// Abort the in-flight SSE stream on unmount so the underlying
|
||||||
|
// `codex login --device-auth` subprocess does not keep streaming
|
||||||
|
// (and consuming a device-auth session) after the dialog closes.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button type="button" disabled={busy} onClick={startLogin}>
|
||||||
|
{busy ? "Signing in to Codex…" : "Sign in to Codex"}
|
||||||
|
</Button>
|
||||||
|
{deviceUrl && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
{/* Opens via a real anchor click so popup blockers cannot
|
||||||
|
interfere -- the popup-block path used to apply when
|
||||||
|
`window.open` was triggered from inside an awaited
|
||||||
|
event handler instead of a fresh user gesture. */}
|
||||||
|
<a
|
||||||
|
href={deviceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Open verification page
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<p className="break-all text-[11px] text-muted-foreground">
|
||||||
|
Or copy: {deviceUrl}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{deviceCode && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
One-time code:{" "}
|
||||||
|
<code className="font-mono text-foreground">{deviceCode}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<pre className="max-h-40 overflow-auto rounded bg-muted/50 p-2 text-[11px]">
|
||||||
|
{logs.join("\n")}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<number | "synthesis">("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<number | "synthesis">(() => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"codex-parallel-card my-2 rounded-md border bg-muted/30 p-2 text-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded px-1 py-1 text-left text-xs font-medium text-muted-foreground hover:bg-accent/50"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Codex parallel calls
|
||||||
|
{totalSlots > 0 ? ` (${state.tabs.length}/${totalSlots})` : null}
|
||||||
|
{state.synthesis ? " — synthesis ready" : ""}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden>{collapsed ? "+" : "−"}</span>
|
||||||
|
</button>
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1 border-b pb-2">
|
||||||
|
{state.tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.tabId}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded-t px-2 py-1 text-xs font-medium",
|
||||||
|
effectiveActive === tab.tabId
|
||||||
|
? "bg-background text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50",
|
||||||
|
tab.error && "text-destructive",
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab(tab.tabId)}
|
||||||
|
>
|
||||||
|
Tab {tab.tabId}
|
||||||
|
{tab.error ? " (error)" : tab.closed ? "" : " …"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{state.synthesis !== null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded-t px-2 py-1 text-xs font-semibold",
|
||||||
|
effectiveActive === "synthesis"
|
||||||
|
? "bg-primary/15 text-primary"
|
||||||
|
: "text-primary/70 hover:bg-primary/10",
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab("synthesis")}
|
||||||
|
>
|
||||||
|
Synthesis
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap rounded bg-background/50 p-2 text-xs">
|
||||||
|
{effectiveActive === "synthesis"
|
||||||
|
? state.synthesis || "(waiting for synthesis…)"
|
||||||
|
: (state.tabs.find((t) => t.tabId === effectiveActive)?.text ||
|
||||||
|
"(waiting…)")}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,13 @@ export interface ExternalProviderConfig {
|
||||||
* OpenAI's hard default is 20. Only meaningful for OpenAI cloud.
|
* OpenAI's hard default is 20. Only meaningful for OpenAI cloud.
|
||||||
*/
|
*/
|
||||||
openaiContainerTtlMinutes?: number;
|
openaiContainerTtlMinutes?: number;
|
||||||
|
/**
|
||||||
|
* Codex provider only: number of parallel Codex turns to fan a chat
|
||||||
|
* request out into. Clamped to [1, 20] by `clampCodexParallelCalls`.
|
||||||
|
* Omitted or 1 takes the single-call path; values > 1 emit per-tab
|
||||||
|
* `codex_tab_*` SSE events plus a final `codex_gather` synthesis.
|
||||||
|
*/
|
||||||
|
codexParallelCalls?: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
@ -86,11 +93,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.
|
// Known text-only providers on their main chat endpoint.
|
||||||
const NON_VISION_PROVIDER_TYPES = new Set<string>([
|
const NON_VISION_PROVIDER_TYPES = new Set<string>([
|
||||||
"cohere",
|
"cohere",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"mistral",
|
"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.
|
// Providers whose vision-tier model selection accepts images.
|
||||||
const VISION_CAPABLE_PROVIDER_TYPES = new Set<string>([
|
const VISION_CAPABLE_PROVIDER_TYPES = new Set<string>([
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue