diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5e84058296..4ef00213562 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -854,7 +854,7 @@ jobs: name: ${{ matrix.checkName }} needs: [preflight] if: needs.preflight.outputs.run_checks_fast == 'true' - runs-on: ${{ github.repository == 'openclaw/openclaw' && needs.preflight.outputs.runner_4vcpu_ubuntu || 'ubuntu-24.04' }} + runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }} timeout-minutes: 60 strategy: fail-fast: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 52698c9a182..f3a987aea9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - CLI/migrate: add bulk on/off and skip controls to interactive Codex skill migration, leaving conflicting skill copies unchecked by default. (#77597) Thanks @kevinslin. - OpenAI/Codex media: advertise Codex audio transcription in runtime and manifest metadata and route active Codex chat models to the OpenAI transcription default instead of sending chat model ids to audio transcription. Thanks @vincentkoc. - Models/auth: add `openclaw models auth list [--provider ] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc. +- OpenAI/Codex: keep Codex OAuth on canonical `openai/*` model refs, preserve Codex route state during doctor repair, and keep direct OpenAI API-key mode on PI so switching auth modes does not accidentally reuse API billing credentials. Thanks @vincentkoc. - Cron CLI: add `openclaw cron list --agent `, normalize the requested agent id, and include jobs without a stored agent id under the configured default agent while keeping `cron list` unfiltered when no agent is supplied. Fixes #77118. Thanks @zhanggttry. - Status: show compact Gateway process uptime and host system uptime in `/status`, making restart and host-lifetime checks visible from chat. Thanks @vincentkoc. - Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant. @@ -166,6 +167,7 @@ Docs: https://docs.openclaw.ai - OpenRouter: keep the default `openrouter/auto` model ref canonical while preventing TUI and Control UI catalog pickers from displaying or submitting `openrouter/openrouter/auto`. Fixes #62655. - Status/Claude CLI: show `oauth (claude-cli)` for working Claude CLI OAuth runtime sessions instead of `unknown` when no local auth profile exists. Fixes #78632. Thanks @gorkem2020. - Memory search: preserve keyword-only hybrid FTS matches when vector scoring is unavailable or below the configured minimum score, so exact lexical hits are not dropped by weighted min-score filtering. +- OpenAI/Codex runtime: install or repair the Codex plugin when OpenAI/Codex models are selected during onboarding, model auth, CLI/default model changes, and session model pickers without forcing normal OpenAI routes off PI. - Exec approvals/node: let trusted backend node invokes complete no-device Control UI approvals after the original request connection changes, while keeping node, command, cwd, env, and allow-once replay bindings enforced. Fixes #78569. Thanks @naturedogdog. - Agents/subagents: keep background completion delivery on the requester-agent handoff/queue-retry path instead of raw-sending child results directly, and strip child-result wrapper or OpenClaw runtime-context scaffolding from queued outbound retries. Fixes #78531. Thanks @EthanSK. - CLI/completion: guard the shell-profile source line written by `openclaw completion --install` with a file existence check (`[ -f ... ] && source ...` for bash/zsh, `test -f ...; and source ...` for fish) so uninstalling OpenClaw no longer makes new login shells error on a missing completion cache. (#78659) Thanks @sjf. @@ -174,7 +176,7 @@ Docs: https://docs.openclaw.ai - Agent delivery: report `deliverySucceeded=false` when outbound delivery returns no adapter result, so claimed/empty delivery paths no longer masquerade as successful sends. Fixes #78532. Thanks @joeyfrasier. - Cron/isolated runs: fail implicit announce delivery before model execution when `delivery.channel=last` has no previous route, so recurring jobs do not spend tokens before hitting a permanent delivery-target error. Fixes #78608. Thanks @sallyom. - Gateway/sessions: persist a new generated transcript file when daily gateway-agent session rollover changes the session id, while preserving custom transcript paths. Fixes #78607. Thanks @nailujac, @zerone0x, and @sallyom. -- Doctor/OpenAI Codex: revert the 2026.5.5 `doctor --fix` repair that rewrote valid `openai-codex/*` ChatGPT/Codex OAuth routes to `openai/*`, which could break OAuth-only GPT-5.5 setups or accidentally move users onto the OpenAI API-key route. If 2026.5.5 already changed your default model, run `openclaw models set openai-codex/gpt-5.5 && openclaw config validate` to switch the default agent back to the Codex OAuth PI route. Fixes #78407. +- Doctor/OpenAI Codex: repair legacy `openai-codex/*` agent model refs to `openai/*` with the Codex runtime while leaving normal OpenAI PI runtime pins intact, preserving existing `openai-codex` auth profiles so ChatGPT/Codex OAuth users do not fall back to OpenAI API-key routing. Fixes #78407. Thanks @pashpashpash. - Telegram: keep the polling watchdog tied to `getUpdates` liveness so unrelated outbound Bot API calls cannot mask a wedged inbound poller. Fixes #78422. Thanks @ai-hpc. - Discord/groups: instruct group-chat agents to stay silent when a message is addressed to someone else, replying only when invited or correcting key facts. (#78615) - Discord/groups: tell Discord-channel agents to wrap bare URLs as `` so link previews do not expand into uninvited embeds. (#78614) diff --git a/docs/cli/models.md b/docs/cli/models.md index 1e617f685ce..c5b87195403 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -43,8 +43,8 @@ Probe rows can come from auth profiles, env credentials, or `models.json`. For Codex OAuth troubleshooting, `openclaw models status`, `openclaw models auth list --provider openai-codex`, and `openclaw config get agents.defaults.model --json` are the quickest way to -confirm whether an agent is using `openai-codex/*` through PI or `openai/*` -through the native Codex runtime. See [OpenAI provider setup](/providers/openai#check-and-recover-codex-oauth-routing). +confirm whether an agent has a usable `openai-codex` auth profile for +`openai/*` through the native Codex runtime. See [OpenAI provider setup](/providers/openai#check-and-recover-codex-oauth-routing). Notes: diff --git a/docs/concepts/agent-runtimes.md b/docs/concepts/agent-runtimes.md index bbae932462a..2e5c9a2f403 100644 --- a/docs/concepts/agent-runtimes.md +++ b/docs/concepts/agent-runtimes.md @@ -41,19 +41,19 @@ There are two runtime families: Most confusion comes from several different surfaces sharing the Codex name: -| Surface | OpenClaw name/config | What it does | -| ---------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| Native Codex app-server runtime | `openai/*` plus `agentRuntime.id: "codex"` | Runs the embedded agent turn through Codex app-server. This is the usual ChatGPT/Codex subscription setup. | -| Codex OAuth provider route | `openai-codex/*` model refs | Uses ChatGPT/Codex subscription OAuth through the normal OpenClaw PI runner. | -| Codex ACP adapter | `runtime: "acp"`, `agentId: "codex"` | Runs Codex through the external ACP/acpx control plane. Use only when ACP/acpx is explicitly asked. | -| Native Codex chat-control command set | `/codex ...` | Binds, resumes, steers, stops, and inspects Codex app-server threads from chat. | -| OpenAI Platform API route for GPT/Codex-style models | `openai/*` model refs | Uses OpenAI API-key auth unless a runtime override, such as `agentRuntime.id: "codex"`, runs the turn. | +| Surface | OpenClaw name/config | What it does | +| ------------------------------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| Native Codex app-server runtime | `openai/*` model refs | Runs OpenAI embedded agent turns through Codex app-server. This is the usual ChatGPT/Codex subscription setup. | +| Codex OAuth auth profiles | `openai-codex` auth provider | Stores ChatGPT/Codex subscription auth that the Codex app-server harness consumes. | +| Codex ACP adapter | `runtime: "acp"`, `agentId: "codex"` | Runs Codex through the external ACP/acpx control plane. Use only when ACP/acpx is explicitly asked. | +| Native Codex chat-control command set | `/codex ...` | Binds, resumes, steers, stops, and inspects Codex app-server threads from chat. | +| OpenAI Platform API route for non-agent surfaces | `openai/*` plus API-key auth | Used for direct OpenAI APIs such as images, embeddings, speech, and realtime. | Those surfaces are intentionally independent. Enabling the `codex` plugin makes -the native app-server features available; it does not rewrite -`openai-codex/*` into `openai/*`, does not change existing sessions, and does -not make ACP the Codex default. Selecting `openai-codex/*` means "use the Codex -OAuth provider route" unless you separately force a runtime. +the native app-server features available; `openclaw doctor --fix` owns legacy +`openai-codex/*` route repair and stale session pin cleanup. Selecting +`openai/*` for an agent model now means "run this through Codex" unless a +non-agent OpenAI API surface is being used. The common ChatGPT/Codex subscription setup uses Codex OAuth for auth, but keeps the model ref as `openai/*` and selects the `codex` runtime: @@ -63,9 +63,6 @@ the model ref as `openai/*` and selects the `codex` runtime: agents: { defaults: { model: "openai/gpt-5.5", - agentRuntime: { - id: "codex", - }, }, }, } @@ -88,10 +85,9 @@ This is the agent-facing decision tree: 1. If the user asks for **Codex bind/control/thread/resume/steer/stop**, use the native `/codex` command surface when the bundled `codex` plugin is enabled. 2. If the user asks for **Codex as the embedded runtime** or wants the normal - subscription-backed Codex agent experience, use - `openai/` with `agentRuntime.id: "codex"`. -3. If the user asks for **Codex OAuth/subscription auth on the normal OpenClaw - runner**, use `openai-codex/` and leave the runtime as PI. + subscription-backed Codex agent experience, use `openai/`. +3. If legacy config still contains **`openai-codex/*` model refs**, repair it to + `openai/` with `openclaw doctor --fix`. 4. If the user explicitly says **ACP**, **acpx**, or **Codex ACP adapter**, use ACP with `runtime: "acp"` and `agentId: "codex"`. 5. If the request is for **Claude Code, Gemini CLI, OpenCode, Cursor, Droid, or @@ -100,8 +96,8 @@ This is the agent-facing decision tree: | You mean... | Use... | | --------------------------------------- | -------------------------------------------- | | Codex app-server chat/thread control | `/codex ...` from the bundled `codex` plugin | -| Codex app-server embedded agent runtime | `agentRuntime.id: "codex"` | -| OpenAI Codex OAuth on the PI runner | `openai-codex/*` model refs | +| Codex app-server embedded agent runtime | `openai/*` agent model refs | +| OpenAI Codex OAuth | `openai-codex` auth profiles | | Claude Code or other external harness | ACP/acpx | For the OpenAI-family prefix split, see [OpenAI](/providers/openai) and @@ -166,17 +162,14 @@ Legacy refs such as `claude-cli/claude-opus-4-7` remain supported for compatibility, but new config should keep the provider/model canonical and put the execution backend in `agentRuntime.id`. -`auto` mode is intentionally conservative. Plugin runtimes can claim -provider/model pairs they understand, but the Codex plugin does not claim the -`openai-codex` provider in `auto` mode. That keeps -`openai-codex/*` as the explicit PI Codex OAuth route and avoids silently -moving subscription-auth configs onto the native app-server harness. +`auto` mode is intentionally conservative for most providers. OpenAI agent +models are the exception: unset runtime and `auto` both resolve to the Codex +harness, while explicit PI runtime config is rejected for `openai/*` agent +turns. If `openclaw doctor` warns that the `codex` plugin is enabled while -`openai-codex/*` still routes through PI, treat that as a diagnosis, not a -migration. Keep the config unchanged when PI Codex OAuth is what you want. -Switch to `openai/` plus `agentRuntime.id: "codex"` only when you want native -Codex app-server execution. +`openai-codex/*` remains in config, treat that as legacy route state. Run +`openclaw doctor --fix` to rewrite it to `openai/*` with the Codex runtime. ## Compatibility contract diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index f48c08b1f66..11042add5cb 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -426,10 +426,10 @@ model, see [Agent runtimes](/concepts/agent-runtimes). - `id`: `"auto"`, `"pi"`, a registered plugin harness id, or a supported CLI backend alias. The bundled Codex plugin registers `codex`; the bundled Anthropic plugin provides the `claude-cli` CLI backend. - `id: "auto"` lets registered plugin harnesses claim supported turns and uses PI when no harness matches. An explicit plugin runtime such as `id: "codex"` requires that harness and fails closed if it is unavailable or fails. - Environment override: `OPENCLAW_AGENT_RUNTIME=` overrides `id` for that process. -- For Codex-only deployments, set `model: "openai/gpt-5.5"` and `agentRuntime.id: "codex"`. +- OpenAI agent models use the Codex harness by default; `agentRuntime.id: "codex"` remains valid when you want to make that explicit. - For Claude CLI deployments, prefer `model: "anthropic/claude-opus-4-7"` plus `agentRuntime.id: "claude-cli"`. Legacy `claude-cli/claude-opus-4-7` model refs still work for compatibility, but new config should keep provider/model selection canonical and put the execution backend in `agentRuntime.id`. - Older runtime-policy keys are rewritten to `agentRuntime` by `openclaw doctor --fix`. -- Harness choice is pinned per session id after the first embedded run. Config/env changes affect new or reset sessions, not an existing transcript. Legacy sessions with transcript history but no recorded pin are treated as PI-pinned. `/status` reports the effective runtime, for example `Runtime: OpenClaw Pi Default` or `Runtime: OpenAI Codex`. +- Harness choice is pinned per session id after the first embedded run. Config/env changes affect new or reset sessions, not an existing transcript. Legacy OpenAI sessions with transcript history but no recorded pin use Codex; stale OpenAI PI pins can be repaired with `openclaw doctor --fix`. `/status` reports the effective runtime, for example `Runtime: OpenClaw Pi Default` or `Runtime: OpenAI Codex`. - This only controls text agent-turn execution. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings. **Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`): diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index c43e5fd5651..fa89914bf1d 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -264,7 +264,7 @@ That stages grounded durable candidates into the short-term dreaming store while If you previously added legacy OpenAI transport settings under `models.providers.openai-codex`, they can shadow the built-in Codex OAuth provider path that newer releases use automatically. Doctor warns when it sees those old transport settings alongside Codex OAuth so you can remove or rewrite the stale transport override and get the built-in routing/fallback behavior back. Custom proxies and header-only overrides are still supported and do not trigger this warning. - Doctor checks for legacy `openai-codex/*` model refs. Native Codex harness routing uses canonical `openai/*` model refs plus `agentRuntime.id: "codex"` so the turn goes through the Codex app-server harness instead of the OpenClaw PI OpenAI path. + Doctor checks for legacy `openai-codex/*` model refs. Native Codex harness routing uses canonical `openai/*` model refs; OpenAI agent turns go through the Codex app-server harness instead of the OpenClaw PI OpenAI path. In `--fix` / `--repair` mode, doctor rewrites affected default-agent and per-agent refs, including primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale persisted session route state: diff --git a/docs/help/faq-first-run.md b/docs/help/faq-first-run.md index 1dc95afef79..05b774112e0 100644 --- a/docs/help/faq-first-run.md +++ b/docs/help/faq-first-run.md @@ -597,26 +597,26 @@ and troubleshooting see the main [FAQ](/help/faq). `openai/gpt-5.5` with `agentRuntime.id: "codex"` for the common setup: ChatGPT/Codex subscription auth plus native Codex app-server execution. Use `openai-codex/gpt-5.5` only when you want Codex OAuth through the default - PI runner. Use `openai/gpt-5.5` without the Codex runtime override for - direct OpenAI API-key access. + Codex runtime. Direct OpenAI API-key access remains available for non-agent + OpenAI API surfaces and for agent models through an ordered + `openai-codex` API-key profile. See [Model providers](/concepts/model-providers) and [Onboarding (CLI)](/start/wizard). `openai-codex` is the provider and auth-profile id for ChatGPT/Codex OAuth. - It is also the explicit PI model prefix for Codex OAuth: + Older configs also used it as a model prefix: - - `openai/gpt-5.5` + `agentRuntime.id: "codex"` = ChatGPT/Codex subscription auth with native Codex runtime - - `openai-codex/gpt-5.5` = Codex OAuth route in PI - - `openai/gpt-5.5` without a Codex runtime override = direct OpenAI API-key route in PI + - `openai/gpt-5.5` = ChatGPT/Codex subscription auth with native Codex runtime for agent turns + - `openai-codex/gpt-5.5` = legacy model route repaired by `openclaw doctor --fix` + - `openai/gpt-5.5` plus an ordered `openai-codex` API-key profile = API-key auth for an OpenAI agent model - `openai-codex:...` = auth profile id, not a model ref If you want the direct OpenAI Platform billing/limit path, set `OPENAI_API_KEY`. If you want ChatGPT/Codex subscription auth, sign in with - `openclaw models auth login --provider openai-codex`. For native Codex - runtime, keep the model ref as `openai/gpt-5.5` and set - `agentRuntime.id: "codex"`. Use `openai-codex/*` model refs only for PI - runs. + `openclaw models auth login --provider openai-codex`. Keep the model ref as + `openai/gpt-5.5`; `openai-codex/*` model refs are legacy config that + `openclaw doctor --fix` rewrites. diff --git a/docs/help/faq-models.md b/docs/help/faq-models.md index 1cb31008742..41282607e31 100644 --- a/docs/help/faq-models.md +++ b/docs/help/faq-models.md @@ -147,9 +147,9 @@ troubleshooting, see the main [FAQ](/help/faq). Yes. Treat model choice and runtime choice separately: - - **Native Codex coding agent:** set `agents.defaults.model.primary` to `openai/gpt-5.5` and `agents.defaults.agentRuntime.id` to `"codex"`. Sign in with `openclaw models auth login --provider openai-codex` when you want ChatGPT/Codex subscription auth. - - **Direct OpenAI API tasks through PI:** use `/model openai/gpt-5.5` without a Codex runtime override and configure `OPENAI_API_KEY`. - - **Codex OAuth through PI:** use `/model openai-codex/gpt-5.5` only when you intentionally want the normal PI runner with Codex OAuth. + - **Native Codex coding agent:** set `agents.defaults.model.primary` to `openai/gpt-5.5`. Sign in with `openclaw models auth login --provider openai-codex` when you want ChatGPT/Codex subscription auth. + - **Direct OpenAI API tasks outside the agent loop:** configure `OPENAI_API_KEY` for images, embeddings, speech, realtime, and other non-agent OpenAI API surfaces. + - **OpenAI agent API-key auth:** use `/model openai/gpt-5.5` with an ordered `openai-codex` API-key profile. - **Sub-agents:** route coding tasks to a Codex-only agent with its own model and `agentRuntime` default. See [Models](/concepts/models) and [Slash commands](/tools/slash-commands). diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index ba9df295729..1c3520c97db 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -106,10 +106,9 @@ The bundled `codex` plugin contributes several separate capabilities: Enabling the plugin makes those capabilities available. It does **not**: -- start using Codex for every OpenAI model -- convert `openai-codex/*` model refs into the native runtime without doctor - verifying that Codex is installed, enabled, contributes the `codex` harness, - and is OAuth-ready +- replace direct OpenAI API-key surfaces such as images, embeddings, speech, or + realtime +- convert `openai-codex/*` model refs without `openclaw doctor --fix` - make ACP/acpx the default Codex path - hot-switch existing sessions that already recorded a PI runtime - replace OpenClaw channel delivery, session files, auth-profile storage, or @@ -141,29 +140,28 @@ tool-result writes. For the plugin hook semantics themselves, see [Plugin hooks](/plugins/hooks) and [Plugin guard behavior](/tools/plugin). -The harness is off by default. New configs should keep OpenAI model refs -canonical as `openai/gpt-*` and explicitly force -`agentRuntime.id: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex` when they -want native app-server execution. Legacy `codex/*` model refs still auto-select -the harness for compatibility, but runtime-backed legacy provider prefixes are -not shown as normal model/provider choices. +OpenAI agent model refs use the harness by default. New configs should keep +OpenAI model refs canonical as `openai/gpt-*`; `agentRuntime.id: "codex"` is +still valid but no longer required for OpenAI agent turns. Legacy `codex/*` +model refs still auto-select the harness for compatibility, but +runtime-backed legacy provider prefixes are not shown as normal model/provider +choices. If any configured model route is still `openai-codex/*`, `openclaw doctor --fix` rewrites it to `openai/*`. For matching agent routes, it sets the agent runtime -to `codex` only when the Codex plugin is installed, enabled, contributes the -`codex` harness, and has usable OAuth; otherwise it sets the runtime to `pi`. +to `codex` and preserves existing `openai-codex` auth profile overrides. ## Route map Use this table before changing config: -| Desired behavior | Model ref | Runtime config | Auth/profile route | Expected status label | -| ---------------------------------------------------- | -------------------------- | -------------------------------------- | ---------------------------- | ------------------------------ | -| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-*` | `agentRuntime.id: "codex"` | Codex OAuth or Codex account | `Runtime: OpenAI Codex` | -| OpenAI API through normal OpenClaw runner | `openai/gpt-*` | omitted or `runtime: "pi"` | OpenAI API key | `Runtime: OpenClaw Pi Default` | -| Legacy config that needs doctor repair | `openai-codex/gpt-*` | repaired to `codex` or `pi` | Existing configured auth | Recheck after `doctor --fix` | -| Mixed providers with conservative auto mode | provider-specific refs | `agentRuntime.id: "auto"` | Per selected provider | Depends on selected runtime | -| Explicit Codex ACP adapter session | ACP prompt/model dependent | `sessions_spawn` with `runtime: "acp"` | ACP backend auth | ACP task/session status | +| Desired behavior | Model ref | Runtime config | Auth/profile route | Expected status label | +| ---------------------------------------------------- | -------------------------- | -------------------------------------- | ------------------------------ | ---------------------------- | +| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-*` | omitted or `agentRuntime.id: "codex"` | Codex OAuth or Codex account | `Runtime: OpenAI Codex` | +| OpenAI API-key auth for agent models | `openai/gpt-*` | omitted or `agentRuntime.id: "codex"` | `openai-codex` API-key profile | `Runtime: OpenAI Codex` | +| Legacy config that needs doctor repair | `openai-codex/gpt-*` | repaired to `codex` | Existing configured auth | Recheck after `doctor --fix` | +| Mixed providers with conservative auto mode | provider-specific refs | `agentRuntime.id: "auto"` | Per selected provider | Depends on selected runtime | +| Explicit Codex ACP adapter session | ACP prompt/model dependent | `sessions_spawn` with `runtime: "acp"` | ACP backend auth | ACP task/session status | The important split is provider versus runtime: @@ -171,8 +169,7 @@ The important split is provider versus runtime: - `agentRuntime.id: "codex"` requires the Codex harness and fails closed if it is unavailable. - `agentRuntime.id: "auto"` lets registered harnesses claim matching provider - routes, but canonical OpenAI refs are still PI-owned unless a harness supports - that provider/model pair. + routes; OpenAI agent refs resolve to Codex instead of PI. - `/codex ...` answers "which native Codex conversation should this chat bind or control?" - ACP answers "which external harness process should acpx launch?" @@ -180,14 +177,14 @@ The important split is provider versus runtime: ## Pick the right model prefix OpenAI-family routes are prefix-specific. For the common subscription plus -native Codex runtime setup, use `openai/*` with `agentRuntime.id: "codex"`. +native Codex runtime setup, use `openai/*`. Treat `openai-codex/*` as legacy config that doctor should rewrite: -| Model ref | Runtime path | Use when | -| --------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- | -| `openai/gpt-5.4` | OpenAI provider through OpenClaw/PI plumbing | You want current direct OpenAI Platform API access with `OPENAI_API_KEY`. | -| `openai-codex/gpt-5.5` | Legacy route repaired by doctor | You are on old config; run `openclaw doctor --fix` to rewrite it. | -| `openai/gpt-5.5` + `agentRuntime.id: "codex"` | Codex app-server harness | You want ChatGPT/Codex subscription auth with native Codex execution. | +| Model ref | Runtime path | Use when | +| ------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------- | +| `openai/gpt-5.4` | Codex app-server harness for agent turns | You want OpenAI agent models through Codex. | +| `openai-codex/gpt-5.5` | Legacy route repaired by doctor | You are on old config; run `openclaw doctor --fix` to rewrite it. | +| `openai/gpt-5.5` + `openai-codex` API-key profile | Codex app-server harness | You want API-key auth for an OpenAI agent model. | GPT-5.5 can appear on both direct OpenAI API-key and Codex subscription routes when your account exposes them. Use `openai/gpt-5.5` with the Codex app-server @@ -219,13 +216,10 @@ state still use `openai-codex/*`. `openclaw doctor --fix` rewrites those routes to: - `openai/` -- `agentRuntime.id: "codex"` when Codex is installed, enabled, contributes the - `codex` harness, and has usable OAuth -- `agentRuntime.id: "pi"` otherwise +- `agentRuntime.id: "codex"` -The `codex` route forces the native Codex harness. The `pi` route keeps the -agent on the default OpenClaw runner instead of enabling or installing Codex as -a side effect of legacy-route cleanup. +The `codex` route forces the native Codex harness. PI runtime config is not +allowed for OpenAI agent model turns. Doctor also repairs stale persisted session pins across discovered agent session stores so old conversations do not stay wedged on the removed route. @@ -349,7 +343,7 @@ Agents should route user requests by intent, not by the word "Codex" alone: | "Show Codex threads" | `/codex threads` | | "File a support report for a bad Codex run" | `/diagnostics [note]` | | "Only send Codex feedback for this attached thread" | `/codex diagnostics [note]` | -| "Use my ChatGPT/Codex subscription with Codex runtime" | `openai/*` plus `agentRuntime.id: "codex"` | +| "Use my ChatGPT/Codex subscription with Codex runtime" | `openai/*` | | "Repair old `openai-codex/*` config/session pins" | `openclaw doctor --fix` | | "Run Codex through ACP/acpx" | ACP `sessions_spawn({ runtime: "acp", ... })` | | "Start Claude Code/Gemini/OpenCode/Cursor in a thread" | ACP/acpx, not `/codex` and not native sub-agents | diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 02f230b0af7..6f9e802649f 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -194,9 +194,10 @@ intentional silent replies such as `NO_REPLY` unclassified. The bundled `codex` harness is the native Codex mode for embedded OpenClaw agent turns. Enable the bundled `codex` plugin first, and include `codex` in `plugins.allow` if your config uses a restrictive allowlist. Native app-server -configs should use `openai/gpt-*` with `agentRuntime.id: "codex"`. -Use `openai-codex/*` for Codex OAuth through PI instead. Legacy `codex/*` -model refs remain compatibility aliases for the native harness. +configs should use `openai/gpt-*`; OpenAI agent turns select the Codex harness +by default. Legacy `openai-codex/*` routes should be repaired with +`openclaw doctor --fix`, and legacy `codex/*` model refs remain compatibility +aliases for the native harness. When this mode runs, Codex owns the native thread id, resume behavior, compaction, and app-server execution. OpenClaw still owns the chat channel, diff --git a/docs/providers/openai.md b/docs/providers/openai.md index a2e42bf3af1..6091265f562 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -11,14 +11,18 @@ OpenAI provides developer APIs for GPT models, and Codex is also available as a ChatGPT-plan coding agent through OpenAI's Codex clients. OpenClaw keeps those surfaces separate so config stays predictable. -OpenClaw supports three OpenAI-family routes. Most ChatGPT/Codex subscribers -who want Codex behavior should use the native Codex app-server runtime. The -model prefix selects the provider/model name; a separate runtime setting selects -who executes the embedded agent loop: +OpenClaw uses `openai/*` as the canonical OpenAI model route. Embedded agent +turns on OpenAI models run through the native Codex app-server runtime by +default; direct OpenAI API-key auth remains available for non-agent OpenAI +surfaces such as images, embeddings, speech, and realtime. -- **API key** - direct OpenAI Platform access with usage-based billing (`openai/*` models) -- **Codex subscription with native Codex runtime** - ChatGPT/Codex sign-in plus Codex app-server execution (`openai/*` models plus `agents.defaults.agentRuntime.id: "codex"`) -- **Codex subscription through PI** - ChatGPT/Codex sign-in with the normal OpenClaw PI runner (`openai-codex/*` models) +- **Agent models** - `openai/*` models through the Codex runtime; sign in with + `openai-codex` auth for ChatGPT/Codex subscription use, or configure an + `openai-codex` API-key profile when you intentionally want API-key auth. +- **Non-agent OpenAI APIs** - direct OpenAI Platform access with usage-based + billing through `OPENAI_API_KEY` or OpenAI API-key onboarding. +- **Legacy config** - `openai-codex/*` model refs are repaired by + `openclaw doctor --fix` to `openai/*` plus the Codex runtime. OpenAI explicitly supports subscription OAuth usage in external tools and workflows like OpenClaw. @@ -28,47 +32,42 @@ changing config. ## Quick choice -| Goal | Use | Notes | -| ---------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------- | -| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-5.5` plus `agentRuntime.id: "codex"` | Recommended Codex setup for most users. Sign in with `openai-codex` auth. | -| Direct API-key billing | `openai/gpt-5.5` | Set `OPENAI_API_KEY` or run OpenAI API-key onboarding. | -| ChatGPT/Codex subscription auth through PI | `openai-codex/gpt-5.5` | Use only when you intentionally want the normal PI runner. | -| Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. | -| Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. | +| Goal | Use | Notes | +| ---------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------- | +| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-5.5` | Default OpenAI agent setup. Sign in with `openai-codex` auth. | +| Direct API-key billing for agent models | `openai/gpt-5.5` plus an `openai-codex` API-key profile | Use `auth.order.openai-codex` to prefer that profile. | +| Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. | +| Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. | ## Naming map The names are similar but not interchangeable: -| Name you see | Layer | Meaning | -| ---------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------- | -| `openai` | Provider prefix | Direct OpenAI Platform API route. | -| `openai-codex` | Provider prefix | OpenAI Codex OAuth/subscription route through the normal OpenClaw PI runner. | -| `codex` plugin | Plugin | Bundled OpenClaw plugin that provides native Codex app-server runtime and `/codex` chat controls. | -| `agentRuntime.id: codex` | Agent runtime | Force the native Codex app-server harness for embedded turns. | -| `/codex ...` | Chat command set | Bind/control Codex app-server threads from a conversation. | -| `runtime: "acp", agentId: "codex"` | ACP session route | Explicit fallback path that runs Codex through ACP/acpx. | +| Name you see | Layer | Meaning | +| ---------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- | +| `openai` | Provider prefix | Canonical OpenAI model route; agent turns use the Codex runtime. | +| `openai-codex` | Auth/profile prefix | OpenAI Codex OAuth/subscription auth profile provider. | +| `codex` plugin | Plugin | Bundled OpenClaw plugin that provides native Codex app-server runtime and `/codex` chat controls. | +| `agentRuntime.id: codex` | Agent runtime | Force the native Codex app-server harness for embedded turns. | +| `/codex ...` | Chat command set | Bind/control Codex app-server threads from a conversation. | +| `runtime: "acp", agentId: "codex"` | ACP session route | Explicit fallback path that runs Codex through ACP/acpx. | -This means a config can intentionally contain both `openai-codex/*` and the -`codex` plugin. That is valid when you want Codex OAuth through PI and also want -native `/codex` chat controls available. `openclaw doctor` warns about that -combination so you can confirm it is intentional; it does not rewrite it. +This means a config can intentionally contain both `openai/*` model refs and +`openai-codex` auth profiles. `openclaw doctor --fix` rewrites legacy +`openai-codex/*` model refs to the canonical OpenAI model route. GPT-5.5 is available through both direct OpenAI Platform API-key access and subscription/OAuth routes. For ChatGPT/Codex subscription plus native Codex -execution, use `openai/gpt-5.5` with `agentRuntime.id: "codex"`. Use -`openai-codex/gpt-5.5` only for Codex OAuth through PI, or `openai/gpt-5.5` -without a Codex runtime override for direct `OPENAI_API_KEY` traffic. +execution, use `openai/gpt-5.5`; unset runtime config now selects the Codex +harness for OpenAI agent turns. Use OpenAI API-key profiles only when you want +direct API-key auth for an OpenAI agent model. -Enabling the OpenAI plugin, or selecting an `openai-codex/*` model, does not -enable the bundled Codex app-server plugin. OpenClaw enables that plugin only -when you explicitly select the native Codex harness with -`agentRuntime.id: "codex"` or use a legacy `codex/*` model ref. -If the bundled `codex` plugin is enabled but `openai-codex/*` still resolves -through PI, `openclaw doctor` warns and leaves the route unchanged. +OpenAI agent model turns require the bundled Codex app-server plugin. Explicit +PI runtime config for OpenAI models is rejected; run `openclaw doctor --fix` to +repair stale `openai-codex/*` model refs or old PI session pins. ## OpenClaw feature coverage @@ -145,15 +144,14 @@ Choose your preferred auth method and follow the setup steps. | Model ref | Runtime config | Route | Auth | | ---------------------- | -------------------------- | --------------------------- | ---------------- | - | `openai/gpt-5.5` | omitted / `agentRuntime.id: "pi"` | Direct OpenAI Platform API | `OPENAI_API_KEY` | - | `openai/gpt-5.4-mini` | omitted / `agentRuntime.id: "pi"` | Direct OpenAI Platform API | `OPENAI_API_KEY` | - | `openai/gpt-5.5` | `agentRuntime.id: "codex"` | Codex app-server harness | Codex app-server | + | `openai/gpt-5.5` | omitted / `agentRuntime.id: "codex"` | Codex app-server harness | `openai-codex` profile | + | `openai/gpt-5.4-mini` | omitted / `agentRuntime.id: "codex"` | Codex app-server harness | `openai-codex` profile | - `openai/*` is the direct OpenAI API-key route unless you explicitly force - the Codex app-server harness. Use `openai-codex/*` for Codex OAuth through - the default PI runner, or use `openai/gpt-5.5` with - `agentRuntime.id: "codex"` for native Codex app-server execution. + `openai/*` agent models use the Codex app-server harness. To use API-key + auth for an agent model, create an `openai-codex` API-key profile and order + it with `auth.order.openai-codex`; `OPENAI_API_KEY` remains the direct + fallback for non-agent OpenAI API surfaces. ### Config example @@ -213,26 +211,21 @@ Choose your preferred auth method and follow the setup steps. | Model ref | Runtime config | Route | Auth | |-----------|----------------|-------|------| - | `openai/gpt-5.5` | `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in or selected `openai-codex` profile | - | `openai-codex/gpt-5.5` | omitted / `runtime: "pi"` | ChatGPT/Codex OAuth through PI | Codex sign-in | - | `openai-codex/gpt-5.4-mini` | omitted / `runtime: "pi"` | ChatGPT/Codex OAuth through PI | Codex sign-in | - | `openai-codex/gpt-5.5` | `runtime: "auto"` | Still PI unless a plugin explicitly claims `openai-codex` | Codex sign-in | + | `openai/gpt-5.5` | omitted / `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in or selected `openai-codex` profile | + | `openai-codex/gpt-5.5` | repaired by doctor | Legacy route rewritten to `openai/gpt-5.5` | Existing `openai-codex` profile | Do not configure older `openai-codex/gpt-5.1*`, `openai-codex/gpt-5.2*`, or `openai-codex/gpt-5.3*` model refs. ChatGPT/Codex OAuth accounts now reject - those models. Use `openai-codex/gpt-5.5` for the PI OAuth route, or - `openai/gpt-5.5` with `agentRuntime.id: "codex"` for native Codex runtime - execution. + those models. Use `openai/gpt-5.5`; OpenAI agent turns now select the Codex + runtime by default. Keep using the `openai-codex` provider id for auth/profile commands. The - `openai-codex/*` model prefix is also the explicit PI route for Codex OAuth. - It does not select or auto-enable the bundled Codex app-server harness. For - the common subscription plus native runtime setup, sign in with - `openai-codex` but keep the model ref as `openai/gpt-5.5` and set - `agentRuntime.id: "codex"`. + `openai-codex/*` model prefix is legacy config repaired by doctor. For the + common subscription plus native runtime setup, sign in with `openai-codex` + but keep the model ref as `openai/gpt-5.5`. ### Config example @@ -249,9 +242,6 @@ Choose your preferred auth method and follow the setup steps. } ``` - To keep Codex OAuth on the normal PI runner instead, use - `openai-codex/gpt-5.5` and omit the Codex runtime override. - Onboarding no longer imports OAuth material from `~/.codex`. Sign in with browser OAuth (default) or the device-code flow above — OpenClaw manages the resulting credentials in its own agent auth store. @@ -275,12 +265,11 @@ Choose your preferred auth method and follow the setup steps. openclaw models auth list --agent --provider openai-codex ``` - If a 2026.5.5 `doctor --fix` run changed a GPT-5.5 subscription setup from - `openai-codex/gpt-5.5` to `openai/gpt-5.5`, switch the default agent back - to the Codex OAuth PI route: + If an older config still has `openai-codex/gpt-*` or a stale OpenAI PI + session pin, repair it: ```bash - openclaw models set openai-codex/gpt-5.5 + openclaw doctor --fix openclaw config validate ``` @@ -292,25 +281,21 @@ Choose your preferred auth method and follow the setup steps. openclaw models status --probe --probe-provider openai-codex ``` - `openai-codex/*` means ChatGPT/Codex OAuth through PI. `openai/*` with - `agentRuntime.id: "codex"` means native Codex app-server execution. + `openai-codex` remains the auth/profile provider id. `openai/*` is the + model route for OpenAI agent turns through Codex. ### Status indicator Chat `/status` shows which model runtime is active for the current session. - The default PI harness appears as `Runtime: OpenClaw Pi Default`. When the - bundled Codex app-server harness is selected, `/status` shows - `Runtime: OpenAI Codex`. Existing sessions keep their recorded harness id, so use - `/new` or `/reset` after changing `agentRuntime` if you want `/status` to - reflect a new PI/Codex choice. + The bundled Codex app-server harness appears as `Runtime: OpenAI Codex` for + OpenAI agent model turns. Existing sessions with stale PI pins should be + repaired with `openclaw doctor --fix` before continuing. ### Doctor warning - If the bundled `codex` plugin is enabled while an `openai-codex/*` route is - selected, `openclaw doctor` warns that the model still resolves through PI. - Keep the config unchanged only when that PI subscription-auth route is - intentional. Switch to `openai/` plus `agentRuntime.id: "codex"` when - you want native Codex app-server execution. + If `openai-codex/*` routes or OpenAI PI pins remain in config or session + state, `openclaw doctor --fix` rewrites them to `openai/*` with the Codex + runtime. ### Context window cap diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index bec9d0b618d..e09f2cbacd0 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -184,8 +184,8 @@ Quick `/acp` flow from chat: - - `openai-codex/*` - PI Codex OAuth/subscription route. - - `openai/*` plus `agentRuntime.id: "codex"` - native Codex app-server embedded runtime. + - `openai-codex/*` - legacy Codex OAuth/subscription model route repaired by doctor. + - `openai/*` - native Codex app-server embedded runtime for OpenAI agent turns. - `/codex ...` - native Codex conversation control. - `/acp ...` or `runtime: "acp"` - explicit ACP/acpx control. diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 298b1702c03..a603f8446df 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -992,6 +992,7 @@ export async function runCodexAppServerAttempt( return refreshCodexAppServerAuthTokens({ agentDir, authProfileId: startupAuthProfileId, + config: params.config, }); } if (!turnId) { diff --git a/extensions/openai/cli-backend.ts b/extensions/openai/cli-backend.ts index 9876d89852e..1e302d763c2 100644 --- a/extensions/openai/cli-backend.ts +++ b/extensions/openai/cli-backend.ts @@ -5,6 +5,13 @@ import { } from "openclaw/plugin-sdk/cli-backend"; const CODEX_CLI_DEFAULT_MODEL_REF = "codex-cli/gpt-5.5"; +export const OPENAI_CODEX_CLI_CLEAR_ENV = [ + "CODEX_API_KEY", + "OPENAI_API_KEY", + "OPENAI_CODEX_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_API_BASE_URL", +] as const; export function buildOpenAICodexCliBackend(): CliBackendPlugin { return { @@ -56,6 +63,7 @@ export function buildOpenAICodexCliBackend(): CliBackendPlugin { imageArg: "--image", imageMode: "repeat", imagePathScope: "workspace", + clearEnv: [...OPENAI_CODEX_CLI_CLEAR_ENV], reliability: { watchdog: { fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS }, diff --git a/extensions/openai/default-models.test.ts b/extensions/openai/default-models.test.ts index 4a57db69339..01166d8f129 100644 --- a/extensions/openai/default-models.test.ts +++ b/extensions/openai/default-models.test.ts @@ -24,6 +24,7 @@ describe("openai default models", () => { it("sets the default model when it is unset", () => { const next = applyOpenAIConfig({}); expect(next.agents?.defaults?.model).toEqual({ primary: OPENAI_DEFAULT_MODEL }); + expect(next.agents?.defaults?.agentRuntime).toEqual({ id: "pi" }); }); it("overrides model.primary while preserving fallbacks", () => { diff --git a/extensions/openai/default-models.ts b/extensions/openai/default-models.ts index 7034c2053c0..5ae849f0248 100644 --- a/extensions/openai/default-models.ts +++ b/extensions/openai/default-models.ts @@ -5,7 +5,7 @@ import { } from "openclaw/plugin-sdk/provider-onboard"; export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.5"; -export const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.5"; +export const OPENAI_CODEX_DEFAULT_MODEL = OPENAI_DEFAULT_MODEL; export const OPENAI_DEFAULT_IMAGE_MODEL = "gpt-image-2"; export const OPENAI_DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"; export const OPENAI_DEFAULT_TTS_VOICE = "alloy"; @@ -36,5 +36,15 @@ export function applyOpenAIProviderConfig(cfg: OpenClawConfig): OpenClawConfig { } export function applyOpenAIConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyAgentDefaultModelPrimary(applyOpenAIProviderConfig(cfg), OPENAI_DEFAULT_MODEL); + const next = applyAgentDefaultModelPrimary(applyOpenAIProviderConfig(cfg), OPENAI_DEFAULT_MODEL); + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + agentRuntime: { id: "pi" }, + }, + }, + }; } diff --git a/extensions/openai/openai-codex-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index 75965a61041..f477e68b6fe 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -229,7 +229,17 @@ describe("openai codex provider", () => { }, }, ], - defaultModel: "openai-codex/gpt-5.5", + defaultModel: "openai/gpt-5.5", + configPatch: { + agents: { + defaults: { + agentRuntime: { id: "codex" }, + models: { + "openai/gpt-5.5": {}, + }, + }, + }, + }, }); expect(result?.profiles[0]?.credential).not.toHaveProperty("idToken"); }); diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 17e7341e87f..b08a18f54d3 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -9,6 +9,7 @@ import { ensureAuthProfileStoreForLocalUpdate, listProfilesForProvider, type OAuthCredential, + type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth"; import { loginOpenAICodexOAuth } from "openclaw/plugin-sdk/provider-auth-login"; @@ -356,6 +357,7 @@ async function runOpenAICodexOAuth(ctx: ProviderAuthContext) { return buildOauthProviderAuthResult({ providerId: PROVIDER_ID, defaultModel: OPENAI_CODEX_DEFAULT_MODEL, + configPatch: buildOpenAICodexAuthConfigPatch(), access: creds.access, refresh: creds.refresh, expires: creds.expires, @@ -406,6 +408,7 @@ async function runOpenAICodexDeviceCode(ctx: ProviderAuthContext) { return buildOauthProviderAuthResult({ providerId: PROVIDER_ID, defaultModel: OPENAI_CODEX_DEFAULT_MODEL, + configPatch: buildOpenAICodexAuthConfigPatch(), access: creds.access, refresh: creds.refresh, expires: creds.expires, @@ -431,6 +434,19 @@ function buildOpenAICodexAuthDoctorHint(ctx: { profileId?: string }) { return "Deprecated profile. Run `openclaw models auth login --provider openai-codex` or `openclaw configure`."; } +function buildOpenAICodexAuthConfigPatch(): NonNullable { + return { + agents: { + defaults: { + agentRuntime: { id: "codex" }, + models: { + [OPENAI_CODEX_DEFAULT_MODEL]: {}, + }, + }, + }, + }; +} + export function buildOpenAICodexProviderPlugin(): ProviderPlugin { return { id: PROVIDER_ID, diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 76503bb293d..b2225c8956e 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -237,7 +237,7 @@ export function buildOpenAIProvider(): ProviderPlugin { if (ctx.provider !== PROVIDER_ID || ctx.listProfileIds("openai-codex").length === 0) { return undefined; } - return 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.5, or set OPENAI_API_KEY for direct OpenAI API access.'; + return 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth; OpenAI agent model runs use openai/gpt-5.5 through the Codex runtime. Set OPENAI_API_KEY only for direct OpenAI API-key surfaces.'; }, augmentModelCatalog: (ctx) => { const openAiGpt55ProTemplate = findCatalogTemplate({ diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index a07e449774e..489a430660e 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -707,52 +707,52 @@ { "provider": "openai-codex", "model": "gpt-5.1", - "reason": "gpt-5.1 is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id=\"codex\" for the native Codex runtime." + "reason": "gpt-5.1 is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime." }, { "provider": "openai-codex", "model": "gpt-5.1-codex", - "reason": "gpt-5.1-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id=\"codex\" for the native Codex runtime." + "reason": "gpt-5.1-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime." }, { "provider": "openai-codex", "model": "gpt-5.1-codex-mini", - "reason": "gpt-5.1-codex-mini is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id=\"codex\" for the native Codex runtime." + "reason": "gpt-5.1-codex-mini is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime." }, { "provider": "openai-codex", "model": "gpt-5.1-codex-max", - "reason": "gpt-5.1-codex-max is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id=\"codex\" for the native Codex runtime." + "reason": "gpt-5.1-codex-max is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime." }, { "provider": "openai-codex", "model": "gpt-5.2", - "reason": "gpt-5.2 is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id=\"codex\" for the native Codex runtime." + "reason": "gpt-5.2 is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime." }, { "provider": "openai-codex", "model": "gpt-5.2-codex", - "reason": "gpt-5.2-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id=\"codex\" for the native Codex runtime." + "reason": "gpt-5.2-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime." }, { "provider": "openai-codex", "model": "gpt-5.2-pro", - "reason": "gpt-5.2-pro is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id=\"codex\" for the native Codex runtime." + "reason": "gpt-5.2-pro is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime." }, { "provider": "openai-codex", "model": "gpt-5.3", - "reason": "gpt-5.3 is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id=\"codex\" for the native Codex runtime." + "reason": "gpt-5.3 is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime." }, { "provider": "openai-codex", "model": "gpt-5.3-codex", - "reason": "gpt-5.3-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id=\"codex\" for the native Codex runtime." + "reason": "gpt-5.3-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime." }, { "provider": "openai-codex", "model": "gpt-5.3-chat-latest", - "reason": "gpt-5.3-chat-latest is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id=\"codex\" for the native Codex runtime." + "reason": "gpt-5.3-chat-latest is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime." } ] }, diff --git a/extensions/openai/test-support/provider-catalog.contract-test-support.ts b/extensions/openai/test-support/provider-catalog.contract-test-support.ts index 2e40510b4d5..039165760ad 100644 --- a/extensions/openai/test-support/provider-catalog.contract-test-support.ts +++ b/extensions/openai/test-support/provider-catalog.contract-test-support.ts @@ -118,7 +118,7 @@ export function describeOpenAIProviderCatalogContract() { const { openaiProvider } = await contractDepsPromise; expectCodexMissingAuthHint( (params) => openaiProvider.buildMissingAuthMessage?.(params.context) ?? undefined, - "openai-codex/gpt-5.5", + "openai/gpt-5.5", ); }); diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 56914ee1cc9..2f1cddc1017 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -307,6 +307,7 @@ beforeEach(() => { systemPromptFileConfigKey: "model_instructions_file", systemPromptWhen: "first", imagePathScope: "workspace", + clearEnv: ["CODEX_API_KEY", "OPENAI_API_KEY"], reliability: { watchdog: { fresh: { @@ -401,6 +402,8 @@ describe("resolveCliBackendConfig reliability merge", () => { 'service_tier="fast"', "--skip-git-repo-check", ]); + expect(resolved?.config.clearEnv).toContain("OPENAI_API_KEY"); + expect(resolved?.config.clearEnv).toContain("CODEX_API_KEY"); }); it("deep-merges reliability watchdog overrides for codex", () => { diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 087385545df..1c26d29e9d0 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -886,7 +886,7 @@ describe("embedded attempt harness pinning", () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); - it("treats legacy sessions with history as PI-pinned", async () => { + it("keeps legacy OpenAI sessions with history on PI", async () => { const sessionEntry: SessionEntry = { sessionId: "legacy-session", updatedAt: Date.now(), @@ -910,7 +910,7 @@ describe("embedded attempt harness pinning", () => { isFallbackRetry: false, resolvedThinkLevel: "medium", timeoutMs: 1_000, - runId: "run-legacy-pi-pin", + runId: "run-legacy-openai-pi", opts: { senderIsOwner: false } as Parameters[0]["opts"], runContext: {} as Parameters[0]["runContext"], spawnedBy: undefined, @@ -980,7 +980,7 @@ describe("embedded attempt harness pinning", () => { ); }); - it("auto-forwards OpenAI Codex auth profiles to configured Codex harness runs", async () => { + it("auto-forwards OpenAI Codex auth profiles to default Codex harness runs", async () => { const sessionEntry: SessionEntry = { sessionId: "codex-auth-session", updatedAt: Date.now(), @@ -1005,16 +1005,10 @@ describe("embedded attempt harness pinning", () => { } satisfies EmbeddedPiRunResult); await runAgentAttempt({ - providerOverride: "openai", - originalProvider: "openai", - modelOverride: "gpt-5.4", - cfg: { - agents: { - defaults: { - agentRuntime: { id: "codex" }, - }, - }, - } as OpenClawConfig, + providerOverride: "openai-codex", + originalProvider: "openai-codex", + modelOverride: "gpt-5.5", + cfg: {} as OpenClawConfig, sessionEntry, sessionId: sessionEntry.sessionId, sessionKey: "agent:main:main", @@ -1034,7 +1028,7 @@ describe("embedded attempt harness pinning", () => { resolvedVerboseLevel: undefined, agentDir: tmpDir, onAgentEvent: vi.fn(), - authProfileProvider: "openai", + authProfileProvider: "openai-codex", sessionHasHistory: true, }); @@ -1047,7 +1041,7 @@ describe("embedded attempt harness pinning", () => { ); }); - it("pins a fresh unpinned session to the default PI harness", async () => { + it("pins a fresh OpenAI Codex session to the Codex harness by default", async () => { const sessionEntry: SessionEntry = { sessionId: "fresh-session", updatedAt: Date.now(), @@ -1057,9 +1051,9 @@ describe("embedded attempt harness pinning", () => { } satisfies EmbeddedPiRunResult); await runAgentAttempt({ - providerOverride: "openai", - originalProvider: "openai", - modelOverride: "gpt-5.4", + providerOverride: "openai-codex", + originalProvider: "openai-codex", + modelOverride: "gpt-5.5", cfg: {} as OpenClawConfig, sessionEntry, sessionId: sessionEntry.sessionId, @@ -1071,7 +1065,7 @@ describe("embedded attempt harness pinning", () => { isFallbackRetry: false, resolvedThinkLevel: "medium", timeoutMs: 1_000, - runId: "run-fresh-no-pin", + runId: "run-fresh-codex-no-pin", opts: { senderIsOwner: false } as Parameters[0]["opts"], runContext: {} as Parameters[0]["runContext"], spawnedBy: undefined, @@ -1080,17 +1074,56 @@ describe("embedded attempt harness pinning", () => { resolvedVerboseLevel: undefined, agentDir: tmpDir, onAgentEvent: vi.fn(), - authProfileProvider: "openai", + authProfileProvider: "openai-codex", sessionHasHistory: false, }); expect(runEmbeddedPiAgent).toHaveBeenCalledWith( expect.objectContaining({ - agentHarnessId: "pi", + agentHarnessId: "codex", }), ); }); + it("rejects stale OpenAI Codex sessions pinned to PI", async () => { + const sessionEntry: SessionEntry = { + sessionId: "stale-pi-session", + updatedAt: Date.now(), + agentHarnessId: "pi", + }; + + expect(() => + runAgentAttempt({ + providerOverride: "openai-codex", + originalProvider: "openai-codex", + modelOverride: "gpt-5.5", + cfg: {} as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey: "agent:main:main", + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "continue", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-stale-openai-codex-pi-pin", + opts: { senderIsOwner: false } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "openai-codex", + sessionHasHistory: true, + }), + ).toThrow("OpenAI Codex agent model runs require the Codex harness"); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); + it("does not pass CLI runtime aliases as embedded harness ids for fallback providers", async () => { const sessionEntry: SessionEntry = { sessionId: "fallback-session", diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index b602936fd7a..a483313bd09 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -23,7 +23,9 @@ import { FailoverError } from "../failover-error.js"; import { resolveAgentHarnessPolicy } from "../harness/selection.js"; import { isCliRuntimeAlias, resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js"; import { isCliProvider } from "../model-selection.js"; +import { normalizeEmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js"; import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js"; +import { normalizeProviderId } from "../provider-id.js"; import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js"; import { acquireSessionWriteLock, @@ -416,6 +418,8 @@ export function runAgentAttempt(params: { sessionHasHistory: params.sessionHasHistory, sessionId: params.sessionId, sessionKey: params.sessionKey ?? params.sessionId, + provider: params.providerOverride, + modelId: params.modelOverride, }); const agentRuntimeOverride = isRawModelRun ? undefined @@ -650,11 +654,21 @@ function resolveSessionPinnedAgentHarnessId(params: { sessionHasHistory?: boolean; sessionId: string; sessionKey: string; + provider: string; + modelId?: string; }): string | undefined { if (params.sessionEntry?.sessionId !== params.sessionId) { return resolveConfiguredAgentHarnessId(params); } if (params.sessionEntry.agentHarnessId) { + if ( + normalizeProviderId(params.provider) === "openai-codex" && + normalizeEmbeddedAgentRuntime(params.sessionEntry.agentHarnessId) === "pi" + ) { + throw new Error( + "OpenAI Codex agent model runs require the Codex harness. The existing session is pinned to PI; run `openclaw doctor --fix` to repair stale Codex runtime pins.", + ); + } return params.sessionEntry.agentHarnessId; } const configuredAgentHarnessId = resolveConfiguredAgentHarnessId(params); @@ -671,11 +685,15 @@ function resolveConfiguredAgentHarnessId(params: { cfg: OpenClawConfig; sessionAgentId: string; sessionKey: string; + provider: string; + modelId?: string; }): string | undefined { const policy = resolveAgentHarnessPolicy({ config: params.cfg, agentId: params.sessionAgentId, sessionKey: params.sessionKey, + provider: params.provider, + modelId: params.modelId, }); if (policy.runtime === "auto" || isCliRuntimeAlias(policy.runtime)) { return undefined; diff --git a/src/agents/harness-runtimes.ts b/src/agents/harness-runtimes.ts index 6c8aca05a09..01374018061 100644 --- a/src/agents/harness-runtimes.ts +++ b/src/agents/harness-runtimes.ts @@ -2,6 +2,57 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; import { resolveAgentRuntimePolicy } from "./agent-runtime-policy.js"; +import { isCliRuntimeAlias } from "./model-runtime-aliases.js"; +import { modelSelectionRequiresCodexRuntime } from "./openai-codex-routing.js"; +import { normalizeEmbeddedAgentRuntime } from "./pi-embedded-runner/runtime.js"; + +function normalizeRuntimeId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const lower = normalizeOptionalLowercaseString(value); + if (!lower) { + return undefined; + } + return normalizeOptionalLowercaseString(normalizeEmbeddedAgentRuntime(lower)); +} + +function listAgentModelRefs(value: unknown): string[] { + if (typeof value === "string") { + return [value]; + } + if (!isRecord(value)) { + return []; + } + const refs: string[] = []; + if (typeof value.primary === "string") { + refs.push(value.primary); + } + if (Array.isArray(value.fallbacks)) { + for (const fallback of value.fallbacks) { + if (typeof fallback === "string") { + refs.push(fallback); + } + } + } + return refs; +} + +function hasCodexRuntimeModelRef(config: OpenClawConfig, value: unknown): boolean { + return listAgentModelRefs(value).some((ref) => { + return modelSelectionRequiresCodexRuntime({ model: ref, config }); + }); +} + +function openAIModelUsesImplicitCodexHarness(runtime: string | undefined): boolean { + if (!runtime || runtime === "auto") { + return true; + } + if (runtime === "pi") { + return false; + } + return runtime === "codex" || isCliRuntimeAlias(runtime); +} export function collectConfiguredAgentHarnessRuntimes( config: OpenClawConfig, @@ -9,26 +60,39 @@ export function collectConfiguredAgentHarnessRuntimes( ): string[] { const runtimes = new Set(); const pushRuntime = (value: unknown) => { - if (typeof value !== "string") { - return; - } - const normalized = normalizeOptionalLowercaseString(value); + const normalized = normalizeRuntimeId(value); if (!normalized || normalized === "auto" || normalized === "pi") { return; } runtimes.add(normalized); }; + const pushCodexForOpenAIModel = (model: unknown, runtime: string | undefined) => { + if (hasCodexRuntimeModelRef(config, model) && openAIModelUsesImplicitCodexHarness(runtime)) { + runtimes.add("codex"); + } + }; - pushRuntime(resolveAgentRuntimePolicy(config.agents?.defaults)?.id); + const envRuntime = normalizeRuntimeId(env.OPENCLAW_AGENT_RUNTIME); + const defaultsRuntime = normalizeRuntimeId( + resolveAgentRuntimePolicy(config.agents?.defaults)?.id, + ); + const defaultsModel = config.agents?.defaults?.model; + pushRuntime(defaultsRuntime); + pushCodexForOpenAIModel(defaultsModel, envRuntime ?? defaultsRuntime); if (Array.isArray(config.agents?.list)) { for (const agent of config.agents.list) { if (!isRecord(agent)) { continue; } - pushRuntime(resolveAgentRuntimePolicy(agent)?.id); + const agentRuntime = normalizeRuntimeId(resolveAgentRuntimePolicy(agent)?.id); + pushRuntime(agentRuntime); + pushCodexForOpenAIModel( + agent.model ?? defaultsModel, + envRuntime ?? agentRuntime ?? defaultsRuntime, + ); } } - pushRuntime(env.OPENCLAW_AGENT_RUNTIME); + pushRuntime(envRuntime); return [...runtimes].toSorted((left, right) => left.localeCompare(right)); } diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 705c850cacd..15fcf8dec6b 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -95,6 +95,21 @@ function registerFailingCodexHarness(): void { ); } +function registerSuccessfulCodexHarness(): void { + registerAgentHarness( + { + id: "codex", + label: "Codex", + supports: (ctx) => + ctx.provider === "codex" || ctx.provider === "openai" + ? { supported: true, priority: 100 } + : { supported: false }, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }, + { ownerPluginId: "codex" }, + ); +} + describe("runAgentHarnessAttempt", () => { it("fails when a forced plugin harness is unavailable and fallback is omitted", async () => { process.env.OPENCLAW_AGENT_RUNTIME = "codex"; @@ -145,6 +160,116 @@ describe("runAgentHarnessAttempt", () => { expect(piRunAttempt).not.toHaveBeenCalled(); }); + it("keeps OpenAI agent model runs on PI by default", async () => { + registerSuccessfulCodexHarness(); + + await expect( + runAgentHarnessAttempt({ + ...createAttemptParams(), + provider: "openai", + modelId: "gpt-5.4", + }), + ).resolves.toMatchObject({ + sessionIdUsed: "pi", + }); + expect(piRunAttempt).toHaveBeenCalledTimes(1); + }); + + it("allows explicit PI runtime for OpenAI agent model runs", async () => { + await expect( + runAgentHarnessAttempt({ + ...createAttemptParams({ + agents: { defaults: { agentRuntime: { id: "pi" } } }, + }), + provider: "openai", + modelId: "gpt-5.4", + }), + ).resolves.toMatchObject({ + sessionIdUsed: "pi", + }); + expect(piRunAttempt).toHaveBeenCalledTimes(1); + }); + + it("uses the Codex harness by default for OpenAI Codex agent model runs", async () => { + registerSuccessfulCodexHarness(); + + await expect( + runAgentHarnessAttempt({ + ...createAttemptParams(), + provider: "openai-codex", + modelId: "gpt-5.5", + }), + ).resolves.toMatchObject({ + sessionIdUsed: "codex", + }); + expect(piRunAttempt).not.toHaveBeenCalled(); + }); + + it("rejects explicit PI runtime for OpenAI Codex agent model runs", async () => { + await expect( + runAgentHarnessAttempt({ + ...createAttemptParams({ + agents: { defaults: { agentRuntime: { id: "pi" } } }, + }), + provider: "openai-codex", + modelId: "gpt-5.5", + }), + ).rejects.toThrow("OpenAI Codex agent model runs require the Codex harness"); + expect(piRunAttempt).not.toHaveBeenCalled(); + }); + + it("keeps custom OpenAI-compatible provider routes on PI by default", async () => { + registerSuccessfulCodexHarness(); + const config: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + api: "openai-responses", + models: [], + }, + }, + }, + }; + + await expect( + runAgentHarnessAttempt({ + ...createAttemptParams(config), + provider: "openai", + modelId: "custom-gpt", + }), + ).resolves.toMatchObject({ + sessionIdUsed: "pi", + }); + expect(piRunAttempt).toHaveBeenCalledTimes(1); + }); + + it("allows explicit PI runtime for custom OpenAI-compatible provider routes", async () => { + const config: OpenClawConfig = { + agents: { defaults: { agentRuntime: { id: "pi" } } }, + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + api: "openai-responses", + models: [], + }, + }, + }, + }; + + await expect( + runAgentHarnessAttempt({ + ...createAttemptParams(config), + provider: "openai", + modelId: "custom-gpt", + }), + ).resolves.toMatchObject({ + sessionIdUsed: "pi", + }); + expect(piRunAttempt).toHaveBeenCalledTimes(1); + }); + it("annotates non-ok harness result classifications for outer model fallback", async () => { const classify = vi.fn(() => "empty" as const); registerAgentHarness( @@ -334,7 +459,8 @@ describe("selectAgentHarness", () => { ).toBe("pi"); }); - it("does not treat CLI runtime aliases as embedded harness ids", async () => { + it("does not treat CLI runtime aliases as PI for OpenAI Codex agent model runs", async () => { + registerSuccessfulCodexHarness(); const config: OpenClawConfig = { agents: { defaults: { @@ -343,17 +469,20 @@ describe("selectAgentHarness", () => { }, }; - expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.4", config }).id).toBe("pi"); + expect(selectAgentHarness({ provider: "openai-codex", modelId: "gpt-5.5", config }).id).toBe( + "codex", + ); await expect( runAgentHarnessAttempt({ ...createAttemptParams(config), - provider: "openai", - modelId: "gpt-5.4", + provider: "openai-codex", + modelId: "gpt-5.5", }), ).resolves.toMatchObject({ - sessionIdUsed: "pi", + sessionIdUsed: "codex", }); + expect(piRunAttempt).not.toHaveBeenCalled(); }); it("keeps an existing session pinned to PI even when config now forces a plugin harness", () => { diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index da2f6e673da..833c9e2e525 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -6,6 +6,7 @@ import { normalizeAgentId } from "../../routing/session-key.js"; import { resolveAgentRuntimePolicy } from "../agent-runtime-policy.js"; import { listAgentEntries, resolveSessionAgentIds } from "../agent-scope.js"; import { isCliRuntimeAlias } from "../model-runtime-aliases.js"; +import { openAIRouteRequiresCodexRuntime } from "../openai-codex-routing.js"; import type { CompactEmbeddedPiSessionParams } from "../pi-embedded-runner/compact.types.js"; import type { EmbeddedRunAttemptParams, @@ -26,6 +27,7 @@ const log = createSubsystemLogger("agents/harness"); type AgentHarnessPolicy = { runtime: EmbeddedAgentRuntime; + runtimeSource?: "env" | "agent" | "defaults" | "implicit" | "pinned"; }; type AgentHarnessSelectionCandidate = { @@ -86,7 +88,11 @@ function selectAgentHarnessDecision(params: { sessionKey?: string; agentHarnessId?: string; }): AgentHarnessSelectionDecision { - const pinnedPolicy = resolvePinnedAgentHarnessPolicy(params.agentHarnessId); + const pinnedPolicy = resolvePinnedAgentHarnessPolicy({ + agentHarnessId: params.agentHarnessId, + provider: params.provider, + config: params.config, + }); const policy = pinnedPolicy ?? resolveAgentHarnessPolicy(params); // PI is intentionally not part of the plugin candidate list. Explicit plugin // runtimes fail closed; only `auto` may route an unmatched turn to PI. @@ -242,9 +248,16 @@ function logAgentHarnessSelection( }); } -function resolvePinnedAgentHarnessPolicy( - agentHarnessId: string | undefined, -): AgentHarnessPolicy | undefined { +function formatOpenAIPiRuntimeError(source: string): string { + return `OpenAI Codex agent model runs require the Codex harness. ${source} selected PI; remove that runtime override or run \`openclaw doctor --fix\` to repair stale Codex runtime pins.`; +} + +function resolvePinnedAgentHarnessPolicy(params: { + agentHarnessId: string | undefined; + provider: string | undefined; + config?: OpenClawConfig; +}): AgentHarnessPolicy | undefined { + const { agentHarnessId } = params; if (!agentHarnessId?.trim()) { return undefined; } @@ -252,7 +265,13 @@ function resolvePinnedAgentHarnessPolicy( if (runtime === "auto") { return undefined; } - return { runtime }; + if ( + runtime === "pi" && + openAIRouteRequiresCodexRuntime({ provider: params.provider, config: params.config }) + ) { + throw new Error(formatOpenAIPiRuntimeError("The existing session harness pin")); + } + return { runtime, runtimeSource: "pinned" }; } export async function maybeCompactAgentHarnessSession( @@ -294,16 +313,40 @@ export function resolveAgentHarnessPolicy(params: { sessionKey: params.sessionKey, }); const defaultsPolicy = resolveAgentRuntimePolicy(params.config?.agents?.defaults); - const runtime = env.OPENCLAW_AGENT_RUNTIME?.trim() + const envRuntime = env.OPENCLAW_AGENT_RUNTIME?.trim(); + const agentRuntime = agentPolicy?.id?.trim(); + const defaultsRuntime = defaultsPolicy?.id?.trim(); + const runtimeSource = envRuntime + ? "env" + : agentRuntime + ? "agent" + : defaultsRuntime + ? "defaults" + : "implicit"; + const runtime = envRuntime ? resolveEmbeddedAgentRuntime(env) - : normalizeEmbeddedAgentRuntime(agentPolicy?.id ?? defaultsPolicy?.id); + : normalizeEmbeddedAgentRuntime(agentRuntime ?? defaultsRuntime); + if (openAIRouteRequiresCodexRuntime({ provider: params.provider, config: params.config })) { + if (runtime === "pi") { + if (runtimeSource === "implicit") { + return { runtime: "codex", runtimeSource }; + } + throw new Error(formatOpenAIPiRuntimeError(`${runtimeSource} runtime config`)); + } + if (runtime === "auto" || isCliRuntimeAlias(runtime)) { + return { runtime: "codex", runtimeSource }; + } + return { runtime, runtimeSource }; + } if (isCliRuntimeAlias(runtime)) { return { runtime: "pi", + runtimeSource, }; } return { runtime, + runtimeSource, }; } diff --git a/src/agents/openai-codex-routing.ts b/src/agents/openai-codex-routing.ts new file mode 100644 index 00000000000..80fd3b54de6 --- /dev/null +++ b/src/agents/openai-codex-routing.ts @@ -0,0 +1,76 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeProviderId } from "./provider-id.js"; + +const OPENAI_PROVIDER_ID = "openai"; +const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; + +function isOfficialOpenAIBaseUrl(baseUrl: unknown): boolean { + if (typeof baseUrl !== "string" || !baseUrl.trim()) { + return true; + } + return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/iu.test(baseUrl.trim()); +} + +function openAIProviderUsesCustomBaseUrl(config: OpenClawConfig | undefined): boolean { + const providerConfig = config?.models?.providers?.openai; + return !isOfficialOpenAIBaseUrl(providerConfig?.baseUrl); +} + +export function isOpenAIProvider(provider: string | undefined): boolean { + return normalizeProviderId(provider ?? "") === OPENAI_PROVIDER_ID; +} + +export function openAIRouteRequiresCodexRuntime(params: { + provider?: string; + config?: OpenClawConfig; +}): boolean { + return normalizeProviderId(params.provider ?? "") === OPENAI_CODEX_PROVIDER_ID; +} + +export function modelSelectionRequiresCodexRuntime(params: { + model?: string; + config?: OpenClawConfig; +}): boolean { + const model = params.model?.trim(); + if (!model) { + return false; + } + const slashIndex = model.indexOf("/"); + if (slashIndex <= 0) { + return false; + } + const provider = normalizeProviderId(model.slice(0, slashIndex)); + return openAIRouteRequiresCodexRuntime({ provider, config: params.config }); +} + +export function modelSelectionShouldEnsureCodexPlugin(params: { + model?: string; + config?: OpenClawConfig; +}): boolean { + const model = params.model?.trim(); + if (!model) { + return false; + } + const slashIndex = model.indexOf("/"); + if (slashIndex <= 0) { + return false; + } + const provider = normalizeProviderId(model.slice(0, slashIndex)); + if (provider === OPENAI_CODEX_PROVIDER_ID) { + return true; + } + return provider === OPENAI_PROVIDER_ID && !openAIProviderUsesCustomBaseUrl(params.config); +} + +export function hasOpenAICodexAuthProfileOverride(value: unknown): boolean { + return typeof value === "string" && value.trim().toLowerCase().startsWith("openai-codex:"); +} + +export function modelRefUsesOpenAIProvider(value: unknown): boolean { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + const slashIndex = trimmed.indexOf("/"); + return slashIndex > 0 && isOpenAIProvider(trimmed.slice(0, slashIndex)); +} diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 7e947c5f813..4c5b0739a9f 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -120,7 +120,7 @@ vi.mock("../model-suppression.js", () => { } if (isStaleOpenAICodexModel(provider, id)) { const modelId = id?.trim().toLowerCase() ?? ""; - return `Unknown model: openai-codex/${modelId}. ${modelId} is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id="codex" for the native Codex runtime.`; + return `Unknown model: openai-codex/${modelId}. ${modelId} is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime.`; } if ( (provider === "openai" || @@ -1511,7 +1511,7 @@ describe("resolveModel", () => { expect(result.model).toBeUndefined(); expect(result.error).toBe( - 'Unknown model: openai-codex/gpt-5.3-codex. gpt-5.3-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id="codex" for the native Codex runtime.', + "Unknown model: openai-codex/gpt-5.3-codex. gpt-5.3-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime.", ); }); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index cdc9d85a867..089e269a736 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -62,9 +62,7 @@ type ProviderRuntimeHooks = { normalizeProviderResolvedModelWithPlugin: ( params: Parameters[0], ) => unknown; - normalizeProviderTransportWithPlugin: ( - params: Parameters[0], - ) => unknown; + normalizeProviderTransportWithPlugin: typeof normalizeProviderTransportWithPlugin; }; const TARGET_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = { @@ -714,6 +712,7 @@ function resolveExplicitModelWithRegistry(params: { provider, cfg, agentDir, + workspaceDir, model: applyConfiguredProviderOverrides({ provider, discoveredModel: model, @@ -724,7 +723,6 @@ function resolveExplicitModelWithRegistry(params: { workspaceDir, }), runtimeHooks, - workspaceDir, }), }; } diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index fbbac755612..a6976031e76 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -2755,7 +2755,7 @@ describe("runAgentTurnWithFallback", () => { it("surfaces direct provider auth guidance for missing API keys", async () => { state.runEmbeddedPiAgentMock.mockRejectedValueOnce( new Error( - 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.5, or set OPENAI_API_KEY for direct OpenAI API access. | No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.5, or set OPENAI_API_KEY for direct OpenAI API access.', + 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth; OpenAI agent model runs use openai/gpt-5.5 through the Codex runtime. Set OPENAI_API_KEY only for direct OpenAI API-key surfaces. | No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth; OpenAI agent model runs use openai/gpt-5.5 through the Codex runtime. Set OPENAI_API_KEY only for direct OpenAI API-key surfaces.', ), ); @@ -2787,7 +2787,7 @@ describe("runAgentTurnWithFallback", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - "⚠️ Missing API key for OpenAI on the gateway. Use `openai-codex/gpt-5.5`, or set `OPENAI_API_KEY`, then try again.", + "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-5.5` through the Codex runtime, or set `OPENAI_API_KEY`, then try again.", ); } }); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 1e519e42f3e..ccdd72e793e 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -465,7 +465,7 @@ function buildMissingApiKeyFailureText(message: string): string | null { return null; } if (provider === "openai" && normalizedMessage.includes("OpenAI Codex OAuth")) { - return "⚠️ Missing API key for OpenAI on the gateway. Use `openai-codex/gpt-5.5`, or set `OPENAI_API_KEY`, then try again."; + return "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-5.5` through the Codex runtime, or set `OPENAI_API_KEY`, then try again."; } if (SAFE_MISSING_API_KEY_PROVIDERS.has(provider)) { return `⚠️ Missing API key for provider "${provider}". Configure the gateway auth for that provider, then try again.`; diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index 9296f7b7401..f99881fa5ca 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -638,19 +638,19 @@ describe("buildStatusReply subagent summary", () => { }, ...commonParams, }); - const piText = await buildStatusText({ + const implicitCodexText = await buildStatusText({ cfg: baseCfg, ...commonParams, }); const normalizedCodex = normalizeTestText(codexText); - const normalizedPi = normalizeTestText(piText); + const normalizedImplicitCodex = normalizeTestText(implicitCodexText); expect(normalizedCodex).toContain("Model: openai/gpt-5.5"); expect(normalizedCodex).toContain("oauth (openai-codex:status)"); expect(normalizedCodex).toContain("openai-codex:status"); - expect(normalizedPi).toContain("Model: openai/gpt-5.5"); - expect(normalizedPi).toContain("unknown"); - expect(normalizedPi).not.toContain("openai-codex:status"); + expect(normalizedImplicitCodex).toContain("Model: openai/gpt-5.5"); + expect(normalizedImplicitCodex).toContain("oauth (openai-codex:status)"); + expect(normalizedImplicitCodex).toContain("Runtime: OpenAI Codex"); }, { env: { diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index a5ce3135d08..12890bf851d 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -25,6 +25,17 @@ const ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); +const ensureCodexRuntimePluginForModelSelection = vi.hoisted(() => + vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg, + required: false, + installed: false, + })), +); + +vi.mock("./codex-runtime-plugin-install.js", () => ({ + ensureCodexRuntimePluginForModelSelection, +})); vi.mock("../plugins/provider-install-catalog.js", () => ({ resolveProviderInstallCatalogEntry: vi.fn(() => undefined), @@ -639,6 +650,7 @@ describe("applyAuthChoice", () => { resolvePluginProviders.mockReset(); resolvePluginProviders.mockReturnValue(defaultProviderPlugins); runProviderModelSelectedHook.mockClear(); + ensureCodexRuntimePluginForModelSelection.mockClear(); detectZaiEndpoint.mockReset(); detectZaiEndpoint.mockResolvedValue(null); testAuthProfileStores.clear(); diff --git a/src/commands/cleanup-utils.test.ts b/src/commands/cleanup-utils.test.ts index 894b6507681..364b057adee 100644 --- a/src/commands/cleanup-utils.test.ts +++ b/src/commands/cleanup-utils.test.ts @@ -1,7 +1,10 @@ import path from "node:path"; import { describe, expect, it, test, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultPrimaryModel } from "../plugins/provider-model-primary.js"; +import { + applyAgentDefaultPrimaryModel, + applyPrimaryModel, +} from "../plugins/provider-model-primary.js"; import type { RuntimeEnv } from "../runtime.js"; import { buildCleanupPlan, @@ -56,6 +59,43 @@ describe("applyAgentDefaultPrimaryModel", () => { }); }); +describe("applyPrimaryModel", () => { + it("leaves OpenAI model selections on the existing runtime", () => { + const next = applyPrimaryModel({ agents: { defaults: {} } }, "openai/gpt-5.5"); + + expect(next.agents?.defaults?.model).toMatchObject({ primary: "openai/gpt-5.5" }); + expect(next.agents?.defaults?.agentRuntime).toBeUndefined(); + }); + + it("pins OpenAI Codex model selections to the Codex runtime", () => { + const next = applyPrimaryModel({ agents: { defaults: {} } }, "openai-codex/gpt-5.5"); + + expect(next.agents?.defaults?.model).toMatchObject({ primary: "openai-codex/gpt-5.5" }); + expect(next.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); + }); + + it("does not pin custom OpenAI-compatible selections to Codex", () => { + const next = applyPrimaryModel( + { + agents: { defaults: {} }, + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + api: "openai-responses", + models: [], + }, + }, + }, + }, + "openai/custom-gpt", + ); + + expect(next.agents?.defaults?.model).toMatchObject({ primary: "openai/custom-gpt" }); + expect(next.agents?.defaults?.agentRuntime).toBeUndefined(); + }); +}); + describe("cleanup path removals", () => { function createRuntimeMock() { return { diff --git a/src/commands/codex-runtime-plugin-install.test.ts b/src/commands/codex-runtime-plugin-install.test.ts new file mode 100644 index 00000000000..f2dc2e7a39c --- /dev/null +++ b/src/commands/codex-runtime-plugin-install.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { + ensureCodexRuntimePluginForModelSelection, + repairCodexRuntimePluginInstallForModelSelection, + selectedModelShouldEnsureCodexRuntimePlugin, +} from "./codex-runtime-plugin-install.js"; + +const mocks = vi.hoisted(() => ({ + ensureOnboardingPluginInstalled: vi.fn(), + repairMissingPluginInstallsForIds: vi.fn(), +})); + +vi.mock("./onboarding-plugin-install.js", () => ({ + ensureOnboardingPluginInstalled: mocks.ensureOnboardingPluginInstalled, +})); + +vi.mock("./doctor/shared/missing-configured-plugin-install.js", () => ({ + repairMissingPluginInstallsForIds: mocks.repairMissingPluginInstallsForIds, +})); + +const prompter = {} as WizardPrompter; +const runtime = {} as RuntimeEnv; + +describe("codex runtime plugin install", () => { + beforeEach(() => { + mocks.ensureOnboardingPluginInstalled.mockReset(); + mocks.repairMissingPluginInstallsForIds.mockReset(); + }); + + it("ensures the Codex plugin for OpenAI model selections", () => { + expect(selectedModelShouldEnsureCodexRuntimePlugin({ cfg: {}, model: "openai/gpt-5.5" })).toBe( + true, + ); + expect( + selectedModelShouldEnsureCodexRuntimePlugin({ cfg: {}, model: "openai-codex/gpt-5.5" }), + ).toBe(true); + }); + + it("skips Codex plugin setup for custom OpenAI-compatible base URLs", () => { + const cfg = { + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + api: "openai-responses", + models: [], + }, + }, + }, + } as OpenClawConfig; + + expect(selectedModelShouldEnsureCodexRuntimePlugin({ cfg, model: "openai/custom-gpt" })).toBe( + false, + ); + expect(selectedModelShouldEnsureCodexRuntimePlugin({ cfg, model: "local/custom-gpt" })).toBe( + false, + ); + }); + + it("installs and enables the Codex plugin for OpenAI selections", async () => { + const installedCfg = { plugins: { entries: { codex: { enabled: true } } } } as OpenClawConfig; + mocks.ensureOnboardingPluginInstalled.mockResolvedValue({ + cfg: installedCfg, + installed: true, + pluginId: "codex", + status: "installed", + }); + + const result = await ensureCodexRuntimePluginForModelSelection({ + cfg: {}, + model: "openai/gpt-5.5", + prompter, + runtime, + workspaceDir: "/tmp/workspace", + }); + + expect(result).toEqual({ + cfg: installedCfg, + required: true, + installed: true, + status: "installed", + }); + expect(mocks.ensureOnboardingPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ + entry: expect.objectContaining({ + pluginId: "codex", + install: expect.objectContaining({ npmSpec: "@openclaw/codex" }), + trustedSourceLinkedOfficialInstall: true, + }), + promptInstall: false, + autoConfirmSingleSource: true, + workspaceDir: "/tmp/workspace", + }), + ); + }); + + it("does not run installer work when OpenAI uses a custom base URL", async () => { + const cfg = { + models: { + providers: { + openai: { baseUrl: "https://compatible.example.test/v1", models: [] }, + }, + }, + } as OpenClawConfig; + mocks.ensureOnboardingPluginInstalled.mockResolvedValue({ + cfg, + installed: false, + pluginId: "codex", + status: "skipped", + }); + + const result = await ensureCodexRuntimePluginForModelSelection({ + cfg, + model: "openai/custom-gpt", + prompter, + runtime, + }); + + expect(result).toEqual({ cfg, required: false, installed: false }); + expect(mocks.ensureOnboardingPluginInstalled).not.toHaveBeenCalled(); + }); + + it("repairs the missing Codex install for non-interactive model selection paths", async () => { + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: ['Installed missing configured plugin "codex" from @openclaw/codex.'], + warnings: [], + }); + + const result = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: {}, + model: "openai/gpt-5.5", + env: { OPENCLAW_TEST: "1" }, + }); + + expect(result.required).toBe(true); + expect(result.warnings).toEqual([]); + expect(mocks.repairMissingPluginInstallsForIds).toHaveBeenCalledWith({ + cfg: {}, + pluginIds: ["codex"], + env: { OPENCLAW_TEST: "1" }, + }); + }); +}); diff --git a/src/commands/codex-runtime-plugin-install.ts b/src/commands/codex-runtime-plugin-install.ts new file mode 100644 index 00000000000..0c584dcfae2 --- /dev/null +++ b/src/commands/codex-runtime-plugin-install.ts @@ -0,0 +1,83 @@ +import { modelSelectionShouldEnsureCodexPlugin } from "../agents/openai-codex-routing.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +const CODEX_RUNTIME_PLUGIN_ID = "codex"; +const CODEX_RUNTIME_PLUGIN_LABEL = "Codex"; +const CODEX_RUNTIME_PLUGIN_NPM_SPEC = "@openclaw/codex"; + +export type CodexRuntimePluginInstallResult = { + cfg: OpenClawConfig; + required: boolean; + installed: boolean; + status?: "installed" | "skipped" | "failed" | "timed_out"; +}; + +export function selectedModelShouldEnsureCodexRuntimePlugin(params: { + cfg: OpenClawConfig; + model?: string; +}): boolean { + return modelSelectionShouldEnsureCodexPlugin({ + config: params.cfg, + model: params.model, + }); +} + +export async function ensureCodexRuntimePluginForModelSelection(params: { + cfg: OpenClawConfig; + model?: string; + prompter: WizardPrompter; + runtime: RuntimeEnv; + workspaceDir?: string; +}): Promise { + if (!selectedModelShouldEnsureCodexRuntimePlugin({ cfg: params.cfg, model: params.model })) { + return { cfg: params.cfg, required: false, installed: false }; + } + const { ensureOnboardingPluginInstalled } = await import("./onboarding-plugin-install.js"); + const result = await ensureOnboardingPluginInstalled({ + cfg: params.cfg, + entry: { + pluginId: CODEX_RUNTIME_PLUGIN_ID, + label: CODEX_RUNTIME_PLUGIN_LABEL, + install: { + npmSpec: CODEX_RUNTIME_PLUGIN_NPM_SPEC, + defaultChoice: "npm", + }, + trustedSourceLinkedOfficialInstall: true, + }, + prompter: params.prompter, + runtime: params.runtime, + ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), + promptInstall: false, + autoConfirmSingleSource: true, + }); + return { + cfg: result.cfg, + required: true, + installed: result.installed, + status: result.status, + }; +} + +export async function repairCodexRuntimePluginInstallForModelSelection(params: { + cfg: OpenClawConfig; + model?: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ required: boolean; changes: string[]; warnings: string[] }> { + if (!selectedModelShouldEnsureCodexRuntimePlugin({ cfg: params.cfg, model: params.model })) { + return { required: false, changes: [], warnings: [] }; + } + const { repairMissingPluginInstallsForIds } = + await import("./doctor/shared/missing-configured-plugin-install.js"); + const result = await repairMissingPluginInstallsForIds({ + cfg: params.cfg, + pluginIds: [CODEX_RUNTIME_PLUGIN_ID], + ...(params.env !== undefined ? { env: params.env } : {}), + }); + return { + required: true, + changes: result.changes, + warnings: result.warnings, + }; +} diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index 93c8e9a9458..51acd4d1f1a 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -9,6 +9,11 @@ const mocks = vi.hoisted(() => ({ applyAuthChoice: vi.fn(), promptModelAllowlist: vi.fn(), promptDefaultModel: vi.fn(), + ensureCodexRuntimePluginForModelSelection: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg, + required: false, + installed: false, + })), applyPrimaryModel: vi.fn((cfg: OpenClawConfig, model: string) => ({ ...cfg, agents: { @@ -176,6 +181,7 @@ vi.mock("./model-picker.js", () => ({ applyModelAllowlist: mocks.applyModelAllowlist, applyModelFallbacksFromSelection: mocks.applyModelFallbacksFromSelection, applyPrimaryModel: mocks.applyPrimaryModel, + ensureCodexRuntimePluginForModelSelection: mocks.ensureCodexRuntimePluginForModelSelection, promptModelAllowlist: mocks.promptModelAllowlist, promptDefaultModel: mocks.promptDefaultModel, })); @@ -200,6 +206,7 @@ import { promptAuthConfig } from "./configure.gateway-auth.js"; beforeEach(() => { mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([]); + mocks.ensureCodexRuntimePluginForModelSelection.mockClear(); }); function makeRuntime(): RuntimeEnv { diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index 4156d210d12..7f8cb09cb9c 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -10,6 +10,7 @@ import { applyModelAllowlist, applyModelFallbacksFromSelection, applyPrimaryModel, + ensureCodexRuntimePluginForModelSelection, promptDefaultModel, promptModelAllowlist, } from "./model-picker.js"; @@ -217,6 +218,15 @@ export async function promptAuthConfig( } if (modelSelection.model) { next = applyPrimaryModel(next, modelSelection.model); + next = ( + await ensureCodexRuntimePluginForModelSelection({ + cfg: next, + model: modelSelection.model, + prompter, + runtime, + workspaceDir: resolveDefaultAgentWorkspaceDir(), + }) + ).cfg; } break; } diff --git a/src/commands/doctor-session-state-providers.test.ts b/src/commands/doctor-session-state-providers.test.ts index ef9edb661f8..f461570bf20 100644 --- a/src/commands/doctor-session-state-providers.test.ts +++ b/src/commands/doctor-session-state-providers.test.ts @@ -66,6 +66,75 @@ describe("doctor session state provider routes", () => { }); }); + it("keeps implicit OpenAI routes on PI", () => { + expect( + resolveConfiguredDoctorSessionStateRoute({ + cfg: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + }, + }, + }, + sessionKey: "agent:main:telegram:direct:1", + env: {}, + }), + ).toMatchObject({ + defaultProvider: "openai", + configuredModelRefs: ["openai/gpt-5.5"], + runtime: "pi", + }); + }); + + it("resolves implicit OpenAI Codex routes to the Codex runtime", () => { + expect( + resolveConfiguredDoctorSessionStateRoute({ + cfg: { + agents: { + defaults: { + model: { primary: "openai-codex/gpt-5.5" }, + }, + }, + }, + sessionKey: "agent:main:telegram:direct:1", + env: {}, + }), + ).toMatchObject({ + defaultProvider: "openai-codex", + configuredModelRefs: ["openai-codex/gpt-5.5"], + runtime: "codex", + }); + }); + + it("keeps implicit custom OpenAI-compatible routes on PI", () => { + expect( + resolveConfiguredDoctorSessionStateRoute({ + cfg: { + agents: { + defaults: { + model: { primary: "openai/custom-gpt" }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + api: "openai-responses", + models: [], + }, + }, + }, + }, + sessionKey: "agent:main:telegram:direct:1", + env: {}, + }), + ).toMatchObject({ + defaultProvider: "openai", + configuredModelRefs: ["openai/custom-gpt"], + runtime: "pi", + }); + }); + it("lets environment CLI runtime overrides reach plugin-owned scanners", () => { expect( resolveConfiguredDoctorSessionStateRoute({ @@ -85,6 +154,36 @@ describe("doctor session state provider routes", () => { }); }); + it("keeps Codex OAuth route state for canonical OpenAI routes", () => { + const sessionKey = "agent:main:telegram:direct:1"; + const scan = scanSessionRouteStateOwners({ + owners: [codexOwner], + store: { + [sessionKey]: { + sessionId: "sess-codex-openai", + updatedAt: 1, + modelProvider: "openai", + model: "gpt-5.5", + agentHarnessId: "codex", + authProfileOverride: "openai-codex:default", + authProfileOverrideSource: "auto", + cliSessionBindings: { + "codex-cli": { sessionId: "codex-session-1" }, + }, + }, + }, + routes: { + [sessionKey]: { + defaultProvider: "openai", + configuredModelRefs: ["openai/gpt-5.5"], + runtime: "pi", + }, + }, + }); + + expect(scan).toEqual({ repairs: [], manualReview: [] }); + }); + it("clears auto-created route state when current route no longer uses the owner", () => { const sessionKey = "agent:main:telegram:direct:1"; const entry: Record = { diff --git a/src/commands/doctor-session-state-providers.ts b/src/commands/doctor-session-state-providers.ts index d180e2f5e5a..23846980963 100644 --- a/src/commands/doctor-session-state-providers.ts +++ b/src/commands/doctor-session-state-providers.ts @@ -108,6 +108,8 @@ export function resolveConfiguredDoctorSessionStateRoute(params: { } } const runtime = resolveAgentHarnessPolicy({ + provider: primary.provider, + modelId: primary.model, config: params.cfg, agentId, sessionKey: params.sessionKey, @@ -216,6 +218,36 @@ function routeAllowsOwnerState(params: { ); } +function routeHasConfiguredProvider( + route: DoctorSessionRouteState | undefined, + providerId: string, +): boolean { + const normalizedProvider = normalizeProviderId(providerId); + if (!route || !normalizedProvider) { + return false; + } + if (normalizeProviderId(route.defaultProvider) === normalizedProvider) { + return true; + } + return route.configuredModelRefs.some((ref) => { + const slash = ref.indexOf("/"); + return slash > 0 && normalizeProviderId(ref.slice(0, slash)) === normalizedProvider; + }); +} + +function routeAllowsOpenAICodexAuthState(params: { + ownerProviderIds: ReadonlySet; + authProfilePrefixes: readonly string[]; + entry: Record; + route: DoctorSessionRouteState | undefined; +}): boolean { + return ( + params.ownerProviderIds.has("openai-codex") && + ownsPrefixedValue(params.authProfilePrefixes, params.entry.authProfileOverride) && + routeHasConfiguredProvider(params.route, "openai") + ); +} + function hasOwnedCliSession(params: { entry: Record; cliSessionKeys: readonly string[]; @@ -254,7 +286,14 @@ function scanEntryForOwner(params: { const runtimeIds = normalizeIdSet(params.owner.runtimeIds); const cliSessionKeys = [...normalizeIdSet(params.owner.cliSessionKeys)]; const authProfilePrefixes = normalizePrefixList(params.owner.authProfilePrefixes); - const routeAllowsOwner = routeAllowsOwnerState({ owner: params.owner, route: params.route }); + const routeAllowsOwner = + routeAllowsOwnerState({ owner: params.owner, route: params.route }) || + routeAllowsOpenAICodexAuthState({ + ownerProviderIds: providerIds, + authProfilePrefixes, + entry: params.entry, + route: params.route, + }); const reasons: string[] = []; const directOverride = resolvePersistedOverrideModelRef({ defaultProvider: params.route?.defaultProvider ?? "", diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index 59bfe67bb1e..d8c19bae140 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -397,11 +397,11 @@ describe("doctor repair sequencing", () => { ); }); - it("moves legacy Codex routes to PI before missing plugin install repair when Codex is not ready", async () => { + it("moves legacy Codex routes to Codex before missing plugin install repair", async () => { mocks.repairMissingConfiguredPluginInstalls.mockImplementationOnce( async (params: { cfg: OpenClawConfig }) => { expect(params.cfg.agents?.defaults?.model).toBe("openai/gpt-5.5"); - expect(params.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "pi" }); + expect(params.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); return { changes: [], warnings: [], @@ -434,9 +434,9 @@ describe("doctor repair sequencing", () => { expect(result.state.pendingChanges).toBe(true); expect(result.state.candidate.agents?.defaults?.model).toBe("openai/gpt-5.5"); - expect(result.state.candidate.agents?.defaults?.agentRuntime).toEqual({ id: "pi" }); + expect(result.state.candidate.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); expect(result.changeNotes.join("\n")).toContain( - 'agents.defaults.model: openai-codex/gpt-5.5 -> openai/gpt-5.5; set agentRuntime.id to "pi".', + 'agents.defaults.model: openai-codex/gpt-5.5 -> openai/gpt-5.5; set agentRuntime.id to "codex".', ); expect(result.changeNotes.join("\n")).not.toContain("Installed missing configured plugin"); }); diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 410f4f50316..34e09e849d6 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -64,12 +64,11 @@ describe("collectCodexRouteWarnings", () => { } as OpenClawConfig, }); - expect(warnings).toEqual([expect.stringContaining("Legacy `openai-codex/*`")]); + expect(warnings).toEqual([expect.stringContaining("OpenAI Codex routes")]); expect(warnings[0]).toContain("agents.defaults.model"); expect(warnings[0]).toContain("openai/gpt-5.5"); - expect(warnings[0]).toContain('runtime is "pi"'); + expect(warnings[0]).toContain('runtime is "codex"'); expect(warnings[0]).toContain('agentRuntime.id: "codex"'); - expect(warnings[0]).toContain("usable OAuth"); }); it("still warns when the native Codex runtime is selected with a legacy model ref", () => { @@ -121,6 +120,45 @@ describe("collectCodexRouteWarnings", () => { expect(warnings).toEqual([]); }); + it("does not warn when OpenAI refs are pinned to PI", () => { + const warnings = collectCodexRouteWarnings({ + cfg: { + agents: { + defaults: { + model: "openai/gpt-5.5", + agentRuntime: { id: "pi" }, + }, + }, + } as OpenClawConfig, + }); + + expect(warnings).toEqual([]); + }); + + it("does not warn when custom OpenAI-compatible refs are pinned to PI", () => { + const warnings = collectCodexRouteWarnings({ + cfg: { + agents: { + defaults: { + model: "openai/custom-gpt", + agentRuntime: { id: "pi" }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + api: "openai-responses", + models: [], + }, + }, + }, + } as OpenClawConfig, + }); + + expect(warnings).toEqual([]); + }); + it("repairs configured Codex model refs to canonical OpenAI refs with the Codex runtime when ready", () => { const result = maybeRepairCodexRoutes({ cfg: { @@ -224,7 +262,7 @@ describe("collectCodexRouteWarnings", () => { expect(result.cfg.messages?.tts?.summaryModel).toBe("openai/gpt-5.4-mini"); }); - it("repairs legacy routes to PI when Codex is not installed, enabled, and OAuth-ready", () => { + it("repairs legacy routes to Codex even when OAuth readiness cannot be proven", () => { const result = maybeRepairCodexRoutes({ cfg: { agents: { @@ -237,11 +275,30 @@ describe("collectCodexRouteWarnings", () => { }); expect(result.cfg.agents?.defaults?.model).toBe("openai/gpt-5.5"); - expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "pi" }); - expect(result.changes.join("\n")).toContain('set agentRuntime.id to "pi"'); + expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); + expect(result.changes.join("\n")).toContain('set agentRuntime.id to "codex"'); }); - it("repairs persisted session route pins to PI when Codex is not ready", () => { + it("does not repair OpenAI PI runtime pins without Codex auth", () => { + const result = maybeRepairCodexRoutes({ + cfg: { + agents: { + defaults: { + model: "openai/gpt-5.5", + agentRuntime: { id: "pi" }, + }, + }, + } as OpenClawConfig, + shouldRepair: true, + codexRuntimeReady: false, + }); + + expect(result.cfg.agents?.defaults?.model).toBe("openai/gpt-5.5"); + expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "pi" }); + expect(result.changes).toEqual([]); + }); + + it("repairs persisted session route pins to Codex and preserves Codex auth pins", () => { const store: Record = { main: { sessionId: "s1", @@ -269,11 +326,11 @@ describe("collectCodexRouteWarnings", () => { const result = repairCodexSessionStoreRoutes({ store, - runtime: "pi", + runtime: "codex", now: 123, }); - expect(result).toEqual({ changed: true, sessionKeys: ["main", "other"] }); + expect(result).toEqual({ changed: true, sessionKeys: ["main"] }); expect(store.main).toMatchObject({ updatedAt: 123, modelProvider: "openai", @@ -281,19 +338,18 @@ describe("collectCodexRouteWarnings", () => { providerOverride: "openai", modelOverride: "gpt-5.4", modelOverrideSource: "auto", - agentHarnessId: "pi", - agentRuntimeOverride: "pi", + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + authProfileOverride: "openai-codex:default", + authProfileOverrideSource: "auto", + authProfileOverrideCompactionCount: 2, }); - expect(store.main.authProfileOverride).toBeUndefined(); - expect(store.main.authProfileOverrideSource).toBeUndefined(); - expect(store.main.authProfileOverrideCompactionCount).toBeUndefined(); expect(store.main.fallbackNoticeSelectedModel).toBeUndefined(); expect(store.main.fallbackNoticeActiveModel).toBeUndefined(); expect(store.main.fallbackNoticeReason).toBeUndefined(); expect(store.other).toMatchObject({ - updatedAt: 123, - agentHarnessId: "pi", - agentRuntimeOverride: "pi", + updatedAt: 2, + agentHarnessId: "codex", }); }); @@ -328,7 +384,116 @@ describe("collectCodexRouteWarnings", () => { }); }); - it("selects the Codex runtime only when the plugin is installed, enabled, and has usable OAuth", () => { + it("repairs canonical OpenAI sessions that are still pinned to PI", () => { + const store: Record = { + main: { + sessionId: "s1", + updatedAt: 1, + modelProvider: "openai", + model: "gpt-5.5", + providerOverride: "openai", + modelOverride: "gpt-5.4", + agentHarnessId: "pi", + agentRuntimeOverride: "pi", + authProfileOverride: "openai-codex:default", + }, + }; + + const result = repairCodexSessionStoreRoutes({ + store, + runtime: "codex", + now: 123, + }); + + expect(result).toEqual({ changed: true, sessionKeys: ["main"] }); + expect(store.main).toMatchObject({ + updatedAt: 123, + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + authProfileOverride: "openai-codex:default", + }); + }); + + it("does not repair custom OpenAI-compatible sessions pinned to PI", () => { + const store: Record = { + main: { + sessionId: "s1", + updatedAt: 1, + modelProvider: "openai", + model: "custom-gpt", + providerOverride: "openai", + modelOverride: "custom-gpt", + agentHarnessId: "pi", + agentRuntimeOverride: "pi", + }, + }; + + const result = repairCodexSessionStoreRoutes({ + store, + runtime: "codex", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + api: "openai-responses", + models: [], + }, + }, + }, + }, + now: 123, + }); + + expect(result).toEqual({ changed: false, sessionKeys: [] }); + expect(store.main).toMatchObject({ + updatedAt: 1, + agentHarnessId: "pi", + agentRuntimeOverride: "pi", + }); + }); + + it("repairs custom OpenAI-compatible PI pins when Codex auth is present", () => { + const store: Record = { + main: { + sessionId: "s1", + updatedAt: 1, + modelProvider: "openai", + model: "custom-gpt", + agentHarnessId: "pi", + agentRuntimeOverride: "pi", + authProfileOverride: "openai-codex:default", + authProfileOverrideSource: "auto", + }, + }; + + const result = repairCodexSessionStoreRoutes({ + store, + runtime: "codex", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + api: "openai-responses", + models: [], + }, + }, + }, + }, + now: 123, + }); + + expect(result).toEqual({ changed: true, sessionKeys: ["main"] }); + expect(store.main).toMatchObject({ + updatedAt: 123, + agentHarnessId: "codex", + agentRuntimeOverride: "codex", + authProfileOverride: "openai-codex:default", + }); + }); + + it("repairs legacy routes to Codex without probing OAuth readiness", () => { const store = { profiles: { "openai-codex:default": { @@ -374,19 +539,14 @@ describe("collectCodexRouteWarnings", () => { shouldRepair: true, }); - expect(mocks.loadInstalledPluginIndex).toHaveBeenCalled(); - expect(mocks.isInstalledPluginEnabled).toHaveBeenCalledWith(index, "codex", expect.anything()); - expect(mocks.resolveAuthProfileOrder).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "openai-codex", - store, - }), - ); + expect(mocks.loadInstalledPluginIndex).not.toHaveBeenCalled(); + expect(mocks.isInstalledPluginEnabled).not.toHaveBeenCalled(); + expect(mocks.resolveAuthProfileOrder).not.toHaveBeenCalled(); expect(result.cfg.agents?.defaults?.model).toBe("openai/gpt-5.5"); expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); }); - it("keeps PI when the installed Codex record does not contribute the Codex harness", () => { + it("still repairs to Codex when installed plugin metadata is unavailable", () => { const store = { profiles: { "openai-codex:default": { @@ -426,6 +586,6 @@ describe("collectCodexRouteWarnings", () => { }); expect(result.cfg.agents?.defaults?.model).toBe("openai/gpt-5.5"); - expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "pi" }); + expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); }); }); diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index 5ca0432cad3..ea66d5434ab 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -1,21 +1,14 @@ import fs from "node:fs"; import { - ensureAuthProfileStore, - resolveAuthProfileOrder, - resolveProfileUnusableUntilForDisplay, -} from "../../../agents/auth-profiles.js"; -import { evaluateStoredCredentialEligibility } from "../../../agents/auth-profiles/credential-state.js"; + hasOpenAICodexAuthProfileOverride, + openAIRouteRequiresCodexRuntime, +} from "../../../agents/openai-codex-routing.js"; import { AGENT_MODEL_CONFIG_KEYS } from "../../../config/model-refs.js"; import { loadSessionStore, updateSessionStore } from "../../../config/sessions/store.js"; import { resolveAllAgentSessionStoreTargetsSync } from "../../../config/sessions/targets.js"; import type { SessionEntry } from "../../../config/sessions/types.js"; import type { AgentRuntimePolicyConfig } from "../../../config/types.agents-shared.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { - getInstalledPluginRecord, - isInstalledPluginEnabled, - loadInstalledPluginIndex, -} from "../../../plugins/installed-plugin-index.js"; type CodexRouteHit = { path: string; @@ -53,6 +46,10 @@ function isOpenAICodexModelRef(model: string | undefined): model is string { return normalizeString(model)?.startsWith("openai-codex/") === true; } +function isOpenAIModelRef(model: string | undefined): model is string { + return normalizeString(model)?.startsWith("openai/") === true; +} + function toCanonicalOpenAIModelRef(model: string): string | undefined { if (!isOpenAICodexModelRef(model)) { return undefined; @@ -78,7 +75,7 @@ function resolveRuntime(params: { normalizeString(params.env?.OPENCLAW_AGENT_RUNTIME) ?? normalizeString(params.agentRuntime?.id) ?? normalizeString(params.defaultsRuntime?.id) ?? - "pi" + "codex" ); } @@ -103,6 +100,36 @@ function recordCodexModelHit(params: { return canonicalModel; } +function recordOpenAICodexRuntimeHit(params: { + hits: CodexRouteHit[]; + path: string; + model: string; + runtime?: string; +}): boolean { + if (!isOpenAIModelRef(params.model)) { + return false; + } + params.hits.push({ + path: params.path, + model: params.model, + canonicalModel: params.model, + ...(params.runtime ? { runtime: params.runtime } : {}), + setsRuntime: true, + }); + return true; +} + +function resolvePrimaryModelConfigValue(value: unknown): string | undefined { + if (typeof value === "string") { + return value.trim() || undefined; + } + const record = asMutableRecord(value); + if (typeof record?.primary === "string") { + return record.primary.trim() || undefined; + } + return undefined; +} + function collectStringModelSlot(params: { hits: CodexRouteHit[]; path: string; @@ -195,6 +222,7 @@ function collectAgentModelRefs(params: { path: string; runtime?: string; collectModelsMap?: boolean; + openAIRequiresCodexRuntime?: boolean; }): void { const agent = asMutableRecord(params.agent); if (!agent) { @@ -209,6 +237,20 @@ function collectAgentModelRefs(params: { setsRuntimeOnPrimary: key === "model", }); } + const primaryModel = resolvePrimaryModelConfigValue(agent.model); + if ( + params.openAIRequiresCodexRuntime === true && + normalizeString(params.runtime) === "pi" && + primaryModel && + isOpenAIModelRef(primaryModel) + ) { + recordOpenAICodexRuntimeHit({ + hits: params.hits, + path: `${params.path}.model`, + model: primaryModel, + runtime: params.runtime, + }); + } collectStringModelSlot({ hits: params.hits, path: `${params.path}.heartbeat.model`, @@ -243,12 +285,17 @@ function collectConfigModelRefs(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): C const hits: CodexRouteHit[] = []; const defaults = cfg.agents?.defaults; const defaultsRuntime = defaults?.agentRuntime; + const openAIRequiresCodexRuntime = openAIRouteRequiresCodexRuntime({ + provider: "openai", + config: cfg, + }); collectAgentModelRefs({ hits, agent: defaults, path: "agents.defaults", runtime: resolveRuntime({ env, defaultsRuntime }), collectModelsMap: true, + openAIRequiresCodexRuntime, }); for (const [index, agent] of (cfg.agents?.list ?? []).entries()) { @@ -262,6 +309,7 @@ function collectConfigModelRefs(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): C agentRuntime: agent.agentRuntime, defaultsRuntime, }), + openAIRequiresCodexRuntime, }); } @@ -423,10 +471,12 @@ function rewriteAgentModelRefs(params: { runtime: CodexRepairRuntime; currentRuntime: string; rewriteModelsMap?: boolean; + openAIRequiresCodexRuntime?: boolean; }): void { if (!params.agent) { return; } + let rewrotePrimaryModel = false; for (const key of AGENT_MODEL_CONFIG_KEYS) { const rewrotePrimary = rewriteModelConfigSlot({ hits: params.hits, @@ -437,11 +487,29 @@ function rewriteAgentModelRefs(params: { setsRuntimeOnPrimary: key === "model", }); if (key === "model" && rewrotePrimary) { + rewrotePrimaryModel = true; const agentRuntime = asMutableRecord(params.agent.agentRuntime) ?? {}; agentRuntime.id = params.runtime; params.agent.agentRuntime = agentRuntime; } } + const primaryModel = resolvePrimaryModelConfigValue(params.agent.model); + if ( + !rewrotePrimaryModel && + params.openAIRequiresCodexRuntime === true && + normalizeString(params.currentRuntime) === "pi" && + primaryModel && + recordOpenAICodexRuntimeHit({ + hits: params.hits, + path: `${params.path}.model`, + model: primaryModel, + runtime: params.currentRuntime, + }) + ) { + const agentRuntime = asMutableRecord(params.agent.agentRuntime) ?? {}; + agentRuntime.id = params.runtime; + params.agent.agentRuntime = agentRuntime; + } rewriteStringModelSlot({ hits: params.hits, container: asMutableRecord(params.agent.heartbeat), @@ -484,6 +552,10 @@ function rewriteConfigModelRefs(params: { const nextConfig = structuredClone(params.cfg); const hits: CodexRouteHit[] = []; const defaultsRuntime = nextConfig.agents?.defaults?.agentRuntime; + const openAIRequiresCodexRuntime = openAIRouteRequiresCodexRuntime({ + provider: "openai", + config: nextConfig, + }); rewriteAgentModelRefs({ hits, agent: asMutableRecord(nextConfig.agents?.defaults), @@ -491,6 +563,7 @@ function rewriteConfigModelRefs(params: { runtime: params.runtime, currentRuntime: resolveRuntime({ env: params.env, defaultsRuntime }), rewriteModelsMap: true, + openAIRequiresCodexRuntime, }); for (const [index, agent] of (nextConfig.agents?.list ?? []).entries()) { const id = typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : String(index); @@ -504,6 +577,7 @@ function rewriteConfigModelRefs(params: { agentRuntime: agent.agentRuntime, defaultsRuntime, }), + openAIRequiresCodexRuntime, }); } const channelsModelByChannel = asMutableRecord(nextConfig.channels?.modelByChannel); @@ -561,47 +635,13 @@ function rewriteConfigModelRefs(params: { }; } -function hasUsableCodexOAuthProfile(cfg: OpenClawConfig): boolean { - try { - const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false, config: cfg }); - const now = Date.now(); - return resolveAuthProfileOrder({ cfg, store, provider: "openai-codex" }).some((profileId) => { - const credential = store.profiles[profileId]; - if (!credential || credential.type !== "oauth") { - return false; - } - const unusableUntil = resolveProfileUnusableUntilForDisplay(store, profileId); - if (unusableUntil && now < unusableUntil) { - return false; - } - return evaluateStoredCredentialEligibility({ credential, now }).eligible; - }); - } catch { - return false; - } -} - -function isCodexPluginInstalledAndEnabled(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): boolean { - const index = loadInstalledPluginIndex({ config: cfg, env }); - const record = getInstalledPluginRecord(index, "codex"); - if (!record || !record.startup.agentHarnesses.includes("codex")) { - return false; - } - return isInstalledPluginEnabled(index, "codex", cfg); -} - function resolveCodexRepairRuntime(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; codexRuntimeReady?: boolean; }): CodexRepairRuntime { - if (params.codexRuntimeReady !== undefined) { - return params.codexRuntimeReady ? "codex" : "pi"; - } - return isCodexPluginInstalledAndEnabled(params.cfg, params.env) && - hasUsableCodexOAuthProfile(params.cfg) - ? "codex" - : "pi"; + void params; + return "codex"; } function formatCodexRouteChange(hit: CodexRouteHit, runtime: CodexRepairRuntime): string { @@ -609,6 +649,14 @@ function formatCodexRouteChange(hit: CodexRouteHit, runtime: CodexRepairRuntime) return `${hit.path}: ${hit.model} -> ${hit.canonicalModel}${suffix}.`; } +function formatCodexRouteWarning(hit: CodexRouteHit): string { + const runtime = hit.runtime ? `; current runtime is "${hit.runtime}"` : ""; + if (hit.model === hit.canonicalModel) { + return `- ${hit.path}: ${hit.model} should use agentRuntime.id "codex"${runtime}.`; + } + return `- ${hit.path}: ${hit.model} should become ${hit.canonicalModel}${runtime}.`; +} + export function collectCodexRouteWarnings(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -619,14 +667,9 @@ export function collectCodexRouteWarnings(params: { } return [ [ - "- Legacy `openai-codex/*` model refs should be rewritten to `openai/*`.", - ...hits.map( - (hit) => - `- ${hit.path}: ${hit.model} should become ${hit.canonicalModel}${ - hit.runtime ? `; current runtime is "${hit.runtime}"` : "" - }.`, - ), - '- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions; primary routes select `agentRuntime.id: "codex"` only when Codex is installed, enabled, and has usable OAuth, otherwise they select OpenClaw PI.', + '- OpenAI Codex routes should use canonical `openai/*` model refs with `agentRuntime.id: "codex"`.', + ...hits.map(formatCodexRouteWarning), + '- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*` with `agentRuntime.id: "codex"`.', ].join("\n"), ]; } @@ -722,9 +765,29 @@ function clearStaleCodexAuthOverride(entry: SessionEntry, runtime: CodexRepairRu return true; } +function hasOpenAIAgentRoute(entry: SessionEntry): boolean { + return ( + normalizeString(entry.modelProvider) === "openai" || + normalizeString(entry.providerOverride) === "openai" || + isOpenAIModelRef(entry.model) || + isOpenAIModelRef(entry.modelOverride) + ); +} + +function hasOpenAIPiRuntimePin(entry: SessionEntry, cfg?: OpenClawConfig): boolean { + return ( + hasOpenAIAgentRoute(entry) && + (hasOpenAICodexAuthProfileOverride(entry.authProfileOverride) || + openAIRouteRequiresCodexRuntime({ provider: "openai", config: cfg })) && + (normalizeString(entry.agentHarnessId) === "pi" || + normalizeString(entry.agentRuntimeOverride) === "pi") + ); +} + export function repairCodexSessionStoreRoutes(params: { store: Record; runtime: CodexRepairRuntime; + cfg?: OpenClawConfig; now?: number; }): SessionRouteRepairResult { const now = params.now ?? Date.now(); @@ -746,16 +809,16 @@ export function repairCodexSessionStoreRoutes(params: { const changedModelRoute = changedRuntimeModelRoute || changedOverrideModelRoute; const changedFallbackNotice = clearStaleCodexFallbackNotice(entry); const changedAuthOverride = clearStaleCodexAuthOverride(entry, params.runtime); - const shouldRepinCodexHarness = entry.agentHarnessId === "codex" && params.runtime !== "codex"; + const shouldRepairOpenAIPiPin = hasOpenAIPiRuntimePin(entry, params.cfg); if ( !changedModelRoute && !changedFallbackNotice && !changedAuthOverride && - !shouldRepinCodexHarness + !shouldRepairOpenAIPiPin ) { continue; } - if (changedModelRoute || shouldRepinCodexHarness) { + if (changedModelRoute || shouldRepairOpenAIPiPin) { entry.agentHarnessId = params.runtime; entry.agentRuntimeOverride = params.runtime; } @@ -771,7 +834,9 @@ export function repairCodexSessionStoreRoutes(params: { function scanCodexSessionStoreRoutes( store: Record, runtime: CodexRepairRuntime, + cfg?: OpenClawConfig, ): string[] { + void runtime; return Object.entries(store).flatMap(([sessionKey, entry]) => { if (!entry) { return []; @@ -783,8 +848,7 @@ function scanCodexSessionStoreRoutes( isOpenAICodexModelRef(entry.modelOverride) || isOpenAICodexModelRef(entry.fallbackNoticeSelectedModel) || isOpenAICodexModelRef(entry.fallbackNoticeActiveModel) || - (runtime !== "codex" && entry.authProfileOverride?.startsWith("openai-codex:") === true) || - (runtime !== "codex" && entry.agentHarnessId === "codex"); + hasOpenAIPiRuntimePin(entry, cfg); return hasLegacyRoute ? [sessionKey] : []; }); } @@ -814,7 +878,11 @@ export async function maybeRepairCodexSessionRoutes(params: { codexRuntimeReady: params.codexRuntimeReady, }); const stale = targets.flatMap((target) => { - const sessionKeys = scanCodexSessionStoreRoutes(loadSessionStore(target.storePath), runtime); + const sessionKeys = scanCodexSessionStoreRoutes( + loadSessionStore(target.storePath), + runtime, + params.cfg, + ); return sessionKeys.map((sessionKey) => `${target.agentId}:${sessionKey}`); }); return { @@ -845,13 +913,14 @@ export async function maybeRepairCodexSessionRoutes(params: { const staleSessionKeys = scanCodexSessionStoreRoutes( loadSessionStore(target.storePath), runtime, + params.cfg, ); if (staleSessionKeys.length === 0) { continue; } const result = await updateSessionStore( target.storePath, - (store) => repairCodexSessionStoreRoutes({ store, runtime }), + (store) => repairCodexSessionStoreRoutes({ store, runtime, cfg: params.cfg }), { skipMaintenance: true }, ); if (!result.changed) { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 1531d1ba597..a3973a5bca1 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -1262,6 +1262,83 @@ describe("repairMissingConfiguredPluginInstalls", () => { }); }); + it("repairs a missing Codex plugin selected by a canonical OpenAI model", async () => { + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "codex", + targetDir: "/tmp/openclaw-plugins/codex", + version: "2026.5.2", + npmResolution: { + name: "@openclaw/codex", + version: "2026.5.2", + resolvedSpec: "@openclaw/codex@2026.5.2", + integrity: "sha512-codex", + resolvedAt: "2026-05-01T00:00:00.000Z", + }, + }); + mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([ + { + id: "codex", + label: "Codex", + install: { + npmSpec: "@openclaw/codex", + defaultChoice: "npm", + }, + }, + ]); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/codex", + expectedPluginId: "codex", + trustedSourceLinkedOfficialInstall: true, + }), + ); + expect(result).toEqual({ + changes: ['Installed missing configured plugin "codex" from @openclaw/codex.'], + warnings: [], + }); + }); + + it("does not repair Codex for custom OpenAI-compatible models", async () => { + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + agents: { + defaults: { + model: { primary: "openai/custom-gpt" }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + models: [], + }, + }, + }, + }, + env: {}, + }); + + expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(result).toEqual({ changes: [], warnings: [] }); + }); + it("does not install a blocked downloadable plugin from explicit channel ids", async () => { mocks.listChannelPluginCatalogEntries.mockReturnValue([ { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 2b852c54205..dc239a4c280 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -1,10 +1,12 @@ import { existsSync } from "node:fs"; import path from "node:path"; +import { modelSelectionShouldEnsureCodexPlugin } from "../../../agents/openai-codex-routing.js"; import { listExplicitlyDisabledChannelIdsForConfig, listPotentialConfiguredChannelIds, } from "../../../channels/config-presence.js"; import { listChannelPluginCatalogEntries } from "../../../channels/plugins/catalog.js"; +import { collectConfiguredModelRefs } from "../../../config/model-refs.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../../config/types.plugins.js"; import { parseClawHubPluginSpec } from "../../../infra/clawhub-spec.js"; @@ -149,6 +151,13 @@ function collectConfiguredPluginIds(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv ids.add("acpx"); } addConfiguredAgentRuntimePluginIds(ids, cfg, env); + if ( + collectConfiguredModelRefs(cfg).some(({ value }) => + modelSelectionShouldEnsureCodexPlugin({ config: cfg, model: value }), + ) + ) { + ids.add("codex"); + } return ids; } diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index 69ffdb7cda7..1fa41bb1b86 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -170,6 +170,50 @@ describe("configured plugin install release step", () => { expect(result.channelIds).toEqual([]); }); + it("collects Codex from canonical OpenAI model selections", async () => { + const { collectReleaseConfiguredPluginIds } = + await import("./release-configured-plugin-installs.js"); + const result = collectReleaseConfiguredPluginIds({ + cfg: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + }, + }, + }, + env: {}, + }); + + expect(result.pluginIds).toEqual(["codex"]); + expect(result.channelIds).toEqual([]); + }); + + it("does not collect Codex from custom OpenAI-compatible model selections", async () => { + const { collectReleaseConfiguredPluginIds } = + await import("./release-configured-plugin-installs.js"); + const result = collectReleaseConfiguredPluginIds({ + cfg: { + agents: { + defaults: { + model: { primary: "openai/custom-gpt" }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + models: [], + }, + }, + }, + }, + env: {}, + }); + + expect(result.pluginIds).toEqual([]); + expect(result.channelIds).toEqual([]); + }); + it("collects external web search and ACP runtime plugins from config-only usage", async () => { const { collectReleaseConfiguredPluginIds } = await import("./release-configured-plugin-installs.js"); diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 69a7f2138f5..5e2ccc78938 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -1,4 +1,5 @@ import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-runtimes.js"; +import { modelSelectionShouldEnsureCodexPlugin } from "../../../agents/openai-codex-routing.js"; import { listPotentialConfiguredChannelPresenceSignals } from "../../../channels/config-presence.js"; import { normalizeChatChannelId } from "../../../channels/registry.js"; import { isChannelConfigured } from "../../../config/channel-configured.js"; @@ -182,6 +183,14 @@ function collectProviderPluginIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): return [...ids].toSorted((left, right) => left.localeCompare(right)); } +function collectModelSelectedRuntimePluginIds(cfg: OpenClawConfig): string[] { + return collectConfiguredModelRefs(cfg).some(({ value }) => + modelSelectionShouldEnsureCodexPlugin({ config: cfg, model: value }), + ) + ? ["codex"] + : []; +} + function collectAgentHarnessRuntimePluginIds( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, @@ -290,6 +299,9 @@ export function collectReleaseConfiguredPluginIds(params: { for (const pluginId of collectAgentHarnessRuntimePluginIds(params.cfg, env)) { addEligiblePluginId(params.cfg, pluginIds, pluginId); } + for (const pluginId of collectModelSelectedRuntimePluginIds(params.cfg)) { + addEligiblePluginId(params.cfg, pluginIds, pluginId); + } for (const pluginId of collectWebSearchPluginIds(params.cfg)) { addEligiblePluginId(params.cfg, pluginIds, pluginId); } diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index ccf69d9a2de..44a03b71639 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -186,7 +186,7 @@ beforeEach(() => { }); describe("promptDefaultModel", () => { - it("adds auth-route hints for OpenAI API and Codex OAuth models", async () => { + it("adds runtime-route hints for canonical and legacy OpenAI Codex models", async () => { loadModelCatalog.mockResolvedValue([ { provider: "openai", @@ -216,11 +216,11 @@ describe("promptDefaultModel", () => { expect.arrayContaining([ expect.objectContaining({ value: "openai/gpt-5.5", - hint: expect.stringContaining("API key route"), + hint: expect.stringContaining("Codex runtime route"), }), expect.objectContaining({ value: "openai-codex/gpt-5.5", - hint: expect.stringContaining("ChatGPT OAuth route"), + hint: expect.stringContaining("legacy Codex OAuth route"), }), ]), ); diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index 3e9e4f5364d..f72d0698d19 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -5,8 +5,14 @@ export { promptDefaultModel, promptModelAllowlist, } from "../flows/model-picker.js"; +export { + ensureCodexRuntimePluginForModelSelection, + repairCodexRuntimePluginInstallForModelSelection, + selectedModelShouldEnsureCodexRuntimePlugin, +} from "./codex-runtime-plugin-install.js"; export type { PromptDefaultModelParams, PromptDefaultModelResult, PromptModelAllowlistResult, } from "../flows/model-picker.js"; +export type { CodexRuntimePluginInstallResult } from "./codex-runtime-plugin-install.js"; diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index cb09062cced..ff0a19f2217 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -25,6 +25,11 @@ const mocks = vi.hoisted(() => ({ listProfilesForProvider: vi.fn(), promoteAuthProfileInOrder: vi.fn(), clearAuthProfileCooldown: vi.fn(), + repairCodexRuntimePluginInstallForModelSelection: vi.fn(async () => ({ + required: false, + changes: [], + warnings: [], + })), })); vi.mock("../../agents/auth-profiles/profiles.js", () => ({ @@ -130,6 +135,11 @@ vi.mock("../auth-token.js", () => ({ validateAnthropicSetupToken: vi.fn(() => undefined), })); +vi.mock("../codex-runtime-plugin-install.js", () => ({ + repairCodexRuntimePluginInstallForModelSelection: + mocks.repairCodexRuntimePluginInstallForModelSelection, +})); + vi.mock("../../plugins/provider-auth-choice-helpers.js", () => { const normalize = (value: string | undefined) => value?.trim().toLowerCase() ?? ""; const isRecord = (value: unknown): value is Record => @@ -313,7 +323,15 @@ describe("modelsAuthLoginCommand", () => { }, }, ], - defaultModel: "openai-codex/gpt-5.5", + configPatch: { + agents: { + defaults: { + agentRuntime: { id: "codex" }, + models: { "openai/gpt-5.5": {} }, + }, + }, + }, + defaultModel: "openai/gpt-5.5", }); mocks.resolvePluginProviders.mockReturnValue([ createProvider({ @@ -407,7 +425,7 @@ describe("modelsAuthLoginCommand", () => { "Auth profile: openai-codex:user@example.com (openai-codex/oauth)", ); expect(runtime.log).toHaveBeenCalledWith( - "Default model available: openai-codex/gpt-5.5 (use --set-default to apply)", + "Default model available: openai/gpt-5.5 (use --set-default to apply)", ); expect(runtime.log).toHaveBeenCalledWith( "Tip: Codex-capable models can use native Codex web search. Enable it with openclaw configure --section web (recommended mode: cached). Docs: https://docs.openclaw.ai/tools/web", @@ -684,15 +702,22 @@ describe("modelsAuthLoginCommand", () => { }, }, ], - configPatch: { agents: { defaults: { models: { "openai-codex/gpt-5.5": {} } } } }, - defaultModel: "openai-codex/gpt-5.5", + configPatch: { + agents: { + defaults: { + agentRuntime: { id: "codex" }, + models: { "openai/gpt-5.5": {} }, + }, + }, + }, + defaultModel: "openai/gpt-5.5", }); await modelsAuthLoginCommand({ provider: "openai-codex" }, runtime); expect(lastUpdatedConfig?.agents?.defaults?.models).toEqual({ ...existingModels, - "openai-codex/gpt-5.5": {}, + "openai/gpt-5.5": { alias: "gpt55" }, }); }); @@ -710,13 +735,14 @@ describe("modelsAuthLoginCommand", () => { await modelsAuthLoginCommand({ provider: "openai-codex", setDefault: true }, runtime); expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ - primary: "openai-codex/gpt-5.5", + primary: "openai/gpt-5.5", }); + expect(lastUpdatedConfig?.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); expect(lastUpdatedConfig?.agents?.defaults?.models).toEqual({ "anthropic/claude-opus-4-6": {}, - "openai-codex/gpt-5.5": {}, + "openai/gpt-5.5": {}, }); - expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.5"); + expect(runtime.log).toHaveBeenCalledWith("Default model set to openai/gpt-5.5"); }); it("survives lockout clearing failure without blocking login", async () => { diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 033815c0816..3fdf2a49711 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -47,6 +47,7 @@ import { import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { validateAnthropicSetupToken } from "../auth-token.js"; +import { repairCodexRuntimePluginInstallForModelSelection } from "../codex-runtime-plugin-install.js"; import { isRemoteEnvironment } from "../oauth-env.js"; import { loadValidConfigOrThrow, resolveKnownAgentId, updateConfig } from "./shared.js"; @@ -243,6 +244,7 @@ async function persistProviderAuthResult(params: { agentDir: string; runtime: RuntimeEnv; prompter: ReturnType; + workspaceDir: string; setDefault?: boolean; }) { for (const profile of params.result.profiles) { @@ -258,7 +260,7 @@ async function persistProviderAuthResult(params: { }); } - await updateConfig((cfg) => { + const updated = await updateConfig((cfg) => { let next = cfg; if (params.result.configPatch) { next = applyProviderAuthConfigPatch(next, params.result.configPatch, { @@ -277,6 +279,15 @@ async function persistProviderAuthResult(params: { } return next; }); + if (params.setDefault && params.result.defaultModel) { + const repaired = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: updated, + model: params.result.defaultModel, + }); + for (const warning of repaired.warnings) { + params.runtime.error?.(warning); + } + } logConfigUpdated(params.runtime); for (const profile of params.result.profiles) { @@ -331,6 +342,7 @@ async function runProviderAuthMethod(params: { agentDir: params.agentDir, runtime: params.runtime, prompter: params.prompter, + workspaceDir: params.workspaceDir, setDefault: params.setDefault, }); } diff --git a/src/commands/models/set.ts b/src/commands/models/set.ts index 3316b05dd8e..e14dff3b596 100644 --- a/src/commands/models/set.ts +++ b/src/commands/models/set.ts @@ -1,12 +1,20 @@ import { logConfigUpdated } from "../../config/logging.js"; import { resolveAgentModelPrimaryValue } from "../../config/model-input.js"; import type { RuntimeEnv } from "../../runtime.js"; +import { repairCodexRuntimePluginInstallForModelSelection } from "../codex-runtime-plugin-install.js"; import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js"; export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) { const updated = await updateConfig((cfg) => { return applyDefaultModelPrimaryUpdate({ cfg, modelRaw, field: "model" }); }); + const repaired = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: updated, + model: resolveAgentModelPrimaryValue(updated.agents?.defaults?.model) ?? modelRaw, + }); + for (const warning of repaired.warnings) { + runtime.error?.(warning); + } logConfigUpdated(runtime); runtime.log( diff --git a/src/commands/models/shared.test.ts b/src/commands/models/shared.test.ts index 09ea65a1aef..72f3392c488 100644 --- a/src/commands/models/shared.test.ts +++ b/src/commands/models/shared.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { loadValidConfigOrThrow, updateConfig } from "./shared.js"; +import { applyDefaultModelPrimaryUpdate, loadValidConfigOrThrow, updateConfig } from "./shared.js"; const mocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), @@ -63,4 +63,47 @@ describe("models/shared", () => { baseHash: "config-1", }); }); + + it("leaves OpenAI default model updates on the existing runtime", () => { + const next = applyDefaultModelPrimaryUpdate({ + cfg: {}, + modelRaw: "openai/gpt-5.5", + field: "model", + }); + + expect(next.agents?.defaults?.model).toEqual({ primary: "openai/gpt-5.5" }); + expect(next.agents?.defaults?.agentRuntime).toBeUndefined(); + }); + + it("pins OpenAI Codex default model updates to the Codex runtime", () => { + const next = applyDefaultModelPrimaryUpdate({ + cfg: {}, + modelRaw: "openai-codex/gpt-5.5", + field: "model", + }); + + expect(next.agents?.defaults?.model).toEqual({ primary: "openai-codex/gpt-5.5" }); + expect(next.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); + }); + + it("leaves custom OpenAI-compatible default model updates on the existing runtime", () => { + const next = applyDefaultModelPrimaryUpdate({ + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://compatible.example.test/v1", + api: "openai-responses", + models: [], + }, + }, + }, + }, + modelRaw: "openai/custom-gpt", + field: "model", + }); + + expect(next.agents?.defaults?.model).toEqual({ primary: "openai/custom-gpt" }); + expect(next.agents?.defaults?.agentRuntime).toBeUndefined(); + }); }); diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 48540cd00aa..f55dda644c5 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -7,6 +7,7 @@ import { parseModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; +import { modelSelectionRequiresCodexRuntime } from "../../agents/openai-codex-routing.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { type OpenClawConfig, @@ -220,6 +221,9 @@ export function applyDefaultModelPrimaryUpdate(params: { const existing = toAgentModelListLike( (defaults as Record)[params.field] as AgentModelConfig | undefined, ); + const shouldUseCodexRuntime = + params.field === "model" && + modelSelectionRequiresCodexRuntime({ model: key, config: params.cfg }); return { ...params.cfg, @@ -227,6 +231,7 @@ export function applyDefaultModelPrimaryUpdate(params: { ...params.cfg.agents, defaults: { ...defaults, + ...(shouldUseCodexRuntime ? { agentRuntime: { id: "codex" } } : {}), [params.field]: mergePrimaryFallbackConfig(existing, { primary: key }), models: nextModels, }, diff --git a/src/config/config.model-ref-validation.test.ts b/src/config/config.model-ref-validation.test.ts index 6157e3d6a1e..54e61effce0 100644 --- a/src/config/config.model-ref-validation.test.ts +++ b/src/config/config.model-ref-validation.test.ts @@ -3,7 +3,7 @@ import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; const staleOpenAICodexReason = - 'is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id="codex" for the native Codex runtime.'; + "is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime."; function createModelSuppressionRegistry(): PluginManifestRegistry { return { @@ -123,12 +123,12 @@ describe("config model reference validation", () => { expect(res.issues).toContainEqual({ path: "agents.defaults.model.fallbacks.0", message: - 'Unknown model: openai-codex/gpt-5.2-codex. gpt-5.2-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id="codex" for the native Codex runtime.', + "Unknown model: openai-codex/gpt-5.2-codex. gpt-5.2-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime.", }); expect(res.issues).toContainEqual({ path: "agents.defaults.model.fallbacks.1", message: - 'Unknown model: openai-codex/gpt-5.3-codex. gpt-5.3-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai-codex/gpt-5.5 for PI OAuth, or openai/gpt-5.5 with agentRuntime.id="codex" for the native Codex runtime.', + "Unknown model: openai-codex/gpt-5.3-codex. gpt-5.3-codex is no longer supported for ChatGPT/Codex OAuth accounts. Use openai/gpt-5.5 through the Codex runtime.", }); }); }); diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index f19cc0e0cdd..71c7ec9d204 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -645,6 +645,35 @@ describe("applyPluginAutoEnable core", () => { ]); }); + it("auto-enables Codex when OpenAI agent models use the implicit runtime default", () => { + const result = applyPluginAutoEnable({ + config: { + agents: { + defaults: { + model: "openai/gpt-5.5", + }, + }, + }, + env, + manifestRegistry: makeRegistry([ + { id: "openai", channels: [], providers: ["openai", "openai-codex"] }, + { + id: "codex", + channels: [], + providers: ["codex"], + activation: { onAgentHarnesses: ["codex"] }, + }, + ]), + }); + + expect(result.config.plugins?.entries?.openai?.enabled).toBe(true); + expect(result.config.plugins?.entries?.codex?.enabled).toBe(true); + expect(result.changes).toEqual([ + "openai/gpt-5.5 model configured, enabled automatically.", + "codex agent runtime configured, enabled automatically.", + ]); + }); + it("auto-enables an opt-in plugin when an agent runtime is configured", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts index 5ac54c0b9b2..5bb21b63a0b 100644 --- a/src/crestodian/operations.ts +++ b/src/crestodian/operations.ts @@ -639,6 +639,17 @@ export async function executeCrestodianOperation( Object.assign(cfg, next); }, }); + if (setupModel.model) { + const { repairCodexRuntimePluginInstallForModelSelection } = + await import("../commands/codex-runtime-plugin-install.js"); + const repaired = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: result.nextConfig, + model: setupModel.model, + }); + for (const warning of repaired.warnings) { + runtime.error?.(warning); + } + } const after = await readConfigFileSnapshot(); await appendCrestodianAuditEntry({ operation: "crestodian.setup", @@ -1001,6 +1012,15 @@ export async function executeCrestodianOperation( Object.assign(cfg, next); }, }); + const { repairCodexRuntimePluginInstallForModelSelection } = + await import("../commands/codex-runtime-plugin-install.js"); + const repaired = await repairCodexRuntimePluginInstallForModelSelection({ + cfg: result.nextConfig, + model: operation.model, + }); + for (const warning of repaired.warnings) { + runtime.error?.(warning); + } const after = await readConfigFileSnapshot(); const { resolveAgentModelPrimaryValue } = await import("../config/model-input.js"); const effectiveModel = resolveAgentModelPrimaryValue(result.nextConfig.agents?.defaults?.model); diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index e0794e26fa6..2f82facb8c6 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -176,10 +176,10 @@ function resolveFallbackModelKeys(params: { function resolveModelRouteHint(provider: string): string | undefined { const normalized = normalizeProviderId(provider); if (normalized === "openai") { - return "API key route"; + return "Codex runtime route"; } if (normalized === "openai-codex") { - return "ChatGPT OAuth route"; + return "legacy Codex OAuth route"; } return undefined; } diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index bfa2b095a6d..8ffe317db68 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -1568,6 +1568,14 @@ export const sessionsHandlers: GatewayRequestHandlers = { storeKey: primaryKey, patch: p, loadGatewayModelCatalog: context.loadGatewayModelCatalog, + ensureCodexRuntimePluginInstall: async ({ cfg: installCfg, model }) => { + const { repairCodexRuntimePluginInstallForModelSelection } = + await import("../../commands/codex-runtime-plugin-install.js"); + return await repairCodexRuntimePluginInstallForModelSelection({ + cfg: installCfg, + model, + }); + }, }); }); if (!applied.ok) { diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 47719c5ef3f..10a8a0beded 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; @@ -18,6 +18,7 @@ async function runPatch(params: { cfg?: OpenClawConfig; storeKey?: string; loadGatewayModelCatalog?: ApplySessionsPatchArgs["loadGatewayModelCatalog"]; + ensureCodexRuntimePluginInstall?: ApplySessionsPatchArgs["ensureCodexRuntimePluginInstall"]; }) { return applySessionsPatchToStore({ cfg: params.cfg ?? EMPTY_CFG, @@ -25,6 +26,9 @@ async function runPatch(params: { storeKey: params.storeKey ?? MAIN_SESSION_KEY, patch: params.patch, loadGatewayModelCatalog: params.loadGatewayModelCatalog, + ...(params.ensureCodexRuntimePluginInstall + ? { ensureCodexRuntimePluginInstall: params.ensureCodexRuntimePluginInstall } + : {}), }); } @@ -343,6 +347,140 @@ describe("gateway sessions patch", () => { expect(entry.modelOverride).toBe("claude-sonnet-4-6"); }); + test("installs plugin without forcing runtime for OpenAI session model picks", async () => { + const ensureCodexRuntimePluginInstall = vi.fn(async () => ({ warnings: [] })); + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + "openai/gpt-5.5": {}, + }, + }, + }, + } as OpenClawConfig; + + const entry = expectPatchOk( + await runPatch({ + cfg, + patch: { key: MAIN_SESSION_KEY, model: "openai/gpt-5.5" }, + loadGatewayModelCatalog: async () => [ + { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }, + ], + ensureCodexRuntimePluginInstall, + }), + ); + + expect(entry.providerOverride).toBe("openai"); + expect(entry.modelOverride).toBe("gpt-5.5"); + expect(entry.agentRuntimeOverride).toBeUndefined(); + expect(ensureCodexRuntimePluginInstall).toHaveBeenCalledWith({ + cfg, + model: "openai/gpt-5.5", + }); + }); + + test("sets Codex runtime override and installs plugin for OpenAI Codex session model picks", async () => { + const ensureCodexRuntimePluginInstall = vi.fn(async () => ({ warnings: [] })); + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + "openai-codex/gpt-5.5": {}, + }, + }, + }, + } as OpenClawConfig; + + const entry = expectPatchOk( + await runPatch({ + cfg, + patch: { key: MAIN_SESSION_KEY, model: "openai-codex/gpt-5.5" }, + loadGatewayModelCatalog: async () => [ + { provider: "openai-codex", id: "gpt-5.5", name: "GPT-5.5" }, + ], + ensureCodexRuntimePluginInstall, + }), + ); + + expect(entry.providerOverride).toBe("openai-codex"); + expect(entry.modelOverride).toBe("gpt-5.5"); + expect(entry.agentRuntimeOverride).toBe("codex"); + expect(ensureCodexRuntimePluginInstall).toHaveBeenCalledWith({ + cfg, + model: "openai-codex/gpt-5.5", + }); + }); + + test("keeps custom OpenAI-compatible session model picks on PI", async () => { + const ensureCodexRuntimePluginInstall = vi.fn(async () => ({ warnings: [] })); + + const entry = expectPatchOk( + await runPatch({ + cfg: { + models: { + providers: { + openai: { baseUrl: "https://compatible.example.test/v1", models: [] }, + }, + }, + } as OpenClawConfig, + store: { + [MAIN_SESSION_KEY]: { + sessionId: "session-1", + updatedAt: 1, + agentRuntimeOverride: "codex", + }, + }, + patch: { key: MAIN_SESSION_KEY, model: "openai/custom-gpt" }, + loadGatewayModelCatalog: async () => [ + { provider: "openai", id: "custom-gpt", name: "Custom GPT" }, + ], + ensureCodexRuntimePluginInstall, + }), + ); + + expect(entry.providerOverride).toBe("openai"); + expect(entry.modelOverride).toBe("custom-gpt"); + expect(entry.agentRuntimeOverride).toBeUndefined(); + expect(ensureCodexRuntimePluginInstall).not.toHaveBeenCalled(); + }); + + test("does not reject OpenAI session picks when Codex plugin install repair fails", async () => { + const ensureCodexRuntimePluginInstall = vi.fn(async () => ({ + warnings: ['Failed to install missing configured plugin "codex" from @openclaw/codex.'], + })); + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + "openai/gpt-5.5": {}, + }, + }, + }, + } as OpenClawConfig; + + const entry = expectPatchOk( + await runPatch({ + cfg, + patch: { key: MAIN_SESSION_KEY, model: "openai/gpt-5.5" }, + loadGatewayModelCatalog: async () => [ + { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }, + ], + ensureCodexRuntimePluginInstall, + }), + ); + + expect(entry.providerOverride).toBe("openai"); + expect(entry.modelOverride).toBe("gpt-5.5"); + expect(entry.agentRuntimeOverride).toBeUndefined(); + expect(ensureCodexRuntimePluginInstall).toHaveBeenCalledTimes(1); + }); + test("sets spawnDepth for subagent sessions", async () => { const entry = expectPatchOk( await runPatch({ diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index d4373272bd2..49037f8a77e 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -6,6 +6,10 @@ import { resolveDefaultModelForAgent, resolveSubagentConfiguredModelSelection, } from "../agents/model-selection.js"; +import { + modelSelectionRequiresCodexRuntime, + modelSelectionShouldEnsureCodexPlugin, +} from "../agents/openai-codex-routing.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { formatThinkingLevels, @@ -66,6 +70,39 @@ function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined return undefined; } +async function applyCodexRuntimeForSessionModelSelection(params: { + cfg: OpenClawConfig; + entry: SessionEntry; + provider: string; + model: string; + ensurePluginInstall?: (params: { cfg: OpenClawConfig; model: string }) => Promise<{ + warnings: string[]; + }>; +}): Promise<{ ok: true } | { ok: false; error: ErrorShape }> { + const modelRef = `${params.provider}/${params.model}`; + const requiresCodexRuntime = modelSelectionRequiresCodexRuntime({ + model: modelRef, + config: params.cfg, + }); + if (!requiresCodexRuntime) { + if (normalizeOptionalLowercaseString(params.entry.agentRuntimeOverride) === "codex") { + delete params.entry.agentRuntimeOverride; + } + } else { + params.entry.agentRuntimeOverride = "codex"; + } + if ( + params.ensurePluginInstall && + modelSelectionShouldEnsureCodexPlugin({ model: modelRef, config: params.cfg }) + ) { + await params.ensurePluginInstall({ + cfg: params.cfg, + model: modelRef, + }); + } + return { ok: true }; +} + function supportsSpawnLineage(storeKey: string): boolean { return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey); } @@ -92,6 +129,9 @@ export async function applySessionsPatchToStore(params: { storeKey: string; patch: SessionsPatchParams; loadGatewayModelCatalog?: () => Promise; + ensureCodexRuntimePluginInstall?: (params: { cfg: OpenClawConfig; model: string }) => Promise<{ + warnings: string[]; + }>; }): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> { const { cfg, store, storeKey, patch } = params; const now = Date.now(); @@ -410,6 +450,9 @@ export async function applySessionsPatchToStore(params: { }, markLiveSwitchPending: true, }); + if (normalizeOptionalLowercaseString(next.agentRuntimeOverride) === "codex") { + delete next.agentRuntimeOverride; + } } else if (raw !== undefined) { const trimmed = normalizeOptionalString(raw) ?? ""; if (!trimmed) { @@ -450,6 +493,18 @@ export async function applySessionsPatchToStore(params: { }, markLiveSwitchPending: true, }); + const runtimeApplied = await applyCodexRuntimeForSessionModelSelection({ + cfg, + entry: next, + provider: resolved.ref.provider, + model: resolved.ref.model, + ...(params.ensureCodexRuntimePluginInstall + ? { ensurePluginInstall: params.ensureCodexRuntimePluginInstall } + : {}), + }); + if (!runtimeApplied.ok) { + return runtimeApplied; + } } } diff --git a/src/plugin-sdk/test-helpers/provider-auth-contract.ts b/src/plugin-sdk/test-helpers/provider-auth-contract.ts index 14ad55e27ba..5ebc6ec3b4d 100644 --- a/src/plugin-sdk/test-helpers/provider-auth-contract.ts +++ b/src/plugin-sdk/test-helpers/provider-auth-contract.ts @@ -94,12 +94,13 @@ function buildOpenAICodexOAuthResult(params: { agents: { defaults: { models: { - "openai-codex/gpt-5.5": {}, + "openai/gpt-5.5": {}, }, + agentRuntime: { id: "codex" }, }, }, }, - defaultModel: "openai-codex/gpt-5.5", + defaultModel: "openai/gpt-5.5", notes: undefined, }; } diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index f6de3a83069..526c3228341 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -1401,6 +1401,27 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); + it("includes Codex when an OpenAI agent model uses the implicit runtime default", () => { + expectStartupPluginIdsCase({ + config: createStartupConfig({ + modelId: "openai/gpt-5.5", + enabledPluginIds: ["codex"], + }), + expected: ["demo-channel", "browser", "codex", "memory-core"], + }); + }); + + it("does not include Codex for custom OpenAI-compatible agent models", () => { + expectStartupPluginIdsCase({ + config: createStartupConfig({ + providerIds: ["openai"], + modelId: "openai/custom-gpt", + enabledPluginIds: ["codex"], + }), + expected: ["demo-channel", "browser", "memory-core"], + }); + }); + it("includes required agent harness owner plugins when an agent override forces the runtime", () => { expectStartupPluginIdsCase({ config: createStartupConfig({ diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index 7eefbb0117d..e6bbb60e754 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -1,4 +1,5 @@ import { normalizeProviderId } from "../agents/model-selection.js"; +import { modelSelectionRequiresCodexRuntime } from "../agents/openai-codex-routing.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty, @@ -134,18 +135,24 @@ export function applyDefaultModel( : existingModel && typeof existingModel === "object" ? (existingModel as { primary?: string }).primary : undefined; + const primary = opts?.preserveExistingPrimary === true ? (existingPrimary ?? model) : model; + const shouldUseCodexRuntime = modelSelectionRequiresCodexRuntime({ + model: primary, + config: cfg, + }); return { ...cfg, agents: { ...cfg.agents, defaults: { ...cfg.agents?.defaults, + ...(shouldUseCodexRuntime ? { agentRuntime: { id: "codex" } } : {}), models, model: { ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } : undefined), - primary: opts?.preserveExistingPrimary === true ? (existingPrimary ?? model) : model, + primary, }, }, }, diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 0f9184e9e35..eb744865247 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -134,6 +134,8 @@ async function applyDefaultModelFromAuthChoice(params: { selectedModelDisplay?: string; preserveExistingDefaultModel: boolean | undefined; prompter: WizardPrompter; + runtime: RuntimeEnv; + workspaceDir?: string; runSelectedModelHook: (config: OpenClawConfig) => Promise; }): Promise { const defaultModelBaseConfig = params.configBeforeProviderAuth ?? params.config; @@ -146,10 +148,21 @@ async function applyDefaultModelFromAuthChoice(params: { params.preserveExistingDefaultModel === true ? restoreConfiguredPrimaryModel(params.config, defaultModelBaseConfig) : params.config; - const nextConfig = applyDefaultModel(defaultModelConfig, params.selectedModel, { + let nextConfig = applyDefaultModel(defaultModelConfig, params.selectedModel, { preserveExistingPrimary: params.preserveExistingDefaultModel === true, }); if (!preservesDifferentPrimary) { + const { ensureCodexRuntimePluginForModelSelection } = + await import("../commands/codex-runtime-plugin-install.js"); + nextConfig = ( + await ensureCodexRuntimePluginForModelSelection({ + cfg: nextConfig, + model: params.selectedModel, + prompter: params.prompter, + runtime: params.runtime, + ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), + }) + ).cfg; await params.runSelectedModelHook(nextConfig); } await noteDefaultModelResult({ @@ -425,6 +438,8 @@ export async function applyAuthChoiceLoadedPluginProvider( selectedModelDisplay, preserveExistingDefaultModel: params.preserveExistingDefaultModel, prompter: params.prompter, + runtime: params.runtime, + workspaceDir, runSelectedModelHook: async (config) => { await runProviderModelSelectedHook({ config, @@ -517,6 +532,8 @@ export async function applyAuthChoicePluginProvider( selectedModelDisplay, preserveExistingDefaultModel: params.preserveExistingDefaultModel, prompter: params.prompter, + runtime: params.runtime, + workspaceDir, runSelectedModelHook: async (config) => { await runProviderModelSelectedHook({ config, diff --git a/src/plugins/provider-model-primary.ts b/src/plugins/provider-model-primary.ts index e3cc3069d30..0a88fd915e3 100644 --- a/src/plugins/provider-model-primary.ts +++ b/src/plugins/provider-model-primary.ts @@ -1,3 +1,4 @@ +import { modelSelectionRequiresCodexRuntime } from "../agents/openai-codex-routing.js"; import type { AgentModelListConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -48,6 +49,7 @@ export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawC const defaults = cfg.agents?.defaults; const existingModel = defaults?.model; const existingModels = defaults?.models; + const shouldUseCodexRuntime = modelSelectionRequiresCodexRuntime({ model, config: cfg }); const fallbacks = typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel ? (existingModel as { fallbacks?: string[] }).fallbacks @@ -62,6 +64,7 @@ export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawC ...(fallbacks ? { fallbacks } : undefined), primary: model, }, + ...(shouldUseCodexRuntime ? { agentRuntime: { id: "codex" } } : undefined), models: { ...existingModels, [model]: existingModels?.[model] ?? {}, diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index ec3d511f92c..ad44b060f79 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -5,6 +5,7 @@ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js"; +import type { OpenClawConfig } from "../config/config.js"; import type { PluginCompatibilityNotice } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter, WizardSelectParams } from "./prompts.js"; @@ -41,6 +42,13 @@ const resolvePluginProvidersRuntime = vi.hoisted(() => ); const warnIfModelConfigLooksOff = vi.hoisted(() => vi.fn(async () => {})); const applyPrimaryModel = vi.hoisted(() => vi.fn((cfg) => cfg)); +const ensureCodexRuntimePluginForModelSelection = vi.hoisted(() => + vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg, + required: false, + installed: false, + })), +); const promptDefaultModel = vi.hoisted(() => vi.fn(async () => ({}))); const promptCustomApiConfig = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config }))); const configureGatewayForSetup = vi.hoisted(() => @@ -192,6 +200,7 @@ vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ })); vi.mock("../commands/model-picker.js", () => ({ + ensureCodexRuntimePluginForModelSelection, applyPrimaryModel, promptDefaultModel, })); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index c65613c4060..c175e32208e 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -617,7 +617,8 @@ export async function runSetupWizard( // Explicit skip should stay cold: do not bootstrap auth/profile machinery // or run model/auth checks when the caller already chose to skip setup. if (authChoiceFromPrompt) { - const { applyPrimaryModel, promptDefaultModel } = await loadModelPickerModule(); + const { applyPrimaryModel, ensureCodexRuntimePluginForModelSelection, promptDefaultModel } = + await loadModelPickerModule(); const modelSelection = await promptDefaultModel({ config: nextConfig, prompter, @@ -633,6 +634,15 @@ export async function runSetupWizard( } if (modelSelection.model) { nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); + nextConfig = ( + await ensureCodexRuntimePluginForModelSelection({ + cfg: nextConfig, + model: modelSelection.model, + prompter, + runtime, + workspaceDir, + }) + ).cfg; } const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); @@ -643,7 +653,7 @@ export async function runSetupWizard( const [ { applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff }, - { applyPrimaryModel, promptDefaultModel }, + { applyPrimaryModel, ensureCodexRuntimePluginForModelSelection, promptDefaultModel }, ] = await Promise.all([loadAuthChoiceModule(), loadModelPickerModule()]); const authResult = await applyAuthChoice({ authChoice, @@ -665,6 +675,15 @@ export async function runSetupWizard( } if (authResult.agentModelOverride) { nextConfig = applyPrimaryModel(nextConfig, authResult.agentModelOverride); + nextConfig = ( + await ensureCodexRuntimePluginForModelSelection({ + cfg: nextConfig, + model: authResult.agentModelOverride, + prompter, + runtime, + workspaceDir, + }) + ).cfg; } const authChoiceModelSelectionPolicy = await resolveAuthChoiceModelSelectionPolicy({ @@ -692,6 +711,15 @@ export async function runSetupWizard( } if (modelSelection.model) { nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); + nextConfig = ( + await ensureCodexRuntimePluginForModelSelection({ + cfg: nextConfig, + model: modelSelection.model, + prompter, + runtime, + workspaceDir, + }) + ).cfg; } }