Compare commits

...
Sign in to create a new pull request.

48 commits

Author SHA1 Message Date
pre-commit-ci[bot]
c716af442f [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-27 14:44:37 +00:00
Daniel Han
6403846bbe Studio: spoof-aware Codex availability probe + autouse fixture
When ``UNSLOTH_CODEX_SPOOF=1`` is exported (the credit-free dev / CI
path the previous commit added), the in-process spoof IS the Codex
SDK and a real ``codex`` CLI is irrelevant. The status endpoint at
``/api/codex/status`` used to gate ``installed`` on the real CLI +
real SDK only, which made the frontend hide the Codex provider in
the connections dropdown even when the spoof was active. Now both
``_sdk_importable`` and ``probe_codex_availability`` short-circuit on
``codex_spoof.is_spoof_enabled()`` so the provider becomes visible
under the spoof. ``installed=True``, ``cli_path="<spoof>"``,
``logged_in=True``, ``version="spoof"`` -- a sentinel that lets devs
read off "yes I am under the spoof" at a glance.

Real production code path (no spoof flag) is unchanged: still gates
on bool(cli_path) AND sdk_ok the same as round 6.

Tests: added an autouse fixture in ``test_codex_provider.py`` that
clears ``UNSLOTH_CODEX_SPOOF`` before every test so the existing
availability / import gating tests are not polluted when a dev runs
the suite with the flag exported. The spoof-targeted tests still
call ``monkeypatch.setenv(...)`` to flip it back on inside their own
scope. 69/69 pass with and without the env flag.
2026-05-27 14:44:15 +00:00
Daniel Han
6867cfbd6d Merge remote-tracking branch 'origin/main' into feat/codex-provider
# Conflicts:
#	studio/backend/main.py
#	studio/backend/routes/__init__.py
2026-05-27 14:10:37 +00:00
pre-commit-ci[bot]
cd8284d5cd [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-27 13:31:21 +00:00
Daniel Han
10d6b6939e Merge branch 'feat/codex-provider' of https://github.com/unslothai/unsloth into feat/codex-provider 2026-05-27 13:31:03 +00:00
Daniel Han
f01011e4dd Studio: real Codex parallel-call tab UI + in-process SDK spoof
Two paired changes that finally make the Codex parallel-calls fan-out
visible as actual clickable tabs in the chat surface, plus a credit-
free spoof that lets the whole pipeline run in dev / CI without ever
touching the upstream API.

1. Real tab UI (frontend).

   The chat-adapter used to render the per-worker outputs as inline
   `[Codex tab 1/N] ...` text blocks in the assistant message body,
   which collapsed into one big run-on block once more than a handful
   of tokens had streamed. Now each `codex_*` SSE event is folded into
   `codexParallelState` and re-published as the `args.state` of a
   single tool-call part with `toolName === "codex_parallel"`. The
   assistant-ui surface dispatches that to the new
   `CodexParallelToolUI` wrapper, which mounts the existing
   `CodexParallelTabs` component -- one tab per worker, one Synthesis
   tab, click to switch. The stable `toolCallId` keeps assistant-ui
   updating the SAME card across stream yields rather than spawning
   new cards.

   `renderCodexTabsBlock` now returns the empty string so the message
   body no longer contains the labelled-text fallback (kept the
   function name so the rest of the adapter's `renderFullContent` /
   pin-signature paths are untouched).

2. Credit-free Codex SDK spoof (backend).

   New `studio/backend/core/inference/codex_spoof.py` exposes a drop-in
   subset of the upstream `openai_codex` surface (`AsyncCodex`,
   `AppServerConfig`, `ApprovalMode.deny_all`, `SandboxMode.read_only`,
   thread with `turn().stream()` + `run_streaming()` + `run()`) and
   emits deterministic per-tab streaming events tagged with the worker
   index, so flipping between tabs in the UI shows visibly distinct
   text. Activated by `UNSLOTH_CODEX_SPOOF=1`; `_import_codex` installs
   the spoof into `sys.modules` under both `openai_codex` and
   `codex_app_server` and the rest of the provider keeps running
   unchanged. OFF by default; production is unaffected.

   Six new tests cover the spoof itself (module install, env-flag
   gating, delta + completion event shape, per-tab tagging, provider
   import path, safety-kwargs resolution against the spoof). 69/69
   tests pass with and without the flag; TypeScript clean.
2026-05-27 13:30:53 +00:00
pre-commit-ci[bot]
3d1075f6fd [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-27 13:24:21 +00:00
Daniel Han
ecf8bc767d Merge remote-tracking branch 'origin/main' into feat/codex-provider
# Conflicts:
#	studio/frontend/src/features/chat/api/chat-adapter.ts
2026-05-27 13:22:31 +00:00
pre-commit-ci[bot]
dd0aeec388 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-27 06:58:56 +00:00
Daniel Han
e2b7f5958b Studio: round 9 -- three P2 fixes from latest Codex bot review
1. Codex SSE wrapper terminates on exact `data: [DONE]` only.

   The old substring check `if "[DONE]" in line` would flip
   sent_done True when a normal model response carried the literal
   text "[DONE]" in delta.content (for example an explanation of
   the OpenAI stream sentinel). The real terminator was then
   suppressed, leaving OpenAI-compatible clients that finalise on
   the explicit sentinel hung on stream close. Now compares the
   stripped line to the exact `data: [DONE]` form.

2. Legacy `thread.run_streaming` path no longer returns an empty
   reply on completion-only streams.

   If the SDK exposes `thread.run_streaming` but the stream emits
   ONLY item.completed / agentMessage events with no message
   deltas, the loop previously exited with emitted_any False and
   never reached the agent-message fallback. The request returned
   200 with an empty assistant reply even though Codex produced a
   final answer. Mirror the canonical-path behavior: collect
   `_completed_agent_message_text` strings in a sidecar list and
   emit the last one when no deltas arrived. Match the canonical
   payload-extraction (`getattr(event, "payload", event)`) so the
   event-vs-payload SDK shape difference is handled the same way
   in both branches.

3. Parallel-calls fan-out propagates CodexUnavailableError so the
   route layer can return 503.

   When the SDK is not importable or the safety enums are missing
   without the dev opt-in, every worker raised the same
   CodexUnavailableError. The previous catch-all converted the
   error into a per-tab codex_tab_error event, the outer stream
   never raised, and clients saw a 200 with only tool events and
   an empty synthesis -- OpenAI-compatible consumers that ignore
   _toolEvent saw a successful empty reply. Now CodexUnavailableError
   re-raises out of the worker (no spurious per-tab error event),
   _await_workers re-raises it when EVERY worker hit the same
   setup failure, and the finally-block drain await propagates the
   exception out of the parallel function so the route's existing
   CodexUnavailableError handler can emit the right 503 SSE error
   frame. Per-tab runtime failures (model rejected, timeout, mid-
   stream SDK crash) still get swallowed into codex_tab_error
   events so a single bad model in the fan-out does not kill the
   others.

Test counts: 63/63 passing (60 round 6-8 plus 3 new round 9 regression
tests). Each new test was first run against a `git stash`-restored
pre-fix tree to confirm it catches the bug, then run against the
patched tree.
2026-05-27 06:58:06 +00:00
Daniel Han
3bbbd41227 Merge branch 'feat/codex-provider' of https://github.com/unslothai/unsloth into feat/codex-provider 2026-05-27 06:52:39 +00:00
Daniel Han
f29faefb98 Merge branch 'main' into feat/codex-provider
Resolve three conflicts touched by main since the branch forked:

- studio/backend/core/inference/external_provider.py: take main's
  rewrite of _anthropic_citation_key (extended dedup keys covering
  end-char/page/block indices plus search_result_index) and the new
  _anthropic_supports_fast_mode helper. The branch had only the
  earlier shorter citation_key form so accepting main wholesale
  here loses nothing Codex-related.

- studio/backend/models/inference.py: keep BOTH the Codex
  parallel_calls field + _clamp_parallel_calls validator (from the
  branch) AND the new Anthropic fast_mode field (from main). They
  occupy different provider lanes.

- studio/frontend/src/features/chat/api/chat-adapter.ts: fold the
  Codex per-tab rendering pass (renderFullContent) into main's new
  orderAssistantContent positioning so tools land before text,
  generated images after, AND any Codex tab text accumulated in
  earlier _toolEvent frames is preserved through the synthesis
  content delta (round 8 render-order fix).

Backend codex tests: 60/60 passing. Anthropic citations / fast_mode
tests: 96/96 passing. Frontend builds cleanly.
2026-05-27 06:52:19 +00:00
pre-commit-ci[bot]
5185032b28 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-25 15:25:04 +00:00
Daniel Han
b17765b5ed Studio: round 8 -- replay guard on non-visible events + Add-flow Codex preselect
Two P1 fixes from the round 8 reviewer pass:

1. _stream_thread_run no longer replays a Codex turn that fired
   non-visible events before crashing.

   The replay guard only tracked `emitted_any` (visible text). A
   Codex turn that emitted, say, a command.delta or file.delta event
   first -- both filtered to "" by _coerce_text -- and THEN crashed
   would leave emitted_any=False and fall through to the buffered
   `thread.run(prompt)` fallback, re-executing the same turn and
   duplicating its side effects (shell commands, file writes,
   tool calls). This is exactly the case the guard was added to
   prevent in earlier rounds; the missing bit was tracking
   "the turn ran at all", not just "the turn yielded text".

   Fix: add a separate turn_started flag that flips True the moment
   we ask the SDK for a turn handle or observe any event from a
   streaming helper. When the buffered fallback is gated on
   turn_started instead of emitted_any, a partial-turn crash
   correctly stops without replaying. Regression test reproduces
   the bug against the pre-fix code (assertion catches the extra
   thread.run call) and locks the fix in.

2. openAddProvider now mirrors the providerType-change effect's
   Codex pre-check.

   The first-run UX fix from `26799d9a` pre-checked every Codex
   default model in the providerType-change effect, but
   openAddProvider() calls resetForm() (which clears
   selectedModelIds) and then only restores availableModels, not
   selectedModelIds. If the user closes the Add connection form
   and re-opens it while Codex is still the current providerType,
   the effect does not re-run, so the form opens with Codex
   defaults available but none selected -- the "Add at least one
   model ID" save guard then blocks the Save click.

   Fix: openAddProvider now seeds selectedModelIds with the full
   default-models list when the provider is Codex, matching the
   providerType-change effect so the two entry paths produce the
   same first-run state.
2026-05-25 15:24:49 +00:00
pre-commit-ci[bot]
be15c57fee [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-25 14:45:40 +00:00
Daniel Han
dee1b68b6d Studio: round 7b -- tighten device-login log filter + harden timeout kill
Two more P1 follow-ups from the round 7 reviewer pass:

1. Device-login log filter no longer leaks sensitive lines.

   The old `_safe_to_forward` used unanchored substring matches like
   `"logged in"`, so a line such as

     Not logged in: refresh_token=rt_LEAK auth.json=/home/u/.codex/auth.json

   slipped through the safe-vocabulary filter and was streamed to the
   browser. A malicious codex shim earlier on PATH can print that
   line trivially, defeating the "opaque output stays in backend
   logs" safety guarantee the route claimed.

   Round 7b fix: anchored regex set (must start with one of the
   known upstream phrases) plus an explicit blocklist for
   refresh_token / access_token / api_key / secret / auth.json / the
   codex config dir / "not logged in" / "not authenticated". A line
   that matches the blocklist is dropped regardless of which safe
   pattern would otherwise have accepted it. New tests reconstruct
   the regex set inline and assert both the leak cases drop and the
   clean upstream phrases pass.

2. `_run_cli` timeout cleanup no longer 500s on a kill race.

   `_run_cli` would call `proc.kill()` after `os.killpg(pid, SIGTERM)`
   reaped the process group. If the SIGTERM landed first, the
   subsequent `proc.kill()` raised `ProcessLookupError` and bubbled
   out of `_run_cli`, turning `/api/codex/status` into a 500 during
   a timeout race. The device-login cleanup at codex_provider.py
   already wraps the same destructive call in a try / except. Mirror
   that exception guard here so the two timeout paths behave the
   same.
2026-05-25 14:45:26 +00:00
pre-commit-ci[bot]
8cef479423 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-25 14:43:25 +00:00
Daniel Han
f05517ac79 Studio: round 7 Codex hardening (cross-wrapper env scrub + device URL allowlist)
Two P1 fixes from the round 7 reviewer pass:

1. _ScrubbedEnvAsyncCodex no longer leaks secrets across overlapping
   sessions.

   The fallback env-scrub wrapper (used when the installed SDK build
   does not accept AppServerConfig(env=...)) refcounts deleted env
   vars under a shared lock so concurrent fan-out workers do not
   restore Studio's secrets while a peer is still inside SDK startup.
   The round 6 implementation enumerated keys to scrub via
   _codex_sdk_env_override() which only returns keys currently in
   os.environ. If wrapper A had already deleted HF_TOKEN before
   wrapper B entered, B's overrides dict no longer contained HF_TOKEN,
   B never bumped the refcount for it, and A's exit restored
   HF_TOKEN into the process env while B was still running -- so
   B's spawned codex app-server inherited the secret.

   Round 7 fix: under the lock, the union of (a) the current
   overrides dict and (b) every key still refcounted by an earlier
   wrapper is the set of keys this session must scrub. Originals are
   tracked module-level rather than per-instance so the last wrapper
   to release a key always restores the right pre-scrub value
   regardless of who first saw it. New regression test reproduces
   the leak against the round 6 code (asserts refcount == 2 after
   B enters; old code records 1) and locks the fix in.

2. Device-auth verification URL is now host-allowlisted.

   The codex login --device-auth output parser pulled any https URL
   matching /device, /activate, or /verify out of the CLI's stdout
   and emitted it as a device_url event. The frontend rendered that
   URL as an "Open verification page" CTA the user can click. A
   compromised codex shim earlier on PATH could print
   https://evil.example/activate?code=ABCD and Studio would surface
   the phishing link verbatim, even though every other login output
   line goes through a strict safe-vocabulary filter.

   Round 7 fix: device_url events only fire for URLs whose host is
   on a small allowlist (auth.openai.com / chatgpt.com over https).
   Anything else is logged at warn and dropped. Tests cover the
   known-good upstream URLs, several attacker patterns (lookalike
   subdomains, http downgrade, javascript:), and garbage input.
2026-05-25 14:43:10 +00:00
Daniel Han
26799d9a18 Studio: pre-check Codex default models when creating the connection
First-run UX bug surfaced by an end-to-end probe: after the user adds
the "OpenAI Codex (local CLI)" connection from Settings -> Connections
-> Add provider, every model in the form starts UNCHECKED. The user
saves, returns to the chat composer, opens the model picker -- and
the "Connected" tab is missing because no Codex models are enabled.
The user has to re-open the connection, tick at least one model, save
again, then return to chat. Two round-trips to make a feature work
that the rest of the UI already advertises as installed.

Anthropic / OpenAI / OpenRouter all keep their explicit-opt-in
defaults because the model choice has billing and capability impact.
Codex is the local CLI on the same machine -- the SDK accepts any
model id, the "default_models" list is the SDK's curated shortlist,
and gating chat behind a manual click adds friction without buying
anything. Pre-checking all default models for Codex (and only Codex)
makes the path "click Add -> click Save -> chat works" survive a
single click sequence, matching the empirical setup we walked
through in the e2e probe.
2026-05-25 14:23:05 +00:00
Daniel Han
b7862388fa Studio: render Codex parallel-calls synthesis AFTER the per-tab blocks
End-to-end probe of a 2-way parallel-calls Codex turn revealed the
visible assistant message looked like:

  <synthesis text>

  [Codex tab 1/2]
  <tab 1 text>

  [Codex tab 2/2]
  <tab 2 text>

  --- Synthesis ---

The synthesis came first (no label), then the tabs, then a trailing
"--- Synthesis ---" line with nothing below it. The header comment in
chat-adapter.ts said the intent was "[tabs] ... [synthesis]" but the
implementation prepended cumulativeText (which carries the synthesis
deltas the backend emits after codex_gather) before the tabs.

Fix: split renderCodexBuffer into renderCodexTabsBlock (pure per-tab
rendering, no trailing divider) and reorder renderFullContent so when
Codex fan-out is active the output is:

  [Codex tab 1/N]
  <tab 1>

  [Codex tab N/N]
  <tab N>

  --- Synthesis ---

  <synthesis text from cumulativeText>

The non-Codex case still returns cumulativeText unchanged.
2026-05-25 14:06:19 +00:00
Daniel Han
c755d00c1f Studio: cross-platform CI matrix for Codex provider tests
Adds a small (ubuntu-latest + macos-14 + windows-latest) x (3.11, 3.13)
matrix that runs tests/test_codex_provider.py on every push touching
the Codex code or this workflow. The existing studio-backend-ci.yml
already runs the full backend test suite on ubuntu across Py 3.10-3.13
but never on macOS / Windows, so cross-platform regressions in the
codex_bin / sys.modules / importlib gates would not be caught before
shipping. macOS coverage matters because Studio's MLX path attracts
Apple Silicon users, Windows because Studio ships a Tauri desktop
build there. Concurrency cancel-in-progress so each new push
supersedes the previous run, paths filter so unrelated changes do not
re-trigger.
2026-05-25 14:01:42 +00:00
Daniel Han
2eaf1bbd31 Studio: pass codex_bin to AppServerConfig so PATH-only codex installs work
Reproduces with pip install openai-codex --no-deps (lightweight install
that skips the pinned openai-codex-cli-bin runtime) or any host where
the codex CLI is installed via npm i -g @openai/codex / Homebrew /
manual download. Studio constructs AsyncCodex(config=AppServerConfig(
env=...)) without codex_bin, so the SDK runs _installed_codex_path
which 'from codex_cli_bin import bundled_codex_path' and raises
FileNotFoundError: Unable to locate the pinned Codex runtime. Install
the published SDK build with its openai-codex-cli-bin dependency, or
set AppServerConfig.codex_bin explicitly. -- even though a perfectly
good codex is on PATH and was the binary the availability probe
already verified.

Fix: resolve shutil.which("codex") and pass it as
AppServerConfig(codex_bin=...). The PR's availability probe already
returns that exact path in /api/codex/status.cli_path, so we are
giving the SDK back the binary the user can see in the Connections
form. Falls back to AppServerConfig(env=...) (no codex_bin) when the
SDK build does not accept the kwarg yet, and falls back to PATH lookup
returning None on hosts without codex on PATH (in which case the
availability probe would have reported installed=false and Studio
never gets here).

Test fixture: also inject the fake module under openai_codex (the
canonical name the production importer prefers) so the test does not
silently exercise the real SDK on developer venvs that have
pip install openai-codex already done.

End-to-end verified live: pip install -e openai/codex sdk/python plus
Studio with codex CLI on PATH yielded ROUND_TRIP_OK streaming for
gpt-5.4-mini through the OpenAI-compat completions route.
2026-05-25 13:47:16 +00:00
Daniel Han
2874abbfec Studio: fail closed when Codex SDK cannot enforce safety pins
Round 6 reviewer noted that the warn-and-proceed path in
`_start_thread_with_system` is "failing open" on a server-side
chat surface: an SDK rev that does not expose ApprovalMode or
SandboxMode would log a warning then call `thread_start(model=...)`
with NO safety kwargs, letting the model run under the SDK's
`auto_review` default. For a route that takes a user-controlled
prompt and can spawn shell commands or file writes, that is the
wrong tradeoff.

Now fails closed: when `_safe_thread_safety_kwargs()` returns the
empty dict the helper raises `CodexUnavailableError`, which the
route layer translates to a 503 with a clear error message telling
the operator to upgrade `openai_codex` (or set the explicit
override env var). The error message names the override so users
who hit this on a pre-release alpha can opt in with eyes open
rather than discovering the unsafe default after the fact.

`UNSLOTH_CODEX_ALLOW_UNSAFE_DEFAULTS=1` is the deliberately-verbose
escape hatch. Variable name long and explicit so it does not creep
into production environments by accident, kept on the codex
subprocess safe-list so the round 6 SDK env-scrub wrapper does not
delete it before the gate sees it.

Tests: 50 cases total (was 49). The previous old-SDK test was
renamed and replaced by two new ones:
- `test_thread_start_fails_closed_when_safety_unavailable` asserts
  the raise fires and `thread_start` is never called.
- `test_thread_start_allows_unsafe_defaults_with_explicit_opt_in`
  asserts the override env var lets the request through and
  `thread_start` runs without the safety kwargs (with a logged
  warning).
The `_install_fake_codex_sdk` helper now injects fake ApprovalMode
and SandboxMode by default so the general translation tests do
not need to opt into the override; the two round-6b tests above
pass `with_safety_enums=False` to exercise the fail-closed branch.
2026-05-24 17:45:20 +00:00
Daniel Han
8ee60019a4 Studio: round 6 Codex hardening (5 follow-ups)
Reviewer round 6 surfaced five real follow-ups on top of rounds 5
through 5g. Each one is a fix for an asymmetric guard or a wrong-
shape lookup in the new Codex provider code:

1. Re-gate `installed=True` on having BOTH the SDK AND a `codex`
   binary on PATH. Round 5 widened the gate to SDK-only, but the
   login route still shells out to the binary, so an SDK-only host
   would surface a Codex row whose Sign-in button immediately
   failed with "codex CLI not found on PATH". The canonical
   `openai-codex` package depends on `openai-codex-cli-bin` which
   places the shim on PATH for free, so the common install still
   lights up; the gate just refuses to advertise a provider Studio
   cannot actually drive end-to-end.

2. `_safe_thread_safety_kwargs` now also probes `.api` and
   `.generated.v2_all` for `SandboxMode`. Upstream `openai_codex`
   exports `ApprovalMode` at the top level but `SandboxMode` lives
   under `openai_codex.generated.v2_all`. The previous lookup
   returned `{}` on the canonical SDK install, so every thread_start
   ran with the unsafe `auto_review` default. Submodule probe
   resolves the canonical layout and keeps backwards-compat with
   builds that DID re-export at the top level.

3. `_coerce_text` now applies the answer-event-type filter on the
   object path too. The upstream SDK emits typed payload classes
   like `CommandExecutionOutputDelta`, `FileChangeDelta`,
   `ToolCallDelta`, `PatchApplyDelta`, etc., all of which carry a
   `.delta` string of local stdout / file paths / tool args. The
   dict path already filtered these out; the object path used to
   return `.delta` unconditionally, so a real SDK install could
   leak tool output into the visible chat reply.

4. `_ScrubbedEnvAsyncCodex` is now process-wide concurrency-safe
   AND fails-closed if the SDK constructor raises:
   - Refcount each scrubbed key under an `asyncio.Lock` so a fan-out
     wrapper that exits early cannot restore a secret while another
     wrapper is still inside SDK startup (round 6 reproduced this:
     wrapper A exited, wrapper B's SDK saw the restored HF_TOKEN).
   - Move `_async_codex_cls()` and its `__aenter__` INSIDE a
     try/except in `__aenter__`; on failure, run the release path
     so the scrubbed env vars are restored even though `__aexit__`
     never fires for the failed construction.

5. `_run_cli` now detaches into its own process group via
   `start_new_session=True` (Unix) / `CREATE_NEW_PROCESS_GROUP`
   (Windows) and kills the whole group on timeout, matching the
   protected path in `stream_codex_device_login`. A shimmed
   `codex login status` that forks a helper and blocks no longer
   leaves the child running after we killed the parent.

Frontend follow-up: chat-adapter now routes every rendered yield
through a `renderFullContent()` helper so the Codex per-tab text
accumulated in earlier `_toolEvent` frames is preserved when the
synthesis content delta arrives. Previously the next regular
content yield rebuilt `parts` from `cumulativeText` alone and the
tab section vanished from the final assistant message.

Tests: 49 cases total (was 47). New regressions:
- `installed_requires_both_cli_and_sdk` (round 6 revert).
- `safety_kwargs_finds_sandbox_mode_in_submodule` (canonical SDK
  layout where `SandboxMode` is in `.generated.v2_all`).
- `scrubbed_env_construction_failure_restores_env` (no permanent
  env leak when the SDK constructor raises).
- Expanded `coerce_text_drops_non_answer_event_types` to also
  exercise the object-shape code path with `CommandExecutionOutputDelta`,
  `FileChangeDelta`, `ToolCallDelta`, `PatchApplyDelta`,
  `PlanUpdateDelta`, `AgentReasoningDelta`, plus the
  positive `AgentMessageDelta` allow-through.
2026-05-24 17:30:01 +00:00
Daniel Han
aa258b983d Studio: account for every Codex turn in fan-out usage chunk
The fan-out path used to report usage as if a single Codex call had
run: `prompt_tokens = max(1, len(prompt)//4)` and
`completion_tokens = len(synthesis)//4`. In reality it had spawned
N parallel worker turns (each carrying the same prompt) plus a
synthesis turn that re-sent the prompt and every tab's output. For
`parallel_calls=20` that meant the cost / context widget under-
reported the request by roughly 20x.

Now sums:

- `prompt_tokens ≈ (N * prompt + synthesis_prompt) / 4` where
  `synthesis_prompt = sum(tab_outputs) + prompt`.
- `completion_tokens ≈ (sum(tab_output_chars) + synthesis_chars) / 4`.

Tests: new regression
`test_parallel_usage_accounts_for_all_calls` runs a 4-way fan-out
against a fake SDK with deterministic chunk lengths and asserts
the reported usage scales with N, not the single-call shape.
2026-05-24 17:11:42 +00:00
Daniel Han
cb0680ebaa Studio: per-tab buffer for Codex fan-out so chunks cannot interleave
The Codex parallel-calls fan-out emits N independent SSE streams
concurrently, so a chunk for tab 2 can arrive between two chunks
for tab 1. The previous chat-adapter logic appended every chunk
into a single `cumulativeText` buffer in arrival order, which made
tab 1's text show up under tab 2's header (or vice versa) whenever
the workers raced. With four or more parallel calls the rendered
output became unreadable.

Replaced the append-on-arrival path with per-tab buffers keyed by
`tab_id`, plus a `renderCodexBuffer()` helper that rebuilds the
Codex block from scratch on every fan-out event:

- `codex_tab_open` allocates an empty buffer for the tab id.
- `codex_tab_chunk` appends only into that tab's buffer.
- `codex_tab_error` records the error string against the tab id.
- `codex_tab_close` marks the tab finished (visual separator).
- `codex_gather` flips a flag that draws the `--- Synthesis ---`
  divider; the synthesis text itself still arrives as a normal
  content delta on the same stream and so is not duplicated here.

The block is re-rendered in `tab_id` order on every event, so the
final output is deterministic regardless of arrival interleaving.
2026-05-24 17:07:00 +00:00
Daniel Han
f97a800d5b Studio: round 5e Codex hardening (4 follow-ups)
1. parallel_calls validator now clamps instead of 422-rejecting.
   The Pydantic schema was `int Field(ge=1, le=20)`, which was a
   regression from the pre-PR OpenAI-extra behaviour: a non-Codex
   client that sent the field with a legacy value like 0 (or a
   stray string from a misconfigured wrapper) now got a 422 even
   though the route silently ignores the field on every non-Codex
   provider. Replaced with a `field_validator(mode="before")` that
   coerces any input to the [1, 20] range, keeping the schema docs
   self-documenting while accepting legacy inputs.

2. Buffered Codex result with `final_response=None` no longer
   leaks `TurnResult(...)` Python object repr into the chat. The
   upstream SDK documents `TurnResult.final_response` as nullable
   for turns that perform tool work without producing a final
   assistant message; the previous `... or str(result)` fallback
   would render the repr as visible assistant text. New
   `_buffered_result_text` helper returns the empty string in that
   case so the stream finishes cleanly with no extra content
   chunk. Same fix applied to `_run_codex_synthesis`.

3. Device-login SSE no longer forwards arbitrary subprocess output
   to the browser. The previous code yielded every CLI line under
   `{type:"log"}`, which on a shimmed binary could leak refresh
   tokens, auth JSON, or local config paths into the authenticated
   stream. Filtered to a known-safe vocabulary ("Welcome to
   Codex", "Initializing", "Successfully logged in", etc.).
   `device_url` and `device_code` events still fire as before.

4. CodexLoginButton no longer calls `window.open` from inside an
   awaited SSE handler. Browser popup blockers (Firefox, Safari,
   Chrome strict) silently block popups triggered outside a fresh
   user gesture, so the auto-open was unreliable. Replaced with a
   prominent "Open verification page" button styled as an anchor;
   the click handler is a real user gesture and is never blocked.
   The URL string is still shown below the button for copy/paste.

Tests: 46 cases total (was 43). New regressions cover the
parallel_calls clamp path on three garbage inputs, the buffered
TurnResult-with-None-final repr leak guard, and the device-login
log filter (asserts refresh tokens / auth.json paths are dropped
while known-safe progress lines pass through).
2026-05-24 16:59:46 +00:00
Daniel Han
9b4bd11c9f Studio: surface Codex parallel-calls in the connections dialog
The `codexParallelCalls` field on `ExternalProviderConfig` was wired
through `chat-adapter.ts` (it is serialised over the wire as
`parallel_calls`) but the connections dialog never set or restored
it. With no UI input and no persistence path, the value was always
left as `undefined` after a reload, the adapter fell back to
`?? 1`, and the Codex fan-out path stayed permanently dormant from
the UI even though the backend supported it.

Three plumbing fixes:

1. Add a "Parallel calls" number input to the Codex form section,
   bounded to [1, CODEX_MAX_PARALLEL_CALLS]. Clamped on every key
   stroke so a hand-edited entry cannot exceed the backend cap.
2. Persist the value on `addProvider`, `saveProviderEdits`, and
   restore it on `editProvider` -- gated on `isCodexProviderType`
   so other providers cannot accidentally carry the field.
3. Preserve the value through `syncedProviders` rebuild on backend
   re-sync. The backend row does not store the fan-out width (it is
   local-only), so we copy it from the existing in-memory entry.

Form reset clears the field back to the default so opening "Add
connection" after editing a Codex provider does not pre-fill an
unrelated value.
2026-05-24 16:49:00 +00:00
Daniel Han
8c1c63a64d Studio: surface Codex final agent message when no deltas stream
The canonical openai_codex SDK can complete a turn successfully
without emitting any `message.delta` events: the final assistant
text arrives only as an `ItemCompletedNotification` whose item is
an `agentMessage`. Before this change `_stream_thread_run` would
loop through the stream, see no delta text, return, and Studio
would emit only the empty usage + stop + `[DONE]` frames -- the
user sees a blank reply for what was actually a complete answer.

Track agent-message texts collected during the stream loop and, if
no streamed deltas came through, yield the last one before
returning. The buffered `thread.run()` fallback is still gated by
the existing `emitted_any` flag so it never replays a turn that
already executed side effects (file writes, shell commands).

`_completed_agent_message_text` accepts both the upstream object
shape (`ItemCompletedNotification(item.root.text=...)`) and the
dict shape pre-release builds and tests use, so it works across SDK
revs without an explicit version gate.

Tests: new regression
`test_empty_stream_falls_back_to_completed_agent_message` exercises
a fake SDK whose `turn().stream()` yields only an `item.completed`
event with an `agentMessage`; the test asserts the final text
reaches the visible chat output and that the buffered `run()` path
is NOT re-executed.
2026-05-24 16:44:22 +00:00
Daniel Han
c5289f0249 Studio: pin Codex thread approvals + sandbox to safe defaults
The upstream openai_codex SDK defaults `approval_mode` to
`ApprovalMode.auto_review` (described in the SDK docs as "automatically
execute tools when permission escalations occur, without user
intervention") and leaves `sandbox` unset. Studio drives Codex from
a server-side chat request with no per-action approval UI, so leaving
those at the SDK defaults would let a model decide on its own to run
shell commands, write files, or hit the network on the operator's
machine.

This wires every `thread_start` call (single-turn, parallel-worker,
synthesis) through a helper that pins:

- `approval_mode = ApprovalMode.deny_all` -- reject any tool /
  command escalation rather than auto-approving it.
- `sandbox = SandboxMode.read_only` -- the policy that bans file
  writes and disables network.

The kwargs are looked up dynamically: when the installed SDK is too
old to expose either enum we log a structured warning and proceed
without them rather than refusing to run, so users on pre-release
alpha builds are not bricked. Once the canonical openai-codex SDK
is what every install pulls, the warning will be silent and the
safety pins will always apply.

Tests: three new regressions in TestCodexHardenedRegressions cover
the safe-pin path on a fake SDK that exposes the enums, the
warn-and-proceed path on a fake SDK that does not, and the same
pins on the synthesis turn so a fan-out tab cannot sneak an unsafe
default into the unification step.
2026-05-24 16:40:48 +00:00
Daniel Han
1a107651c4 Studio: round 5 hardening for the Codex provider
Five tightening fixes driven by the reviewer pass on top of round 4:

1. Drop OPENAI_API_KEY from the codex subprocess safe-list.
   The OpenAI provider key belongs to the OpenAI provider; a shimmed
   `codex` binary on PATH must not receive it. Users who want to wire
   the same key into Codex now set CODEX_OPENAI_API_KEY, which is the
   one OpenAI-shaped key we still forward.

2. Fail-closed env scrub on the SDK path.
   When AppServerConfig is missing from the installed openai_codex
   build, the bare AsyncCodex() constructor used to inherit the full
   os.environ via the SDK's internal os.environ.copy(). Replaced the
   fallback with a _ScrubbedEnvAsyncCodex wrapper that swaps
   os.environ for the lifetime of the session so HF_TOKEN, GH_TOKEN,
   WANDB_API_KEY etc never reach the spawned app-server.

3. Prefer the upstream-canonical base_instructions kwarg.
   The real openai_codex SDK takes the system prompt as
   `base_instructions`; our previous helper only knew `system`. Now
   tries base_instructions first, falls back to system, then inlines
   the system text in the user prompt as a last resort.

4. Filter visible text to answer-bearing event types only.
   _coerce_text used to render any payload that exposed a `delta` /
   `text` / `content` field, which let command output, file paths and
   tool call arguments leak into the Chat Completions reply. Gated on
   a _ANSWER_EVENT_TYPES allow-list (message.delta, completed,
   text_delta, etc.); untyped legacy dicts still pass through.

5. Treat the SDK as the install gate.
   openai-codex-cli-bin ships the codex runtime that backs
   AsyncCodex(...), so SDK alone is sufficient to drive the provider.
   `installed` no longer also requires a standalone codex binary on
   PATH; cli_path remains reported separately so the UI can still
   show whether a CLI is also installed.

Also added the matching positive-match regex line ("Authenticated:
Yes") for one more login-status wording the CLI ships in some
locales.

Tests: grown to 39 cases. New regressions cover the SDK-only install
gate, base_instructions kwarg priority + system fallback, the
fail-closed env scrub wrapper, the answer-only delta filter, the
Authenticated: Yes wording, and the CODEX_OPENAI_API_KEY-vs-
OPENAI_API_KEY split.
2026-05-24 16:20:27 +00:00
pre-commit-ci[bot]
593fc9edba [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 15:44:59 +00:00
Daniel Han
67b837e9f1 Merge branch 'feat/codex-provider' of https://github.com/unslothai/unsloth into feat/codex-provider 2026-05-24 15:44:11 +00:00
Daniel Han
4be807bbd8 Studio: round 4 hardening for the Codex provider
Fourth reviewer.py pass surfaced one more security finding and a
handful of correctness gaps. Each is small but the env-scrub for the
SDK path closes the asymmetric-fix loop opened in the previous round.

* Codex SDK construction now passes an `AppServerConfig(env=...)`
  that overrides every non-safe-listed env key to an empty string.
  Upstream openai/codex/sdk/python/client.py builds the spawn env as
  `os.environ.copy()` then `env.update(self.config.env)`, so this
  scrubs HF_TOKEN / GH_TOKEN / WANDB_API_KEY / ANTHROPIC_API_KEY etc.
  out of the codex app-server subprocess env on the chat / parallel
  / synthesis paths, matching the CLI/login paths from the previous
  round. The helper falls back to bare `AsyncCodex()` when the SDK
  version does not expose AppServerConfig, with logged warning.

* Install hint now names the actual upstream PyPI project,
  `openai-codex` (canonical), with `codex_app_server` documented as
  the legacy alias. The probe still accepts both import names so
  forward compat is preserved.

* Device-auth URL regex broadened to accept upstream's current
  `chatgpt.com/activate` shape and any `/device|/activate|/verify`
  variant, not just `/codex/device`. The frontend can now open the
  verification page on CLI builds that print the documented
  ChatGPT-style URL.

* `_run_codex_synthesis` now takes a `system` arg and forwards it
  to `thread_start(system=...)`, falling back to a prompt-prefix on
  older SDK revs that reject the kwarg. Previously a fan-out with
  "Always answer in Spanish" produced Spanish per-tab attempts but
  an English synthesis.

* `_detect_logged_in` negative regex now also matches "Not signed
  in", "Please sign in" (alternative localisations / future CLI
  releases). Same word-boundary anchoring as before.

* Frontend `CodexLoginEvent` union gains `device_code` and a `code`
  field. `CodexLoginButton` now renders the one-time code under the
  verification URL so users on a headless / remote install can copy
  the code without scraping the log pane. Also fixes a closure-stale
  bug where setError(message) was followed by a stale `error` read,
  losing specific backend errors; the new path keeps `lastStreamError`
  inside the closure.

* Replaced four hardcoded `/mnt/disks/...` paths in the new
  regression tests with `_backend_file()` resolved from `__file__`,
  so the suite runs in any checkout (CI, local dev, the review
  worker tree). Found by the round-4 reviewer.

Four new pytest cases pin the behaviour:
`test_not_signed_in_wording_also_handled`,
`test_device_url_accepts_generic_verification_url`,
`test_synthesis_call_forwards_system_prompt`, and
`test_sdk_env_scrubbed_via_appserverconfig`. 32/32 codex_provider
tests pass; `tsc --noEmit` clean.
2026-05-24 15:44:11 +00:00
pre-commit-ci[bot]
4b4c8553f8 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 15:16:52 +00:00
Daniel Han
fd8f25f507 Studio: scrub Codex subprocess env, guard partial-stream replay, abort login on unmount
Third reviewer.py pass found three remaining sharp edges. Each fix
is small and paired with a regression test where applicable.

* Codex subprocess env is now scrubbed to a safe-list before spawn.
  Both `_run_cli` in codex_availability and the device-auth spawn
  in stream_codex_device_login switch from `env=os.environ.copy()`
  to `env=_codex_subprocess_env()`, which forwards only PATH /
  HOME / USER / Windows-equivalents / CODEX_HOME / OPENAI_API_KEY /
  OPENAI_BASE_URL. Other-provider secrets like HF_TOKEN, GH_TOKEN,
  WANDB_API_KEY, ANTHROPIC_API_KEY no longer reach the local codex
  binary, so a shimmed `codex` earlier on PATH cannot harvest them.

* `_stream_thread_run` now tracks `emitted_any` and refuses to fall
  through to the buffered `await thread.run(prompt)` after either
  streaming helper has already yielded text. Previously a network
  glitch mid-stream re-executed the same Codex turn, which can
  duplicate file writes, shell commands, and other Codex side
  effects. The buffered path is now reserved for the zero-output
  case (no streaming helper resolved, or streaming returned empty).

* `CodexLoginButton` now aborts the SSE reader on unmount via a
  useEffect cleanup that calls `abortRef.current?.abort()`. The
  underlying `codex login --device-auth` subprocess no longer
  keeps streaming (and holding a device-auth session) after the
  dialog closes.

Two new pytest cases pin the behaviour: `test_codex_subprocess_env_scrubbed`
sets HF/GH/WANDB/ANTHROPIC keys and asserts none reach the codex
env while OPENAI_API_KEY / CODEX_HOME survive; and
`test_partial_stream_failure_does_not_replay_turn` injects a fake
`turn().stream()` that yields "partial output " then raises, and
asserts `thread.run()` is never called. 28/28 codex_provider tests
pass; `tsc --noEmit` clean.
2026-05-24 15:16:38 +00:00
Daniel Han
1f7cad4ccf Merge branch 'feat/codex-provider' of https://github.com/unslothai/unsloth into feat/codex-provider 2026-05-24 15:01:49 +00:00
Daniel Han
028b7b7187 Studio: finish Codex UI flow (test, sign-in, parallel tabs)
Second reviewer.py pass surfaced three follow-ups missed in the
earlier round. All caught by 12 parallel reviewers + cross-block
audit; each fix is small but user-facing.

* `testProvider` no longer pushes a Codex connection back to the
  edit form to "add an API key". Codex has no remote endpoint to
  ping, so the Test button now calls `/api/codex/status` directly:
  toasts success with the CLI version when installed+logged in,
  prompts to sign in when installed+logged out, and errors when
  the CLI or SDK is missing.

* The Sign-in to Codex affordance is now actually mounted. When
  the selected provider is Codex and `/api/codex/status` reports
  `installed:true, logged_in:false`, the dialog renders the new
  `CodexLoginButton` above the (hidden) API key row. The button's
  `onLoggedIn` callback re-probes status so the UI flips to the
  ready state without a page reload.

* The chat adapter now handles `codex_*` `_toolEvent` types
  instead of silently swallowing them. Per-tab chunks render
  inline with a `[Codex tab N/M]` header so users see each
  parallel attempt; `codex_gather` adds a `--- Synthesis ---`
  divider before the final unified content delta the backend
  also emits as plain text. This unblocks the existing fan-out
  path while a dedicated `CodexParallelTabs` UI is wired in a
  future change.

Verified: 26/26 codex_provider tests pass; `tsc --noEmit` on
studio/frontend completes clean.
2026-05-24 15:01:33 +00:00
pre-commit-ci[bot]
e2ac4907bf [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:46:17 +00:00
Daniel Han
d6c47f6664 Studio: wire Codex provider through the UI end-to-end
Post-review pass driven by reviewer.py. The original PR shipped the
backend codex provider, the registry entry (with `hidden:true`), the
status API, and the `CodexParallelTabs` component, but the chat UI
never surfaced the row, required an API key for the connection, and
never sent `parallel_calls` over the wire. Also fixes a CodeQL leak
in the parallel fan-out error path and adds the canonical streaming
hook upstream actually exposes.

Frontend
* chat-providers-dialog.tsx now calls `/api/codex/status` alongside
  `/api/providers/registry`. When the host has Codex installed the
  Add connection dialog gains a synthetic Codex row (curated model
  list comes from `supported_models`) so the picker is reachable.
* The Add / Edit connection guards now skip the API-key requirement
  for Codex the same way they do for the custom OpenAI-compat
  presets; the field itself is also hidden so the user is not asked
  for a key Studio will not use.
* chat-adapter.ts now also exempts Codex from the "Missing API key"
  pre-flight, and emits `parallel_calls` on the outgoing request
  when the selected connection is Codex (clamped to [1, 20] by the
  shared helper, defaults to 1).
* external-providers.ts adds `codexParallelCalls` to
  ExternalProviderConfig so future composer UI can persist the
  user's pick per connection.

Backend
* `_stream_thread_run` now tries `thread.turn(prompt).stream()`
  first, mirroring the canonical openai_codex API
  (`openai/codex/sdk/python/src/openai_codex/api.py`). The legacy
  `thread.run_streaming(prompt)` path is kept as a fallback and the
  buffered `await thread.run(prompt)` stays as the last resort.
* `_stream_codex_parallel` no longer echoes `str(exc)` in the
  `codex_tab_error` SSE event. Per-tab failures now surface a
  generic "Codex tab failed" message plus an `exception_type`
  discriminator; `CodexUnavailableError` is the only exception
  whose text is forwarded verbatim because it is a user-actionable
  install hint with no sensitive content (CodeQL
  `py/information-exposure-through-exception`).

Tests
* New `TestCodexHardenedRegressions::test_parallel_tab_error_sanitised`
  injects a fake SDK that raises with a path-like message and
  asserts the SSE frames do not echo it.
* New `TestCodexHardenedRegressions::test_thread_turn_stream_path_taken`
  verifies the canonical `thread.turn(prompt).stream()` hook is
  preferred over the legacy helper.

All 26 codex_provider tests pass. Frontend `tsc --noEmit` clean.
2026-05-24 14:46:03 +00:00
Daniel Han
0d904d615c Merge branch 'main' into feat/codex-provider 2026-05-24 14:25:41 +00:00
Daniel Han
b6577a6287 Studio: name the actual PyPI package in the Codex install hint
The OpenAI Codex Python SDK ships on PyPI as
`openai-codex-app-server-sdk`, not `openai-codex` (which is the
GitHub repo project name in pyproject.toml). The runtime binary
ships separately as `openai-codex-cli-bin`. Both packages expose
the import name `openai_codex`; the older docs reference
`codex_app_server` so we keep probing both.

Update the `CodexUnavailableError` message and the provider
registry notes so a user hitting the unavailable path gets a
copy-pasteable `pip install` command. No behaviour change.
2026-05-24 14:25:04 +00:00
pre-commit-ci[bot]
861da31fcf [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:13:24 +00:00
Daniel Han
b8cd677397 Studio: harden Codex provider against upstream CLI/SDK shape
Followups on the post-merge review pass for the Codex SDK chat
provider. Verified against codex-cli 0.133.0 + the upstream
`openai/codex` Rust + Python sources, then pinned each fix with
a regression test in `test_codex_provider.py` (24/24 passing).

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

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

Tested: pytest 24 cases in `test_codex_provider.py` (the original
14 + 10 new `TestCodexHardenedRegressions`) plus the rest of the
Studio-backend test suite the PR touches (209 passing). Also
verified live against Studio launched from this branch on a
Blackwell B200 via `UNSLOTH_STUDIO_HOME=$WORKSPACE/temp/...
./install.sh --local` then a Playwright probe.
2026-05-24 14:13:03 +00:00
Daniel Han
0a4309fefd Studio: add codex_router to test_desktop_auth router stub
test_health_response_reports_desktop_capability_fields builds a
SimpleNamespace as a fake routes module so it can exercise
main.health_check without standing the full app up. The stub
listed every router name except codex_router, which lands in the
main.py import block alongside the others as of this PR, so the
import failed with 'cannot import name codex_router from <unknown
module name>' on the Python 3.13 unit run.

Add the codex_router slot to the stub.
2026-05-23 14:00:31 +00:00
Daniel Han
4188f916f4 wip: anthropic citation helper 2026-05-23 14:00:31 +00:00
pre-commit-ci[bot]
ea7ae85562 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-23 14:00:31 +00:00
Daniel Han
cbc3c43655 Studio: add Codex SDK as a chat provider with parallel-calls fan-out
Wires the OpenAI Codex CLI / Python SDK (codex_app_server) into Studio
as a new chat provider type. Hosts that don't have the CLI or the SDK
installed never see the entry; on logged-out hosts the provider config
dialog renders a device-auth Sign-in button that surfaces the
verification URL and streams CLI progress back over SSE.

Backend
- new core/inference/codex_availability.py probes the CLI + SDK and
  reports {installed, logged_in, version, supported_models}; it never
  imports codex_app_server at module top level so the rest of the
  backend keeps starting cleanly on hosts that don't have the SDK.
- new core/inference/codex_provider.py wraps AsyncCodex and translates
  Codex events into OpenAI chat-completion chunks. Supports the
  thread.run_streaming path with a non-streaming fallback for older
  SDK revs.
- parallel_calls > 1 fans the turn out across N tasks (capped at 20)
  via asyncio.gather and emits codex_tab_open / codex_tab_chunk /
  codex_tab_close tool-events per attempt plus a final codex_gather
  synthesis event. A separate standalone Codex call produces the
  unified answer.
- new routes/codex.py exposes GET /api/codex/status and POST
  /api/codex/login. The login route shells out to
  codex auth login --device-auth and streams events; the first event
  carries the verification URL so the frontend can window.open it.
- ChatCompletionRequest gains a parallel_calls field bounded [1, 20]
  by pydantic. The codex registry entry stays hidden by default; the
  /api/codex/status probe is the authoritative gate.
- routes/inference.py dispatches provider_type=codex through the
  local CLI/SDK pipeline instead of the standard HTTP client, with
  graceful error surfacing for CodexUnavailableError.

Frontend
- new api/codex-api.ts exposes fetchCodexStatus() and an async
  generator streamCodexDeviceLogin() that drives the SSE stream and
  yields parsed events.
- new components/codex-parallel-tabs.tsx renders the tabbed parallel-
  calls UI with a Synthesis tab highlighted once the codex_gather
  event arrives. Pure reducer keeps the state transitions unit-
  testable.
- new components/codex-login-button.tsx posts to /api/codex/login,
  opens the verification URL in a new tab via window.open, and shows
  the streamed CLI log as it lands.
- external-providers.ts exports CODEX_PROVIDER_TYPE,
  CODEX_MAX_PARALLEL_CALLS, isCodexProviderType, and
  clampCodexParallelCalls. Codex is marked text-only so the composer
  hides image-attach affordances when selected.

Tests
- tests/test_codex_provider.py (14 cases) covers the availability
  probe across the four install / login states, the streaming +
  parallel-calls translation against a fake codex_app_server module
  injected into sys.modules, the [1, 20] pydantic clamp, the
  CodexUnavailableError surfacing path, and the parallel_calls=1
  single-call shape (no tab tool-events).
2026-05-23 14:00:31 +00:00
21 changed files with 6207 additions and 33 deletions

View 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

View 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

File diff suppressed because it is too large Load diff

View 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

View file

@ -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",

View file

@ -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"])

View file

@ -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.

View file

@ -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",
] ]

View 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",
},
)

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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(),

View file

@ -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,

View file

@ -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";

View file

@ -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,

View 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
}
}
}

View file

@ -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">

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>([