From aa27e27f360646350c0d923c66a6b35d80839d4d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 16:56:49 -0700 Subject: [PATCH] fix(models): normalize provider runtime selection (#71259) * fix(models): normalize provider runtime selection * fix(models): reverse codex-only runtime migration * fix(models): default runtime selection to pi * fix(status): label model runtime clearly * fix(status): align pi runtime label * fix(plugins): align tool result middleware runtime naming * fix(models): validate runtime overrides --- CHANGELOG.md | 2 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/channels/discord.md | 2 +- docs/cli/status.md | 2 +- docs/concepts/model-providers.md | 6 + docs/gateway/config-agents.md | 6 +- docs/plugins/building-plugins.md | 4 +- docs/plugins/codex-harness.md | 22 ++- docs/plugins/manifest.md | 4 +- docs/plugins/sdk-agent-harness.md | 6 +- docs/plugins/sdk-migration.md | 10 +- docs/plugins/sdk-overview.md | 8 +- docs/providers/google.md | 14 +- docs/providers/openai.md | 9 +- docs/tools/slash-commands.md | 4 +- extensions/anthropic/cli-migration.test.ts | 61 ++++--- extensions/anthropic/cli-migration.ts | 57 ++++-- extensions/anthropic/config-defaults.ts | 12 +- extensions/anthropic/index.test.ts | 17 +- extensions/anthropic/register.runtime.ts | 17 +- .../src/app-server/dynamic-tools.test.ts | 12 +- .../codex/src/app-server/dynamic-tools.ts | 2 +- .../discord/src/monitor/model-picker.test.ts | 64 ++++++- .../discord/src/monitor/model-picker.ts | 109 ++++++++++- .../discord/src/monitor/native-command-ui.ts | 93 +++++++++- .../native-command.model-picker.test.ts | 50 ++++- extensions/google/gemini-cli-provider.ts | 12 +- .../mattermost/src/mattermost/model-picker.ts | 1 + .../src/mattermost/slash-commands.ts | 2 +- extensions/tokenjuice/index.test.ts | 4 +- extensions/tokenjuice/index.ts | 2 +- extensions/tokenjuice/manifest.test.ts | 4 +- extensions/tokenjuice/openclaw.plugin.json | 2 +- .../codex-app-server.extensions.test.ts | 34 ++-- .../command/attempt-execution.cli.test.ts | 4 +- src/agents/harness/selection.test.ts | 77 +++++--- src/agents/harness/selection.ts | 3 + .../harness/tool-result-middleware.test.ts | 18 +- src/agents/harness/tool-result-middleware.ts | 9 +- src/agents/model-picker-visibility.ts | 5 +- src/agents/model-runtime-aliases.ts | 123 +++++++++++++ .../pi-embedded-runner.extensions.test.ts | 2 +- src/agents/pi-embedded-runner/extensions.ts | 2 +- .../pi-embedded-runner/run/backend.test.ts | 8 +- src/agents/pi-embedded-runner/runtime.ts | 5 +- src/auto-reply/model.test.ts | 8 + src/auto-reply/model.ts | 5 +- .../reply/agent-runner-execution.ts | 33 +++- .../agent-runner.misc.runreplyagent.test.ts | 79 ++++++++ src/auto-reply/reply/commands-models.test.ts | 67 ++++--- src/auto-reply/reply/commands-models.ts | 30 ++- src/auto-reply/reply/commands-status.test.ts | 5 +- .../reply/directive-handling.model.test.ts | 54 ++++++ .../reply/directive-handling.model.ts | 1 + .../reply/directive-handling.parse.ts | 3 + .../reply/directive-handling.persist.ts | 63 ++++++- src/auto-reply/status.test.ts | 36 ++-- .../doctor-legacy-config.migrations.test.ts | 107 ++++++++++- .../legacy-config-compatibility-base.ts | 4 +- .../shared/legacy-config-core-normalizers.ts | 172 +++++++++--------- src/commands/model-picker.test.ts | 32 +++- src/config/schema.base.generated.ts | 34 ++-- src/config/schema.help.ts | 6 +- src/config/schema.labels.ts | 8 +- src/config/sessions/types.ts | 2 + src/config/types.agents-shared.ts | 2 +- src/plugin-sdk/agent-harness-runtime.ts | 1 + .../agent-tool-result-middleware-types.ts | 12 +- src/plugins/agent-tool-result-middleware.ts | 68 +++++-- src/plugins/captured-registration.test.ts | 4 +- src/plugins/captured-registration.ts | 6 +- src/plugins/registry-types.ts | 4 +- src/plugins/registry.ts | 21 ++- src/plugins/types.ts | 5 +- src/status/status-message.ts | 50 +++-- 75 files changed, 1422 insertions(+), 414 deletions(-) create mode 100644 src/agents/model-runtime-aliases.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc84bd1afa..699fee24639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,7 +98,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Chat: log webhook auth rejection reasons only after all candidates fail, and warn when add-on `appPrincipal` values do not match configuration. Fixes #71078. (#71145) Thanks @luyao618. - Models/configure: preserve the existing default model when provider auth is re-run from configure while keeping explicit default-setting commands authoritative. Fixes #70696. (#70793) Thanks @Sathvik-1007. - Config/plugins: accept `plugins.entries.*.hooks.allowConversationAccess` in validation, generated schema metadata, and plugin policy inspection so trusted external plugins can enable conversation-access hooks such as `agent_end` without local schema patches. Fixes #71215. (#71221) Thanks @BillChirico. -- Codex harness/models: keep legacy `codex/*` harness shorthand out of model picker and `/models` choice surfaces while migrating primary legacy refs to canonical `openai/*` plus explicit Codex harness config. (#71193) Thanks @vincentkoc. +- Models/runtime: show one model provider choice per provider and move Codex, Claude CLI, and Gemini CLI execution into explicit runtime selection while keeping fallback-only legacy runtime refs unchanged. Thanks @vincentkoc. - Plugins/runtime deps: respect explicit plugin and channel disablement when repairing bundled runtime dependencies, so doctor and health checks no longer install deps for disabled configured channels. Thanks @vincentkoc. - Diagnostics/OTEL: export logs through bounded diagnostic log events instead of a direct logger transport hook. Thanks @vincentkoc. - WhatsApp/plugins: support an explicit opt-in for inbound `message_received` hooks with canonical channel, conversation, session, and sender fields. Thanks @vincentkoc. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 068ea6bb393..c258f2848f7 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -eb5c790aaa54be7b1380eb5a162db50dd314e052aedb5e608290092c33d999f2 plugin-sdk-api-baseline.json -0d2fd80f69e0c3488b6bdbbbb035b08ab108637790d1f30b8e4f84c71c5bc8e2 plugin-sdk-api-baseline.jsonl +f74435d49aa0af2509264d8581e12ffc624b1d6542d250d608ee5c3b41a234f3 plugin-sdk-api-baseline.json +df33bbe47bb092ed11814576b5386253140f7aa6f8479a5334aff9b988125afc plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 4c3f46622ff..ca440706749 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -305,7 +305,7 @@ By default, components are single use. Set `components.reusable=true` to allow b To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial. -The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. `/models add` is deprecated and now returns a deprecation message instead of registering models from chat. The picker reply is ephemeral and only the invoking user can use it. +The `/model` and `/models` slash commands open an interactive model picker with provider, model, and compatible runtime dropdowns plus a Submit step. `/models add` is deprecated and now returns a deprecation message instead of registering models from chat. The picker reply is ephemeral and only the invoking user can use it. File attachments: diff --git a/docs/cli/status.md b/docs/cli/status.md index e493528acba..2d546c30596 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -21,7 +21,7 @@ Notes: - `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal). - `--usage` prints normalized provider usage windows as `X% left`. -- Session status output now separates `Runtime:` from `Runner:`. `Runtime` is the execution path and sandbox state (`direct`, `docker/*`), while `Runner` tells you whether the session is using embedded Pi, a CLI-backed provider, or an ACP harness backend such as `codex (acp/acpx)`. +- Session status output separates `Execution:` from `Runtime:`. `Execution` is the sandbox path (`direct`, `docker/*`), while `Runtime` tells you whether the session is using `OpenClaw Pi Default`, `OpenAI Codex`, a CLI backend, or an ACP backend such as `codex (acp/acpx)`. - MiniMax's raw `usage_percent` / `usagePercent` fields are remaining quota, so OpenClaw inverts them before display; count-based fields win when present. `model_remains` responses prefer the chat-model entry, derive the window label from timestamps when needed, and include the model name in the plan label. - When the current session snapshot is sparse, `/status` can backfill token and cache counters from the most recent transcript usage log. Existing nonzero live values still win over transcript fallback values. - Transcript fallback can also recover the active runtime model label when the live session entry is missing it. If that transcript model differs from the selected model, status resolves the context window against the recovered runtime model instead of the selected one. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index ef90cc9eb2b..584f1ad40d0 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -24,6 +24,12 @@ For model selection rules, see [/concepts/models](/concepts/models). - Plugin auto-enable follows that same boundary: `openai-codex/` belongs to the OpenAI plugin, while the Codex plugin is enabled by `embeddedHarness.runtime: "codex"` or legacy `codex/` refs. +- CLI runtimes use the same split: choose canonical model refs such as + `anthropic/claude-*`, `google/gemini-*`, or `openai/gpt-*`, then set + `agents.defaults.embeddedHarness.runtime` to `claude-cli`, + `google-gemini-cli`, or `codex-cli` when you want a local CLI backend. + Legacy `claude-cli/*`, `google-gemini-cli/*`, and `codex-cli/*` refs migrate + back to canonical provider refs with the runtime recorded separately. - GPT-5.5 is currently available through subscription/OAuth routes: `openai-codex/gpt-5.5` in PI or `openai/gpt-5.5` with the Codex app-server harness. The direct API-key route for `openai/gpt-5.5` is supported once diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index d5f9f0a02af..238c7487f0f 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -316,7 +316,7 @@ Time format in system prompt. Default: `auto` (OS preference). }, params: { cacheRetention: "long" }, // global default provider params embeddedHarness: { - runtime: "auto", // auto | pi | registered harness id, e.g. codex + runtime: "pi", // pi | auto | registered harness id, e.g. codex fallback: "pi", // pi | none }, pdfMaxBytesMb: 10, @@ -369,14 +369,14 @@ Time format in system prompt. Default: `auto` (OS preference). - For direct OpenAI Responses models, server-side compaction is enabled automatically. Use `params.responsesServerCompaction: false` to stop injecting `context_management`, or `params.responsesCompactThreshold` to override the threshold. See [OpenAI server-side compaction](/providers/openai#server-side-compaction-responses-api). - `params`: global default provider parameters applied to all models. Set at `agents.defaults.params` (e.g. `{ cacheRetention: "long" }`). - `params` merge precedence (config): `agents.defaults.params` (global base) is overridden by `agents.defaults.models["provider/model"].params` (per-model), then `agents.list[].params` (matching agent id) overrides by key. See [Prompt Caching](/reference/prompt-caching) for details. -- `embeddedHarness`: default low-level embedded agent runtime policy. Use `runtime: "auto"` to let registered plugin harnesses claim supported models, `runtime: "pi"` to force the built-in PI harness, or a registered harness id such as `runtime: "codex"`. Automatic PI fallback defaults to `"pi"` only in `auto` mode. Explicit plugin runtimes such as `codex` default to `"none"` unless you set `fallback: "pi"`. New Codex harness configs should keep model refs canonical as `openai/*` and select the harness here rather than using legacy `codex/*` model refs. +- `embeddedHarness`: default low-level embedded agent runtime policy. Omitted runtime defaults to OpenClaw Pi. Use `runtime: "pi"` to force the built-in PI harness, `runtime: "auto"` to let registered plugin harnesses claim supported models, or a registered harness id such as `runtime: "codex"`. Set `fallback: "none"` to disable automatic PI fallback. Keep model refs canonical as `provider/model`; select Codex, Claude CLI, Gemini CLI, and other execution backends through runtime config instead of legacy runtime provider prefixes. - Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible. - `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 4. ### `agents.defaults.embeddedHarness` `embeddedHarness` controls which low-level executor runs embedded agent turns. -Most deployments should keep the default `{ runtime: "auto", fallback: "pi" }`. +Most deployments should keep the default OpenClaw Pi runtime. Use it when a trusted plugin provides a native harness, such as the bundled Codex app-server harness. diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index 5d5377cdfc2..3005862619c 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -172,8 +172,8 @@ For the full registration API, see [SDK Overview](/plugins/sdk-overview#registra Bundled plugins can use `api.registerAgentToolResultMiddleware(...)` when they need async tool-result rewriting before the model sees the output. Declare the -targeted harnesses in `contracts.agentToolResultMiddleware`, for example -`["pi", "codex-app-server"]`. This is a trusted bundled-plugin seam; external +targeted runtimes in `contracts.agentToolResultMiddleware`, for example +`["pi", "codex"]`. This is a trusted bundled-plugin seam; external plugins should prefer regular OpenClaw plugin hooks unless OpenClaw grows an explicit trust policy for this capability. diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index bd5fca968e4..d053ece4daa 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -25,7 +25,7 @@ These are in-process OpenClaw hooks, not Codex `hooks.json` command hooks: - `before_message_write` for mirrored transcript records - `agent_end` -Plugins can also register harness-neutral tool-result middleware to rewrite +Plugins can also register runtime-neutral tool-result middleware to rewrite OpenClaw dynamic tool results after OpenClaw executes the tool and before the result is returned to Codex. This is separate from the public `tool_result_persist` plugin hook, which transforms OpenClaw-owned transcript @@ -35,8 +35,8 @@ The harness is off by default. New configs should keep OpenAI model refs canonical as `openai/gpt-*` and explicitly force `embeddedHarness.runtime: "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 they are not shown as normal model/provider -choices. +the harness for compatibility, but runtime-backed legacy provider prefixes are +not shown as normal model/provider choices. ## Pick the right model prefix @@ -56,10 +56,12 @@ app-server harness. Direct API-key access for `openai/gpt-5.5` is supported once OpenAI enables GPT-5.5 on the public API. Legacy `codex/gpt-*` refs remain accepted as compatibility aliases. Doctor -compatibility migration rewrites legacy primary `codex/*` refs to `openai/*` -and records the Codex harness policy separately. New PI Codex OAuth configs -should use `openai-codex/gpt-*`; new native app-server harness configs should -use `openai/gpt-*` plus `embeddedHarness.runtime: "codex"`. +compatibility migration rewrites legacy primary runtime refs to canonical model +refs and records the runtime policy separately, while fallback-only legacy refs +are left unchanged because runtime is configured for the whole agent container. +New PI Codex OAuth configs should use `openai-codex/gpt-*`; new native +app-server harness configs should use `openai/gpt-*` plus +`embeddedHarness.runtime: "codex"`. `agents.defaults.imageModel` follows the same prefix split. Use `openai-codex/gpt-*` when image understanding should run through the OpenAI @@ -86,9 +88,9 @@ Legacy sessions created before harness pins are treated as PI-pinned once they have transcript history. Use `/new` or `/reset` to opt that conversation into Codex after changing config. -`/status` shows the effective non-PI harness next to `Fast`, for example -`Fast · codex`. The default PI harness remains `Runner: pi (embedded)` and does -not add a separate harness badge. +`/status` shows the effective model runtime. The default PI harness appears as +`Runtime: OpenClaw Pi Default`, and the Codex app-server harness appears as +`Runtime: OpenAI Codex`. ## Requirements diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 22eab879a70..e7d75746285 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -406,7 +406,7 @@ read without importing the plugin runtime. ```json { "contracts": { - "agentToolResultMiddleware": ["pi", "codex-app-server"], + "agentToolResultMiddleware": ["pi", "codex"], "externalAuthProviders": ["acme-ai"], "speechProviders": ["openai"], "realtimeTranscriptionProviders": ["openai"], @@ -427,7 +427,7 @@ Each list is optional: | Field | Type | What it means | | -------------------------------- | ---------- | --------------------------------------------------------------------- | | `embeddedExtensionFactories` | `string[]` | Deprecated embedded extension factory ids. | -| `agentToolResultMiddleware` | `string[]` | Harness ids a bundled plugin may register tool-result middleware for. | +| `agentToolResultMiddleware` | `string[]` | Runtime ids a bundled plugin may register tool-result middleware for. | | `externalAuthProviders` | `string[]` | Provider ids whose external auth profile hook this plugin owns. | | `speechProviders` | `string[]` | Speech provider ids this plugin owns. | | `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. | diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index e5bb2d49c44..b1b5f9cdbff 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -146,15 +146,15 @@ OpenClaw only runs against the protocol surface it has been tested with. ### Tool-result middleware -Bundled plugins can attach harness-neutral tool-result middleware through +Bundled plugins can attach runtime-neutral tool-result middleware through `api.registerAgentToolResultMiddleware(...)` when their manifest declares the -targeted harness ids in `contracts.agentToolResultMiddleware`. This trusted +targeted runtime ids in `contracts.agentToolResultMiddleware`. This trusted seam is for async tool-result transforms that must run before PI or Codex feeds tool output back into the model. Legacy bundled plugins can still use `api.registerCodexAppServerExtensionFactory(...)` for Codex app-server-only -middleware, but new result transforms should use the harness-neutral API. +middleware, but new result transforms should use the runtime-neutral API. The Pi-only `api.registerEmbeddedExtensionFactory(...)` hook is deprecated for tool-result transforms; keep it only for bundled compatibility code that still needs direct Pi embedded-runner events. diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 671e94b771d..65c54ec6630 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -93,7 +93,7 @@ releases. Bundled plugins should replace Pi-only `api.registerEmbeddedExtensionFactory(...)` tool-result handlers with - harness-neutral middleware. + runtime-neutral middleware. ```typescript // Before: Pi-only compatibility hook @@ -103,11 +103,11 @@ releases. }); }); - // After: Pi and Codex app-server dynamic tools + // After: Pi and Codex runtime dynamic tools api.registerAgentToolResultMiddleware(async (event) => { return compactToolResult(event); }, { - harnesses: ["pi", "codex-app-server"], + runtimes: ["pi", "codex"], }); ``` @@ -116,7 +116,7 @@ releases. ```json { "contracts": { - "agentToolResultMiddleware": ["pi", "codex-app-server"] + "agentToolResultMiddleware": ["pi", "codex"] } } ``` @@ -626,7 +626,7 @@ canonical replacement. Covered in "How to migrate → Migrate Pi tool-result extensions to middleware" above. Included here for completeness: the Pi-only `api.registerEmbeddedExtensionFactory(...)` path is deprecated in favor of - `api.registerAgentToolResultMiddleware(...)` with an explicit harness + `api.registerAgentToolResultMiddleware(...)` with an explicit runtime list in `contracts.agentToolResultMiddleware`. diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 9a31d935f5a..7569eb41c40 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -99,7 +99,7 @@ methods: | `api.registerCli(registrar, opts?)` | CLI subcommand | | `api.registerService(service)` | Background service | | `api.registerInteractiveHandler(registration)` | Interactive handler | -| `api.registerAgentToolResultMiddleware(...)` | Harness tool-result middleware | +| `api.registerAgentToolResultMiddleware(...)` | Runtime tool-result middleware | | `api.registerEmbeddedExtensionFactory(factory)` | Deprecated PI extension factory | | `api.registerMemoryPromptSupplement(builder)` | Additive memory-adjacent prompt section | | `api.registerMemoryCorpusSupplement(adapter)` | Additive memory search/read corpus | @@ -113,12 +113,12 @@ methods: Bundled plugins can use `api.registerAgentToolResultMiddleware(...)` when - they need to rewrite a tool result after execution and before the harness - feeds that result back into the model. This is the trusted harness-neutral + they need to rewrite a tool result after execution and before the runtime + feeds that result back into the model. This is the trusted runtime-neutral seam for async output reducers such as tokenjuice. Bundled plugins must declare `contracts.agentToolResultMiddleware` for each -targeted harness, for example `["pi", "codex-app-server"]`. External plugins +targeted runtime, for example `["pi", "codex"]`. External plugins cannot register this middleware; keep normal OpenClaw plugin hooks for work that does not need pre-model tool-result timing. diff --git a/docs/providers/google.md b/docs/providers/google.md index d805a1bf6f2..506a761a8dc 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -13,7 +13,8 @@ Gemini Grounding. - Provider: `google` - Auth: `GEMINI_API_KEY` or `GOOGLE_API_KEY` - API: Google Gemini API -- Alternative provider: `google-gemini-cli` (OAuth) +- Runtime option: `agents.defaults.embeddedHarness.runtime: "google-gemini-cli"` + reuses Gemini CLI OAuth while keeping model refs canonical as `google/*`. ## Getting started @@ -92,12 +93,13 @@ Choose your preferred auth method and follow the setup steps. ```bash - openclaw models list --provider google-gemini-cli + openclaw models list --provider google ``` - - Default model: `google-gemini-cli/gemini-3-flash-preview` + - Default model: `google/gemini-3.1-pro-preview` + - Runtime: `google-gemini-cli` - Alias: `gemini-cli` **Environment variables:** @@ -117,9 +119,9 @@ Choose your preferred auth method and follow the setup steps. command is installed and on `PATH`. - The OAuth-only `google-gemini-cli` provider is a separate text-inference - surface. Image generation, media understanding, and Gemini Grounding stay on - the `google` provider id. + `google-gemini-cli/*` model refs are legacy compatibility aliases. New + configs should use `google/*` model refs plus the `google-gemini-cli` + runtime when they want local Gemini CLI execution. diff --git a/docs/providers/openai.md b/docs/providers/openai.md index eb50fe998e1..92dc6796754 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -174,11 +174,10 @@ Choose your preferred auth method and follow the setup steps. ### Status indicator - Chat `/status` shows which embedded harness is active for the current - session. The default PI harness appears as `Runner: pi (embedded)` and does - not add a separate badge. When the bundled Codex app-server harness is - selected, `/status` appends the non-PI harness id next to `Fast`, for example - `Fast · codex`. Existing sessions keep their recorded harness id, so use + 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 `embeddedHarness` if you want `/status` to reflect a new PI/Codex choice. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 6e189db38c8..e5710783cc6 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -106,7 +106,7 @@ Built-in commands available today: - `/help` shows the short help summary. - `/commands` shows the generated command catalog. - `/tools [compact|verbose]` shows what the current agent can use right now. -- `/status` shows runtime status, including `Runtime`/`Runner` labels and provider usage/quota when available. +- `/status` shows execution/runtime status, including `Execution`/`Runtime` labels and provider usage/quota when available. - `/tasks` lists active/recent background tasks for the current session. - `/context [list|detail|json]` explains how context is assembled. - `/export-session [path]` exports the current session to HTML. Alias: `/export`. @@ -227,7 +227,7 @@ of treating `/tools` as a static catalog. - **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled. OpenClaw normalizes provider windows to `% left`; for MiniMax, remaining-only percent fields are inverted before display, and `model_remains` responses prefer the chat-model entry plus a model-tagged plan label. - **Token/cache lines** in `/status` can fall back to the latest transcript usage entry when the live session snapshot is sparse. Existing nonzero live values still win, and transcript fallback can also recover the active runtime model label plus a larger prompt-oriented total when stored totals are missing or smaller. -- **Runtime vs runner:** `/status` reports `Runtime` for the effective execution path and sandbox state, and `Runner` for who is actually running the session: embedded Pi, a CLI-backed provider, or an ACP harness/backend. +- **Execution vs runtime:** `/status` reports `Execution` for the effective sandbox path and `Runtime` for who is actually running the session: `OpenClaw Pi Default`, `OpenAI Codex`, a CLI backend, or an ACP backend. - **Per-response tokens/cost** is controlled by `/usage off|tokens|full` (appended to normal replies). - `/model status` is about **models/auth/endpoints**, not usage. diff --git a/extensions/anthropic/cli-migration.test.ts b/extensions/anthropic/cli-migration.test.ts index d029e9425e1..04396ccbe39 100644 --- a/extensions/anthropic/cli-migration.test.ts +++ b/extensions/anthropic/cli-migration.test.ts @@ -96,7 +96,7 @@ describe("anthropic cli migration", () => { expect(readClaudeCliCredentialsForSetupNonInteractive).toHaveBeenCalledTimes(1); }); - it("rewrites anthropic defaults to claude-cli defaults", () => { + it("keeps anthropic defaults and selects the claude-cli runtime", () => { const result = buildAnthropicCliMigrationResult({ agents: { defaults: { @@ -114,21 +114,22 @@ describe("anthropic cli migration", () => { }); expect(result.profiles).toEqual([]); - expect(result.defaultModel).toBe("claude-cli/claude-opus-4-7"); + expect(result.defaultModel).toBe("anthropic/claude-opus-4-7"); expect(result.configPatch).toEqual({ agents: { defaults: { model: { - primary: "claude-cli/claude-opus-4-7", - fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"], + primary: "anthropic/claude-opus-4-7", + fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"], }, + embeddedHarness: { runtime: "claude-cli" }, models: { - "claude-cli/claude-opus-4-7": { alias: "Opus" }, - "claude-cli/claude-sonnet-4-6": {}, - "claude-cli/claude-opus-4-6": { alias: "Opus" }, - "claude-cli/claude-opus-4-5": {}, - "claude-cli/claude-sonnet-4-5": {}, - "claude-cli/claude-haiku-4-5": {}, + "anthropic/claude-opus-4-7": { alias: "Opus" }, + "anthropic/claude-sonnet-4-6": {}, + "anthropic/claude-opus-4-6": { alias: "Opus" }, + "anthropic/claude-opus-4-5": {}, + "anthropic/claude-sonnet-4-5": {}, + "anthropic/claude-haiku-4-5": {}, "openai/gpt-5.2": {}, }, }, @@ -148,18 +149,19 @@ describe("anthropic cli migration", () => { }, }); - expect(result.defaultModel).toBe("claude-cli/claude-opus-4-7"); + expect(result.defaultModel).toBe("anthropic/claude-opus-4-7"); expect(result.configPatch).toEqual({ agents: { defaults: { + embeddedHarness: { runtime: "claude-cli" }, models: { "openai/gpt-5.2": {}, - "claude-cli/claude-opus-4-7": {}, - "claude-cli/claude-sonnet-4-6": {}, - "claude-cli/claude-opus-4-6": {}, - "claude-cli/claude-opus-4-5": {}, - "claude-cli/claude-sonnet-4-5": {}, - "claude-cli/claude-haiku-4-5": {}, + "anthropic/claude-opus-4-7": {}, + "anthropic/claude-sonnet-4-6": {}, + "anthropic/claude-opus-4-6": {}, + "anthropic/claude-opus-4-5": {}, + "anthropic/claude-sonnet-4-5": {}, + "anthropic/claude-haiku-4-5": {}, }, }, }, @@ -181,13 +183,15 @@ describe("anthropic cli migration", () => { expect(result.configPatch).toEqual({ agents: { defaults: { + model: { primary: "anthropic/claude-opus-4-7" }, + embeddedHarness: { runtime: "claude-cli" }, models: { - "claude-cli/claude-opus-4-7": {}, - "claude-cli/claude-sonnet-4-6": {}, - "claude-cli/claude-opus-4-6": {}, - "claude-cli/claude-opus-4-5": {}, - "claude-cli/claude-sonnet-4-5": {}, - "claude-cli/claude-haiku-4-5": {}, + "anthropic/claude-opus-4-7": {}, + "anthropic/claude-sonnet-4-6": {}, + "anthropic/claude-opus-4-6": {}, + "anthropic/claude-opus-4-5": {}, + "anthropic/claude-sonnet-4-5": {}, + "anthropic/claude-haiku-4-5": {}, }, }, }, @@ -287,7 +291,7 @@ describe("anthropic cli migration", () => { ]); }); - it("registered non-interactive cli auth rewrites anthropic fallbacks before setting the claude-cli default", async () => { + it("registered non-interactive cli auth keeps anthropic fallbacks and selects claude-cli runtime", async () => { readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({ type: "oauth", provider: "anthropic", @@ -318,12 +322,13 @@ describe("anthropic cli migration", () => { agents: { defaults: { model: { - primary: "claude-cli/claude-opus-4-7", - fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"], + primary: "anthropic/claude-opus-4-7", + fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"], }, + embeddedHarness: { runtime: "claude-cli" }, models: { - "claude-cli/claude-opus-4-7": { alias: "Opus" }, - "claude-cli/claude-opus-4-6": { alias: "Opus" }, + "anthropic/claude-opus-4-7": { alias: "Opus" }, + "anthropic/claude-opus-4-6": { alias: "Opus" }, "openai/gpt-5.2": {}, }, }, diff --git a/extensions/anthropic/cli-migration.ts b/extensions/anthropic/cli-migration.ts index 6603c280e69..b2a8ca48180 100644 --- a/extensions/anthropic/cli-migration.ts +++ b/extensions/anthropic/cli-migration.ts @@ -8,26 +8,31 @@ import { readClaudeCliCredentialsForSetup, readClaudeCliCredentialsForSetupNonInteractive, } from "./cli-auth-seam.js"; -import { - CLAUDE_CLI_BACKEND_ID, - CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS, - CLAUDE_CLI_DEFAULT_MODEL_REF, -} from "./cli-shared.js"; +import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-shared.js"; type AgentDefaultsModel = NonNullable["defaults"]>["model"]; type AgentDefaultsModels = NonNullable["defaults"]>["models"]; +type AgentDefaultsEmbeddedHarness = NonNullable< + NonNullable["defaults"] +>["embeddedHarness"]; type ClaudeCliCredential = NonNullable>; -function toClaudeCliModelRef(raw: string): string | null { +function toAnthropicModelRef(raw: string): string | null { const trimmed = raw.trim(); - if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("anthropic/")) { + const lower = normalizeLowercaseStringOrEmpty(trimmed); + const provider = lower.startsWith("anthropic/") + ? "anthropic" + : lower.startsWith(`${CLAUDE_CLI_BACKEND_ID}/`) + ? CLAUDE_CLI_BACKEND_ID + : ""; + if (!provider) { return null; } - const modelId = trimmed.slice("anthropic/".length).trim(); + const modelId = trimmed.slice(provider.length + 1).trim(); if (!normalizeLowercaseStringOrEmpty(modelId).startsWith("claude-")) { return null; } - return `claude-cli/${modelId}`; + return `anthropic/${modelId}`; } function rewriteModelSelection(model: AgentDefaultsModel): { @@ -36,7 +41,7 @@ function rewriteModelSelection(model: AgentDefaultsModel): { changed: boolean; } { if (typeof model === "string") { - const converted = toClaudeCliModelRef(model); + const converted = toAnthropicModelRef(model); return converted ? { value: converted, primary: converted, changed: true } : { value: model, changed: false }; @@ -51,7 +56,7 @@ function rewriteModelSelection(model: AgentDefaultsModel): { let primary: string | undefined; if (typeof current.primary === "string") { - const converted = toClaudeCliModelRef(current.primary); + const converted = toAnthropicModelRef(current.primary); if (converted) { next.primary = converted; primary = converted; @@ -62,7 +67,7 @@ function rewriteModelSelection(model: AgentDefaultsModel): { const currentFallbacks = current.fallbacks; if (Array.isArray(currentFallbacks)) { const nextFallbacks = currentFallbacks.map((entry) => - typeof entry === "string" ? (toClaudeCliModelRef(entry) ?? entry) : entry, + typeof entry === "string" ? (toAnthropicModelRef(entry) ?? entry) : entry, ); if (nextFallbacks.some((entry, index) => entry !== currentFallbacks[index])) { next.fallbacks = nextFallbacks; @@ -89,10 +94,13 @@ function rewriteModelEntryMap(models: Record | undefined): { const migrated: string[] = []; for (const [rawKey, value] of Object.entries(models)) { - const converted = toClaudeCliModelRef(rawKey); + const converted = toAnthropicModelRef(rawKey); if (!converted) { continue; } + if (converted === rawKey) { + continue; + } if (!(converted in next)) { next[converted] = value; } @@ -111,11 +119,25 @@ function seedClaudeCliAllowlist( ): NonNullable { const next = { ...models }; for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) { - next[ref] = next[ref] ?? {}; + const canonicalRef = toAnthropicModelRef(ref) ?? ref; + next[canonicalRef] = next[canonicalRef] ?? {}; } return next; } +function selectClaudeCliRuntime( + embeddedHarness: AgentDefaultsEmbeddedHarness | undefined, +): AgentDefaultsEmbeddedHarness { + const currentRuntime = embeddedHarness?.runtime?.trim(); + if (currentRuntime && currentRuntime !== "auto") { + return embeddedHarness; + } + return { + ...embeddedHarness, + runtime: CLAUDE_CLI_BACKEND_ID, + }; +} + export function hasClaudeCliAuth(options?: { allowKeychainPrompt?: boolean }): boolean { return Boolean( options?.allowKeychainPrompt === false @@ -168,7 +190,7 @@ export function buildAnthropicCliMigrationResult( defaults?.models ?? {}) as NonNullable; const nextModels = seedClaudeCliAllowlist(existingModels); - const defaultModel = rewrittenModel.primary ?? CLAUDE_CLI_DEFAULT_MODEL_REF; + const defaultModel = rewrittenModel.primary ?? "anthropic/claude-opus-4-7"; return { profiles: buildClaudeCliAuthProfiles(credential), @@ -176,15 +198,16 @@ export function buildAnthropicCliMigrationResult( agents: { defaults: { ...(rewrittenModel.changed ? { model: rewrittenModel.value } : {}), + embeddedHarness: selectClaudeCliRuntime(defaults?.embeddedHarness), models: nextModels, }, }, }, - // Rewrites `anthropic/*` -> `claude-cli/*`; merge would keep stale keys. + // Rewrites `claude-cli/*` -> `anthropic/*`; merge would keep stale keys. replaceDefaultModels: true, defaultModel, notes: [ - "Claude CLI auth detected; switched Anthropic model selection to the local Claude CLI backend.", + "Claude CLI auth detected; kept Anthropic model refs and selected the local Claude CLI runtime.", "Existing Anthropic auth profiles are kept for rollback.", ...(rewrittenModels.migrated.length > 0 ? [`Migrated allowlist entries: ${rewrittenModels.migrated.join(", ")}.`] diff --git a/extensions/anthropic/config-defaults.ts b/extensions/anthropic/config-defaults.ts index 334ac099589..25a046696af 100644 --- a/extensions/anthropic/config-defaults.ts +++ b/extensions/anthropic/config-defaults.ts @@ -140,6 +140,9 @@ function isAnthropicCacheRetentionTarget( } function usesClaudeCliModelSelection(config: OpenClawConfig): boolean { + if (config.agents?.defaults?.embeddedHarness?.runtime === CLAUDE_CLI_BACKEND_ID) { + return true; + } const primary = resolveModelPrimaryValue( config.agents?.defaults?.model as | string @@ -156,6 +159,12 @@ function usesClaudeCliModelSelection(config: OpenClawConfig): boolean { }); } +function toCanonicalAnthropicModelRef(ref: string): string { + return ref.startsWith(`${CLAUDE_CLI_BACKEND_ID}/`) + ? `anthropic/${ref.slice(CLAUDE_CLI_BACKEND_ID.length + 1)}` + : ref; +} + export function normalizeAnthropicProviderConfig( providerConfig: T, ): T { @@ -267,7 +276,8 @@ export function applyAnthropicConfigDefaults(params: { if (authMode === "oauth" && usesClaudeCliModelSelection(params.config)) { const nextModels = defaults.models ? { ...defaults.models } : {}; let modelsMutated = false; - for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) { + for (const rawRef of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) { + const ref = toCanonicalAnthropicModelRef(rawRef); if (ref in nextModels) { continue; } diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index c5ba0622acf..f7df1c72bad 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -176,9 +176,10 @@ describe("anthropic provider replay hooks", () => { }, agents: { defaults: { - model: { primary: "claude-cli/claude-opus-4-7" }, + embeddedHarness: { runtime: "claude-cli" }, + model: { primary: "anthropic/claude-opus-4-7" }, models: { - "claude-cli/claude-opus-4-7": {}, + "anthropic/claude-opus-4-7": {}, }, }, }, @@ -189,12 +190,12 @@ describe("anthropic provider replay hooks", () => { every: "1h", }); expect(next?.agents?.defaults?.models).toMatchObject({ - "claude-cli/claude-opus-4-7": {}, - "claude-cli/claude-sonnet-4-6": {}, - "claude-cli/claude-opus-4-6": {}, - "claude-cli/claude-opus-4-5": {}, - "claude-cli/claude-sonnet-4-5": {}, - "claude-cli/claude-haiku-4-5": {}, + "anthropic/claude-opus-4-7": {}, + "anthropic/claude-sonnet-4-6": {}, + "anthropic/claude-opus-4-6": {}, + "anthropic/claude-opus-4-5": {}, + "anthropic/claude-sonnet-4-5": {}, + "anthropic/claude-haiku-4-5": {}, }); }); diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 3bc7042bce2..3e5b2074d4a 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -73,6 +73,17 @@ const ANTHROPIC_SETUP_TOKEN_NOTE_LINES = [ `If you want a direct API billing path instead, use ${formatCliCommand("openclaw models auth login --provider anthropic --method api-key --set-default")} or ${formatCliCommand("openclaw models auth login --provider anthropic --method cli --set-default")}.`, ] as const; +const CLAUDE_CLI_CANONICAL_ALLOWLIST_REFS = CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS.map((ref) => + ref.startsWith(`${CLAUDE_CLI_BACKEND_ID}/`) + ? `anthropic/${ref.slice(CLAUDE_CLI_BACKEND_ID.length + 1)}` + : ref, +); +const CLAUDE_CLI_CANONICAL_DEFAULT_MODEL_REF = CLAUDE_CLI_DEFAULT_MODEL_REF.startsWith( + `${CLAUDE_CLI_BACKEND_ID}/`, +) + ? `anthropic/${CLAUDE_CLI_DEFAULT_MODEL_REF.slice(CLAUDE_CLI_BACKEND_ID.length + 1)}` + : CLAUDE_CLI_DEFAULT_MODEL_REF; + function normalizeAnthropicSetupTokenInput(value: string): string { return value.replaceAll(/\s+/g, "").trim(); } @@ -492,7 +503,7 @@ export function buildAnthropicProvider(): ProviderPlugin { { id: "cli", label: "Claude CLI", - hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*", + hint: "Reuse a local Claude CLI login and run Anthropic models through the Claude CLI runtime", kind: "custom", wizard: { choiceId: "anthropic-cli", @@ -503,8 +514,8 @@ export function buildAnthropicProvider(): ProviderPlugin { groupLabel: "Anthropic", groupHint: "Claude CLI + API key", modelAllowlist: { - allowedKeys: [...CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS], - initialSelections: [CLAUDE_CLI_DEFAULT_MODEL_REF], + allowedKeys: [...CLAUDE_CLI_CANONICAL_ALLOWLIST_REFS], + initialSelections: [CLAUDE_CLI_CANONICAL_DEFAULT_MODEL_REF], message: "Claude CLI models", }, }, diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 22ab952d43e..476b54869d9 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -225,7 +225,7 @@ describe("createCodexDynamicToolBridge", () => { pluginName: "Tokenjuice", rawHandler: handler, handler, - harnesses: ["codex-app-server"], + runtimes: ["codex"], source: "test", }); setActivePluginRegistry(registry); @@ -253,7 +253,7 @@ describe("createCodexDynamicToolBridge", () => { toolName: "exec", args: { command: "git status" }, }), - expect.objectContaining({ harness: "codex-app-server" }), + expect.objectContaining({ runtime: "codex" }), ); }); @@ -265,7 +265,7 @@ describe("createCodexDynamicToolBridge", () => { pluginName: "Tokenjuice", rawHandler: handler, handler, - harnesses: ["codex-app-server"], + runtimes: ["codex"], source: "test", }); setActivePluginRegistry(registry); @@ -286,7 +286,7 @@ describe("createCodexDynamicToolBridge", () => { expect(handler).toHaveBeenCalledWith( expect.objectContaining({ isError: true }), - expect.objectContaining({ harness: "codex-app-server" }), + expect.objectContaining({ runtime: "codex" }), ); }); @@ -308,7 +308,7 @@ describe("createCodexDynamicToolBridge", () => { pluginName: "Tokenjuice", rawHandler: handler, handler, - harnesses: ["codex-app-server"], + runtimes: ["codex"], source: "test", }); setActivePluginRegistry(registry); @@ -583,7 +583,7 @@ describe("createCodexDynamicToolBridge", () => { pluginName: "Tokenjuice", rawHandler: handler, handler, - harnesses: ["codex-app-server"], + runtimes: ["codex"], source: "test", }); setActivePluginRegistry(registry); diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 097ab3791ce..d0192fdbd01 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -60,7 +60,7 @@ export function createCodexDynamicToolBridge(params: { toolAudioAsVoice: false, }; const middlewareRunner = createAgentToolResultMiddlewareRunner({ - harness: "codex-app-server", + runtime: "codex", ...params.hookContext, }); const legacyExtensionRunner = createCodexAppServerToolResultExtensionRunner( diff --git a/extensions/discord/src/monitor/model-picker.test.ts b/extensions/discord/src/monitor/model-picker.test.ts index eece94e1d99..d510579fca9 100644 --- a/extensions/discord/src/monitor/model-picker.test.ts +++ b/extensions/discord/src/monitor/model-picker.test.ts @@ -29,7 +29,7 @@ vi.mock("openclaw/plugin-sdk/models-provider-runtime", () => ({ type SerializedComponent = { type: number; custom_id?: string; - options?: Array<{ value: string; default?: boolean }>; + options?: Array<{ label?: string; value: string; default?: boolean }>; components?: SerializedComponent[]; }; @@ -163,6 +163,7 @@ describe("Discord model picker custom_id", () => { view: "models", u: "42", p: "openai", + r: "codex", pg: "1", mi: "7", }); @@ -173,6 +174,7 @@ describe("Discord model picker custom_id", () => { view: "models", userId: "42", provider: "openai", + runtime: "codex", page: 1, modelIndex: 7, }); @@ -528,6 +530,66 @@ describe("Discord model picker rendering", () => { expect(submitState?.modelIndex).toBe(3); }); + it("renders provider-compatible runtime choices in the model view", () => { + const data = createModelsProviderData({ + openai: ["gpt-4.1", "gpt-4o", "o3"], + anthropic: ["claude-sonnet-4-5"], + }); + data.runtimeChoicesByProvider = new Map([ + [ + "openai", + [ + { + id: "pi", + label: "OpenClaw Pi Default", + description: "Use the built-in OpenClaw Pi runtime.", + }, + { + id: "codex", + label: "codex", + description: "Run openai models through the codex harness.", + }, + ], + ], + ]); + + const rows = renderModelsViewRows({ + command: "model", + userId: "42", + data, + provider: "openai", + page: 1, + providerPage: 1, + currentModel: "openai/gpt-4o", + currentRuntime: "pi", + pendingModel: "openai/o3", + pendingModelIndex: 3, + pendingRuntime: "codex", + }); + + expect(rows).toHaveLength(4); + + const runtimeSelect = rows[1]?.components?.find( + (component) => component.type === DISCORD_STRING_SELECT_COMPONENT_TYPE, + ); + if (!runtimeSelect) { + throw new Error("models view did not render a runtime select"); + } + expect(runtimeSelect.options?.map((option) => option.value)).toEqual(["pi", "codex"]); + expect(runtimeSelect.options?.find((option) => option.value === "pi")?.label).toBe( + "OpenClaw Pi Default", + ); + expect(runtimeSelect.options?.find((option) => option.value === "codex")?.default).toBe(true); + + const submitButton = rows[3]?.components?.at(-1); + const submitState = requireValue( + parseDiscordModelPickerCustomId(submitButton?.custom_id ?? ""), + "submit custom id should parse", + ); + expect(submitState.runtime).toBe("codex"); + expect(submitState.modelIndex).toBe(3); + }); + it("renders not-found model view with a back button", () => { const data = createModelsProviderData({ openai: ["gpt-4o"] }); diff --git a/extensions/discord/src/monitor/model-picker.ts b/extensions/discord/src/monitor/model-picker.ts index e88c71a0f72..b4ac0994d78 100644 --- a/extensions/discord/src/monitor/model-picker.ts +++ b/extensions/discord/src/monitor/model-picker.ts @@ -38,6 +38,7 @@ const PICKER_ACTIONS = [ "open", "provider", "model", + "runtime", "submit", "quick", "back", @@ -57,6 +58,7 @@ export type DiscordModelPickerState = { view: DiscordModelPickerView; userId: string; provider?: string; + runtime?: string; page: number; providerPage?: number; modelIndex?: number; @@ -135,6 +137,8 @@ export type DiscordModelPickerModelViewParams = { currentModel?: string; pendingModel?: string; pendingModelIndex?: number; + currentRuntime?: string; + pendingRuntime?: string; quickModels?: string[]; layout?: DiscordModelPickerLayout; }; @@ -319,6 +323,37 @@ function createModelSelect(params: { return new DiscordModelPickerSelect(); } +function getRuntimeChoices(params: { + data: ModelsProviderData; + provider: string; +}): Array<{ id: string; label: string; description?: string }> { + return ( + params.data.runtimeChoicesByProvider?.get(normalizeProviderId(params.provider)) ?? [ + { + id: "pi", + label: "OpenClaw Pi Default", + description: "Use the built-in OpenClaw Pi runtime.", + }, + ] + ); +} + +function resolveSelectedRuntime(params: { + data: ModelsProviderData; + provider: string; + currentRuntime?: string; + pendingRuntime?: string; +}): string { + const choices = getRuntimeChoices({ data: params.data, provider: params.provider }); + const allowed = new Set(choices.map((choice) => choice.id)); + const pending = params.pendingRuntime?.trim(); + if (pending && allowed.has(pending)) { + return pending; + } + const current = params.currentRuntime?.trim(); + return current && allowed.has(current) ? current : "pi"; +} + function buildRenderedShell( params: DiscordModelPickerRenderShellParams, ): DiscordModelPickerRenderedView { @@ -398,6 +433,8 @@ function buildModelRows(params: { currentModel?: string; pendingModel?: string; pendingModelIndex?: number; + currentRuntime?: string; + pendingRuntime?: string; quickModels?: string[]; }): { rows: DiscordModelPickerRow[]; buttonRow: Row