diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index e9c27493fbd6..8f0c19f98905 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -07c747212af5b8a228a7ff23101a2fa4096f148600d1d2e0fc83ac8f151cb58b plugin-sdk-api-baseline.json -85868eedeb4a99e714468f7f8c81d070227f8ed7e247939a4ac0d458ad04426d plugin-sdk-api-baseline.jsonl +1a655db74b630c67a5549e4254fd4dbb2965dbb8f1420ba425eafde5c6e41ad9 plugin-sdk-api-baseline.json +da435187b9793395e7555949d1e2ca483e9ed4d8478d666bbb20586b33a62c32 plugin-sdk-api-baseline.jsonl diff --git a/docs/agent-runtime-architecture.md b/docs/agent-runtime-architecture.md index 52e422d04854..7fd7cf2e4bf8 100644 --- a/docs/agent-runtime-architecture.md +++ b/docs/agent-runtime-architecture.md @@ -46,8 +46,8 @@ Resource types not listed in a manifest fall back to discovery of conventional ` - The built-in runtime id is `openclaw`. The legacy alias `pi` normalizes to `openclaw`; `codex-app-server` normalizes to `codex`. - Plugin harnesses register additional runtime ids (for example `codex`). - Runtime policy is model/provider-scoped `agentRuntime.id` config (model entry wins over provider entry). Unset or `default` resolves to `auto`. -- `auto` selects a registered plugin harness that supports the provider/model, otherwise the built-in OpenClaw runtime. -- The `openai` provider on the official API endpoint defaults to the `codex` harness; custom `baseUrl` values keep their configured behavior. +- `auto` selects a registered plugin harness that supports the effective provider route, otherwise the built-in OpenClaw runtime. A provider or model prefix alone never selects a harness. +- OpenAI may select `codex` implicitly only for an exact official HTTPS Platform Responses or ChatGPT Responses route with no authored request override. Completions adapters, custom endpoints, and routes with authored request behavior stay on `openclaw`; plaintext official HTTP endpoints are rejected. See [OpenAI implicit agent runtime](/providers/openai#implicit-agent-runtime). ## Related diff --git a/docs/cli/models.md b/docs/cli/models.md index e55c127059be..8a6ddd370afb 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -68,11 +68,12 @@ Options: `--all` (full catalog), `--local` (filter to local models), `--provider Notes: -- The `Auth` column is provider-level and read-only. It is computed from local auth profile metadata, env markers, configured provider keys, local-provider markers, AWS Bedrock env/profile markers, and plugin synthetic-auth metadata; it does not load provider runtime, read keychain secrets, call provider APIs, or prove exact per-model execution readiness. +- The `Auth` column is read-only. For provider-owned model routes such as OpenAI, it matches each row's API/base-URL route to eligible profiles in effective `auth.order`, env/config credentials, and resolved command-scoped SecretRefs. A concrete OpenAI row stays unknown when its route policy is unavailable instead of borrowing provider-level auth; provider-only legacy checks and other providers retain provider-level behavior. Plugin synthetic-auth metadata is only a runtime-capability hint, not proof of native account authentication, so account-dependent routes remain unknown without positive registry evidence. The command does not load provider runtime, read keychain secrets, call provider APIs, or prove exact execution readiness. - `models list --all --provider ` can include provider-owned static catalog rows from plugin manifests or bundled provider catalog metadata even when you have not authenticated with that provider yet. Those rows still show as unavailable until matching auth is configured. - `models list` keeps the control plane responsive while provider catalog discovery is slow. The default and configured views fall back to configured or synthetic model rows after a short wait and let discovery finish in the background. Use `--all` when you need the exact full discovered catalog and are willing to wait for provider discovery. - Broad `models list --all` merges manifest catalog rows over registry rows without loading provider runtime supplement hooks. Provider-filtered manifest fast paths use only providers marked `static`; providers marked `refreshable` stay registry/cache-backed and append manifest rows as supplements, while providers marked `runtime` stay on registry/runtime discovery. - `models list` keeps native model metadata and runtime caps distinct. In table output, `Ctx` shows `contextTokens/contextWindow` when an effective runtime cap differs from the native context window; JSON rows include `contextTokens` when a provider exposes that cap. +- For provider-owned routes, `models list` projects one logical provider/model row onto the selected route. `Input` and `Ctx` come only from an exact physical-route catalog row, with explicit configured logical overrides applied last; unresolved route selection shows unknown capability fields instead of borrowing sibling-route metadata. - `models list --provider ` filters by provider id, such as `moonshot` or `openai`. It does not accept display labels from interactive provider pickers, such as `Moonshot AI`. - Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`). - If you omit the provider, OpenClaw resolves the input as an alias first, then as a unique configured-provider match for that exact model id, and only then falls back to the configured default provider with a deprecation warning. If that provider no longer exposes the configured default model, OpenClaw falls back to the first configured provider/model instead of surfacing a stale removed-provider default. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 69d7e4a7bf2d..d323f0226873 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -27,15 +27,17 @@ Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram) - OpenAI-family routes are prefix-specific: + OpenAI model refs and agent runtimes are separate: - - `openai/` uses the native Codex app-server harness for agent turns by default. This is the usual ChatGPT/Codex subscription setup. + - `openai/` selects the canonical OpenAI provider and model. The prefix alone never selects Codex. + - With provider/model runtime policy unset or `auto`, OpenAI may select Codex implicitly only for an exact official HTTPS Platform Responses or ChatGPT Responses route with no authored request override. + - Authored Completions adapters, custom endpoints, and routes with authored request behavior stay on OpenClaw. Plaintext official HTTP endpoints are rejected. - legacy Codex model refs are legacy config that doctor rewrites to `openai/`. - - `openai/` plus provider/model `agentRuntime.id: "openclaw"` uses OpenClaw's built-in runtime for explicit API-key or compatibility routes. + - Provider/model `agentRuntime.id: "openclaw"` explicitly keeps an otherwise eligible route on OpenClaw. `agentRuntime.id: "codex"` requires Codex and fails closed when the effective route is not Codex-compatible. - See [OpenAI](/providers/openai) and [Codex harness](/plugins/codex-harness). If the provider/runtime split is confusing, read [Agent runtimes](/concepts/agent-runtimes) first. + See [OpenAI implicit agent runtime](/providers/openai#implicit-agent-runtime) and [Codex harness](/plugins/codex-harness). If the provider/runtime split is confusing, read [Agent runtimes](/concepts/agent-runtimes) first. - Plugin auto-enable follows the same boundary: `openai/*` agent refs enable the Codex plugin for the default route, and explicit provider/model `agentRuntime.id: "codex"` or legacy `codex/` refs also require it. + Plugin auto-enable follows the same boundary: an implicitly Codex-compatible effective route can enable the Codex plugin, while explicit provider/model `agentRuntime.id: "codex"` or legacy `codex/` refs require it. An `openai/*` prefix by itself does not. Fresh OpenAI setup uses a route-specific GPT-5.6 ref: API-key setup selects `openai/gpt-5.6` (the bare direct-API id resolves to Sol), while @@ -149,17 +151,16 @@ Claude CLI reuse (`claude -p`) is a sanctioned OpenClaw integration path. Anthro - Fresh native Codex app-server harness ref: `openai/gpt-5.6-sol` - Native Codex app-server harness docs: [Codex harness](/plugins/codex-harness) - Legacy model refs: `codex/gpt-*` -- Plugin boundary: `openai/*` loads the OpenAI plugin; the native Codex app-server plugin is selected by the Codex harness runtime. +- Plugin boundary: `openai/*` loads the OpenAI plugin; explicit runtime policy or the provider-owned effective route decides whether the native Codex app-server plugin is selected. - CLI: `openclaw onboard --auth-choice openai` or `openclaw models auth login --provider openai` -- Default transport is `auto` (WebSocket-first, SSE fallback) -- Override per OpenAI Codex model via `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) -- `params.serviceTier` is also forwarded on native Codex Responses requests (`chatgpt.com/backend-api`) +- OpenClaw's embedded ChatGPT Responses transport defaults to `auto` (WebSocket-first, SSE fallback). +- `agents.defaults.models["openai/"].params.transport`, `params.serviceTier`, and `params.fastMode` are authored embedded-request settings. They keep implicit runtime selection on OpenClaw; native Codex owns its app-server transport and service tier. - Hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`) are only attached on native Codex traffic to `chatgpt.com/backend-api`, not generic OpenAI-compatible proxies -- Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*`; OpenClaw maps that to `service_tier=priority` +- The shared `/fast` toggle remains available as a runtime control; it is distinct from authored model params. - The native Codex catalog can expose exact `openai/gpt-5.6-sol`, `openai/gpt-5.6-terra`, and `openai/gpt-5.6-luna` refs according to account access. It does not apply the direct API's bare `gpt-5.6` alias client-side. - `openai/gpt-5.5` uses the Codex catalog native `contextWindow = 400000` and default runtime `contextTokens = 272000`; override the runtime cap with `models.providers.openai.models[].contextTokens` - Sign in with `openai` auth and use `openai/gpt-5.6-sol` for a fresh subscription-backed setup. Select `openai/gpt-5.5` explicitly if that Codex workspace does not expose GPT-5.6. -- Use provider/model `agentRuntime.id: "openclaw"` only when you want the built-in OpenClaw route; otherwise keep the selected `openai/*` model on the default Codex harness. +- Use provider/model `agentRuntime.id: "openclaw"` to keep an otherwise eligible route on the built-in runtime. With runtime unset or `auto`, only an exact official HTTPS Responses/ChatGPT-compatible route with no authored request override may select Codex implicitly. - Legacy Codex GPT refs are legacy state, not a live provider route. Use canonical `openai/*` refs for new agent config, and run `openclaw doctor --fix` to migrate old legacy Codex model refs without upgrading an existing explicit `openai/gpt-5.5` selection. ```json5 diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 16a387428a31..33967687354b 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -23,7 +23,21 @@ sidebarTitle: "Models CLI" -A model ref (`provider/model`) chooses a provider and model. It does not usually choose the low-level agent runtime. OpenAI is the main exception: official `openai/gpt-*` agent refs run through the Codex app-server runtime by default. Subscription Copilot refs (`github-copilot/*`) can be opted into the external GitHub Copilot agent runtime plugin, but that path is always explicit (never selected by `auto`). Runtime overrides belong on provider/model policy, not on the whole agent or session. In Codex runtime mode, `openai/gpt-*` does not imply API-key billing; auth can come from a Codex account or an `openai` OAuth profile. See [Agent runtimes](/concepts/agent-runtimes) and [GitHub Copilot agent runtime](/plugins/copilot). +A model ref (`provider/model`) chooses a provider and model, not the low-level +agent runtime. With runtime policy unset or `auto`, OpenAI's provider-owned +route policy may select Codex only for an exact official HTTPS Platform +Responses or ChatGPT Responses route with no authored request override; the +`openai/*` prefix alone never selects Codex. Completions adapters, custom +endpoints, and authored request behavior stay on OpenClaw. Plaintext official +HTTP endpoints are rejected. See [OpenAI implicit agent runtime](/providers/openai#implicit-agent-runtime). + +Subscription Copilot refs (`github-copilot/*`) can be opted into the external +GitHub Copilot agent runtime plugin, but that path is always explicit (never +selected by `auto`). Runtime overrides belong on provider/model policy, not on +the whole agent or session. Runtime selection does not determine billing: +OpenAI API-key and ChatGPT/Codex subscription credentials remain distinct. See +[Agent runtimes](/concepts/agent-runtimes) and +[GitHub Copilot agent runtime](/plugins/copilot). ## Selection order diff --git a/docs/docs_map.md b/docs/docs_map.md index 9cc1135d8021..cf399d02b9fb 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -7085,6 +7085,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: What core still owns - H3: Harness-owned auth bootstrap - H3: Verified setup runtime artifacts + - H3: Request-transport contract - H2: Register a harness - H3: Delegated execution - H2: Selection policy @@ -7938,6 +7939,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Usage and cost tracking - H2: Quick choice - H2: Naming map + - H2: Implicit agent runtime - H2: GPT-5.6 limited preview - H2: OpenClaw feature coverage - H2: Memory embeddings diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 68dc824f94bc..731c53e7369e 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -447,7 +447,7 @@ Time format in system prompt. Default: `auto` (OS preference). - `compat.supportedReasoningEfforts`: per-model OpenAI-compatible reasoning effort list. Include `"xhigh"` for custom endpoints that truly accept it; OpenClaw then exposes `/think xhigh` in command menus, Gateway session rows, session patch validation, agent CLI validation, and `llm-task` validation for that configured provider/model. Use `compat.reasoningEffortMap` when the backend wants a provider-specific value for a canonical level. - `params.preserveThinking`: Z.AI-only opt-in for preserved thinking. When enabled and thinking is on, OpenClaw sends `thinking.clear_thinking: false` and replays prior `reasoning_content`; see [Z.AI thinking and preserved thinking](/providers/zai#advanced-configuration). - `localService`: optional provider-level process manager for local/self-hosted model servers. When the selected model belongs to that provider, OpenClaw probes `healthUrl` (or `baseUrl + "/models"`), starts `command` with `args` if the endpoint is down, waits up to `readyTimeoutMs`, then sends the model request. `command` must be an absolute path. `idleStopMs: 0` keeps the process alive until OpenClaw exits; a positive value stops the OpenClaw-spawned process after that many idle milliseconds. See [Local model services](/gateway/local-model-services). -- Runtime policy belongs on providers or models, not on `agents.defaults`. Use `models.providers..agentRuntime` for provider-wide rules or `agents.defaults.models["provider/model"].agentRuntime` / `agents.list[].models["provider/model"].agentRuntime` for model-specific rules. OpenAI agent models on the official OpenAI provider select Codex by default. +- Runtime policy belongs on providers or models, not on `agents.defaults`. Use `models.providers..agentRuntime` for provider-wide rules or `agents.defaults.models["provider/model"].agentRuntime` / `agents.list[].models["provider/model"].agentRuntime` for model-specific rules. A provider/model prefix alone never selects a harness. With runtime unset or `auto`, OpenAI may select Codex implicitly only for an exact official HTTPS Platform Responses or ChatGPT Responses route with no authored request override. See [OpenAI implicit agent runtime](/providers/openai#implicit-agent-runtime). - 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`. @@ -479,11 +479,11 @@ Time format in system prompt. Default: `auto` (OS preference). ``` - `id`: `"auto"`, `"openclaw"`, 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 OpenClaw when no harness matches. An explicit plugin runtime such as `id: "codex"` requires that harness and fails closed if it is unavailable or fails. +- `id: "auto"` lets registered plugin harnesses claim effective routes that declare or otherwise satisfy their support contract, and uses OpenClaw when no harness matches. An explicit plugin runtime such as `id: "codex"` requires that harness and a compatible effective route; it fails closed if either is unavailable or if execution fails. - `id: "pi"` is accepted only as a deprecated alias for `openclaw` to preserve shipped configs from v2026.5.22 and earlier. New config should use `openclaw`. - Runtime precedence is exact model policy first (`agents.list[].models["provider/model"]`, `agents.defaults.models["provider/model"]`, or `models.providers..models[]`), then `agents.list[]` / `agents.defaults.models["provider/*"]`, then provider-wide policy at `models.providers..agentRuntime`. - Whole-agent runtime keys are legacy. `agents.defaults.agentRuntime`, `agents.list[].agentRuntime`, session runtime pins, and `OPENCLAW_AGENT_RUNTIME` are ignored by runtime selection. Run `openclaw doctor --fix` to remove stale values. -- OpenAI agent models use the Codex harness by default; provider/model `agentRuntime.id: "codex"` remains valid when you want to make that explicit. +- Eligible exact official HTTPS OpenAI Responses/ChatGPT routes with no authored request override may use the Codex harness implicitly. Provider/model `agentRuntime.id: "codex"` makes Codex a fail-closed requirement but does not make an incompatible route compatible. - For Claude CLI deployments, prefer `model: "anthropic/claude-opus-4-8"` plus model-scoped `agentRuntime.id: "claude-cli"`. Legacy `claude-cli/` refs still work for compatibility, but new config should keep provider/model selection canonical and put the execution backend in provider/model runtime policy. - This only controls text agent-turn execution. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index b93f1f5bbc59..6e7f8c78dac3 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -184,7 +184,7 @@ Flags: - Channel status warnings (probed from the running gateway). - Channel-specific permission checks live under `openclaw channels capabilities`; for example, Discord voice channel permissions are audited with `openclaw channels capabilities --channel discord --target channel:`. - WhatsApp responsiveness checks for degraded Gateway event-loop health with local TUI clients still running; `--fix` stops only verified local TUI clients. - - Codex route repair for legacy `openai-codex/*` model refs in primary models, fallbacks, image/video generation models, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and session route pins; `--fix` rewrites them to `openai/*`, migrates `openai-codex:*` auth profiles/order to `openai:*`, removes stale session/whole-agent runtime pins, and leaves canonical OpenAI agent refs on the default Codex harness. + - Codex route repair for legacy `openai-codex/*` model refs in primary models, fallbacks, image/video generation models, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and session route pins; `--fix` rewrites them to `openai/*`, migrates `openai-codex:*` auth profiles/order to `openai:*`, removes stale session/whole-agent runtime pins, and lets the repaired effective route determine whether Codex is compatible. - Supervisor config audit (launchd/systemd/schtasks) with optional repair. - Embedded proxy environment cleanup for gateway services that captured shell `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` values during install or update. - Gateway runtime best-practice checks (Node vs Bun, version-manager paths). @@ -335,10 +335,10 @@ That stages grounded durable candidates into the short-term dreaming store while When an OpenAI Codex OAuth profile is configured, doctor probes the OpenAI authorization endpoint to verify that the local Node/OpenSSL TLS stack can validate the certificate chain. If the probe fails with a certificate error (for example `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, expired cert, or self-signed cert), doctor prints platform-specific fix guidance. On macOS with a Homebrew Node, the fix is usually `brew postinstall ca-certificates`. With `--deep`, the probe runs even if the gateway is healthy. - 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. + If you previously added legacy OpenAI transport settings under `models.providers.openai-codex`, they can shadow the built-in Codex OAuth provider path. Doctor warns when it sees those old transport settings alongside Codex OAuth so you can remove or rewrite the stale transport override and restore current routing behavior. Custom proxies and header-only overrides remain supported and do not trigger this warning, but those authored request routes are not eligible for implicit Codex selection. - 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 OpenAI provider path. + Doctor checks for legacy `openai-codex/*` model refs. Native Codex harness routing uses canonical `openai/*` model refs, but the prefix alone never selects Codex. With runtime policy unset or `auto`, only an exact official HTTPS Platform Responses or ChatGPT Responses route with no authored request override is eligible. See [OpenAI implicit agent runtime](/providers/openai#implicit-agent-runtime). In `--fix` / `--repair` mode, doctor rewrites affected default-agent and per-agent refs, including primary models, fallbacks, image/video generation models, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale persisted session route state: diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 80532685e999..1227bd09da68 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -19,6 +19,15 @@ legacy Codex GPT refs; put OpenAI agent auth order under `auth.order.openai`. Legacy Codex auth profile ids and legacy Codex auth order entries are repaired by `openclaw doctor --fix`. +With provider/model runtime policy unset or `auto`, the `openai/*` prefix alone +never selects this harness. OpenAI may select Codex implicitly only for an +exact official HTTPS Platform Responses or ChatGPT Responses route with no +authored request override. See +[OpenAI implicit agent runtime](/providers/openai#implicit-agent-runtime). +If Codex owns auth before Platform versus ChatGPT routing is known, OpenClaw +still requires every candidate route to declare Codex compatibility. Native +auth ownership alone never bypasses that route check. + When no OpenClaw sandbox is active, OpenClaw starts Codex app-server threads with Codex native code mode enabled (code-mode-only stays off by default), so native workspace/code capabilities remain available alongside OpenClaw @@ -171,19 +180,19 @@ rules, paired-node limits, metadata exposure, and troubleshooting. ## Configuration -| Need | Set | Where | -| -------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------- | -| Enable the harness | `plugins.entries.codex.enabled: true` | OpenClaw config | -| Show non-archived Codex sessions | `plugins.entries.codex.config.supervision.enabled: true` | Codex plugin config | -| Keep an allowlisted plugin install | Include `codex` in `plugins.allow` | OpenClaw config | -| Route OpenAI agent turns through Codex | `agents.defaults.model` or `agents.list[].model` as `openai/gpt-*` | OpenClaw agent config | -| Sign in with ChatGPT/Codex OAuth | `openclaw models auth login --provider openai` | CLI auth profile | -| Add API-key backup for Codex runs | `openai:*` API-key profile listed after subscription auth in `auth.order.openai` | CLI auth profile + OpenClaw config | -| Fail closed when Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | OpenClaw model/provider config | -| Use direct OpenAI API traffic | Provider or model `agentRuntime.id: "openclaw"` with normal OpenAI auth | OpenClaw model/provider config | -| Tune app-server behavior | `plugins.entries.codex.config.appServer.*` | Codex plugin config | -| Enable native Codex plugin apps | `plugins.entries.codex.config.codexPlugins.*` | Codex plugin config | -| Enable Codex Computer Use | `plugins.entries.codex.config.computerUse.*` | Codex plugin config | +| Need | Set | Where | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------- | +| Enable the harness | `plugins.entries.codex.enabled: true` | OpenClaw config | +| Show non-archived Codex sessions | `plugins.entries.codex.config.supervision.enabled: true` | Codex plugin config | +| Keep an allowlisted plugin install | Include `codex` in `plugins.allow` | OpenClaw config | +| Allow eligible OpenAI turns to use Codex implicitly | Exact official HTTPS Responses/ChatGPT route, no authored request override, runtime unset/`auto` | OpenAI provider/model config | +| Sign in with ChatGPT/Codex OAuth | `openclaw models auth login --provider openai` | CLI auth profile | +| Add API-key backup for Codex runs | `openai:*` API-key profile listed after subscription auth in `auth.order.openai` | CLI auth profile + OpenClaw config | +| Fail closed when Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | OpenClaw model/provider config | +| Use direct OpenAI API traffic | Provider or model `agentRuntime.id: "openclaw"` with normal OpenAI auth | OpenClaw model/provider config | +| Tune app-server behavior | `plugins.entries.codex.config.appServer.*` | Codex plugin config | +| Enable native Codex plugin apps | `plugins.entries.codex.config.codexPlugins.*` | Codex plugin config | +| Enable Codex Computer Use | `plugins.entries.codex.config.computerUse.*` | Codex plugin config | Prefer `auth.order.openai` for subscription-first/API-key-backup ordering. Existing legacy Codex auth profile ids and legacy Codex auth order are @@ -199,9 +208,10 @@ doctor-only legacy state; do not write new legacy Codex GPT refs. } ``` -Both profiles above still run through Codex for `openai/gpt-*` agent turns. -The API key is only an auth fallback, not a request to switch to OpenClaw or -plain OpenAI Responses. +For a Codex-compatible effective route, both profiles above remain candidates +for the same Codex run. Profile order chooses credentials, not the runtime. +Changing auth order does not make a custom, Completions, HTTP, or +request-overridden route Codex-compatible. ### Compaction @@ -259,11 +269,15 @@ for the harness and account. If `/status` is surprising, see Keep provider refs and runtime policy separate: -- Use `openai/gpt-*` for OpenAI agent turns through Codex. +- Use `openai/gpt-*` for canonical OpenAI model selection. The prefix alone + never selects Codex. +- With runtime unset or `auto`, only an exact official HTTPS Platform Responses + or ChatGPT Responses route with no authored request override may select Codex + implicitly. - Do not use legacy Codex GPT refs in config; run `openclaw doctor --fix` to repair legacy refs and stale session route pins. -- `agentRuntime.id: "codex"` is optional for normal OpenAI auto mode, but - useful when a deployment should fail closed if Codex is unavailable. +- `agentRuntime.id: "codex"` makes Codex a fail-closed requirement for a + compatible route. It does not make an incompatible effective route compatible. - `agentRuntime.id: "openclaw"` opts a provider or model into the embedded OpenClaw runtime when that is intentional. - `/codex ...` controls native Codex app-server conversations from chat. @@ -285,13 +299,13 @@ Keep provider refs and runtime policy separate: | Send Codex feedback only | `/codex diagnostics [note]` | | Start an ACP/acpx task | ACP/acpx session commands, not `/codex` | -| Use case | Configure | Verify | Notes | -| ---------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------- | ------------------------------------- | -| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-*` plus enabled `codex` plugin | `/status` shows `Runtime: OpenAI Codex` | Recommended path | -| Fail closed if Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | Turn fails instead of embedded fallback | Use for Codex-only deployments | -| Direct OpenAI API-key traffic through OpenClaw | Provider or model `agentRuntime.id: "openclaw"` and normal OpenAI auth | `/status` shows OpenClaw runtime | Use only when OpenClaw is intentional | -| Legacy config | legacy Codex GPT refs | `openclaw doctor --fix` rewrites it | Do not write new config this way | -| ACP/acpx Codex adapter | ACP `sessions_spawn({ runtime: "acp" })` | ACP task/session status | Separate from native Codex harness | +| Use case | Configure | Verify | Notes | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------ | +| Eligible OpenAI route with native Codex runtime | Exact official HTTPS Responses/ChatGPT route with no authored request override, plus enabled `codex` plugin | `/status` shows `Runtime: OpenAI Codex` | Implicit path when runtime is unset/`auto` | +| Fail closed if Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | Turn fails instead of embedded fallback | Use for Codex-only deployments | +| Direct OpenAI API-key traffic through OpenClaw | Provider or model `agentRuntime.id: "openclaw"` and normal OpenAI auth | `/status` shows OpenClaw runtime | Use only when OpenClaw is intentional | +| Legacy config | legacy Codex GPT refs | `openclaw doctor --fix` rewrites it | Do not write new config this way | +| ACP/acpx Codex adapter | ACP `sessions_spawn({ runtime: "acp" })` | ACP task/session status | Separate from native Codex harness | `agents.defaults.imageModel` follows the same prefix split. Use `openai/gpt-*` for the normal OpenAI route and `codex/gpt-*` only when image understanding @@ -302,8 +316,8 @@ Codex GPT refs to `openai/gpt-*`. ### Basic Codex deployment -Use the quickstart config when all OpenAI agent turns should use Codex by -default: +Use the quickstart config for an OpenAI model whose effective official HTTPS +route is eligible to select Codex implicitly: ```json5 { @@ -355,13 +369,16 @@ Keep Claude as the default agent and add a named Codex agent: } ``` -The `main` agent uses its normal provider path; the `codex` agent uses Codex -app-server. +The `main` agent uses its normal provider path. The `codex` agent uses Codex +app-server when its effective OpenAI route remains compatible; add explicit +model-scoped `agentRuntime.id: "codex"` when that should be a fail-closed +requirement. ### Fail-closed Codex deployment -`openai/gpt-*` already resolves to Codex when the bundled plugin is -available. Add explicit runtime policy for a written fail-closed rule: +An eligible exact official HTTPS OpenAI route can resolve to Codex when the +bundled plugin is available. Add explicit runtime policy for a written +fail-closed rule: ```json5 { @@ -389,8 +406,9 @@ available. Add explicit runtime policy for a written fail-closed rule: } ``` -With Codex forced, OpenClaw fails early if the Codex plugin is disabled, the -app-server is too old, or the app-server cannot start. +With Codex forced, OpenClaw fails early if the effective route is not declared +Codex-compatible, the plugin is disabled, the app-server is too old, or the +app-server cannot start. ## App-server policy @@ -901,11 +919,12 @@ configs. Select an `openai/gpt-*` model, enable `plugins.entries.codex.enabled`, and check whether `plugins.allow` excludes `codex`. -**OpenClaw uses the built-in harness instead of Codex:** confirm the model -ref is `openai/gpt-*` on the official OpenAI provider and that the Codex -plugin is installed and enabled. For strict proof while testing, set -provider or model `agentRuntime.id: "codex"` — a forced Codex runtime fails -instead of falling back to OpenClaw. +**OpenClaw uses the built-in harness instead of Codex:** confirm the effective +route is an exact official HTTPS Platform Responses or ChatGPT Responses route, +has no authored request override, and that the Codex plugin is installed and +enabled. The `openai/gpt-*` prefix alone is not enough. For strict proof while +testing, set provider or model `agentRuntime.id: "codex"`; forced Codex fails +instead of falling back when the route or harness is incompatible. **OpenAI Codex runtime falls back to the API-key path:** collect a redacted gateway excerpt that shows the model, runtime, selected provider, and diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 9f17d59da918..6335a137cbd6 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -92,6 +92,25 @@ Harnesses may use the plan for decisions that need to match OpenClaw behavior, but treat it as host-owned attempt state: do not mutate it or use it to switch providers/models inside a turn. +### Request-transport contract + +`supports(ctx)` receives the resolved model transport in `ctx.modelProvider`. +Two secret-free provider-owned facts describe the selected route: + +- `runtimePolicy.compatibleIds` lists the runtime ids the provider declares + compatible with that concrete route. An absent policy means the provider did + not declare route-level compatibility; it is not permission to assume support. +- `requestTransportOverrides: "none"` means no authored provider/model request + override must be reproduced. `"present"` means authored headers, auth + transport, proxy, TLS, local-service, private-network behavior, or request + parameters exist. The fact does not expose those values. + +Return `{ supported: false, reason }` when the harness cannot reproduce the +prepared transport. Do not infer support by reading raw config after selection. +When auth preparation yields multiple retry routes, one harness must support +all of them before dispatch. Implicit selection uses OpenClaw if no plugin can +own the full set; an explicit or persisted plugin selection fails closed. + ## Register a harness **Import:** `openclaw/plugin-sdk/agent-harness` @@ -105,9 +124,12 @@ const myHarness: AgentHarness = { label: "My native agent harness", supports(ctx) { - return ctx.provider === "my-provider" + const routeSupportsHarness = + ctx.modelProvider?.runtimePolicy?.compatibleIds.includes("my-harness") === true; + const canReproduceRequest = ctx.modelProvider?.requestTransportOverrides !== "present"; + return ctx.provider === "my-provider" && routeSupportsHarness && canReproduceRequest ? { supported: true, priority: 100 } - : { supported: false }; + : { supported: false, reason: "effective route is not harness-compatible" }; }, async runAttempt(params) { @@ -150,8 +172,8 @@ OpenClaw chooses a harness after provider/model resolution: 1. Model-scoped runtime policy wins. 2. Provider-scoped runtime policy comes next. -3. `auto` asks registered harnesses if they support the resolved - provider/model. +3. `auto` asks registered harnesses if they support the resolved effective + route. Provider/model prefixes alone never select a harness. 4. If no registered harness matches, OpenClaw uses its embedded runtime. Plugin harness failures surface as run failures. In `auto` mode, embedded @@ -160,10 +182,19 @@ provider/model. Once a plugin harness has claimed a run, OpenClaw does not replay that same turn through another runtime, because that can change auth/runtime semantics or duplicate side effects. -Whole-session and whole-agent runtime pins are ignored by selection. That -includes stale session `agentHarnessId` values, `agents.defaults.agentRuntime`, -`agents.list[].agentRuntime`, and `OPENCLAW_AGENT_RUNTIME`. `/status` shows the -effective runtime selected from the provider/model route. +Configured runtime policy remains authoritative about the desired runtime. A +persisted session `agentHarnessId` keeps ownership of its native transcript +while route/auth preparation is still pending. Neither makes an incompatible +route compatible: once prepared facts exist, the selected or pinned harness +must support them or the run fails closed. `/status` shows the effective runtime +selected from policy, persisted ownership, and route support. +Prepared status is explicit: missing `runtimePolicy` stays undeclared instead +of being inferred from whichever transport fields happen to be present. +When harness-owned auth leaves multiple physical routes unresolved, the +prepared support fact is the intersection of their compatible runtime ids and +reports request overrides if any candidate has them. One undeclared candidate +therefore makes native compatibility empty; `preparedAuth.source: "harness"` +is an auth owner, not permission to infer route support. If the selected harness is surprising, enable `agents/harness` debug logging and inspect the gateway's structured `agent harness selected` record: it @@ -191,11 +222,14 @@ The bundled Codex plugin follows this pattern: - app-server request: OpenClaw sends the bare model id to Codex and lets the harness talk to the native app-server protocol -The Codex plugin is additive. Plain `openai/gpt-*` agent refs on the official -OpenAI API endpoint (`api.openai.com`) select the Codex harness by default; -custom OpenAI-compatible base URLs keep their configured provider behavior -instead. Older `codex/gpt-*` refs still select the Codex provider and harness -for compatibility. +The Codex plugin is additive. With runtime policy unset or `auto`, OpenAI may +select Codex only when its provider-owned route contract declares `codex` +compatible: an exact official HTTPS Platform Responses or ChatGPT Responses +route with no authored request override. The `openai/*` prefix alone never +selects Codex. Custom endpoints, Completions adapters, and authored request +behavior stay on OpenClaw. Plaintext official HTTP endpoints are rejected. Older `codex/gpt-*` +refs remain compatibility inputs. See +[OpenAI implicit agent runtime](/providers/openai#implicit-agent-runtime). For operator setup, model prefix examples, and Codex-only configs, see [Codex Harness](/plugins/codex-harness). @@ -266,9 +300,9 @@ 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-*`; OpenAI agent turns select the Codex harness -by default. Legacy Codex model refs routes should be repaired with -`openclaw doctor --fix`, and legacy `codex/*` model refs remain compatibility -aliases for the native harness. +only when the effective route declares Codex compatibility. Legacy Codex model +refs 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, @@ -281,12 +315,13 @@ are not retried through another runtime. ## Runtime strictness By default, OpenClaw uses `auto` provider/model runtime policy: registered -plugin harnesses can claim a provider/model pair, and the embedded runtime -handles the turn when none match. OpenAI agent refs on the official OpenAI -provider default to Codex. Use an explicit provider/model plugin runtime such -as `agentRuntime.id: "codex"` when missing harness selection should fail -instead of routing through the embedded runtime. Selected plugin harness -failures always fail hard. This does not block an explicit provider/model +plugin harnesses can claim compatible effective routes, and the embedded +runtime handles the turn when none match. A provider/model prefix alone never +selects a harness. Use an explicit provider/model plugin runtime such as +`agentRuntime.id: "codex"` when missing harness selection should fail instead +of routing through the embedded runtime. Explicit selection does not make an +incompatible route compatible. Selected plugin harness failures always fail +hard. This does not block an explicit provider/model `agentRuntime.id: "openclaw"`. For Codex-only embedded runs: diff --git a/docs/providers/openai.md b/docs/providers/openai.md index a97ec021bacb..44ecda518a8d 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -9,12 +9,12 @@ title: "OpenAI" OpenClaw uses one provider id, `openai`, for both direct API-key auth and ChatGPT/Codex subscription auth. `openai/*` is the canonical model route. -Embedded agent turns on `openai/*` run through the bundled Codex app-server -runtime by default; direct API-key auth stays available for non-agent OpenAI -surfaces (images, video, embeddings, speech, realtime) and as an explicit -compatibility route for agent turns. +For embedded agent turns with runtime policy unset or `auto`, OpenAI's route +facts decide whether OpenClaw may select the bundled Codex app-server runtime +implicitly. The `openai/*` prefix alone does not select a runtime. -- **Agent models** - `openai/*` through the Codex runtime. Sign in with Codex +- **Agent models** - `openai/*` through the runtime selected by explicit + `agentRuntime` config or OpenAI's implicit route policy. Sign in with Codex auth for ChatGPT/Codex subscription use, or configure an API-key auth profile when you want key-based billing. - **Non-agent OpenAI APIs** - direct OpenAI Platform access, billed per use, @@ -59,12 +59,33 @@ changing config. | Name you see | Layer | Meaning | | --------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------- | -| `openai` | Provider prefix | Canonical OpenAI model route; agent turns default to the Codex runtime. | +| `openai` | Provider prefix | Canonical OpenAI model route; route facts determine the implicit runtime. | | `codex` plugin | Plugin | Bundled plugin providing the native Codex app-server runtime and `/codex` chat controls. | | provider/model `agentRuntime.id: codex` | Agent runtime | Force the native Codex app-server harness for matching 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. | +## Implicit agent runtime + +When provider/model `agentRuntime` policy is unset or `auto`, OpenAI's +provider-owned route policy chooses the implicit runtime from the effective +endpoint and adapter: + +| Effective route facts | Implicit runtime | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| Exact official Platform HTTPS endpoint with `openai-responses`, or exact official ChatGPT HTTPS endpoint with `openai-chatgpt-responses`; no authored request override | Codex may be selected | +| Authored `openai-completions` adapter | OpenClaw | +| Custom endpoint | OpenClaw | +| Explicit exact official endpoint using HTTP | Rejected | +| Route with an authored provider/model request override | OpenClaw | + +An explicit non-default provider/model `agentRuntime.id` remains authoritative. +For example, `agentRuntime.id: "openclaw"` keeps an otherwise Codex-eligible +route on OpenClaw, while `agentRuntime.id: "codex"` requires Codex and fails +closed when the effective route is not declared Codex-compatible. +Runtime selection does not change credential type or billing: Platform API-key +auth and ChatGPT/Codex subscription auth remain distinct. + `openclaw doctor --fix` migrates legacy Codex model refs, legacy Codex auth profile ids, and legacy Codex auth-order entries to the canonical `openai` route. Use `auth.order.openai` for new auth-order config. @@ -108,13 +129,13 @@ OpenClaw surfaces the upstream access error and does not silently replace a GPT-5.6 selection with GPT-5.5. -Agent model turns on `openai/*` require the bundled Codex app-server plugin by -default. Explicit OpenClaw runtime config remains available as an opt-in -compatibility route: when OpenClaw is explicitly selected with an `openai` -OAuth profile, the model ref stays `openai/*` but requests route through the -Codex-auth transport internally. Run `openclaw doctor --fix` to repair stale -legacy Codex model refs, `codex-cli/*` refs, or old runtime session pins that -were not set by explicit runtime config. +Eligible exact official HTTPS routes may select the bundled Codex app-server +plugin when runtime policy is unset or `auto`; authored Completions routes, +custom endpoints, and request-transport overrides remain on OpenClaw. Plaintext +official HTTP endpoints are rejected. Explicit provider/model runtime config remains +authoritative. Run `openclaw doctor --fix` to repair stale legacy Codex model +refs, `codex-cli/*` refs, or old runtime session pins that were not set by +explicit runtime config. ## OpenClaw feature coverage @@ -124,7 +145,7 @@ were not set by explicit runtime config. | Chat / Responses | `openai/` model provider | Yes | | Codex subscription models | `openai/` with OpenAI OAuth | Yes | | Legacy Codex model refs | old Codex model refs, `codex-cli/` | Repaired by doctor to `openai/` | -| Codex app-server harness | `openai/` with runtime unset, or provider/model `agentRuntime.id: codex` | Yes | +| Codex app-server harness | Codex-compatible HTTPS route with runtime unset/`auto`, or explicit `agentRuntime.id: codex` | Yes | | Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no other provider is pinned | | Images | `image_generate` | Yes | | Videos | `video_generate` | Yes | @@ -206,19 +227,21 @@ for the full example. ### Route summary - | Model ref | Runtime config | Route | Auth | - | --------------------- | --------------------------------------------------- | ------------------------ | -------------------------------- | - | `openai/gpt-5.6` | unset, or provider/model `agentRuntime.id: "codex"` | Codex app-server harness | Ordered API-key auth profile | - | `openai/gpt-5.6` | provider/model `agentRuntime.id: "openclaw"` | OpenClaw embedded runtime | Selected `openai` API-key profile | - | `openai/gpt-5.5` | either runtime | Selected agent runtime | Selected OpenAI API-key profile | - | `openai/gpt-5.4-mini` | unset, or provider/model `agentRuntime.id: "codex"` | Codex app-server harness | Ordered API-key auth profile | + | Model ref | Runtime policy or route facts | Route | Auth | + | ---------------- | ------------------------------------------------------------- | ------------------------- | --------------------------------- | + | `openai/gpt-5.6` | unset/`auto`, exact official HTTPS native route, no request override | Codex may be selected | Ordered API-key auth profile | + | `openai/gpt-5.6` | provider/model `agentRuntime.id: "openclaw"` | OpenClaw embedded runtime | Selected `openai` API-key profile | + | `openai/gpt-5.5` | explicit provider/model `agentRuntime.id` | Selected agent runtime | Selected OpenAI API-key profile | + | `openai/*` | authored Completions, custom, or request override | OpenClaw embedded runtime | Credential type remains unchanged | + | `openai/*` | plaintext official HTTP endpoint | Rejected | Credential is not sent | - Agent turns on `openai/*` use the Codex app-server harness by default. For - API-key auth on an agent model, create an `openai` API-key auth profile and - order it with `auth.order.openai`; `OPENAI_API_KEY` remains the direct - fallback for non-agent OpenAI API surfaces. Run `openclaw doctor --fix` to - migrate older legacy Codex auth-order entries. + With runtime unset or `auto`, only an eligible exact official HTTPS native + route may select the Codex app-server harness implicitly. For API-key auth + on an agent model, create an `openai` API-key auth profile and order it with + `auth.order.openai`; `OPENAI_API_KEY` remains the direct fallback for + non-agent OpenAI API surfaces. Run `openclaw doctor --fix` to migrate older + legacy Codex auth-order entries. ### Config example @@ -288,9 +311,9 @@ for the full example. openclaw config set agents.defaults.model.primary openai/gpt-5.6-sol ``` - No runtime config is required for the default path. OpenAI agent - turns select the native Codex app-server runtime automatically, and - OpenClaw installs or repairs the bundled Codex plugin when this route + No runtime config is required for this exact official HTTPS native + route. It may select the Codex app-server runtime automatically, and + OpenClaw installs or repairs the bundled Codex plugin when that runtime is chosen. @@ -305,15 +328,17 @@ for the full example. ### Route summary - | Model ref | Runtime config | Route | Auth | - | ------------------------- | --------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------- | - | `openai/gpt-5.6-sol` | unset, or provider/model `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in, or an ordered `openai` auth profile | - | `openai/gpt-5.6-terra` | unset, or provider/model `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in when the catalog exposes Terra | - | `openai/gpt-5.6-luna` | unset, or provider/model `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in when the catalog exposes Luna | - | `openai/gpt-5.6-sol` | provider/model `agentRuntime.id: "openclaw"` | OpenClaw embedded runtime, internal Codex-auth transport | Selected `openai` OAuth profile | - | `openai/gpt-5.5` | either runtime | Selected agent runtime | Selected OpenAI auth profile | - | Legacy Codex GPT-5.5 ref | repaired by doctor | Rewritten to `openai/gpt-5.5` | Migrated OpenAI OAuth profile | - | `codex-cli/gpt-5.5` | repaired by doctor | Rewritten to `openai/gpt-5.5` | Codex app-server auth | + | Model ref | Runtime policy or route facts | Route | Auth | + | ------------------------ | ------------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------- | + | `openai/gpt-5.6-sol` | unset/`auto`, exact official HTTPS native route, no request override | Codex may be selected | Codex sign-in, or an ordered `openai` auth profile | + | `openai/gpt-5.6-terra` | unset/`auto`, exact official HTTPS native route, no request override | Codex may be selected | Codex sign-in when the catalog exposes Terra | + | `openai/gpt-5.6-luna` | unset/`auto`, exact official HTTPS native route, no request override | Codex may be selected | Codex sign-in when the catalog exposes Luna | + | `openai/gpt-5.6-sol` | provider/model `agentRuntime.id: "openclaw"` | OpenClaw embedded runtime, internal Codex-auth transport | Selected `openai` OAuth profile | + | `openai/gpt-5.5` | explicit provider/model `agentRuntime.id` | Selected agent runtime | Selected OpenAI auth profile | + | `openai/*` | authored Completions, custom, or request override | OpenClaw embedded runtime | Credential requirement remains route-specific | + | `openai/*` | plaintext official HTTP endpoint | Rejected | Credential is not sent | + | Legacy Codex GPT-5.5 ref | repaired by doctor | Rewritten to `openai/gpt-5.5` | Migrated OpenAI OAuth profile | + | `codex-cli/gpt-5.5` | repaired by doctor | Rewritten to `openai/gpt-5.5` | Codex app-server auth | Fresh subscription-backed setup uses exact `openai/gpt-5.6-sol`; the @@ -420,8 +445,8 @@ for the full example. Chat `/status` shows which model runtime is active for the current session. The bundled Codex app-server harness appears as - `Runtime: OpenAI Codex` for `openai/*` agent turns. Stale OpenAI runtime - session pins are repaired to Codex unless config explicitly pins OpenClaw. + `Runtime: OpenAI Codex` when an eligible implicit route or explicit + provider/model runtime policy selects it. ### Doctor warning @@ -472,9 +497,10 @@ for the full example. ## Native Codex app-server auth -The native Codex app-server harness uses `openai/*` model refs with runtime -config unset or provider/model `agentRuntime.id: "codex"`, but its auth is -still account-based. OpenClaw selects auth in this order: +The native Codex app-server harness uses `openai/*` model refs when an eligible +exact official HTTPS route selects it implicitly, or when provider/model +`agentRuntime.id: "codex"` selects it explicitly. Its auth is still +account-based. OpenClaw selects auth in this order: 1. Ordered OpenAI auth profiles for the agent, preferably under `auth.order.openai`. Run `openclaw doctor --fix` to migrate older legacy @@ -999,6 +1025,13 @@ accordion below. ## Advanced configuration +The per-model `params` examples below shape OpenClaw's embedded provider +request. Configuring them is authored request behavior, so an otherwise eligible +`auto` route stays on OpenClaw instead of selecting Codex implicitly. The native +Codex app-server harness owns its own transport and request settings; explicit +`agentRuntime.id: "codex"` fails closed when the effective route is not declared +Codex-compatible. + OpenClaw uses WebSocket-first with SSE fallback (`"auto"`) for `openai/*`. diff --git a/extensions/codex/harness.test.ts b/extensions/codex/harness.test.ts index c42a49c6b8a3..617def538845 100644 --- a/extensions/codex/harness.test.ts +++ b/extensions/codex/harness.test.ts @@ -8,6 +8,10 @@ import { } from "./src/app-server/session-binding.test-helpers.js"; describe("Codex agent harness supports()", () => { + it("owns auth bootstrap for every native attempt", () => { + expect(harness.authBootstrap).toBe("harness"); + }); + const harness = createCodexAppServerAgentHarness({ bindingStore: testCodexAppServerBindingStore, }); @@ -37,6 +41,161 @@ describe("Codex agent harness supports()", () => { }); }); + it("supports an official route declared compatible with Codex", () => { + expect( + harness.supports({ + provider: "openai", + requestedRuntime: "codex", + modelProvider: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + }), + ).toEqual({ supported: true, priority: 100 }); + }); + + it("rejects unresolved harness auth without declared route compatibility", () => { + const result = harness.supports({ + provider: "openai", + requestedRuntime: "codex", + modelProvider: { + requestTransportOverrides: "none", + preparedAuth: { source: "harness" }, + }, + }); + expect(result.supported).toBe(false); + expect(!result.supported ? result.reason : undefined).toContain("not declared"); + }); + + it.each([ + { + label: "forwarded OAuth subscription", + preparedAuth: { source: "profile", mode: "oauth", requirement: "subscription" } as const, + supported: true, + }, + { + label: "direct subscription credential", + preparedAuth: { source: "direct", mode: "oauth", requirement: "subscription" } as const, + supported: false, + }, + { + label: "missing subscription credential", + preparedAuth: { source: "none", requirement: "subscription" } as const, + supported: false, + }, + { + label: "resolved direct Platform key", + preparedAuth: { source: "direct", mode: "api-key", requirement: "api-key" } as const, + supported: true, + }, + { + label: "forwarded Platform key profile", + preparedAuth: { source: "profile", mode: "api_key", requirement: "api-key" } as const, + supported: true, + }, + { + label: "unresolved harness-native auth", + preparedAuth: { source: "harness" } as const, + supported: true, + }, + { + label: "unvalidated harness-native subscription", + preparedAuth: { source: "harness", requirement: "subscription" } as const, + supported: false, + }, + ])("reports $label reproducibility", ({ preparedAuth, supported }) => { + const result = harness.supports({ + provider: "openai", + requestedRuntime: "codex", + modelProvider: { + api: + preparedAuth.requirement === "api-key" ? "openai-responses" : "openai-chatgpt-responses", + baseUrl: + preparedAuth.requirement === "api-key" + ? "https://api.openai.com/v1" + : "https://chatgpt.com/backend-api/codex", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + preparedAuth, + }, + }); + + expect(result.supported).toBe(supported); + if (!supported) { + expect(!result.supported ? result.reason : undefined).toContain("prepared"); + } + }); + + it.each([ + { + name: "custom endpoint", + modelProvider: { + api: "openai-responses", + baseUrl: "https://relay.example.test/v1", + requestTransportOverrides: "none" as const, + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + }, + { + name: "Completions adapter", + modelProvider: { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "none" as const, + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + }, + { + name: "HTTP endpoint", + modelProvider: { + api: "openai-responses", + baseUrl: "http://api.openai.com/v1", + requestTransportOverrides: "none" as const, + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + }, + ])("rejects a $name that Codex cannot reproduce", ({ modelProvider }) => { + const result = harness.supports({ + provider: "openai", + requestedRuntime: "codex", + modelProvider, + }); + expect(result.supported).toBe(false); + expect(!result.supported ? result.reason : undefined).toContain("prepared provider route"); + }); + + it("rejects authored request overrides defensively", () => { + const result = harness.supports({ + provider: "openai", + requestedRuntime: "codex", + modelProvider: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "present", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + preparedAuth: { source: "harness" }, + }, + }); + expect(result.supported).toBe(false); + expect(!result.supported ? result.reason : undefined).toContain("request transport overrides"); + }); + + it("rejects an OpenAI route without a provider compatibility declaration", () => { + const result = harness.supports({ + provider: "openai", + requestedRuntime: "codex", + modelProvider: { + api: "openai-responses", + baseUrl: "https://relay.example.test/v1", + requestTransportOverrides: "none", + }, + }); + expect(result.supported).toBe(false); + expect(!result.supported ? result.reason : undefined).toContain("not declared"); + }); + it("rejects providers Codex app-server cannot resolve from its own config", () => { const result = harness.supports({ provider: "9router", requestedRuntime: "codex" }); expect(result.supported).toBe(false); diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 4b4511608bf2..7d1017ec2a91 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -48,13 +48,15 @@ export function createCodexAppServerAgentHarness(options: { resolveConfig?: () => OpenClawConfig | undefined; bindingStore: CodexAppServerBindingStore; }): AgentHarness { + const harnessRuntimeId = options?.id ?? "codex"; + const normalizedHarnessRuntimeId = harnessRuntimeId.trim().toLowerCase(); const providerIds = new Set( [...(options?.providerIds ?? DEFAULT_CODEX_HARNESS_PROVIDER_IDS)].map((id) => id.trim().toLowerCase(), ), ); const harness: CodexAppServerAgentHarness = { - id: options?.id ?? "codex", + id: harnessRuntimeId, label: options?.label ?? "Codex agent harness", delegatedExecutionPluginIds: ["voice-call"], contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES, @@ -78,13 +80,59 @@ export function createCodexAppServerAgentHarness(options: { }, supports: (ctx) => { const provider = ctx.provider.trim().toLowerCase(); - if (providerIds.has(provider)) { - return { supported: true, priority: 100 }; + if (!providerIds.has(provider)) { + return { + supported: false, + reason: `provider is not one of: ${[...providerIds].toSorted().join(", ")}`, + }; } - return { - supported: false, - reason: `provider is not one of: ${[...providerIds].toSorted().join(", ")}`, - }; + if (ctx.modelProvider?.requestTransportOverrides === "present") { + return { + supported: false, + reason: "Codex cannot reproduce authored request transport overrides", + }; + } + const preparedAuth = ctx.modelProvider?.preparedAuth; + const runtimePolicy = ctx.modelProvider?.runtimePolicy; + if (runtimePolicy) { + const compatible = runtimePolicy.compatibleIds.some( + (id) => id.trim().toLowerCase() === normalizedHarnessRuntimeId, + ); + if (!compatible) { + return { + supported: false, + reason: "Codex cannot reproduce the prepared provider route", + }; + } + } else if (ctx.modelProvider && provider !== "codex") { + return { + supported: false, + reason: "provider route compatibility with Codex is not declared", + }; + } + if (preparedAuth?.requirement === "subscription") { + const reproducibleSubscription = + preparedAuth.source === "profile" && + (preparedAuth.mode === "oauth" || preparedAuth.mode === "token"); + if (!reproducibleSubscription) { + return { + supported: false, + reason: "Codex subscription auth requires a prepared OAuth or token profile", + }; + } + } else if (preparedAuth?.requirement === "api-key") { + const reproducibleApiKey = + preparedAuth.source !== "none" && + preparedAuth.source !== "harness" && + (preparedAuth.mode === "api-key" || preparedAuth.mode === "api_key"); + if (!reproducibleApiKey) { + return { + supported: false, + reason: "Codex Platform auth requires a prepared API key", + }; + } + } + return { supported: true, priority: 100 }; }, runAttempt: async (params) => { // Keep app-server runtime code behind lazy imports so plugin discovery and diff --git a/extensions/codex/src/app-server/attempt-startup.test.ts b/extensions/codex/src/app-server/attempt-startup.test.ts index 69869822c5d3..f96f6aea9aba 100644 --- a/extensions/codex/src/app-server/attempt-startup.test.ts +++ b/extensions/codex/src/app-server/attempt-startup.test.ts @@ -25,6 +25,7 @@ import { getLeasedSharedCodexAppServerClient, releaseLeasedSharedCodexAppServerClient, resolveCodexAppServerSpawnIdentity, + type CodexAppServerPreparedAuth, type CodexAppServerClientFactory, } from "./shared-client.js"; import { createClientHarness, createCodexTestModel } from "./test-support.js"; @@ -95,6 +96,7 @@ function startThreadWithHarness( signal = new AbortController().signal, overrides?: { pluginConfig?: CodexPluginConfig; + startupPreparedAuth?: CodexAppServerPreparedAuth; attemptClientFactory?: (harness: ClientHarness) => CodexAppServerClientFactory; buildAttemptParams?: () => EmbeddedRunAttemptParams; harness?: ClientHarness; @@ -124,6 +126,7 @@ function startThreadWithHarness( ...(overrides?.runtimeArtifactRequest ? { runtimeArtifactRequest: overrides.runtimeArtifactRequest } : {}), + startupPreparedAuth: overrides?.startupPreparedAuth, startupAuthAccountCacheKey: undefined, startupEnvApiKeyCacheKey: undefined, agentDir: paths.agentDir, @@ -642,6 +645,27 @@ describe("startCodexAttemptThread", () => { expect(harness.stdinDestroyed).toBe(true); }); + it("forwards prepared auth without a legacy profile selector", async () => { + const preparedAuth = { + kind: "api-key" as const, + apiKey: "prepared-platform-key", + }; + const clientFactory = vi.fn(async () => { + throw new Error("stop after option capture"); + }); + const { run } = startThreadWithHarness(5_000, new AbortController().signal, { + startupPreparedAuth: preparedAuth, + attemptClientFactory: () => clientFactory, + }); + + await expect(run).rejects.toThrow("stop after option capture"); + expect(clientFactory).toHaveBeenCalledWith(expect.objectContaining({ preparedAuth })); + expect(clientFactory.mock.calls[0]?.[0]?.preparedAuth).toBe(preparedAuth); + expect(clientFactory).not.toHaveBeenCalledWith( + expect.objectContaining({ authProfileId: expect.anything() }), + ); + }); + it("closes a startup client that arrives after startup timeout", async () => { let observedFactoryOptions: | { diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index 737dc58d6871..a54857270732 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -66,6 +66,7 @@ import { isCodexAppServerStartSelectionChangedError, releaseLeasedSharedCodexAppServerClient, retireSharedCodexAppServerClientIfCurrent, + type CodexAppServerClientOptions, type CodexAppServerClientFactory, } from "./shared-client.js"; import { @@ -128,6 +129,7 @@ export async function startCodexAttemptThread(params: { runtimeArtifactRequest?: Readonly<{ expected?: AgentHarnessRuntimeArtifactBinding; }>; + startupPreparedAuth?: CodexAppServerClientOptions["preparedAuth"]; startupAuthAccountCacheKey: string | undefined; startupEnvApiKeyCacheKey: string | undefined; agentDir: string; @@ -155,7 +157,12 @@ export async function startCodexAttemptThread(params: { spawnedBy: EmbeddedRunAttemptParams["spawnedBy"]; }): Promise { let pluginAppServer = params.appServer; - const startupRuntimeAuthProfileId = params.startupAuthProfileId ?? undefined; + const startupRuntimeAuthProfileId = + params.startupPreparedAuth?.kind === "profile" + ? params.startupPreparedAuth.profileId + : (params.startupAuthProfileId ?? undefined); + const startupRuntimeAuthProfileStore = + params.startupPreparedAuth?.kind === "profile" ? params.startupPreparedAuth.store : undefined; let releaseSharedClientLease: (() => void) | undefined; let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined; let releaseStartupResourcesOnTimeout: (() => Promise) | undefined; @@ -218,7 +225,9 @@ export async function startCodexAttemptThread(params: { const attemptParams = params.buildAttemptParams(); startupClient = await params.attemptClientFactory({ startOptions: params.appServer.start, - authProfileId: params.startupAuthProfileId, + ...(params.startupPreparedAuth + ? { preparedAuth: params.startupPreparedAuth } + : { authProfileId: params.startupAuthProfileId }), authProfileStore: attemptParams.authProfileStore, authBindingFingerprint: params.startupAuthBindingFingerprint, ...(params.runtimeArtifactRequest @@ -296,7 +305,9 @@ export async function startCodexAttemptThread(params: { ensureCodexAppServerClientRuntime(activeStartupClient, { agentDir: params.agentDir, authProfileId: startupRuntimeAuthProfileId, - authProfileStore: attemptParams.authProfileStore, + authMode: + params.startupPreparedAuth?.kind === "api-key" ? "prepared-api-key" : "profile", + authProfileStore: startupRuntimeAuthProfileStore ?? attemptParams.authProfileStore, config: params.config, }); const turnRouter = getCodexAppServerTurnRouter(activeStartupClient); diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index 8fe20dabbef7..82968f689c82 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -20,8 +20,12 @@ import { resolveCodexAppServerFallbackApiKeyCacheKey, resolveCodexAppServerHomeDir, resolveCodexAppServerNativeHomeDir, + resolveCodexAppServerPreparedAuthHandoff, + resolveCodexAppServerPreparedAuthProfileSnapshot, + resolveCodexAppServerPreparedApiKeyCacheKey, } from "./auth-bridge.js"; import type { CodexAppServerStartOptions } from "./config.js"; +import { resolveCodexAppServerSpawnEnv } from "./transport-stdio.js"; const oauthMocks = vi.hoisted(() => ({ refreshOpenAICodexToken: vi.fn(), @@ -182,7 +186,7 @@ async function writeCodexCliApiKeyAuthFile(codexHome: string): Promise { } describe("bridgeCodexAppServerStartOptions", () => { - it("preserves persisted provenance when preparing a supplied base store", async () => { + it("never overlays persisted profiles onto a supplied runtime store", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const authProfileStore = { version: 1, profiles: {} }; try { @@ -198,18 +202,13 @@ describe("bridgeCodexAppServerStartOptions", () => { }, }); - const prepared = resolveCodexAppServerAuthProfileStore({ - agentDir, - authProfileId: "openai:work", - authProfileStore, - }); - - expect(prepared).not.toBe(authProfileStore); - expect(prepared.runtimePersistedProfileIds).toContain("openai:work"); - expect(prepared.profiles["openai:work"]).toMatchObject({ - access: "persisted-access", - refresh: "persisted-refresh", - }); + expect( + resolveCodexAppServerAuthProfileStore({ + agentDir, + authProfileId: "openai:work", + authProfileStore, + }), + ).toBe(authProfileStore); } finally { await fs.rm(agentDir, { recursive: true, force: true }); } @@ -420,6 +419,176 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it.each(["api-key", "profile"] as const)( + "clears all ambient auth env vars for prepared %s startup", + async (preparedAuth) => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const startOptions = createStartOptions({ clearEnv: ["FOO", "OPENAI_API_KEY"] }); + const preparedAuthHandoff = + preparedAuth === "api-key" + ? ({ kind: "api-key", apiKey: "prepared-platform-key" } as const) + : ({ + kind: "profile", + profileId: "openai:prepared", + store: { version: 1, profiles: {} }, + } as const); + try { + const bridged = await bridgeCodexAppServerStartOptions({ + startOptions, + agentDir, + authProfileId: preparedAuth === "api-key" ? null : "openai:prepared", + preparedAuth: preparedAuthHandoff, + }); + expect(bridged).toEqual({ + ...startOptions, + env: { CODEX_HOME: resolveCodexAppServerHomeDir(agentDir) }, + clearEnv: ["FOO", "OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN"], + }); + expect( + resolveCodexAppServerSpawnEnv(bridged, { + FOO: "ambient", + CODEX_API_KEY: "ambient-codex-key", + OPENAI_API_KEY: "ambient-openai-key", + CODEX_ACCESS_TOKEN: "ambient-access-token", + }), + ).toMatchObject({ CODEX_HOME: resolveCodexAppServerHomeDir(agentDir) }); + const spawnEnv = resolveCodexAppServerSpawnEnv(bridged, { + CODEX_API_KEY: "ambient-codex-key", + OPENAI_API_KEY: "ambient-openai-key", + CODEX_ACCESS_TOKEN: "ambient-access-token", + }); + expect(spawnEnv).not.toHaveProperty("CODEX_API_KEY"); + expect(spawnEnv).not.toHaveProperty("OPENAI_API_KEY"); + expect(spawnEnv).not.toHaveProperty("CODEX_ACCESS_TOKEN"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }, + ); + + it("maps a prepared API-key route to one closed auth handoff", async () => { + await expect( + resolveCodexAppServerPreparedAuthHandoff({ + authRequirement: "api-key", + resolvedApiKey: " prepared-platform-key ", + authProfileId: "openai:decoy", + authProfileStore: { + version: 1, + profiles: { + "openai:decoy": { + type: "token", + provider: "openai", + token: "decoy-subscription-token", + }, + }, + }, + subscriptionProfileRequiredError: "unused", + subscriptionProfileUnusableError: "unused", + }), + ).resolves.toEqual({ + nativeAuthProfile: false, + preparedAuth: { kind: "api-key", apiKey: "prepared-platform-key" }, + }); + }); + + it("materializes one prepared subscription profile snapshot", async () => { + const authProfileStore: AuthProfileStore = { + version: 1, + profiles: { + "openai:work": { + type: "token", + provider: "openai", + token: "prepared-subscription-token", + email: "prepared@example.test", + }, + }, + }; + + const handoff = await resolveCodexAppServerPreparedAuthHandoff({ + authRequirement: "subscription", + authProfileId: "openai:work", + authProfileStore, + agentDir: "/tmp/openclaw-agent", + subscriptionProfileRequiredError: "profile required", + subscriptionProfileUnusableError: "profile unusable", + }); + + expect(handoff).toMatchObject({ + authProfileId: "openai:work", + nativeAuthProfile: true, + preparedAuth: { + kind: "profile", + profileId: "openai:work", + store: authProfileStore, + snapshot: { + loginParams: { + type: "chatgptAuthTokens", + accessToken: "prepared-subscription-token", + chatgptAccountId: "prepared@example.test", + }, + }, + }, + }); + expect( + handoff.preparedAuth?.kind === "profile" + ? handoff.preparedAuth.snapshot?.secretFreeCacheKey + : undefined, + ).toMatch(/^prepared@example\.test:token:sha256:[a-f0-9]{64}$/u); + }); + + it("isolates prepared OAuth snapshots without a stable account identity", async () => { + const snapshotFor = (access: string) => + resolveCodexAppServerPreparedAuthProfileSnapshot({ + authProfileId: "openai:shared", + authProfileStore: { + version: 1, + profiles: { + "openai:shared": { + type: "oauth", + provider: "openai", + access, + refresh: `${access}-refresh`, + expires: Date.now() + 60 * 60_000, + }, + }, + }, + }); + + const first = await snapshotFor("first-access-token"); + const second = await snapshotFor("second-access-token"); + + expect(first?.secretFreeCacheKey).toMatch(/^openai:shared:token:sha256:[a-f0-9]{64}$/u); + expect(second?.secretFreeCacheKey).toMatch(/^openai:shared:token:sha256:[a-f0-9]{64}$/u); + expect(first?.secretFreeCacheKey).not.toBe(second?.secretFreeCacheKey); + expect(first?.secretFreeCacheKey).not.toContain("first-access-token"); + expect(second?.secretFreeCacheKey).not.toContain("second-access-token"); + }); + + it("keeps legacy profile classification outside the prepared union", async () => { + const authProfileStore: AuthProfileStore = { + version: 1, + profiles: { + "openai:legacy": { + type: "token", + provider: "openai", + token: "legacy-subscription-token", + }, + }, + }; + + await expect( + resolveCodexAppServerPreparedAuthHandoff({ + authProfileId: "openai:legacy", + authProfileStore, + subscriptionProfileRequiredError: "unused", + subscriptionProfileUnusableError: "unused", + }), + ).resolves.toEqual({ + authProfileId: "openai:legacy", + nativeAuthProfile: true, + }); + }); + it("keeps an inherited OpenAI API key for an explicit Codex api-key profile", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const startOptions = createStartOptions({ clearEnv: ["FOO"] }); @@ -594,6 +763,34 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it("fingerprints supplied token stores with the same profile id independently", async () => { + const resolveKey = async (token: string) => + await resolveCodexAppServerAuthAccountCacheKey({ + agentDir: "/tmp/openclaw-codex-prepared-auth", + authProfileId: "openai:work", + authProfileStore: { + version: 1, + profiles: { + "openai:work": { + type: "token", + provider: "openai", + token, + email: "codex@example.test", + }, + }, + }, + }); + + const first = await resolveKey("first-prepared-token"); + const second = await resolveKey("second-prepared-token"); + + expect(first).toMatch(/^codex@example\.test:token:sha256:[a-f0-9]{64}$/); + expect(second).toMatch(/^codex@example\.test:token:sha256:[a-f0-9]{64}$/); + expect(second).not.toBe(first); + expect(first).not.toContain("first-prepared-token"); + expect(second).not.toContain("second-prepared-token"); + }); + it("applies an OpenAI Codex OAuth profile through app-server login", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); @@ -825,11 +1022,7 @@ describe("bridgeCodexAppServerStartOptions", () => { expires: Date.now() + 60_000, }, }); - const authProfileStore = resolveCodexAppServerAuthProfileStore({ - agentDir, - authProfileId: "openai:work", - authProfileStore: { version: 1, profiles: {} }, - }); + const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir); await refreshCodexAppServerAuthTokens({ agentDir, @@ -878,11 +1071,7 @@ describe("bridgeCodexAppServerStartOptions", () => { expires: Date.now() + 60_000, }, }); - const authProfileStore = resolveCodexAppServerAuthProfileStore({ - agentDir, - authProfileId: "openai:work", - authProfileStore: { version: 1, profiles: {} }, - }); + const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir); const refresh = refreshCodexAppServerAuthTokens({ agentDir, @@ -1245,6 +1434,86 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it("applies a prepared API key without resolving an available OAuth profile", async () => { + const request = vi.fn(async () => ({ type: "apiKey" })); + const authProfileStore: AuthProfileStore = { + version: 1, + profiles: { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + order: { openai: ["openai:chatgpt"] }, + }; + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir: "/tmp/openclaw-agent", + authProfileId: null, + authProfileStore, + preparedAuth: { kind: "api-key", apiKey: "prepared-platform-key" }, + }); + + expect(request).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "apiKey", + apiKey: "prepared-platform-key", + }); + const cacheKey = resolveCodexAppServerPreparedApiKeyCacheKey("prepared-platform-key"); + expect(cacheKey).toMatch(/^api_key:sha256:[a-f0-9]{64}$/u); + expect(cacheKey).not.toContain("prepared-platform-key"); + }); + + it("uses one SecretRef snapshot for prepared profile cache identity and login", async () => { + const authProfileStore: AuthProfileStore = { + version: 1, + profiles: { + "openai:work": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_ROTATING_PREPARED_KEY" }, + }, + }, + }; + vi.stubEnv("OPENAI_ROTATING_PREPARED_KEY", "first-prepared-key"); + const snapshot = await resolveCodexAppServerPreparedAuthProfileSnapshot({ + agentDir: "/tmp/openclaw-agent", + authProfileId: "openai:work", + authProfileStore, + }); + vi.stubEnv("OPENAI_ROTATING_PREPARED_KEY", "second-prepared-key"); + const request = vi.fn(async () => ({ type: "apiKey" })); + + try { + expect(snapshot).toEqual({ + loginParams: { type: "apiKey", apiKey: "first-prepared-key" }, + secretFreeCacheKey: `openai:work:${resolveCodexAppServerPreparedApiKeyCacheKey("first-prepared-key")}`, + }); + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir: "/tmp/openclaw-agent", + authProfileId: "openai:work", + authProfileStore, + preparedAuth: { + kind: "profile", + profileId: "openai:work", + store: authProfileStore, + snapshot: snapshot as NonNullable, + }, + }); + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "apiKey", + apiKey: "first-prepared-key", + }); + } finally { + vi.unstubAllEnvs(); + } + }); + it("applies a normal OpenAI API-key profile as a Codex app-server backup", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const request = vi.fn(async () => ({ type: "apiKey" })); @@ -1447,7 +1716,25 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); - it("uses native Codex CLI OAuth when deriving cache keys from a supplied base store", async () => { + it("uses native Codex CLI OAuth when deriving cache keys without a supplied store", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const agentDir = path.join(root, "agent"); + const codexHome = path.join(root, "codex-cli"); + vi.stubEnv("CODEX_HOME", codexHome); + try { + await writeCodexCliAuthFile(codexHome); + + await expect( + resolveCodexAppServerAuthAccountCacheKey({ + agentDir, + }), + ).resolves.toBe("account-cli"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("keeps a supplied empty store authoritative over native Codex CLI OAuth", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const agentDir = path.join(root, "agent"); const codexHome = path.join(root, "codex-cli"); @@ -1460,7 +1747,7 @@ describe("bridgeCodexAppServerStartOptions", () => { agentDir, authProfileStore: { version: 1, profiles: {} }, }), - ).resolves.toBe("account-cli"); + ).resolves.toBeUndefined(); } finally { await fs.rm(root, { recursive: true, force: true }); } diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index 2cc60395657f..4eb3930c9e3b 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -32,6 +32,7 @@ import type { CodexGetAccountResponse, CodexLoginAccountParams, } from "./protocol.js"; +import { isCodexAppServerNativeAuthProfile } from "./session-binding.js"; import { resolveCodexAppServerSpawnEnv } from "./transport-stdio.js"; const CODEX_APP_SERVER_AUTH_PROVIDER = "openai"; @@ -49,7 +50,13 @@ const CODEX_APP_SERVER_HOME_DIRNAME = "codex-home"; const CODEX_APP_SERVER_NATIVE_HOME_DIRNAME = "home"; const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY"; const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY"; +const CODEX_ACCESS_TOKEN_ENV_VAR = "CODEX_ACCESS_TOKEN"; const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR]; +const CODEX_APP_SERVER_PREPARED_AUTH_ENV_VARS = [ + CODEX_API_KEY_ENV_VAR, + OPENAI_API_KEY_ENV_VAR, + CODEX_ACCESS_TOKEN_ENV_VAR, +]; const CODEX_APP_SERVER_HOME_ENV_VARS = [CODEX_HOME_ENV_VAR, HOME_ENV_VAR]; const CODEX_AUTH_JSON_FILENAME = "auth.json"; const CODEX_HOME_DIRNAME = ".codex"; @@ -65,6 +72,7 @@ export async function bridgeCodexAppServerStartOptions(params: { agentDir: string; authProfileId?: string | null; authProfileStore?: AuthProfileStore; + preparedAuth?: CodexAppServerPreparedAuth; config?: AuthProfileOrderConfig; pluginConfig?: unknown; }): Promise { @@ -76,6 +84,12 @@ export async function bridgeCodexAppServerStartOptions(params: { params.agentDir, params.pluginConfig, ); + if (params.preparedAuth) { + return withClearedEnvironmentVariables( + scopedStartOptions, + CODEX_APP_SERVER_PREPARED_AUTH_ENV_VARS, + ); + } if (params.authProfileId === null) { return scopedStartOptions; } @@ -156,65 +170,139 @@ export function resolveCodexAppServerAuthProfileStore(params: { config?: AuthProfileOrderConfig; }): AuthProfileStore { if (params.authProfileStore) { - const providedProfileId = resolveCodexAppServerAuthProfileId({ - authProfileId: params.authProfileId, - store: params.authProfileStore, - config: params.config, - }); - if (providedProfileId && params.authProfileStore.profiles[providedProfileId]) { - return params.authProfileStore; - } + return params.authProfileStore; } - const overlaidStore = ensureCodexAppServerAuthProfileStore({ + return ensureCodexAppServerAuthProfileStore({ agentDir: params.agentDir, authProfileId: params.authProfileId, config: params.config, }); - if (!params.authProfileStore) { - return overlaidStore; +} + +export type CodexAppServerPreparedAuthProfileSnapshot = { + loginParams: CodexLoginAccountParams; + secretFreeCacheKey: string; +}; + +export type CodexAppServerPreparedAuth = + | { kind: "api-key"; apiKey: string } + | { + kind: "profile"; + profileId: string; + store: AuthProfileStore; + snapshot?: CodexAppServerPreparedAuthProfileSnapshot; + }; + +export type CodexAppServerResolvedPreparedAuth = + | Extract + | (Extract & { + snapshot: CodexAppServerPreparedAuthProfileSnapshot; + }); + +/** Resolves prepared profile login material once so cache identity and RPC login cannot drift. */ +export async function resolveCodexAppServerPreparedAuthProfileSnapshot(params: { + authProfileId?: string; + authProfileStore?: AuthProfileStore; + agentDir?: string; + config?: AuthProfileOrderConfig; +}): Promise { + const agentDir = params.agentDir?.trim() || resolveDefaultAgentDir(params.config ?? {}); + const store = resolveCodexAppServerAuthProfileStore({ + agentDir, + authProfileId: params.authProfileId, + authProfileStore: params.authProfileStore, + config: params.config, + }); + const profileId = resolveCodexAppServerAuthProfileId({ + authProfileId: params.authProfileId, + store, + config: params.config, + }); + if (!profileId) { + return undefined; + } + const credential = store.profiles[profileId]; + if (!credential || !isCodexAppServerAuthProfileCredential(credential, params.config)) { + return undefined; + } + const loginParams = await resolveCodexAppServerAuthProfileLoginParamsInternal({ + agentDir, + authProfileId: profileId, + authProfileStore: store, + config: params.config, + }); + if (!loginParams) { + return undefined; + } + const accountId = + loginParams.type === "chatgptAuthTokens" + ? loginParams.chatgptAccountId + : resolveChatgptAccountId(profileId, credential); + const stableChatgptAccountId = resolveStableChatgptAccountId(credential); + const secretFreeCacheKey = + credential.type === "api_key" && loginParams.type === "apiKey" + ? `${accountId}:${fingerprintApiKeyAuthProfileCacheKey(loginParams.apiKey)}` + : loginParams.type === "chatgptAuthTokens" && + (credential.type === "token" || !stableChatgptAccountId) + ? `${accountId}:${fingerprintTokenAuthProfileCacheKey(loginParams.accessToken)}` + : accountId; + return { loginParams, secretFreeCacheKey }; +} + +/** Maps one prepared route to one mutually exclusive app-server auth handoff. */ +export async function resolveCodexAppServerPreparedAuthHandoff(params: { + authRequirement?: "api-key" | "subscription"; + resolvedApiKey?: string; + authProfileId?: string; + authProfileStore: AuthProfileStore; + agentDir?: string; + config?: AuthProfileOrderConfig; + subscriptionProfileRequiredError: string; + subscriptionProfileUnusableError: string; +}) { + if (params.authRequirement === "api-key") { + const apiKey = params.resolvedApiKey?.trim(); + if (!apiKey) { + throw new Error("Prepared Codex API-key route is missing its resolved API key."); + } + return { + nativeAuthProfile: false, + preparedAuth: { kind: "api-key" as const, apiKey }, + }; + } + + const authProfileId = params.authProfileId?.trim() || undefined; + const nativeAuthProfile = isCodexAppServerNativeAuthProfile({ + authProfileId, + authProfileStore: params.authProfileStore, + agentDir: params.agentDir, + config: params.config, + }); + if (params.authRequirement !== "subscription") { + return { authProfileId, nativeAuthProfile }; + } + if (!authProfileId || !nativeAuthProfile) { + throw new Error(params.subscriptionProfileRequiredError); + } + + const snapshot = await resolveCodexAppServerPreparedAuthProfileSnapshot({ + authProfileId, + authProfileStore: params.authProfileStore, + agentDir: params.agentDir, + config: params.config, + }); + if (!snapshot) { + throw new Error(params.subscriptionProfileUnusableError); } - const order = - params.authProfileStore.order || overlaidStore.order - ? { - ...overlaidStore.order, - ...params.authProfileStore.order, - } - : undefined; - const profiles = { - ...overlaidStore.profiles, - ...params.authProfileStore.profiles, - }; - const suppliedProfileIds = new Set(Object.keys(params.authProfileStore.profiles)); - const mergeRuntimeProfileIds = (overlaidIds?: string[], suppliedIds?: string[]) => [ - ...(overlaidIds ?? []).filter((profileId) => !suppliedProfileIds.has(profileId)), - ...(suppliedIds ?? []), - ]; - const runtimePersistedProfileIds = mergeRuntimeProfileIds( - overlaidStore.runtimePersistedProfileIds, - params.authProfileStore.runtimePersistedProfileIds, - ).filter((profileId) => profiles[profileId]); - const runtimeExternalProfileIds = mergeRuntimeProfileIds( - overlaidStore.runtimeExternalProfileIds, - params.authProfileStore.runtimeExternalProfileIds, - ).filter((profileId) => profiles[profileId]); - const runtimeExternalProfileIdsAuthoritative = - overlaidStore.runtimeExternalProfileIdsAuthoritative === true || - params.authProfileStore.runtimeExternalProfileIdsAuthoritative === true; return { - ...params.authProfileStore, - ...(order ? { order } : {}), - profiles, - ...(runtimePersistedProfileIds.length > 0 - ? { runtimePersistedProfileIds: [...new Set(runtimePersistedProfileIds)] } - : {}), - ...(runtimeExternalProfileIds.length > 0 || runtimeExternalProfileIdsAuthoritative - ? { - runtimeExternalProfileIds: [...new Set(runtimeExternalProfileIds)], - ...(runtimeExternalProfileIdsAuthoritative - ? { runtimeExternalProfileIdsAuthoritative: true } - : {}), - } - : {}), + authProfileId, + nativeAuthProfile, + preparedAuth: { + kind: "profile" as const, + profileId: authProfileId, + store: params.authProfileStore, + snapshot, + }, }; } @@ -244,22 +332,14 @@ export async function resolveCodexAppServerAuthAccountCacheKey(params: { return undefined; } if (credential.type === "api_key") { - const resolved = await resolveApiKeyForProfile({ - store, - profileId, - agentDir, - }); + const resolved = await resolveApiKeyForProfile({ store, profileId, agentDir }); const apiKey = resolved?.apiKey?.trim(); return apiKey ? `${resolveChatgptAccountId(profileId, credential)}:${fingerprintApiKeyAuthProfileCacheKey(apiKey)}` : resolveChatgptAccountId(profileId, credential); } if (credential.type === "token") { - const resolved = await resolveApiKeyForProfile({ - store, - profileId, - agentDir, - }); + const resolved = await resolveApiKeyForProfile({ store, profileId, agentDir }); const accessToken = resolved?.apiKey?.trim(); return accessToken ? `${resolveChatgptAccountId(profileId, credential)}:${fingerprintTokenAuthProfileCacheKey(accessToken)}` @@ -308,6 +388,14 @@ export function resolveCodexAppServerFallbackApiKeyCacheKey(params: { ); } +/** Secret-free cache identity for an API key already resolved by the runtime plan. */ +export function resolveCodexAppServerPreparedApiKeyCacheKey( + apiKey: string | undefined, +): string | undefined { + const resolved = apiKey?.trim(); + return resolved ? fingerprintApiKeyAuthProfileCacheKey(resolved) : undefined; +} + function fingerprintApiKeyAuthProfileCacheKey(apiKey: string): string { const hash = createHash("sha256"); hash.update("openclaw:codex:app-server-auth-profile-api-key:v1"); @@ -392,9 +480,21 @@ export async function applyCodexAppServerAuthProfile(params: { agentDir: string; authProfileId?: string | null; authProfileStore?: AuthProfileStore; + preparedAuth?: CodexAppServerResolvedPreparedAuth; startOptions?: CodexAppServerStartOptions; config?: AuthProfileOrderConfig; }): Promise { + if (params.preparedAuth?.kind === "profile") { + await params.client.request("account/login/start", params.preparedAuth.snapshot.loginParams); + return; + } + if (params.preparedAuth?.kind === "api-key") { + await params.client.request("account/login/start", { + type: "apiKey", + apiKey: params.preparedAuth.apiKey, + }); + return; + } if (params.authProfileId === null) { return; } @@ -901,6 +1001,10 @@ function resolveChatgptPlanType(credential: AuthProfileCredential): string | nul } function resolveChatgptAccountId(profileId: string, credential: AuthProfileCredential): string { + return resolveStableChatgptAccountId(credential) ?? profileId; +} + +function resolveStableChatgptAccountId(credential: AuthProfileCredential): string | undefined { if ("accountId" in credential && typeof credential.accountId === "string") { const accountId = credential.accountId.trim(); if (accountId) { @@ -908,5 +1012,5 @@ function resolveChatgptAccountId(profileId: string, credential: AuthProfileCrede } } const email = credential.email?.trim(); - return email || profileId; + return email || undefined; } diff --git a/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts b/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts index 1c6ee5b26bcf..ad243ab4bb31 100644 --- a/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/auth-profile-runtime-contract.test.ts @@ -8,6 +8,7 @@ import { } from "openclaw/plugin-sdk/agent-harness"; import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createCodexRuntimePlanFixture } from "./run-attempt-test-harness.js"; import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js"; import { readCodexAppServerBinding, @@ -78,6 +79,35 @@ function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAtt } as EmbeddedRunAttemptParams; } +function setPreparedOpenAIRoute( + params: EmbeddedRunAttemptParams, + authRequirement: "api-key" | "subscription", + forwardedAuthProfileId?: string, +): void { + const runtimePlan = createCodexRuntimePlanFixture(); + params.runtimePlan = { + ...runtimePlan, + auth: { + ...runtimePlan.auth, + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: authRequirement, + ...(forwardedAuthProfileId ? { forwardedAuthProfileId } : {}), + modelRoute: { + provider: "openai", + modelId: "gpt-5.4-codex", + api: authRequirement === "api-key" ? "openai-responses" : "openai-chatgpt-responses", + baseUrl: + authRequirement === "api-key" + ? "https://api.openai.com/v1" + : "https://chatgpt.com/backend-api/codex", + authRequirement, + requestTransportOverrides: "none", + }, + }, + }; +} + const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({ "features.standalone_web_search": false, web_search: "disabled", @@ -163,6 +193,7 @@ function mockClientRuntimeMethods() { function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "thread/resume" }) { const seenAuthProfileIds: Array = []; const seenAgentDirs: Array = []; + const seenClientOptions: Array[4]>> = []; const requests: Array<{ method: string; params: unknown }> = []; const notificationHandlers = new Set<(notification: unknown) => Promise | void>(); const notify = async (notification: unknown) => { @@ -170,32 +201,38 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | " [...notificationHandlers].map((handler) => Promise.resolve(handler(notification))), ); }; - setCodexAppServerClientFactoryForTest(async (_startOptions, authProfileId, agentDir) => { - seenAuthProfileIds.push(authProfileId); - seenAgentDirs.push(agentDir); - return { - ...mockClientRuntimeMethods(), - request: vi.fn(async (method: string, requestParams?: unknown) => { - requests.push({ method, params: requestParams }); - if (method === params.startMethod) { - return threadStartResult(); - } - if (method === "turn/start") { - return turnStartResult(); - } - throw new Error(`unexpected method: ${method}`); - }), - addNotificationHandler: (handler: (notification: unknown) => Promise | void) => { - notificationHandlers.add(handler); - return () => notificationHandlers.delete(handler); - }, - addRequestHandler: () => () => undefined, - addCloseHandler: () => () => undefined, - } as never; - }); + setCodexAppServerClientFactoryForTest( + async (_startOptions, authProfileId, agentDir, _config, options) => { + seenAuthProfileIds.push(authProfileId); + seenAgentDirs.push(agentDir); + if (options) { + seenClientOptions.push(options); + } + return { + ...mockClientRuntimeMethods(), + request: vi.fn(async (method: string, requestParams?: unknown) => { + requests.push({ method, params: requestParams }); + if (method === params.startMethod) { + return threadStartResult(); + } + if (method === "turn/start") { + return turnStartResult(); + } + throw new Error(`unexpected method: ${method}`); + }), + addNotificationHandler: (handler: (notification: unknown) => Promise | void) => { + notificationHandlers.add(handler); + return () => notificationHandlers.delete(handler); + }, + addRequestHandler: () => () => undefined, + addCloseHandler: () => () => undefined, + } as never; + }, + ); return { seenAuthProfileIds, seenAgentDirs, + seenClientOptions, async waitForMethod(method: string) { await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain(method), { ...APP_SERVER_START_WAIT, @@ -309,4 +346,194 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => { const binding = await readCodexAppServerBinding(sessionFile); expect(binding?.authProfileId).toBe(AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId); }); + + it("locks a prepared Platform route to its resolved API key", async () => { + const harness = createCodexAuthProfileHarness({ startMethod: "thread/start" }); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const params = createParams(sessionFile, tmpDir); + params.agentDir = tmpDir; + params.resolvedApiKey = "prepared-platform-key"; + params.authProfileStore = { + version: 1, + profiles: { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60 * 60_000, + }, + }, + order: { openai: ["openai:chatgpt"] }, + }; + setPreparedOpenAIRoute(params, "api-key"); + + const run = runCodexAppServerAttempt(params); + await vi.waitFor( + () => expect(harness.seenClientOptions).toHaveLength(1), + APP_SERVER_START_WAIT, + ); + expect(harness.seenClientOptions[0]).toMatchObject({ + preparedAuth: { + kind: "api-key", + apiKey: "prepared-platform-key", + }, + }); + expect(harness.seenClientOptions[0]).not.toHaveProperty("authProfileId"); + await harness.waitForMethod("turn/start"); + await harness.completeTurn(); + await run; + + const binding = await readCodexAppServerBinding(sessionFile); + expect(binding?.authProfileId).toBeUndefined(); + }); + + it("locks a prepared subscription route to its forwarded OAuth profile", async () => { + const harness = createCodexAuthProfileHarness({ startMethod: "thread/start" }); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const params = createParams(sessionFile, tmpDir); + const authProfileStore = { + version: 1 as const, + profiles: { + "openai:chatgpt": { + type: "oauth" as const, + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60 * 60_000, + }, + }, + }; + params.authProfileStore = authProfileStore; + setPreparedOpenAIRoute(params, "subscription", "openai:chatgpt"); + + const run = runCodexAppServerAttempt(params); + await vi.waitFor( + () => expect(harness.seenClientOptions).toHaveLength(1), + APP_SERVER_START_WAIT, + ); + expect(harness.seenClientOptions[0]).toMatchObject({ + preparedAuth: { + kind: "profile", + profileId: "openai:chatgpt", + store: authProfileStore, + }, + }); + expect(harness.seenClientOptions[0]).not.toHaveProperty("authProfileId"); + await harness.waitForMethod("turn/start"); + await harness.completeTurn(); + await run; + }); + + it("accepts a prepared subscription route with a real token profile", async () => { + const harness = createCodexAuthProfileHarness({ startMethod: "thread/start" }); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const params = createParams(sessionFile, tmpDir); + const authProfileStore = { + version: 1 as const, + profiles: { + "openai:token": { + type: "token" as const, + provider: "openai", + token: "prepared-subscription-token", + }, + }, + }; + params.authProfileStore = authProfileStore; + setPreparedOpenAIRoute(params, "subscription", "openai:token"); + + const run = runCodexAppServerAttempt(params); + await vi.waitFor( + () => expect(harness.seenClientOptions).toHaveLength(1), + APP_SERVER_START_WAIT, + ); + expect(harness.seenClientOptions[0]).toMatchObject({ + preparedAuth: { + kind: "profile", + profileId: "openai:token", + store: authProfileStore, + }, + }); + await harness.waitForMethod("turn/start"); + await harness.completeTurn(); + await run; + }); + + it("fails before profile selection when a prepared Platform route has no key", async () => { + const harness = createCodexAuthProfileHarness({ startMethod: "thread/start" }); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const params = createParams(sessionFile, tmpDir); + params.authProfileStore = { + version: 1, + profiles: { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + order: { openai: ["openai:chatgpt"] }, + }; + setPreparedOpenAIRoute(params, "api-key"); + + await expect(runCodexAppServerAttempt(params)).rejects.toThrow( + "Prepared Codex API-key route is missing its resolved API key.", + ); + expect(harness.seenClientOptions).toHaveLength(0); + }); + + it.each([ + { label: "no forwarded profile", forwardedProfileId: undefined, profileType: "oauth" as const }, + { + label: "an API-key profile", + forwardedProfileId: "openai:platform", + profileType: "api_key" as const, + }, + ])("rejects a subscription route with $label", async (testCase) => { + const harness = createCodexAuthProfileHarness({ startMethod: "thread/start" }); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const params = createParams(sessionFile, tmpDir); + vi.stubEnv("OPENAI_API_KEY", "ambient-platform-key"); + vi.stubEnv("CODEX_ACCESS_TOKEN", "ambient-subscription-token"); + params.authProfileStore = { + version: 1, + profiles: + testCase.profileType === "api_key" + ? { + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-profile-key", + }, + "openai:decoy": { + type: "oauth", + provider: "openai", + access: "decoy-subscription-token", + refresh: "decoy-refresh-token", + expires: Date.now() + 60_000, + }, + } + : { + "openai:decoy": { + type: "oauth", + provider: "openai", + access: "decoy-subscription-token", + refresh: "decoy-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; + setPreparedOpenAIRoute(params, "subscription", testCase.forwardedProfileId); + + try { + await expect(runCodexAppServerAttempt(params)).rejects.toThrow( + "Prepared Codex subscription route requires a forwarded OpenAI OAuth or token profile.", + ); + expect(harness.seenClientOptions).toHaveLength(0); + } finally { + vi.unstubAllEnvs(); + } + }); }); diff --git a/extensions/codex/src/app-server/client-runtime.test.ts b/extensions/codex/src/app-server/client-runtime.test.ts index 1b3acfb42ee3..3ec4fe89f77d 100644 --- a/extensions/codex/src/app-server/client-runtime.test.ts +++ b/extensions/codex/src/app-server/client-runtime.test.ts @@ -75,4 +75,28 @@ describe("Codex app-server client runtime", () => { }), ); }); + + it("rejects ChatGPT refresh on a prepared API-key client", async () => { + const harness = createClientHarness(); + clients.push(harness.client); + ensureCodexAppServerClientRuntime(harness.client, { + agentDir: "/tmp/agent", + authMode: "prepared-api-key", + }); + + harness.send({ + id: "refresh-api-key", + method: "account/chatgptAuthTokens/refresh", + params: { reason: "expired" }, + }); + + await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(0)); + expect(mocks.refreshAuth).not.toHaveBeenCalled(); + expect(JSON.parse(harness.writes.at(-1) ?? "{}")).toMatchObject({ + id: "refresh-api-key", + error: { + message: "ChatGPT token refresh is unavailable for prepared Codex API-key auth.", + }, + }); + }); }); diff --git a/extensions/codex/src/app-server/client-runtime.ts b/extensions/codex/src/app-server/client-runtime.ts index 5c3790b2c5be..174dda9c0a63 100644 --- a/extensions/codex/src/app-server/client-runtime.ts +++ b/extensions/codex/src/app-server/client-runtime.ts @@ -7,6 +7,7 @@ import type { CodexAppServerAuthProfileLookup } from "./session-binding.js"; type ClientRuntimeContext = Omit & { agentDir: string; + authMode?: "prepared-api-key" | "profile"; }; type ClientRuntime = { @@ -33,6 +34,9 @@ export function ensureCodexAppServerClientRuntime( if (request.method !== "account/chatgptAuthTokens/refresh") { return undefined; } + if (runtime.context.authMode === "prepared-api-key") { + throw new Error("ChatGPT token refresh is unavailable for prepared Codex API-key auth."); + } return (await refreshCodexAppServerAuthTokens({ agentDir: runtime.context.agentDir, authProfileId: runtime.context.authProfileId, diff --git a/extensions/codex/src/app-server/compact.test.ts b/extensions/codex/src/app-server/compact.test.ts index d420eaabf703..02586b4dc3e2 100644 --- a/extensions/codex/src/app-server/compact.test.ts +++ b/extensions/codex/src/app-server/compact.test.ts @@ -202,6 +202,92 @@ describe("maybeCompactCodexAppServerSession", () => { expect(details.completed).toBe(true); }); + it("uses the exact prepared Platform key for native compaction", async () => { + const fake = createFakeCodexClient(); + const factory = vi.fn(async () => fake.client); + const sessionFile = await writeTestBinding(); + + const result = requireCompactResult( + await maybeCompactCodexAppServerSession( + { + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + trigger: "manual", + provider: "openai", + model: "gpt-5.5", + resolvedApiKey: "prepared-platform-key", + runtimeAuthPlan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + selectedAuthMode: "api-key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }, + }, + { clientFactory: factory }, + ), + ); + + expect(result.ok).toBe(true); + expect(factory).toHaveBeenCalledWith( + expect.objectContaining({ + preparedAuth: { kind: "api-key", apiKey: "prepared-platform-key" }, + }), + ); + expect(factory.mock.calls[0]?.[0]).not.toHaveProperty("authProfileId"); + }); + + it("fails closed when prepared Platform compaction has no key", async () => { + const fake = createFakeCodexClient(); + const factory = vi.fn(async () => fake.client); + const sessionFile = await writeTestBinding(); + + const result = requireCompactResult( + await maybeCompactCodexAppServerSession( + { + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + trigger: "manual", + provider: "openai", + model: "gpt-5.5", + runtimeAuthPlan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + selectedAuthMode: "api-key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }, + }, + { clientFactory: factory }, + ), + ); + + expect(result).toMatchObject({ + ok: false, + compacted: false, + reason: "Prepared Codex Platform compaction route is missing its resolved API key.", + }); + expect(factory).not.toHaveBeenCalled(); + }); + it("uses the native supervision runtime and auth for supervised bindings", async () => { const fake = createFakeCodexClient(); const factory = vi.fn(async () => fake.client); diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index 4b4946461e41..58864e5dddc8 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -554,6 +554,17 @@ async function compactCodexNativeThread( } const shouldReleaseDefaultLease = !options.clientFactory; const clientFactory = options.clientFactory ?? getLeasedSharedCodexAppServerClient; + const runtimeAuthPlan = params.runtimeAuthPlan ?? params.runtimePlan?.auth; + const usesPreparedApiKey = + !usesSupervisionConnection && runtimeAuthPlan?.modelRoute?.authRequirement === "api-key"; + const preparedApiKey = usesPreparedApiKey ? params.resolvedApiKey?.trim() : undefined; + if (usesPreparedApiKey && !preparedApiKey) { + return { + ok: false, + compacted: false, + reason: "Prepared Codex Platform compaction route is missing its resolved API key.", + }; + } try { return await runExclusiveCodexNativeCompaction( binding.threadId, @@ -561,7 +572,9 @@ async function compactCodexNativeThread( async () => { const client = await clientFactory({ startOptions: appServer.start, - authProfileId: connection.clientAuthProfileId, + ...(preparedApiKey + ? { preparedAuth: { kind: "api-key" as const, apiKey: preparedApiKey } } + : { authProfileId: connection.clientAuthProfileId }), agentDir: params.agentDir, config: params.config, }); diff --git a/extensions/codex/src/app-server/provider-capabilities.test.ts b/extensions/codex/src/app-server/provider-capabilities.test.ts index 2f6068827511..2e432c8be9a6 100644 --- a/extensions/codex/src/app-server/provider-capabilities.test.ts +++ b/extensions/codex/src/app-server/provider-capabilities.test.ts @@ -47,6 +47,85 @@ describe("resolveCodexProviderWebSearchSupport", () => { ); }); + it("forwards one prepared auth handoff to capability startup", async () => { + const { clientFactory } = createClientFactory(true); + const preparedAuth = { + kind: "api-key" as const, + apiKey: "prepared-platform-key", + }; + + await expect( + resolveCodexProviderWebSearchSupport({ + clientFactory, + appServer, + authProfileId: "openai:decoy", + preparedAuth, + agentDir: "/tmp/agent", + config: undefined, + modelProviderOverride: undefined, + signal: new AbortController().signal, + }), + ).resolves.toBe("supported"); + + expect(clientFactory).toHaveBeenCalledWith(expect.objectContaining({ preparedAuth })); + const factoryCalls = ( + clientFactory as unknown as { + mock: { calls: Array<[{ preparedAuth?: unknown }]> }; + } + ).mock.calls; + expect(factoryCalls[0]?.[0].preparedAuth).toBe(preparedAuth); + expect(clientFactory).not.toHaveBeenCalledWith( + expect.objectContaining({ authProfileId: expect.anything() }), + ); + }); + + it("forwards the exact prepared profile snapshot to capability startup", async () => { + const { clientFactory } = createClientFactory(true); + const preparedAuth = { + kind: "profile" as const, + profileId: "openai:work", + store: { + version: 1 as const, + profiles: { + "openai:work": { + type: "token" as const, + provider: "openai", + token: "prepared-token", + }, + }, + }, + snapshot: { + loginParams: { + type: "chatgptAuthTokens" as const, + accessToken: "prepared-token", + chatgptAccountId: "prepared-account", + chatgptPlanType: null, + }, + secretFreeCacheKey: "prepared-account:token:sha256:opaque", + }, + }; + + await expect( + resolveCodexProviderWebSearchSupport({ + clientFactory, + appServer, + authProfileId: undefined, + preparedAuth, + agentDir: "/tmp/agent", + config: undefined, + modelProviderOverride: undefined, + signal: new AbortController().signal, + }), + ).resolves.toBe("supported"); + + const factoryCalls = ( + clientFactory as unknown as { + mock: { calls: Array<[{ preparedAuth?: unknown }]> }; + } + ).mock.calls; + expect(factoryCalls[0]?.[0].preparedAuth).toBe(preparedAuth); + }); + it("reports unknown support when app-server startup fails", async () => { const clientFactory = vi.fn(async () => { throw new Error("old app-server"); diff --git a/extensions/codex/src/app-server/provider-capabilities.ts b/extensions/codex/src/app-server/provider-capabilities.ts index 261afa044ae2..d5400101071c 100644 --- a/extensions/codex/src/app-server/provider-capabilities.ts +++ b/extensions/codex/src/app-server/provider-capabilities.ts @@ -3,6 +3,7 @@ import type { CodexAppServerClient } from "./client.js"; import type { CodexAppServerRuntimeOptions } from "./config.js"; import { releaseLeasedSharedCodexAppServerClient, + type CodexAppServerClientOptions, type CodexAppServerClientFactory, } from "./shared-client.js"; import type { CodexNativeWebSearchSupport } from "./web-search.js"; @@ -50,6 +51,7 @@ export async function resolveCodexProviderWebSearchSupport(params: { clientFactory: CodexAppServerClientFactory; appServer: CodexAppServerRuntimeOptions; authProfileId: string | null | undefined; + preparedAuth?: CodexAppServerClientOptions["preparedAuth"]; agentDir: string; config: EmbeddedRunAttemptParams["config"] | undefined; modelProviderOverride: string | undefined; @@ -59,7 +61,9 @@ export async function resolveCodexProviderWebSearchSupport(params: { try { client = await params.clientFactory({ startOptions: params.appServer.start, - authProfileId: params.authProfileId, + ...(params.preparedAuth + ? { preparedAuth: params.preparedAuth } + : { authProfileId: params.authProfileId }), agentDir: params.agentDir, config: params.config, timeoutMs: params.appServer.requestTimeoutMs, diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index fb6f93772970..75498ec05a80 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -125,6 +125,8 @@ import { resolveCodexAppServerHomeDir, resolveCodexAppServerAuthProfileId, resolveCodexAppServerAuthProfileIdForAgent, + resolveCodexAppServerPreparedAuthHandoff, + resolveCodexAppServerPreparedApiKeyCacheKey, } from "./auth-bridge.js"; import { resolveCodexBindingAppServerConnection } from "./binding-connection.js"; import { @@ -231,7 +233,6 @@ import { resolveCodexProviderWebSearchSupport } from "./provider-capabilities.js import { readCodexRateLimitsRevision, readRecentCodexRateLimits } from "./rate-limit-cache.js"; import { releaseCodexSandboxExecServerEnvironment } from "./sandbox-exec-server.js"; import { - isCodexAppServerNativeAuthProfile, reclaimCurrentCodexSessionGeneration, sessionBindingIdentity, type CodexAppServerBindingIdentity, @@ -595,36 +596,55 @@ export async function runCodexAppServerAttempt( agentDir, openClawSandboxActive: sandbox?.enabled === true, }).appServer; - const startupBindingAuthProfileId = startupBinding?.authProfileId; const initialStartupBindingHadInactiveThreadBootstrap = isInactiveThreadBootstrapBinding(startupBinding); + const preparedAuthRoute = usesSupervisionConnection + ? undefined + : params.runtimePlan?.auth.modelRoute; const startupAuthProfileCandidate = usesSupervisionConnection ? undefined - : (params.runtimePlan?.auth.forwardedAuthProfileId ?? - params.authProfileId ?? - startupBinding?.authProfileId ?? - startupBindingAuthProfileId); - const startupAuthProfileId = usesSupervisionConnection + : preparedAuthRoute + ? params.runtimePlan?.auth.forwardedAuthProfileId + : (params.runtimePlan?.auth.forwardedAuthProfileId ?? + params.authProfileId ?? + startupBinding?.authProfileId); + const resolvedStartupAuthProfileId = usesSupervisionConnection ? undefined - : params.authProfileStore - ? resolveCodexAppServerAuthProfileId({ - authProfileId: startupAuthProfileCandidate, - store: params.authProfileStore, - config: params.config, - }) - : resolveCodexAppServerAuthProfileIdForAgent({ - authProfileId: startupAuthProfileCandidate, - agentDir, - config: params.config, - }); - const startupClientAuthProfileId = usesSupervisionConnection ? null : startupAuthProfileId; - const nativeAuthProfile = - isCodexAppServerNativeAuthProfile({ - authProfileId: startupClientAuthProfileId ?? undefined, - authProfileStore: params.authProfileStore, - agentDir, - config: params.config, - }) || usesSupervisionConnection; + : preparedAuthRoute + ? startupAuthProfileCandidate + : params.authProfileStore + ? resolveCodexAppServerAuthProfileId({ + authProfileId: startupAuthProfileCandidate, + store: params.authProfileStore, + config: params.config, + }) + : resolveCodexAppServerAuthProfileIdForAgent({ + authProfileId: startupAuthProfileCandidate, + agentDir, + config: params.config, + }); + const authHandoff = usesSupervisionConnection + ? { authProfileId: undefined, nativeAuthProfile: true, preparedAuth: undefined } + : await resolveCodexAppServerPreparedAuthHandoff({ + authRequirement: preparedAuthRoute?.authRequirement, + resolvedApiKey: params.resolvedApiKey, + authProfileId: resolvedStartupAuthProfileId, + authProfileStore: params.authProfileStore, + agentDir, + config: params.config, + subscriptionProfileRequiredError: + "Prepared Codex subscription route requires a forwarded OpenAI OAuth or token profile.", + subscriptionProfileUnusableError: "Prepared Codex subscription auth profile is unusable.", + }); + const { + authProfileId: startupAuthProfileId, + nativeAuthProfile, + preparedAuth: startupPreparedAuth, + } = authHandoff; + const startupClientAuthProfileId = + usesSupervisionConnection || startupPreparedAuth?.kind === "api-key" + ? null + : startupAuthProfileId; const resolveReviewerPolicyContext = (binding: CodexAppServerThreadBinding | undefined) => { const nativeModelOwned = binding?.preserveNativeModel === true; // A supervised Codex branch owns its model. The outer OpenClaw default may @@ -846,15 +866,19 @@ export async function runCodexAppServerAttempt( }); const startupAuthAccountCacheKey = usesSupervisionConnection ? undefined - : await resolveCodexAppServerAuthAccountCacheKey({ - authProfileId: startupAuthProfileId, - authProfileStore: attemptAuthProfileStore, - agentDir, - config: params.config, - }); + : startupPreparedAuth?.kind === "api-key" + ? resolveCodexAppServerPreparedApiKeyCacheKey(startupPreparedAuth.apiKey) + : startupPreparedAuth?.kind === "profile" + ? startupPreparedAuth.snapshot?.secretFreeCacheKey + : await resolveCodexAppServerAuthAccountCacheKey({ + authProfileId: startupAuthProfileId, + authProfileStore: attemptAuthProfileStore, + agentDir, + config: params.config, + }); const startupEnvApiKeyCacheKey = usesSupervisionConnection ? undefined - : startupAuthProfileId + : startupPreparedAuth || startupAuthProfileId ? undefined : resolveCodexAppServerFallbackApiKeyCacheKey({ startOptions: appServer.start, @@ -889,6 +913,7 @@ export async function runCodexAppServerAttempt( clientFactory: attemptClientFactory, appServer, authProfileId: startupClientAuthProfileId, + preparedAuth: startupPreparedAuth, agentDir, config: params.config, modelProviderOverride: usesSupervisionConnection @@ -1772,6 +1797,7 @@ export async function runCodexAppServerAttempt( startupAuthProfileId: startupClientAuthProfileId, startupAuthBindingFingerprint: preparedAuthBinding?.fingerprint, ...(runtimeArtifactRequest ? { runtimeArtifactRequest } : {}), + startupPreparedAuth, startupAuthAccountCacheKey, startupEnvApiKeyCacheKey, agentDir, diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index 7a97ff634f3b..f4e37ca6a066 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -24,12 +24,24 @@ const mocks = vi.hoisted(() => ({ resolveCodexAppServerAuthProfileStore: vi.fn( (params?: { authProfileStore?: unknown }) => params?.authProfileStore, ), + resolveCodexAppServerPreparedAuthProfileSnapshot: vi.fn(async () => ({ + loginParams: { + type: "chatgptAuthTokens" as const, + accessToken: "prepared-token", + chatgptAccountId: "prepared-account", + chatgptPlanType: null, + }, + secretFreeCacheKey: "prepared-account:token:sha256:prepared", + })), refreshCodexAppServerAuthTokens: vi.fn(async () => ({ accessToken: "refreshed-access", chatgptAccountId: "refreshed-account", chatgptPlanType: null, })), resolveCodexAppServerFallbackApiKeyCacheKey: vi.fn(() => undefined as string | undefined), + resolveCodexAppServerPreparedApiKeyCacheKey: vi.fn( + (_apiKey: string) => "api_key:sha256:prepared", + ), resolveManagedCodexAppServerStartOptions: vi.fn(async (startOptions) => startOptions), resolveManagedCodexNativeCommand: vi.fn((command: string) => `${command}.native`), embeddedAgentLog: { debug: vi.fn(), warn: vi.fn() }, @@ -41,10 +53,13 @@ vi.mock("./auth-bridge.js", () => ({ bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions, resolveCodexAppServerAuthProfileIdForAgent: mocks.resolveCodexAppServerAuthProfileIdForAgent, resolveCodexAppServerAuthProfileStore: mocks.resolveCodexAppServerAuthProfileStore, + resolveCodexAppServerPreparedAuthProfileSnapshot: + mocks.resolveCodexAppServerPreparedAuthProfileSnapshot, refreshCodexAppServerAuthTokens: mocks.refreshCodexAppServerAuthTokens, resolveCodexAppServerFallbackApiKeyCacheKey: mocks.resolveCodexAppServerFallbackApiKeyCacheKey, resolveCodexAppServerHomeDir: (agentDir: string) => path.join(path.resolve(agentDir), "codex-home"), + resolveCodexAppServerPreparedApiKeyCacheKey: mocks.resolveCodexAppServerPreparedApiKeyCacheKey, })); vi.mock("./managed-binary.js", () => ({ @@ -109,6 +124,9 @@ function bridgeStartOptionsCall() { agentDir?: string; authProfileId?: string; authProfileStore?: unknown; + preparedAuth?: + | { kind: "api-key"; apiKey: string } + | { kind: "profile"; profileId: string; snapshot?: unknown }; config?: unknown; startOptions: { command?: string; commandSource?: string }; }; @@ -119,6 +137,9 @@ function applyAuthProfileCall() { agentDir?: string; authProfileId?: string; authProfileStore?: unknown; + preparedAuth?: + | { kind: "api-key"; apiKey: string } + | { kind: "profile"; snapshot: { loginParams: unknown } }; config?: unknown; }; } @@ -196,9 +217,20 @@ describe("shared Codex app-server client", () => { mocks.resolveCodexAppServerAuthProfileStore.mockImplementation( (params?: { authProfileStore?: unknown }) => params?.authProfileStore, ); + mocks.resolveCodexAppServerPreparedAuthProfileSnapshot.mockReset(); + mocks.resolveCodexAppServerPreparedAuthProfileSnapshot.mockResolvedValue({ + loginParams: { + type: "chatgptAuthTokens", + accessToken: "prepared-token", + chatgptAccountId: "prepared-account", + chatgptPlanType: null, + }, + secretFreeCacheKey: "prepared-account:token:sha256:prepared", + }); mocks.refreshCodexAppServerAuthTokens.mockClear(); mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockClear(); mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockReturnValue(undefined); + mocks.resolveCodexAppServerPreparedApiKeyCacheKey.mockClear(); mocks.resolveManagedCodexAppServerStartOptions.mockClear(); mocks.resolveManagedCodexAppServerStartOptions.mockImplementation( async (startOptions) => startOptions, @@ -860,6 +892,242 @@ describe("shared Codex app-server client", () => { }); }); + it("keeps a shared prepared auth store authoritative through startup and refresh", async () => { + const harness = createClientHarness(); + vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client); + const authProfileStore = { + version: 1 as const, + profiles: { + "openai:scoped": { + type: "token" as const, + provider: "openai", + token: "prepared-token", + }, + }, + order: { openai: ["openai:scoped"] }, + }; + const clientPromise = getSharedCodexAppServerClient({ + timeoutMs: 1000, + preparedAuth: { + kind: "profile", + profileId: "openai:scoped", + store: authProfileStore, + }, + }); + await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)"); + + await expect(clientPromise).resolves.toBe(harness.client); + expect(mocks.resolveCodexAppServerAuthProfileStore).not.toHaveBeenCalled(); + expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled(); + expect(mocks.resolveCodexAppServerPreparedAuthProfileSnapshot).toHaveBeenCalledOnce(); + expect(bridgeStartOptionsCall()).toMatchObject({ + authProfileId: "openai:scoped", + authProfileStore, + preparedAuth: { kind: "profile", profileId: "openai:scoped" }, + }); + expect(applyAuthProfileCall()).toMatchObject({ + authProfileId: "openai:scoped", + authProfileStore, + preparedAuth: { + kind: "profile", + snapshot: { + loginParams: { + type: "chatgptAuthTokens", + accessToken: "prepared-token", + }, + }, + }, + }); + + const priorWriteCount = harness.writes.length; + harness.send({ + id: "refresh-authoritative", + method: "account/chatgptAuthTokens/refresh", + params: { reason: "unauthorized", previousAccountId: "scoped-account" }, + }); + await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(priorWriteCount)); + expect(mocks.refreshCodexAppServerAuthTokens).toHaveBeenCalledWith({ + agentDir: "/tmp/openclaw-agent", + authProfileId: "openai:scoped", + authProfileStore, + config: undefined, + }); + }); + + it("separates prepared profile clients by secret-free account identity", async () => { + const firstHarness = createClientHarness(); + const secondHarness = createClientHarness(); + const startSpy = vi + .spyOn(CodexAppServerClient, "start") + .mockReturnValueOnce(firstHarness.client) + .mockReturnValueOnce(secondHarness.client); + const resolvedCacheKeys: string[] = []; + mocks.resolveCodexAppServerPreparedAuthProfileSnapshot.mockImplementation( + async (params?: { + authProfileStore?: { + profiles?: Record; + }; + }) => { + const token = params?.authProfileStore?.profiles?.["openai:scoped"]?.token; + const key = + token === "first-secret-token" ? "account:sha256:first" : "account:sha256:second"; + resolvedCacheKeys.push(key); + return { + loginParams: { + type: "chatgptAuthTokens" as const, + accessToken: token ?? "", + chatgptAccountId: "prepared-account", + chatgptPlanType: null, + }, + secretFreeCacheKey: key, + }; + }, + ); + const firstStore = { + version: 1 as const, + profiles: { + "openai:scoped": { + type: "token" as const, + provider: "openai", + token: "first-secret-token", + }, + }, + }; + const secondStore = { + version: 1 as const, + profiles: { + "openai:scoped": { + type: "token" as const, + provider: "openai", + token: "second-secret-token", + }, + }, + }; + + const firstPromise = getSharedCodexAppServerClient({ + timeoutMs: 1000, + preparedAuth: { kind: "profile", profileId: "openai:scoped", store: firstStore }, + }); + await sendInitializeResult(firstHarness, "openclaw/0.143.0 (macOS; test)"); + await expect(firstPromise).resolves.toBe(firstHarness.client); + + const secondPromise = getSharedCodexAppServerClient({ + timeoutMs: 1000, + preparedAuth: { kind: "profile", profileId: "openai:scoped", store: secondStore }, + }); + await vi.waitFor(() => expect(startSpy).toHaveBeenCalledTimes(2)); + await sendInitializeResult(secondHarness, "openclaw/0.143.0 (macOS; test)"); + await expect(secondPromise).resolves.toBe(secondHarness.client); + + expect(resolvedCacheKeys).toEqual(["account:sha256:first", "account:sha256:second"]); + expect(mocks.applyCodexAppServerAuthProfile).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + preparedAuth: expect.objectContaining({ + snapshot: expect.objectContaining({ + loginParams: expect.objectContaining({ accessToken: "first-secret-token" }), + }), + }), + }), + ); + expect(mocks.applyCodexAppServerAuthProfile).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + preparedAuth: expect.objectContaining({ + snapshot: expect.objectContaining({ + loginParams: expect.objectContaining({ accessToken: "second-secret-token" }), + }), + }), + }), + ); + expect(resolvedCacheKeys.join("\n")).not.toContain("first-secret-token"); + expect(resolvedCacheKeys.join("\n")).not.toContain("second-secret-token"); + }); + + it("starts a prepared API-key client without profile or ambient-store resolution", async () => { + const harness = createClientHarness(); + vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client); + + const clientPromise = getSharedCodexAppServerClient({ + timeoutMs: 1000, + preparedAuth: { kind: "api-key", apiKey: "platform-key" }, + }); + await sendInitializeResult(harness, "openclaw/0.143.0 (macOS; test)"); + + await expect(clientPromise).resolves.toBe(harness.client); + expect(mocks.resolveCodexAppServerAuthProfileStore).not.toHaveBeenCalled(); + expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled(); + expect(bridgeStartOptionsCall().authProfileId).toBeNull(); + expect(bridgeStartOptionsCall().preparedAuth).toEqual({ + kind: "api-key", + apiKey: "platform-key", + }); + expect(applyAuthProfileCall()).toMatchObject({ + authProfileId: null, + preparedAuth: { kind: "api-key", apiKey: "platform-key" }, + }); + expect(mocks.resolveCodexAppServerPreparedApiKeyCacheKey).toHaveBeenCalledWith("platform-key"); + }); + + it("rejects ambiguous prepared and legacy auth before starting a client", async () => { + const startSpy = vi.spyOn(CodexAppServerClient, "start"); + + await expect( + getSharedCodexAppServerClient({ + authProfileId: "openai:legacy", + preparedAuth: { kind: "api-key", apiKey: "platform-key" }, + }), + ).rejects.toThrow("Prepared Codex auth cannot also select a legacy auth profile"); + + expect(startSpy).not.toHaveBeenCalled(); + }); + + it("rotates prepared API keys onto distinct shared clients", async () => { + const firstHarness = createClientHarness(); + const secondHarness = createClientHarness(); + const startSpy = vi + .spyOn(CodexAppServerClient, "start") + .mockReturnValueOnce(firstHarness.client) + .mockReturnValueOnce(secondHarness.client); + const cacheKeys: string[] = []; + mocks.resolveCodexAppServerPreparedApiKeyCacheKey.mockImplementation((apiKey: string) => { + const cacheKey = + apiKey === "first-platform-key" ? "api_key:sha256:first" : "api_key:sha256:second"; + cacheKeys.push(cacheKey); + return cacheKey; + }); + + const firstPromise = getSharedCodexAppServerClient({ + timeoutMs: 1000, + preparedAuth: { kind: "api-key", apiKey: "first-platform-key" }, + }); + await sendInitializeResult(firstHarness, "openclaw/0.143.0 (macOS; test)"); + await expect(firstPromise).resolves.toBe(firstHarness.client); + + const secondPromise = getSharedCodexAppServerClient({ + timeoutMs: 1000, + preparedAuth: { kind: "api-key", apiKey: "second-platform-key" }, + }); + await vi.waitFor(() => expect(startSpy).toHaveBeenCalledTimes(2)); + await sendInitializeResult(secondHarness, "openclaw/0.143.0 (macOS; test)"); + await expect(secondPromise).resolves.toBe(secondHarness.client); + + expect(cacheKeys).toEqual(["api_key:sha256:first", "api_key:sha256:second"]); + expect(cacheKeys.join("\n")).not.toContain("platform-key"); + expect(mocks.applyCodexAppServerAuthProfile).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + preparedAuth: { kind: "api-key", apiKey: "first-platform-key" }, + }), + ); + expect(mocks.applyCodexAppServerAuthProfile).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + preparedAuth: { kind: "api-key", apiKey: "second-platform-key" }, + }), + ); + }); + it("registers persisted profile refresh for isolated app-server startup", async () => { const harness = createClientHarness(); vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client); diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index e4a3f2646670..39e19d197caa 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -14,6 +14,10 @@ import { resolveCodexAppServerAuthProfileStore, resolveCodexAppServerFallbackApiKeyCacheKey, resolveCodexAppServerHomeDir, + resolveCodexAppServerPreparedAuthProfileSnapshot, + resolveCodexAppServerPreparedApiKeyCacheKey, + type CodexAppServerPreparedAuth, + type CodexAppServerResolvedPreparedAuth, } from "./auth-bridge.js"; import { ensureCodexAppServerClientRuntime } from "./client-runtime.js"; import { CodexAppServerClient, isUnsupportedCodexAppServerVersionError } from "./client.js"; @@ -31,6 +35,8 @@ import { import { acquireCodexNativeConfigFence } from "./native-config-fence.js"; import { withTimeout } from "./timeout.js"; +export type { CodexAppServerPreparedAuth } from "./auth-bridge.js"; + type SharedCodexAppServerClientEntry = { client?: CodexAppServerClient; startup?: SharedCodexAppServerClientStartup; @@ -238,6 +244,7 @@ export type CodexAppServerClientOptions = { runtimeArtifactMode?: "capture"; /** Previously minted exact runtime required before the process may start. */ expectedRuntimeArtifact?: AgentHarnessRuntimeArtifactBinding; + preparedAuth?: CodexAppServerPreparedAuth; agentDir?: string; config?: Parameters[0]["config"]; onStartedClient?: (client: CodexAppServerClient) => void; @@ -254,6 +261,7 @@ type ResolvedCodexAppServerClientStartContext = { usesNativeAuth: boolean; authProfileId: string | undefined; authProfileStore: AuthProfileStore | undefined; + preparedAuth: CodexAppServerResolvedPreparedAuth | undefined; requestedStartOptions: CodexAppServerStartOptions; startOptions: CodexAppServerStartOptions; }; @@ -264,27 +272,73 @@ async function resolveCodexAppServerClientStartContext( const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {}); const requestedStartOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start; + const preparedAuth = options?.preparedAuth; + const preparedApiKey = preparedAuth?.kind === "api-key" ? preparedAuth.apiKey.trim() : undefined; + if (preparedAuth && options?.authProfileId !== undefined) { + throw new Error("Prepared Codex auth cannot also select a legacy auth profile."); + } + if (preparedAuth?.kind === "profile" && !preparedAuth.store.profiles[preparedAuth.profileId]) { + throw new Error(`Prepared Codex auth profile "${preparedAuth.profileId}" was not found.`); + } + if (preparedAuth?.kind === "api-key" && !preparedApiKey) { + throw new Error("Prepared Codex API-key auth is missing its resolved key."); + } + if (preparedAuth && requestedStartOptions.homeScope === "user") { + throw new Error("Prepared Codex auth requires an isolated app-server home."); + } const usesNativeAuth = - options?.authProfileId === null || requestedStartOptions.homeScope === "user"; + !preparedAuth && + (options?.authProfileId === null || requestedStartOptions.homeScope === "user"); const requestedAuthProfileId = - options?.authProfileId === null ? undefined : options?.authProfileId; + preparedAuth?.kind === "profile" + ? preparedAuth.profileId + : (options?.authProfileId ?? undefined); const authProfileStore = - !usesNativeAuth && options?.authProfileStore - ? resolveCodexAppServerAuthProfileStore({ + preparedAuth?.kind === "profile" + ? preparedAuth.store + : !usesNativeAuth && options?.authProfileStore + ? resolveCodexAppServerAuthProfileStore({ + agentDir, + authProfileId: requestedAuthProfileId, + authProfileStore: options.authProfileStore, + config: options.config, + }) + : options?.authProfileStore; + const authProfileId = + preparedAuth?.kind === "profile" + ? preparedAuth.profileId + : usesNativeAuth || preparedAuth?.kind === "api-key" + ? undefined + : resolveCodexAppServerAuthProfileIdForAgent({ + authProfileId: requestedAuthProfileId, + agentDir, + config: options?.config, + ...(authProfileStore ? { authProfileStore } : {}), + }); + const preparedAuthProfileSnapshot = + preparedAuth?.kind === "profile" + ? (preparedAuth.snapshot ?? + (await resolveCodexAppServerPreparedAuthProfileSnapshot({ + authProfileId, + authProfileStore, agentDir, - authProfileId: requestedAuthProfileId, - authProfileStore: options.authProfileStore, - config: options.config, - }) - : options?.authProfileStore; - const authProfileId = usesNativeAuth - ? undefined - : resolveCodexAppServerAuthProfileIdForAgent({ - authProfileId: requestedAuthProfileId, - agentDir, - config: options?.config, - ...(authProfileStore ? { authProfileStore } : {}), - }); + config: options?.config, + }))) + : undefined; + if (preparedAuth?.kind === "profile" && !preparedAuthProfileSnapshot) { + throw new Error(`Prepared Codex auth profile "${preparedAuth.profileId}" is unusable.`); + } + const resolvedPreparedAuth: CodexAppServerResolvedPreparedAuth | undefined = + preparedAuth?.kind === "api-key" + ? { kind: "api-key", apiKey: preparedApiKey as string } + : preparedAuth?.kind === "profile" + ? { + ...preparedAuth, + snapshot: preparedAuthProfileSnapshot as NonNullable< + typeof preparedAuthProfileSnapshot + >, + } + : undefined; const agentStartOptions = resolveCodexAppServerStartOptionsForAgent({ startOptions: requestedStartOptions, agentDir, @@ -293,7 +347,8 @@ async function resolveCodexAppServerClientStartContext( const startOptions = await bridgeCodexAppServerStartOptions({ startOptions: managedStartOptions, agentDir, - authProfileId: usesNativeAuth ? null : authProfileId, + authProfileId: usesNativeAuth || preparedAuth?.kind === "api-key" ? null : authProfileId, + ...(resolvedPreparedAuth ? { preparedAuth: resolvedPreparedAuth } : {}), config: options?.config, pluginConfig: options?.pluginConfig, ...(authProfileStore ? { authProfileStore } : {}), @@ -304,6 +359,7 @@ async function resolveCodexAppServerClientStartContext( authProfileId, authProfileStore, requestedStartOptions, + preparedAuth: resolvedPreparedAuth, startOptions, }; } @@ -442,18 +498,23 @@ async function acquireSharedCodexAppServerClient( usesNativeAuth, authProfileId, authProfileStore, + preparedAuth, requestedStartOptions, startOptions, } = context; const remainingTimeoutMs = resolveRemainingAcquireTimeout(timeoutMs, acquireStartedAt); - const fallbackApiKeyCacheKey = authProfileId - ? undefined - : resolveCodexAppServerFallbackApiKeyCacheKey({ startOptions }); + const authIdentityCacheKey = + preparedAuth?.kind === "api-key" + ? resolveCodexAppServerPreparedApiKeyCacheKey(preparedAuth.apiKey) + : (preparedAuth?.snapshot.secretFreeCacheKey ?? + (authProfileId + ? undefined + : resolveCodexAppServerFallbackApiKeyCacheKey({ startOptions }))); const baseKey = codexAppServerStartOptionsKey(startOptions, { authProfileId, authBindingFingerprint: options?.authBindingFingerprint, agentDir: usesNativeAuth ? undefined : agentDir, - fallbackApiKeyCacheKey, + fallbackApiKeyCacheKey: authIdentityCacheKey, }); // Capture turns cannot inherit a normal client whose loaded bytes predate the // filesystem snapshot. Keep their physical process generation separate. @@ -511,8 +572,9 @@ async function acquireSharedCodexAppServerClient( requestedStartOptions, startOptions, agentDir, - authProfileId: usesNativeAuth ? null : authProfileId, + authProfileId: usesNativeAuth || preparedAuth?.kind === "api-key" ? null : authProfileId, authProfileStore, + preparedAuth, runtimeArtifactMode, ...(options?.expectedRuntimeArtifact ? { expectedRuntimeArtifact: options.expectedRuntimeArtifact } @@ -541,6 +603,7 @@ async function acquireSharedCodexAppServerClient( agentDir, authProfileId: usesNativeAuth ? undefined : authProfileId, ...(authProfileStore ? { authProfileStore } : {}), + authMode: preparedAuth?.kind === "api-key" ? "prepared-api-key" : "profile", config: options?.config, }); const release = leaseOptions?.leased ? retainSharedClientEntry(entry) : undefined; @@ -600,6 +663,7 @@ function createSharedCodexAppServerClientStartup(params: { runtimeArtifactMode?: "capture"; expectedRuntimeArtifact?: AgentHarnessRuntimeArtifactBinding; runtimeArtifactSignal?: AbortSignal; + preparedAuth?: CodexAppServerResolvedPreparedAuth; config?: CodexAppServerClientOptions["config"]; }): SharedCodexAppServerClientStartup { const initialized = createDeferred(); @@ -609,6 +673,7 @@ function createSharedCodexAppServerClientStartup(params: { agentDir: params.agentDir, authProfileId: params.authProfileId, authProfileStore: params.authProfileStore, + preparedAuth: params.preparedAuth, runtimeArtifactMode: params.runtimeArtifactMode, ...(params.expectedRuntimeArtifact ? { expectedRuntimeArtifact: params.expectedRuntimeArtifact } @@ -655,6 +720,7 @@ export async function createIsolatedCodexAppServerClient( usesNativeAuth, authProfileId, authProfileStore, + preparedAuth, requestedStartOptions, startOptions, } = await withCodexAppServerAcquireDeadline( @@ -666,8 +732,9 @@ export async function createIsolatedCodexAppServerClient( requestedStartOptions, startOptions, agentDir, - authProfileId: usesNativeAuth ? null : authProfileId, + authProfileId: usesNativeAuth || preparedAuth?.kind === "api-key" ? null : authProfileId, authProfileStore, + preparedAuth, runtimeArtifactMode: options?.runtimeArtifactMode ?? (options?.expectedRuntimeArtifact ? "capture" : undefined), ...(options?.expectedRuntimeArtifact @@ -690,6 +757,7 @@ async function startInitializedCodexAppServerClient(params: { runtimeArtifactMode?: "capture"; expectedRuntimeArtifact?: AgentHarnessRuntimeArtifactBinding; runtimeArtifactSignal?: AbortSignal; + preparedAuth?: CodexAppServerResolvedPreparedAuth; config?: CodexAppServerClientOptions["config"]; timeoutMs?: number; abandonSignal?: AbortSignal; @@ -778,6 +846,7 @@ async function startInitializedCodexAppServerClient(params: { ensureCodexAppServerClientRuntime(client, { agentDir: params.agentDir, authProfileId: params.authProfileId ?? undefined, + authMode: params.preparedAuth?.kind === "api-key" ? "prepared-api-key" : "profile", ...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}), config: params.config, }); @@ -788,6 +857,7 @@ async function startInitializedCodexAppServerClient(params: { client, agentDir: params.agentDir, authProfileId: params.authProfileId, + preparedAuth: params.preparedAuth, startOptions, config: params.config, ...(params.authProfileStore ? { authProfileStore: params.authProfileStore } : {}), diff --git a/extensions/codex/src/app-server/side-question.test.ts b/extensions/codex/src/app-server/side-question.test.ts index ef75c5349c58..6193b06c53a1 100644 --- a/extensions/codex/src/app-server/side-question.test.ts +++ b/extensions/codex/src/app-server/side-question.test.ts @@ -73,7 +73,8 @@ vi.mock("./shared-client.js", () => ({ withLeasedCodexAppServerClientStartSelectionRetryMock(params), })); -vi.mock("./auth-bridge.js", () => ({ +vi.mock("./auth-bridge.js", async (importOriginal) => ({ + ...(await importOriginal()), refreshCodexAppServerAuthTokens: (...args: unknown[]) => refreshCodexAppServerAuthTokensMock(...args), })); @@ -364,6 +365,12 @@ function nativeCommandItem( } function sideParams(overrides: Partial[0]> = {}) { + const authProfileId = Object.hasOwn(overrides, "authProfileId") + ? overrides.authProfileId + : "openai:work"; + const authProfileIdSource = Object.hasOwn(overrides, "authProfileIdSource") + ? overrides.authProfileIdSource + : "user"; return { cfg: {} as never, agentDir: "/tmp/agent", @@ -381,12 +388,71 @@ function sideParams(overrides: Partial[0]; } +function platformPreparedRuntimeAuth(resolvedApiKey?: string) { + return { + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: "api-key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.6", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }, + authProfileStore: { + version: 1 as const, + profiles: {}, + order: { openai: [] }, + }, + authStorage: {} as never, + modelRegistry: {} as never, + ...(resolvedApiKey ? { resolvedApiKey } : {}), + } satisfies Parameters[0]["preparedRuntimeAuth"]; +} + async function runSideQuestionWithManagedWebSearchCall( params: Parameters[0] = sideParams(), options: { preserveToolFactory?: boolean } = {}, @@ -539,6 +605,16 @@ describe("runCodexAppServerSideQuestion", () => { ); expect(result).toEqual({ text: "Side answer." }); + expect(mockCall(getSharedCodexAppServerClientMock)[0]).toMatchObject({ + preparedAuth: { + kind: "profile", + profileId: "openai:work", + store: expect.objectContaining({ + profiles: expect.objectContaining({ "openai:work": expect.any(Object) }), + }), + }, + }); + expect(mockCall(getSharedCodexAppServerClientMock)[0]).not.toHaveProperty("authProfileId"); const forkCall = mockCall(client.request); expect(forkCall?.[0]).toBe("thread/fork"); const forkParams = forkCall?.[1] as Record | undefined; @@ -708,6 +784,104 @@ describe("runCodexAppServerSideQuestion", () => { expect(replacementClient.requests).toHaveLength(1); }); + it("rejects a Platform plan before binding OAuth can fill missing prepared auth", async () => { + await expect( + runCodexAppServerSideQuestion( + sideParams({ + provider: "openai", + model: "gpt-5.6", + runtimeModel: { + provider: "openai", + id: "gpt-5.6", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + } as never, + authProfileId: undefined, + authProfileIdSource: undefined, + preparedRuntimeAuth: platformPreparedRuntimeAuth(), + }), + ), + ).rejects.toThrow("Prepared Codex API-key route is missing its resolved API key"); + + expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled(); + expect(isCodexAppServerNativeAuthProfileMock).not.toHaveBeenCalled(); + }); + + it("rejects an unprofiled subscription plan before native account inference", async () => { + isCodexAppServerNativeAuthProfileMock.mockReturnValue(false); + await expect( + runCodexAppServerSideQuestion( + sideParams({ + authProfileId: undefined, + authProfileIdSource: undefined, + }), + ), + ).rejects.toThrow( + "Prepared Codex subscription route requires a scoped native OAuth or token profile", + ); + + expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled(); + expect(isCodexAppServerNativeAuthProfileMock).toHaveBeenCalledWith( + expect.objectContaining({ authProfileId: undefined, authProfileStore: expect.any(Object) }), + ); + }); + + it("rejects an API-key profile for a prepared subscription route", async () => { + isCodexAppServerNativeAuthProfileMock.mockReturnValue(false); + const params = sideParams(); + params.preparedRuntimeAuth.authProfileStore.profiles["openai:work"] = { + type: "api_key", + provider: "openai", + key: "platform-key", + }; + + await expect(runCodexAppServerSideQuestion(params)).rejects.toThrow( + "Prepared Codex subscription route requires a scoped native OAuth or token profile", + ); + expect(isCodexAppServerNativeAuthProfileMock).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: "openai:work", + authProfileStore: params.preparedRuntimeAuth.authProfileStore, + }), + ); + expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled(); + }); + + it("starts a Platform side question with only its authoritative prepared API key", async () => { + const client = createFakeClient(); + getSharedCodexAppServerClientMock.mockResolvedValue(client); + isCodexAppServerNativeAuthProfileMock.mockReturnValue(false); + const preparedRuntimeAuth = platformPreparedRuntimeAuth("platform-key"); + + await expect( + runCodexAppServerSideQuestion( + sideParams({ + provider: "openai", + model: "gpt-5.6", + runtimeModel: { + provider: "openai", + id: "gpt-5.6", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + } as never, + authProfileId: undefined, + authProfileIdSource: undefined, + preparedRuntimeAuth, + }), + ), + ).resolves.toEqual({ text: "Side answer." }); + + expect(getSharedCodexAppServerClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + preparedAuth: { kind: "api-key", apiKey: "platform-key" }, + }), + ); + expect(mockCall(getSharedCodexAppServerClientMock)[0]).not.toHaveProperty("authProfileId"); + expect(isCodexAppServerNativeAuthProfileMock).not.toHaveBeenCalledWith( + expect.objectContaining({ authProfileId: "openai:work" }), + ); + }); + it("allocates one fallback run ID per side-question invocation", async () => { const client = createFakeClient(); getSharedCodexAppServerClientMock.mockResolvedValue(client); @@ -2839,11 +3013,14 @@ describe("runCodexAppServerSideQuestion", () => { await runCodexAppServerSideQuestion(sideParams()); - expect(refreshCodexAppServerAuthTokensMock).toHaveBeenCalledWith({ - agentDir: "/tmp/agent", - authProfileId: "openai:work", - config: {}, - }); + expect(refreshCodexAppServerAuthTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ + agentDir: "/tmp/agent", + authProfileId: "openai:work", + authProfileStore: expect.any(Object), + config: {}, + }), + ); }); it("returns a clear setup error when there is no Codex parent thread", async () => { diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index ef5faefad712..30bba839bd04 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -22,6 +22,7 @@ import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime"; import { readCodexSupportedReasoningEfforts } from "../../provider.js"; import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; +import { resolveCodexAppServerPreparedAuthHandoff } from "./auth-bridge.js"; import { requireCodexSupervisionModelSelection, resolveCodexBindingAppServerConnection, @@ -90,11 +91,7 @@ import { resolveCodexProviderWebSearchSupportForClient } from "./provider-capabi import { readRecentCodexRateLimits } from "./rate-limit-cache.js"; import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js"; import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js"; -import { - isCodexAppServerNativeAuthProfile, - sessionBindingIdentity, - type CodexAppServerBindingStore, -} from "./session-binding.js"; +import { sessionBindingIdentity, type CodexAppServerBindingStore } from "./session-binding.js"; import { getLeasedSharedCodexAppServerClient, releaseCodexAppServerClientLease, @@ -194,14 +191,31 @@ export async function runCodexAppServerSideQuestion( const supervisionModelSelection = usesSupervisionConnection ? requireCodexSupervisionModelSelection(binding) : undefined; - const authProfileId = usesSupervisionConnection - ? undefined - : (params.authProfileId ?? binding.authProfileId); + const preparedRuntimeAuth = params.preparedRuntimeAuth; + const authHandoff = usesSupervisionConnection + ? { authProfileId: undefined, nativeAuthProfile: true, preparedAuth: undefined } + : await resolveCodexAppServerPreparedAuthHandoff({ + authRequirement: preparedRuntimeAuth.plan.modelRoute?.authRequirement, + resolvedApiKey: preparedRuntimeAuth.resolvedApiKey, + authProfileId: preparedRuntimeAuth.plan.forwardedAuthProfileId, + authProfileStore: preparedRuntimeAuth.authProfileStore, + agentDir: params.agentDir, + config: params.cfg, + subscriptionProfileRequiredError: + "Prepared Codex subscription route requires a scoped native OAuth or token profile.", + subscriptionProfileUnusableError: `Prepared Codex auth profile "${preparedRuntimeAuth.plan.forwardedAuthProfileId}" is unusable.`, + }); + const { + authProfileId, + nativeAuthProfile: preparedNativeAuthProfile, + preparedAuth: startupPreparedAuth, + } = authHandoff; const modelProvider = supervisionModelSelection ? supervisionModelSelection.modelProvider : (resolveCodexAppServerModelProvider({ provider: params.provider, authProfileId, + authProfileStore: preparedRuntimeAuth.authProfileStore, agentDir: params.agentDir, config: params.cfg, }) ?? @@ -215,6 +229,7 @@ export async function runCodexAppServerSideQuestion( model: supervisionModelSelection?.model ?? params.model, modelProvider, authProfileId, + authProfileStore: preparedRuntimeAuth.authProfileStore, agentDir: params.agentDir, config: params.cfg, }); @@ -223,13 +238,7 @@ export async function runCodexAppServerSideQuestion( model: supervisionModelSelection?.model ?? params.model, bindingModelProvider: binding.modelProvider, bindingModel: binding.model, - nativeAuthProfile: - usesSupervisionConnection || - isCodexAppServerNativeAuthProfile({ - authProfileId, - agentDir: params.agentDir, - config: params.cfg, - }), + nativeAuthProfile: usesSupervisionConnection || preparedNativeAuthProfile, }); const connection = resolveCodexBindingAppServerConnection({ binding, @@ -281,7 +290,9 @@ export async function runCodexAppServerSideQuestion( const clientOptions = { startOptions: appServer.start, timeoutMs: appServer.requestTimeoutMs, - authProfileId: connection.clientAuthProfileId, + ...(startupPreparedAuth + ? { preparedAuth: startupPreparedAuth } + : { authProfileId: connection.clientAuthProfileId }), agentDir: params.agentDir, config: params.cfg, ...(params.opts?.abortSignal ? { abandonSignal: params.opts.abortSignal } : {}), @@ -404,7 +415,17 @@ export async function runCodexAppServerSideQuestion( // stays installed once per client instead of once per side question. ensureCodexAppServerClientRuntime(client, { agentDir: params.agentDir, - authProfileId: connection.requestAuthProfileId, + authProfileId: + startupPreparedAuth?.kind === "api-key" ? undefined : connection.requestAuthProfileId, + ...(!usesSupervisionConnection + ? { + authProfileStore: preparedRuntimeAuth.authProfileStore, + authMode: + startupPreparedAuth?.kind === "api-key" + ? ("prepared-api-key" as const) + : ("profile" as const), + } + : {}), config: params.cfg, }); const registerRequestHandler = (targetClient: CodexAppServerClient) => @@ -843,12 +864,17 @@ function buildSideRunAttemptParams( ...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}), workspaceDir: options.cwd, authProfileId: options.authProfileId, - authProfileIdSource: params.authProfileIdSource, + authProfileIdSource: options.authProfileId + ? params.preparedRuntimeAuth.plan.forwardedAuthProfileSource + : undefined, thinkLevel: params.resolvedThinkLevel ?? "off", resolvedReasoningLevel: params.resolvedReasoningLevel, - authStorage: undefined as never, - authProfileStore: undefined as never, - modelRegistry: undefined as never, + authStorage: params.preparedRuntimeAuth.authStorage, + authProfileStore: params.preparedRuntimeAuth.authProfileStore, + modelRegistry: params.preparedRuntimeAuth.modelRegistry, + ...(params.preparedRuntimeAuth.resolvedApiKey + ? { resolvedApiKey: params.preparedRuntimeAuth.resolvedApiKey } + : {}), runId: options.runId, abortSignal: params.opts?.abortSignal, onAgentEvent: (event: { stream: string; data: Record }) => { diff --git a/extensions/openai/base-url.test.ts b/extensions/openai/base-url.test.ts index bc346dee214c..1dadb169d80d 100644 --- a/extensions/openai/base-url.test.ts +++ b/extensions/openai/base-url.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { canonicalizeCodexResponsesBaseUrl, + classifyOpenAIBaseUrl, isOpenAIApiBaseUrl, isOpenAICodexBaseUrl, + isOpenAIHttpsApiBaseUrl, OPENAI_API_BASE_URL, OPENAI_CODEX_RESPONSES_BASE_URL, resolveOpenAIDefaultBaseUrl, @@ -11,24 +13,62 @@ import { describe("openai base URL helpers", () => { it("recognizes direct OpenAI API routes", () => { + expect(isOpenAIApiBaseUrl("http://api.openai.com/v1")).toBe(false); expect(isOpenAIApiBaseUrl("https://api.openai.com")).toBe(true); expect(isOpenAIApiBaseUrl("https://api.openai.com/v1")).toBe(true); expect(isOpenAIApiBaseUrl("https://api.openai.com/v1/")).toBe(true); + expect(isOpenAIApiBaseUrl("https://api.openai.com:443/v1")).toBe(true); + expect(isOpenAIApiBaseUrl("https://api.openai.com./v1")).toBe(true); }); it("rejects proxy or unrelated API routes", () => { + expect(isOpenAIApiBaseUrl("ftp://api.openai.com/v1")).toBe(false); expect(isOpenAIApiBaseUrl("https://proxy.example.com/v1")).toBe(false); expect(isOpenAIApiBaseUrl("https://chatgpt.com/backend-api")).toBe(false); expect(isOpenAIApiBaseUrl(undefined)).toBe(false); }); + it("limits native transport hooks to HTTPS official routes", () => { + expect(isOpenAIHttpsApiBaseUrl("https://api.openai.com/v1")).toBe(true); + expect(isOpenAIHttpsApiBaseUrl("http://api.openai.com/v1")).toBe(false); + }); + + it("classifies exact HTTPS native endpoints as official", () => { + expect(classifyOpenAIBaseUrl(undefined)).toBe("unresolved"); + expect(classifyOpenAIBaseUrl("https://api.openai.com/v1")).toBe("platform"); + expect(classifyOpenAIBaseUrl("https://api.openai.com:443/v1")).toBe("platform"); + expect(classifyOpenAIBaseUrl("https://api.openai.com./v1")).toBe("platform"); + expect(classifyOpenAIBaseUrl("https://chatgpt.com/backend-api/codex/responses")).toBe( + "chatgpt", + ); + expect(classifyOpenAIBaseUrl("https://proxy.example.test/v1?tenant=one")).toBe("custom"); + for (const invalid of [ + "ftp://api.openai.com/v1", + "http://api.openai.com/v1", + "http://chatgpt.com/backend-api/codex", + "https://api.openai.com:8443/v1", + "http://api.openai.com:443/v1", + "https://user@api.openai.com/v1", + "https://api.openai.com/v1/models", + "https://api.openai.com/v1?proxy=1", + "https://chatgpt.com/backend-api/codex#fragment", + "not a URL", + ]) { + expect(classifyOpenAIBaseUrl(invalid)).toBe("invalid"); + } + }); + it("recognizes Codex ChatGPT backend routes", () => { // New canonical form (includes /codex segment; OpenAI removed the // /backend-api/responses alias server-side on 2026-04). expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/codex")).toBe(true); + expect(isOpenAICodexBaseUrl("http://chatgpt.com/backend-api/codex")).toBe(false); expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/codex/")).toBe(true); expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/codex/v1")).toBe(true); expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/codex/v1/")).toBe(true); + expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/codex/responses")).toBe(true); + expect(isOpenAICodexBaseUrl("https://chatgpt.com:443/backend-api/codex")).toBe(true); + expect(isOpenAICodexBaseUrl("https://chatgpt.com./backend-api/codex")).toBe(true); // Legacy form still recognized as a Codex baseURL for backward // compatibility with existing user configs. expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api")).toBe(true); @@ -38,6 +78,7 @@ describe("openai base URL helpers", () => { }); it("rejects non-Codex backend routes", () => { + expect(isOpenAICodexBaseUrl("ftp://chatgpt.com/backend-api/codex")).toBe(false); expect(isOpenAICodexBaseUrl("https://api.openai.com/v1")).toBe(false); expect(isOpenAICodexBaseUrl("https://chatgpt.com")).toBe(false); expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/v2")).toBe(false); @@ -55,6 +96,12 @@ describe("openai base URL helpers", () => { expect(canonicalizeCodexResponsesBaseUrl("https://chatgpt.com/backend-api/codex/v1")).toBe( OPENAI_CODEX_RESPONSES_BASE_URL, ); + expect( + canonicalizeCodexResponsesBaseUrl("https://chatgpt.com/backend-api/codex/responses"), + ).toBe(OPENAI_CODEX_RESPONSES_BASE_URL); + expect(canonicalizeCodexResponsesBaseUrl("http://chatgpt.com/backend-api/codex")).toBe( + "http://chatgpt.com/backend-api/codex", + ); expect(canonicalizeCodexResponsesBaseUrl("https://proxy.example.com/v1")).toBe( "https://proxy.example.com/v1", ); diff --git a/extensions/openai/base-url.ts b/extensions/openai/base-url.ts index be96c537b94a..a9d687c4bf4c 100644 --- a/extensions/openai/base-url.ts +++ b/extensions/openai/base-url.ts @@ -4,6 +4,66 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti export const OPENAI_CODEX_RESPONSES_BASE_URL = "https://chatgpt.com/backend-api/codex"; export const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; +type OpenAIEndpointKind = "unresolved" | "platform" | "chatgpt" | "custom" | "invalid"; + +const OPENAI_PLATFORM_PATHS = new Set(["/", "/v1", "/v1/"]); +const OPENAI_CHATGPT_PATHS = new Set([ + "/backend-api", + "/backend-api/", + "/backend-api/v1", + "/backend-api/v1/", + "/backend-api/codex", + "/backend-api/codex/", + "/backend-api/codex/v1", + "/backend-api/codex/v1/", + "/backend-api/codex/responses", + "/backend-api/codex/responses/", +]); + +/** Classifies exact native endpoints, valid custom URLs, and unsafe/invalid input. */ +export function classifyOpenAIBaseUrl(baseUrl: unknown): OpenAIEndpointKind { + if (baseUrl === undefined || baseUrl === null || baseUrl === "") { + return "unresolved"; + } + if (typeof baseUrl !== "string") { + return "invalid"; + } + const trimmed = baseUrl.trim(); + if (!trimmed) { + return "unresolved"; + } + try { + const url = new URL(trimmed); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + !url.hostname || + url.username || + url.password + ) { + return "invalid"; + } + const rawHost = url.hostname.toLowerCase(); + const host = rawHost.endsWith(".") ? rawHost.slice(0, -1) : rawHost; + if (host === "api.openai.com" || host === "chatgpt.com") { + // Official remote endpoints carry API keys or subscription bearers. + // Never reinterpret their plaintext form as an eligible native route. + if (url.protocol !== "https:" || url.port || url.search || url.hash) { + return "invalid"; + } + if (host === "api.openai.com" && OPENAI_PLATFORM_PATHS.has(url.pathname)) { + return "platform"; + } + if (host === "chatgpt.com" && OPENAI_CHATGPT_PATHS.has(url.pathname)) { + return "chatgpt"; + } + return "invalid"; + } + return "custom"; + } catch { + return "invalid"; + } +} + export function resolveOpenAIDefaultBaseUrl( env: Record = process.env, ): string { @@ -11,19 +71,19 @@ export function resolveOpenAIDefaultBaseUrl( } export function isOpenAIApiBaseUrl(baseUrl?: string): boolean { - const trimmed = normalizeOptionalString(baseUrl); - if (!trimmed) { - return false; - } - return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); + return classifyOpenAIBaseUrl(baseUrl) === "platform"; } export function isOpenAICodexBaseUrl(baseUrl?: string): boolean { - const trimmed = normalizeOptionalString(baseUrl); - if (!trimmed) { + return classifyOpenAIBaseUrl(baseUrl) === "chatgpt"; +} + +/** True only for an HTTPS OpenAI Platform endpoint eligible for native transport hooks. */ +export function isOpenAIHttpsApiBaseUrl(baseUrl?: string): boolean { + if (typeof baseUrl !== "string" || classifyOpenAIBaseUrl(baseUrl) !== "platform") { return false; } - return /^https?:\/\/chatgpt\.com\/backend-api(?:\/codex)?(?:\/v1)?\/?$/i.test(trimmed); + return new URL(baseUrl.trim()).protocol === "https:"; } export function canonicalizeCodexResponsesBaseUrl(baseUrl?: string): string | undefined { diff --git a/extensions/openai/model-route-contract.test.ts b/extensions/openai/model-route-contract.test.ts new file mode 100644 index 000000000000..9dffd596756e --- /dev/null +++ b/extensions/openai/model-route-contract.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + OPENAI_CHATGPT_MODERN_MODEL_IDS, + OPENAI_DUAL_ROUTE_MODEL_IDS, + OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS, + OPENAI_PROVIDER_MODERN_MODEL_IDS, + OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS, + isOpenAIDualRouteModelId, + isOpenAIPlatformOnlyRouteModelId, + isOpenAISubscriptionOnlyRouteModelId, + normalizeOpenAIModelRouteId, +} from "./model-route-contract.js"; +import { buildOpenAICodexProviderHooks } from "./openai-chatgpt-provider.js"; +import { buildOpenAIProvider } from "./openai-provider.js"; +import { resolveModelRoutes } from "./provider-policy-api.js"; + +function resolveUnconfiguredModel(modelId: string) { + return resolveModelRoutes({ + provider: "openai", + modelId, + env: {}, + }); +} + +describe("OpenAI model route contract", () => { + it("preserves custom model spelling while matching built-in routes case-insensitively", () => { + expect(normalizeOpenAIModelRouteId(" openai/Future-MODEL ")).toBe("openai/Future-MODEL"); + expect(normalizeOpenAIModelRouteId("future-model")).toBe("future-model"); + expect(normalizeOpenAIModelRouteId("GPT-5.4-CODEX")).toBe("gpt-5.4"); + + expect(isOpenAIDualRouteModelId("GPT-5.5")).toBe(true); + expect(isOpenAIPlatformOnlyRouteModelId("CHAT-LATEST")).toBe(true); + expect(isOpenAISubscriptionOnlyRouteModelId("GPT-5.3-CODEX-SPARK")).toBe(true); + }); + + it("keeps route eligibility aligned with both provider runtime surfaces", () => { + const provider = buildOpenAIProvider(); + const chatGPTHooks = buildOpenAICodexProviderHooks(); + const routeModelIds = [ + ...OPENAI_DUAL_ROUTE_MODEL_IDS, + ...OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS, + ...OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS, + ]; + + expect(new Set(routeModelIds).size).toBe(routeModelIds.length); + + for (const modelId of OPENAI_PROVIDER_MODERN_MODEL_IDS) { + expect(provider.isModernModelRef?.({ provider: "openai", modelId })).toBe(true); + } + for (const modelId of OPENAI_CHATGPT_MODERN_MODEL_IDS) { + expect(chatGPTHooks.isModernModelRef?.({ provider: "openai", modelId })).toBe(true); + } + + for (const modelId of OPENAI_DUAL_ROUTE_MODEL_IDS) { + const resolution = resolveUnconfiguredModel(modelId); + expect( + resolution.kind === "routes" ? resolution.routes.map((route) => route.api) : [], + ).toEqual(["openai-responses", "openai-chatgpt-responses"]); + } + for (const modelId of OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS) { + expect(resolveUnconfiguredModel(modelId)).toMatchObject({ + kind: "routes", + defaultRuntimeId: "codex", + routes: [{ api: "openai-responses", authRequirement: "api-key" }], + }); + } + for (const modelId of OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS) { + expect(resolveUnconfiguredModel(modelId)).toMatchObject({ + kind: "routes", + defaultRuntimeId: "codex", + routes: [{ api: "openai-chatgpt-responses", authRequirement: "subscription" }], + }); + } + }); +}); diff --git a/extensions/openai/model-route-contract.ts b/extensions/openai/model-route-contract.ts new file mode 100644 index 000000000000..38b5c82e7347 --- /dev/null +++ b/extensions/openai/model-route-contract.ts @@ -0,0 +1,88 @@ +// OpenAI model route membership shared by catalog and policy surfaces. + +export const OPENAI_CHAT_LATEST_MODEL_ID = "chat-latest"; +export const OPENAI_GPT_56_MODEL_ID = "gpt-5.6"; +export const OPENAI_GPT_56_SOL_MODEL_ID = "gpt-5.6-sol"; +export const OPENAI_GPT_56_TERRA_MODEL_ID = "gpt-5.6-terra"; +export const OPENAI_GPT_56_LUNA_MODEL_ID = "gpt-5.6-luna"; +export const OPENAI_GPT_55_MODEL_ID = "gpt-5.5"; +export const OPENAI_GPT_55_PRO_MODEL_ID = "gpt-5.5-pro"; +export const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; +export const OPENAI_GPT_54_LEGACY_MODEL_ID = "gpt-5.4-codex"; +export const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; +export const OPENAI_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini"; +export const OPENAI_GPT_54_NANO_MODEL_ID = "gpt-5.4-nano"; +export const OPENAI_GPT_53_CODEX_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; + +export const OPENAI_GPT_56_VARIANT_MODEL_IDS = [ + OPENAI_GPT_56_SOL_MODEL_ID, + OPENAI_GPT_56_TERRA_MODEL_ID, + OPENAI_GPT_56_LUNA_MODEL_ID, +] as const; + +/** Models with known first-party Platform and ChatGPT transports. */ +export const OPENAI_DUAL_ROUTE_MODEL_IDS = [ + ...OPENAI_GPT_56_VARIANT_MODEL_IDS, + OPENAI_GPT_55_MODEL_ID, + OPENAI_GPT_55_PRO_MODEL_ID, + OPENAI_GPT_54_MODEL_ID, + OPENAI_GPT_54_PRO_MODEL_ID, + OPENAI_GPT_54_MINI_MODEL_ID, +] as const; + +/** Direct aliases excluded from the ChatGPT catalog. */ +export const OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS = [ + OPENAI_CHAT_LATEST_MODEL_ID, + OPENAI_GPT_56_MODEL_ID, +] as const; + +export const OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS = [ + OPENAI_GPT_53_CODEX_SPARK_MODEL_ID, +] as const; + +/** Modern model refs recognized by the unified OpenAI provider surface. */ +export const OPENAI_PROVIDER_MODERN_MODEL_IDS = [ + ...OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS, + ...OPENAI_DUAL_ROUTE_MODEL_IDS, + OPENAI_GPT_54_NANO_MODEL_ID, + ...OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS, +] as const; + +export const OPENAI_CHATGPT_MODERN_MODEL_IDS = [ + ...OPENAI_DUAL_ROUTE_MODEL_IDS, + ...OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS, +] as const; + +const openAIDualRouteModelIds = new Set(OPENAI_DUAL_ROUTE_MODEL_IDS); +const openAIPlatformOnlyRouteModelIds = new Set(OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS); +const openAISubscriptionOnlyRouteModelIds = new Set( + OPENAI_SUBSCRIPTION_ONLY_ROUTE_MODEL_IDS, +); + +export function normalizeOpenAIModelRouteId(value: string | undefined): string { + const modelId = value?.trim() ?? ""; + // OpenAI-compatible model ids are case-sensitive. Collapse only the shipped + // legacy alias; configured custom ids must retain their authored identity. + const normalized = modelId.toLowerCase(); + return normalized === OPENAI_GPT_54_LEGACY_MODEL_ID || + normalized === `openai/${OPENAI_GPT_54_LEGACY_MODEL_ID}` + ? OPENAI_GPT_54_MODEL_ID + : modelId; +} + +function normalizeOpenAIRouteMembershipId(value: string | undefined): string { + // Static first-party membership is case-insensitive without changing catalog keys. + return normalizeOpenAIModelRouteId(value).toLowerCase(); +} + +export function isOpenAIDualRouteModelId(value: string | undefined): boolean { + return openAIDualRouteModelIds.has(normalizeOpenAIRouteMembershipId(value)); +} + +export function isOpenAIPlatformOnlyRouteModelId(value: string | undefined): boolean { + return openAIPlatformOnlyRouteModelIds.has(normalizeOpenAIRouteMembershipId(value)); +} + +export function isOpenAISubscriptionOnlyRouteModelId(value: string | undefined): boolean { + return openAISubscriptionOnlyRouteModelIds.has(normalizeOpenAIRouteMembershipId(value)); +} diff --git a/extensions/openai/openai-chatgpt-provider.ts b/extensions/openai/openai-chatgpt-provider.ts index 9748e3fdb06f..88ed3895799a 100644 --- a/extensions/openai/openai-chatgpt-provider.ts +++ b/extensions/openai/openai-chatgpt-provider.ts @@ -33,6 +33,17 @@ import { OPENAI_CODEX_RESPONSES_BASE_URL, } from "./base-url.js"; import { OPENAI_CODEX_DEFAULT_MODEL } from "./default-models.js"; +import { + OPENAI_CHATGPT_MODERN_MODEL_IDS, + OPENAI_GPT_53_CODEX_SPARK_MODEL_ID as OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, + OPENAI_GPT_54_LEGACY_MODEL_ID as OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID, + OPENAI_GPT_54_MINI_MODEL_ID as OPENAI_CODEX_GPT_54_MINI_MODEL_ID, + OPENAI_GPT_54_MODEL_ID as OPENAI_CODEX_GPT_54_MODEL_ID, + OPENAI_GPT_54_PRO_MODEL_ID as OPENAI_CODEX_GPT_54_PRO_MODEL_ID, + OPENAI_GPT_55_MODEL_ID as OPENAI_CODEX_GPT_55_MODEL_ID, + OPENAI_GPT_55_PRO_MODEL_ID as OPENAI_CODEX_GPT_55_PRO_MODEL_ID, + OPENAI_GPT_56_VARIANT_MODEL_IDS as OPENAI_CODEX_GPT_56_MODEL_IDS, +} from "./model-route-contract.js"; import { resolveCodexAuthIdentity } from "./openai-chatgpt-auth-identity.js"; import { loginOpenAICodexDeviceCode } from "./openai-chatgpt-device-code.js"; import { loginOpenAICodexOAuth } from "./openai-chatgpt-oauth.runtime.js"; @@ -50,19 +61,11 @@ const PROVIDER_ID = "openai"; const OPENAI_CODEX_BASE_URL = OPENAI_CODEX_RESPONSES_BASE_URL; const OPENAI_CODEX_LOGIN_ASSISTANT_PRIORITY = -30; const OPENAI_CODEX_DEVICE_PAIRING_ASSISTANT_PRIORITY = -10; -const OPENAI_CODEX_GPT_56_MODEL_IDS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] as const; const OPENAI_CODEX_GPT_56_THINKING_LEVEL_MAP = { off: null, xhigh: "xhigh", max: "max", } as const; -const OPENAI_CODEX_GPT_55_MODEL_ID = "gpt-5.5"; -const OPENAI_CODEX_GPT_55_PRO_MODEL_ID = "gpt-5.5-pro"; -const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4"; -const OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID = "gpt-5.4-codex"; -const OPENAI_CODEX_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini"; -const OPENAI_CODEX_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; -const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; const OPENAI_CODEX_GPT_56_CONTEXT_TOKENS = 372_000; const OPENAI_CODEX_GPT_55_CODEX_CONTEXT_TOKENS = 400_000; const OPENAI_CODEX_GPT_55_DEFAULT_RUNTIME_CONTEXT_TOKENS = 272_000; @@ -108,15 +111,6 @@ const OPENAI_CODEX_GPT_55_PRO_TEMPLATE_MODEL_IDS = [ OPENAI_CODEX_GPT_54_PRO_MODEL_ID, ...OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS, ] as const; -const OPENAI_CODEX_MODERN_MODEL_IDS = [ - ...OPENAI_CODEX_GPT_56_MODEL_IDS, - OPENAI_CODEX_GPT_55_MODEL_ID, - OPENAI_CODEX_GPT_55_PRO_MODEL_ID, - OPENAI_CODEX_GPT_54_MODEL_ID, - OPENAI_CODEX_GPT_54_PRO_MODEL_ID, - OPENAI_CODEX_GPT_54_MINI_MODEL_ID, - OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, -] as const; const OPENAI_CODEX_IMAGE_CAPABLE_MODEL_IDS = [ ...OPENAI_CODEX_GPT_56_MODEL_IDS, OPENAI_CODEX_GPT_55_MODEL_ID, @@ -638,21 +632,14 @@ export function buildOpenAICodexProviderHooks(): Pick< buildAuthDoctorHint: (ctx) => buildOpenAICodexAuthDoctorHint(ctx), resolveThinkingProfile: ({ modelId, agentRuntime, compat }) => resolveOpenAICodexThinkingProfile(modelId, agentRuntime, compat), - isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS), + isModernModelRef: ({ modelId }) => + matchesExactOrPrefix(modelId, OPENAI_CHATGPT_MODERN_MODEL_IDS), preferRuntimeResolvedModel: (ctx) => { if (!isOpenAIOrLegacyCodexProvider(ctx.provider)) { return false; } const id = ctx.modelId.trim().toLowerCase(); - return [ - ...OPENAI_CODEX_GPT_56_MODEL_IDS, - OPENAI_CODEX_GPT_55_MODEL_ID, - OPENAI_CODEX_GPT_55_PRO_MODEL_ID, - OPENAI_CODEX_GPT_54_MODEL_ID, - OPENAI_CODEX_GPT_54_PRO_MODEL_ID, - OPENAI_CODEX_GPT_54_MINI_MODEL_ID, - OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, - ].includes(id); + return OPENAI_CHATGPT_MODERN_MODEL_IDS.some((modelId) => modelId === id); }, ...buildOpenAIResponsesProviderHooks(), resolveReasoningOutputMode: () => "native", diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 8feeaace0b88..78ae814bb804 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -5,7 +5,8 @@ import { clearLiveCatalogCacheForTests, type LiveModelCatalogFetchGuard, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OPENAI_API_BASE_URL, OPENAI_CODEX_RESPONSES_BASE_URL } from "./base-url.js"; import { OPENAI_CODEX_DEFAULT_MODEL, OPENAI_DEFAULT_MODEL } from "./default-models.js"; import { buildOpenAICodexLiveProviderConfig, @@ -13,6 +14,7 @@ import { buildOpenAIProvider, } from "./openai-provider.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; +import { resolveModelRoutes } from "./provider-policy-api.js"; const mocks = vi.hoisted(() => ({ refreshOpenAICodexToken: vi.fn(), @@ -149,6 +151,10 @@ describe("buildOpenAIProvider", () => { }); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("exposes grouped model/auth picker labels for API key setup", () => { const provider = buildOpenAIProvider(); const apiKey = provider.auth.find((method) => method.id === "api-key"); @@ -392,9 +398,21 @@ describe("buildOpenAIProvider", () => { expect(fetchGuard).not.toHaveBeenCalled(); expect(provider.baseUrl).toBe(customBaseUrl); + expect(provider.api).toBe("openai-responses"); expect(provider.apiKey).toBe("sk-custom-openai-compatible"); const apiModel = provider.models.find((model) => model.api !== "openai-chatgpt-responses"); expect(apiModel?.baseUrl).toBe(customBaseUrl); + expect( + resolveModelRoutes({ + provider: "openai", + modelId: apiModel?.id, + configuredProvider: { api: provider.api, baseUrl: customBaseUrl }, + observedRoutes: apiModel ? [{ api: apiModel.api, baseUrl: apiModel.baseUrl }] : [], + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: provider.api, baseUrl: customBaseUrl }], + }); }); it("uses the Codex backend catalog for OpenAI OAuth discovery", async () => { @@ -839,6 +857,177 @@ describe("buildOpenAIProvider", () => { }); }); + it("upgrades catalog Completions metadata but preserves authored official adapters", () => { + const provider = buildOpenAIProvider(); + const transport = { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + } as const; + + for (const baseUrl of [ + "https://api.openai.com/v1", + "https://api.openai.com:443/v1", + "https://api.openai.com./v1", + ]) { + expect(provider.normalizeTransport?.({ ...transport, baseUrl } as never)).toEqual({ + api: "openai-responses", + baseUrl, + }); + } + expect( + provider.normalizeTransport?.({ + ...transport, + baseUrl: "http://api.openai.com/v1", + } as never), + ).toBeUndefined(); + for (const config of [ + { + models: { + providers: { + openai: { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }, + { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5.5", api: "openai-completions" }], + }, + }, + }, + }, + { + models: { + providers: { + openai: { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5.5", baseUrl: "https://api.openai.com/v1" }], + }, + }, + }, + }, + { + models: { + providers: { + OpenAI: { + api: "openai-responses", + baseUrl: "https://case-distinct.example/v1", + models: [], + }, + openai: { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }, + ]) { + expect(provider.normalizeTransport?.({ ...transport, config } as never)).toBeUndefined(); + expect( + provider.normalizeResolvedModel?.({ + ...transport, + config, + model: { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + }, + } as never), + ).toMatchObject({ api: "openai-completions" }); + } + + const legacyAliasConfig = { + models: { + providers: { + openai: { + api: "openai-responses", + models: [{ id: "OpenAI/GPT-5.4-CODEX", api: "openai-completions" }], + }, + }, + }, + }; + expect( + provider.normalizeTransport?.({ + ...transport, + modelId: "gpt-5.4", + config: legacyAliasConfig, + } as never), + ).toBeUndefined(); + + expect( + provider.normalizeTransport?.({ + ...transport, + provider: "OpenAI", + config: { + models: { + providers: { + OpenAI: { api: "openai-responses", models: [] }, + openai: { api: "openai-completions", models: [] }, + }, + }, + }, + } as never), + ).toEqual({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + }); + + it("lets an authored Completions route replace observed ChatGPT transport metadata", () => { + vi.stubEnv("OPENAI_BASE_URL", ""); + const provider = buildOpenAIProvider(); + const config = { + models: { + providers: { + openai: { + api: "openai-completions", + models: [{ id: "gpt-5.5" }], + }, + }, + }, + }; + const observedTransport = { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL, + config, + } as const; + + expect(provider.normalizeTransport?.(observedTransport as never)).toEqual({ + api: "openai-completions", + baseUrl: OPENAI_API_BASE_URL, + }); + expect( + provider.normalizeResolvedModel?.({ + ...observedTransport, + model: { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL, + }, + } as never), + ).toMatchObject({ + api: "openai-completions", + baseUrl: OPENAI_API_BASE_URL, + }); + }); + it("resolves gpt-5.4 mini and nano from GPT-5 small-model templates", () => { const provider = buildOpenAIProvider(); const registry = { @@ -972,7 +1161,7 @@ describe("buildOpenAIProvider", () => { ).toBe("native"); }); - it("routes GPT forward-compat models by selected OpenAI auth mode", () => { + it("routes GPT forward-compat models by the projected route, not profile order", () => { const provider = buildOpenAIProvider(); const openaiModel = provider.resolveDynamicModel?.({ @@ -983,30 +1172,47 @@ describe("buildOpenAIProvider", () => { auth: "api-key", }, } as never); - const codexModel = provider.resolveDynamicModel?.({ + const unselectedPlatformModel = provider.resolveDynamicModel?.({ provider: "openai", - modelId: "gpt-5.4", + modelId: "gpt-5.6", modelRegistry: { find: () => null }, + authProfileId: "openai:oauth", + authProfileMode: "oauth", config: { auth: { profiles: { - "openai:default": { + "openai:oauth": { provider: "openai", mode: "oauth", }, + "openai:api-key": { + provider: "openai", + mode: "api_key", + }, }, order: { - openai: ["openai:default"], + openai: ["openai:oauth", "openai:api-key"], }, }, }, } as never); + const unprojectedOauthModel = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.4", + modelRegistry: { find: () => null }, + authProfileId: "openai:oauth", + authProfileMode: "oauth", + } as never); const selectedOauthModel = provider.resolveDynamicModel?.({ provider: "openai", modelId: "gpt-5.4", modelRegistry: { find: () => null }, authProfileId: "openai:work", authProfileMode: "oauth", + providerConfig: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, } as never); expectFields(openaiModel, { @@ -1017,14 +1223,20 @@ describe("buildOpenAIProvider", () => { contextWindow: 1_050_000, maxTokens: 128_000, }); - expectFields(codexModel, { + expectFields(unselectedPlatformModel, { provider: "openai", - id: "gpt-5.4", - api: "openai-chatgpt-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", + id: "gpt-5.6", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", contextWindow: 1_050_000, maxTokens: 128_000, }); + expectFields(unprojectedOauthModel, { + provider: "openai", + id: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); expectFields(selectedOauthModel, { provider: "openai", id: "gpt-5.4", @@ -1035,6 +1247,41 @@ describe("buildOpenAIProvider", () => { }); }); + it("keeps HTTP Platform routes out of Codex transport gates", () => { + const provider = buildOpenAIProvider(); + const baseUrl = "http://api.openai.com/v1"; + const providerConfig = { + api: "openai-responses", + baseUrl, + models: [], + } as const; + + const model = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.4", + modelRegistry: { find: () => null }, + authProfileMode: "oauth", + providerConfig, + } as never); + expect(model?.api).toBe("openai-responses"); + + expect( + provider.prepareExtraParams?.({ + provider: "openai", + modelId: "gpt-5.4", + extraParams: { effort: "high" }, + config: { + models: { providers: { openai: providerConfig } }, + auth: { + profiles: { + "openai:default": { provider: "openai", mode: "oauth" }, + }, + }, + }, + } as never), + ).toEqual({ effort: "high", transport: "sse" }); + }); + it("restores gpt-5.3-codex-spark only through ChatGPT/Codex OAuth routing", () => { const provider = buildOpenAIProvider(); @@ -1051,6 +1298,8 @@ describe("buildOpenAIProvider", () => { modelRegistry: { find: () => null }, providerConfig: { auth: "api-key", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", }, } as never); const runtimeModel = provider.resolveDynamicModel?.({ @@ -1066,6 +1315,10 @@ describe("buildOpenAIProvider", () => { agentRuntimeId: "codex", authProfileId: "openai:api-key", authProfileMode: "api_key", + providerConfig: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, } as never); const unknownModelHint = provider.buildUnknownModelHint?.({ provider: "openai", @@ -1754,7 +2007,7 @@ describe("buildOpenAIProvider", () => { ).toBe(explicit); }); - it("defaults Codex responses transport without forcing extra flags", () => { + it("does not infer Codex transport from an unselected OAuth profile", () => { const provider = buildOpenAIProvider(); expect( @@ -1775,7 +2028,7 @@ describe("buildOpenAIProvider", () => { } as never), ).toEqual({ effort: "high", - transport: "auto", + transport: "sse", }); expect( provider.prepareExtraParams?.({ diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 87db4dc2d717..5462b6145973 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -25,8 +25,9 @@ import { import { OPENAI_ACCOUNT_WIZARD_GROUP, OPENAI_API_KEY_LABEL } from "./auth-choice-copy.js"; import { OPENAI_CODEX_RESPONSES_BASE_URL, - isOpenAIApiBaseUrl, + classifyOpenAIBaseUrl, isOpenAICodexBaseUrl, + isOpenAIHttpsApiBaseUrl, resolveOpenAIDefaultBaseUrl, } from "./base-url.js"; import { @@ -34,11 +35,30 @@ import { OPENAI_CODEX_DEFAULT_MODEL, OPENAI_DEFAULT_MODEL, } from "./default-models.js"; +import { + OPENAI_CHAT_LATEST_MODEL_ID, + OPENAI_GPT_53_CODEX_SPARK_MODEL_ID, + OPENAI_GPT_54_MINI_MODEL_ID, + OPENAI_GPT_54_MODEL_ID, + OPENAI_GPT_54_NANO_MODEL_ID, + OPENAI_GPT_54_PRO_MODEL_ID, + OPENAI_GPT_55_MODEL_ID, + OPENAI_GPT_55_PRO_MODEL_ID, + OPENAI_GPT_56_LUNA_MODEL_ID, + OPENAI_GPT_56_MODEL_ID, + OPENAI_GPT_56_SOL_MODEL_ID, + OPENAI_GPT_56_TERRA_MODEL_ID, + OPENAI_PROVIDER_MODERN_MODEL_IDS, + isOpenAIPlatformOnlyRouteModelId, + isOpenAISubscriptionOnlyRouteModelId, + normalizeOpenAIModelRouteId, +} from "./model-route-contract.js"; import { buildOpenAIChatGPTAuthMethods, buildOpenAICodexProviderHooks, } from "./openai-chatgpt-provider.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; +import { resolveModelRoutes } from "./provider-policy-api.js"; import { buildOpenAIResponsesProviderHooks, buildOpenAISyntheticCatalogEntry, @@ -53,18 +73,6 @@ const OPENAI_MODELS_ENDPOINT = "https://api.openai.com/v1/models"; const OPENAI_CODEX_MODELS_ENDPOINT = `${OPENAI_CODEX_RESPONSES_BASE_URL}/models?client_version=1.0.0`; const OPENAI_MODELS_CACHE_TTL_MS = 60_000; const OPENAI_CODEX_MODELS_CACHE_TTL_MS = 60_000; -const OPENAI_CHAT_LATEST_MODEL_ID = "chat-latest"; -const OPENAI_GPT_56_MODEL_ID = "gpt-5.6"; -const OPENAI_GPT_56_SOL_MODEL_ID = "gpt-5.6-sol"; -const OPENAI_GPT_56_TERRA_MODEL_ID = "gpt-5.6-terra"; -const OPENAI_GPT_56_LUNA_MODEL_ID = "gpt-5.6-luna"; -const OPENAI_GPT_55_MODEL_ID = "gpt-5.5"; -const OPENAI_GPT_55_PRO_MODEL_ID = "gpt-5.5-pro"; -const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; -const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; -const OPENAI_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini"; -const OPENAI_GPT_54_NANO_MODEL_ID = "gpt-5.4-nano"; -const OPENAI_GPT_53_CODEX_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; const OPENAI_GPT_56_DIRECT_CONTEXT_TOKENS = 1_050_000; const OPENAI_CODEX_GPT_56_CONTEXT_TOKENS = 372_000; const OPENAI_GPT_55_CONTEXT_WINDOW = 1_000_000; @@ -131,20 +139,6 @@ const OPENAI_GPT_56_THINKING_LEVEL_MAP = { xhigh: "xhigh", max: "max", } as const; -const OPENAI_MODERN_MODEL_IDS = [ - OPENAI_CHAT_LATEST_MODEL_ID, - OPENAI_GPT_56_MODEL_ID, - OPENAI_GPT_56_SOL_MODEL_ID, - OPENAI_GPT_56_TERRA_MODEL_ID, - OPENAI_GPT_56_LUNA_MODEL_ID, - OPENAI_GPT_55_MODEL_ID, - OPENAI_GPT_55_PRO_MODEL_ID, - OPENAI_GPT_54_MODEL_ID, - OPENAI_GPT_54_PRO_MODEL_ID, - OPENAI_GPT_54_MINI_MODEL_ID, - OPENAI_GPT_54_NANO_MODEL_ID, - OPENAI_GPT_53_CODEX_SPARK_MODEL_ID, -] as const; const OPENAI_UNKNOWN_MODEL_COST = { input: 0, output: 0, @@ -167,7 +161,7 @@ type BuildOpenAILiveProviderConfigParams = { }; function shouldFetchOpenAILiveModels(baseUrl: string): boolean { - return /^https:/i.test(baseUrl) && isOpenAIApiBaseUrl(baseUrl); + return isOpenAIHttpsApiBaseUrl(baseUrl); } function buildOpenAIManifestModelsForBaseUrl(baseUrl: string): ModelDefinitionConfig[] { @@ -507,17 +501,101 @@ function resolveOpenAICatalogBaseUrl(ctx: { function shouldUseOpenAIResponsesTransport(params: { provider: string; + modelId?: string; api?: string | null; baseUrl?: string; + config?: { models?: { providers?: Record } }; }): boolean { if (params.api !== "openai-completions") { return false; } const isOwnerProvider = normalizeProviderId(params.provider) === PROVIDER_ID; + const isPlatformEndpoint = + typeof params.baseUrl === "string" && classifyOpenAIBaseUrl(params.baseUrl) === "platform"; if (isOwnerProvider) { - return !params.baseUrl || isOpenAIApiBaseUrl(params.baseUrl); + if (resolveAuthoredOpenAICompletionsRoute(params)) { + return false; + } + return !params.baseUrl || isPlatformEndpoint; } - return typeof params.baseUrl === "string" && isOpenAIApiBaseUrl(params.baseUrl); + return isPlatformEndpoint; +} + +/** Resolves the effective authored OpenAI config route for one model. */ +function resolveAuthoredOpenAIConfigRoute(params: { + provider: string; + modelId?: string; + config?: { models?: { providers?: Record } }; +}): + | { configuredModel?: ModelDefinitionConfig; configuredProvider: ModelProviderConfig } + | undefined { + if (normalizeProviderId(params.provider) !== PROVIDER_ID) { + return undefined; + } + const providers = Object.entries(params.config?.models?.providers ?? {}); + const requestedProvider = params.provider.trim(); + const providerKey = + providers.find(([providerId]) => providerId.trim() === requestedProvider)?.[0].trim() ?? + providers.find(([providerId]) => normalizeProviderId(providerId) === PROVIDER_ID)?.[0].trim(); + let providerConfig: ModelProviderConfig | undefined; + for (const [providerId, candidate] of providers) { + if (providerId.trim() !== providerKey || !candidate) { + continue; + } + providerConfig = providerConfig + ? { + ...providerConfig, + ...candidate, + models: candidate.models ?? providerConfig.models, + } + : candidate; + } + if (!providerConfig) { + return undefined; + } + const modelId = normalizeOpenAIModelRouteId(params.modelId); + let modelConfig: ModelDefinitionConfig | undefined; + for (const model of providerConfig.models ?? []) { + if (normalizeOpenAIModelRouteId(model.id) !== modelId) { + continue; + } + // Match config normalization: the first row stays authoritative while + // later duplicate rows fill fields the first row omitted. + modelConfig = modelConfig ? { ...model, ...modelConfig } : model; + } + return { + ...(modelConfig ? { configuredModel: modelConfig } : {}), + configuredProvider: providerConfig, + }; +} + +/** Authored Completions is a current transport contract; only catalog defaults are upgraded. */ +function resolveAuthoredOpenAICompletionsRoute(params: { + provider: string; + modelId?: string; + config?: { models?: { providers?: Record } }; +}): { api: "openai-completions"; baseUrl: string } | undefined { + const configuredRoute = resolveAuthoredOpenAIConfigRoute(params); + if (!configuredRoute) { + return undefined; + } + const effectiveApi = + normalizeOptionalString(configuredRoute.configuredModel?.api) ?? + normalizeOptionalString(configuredRoute.configuredProvider.api); + if (effectiveApi !== "openai-completions") { + return undefined; + } + const resolution = resolveModelRoutes({ + provider: params.provider, + modelId: params.modelId, + ...configuredRoute, + env: process.env, + }); + if (resolution.kind !== "routes") { + return undefined; + } + const route = resolution.routes.find((candidate) => candidate.api === "openai-completions"); + return route ? { api: "openai-completions", baseUrl: route.baseUrl } : undefined; } function isOpenAIProvider(provider: string | undefined): boolean { @@ -525,11 +603,19 @@ function isOpenAIProvider(provider: string | undefined): boolean { return normalized === PROVIDER_ID; } -function normalizeOpenAITransport(model: ProviderRuntimeModel): ProviderRuntimeModel { +function normalizeOpenAITransport( + model: ProviderRuntimeModel, + context?: { + modelId?: string; + config?: { models?: { providers?: Record } }; + }, +): ProviderRuntimeModel { const useResponsesTransport = shouldUseOpenAIResponsesTransport({ provider: model.provider, + modelId: context?.modelId, api: model.api, baseUrl: model.baseUrl, + config: context?.config, }); if (!useResponsesTransport) { @@ -553,19 +639,10 @@ function shouldUseCodexResponsesHooks(params: { return typeof params.baseUrl === "string" && isOpenAICodexBaseUrl(params.baseUrl); } -function resolveConfiguredAuthTransport( - ctx: Pick< - ProviderResolveDynamicModelContext, - "authProfileId" | "authProfileMode" | "config" | "providerConfig" - >, +function resolveConfiguredProviderAuthTransport( + providerConfig: ProviderResolveDynamicModelContext["providerConfig"], ) { - if (ctx.authProfileMode === "oauth" || ctx.authProfileMode === "token") { - return "codex"; - } - if (ctx.authProfileMode === "api_key" || ctx.authProfileMode === "aws-sdk") { - return "responses"; - } - const authMode = ctx.providerConfig?.auth; + const authMode = providerConfig?.auth; if (authMode === "oauth" || authMode === "token") { return "codex"; } @@ -573,28 +650,6 @@ function resolveConfiguredAuthTransport( return "responses"; } - const auth = ctx.config?.auth; - const profiles = auth?.profiles ?? {}; - const orderedProfileIds = auth?.order?.[PROVIDER_ID] ?? []; - for (const profileId of orderedProfileIds) { - const mode = profiles[profileId]?.mode; - if (mode === "oauth" || mode === "token") { - return "codex"; - } - if (mode === "api_key") { - return "responses"; - } - } - - const providerModes = Object.values(profiles) - .filter((profile) => normalizeProviderId(profile.provider) === PROVIDER_ID) - .map((profile) => profile.mode); - if (providerModes.some((mode) => mode === "oauth" || mode === "token")) { - return "codex"; - } - if (providerModes.includes("api_key")) { - return "responses"; - } return undefined; } @@ -608,12 +663,21 @@ function shouldResolveDynamicModelThroughCodex(ctx: ProviderResolveDynamicModelC ) { return true; } - if (ctx.providerConfig?.baseUrl && !isOpenAIApiBaseUrl(ctx.providerConfig.baseUrl)) { + if ( + ctx.providerConfig?.api === "openai-responses" || + ctx.providerConfig?.api === "openai-completions" || + (ctx.providerConfig?.baseUrl && !isOpenAICodexBaseUrl(ctx.providerConfig.baseUrl)) + ) { return false; } - const authTransport = resolveConfiguredAuthTransport(ctx); - if (authTransport) { - return authTransport === "codex"; + // The auth planner owns profile ordering and projects the selected physical + // route into providerConfig before materialization. Until then, only a + // one-route model contract may choose a transport. + if (isOpenAIPlatformOnlyRouteModelId(ctx.modelId)) { + return false; + } + if (isOpenAISubscriptionOnlyRouteModelId(ctx.modelId)) { + return true; } return ctx.agentRuntimeId === "codex"; } @@ -877,6 +941,10 @@ export function buildOpenAIProvider(): ProviderPlugin { if (!isOpenAIProvider(ctx.provider)) { return undefined; } + const authoredCompletionsRoute = resolveAuthoredOpenAICompletionsRoute(ctx); + if (authoredCompletionsRoute) { + return { ...ctx.model, ...authoredCompletionsRoute }; + } if ( shouldUseCodexResponsesHooks({ provider: ctx.provider, @@ -886,9 +954,16 @@ export function buildOpenAIProvider(): ProviderPlugin { ) { return codexHooks.normalizeResolvedModel?.(ctx); } - return normalizeOpenAITransport(ctx.model); + return normalizeOpenAITransport(ctx.model, ctx); }, normalizeTransport: (ctx) => { + const authoredCompletionsRoute = resolveAuthoredOpenAICompletionsRoute(ctx); + if (authoredCompletionsRoute) { + return ctx.api === authoredCompletionsRoute.api && + ctx.baseUrl === authoredCompletionsRoute.baseUrl + ? undefined + : authoredCompletionsRoute; + } if (shouldUseCodexResponsesHooks(ctx)) { return codexHooks.normalizeTransport?.(ctx); } @@ -906,11 +981,8 @@ export function buildOpenAIProvider(): ProviderPlugin { baseUrl: ctx.model?.baseUrl, }) || (normalizeProviderId(ctx.provider) === PROVIDER_ID && - (!providerConfig?.baseUrl || isOpenAIApiBaseUrl(providerConfig.baseUrl)) && - resolveConfiguredAuthTransport({ - config: ctx.config, - providerConfig, - }) === "codex"); + (!providerConfig?.baseUrl || isOpenAIHttpsApiBaseUrl(providerConfig.baseUrl)) && + resolveConfiguredProviderAuthTransport(providerConfig) === "codex"); return (useCodexTransport ? codexResponsesHooks : responsesHooks).prepareExtraParams?.(ctx); }, resolveUsageAuth: codexHooks.resolveUsageAuth, @@ -933,7 +1005,8 @@ export function buildOpenAIProvider(): ProviderPlugin { normalizeProviderId(provider) === PROVIDER_ID ? resolveUnifiedOpenAIThinkingProfile(modelId, agentRuntime, compat) : null, - isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_MODERN_MODEL_IDS), + isModernModelRef: ({ modelId }) => + matchesExactOrPrefix(modelId, OPENAI_PROVIDER_MODERN_MODEL_IDS), augmentModelCatalog: (ctx) => { const openAiGpt55ProTemplate = findCatalogTemplate({ entries: ctx.entries, diff --git a/extensions/openai/provider-policy-api.test.ts b/extensions/openai/provider-policy-api.test.ts index 35db9603787d..047510addc75 100644 --- a/extensions/openai/provider-policy-api.test.ts +++ b/extensions/openai/provider-policy-api.test.ts @@ -1,8 +1,39 @@ // Openai tests cover provider policy api plugin behavior. -import { describe, expect, it } from "vitest"; -import { resolveThinkingProfile } from "./provider-policy-api.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + normalizeModelCatalogId, + resolveModelRoutes, + resolveThinkingProfile, +} from "./provider-policy-api.js"; describe("OpenAI provider policy artifact", () => { + beforeEach(() => { + vi.stubEnv("OPENAI_BASE_URL", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("normalizes the legacy Codex model alias at the provider boundary", () => { + expect(normalizeModelCatalogId({ provider: " OpenAI ", modelId: "openai/GPT-5.4-CODEX" })).toBe( + "gpt-5.4", + ); + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "openai/gpt-5.4-codex", + env: {}, + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-responses" }, { api: "openai-chatgpt-responses" }], + }); + expect(normalizeModelCatalogId({ provider: "openai", modelId: "openai/acme-model" })).toBe( + "openai/acme-model", + ); + }); + it("keeps OpenAI thinking policy for openai refs", () => { const codexProfile = resolveThinkingProfile({ provider: "openai", @@ -163,4 +194,806 @@ describe("OpenAI provider policy artifact", () => { expect(levels).toContain("max"); expect(levels).not.toContain("ultra"); }); + it("orders Platform before ChatGPT for unconfigured routable models", () => { + const expected = { + kind: "routes", + defaultRuntimeId: "codex", + routes: [ + { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + ], + } as const; + expect(resolveModelRoutes({ provider: "openai", modelId: "gpt-5.5" })).toEqual(expected); + for (const observed of [ + { api: "openai-responses", baseUrl: "https://api.openai.com/v1" }, + { api: "openai-completions", baseUrl: "https://api.openai.com/v1" }, + { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + ] as const) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + observedRoutes: [observed], + }), + ).toEqual(expected); + } + }); + + it.each(["gpt-5.4-nano", "gpt-future-observed"])( + "groups reversed physical routes for unknown logical model %s", + (modelId) => { + const platform = { + api: "openai-responses" as const, + baseUrl: "https://api.openai.com/v1", + }; + const chatGPT = { + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + const forward = resolveModelRoutes({ + provider: "openai", + modelId, + observedRoutes: [platform, chatGPT], + }); + const reversed = resolveModelRoutes({ + provider: "openai", + modelId, + observedRoutes: [chatGPT, platform], + }); + + expect(reversed).toEqual(forward); + expect(forward).toMatchObject({ + kind: "routes", + routes: [ + { api: "openai-responses", authRequirement: "api-key" }, + { api: "openai-chatgpt-responses", authRequirement: "subscription" }, + ], + }); + }, + ); + + it("deduplicates equivalent custom URLs independently of observation order", () => { + const withoutSlash = { + api: "openai-responses" as const, + baseUrl: "https://relay.example.test:443/v1", + }; + const withSlash = { + api: "openai-responses" as const, + baseUrl: "https://relay.example.test/v1/", + }; + const forward = resolveModelRoutes({ + provider: "openai", + modelId: "gpt-future-observed", + observedRoutes: [withoutSlash, withSlash], + }); + const reversed = resolveModelRoutes({ + provider: "openai", + modelId: "gpt-future-observed", + observedRoutes: [withSlash, withoutSlash], + }); + + expect(reversed).toEqual(forward); + expect(forward).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-responses", authRequirement: "api-key" }], + }); + expect(forward.kind === "routes" ? forward.routes : []).toHaveLength(1); + }); + + it("rejects plaintext observations beside HTTPS routes", () => { + const httpsRoute = { + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + const httpRoute = { + api: "openai-chatgpt-responses" as const, + baseUrl: "http://chatgpt.com/backend-api/codex", + }; + for (const observedRoutes of [ + [httpsRoute, httpRoute], + [httpRoute, httpsRoute], + ]) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-future-observed", + observedRoutes, + }), + ).toMatchObject({ kind: "incompatible", code: "invalid-openai-base-url" }); + } + }); + + it("carries prepared request transport behavior across every candidate", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + requestTransportOverrides: "present", + }), + ).toMatchObject({ + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [ + { + requestTransportOverrides: "present", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + { + requestTransportOverrides: "present", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + ], + }); + }); + + it("lets authored model routes lock provider, environment, and observed facts", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredModel: { + api: "openai-responses", + baseUrl: "https://model.example.test/v1", + }, + configuredProvider: { + api: "openai-chatgpt-responses", + baseUrl: "https://provider.example.test/v1", + }, + env: { OPENAI_BASE_URL: "https://env.example.test/v1" }, + observedRoutes: [ + { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + ], + }), + ).toEqual({ + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [ + { + api: "openai-responses", + baseUrl: "https://model.example.test/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + ], + }); + }); + + it("preserves custom ChatGPT relays as subscription routes", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredModel: { + api: "openai-chatgpt-responses", + baseUrl: "https://proxy.example.test/v1", + }, + }), + ).toEqual({ + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [ + { + api: "openai-chatgpt-responses", + baseUrl: "https://proxy.example.test/v1", + authRequirement: "subscription", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + ], + }); + }); + + it("preserves configured versus environment custom transport defaults", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredProvider: { baseUrl: "https://configured.example.test/v1" }, + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-completions", authRequirement: "api-key" }], + }); + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + env: { OPENAI_BASE_URL: "https://env.example.test/v1" }, + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-responses", authRequirement: "api-key" }], + }); + }); + + it("rejects unsupported observed adapters for authored custom endpoints", () => { + for (const observedRoutes of [ + [{ api: "anthropic-messages" as const }], + [{ api: "openai-responses" as const }, { api: "anthropic-messages" as const }], + ]) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredProvider: { baseUrl: "https://configured.example.test/v1" }, + observedRoutes, + }), + ).toMatchObject({ + kind: "incompatible", + code: "unsupported-custom-openai-api", + }); + } + }); + + it("rejects conflicting observed Platform adapters regardless of order", () => { + for (const observedRoutes of [ + [{ api: "openai-responses" as const }, { api: "openai-completions" as const }], + [{ api: "openai-completions" as const }, { api: "openai-responses" as const }], + ]) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredProvider: { baseUrl: "https://configured.example.test/v1" }, + observedRoutes, + }), + ).toMatchObject({ + kind: "incompatible", + code: "ambiguous-openai-route-group", + }); + } + }); + + it("ignores unauthored ChatGPT observations beside a Platform adapter", () => { + for (const observedRoutes of [ + [{ api: "openai-chatgpt-responses" as const }, { api: "openai-responses" as const }], + [{ api: "openai-responses" as const }, { api: "openai-chatgpt-responses" as const }], + ]) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredProvider: { baseUrl: "https://configured.example.test/v1" }, + observedRoutes, + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-responses", authRequirement: "api-key" }], + }); + } + }); + + it("owns OPENAI_BASE_URL interpretation", () => { + vi.stubEnv("OPENAI_BASE_URL", "https://process-env.example.test/v1"); + + expect(resolveModelRoutes({ provider: "openai", modelId: "gpt-5.5" })).toMatchObject({ + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [ + { + api: "openai-responses", + baseUrl: "https://process-env.example.test/v1", + authRequirement: "api-key", + }, + ], + }); + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + env: { OPENAI_BASE_URL: "https://injected-env.example.test/v1" }, + }), + ).toMatchObject({ + kind: "routes", + routes: [{ baseUrl: "https://injected-env.example.test/v1" }], + }); + }); + + it("uses only API-key observed adapters for independently authored custom endpoints", () => { + for (const [configured, observedApi] of [ + [ + { configuredProvider: { baseUrl: "https://configured.example.test/v1" } }, + "openai-completions", + ], + [{ env: { OPENAI_BASE_URL: "https://env.example.test/v1" } }, "openai-responses"], + ] as const) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + ...configured, + observedRoutes: [{ api: observedApi }], + }), + ).toMatchObject({ + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [{ api: observedApi, authRequirement: "api-key" }], + }); + } + }); + + it("ignores unrelated observed adapter conflicts for complete authored routes", () => { + const observedRoutes = [ + { api: "openai-responses" as const }, + { api: "openai-completions" as const }, + ]; + + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredModel: { api: "openai-chatgpt-responses" }, + observedRoutes, + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-chatgpt-responses", authRequirement: "subscription" }], + }); + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + env: { OPENAI_BASE_URL: "https://api.openai.com/v1" }, + observedRoutes, + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-responses", authRequirement: "api-key" }], + }); + }); + + it("requires authored ChatGPT intent before sending subscription auth to a custom endpoint", () => { + expect( + resolveModelRoutes({ + provider: "openai", + configuredProvider: { baseUrl: "https://configured.example.test/v1" }, + observedRoutes: [{ api: "openai-chatgpt-responses" }], + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-completions", authRequirement: "api-key" }], + }); + expect( + resolveModelRoutes({ + provider: "openai", + env: { OPENAI_BASE_URL: "https://env.example.test/v1" }, + observedRoutes: [{ api: "openai-chatgpt-responses" }], + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-responses", authRequirement: "api-key" }], + }); + expect( + resolveModelRoutes({ + provider: "openai", + observedRoutes: [ + { + api: "openai-chatgpt-responses", + baseUrl: "https://observed-relay.example.test/v1", + }, + ], + }), + ).toMatchObject({ + kind: "incompatible", + code: "custom-chatgpt-relay-requires-configuration", + }); + }); + + it("treats an environment Platform URL as an explicit route lock", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + env: { OPENAI_BASE_URL: "https://api.openai.com/v1" }, + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-responses", authRequirement: "api-key" }], + }); + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.3-codex-spark", + env: { OPENAI_BASE_URL: "https://api.openai.com/v1" }, + }), + ).toMatchObject({ + kind: "incompatible", + code: "subscription-only-model-on-platform", + }); + }); + + it("routes unconfigured Spark only through ChatGPT", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.3-codex-spark", + }), + ).toEqual({ + kind: "routes", + defaultRuntimeId: "codex", + routes: [ + { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + ], + }); + }); + + it("rejects explicitly authored Platform Spark routes", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.3-codex-spark", + configuredProvider: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + }), + ).toMatchObject({ + kind: "incompatible", + code: "subscription-only-model-on-platform", + }); + }); + + it("rejects conflicting official APIs and endpoints", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredProvider: { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + }, + }), + ).toMatchObject({ + kind: "incompatible", + code: "conflicting-official-openai-route", + }); + }); + + it("rejects the wrong provider and unsupported official adapters", () => { + expect(resolveModelRoutes({ provider: "anthropic", modelId: "gpt-5.5" })).toMatchObject({ + kind: "incompatible", + code: "openai-route-provider-mismatch", + }); + expect( + resolveModelRoutes({ + provider: "openai", + configuredProvider: { + api: "anthropic-messages", + baseUrl: "https://api.openai.com/v1", + }, + }), + ).toMatchObject({ + kind: "incompatible", + code: "unsupported-official-openai-api", + }); + expect( + resolveModelRoutes({ + provider: "openai", + configuredProvider: { + api: "anthropic-messages", + baseUrl: "https://relay.example.test/v1", + }, + }), + ).toMatchObject({ + kind: "incompatible", + code: "unsupported-custom-openai-api", + }); + }); + + it("inherits a provider adapter when the model overrides only its official base URL", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredModel: { baseUrl: "https://api.openai.com/v1" }, + configuredProvider: { api: "openai-completions" }, + }), + ).toEqual({ + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [ + { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + ], + }); + }); + + it("inherits lower custom endpoints without changing the model adapter", () => { + for (const [api, authRequirement] of [ + ["openai-chatgpt-responses", "subscription"], + ["openai-responses", "api-key"], + ] as const) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredModel: { api }, + configuredProvider: { baseUrl: "https://relay.example.test/v1" }, + }), + ).toMatchObject({ + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [ + { + api, + baseUrl: "https://relay.example.test/v1", + authRequirement, + }, + ], + }); + } + }); + + it("does not combine authored ChatGPT facts with an observed Platform row", () => { + const observed = { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + } as const; + for (const configuredModel of [ + { api: "openai-chatgpt-responses" }, + { baseUrl: "https://chatgpt.com/backend-api/v1" }, + ] as const) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredModel, + observedRoutes: [observed], + }), + ).toMatchObject({ + kind: "routes", + defaultRuntimeId: "codex", + routes: [ + { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + }, + ], + }); + } + }); + + it("rejects invalid configured routes", () => { + expect( + resolveModelRoutes({ + provider: "openai", + configuredProvider: { baseUrl: { url: "https://api.openai.com/v1" } }, + }), + ).toMatchObject({ kind: "incompatible", code: "invalid-openai-base-url" }); + for (const baseUrl of [ + "not a URL", + "https://api.openai.com:8443/v1", + "http://api.openai.com:443/v1", + "https://api.openai.com/v1/models", + "https://api.openai.com/v1?proxy=1", + "https://chatgpt.com/backend-api/codex#fragment", + ]) { + expect( + resolveModelRoutes({ provider: "openai", configuredProvider: { baseUrl } }), + ).toMatchObject({ + kind: "incompatible", + code: "invalid-openai-base-url", + }); + } + }); + + it("rejects internally contradictory observed routes", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + observedRoutes: [ + { + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + }, + ], + }), + ).toMatchObject({ kind: "incompatible", code: "conflicting-official-openai-route" }); + expect( + resolveModelRoutes({ + provider: "openai", + observedRoutes: [{ baseUrl: { url: "https://api.openai.com/v1" } }], + }), + ).toMatchObject({ kind: "incompatible", code: "invalid-openai-base-url" }); + for (const baseUrl of [ + "not a URL", + "https://api.openai.com:8443/v1", + "http://api.openai.com:443/v1", + "https://api.openai.com/v1/models", + "https://api.openai.com/v1?proxy=1", + "https://chatgpt.com/backend-api/codex#fragment", + ]) { + expect( + resolveModelRoutes({ provider: "openai", observedRoutes: [{ baseUrl }] }), + ).toMatchObject({ + kind: "incompatible", + code: "invalid-openai-base-url", + }); + } + }); + + it("rejects plaintext official routes", () => { + for (const baseUrl of ["http://api.openai.com/v1", "http://chatgpt.com/backend-api/codex"]) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredProvider: { baseUrl }, + }), + ).toMatchObject({ kind: "incompatible", code: "invalid-openai-base-url" }); + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-future-observed", + observedRoutes: [{ baseUrl }], + }), + ).toMatchObject({ kind: "incompatible", code: "invalid-openai-base-url" }); + } + }); + + it("preserves explicit official completions and keeps them on OpenClaw", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredProvider: { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + }, + }), + ).toEqual({ + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [ + { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + ], + }); + for (const modelId of ["chat-latest", "gpt-5.6"]) { + expect(resolveModelRoutes({ provider: "openai", modelId })).toMatchObject({ + kind: "routes", + defaultRuntimeId: "codex", + routes: [{ api: "openai-responses", authRequirement: "api-key" }], + }); + expect( + resolveModelRoutes({ + provider: "openai", + modelId, + configuredProvider: { api: "openai-chatgpt-responses" }, + }), + ).toMatchObject({ + kind: "incompatible", + code: "platform-only-model-on-chatgpt", + }); + } + }); + + it("preserves explicit ChatGPT routes for known model contracts", () => { + for (const modelId of ["gpt-5.3-chat-latest", "gpt-5.4-nano"]) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId, + configuredProvider: { api: "openai-chatgpt-responses" }, + }), + ).toMatchObject({ + kind: "routes", + defaultRuntimeId: "codex", + routes: [{ api: "openai-chatgpt-responses", authRequirement: "subscription" }], + }); + } + }); + + it("canonicalizes equivalent Platform URLs and keeps unknown variants single-route", () => { + for (const baseUrl of [ + "https://api.openai.com", + "https://api.openai.com/v1/", + "https://api.openai.com:443/v1", + "https://api.openai.com./v1", + ]) { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + configuredProvider: { baseUrl }, + }), + ).toMatchObject({ + kind: "routes", + routes: [{ baseUrl: "https://api.openai.com/v1" }], + }); + } + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5-unknown", + observedRoutes: [{ api: "openai-responses", baseUrl: "https://api.openai.com/v1" }], + }), + ).toMatchObject({ kind: "routes", routes: [{ api: "openai-responses" }] }); + const unknown = resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5-unknown", + observedRoutes: [{ api: "openai-responses", baseUrl: "https://api.openai.com/v1" }], + }); + expect(unknown.kind === "routes" ? unknown.routes : []).toHaveLength(1); + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5-unknown", + observedRoutes: [ + { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + ], + }), + ).toMatchObject({ + kind: "routes", + routes: [{ api: "openai-chatgpt-responses", authRequirement: "subscription" }], + }); + expect(resolveModelRoutes({ provider: "openai", modelId: "gpt-5.5-unknown" })).toEqual({ + kind: "indeterminate", + defaultRuntimeId: "codex", + }); + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.5-unknown", + requestTransportOverrides: "present", + }), + ).toEqual({ kind: "indeterminate", defaultRuntimeId: "openclaw" }); + }); + + it("allows custom endpoints to expose Spark-like ids", () => { + expect( + resolveModelRoutes({ + provider: "openai", + modelId: "gpt-5.3-codex-spark", + configuredModel: { + api: "openai-responses", + baseUrl: "https://relay.example.test/v1", + }, + }), + ).toMatchObject({ + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [{ authRequirement: "api-key" }], + }); + }); }); diff --git a/extensions/openai/provider-policy-api.ts b/extensions/openai/provider-policy-api.ts index 084f87df74d9..92632a5c0d74 100644 --- a/extensions/openai/provider-policy-api.ts +++ b/extensions/openai/provider-policy-api.ts @@ -1,8 +1,539 @@ -import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/plugin-entry"; // Openai API module exposes the plugin public contract. -import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types"; +import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/core"; +import type { + ModelApi, + ModelProviderConfig, + ProviderModelRouteCandidate, + ProviderModelRouteResolution, + ProviderModelRouteSource, + ProviderNormalizeModelCatalogIdContext, + ProviderResolveModelRoutesContext, +} from "openclaw/plugin-sdk/provider-model-types"; +import { + classifyOpenAIBaseUrl, + OPENAI_API_BASE_URL, + OPENAI_CODEX_RESPONSES_BASE_URL, +} from "./base-url.js"; +import { + isOpenAIDualRouteModelId, + isOpenAIPlatformOnlyRouteModelId, + isOpenAISubscriptionOnlyRouteModelId, + normalizeOpenAIModelRouteId, +} from "./model-route-contract.js"; import { resolveUnifiedOpenAIThinkingProfile } from "./thinking-policy.js"; +const OPENAI_RESPONSES_API = "openai-responses"; +const OPENAI_COMPLETIONS_API = "openai-completions"; +const OPENAI_CHATGPT_RESPONSES_API = "openai-chatgpt-responses"; +const OPENAI_AGENT_RUNTIME_ID = "openclaw"; +const CODEX_AGENT_RUNTIME_ID = "codex"; +const OPENCLAW_RUNTIME_COMPATIBLE_IDS = [OPENAI_AGENT_RUNTIME_ID] as const; +const CODEX_RUNTIME_COMPATIBLE_IDS = [OPENAI_AGENT_RUNTIME_ID, CODEX_AGENT_RUNTIME_ID] as const; + +type OpenAIResolveSingleModelRouteContext = Omit< + ProviderResolveModelRoutesContext, + "observedRoutes" +> & { + observed?: ProviderModelRouteSource; +}; + +function normalizeOptionalRouteApi(value: ModelApi | null | undefined): ModelApi | undefined { + return typeof value === "string" && value.trim() ? (value.trim() as ModelApi) : undefined; +} + +function normalizeOptionalRouteBaseUrl(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +/** Canonical logical id for OpenAI catalog projection. */ +export function normalizeModelCatalogId(params: ProviderNormalizeModelCatalogIdContext) { + return params.provider.trim().toLowerCase() === "openai" + ? normalizeOpenAIModelRouteId(params.modelId) + : null; +} + +function firstRouteBaseUrl(...values: unknown[]): unknown { + for (const value of values) { + if (typeof value === "string") { + if (value.trim()) { + return value.trim(); + } + continue; + } + if (value !== undefined && value !== null) { + return value; + } + } + return undefined; +} + +function concreteBaseUrl(value: unknown, fallback: string): string { + return normalizeOptionalRouteBaseUrl(value) ?? fallback; +} + +function resolveOpenAIEnvironmentBaseUrl( + context: Pick, +): string | undefined { + return (context.env ?? process.env).OPENAI_BASE_URL; +} + +function isHttpBaseUrl(baseUrl: unknown): boolean { + if (typeof baseUrl !== "string") { + return false; + } + try { + return new URL(baseUrl.trim()).protocol === "http:"; + } catch { + return false; + } +} + +function codexCanReproduceRoute( + candidate: ProviderModelRouteCandidate, + sourceBaseUrl: unknown = candidate.baseUrl, +): boolean { + // Official HTTP ChatGPT input normalizes to the native HTTPS candidate. Retain the source + // protocol here so normalization cannot silently make an unreproducible route Codex-compatible. + if (isHttpBaseUrl(sourceBaseUrl) || candidate.requestTransportOverrides === "present") { + return false; + } + const endpointKind = classifyOpenAIBaseUrl(candidate.baseUrl); + return ( + (candidate.api === OPENAI_RESPONSES_API && endpointKind === "platform") || + (candidate.api === OPENAI_CHATGPT_RESPONSES_API && endpointKind === "chatgpt") + ); +} + +function withRuntimePolicy( + candidate: ProviderModelRouteCandidate, + sourceBaseUrl: unknown = candidate.baseUrl, +): ProviderModelRouteCandidate { + return { + ...candidate, + runtimePolicy: { + compatibleIds: codexCanReproduceRoute(candidate, sourceBaseUrl) + ? CODEX_RUNTIME_COMPATIBLE_IDS + : OPENCLAW_RUNTIME_COMPATIBLE_IDS, + }, + }; +} + +function defaultRuntimeIdForRoute( + candidate: ProviderModelRouteCandidate, + sourceBaseUrl: unknown = candidate.baseUrl, +): string { + return codexCanReproduceRoute(candidate, sourceBaseUrl) + ? CODEX_AGENT_RUNTIME_ID + : OPENAI_AGENT_RUNTIME_ID; +} + +function route( + candidate: ProviderModelRouteCandidate, + sourceBaseUrl?: unknown, +): ProviderModelRouteResolution & { kind: "routes" } { + const compatibleCandidate = withRuntimePolicy(candidate, sourceBaseUrl); + return { + kind: "routes", + routes: [compatibleCandidate], + defaultRuntimeId: defaultRuntimeIdForRoute(compatibleCandidate, sourceBaseUrl), + }; +} + +/** + * Resolves OpenAI transport policy in provider-default order. + * + * Candidate order is not credential order. Callers must honor a locked profile, + * provider auth, then auth.order before choosing a compatible candidate. Unknown + * models without route facts remain indeterminate until a catalog row is observed. + */ +function resolveSingleObservedModelRoute( + context: OpenAIResolveSingleModelRouteContext, +): ProviderModelRouteResolution { + if (context.provider.trim().toLowerCase() !== "openai") { + return { + kind: "incompatible", + code: "openai-route-provider-mismatch", + message: `OpenAI route policy cannot resolve provider ${context.provider || "(empty)"}.`, + }; + } + const modelApi = normalizeOptionalRouteApi(context.configuredModel?.api); + const requestTransportOverrides = context.requestTransportOverrides ?? "none"; + const providerApi = normalizeOptionalRouteApi(context.configuredProvider?.api); + const modelBaseUrl = firstRouteBaseUrl(context.configuredModel?.baseUrl); + const providerBaseUrl = firstRouteBaseUrl(context.configuredProvider?.baseUrl); + const environmentBaseUrl = firstRouteBaseUrl(resolveOpenAIEnvironmentBaseUrl(context)); + const observedApi = normalizeOptionalRouteApi(context.observed?.api); + const observedBaseUrl = firstRouteBaseUrl(context.observed?.baseUrl); + const hasObservedRoute = observedApi !== undefined || observedBaseUrl !== undefined; + let effectiveApi: ModelApi | undefined; + let effectiveBaseUrl: unknown; + let configuredRoute = false; + let customDefaultApi: ModelApi = OPENAI_COMPLETIONS_API; + + // Model facts override provider facts field-by-field, which override the environment. + // Observed rows are atomic fallback only; custom bases may inherit a lower + // authored adapter without combining contradictory official transports. + if (modelApi !== undefined || modelBaseUrl !== undefined) { + configuredRoute = true; + effectiveApi = modelApi ?? providerApi; + effectiveBaseUrl = modelBaseUrl; + if (modelBaseUrl === undefined) { + const lowerBaseUrl = providerBaseUrl ?? environmentBaseUrl; + const lowerEndpointKind = classifyOpenAIBaseUrl(lowerBaseUrl); + effectiveBaseUrl = + lowerEndpointKind === "custom" || lowerEndpointKind === "invalid" + ? lowerBaseUrl + : undefined; + } + } else if (providerApi !== undefined || providerBaseUrl !== undefined) { + configuredRoute = true; + effectiveApi = providerApi; + effectiveBaseUrl = providerBaseUrl; + if (providerBaseUrl === undefined) { + const environmentEndpointKind = classifyOpenAIBaseUrl(environmentBaseUrl); + if (environmentEndpointKind === "custom" || environmentEndpointKind === "invalid") { + effectiveBaseUrl = environmentBaseUrl; + } + } + } else if (environmentBaseUrl !== undefined) { + configuredRoute = true; + effectiveBaseUrl = environmentBaseUrl; + customDefaultApi = OPENAI_RESPONSES_API; + } else { + effectiveApi = observedApi; + effectiveBaseUrl = observedBaseUrl; + } + const endpointKind = classifyOpenAIBaseUrl(effectiveBaseUrl); + if (endpointKind === "invalid") { + return { + kind: "incompatible", + code: "invalid-openai-base-url", + message: "OpenAI model route baseUrl must be a non-empty URL string.", + }; + } + const chatGPTApi = effectiveApi?.toLowerCase() === OPENAI_CHATGPT_RESPONSES_API; + const authoredChatGPTApi = + modelApi?.toLowerCase() === OPENAI_CHATGPT_RESPONSES_API || + providerApi?.toLowerCase() === OPENAI_CHATGPT_RESPONSES_API; + + // A custom endpoint owns its protocol contract. Subscription egress always + // requires authored ChatGPT intent; observed Platform adapters remain safe + // API-key fallbacks for otherwise unspecified custom routes. + if (endpointKind === "custom") { + if (chatGPTApi && !authoredChatGPTApi) { + return { + kind: "incompatible", + code: "custom-chatgpt-relay-requires-configuration", + message: "Custom ChatGPT relays require an explicitly configured ChatGPT adapter.", + }; + } + // An independently authored custom endpoint may reuse only observed + // Platform adapters. Requiring authored ChatGPT intent prevents a stale + // catalog row from redirecting a subscription bearer to that endpoint. + const observedPlatformApi = + observedApi === OPENAI_RESPONSES_API || observedApi === OPENAI_COMPLETIONS_API + ? observedApi + : undefined; + const customApi = effectiveApi ?? observedPlatformApi ?? customDefaultApi; + if ( + customApi !== OPENAI_RESPONSES_API && + customApi !== OPENAI_COMPLETIONS_API && + customApi !== OPENAI_CHATGPT_RESPONSES_API + ) { + return { + kind: "incompatible", + code: "unsupported-custom-openai-api", + message: `${customApi} is not an OpenAI-compatible model adapter.`, + }; + } + const customAuthRequirement = + customApi.toLowerCase() === OPENAI_CHATGPT_RESPONSES_API ? "subscription" : "api-key"; + return route( + { + api: customApi, + baseUrl: concreteBaseUrl(effectiveBaseUrl, OPENAI_API_BASE_URL), + authRequirement: customAuthRequirement, + requestTransportOverrides, + }, + effectiveBaseUrl, + ); + } + + if ( + (endpointKind === "platform" && chatGPTApi) || + (endpointKind === "chatgpt" && effectiveApi !== undefined && !chatGPTApi) + ) { + return { + kind: "incompatible", + code: "conflicting-official-openai-route", + message: "OpenAI model API and baseUrl select different official transports.", + }; + } + + if ( + effectiveApi !== undefined && + effectiveApi !== OPENAI_RESPONSES_API && + effectiveApi !== OPENAI_COMPLETIONS_API && + effectiveApi !== OPENAI_CHATGPT_RESPONSES_API + ) { + return { + kind: "incompatible", + code: "unsupported-official-openai-api", + message: `${effectiveApi} is not an OpenAI Platform model adapter.`, + }; + } + + const modelId = normalizeOpenAIModelRouteId(context.modelId); + const sourceBaseUrl = effectiveBaseUrl; + // An authored Completions adapter is a concrete transport contract, not an + // alias for Responses. Codex does not execute that adapter, so preserve it + // and let the OpenClaw runtime own the request. + const platformApi = + configuredRoute && effectiveApi === OPENAI_COMPLETIONS_API + ? OPENAI_COMPLETIONS_API + : OPENAI_RESPONSES_API; + const platformRoute = withRuntimePolicy( + { + api: platformApi, + baseUrl: + classifyOpenAIBaseUrl(sourceBaseUrl) === "platform" && isHttpBaseUrl(sourceBaseUrl) + ? concreteBaseUrl(sourceBaseUrl, OPENAI_API_BASE_URL) + : OPENAI_API_BASE_URL, + authRequirement: "api-key", + requestTransportOverrides, + }, + sourceBaseUrl, + ); + const chatGPTRoute = withRuntimePolicy( + { + api: OPENAI_CHATGPT_RESPONSES_API, + baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL, + authRequirement: "subscription", + requestTransportOverrides, + }, + sourceBaseUrl, + ); + const platformOnly = isOpenAIPlatformOnlyRouteModelId(modelId); + const subscriptionOnly = isOpenAISubscriptionOnlyRouteModelId(modelId); + const dualRoute = isOpenAIDualRouteModelId(modelId); + + // Observed catalog transport is not authored route intent. Known model + // contracts stay stable regardless of which official sibling row was seen. + if (!configuredRoute) { + if (subscriptionOnly) { + return route(chatGPTRoute, sourceBaseUrl); + } + if (platformOnly) { + return route(platformRoute, sourceBaseUrl); + } + if (dualRoute) { + return { + kind: "routes", + defaultRuntimeId: defaultRuntimeIdForRoute(platformRoute, sourceBaseUrl), + routes: [platformRoute, chatGPTRoute], + }; + } + } + + if (endpointKind === "chatgpt" || chatGPTApi) { + if (platformOnly) { + return { + kind: "incompatible", + code: "platform-only-model-on-chatgpt", + message: `${modelId} is available only through OpenAI Platform API-key authentication.`, + }; + } + return route(chatGPTRoute, sourceBaseUrl); + } + + if (subscriptionOnly) { + return { + kind: "incompatible", + code: "subscription-only-model-on-platform", + message: `${modelId} is available only through ChatGPT subscription authentication.`, + }; + } + + if (!configuredRoute && !hasObservedRoute) { + return { + kind: "indeterminate", + defaultRuntimeId: + requestTransportOverrides === "present" ? OPENAI_AGENT_RUNTIME_ID : CODEX_AGENT_RUNTIME_ID, + }; + } + return route(platformRoute, sourceBaseUrl); +} + +function hasAuthoredRouteFacts(context: ProviderResolveModelRoutesContext): boolean { + return ( + normalizeOptionalRouteApi(context.configuredModel?.api) !== undefined || + firstRouteBaseUrl(context.configuredModel?.baseUrl) !== undefined || + normalizeOptionalRouteApi(context.configuredProvider?.api) !== undefined || + firstRouteBaseUrl(context.configuredProvider?.baseUrl) !== undefined || + firstRouteBaseUrl(resolveOpenAIEnvironmentBaseUrl(context)) !== undefined + ); +} + +function authoredRouteNeedsObservedPlatformApi( + context: ProviderResolveModelRoutesContext, +): boolean { + // Observations may fill only the missing protocol for an authored custom + // endpoint. Complete authored routes must stay isolated from catalog rows. + if ( + normalizeOptionalRouteApi(context.configuredModel?.api) !== undefined || + normalizeOptionalRouteApi(context.configuredProvider?.api) !== undefined + ) { + return false; + } + const authoredBaseUrl = firstRouteBaseUrl( + context.configuredModel?.baseUrl, + context.configuredProvider?.baseUrl, + resolveOpenAIEnvironmentBaseUrl(context), + ); + return classifyOpenAIBaseUrl(authoredBaseUrl) === "custom"; +} + +function canonicalRouteCandidateBaseUrl(baseUrl: string): string { + // Catalog rows may spell one endpoint differently. A canonical grouping key + // prevents observation order from creating a false route ambiguity. + try { + const url = new URL(baseUrl); + url.pathname = url.pathname.replace(/\/+$/u, "") || "/"; + return url.toString(); + } catch { + return baseUrl; + } +} + +function routeCandidateKey(candidate: ProviderModelRouteCandidate): string { + return [ + candidate.api, + canonicalRouteCandidateBaseUrl(candidate.baseUrl), + candidate.authRequirement, + candidate.requestTransportOverrides, + ...(candidate.runtimePolicy?.compatibleIds ?? []), + ].join("\u0000"); +} + +function compareRouteCandidates( + a: ProviderModelRouteCandidate, + b: ProviderModelRouteCandidate, +): number { + const authOrder = (candidate: ProviderModelRouteCandidate) => + candidate.authRequirement === "api-key" ? 0 : 1; + return ( + authOrder(a) - authOrder(b) || a.api.localeCompare(b.api) || a.baseUrl.localeCompare(b.baseUrl) + ); +} + +function ambiguousObservedRouteGroup( + message: string, +): Extract { + return { kind: "incompatible", code: "ambiguous-openai-route-group", message }; +} + +function resolveAuthoredObservedFallback(observedRoutes: readonly ProviderModelRouteSource[]): + | { kind: "observed"; route?: ProviderModelRouteSource } + | { + kind: "incompatible"; + resolution: Extract; + } { + const platformApis = new Set(); + for (const observed of observedRoutes) { + const api = normalizeOptionalRouteApi(observed.api); + if (!api || api === OPENAI_CHATGPT_RESPONSES_API) { + continue; + } + if (api !== OPENAI_RESPONSES_API && api !== OPENAI_COMPLETIONS_API) { + return { + kind: "incompatible", + resolution: { + kind: "incompatible", + code: "unsupported-custom-openai-api", + message: `${api} is not an OpenAI-compatible model adapter.`, + }, + }; + } + platformApis.add(api); + } + if (platformApis.size > 1) { + return { + kind: "incompatible", + resolution: ambiguousObservedRouteGroup( + "Observed OpenAI routes disagree on the Platform adapter for an authored endpoint.", + ), + }; + } + const api = [...platformApis][0]; + return { kind: "observed", ...(api ? { route: { api } } : {}) }; +} + +/** Resolves every physical row for one logical OpenAI model in provider order. */ +export function resolveModelRoutes( + context: ProviderResolveModelRoutesContext, +): ProviderModelRouteResolution { + const observedRoutes = (context.observedRoutes ?? []).filter( + (observed) => observed.api != null || observed.baseUrl != null, + ); + if (hasAuthoredRouteFacts(context)) { + if (authoredRouteNeedsObservedPlatformApi(context)) { + const fallback = resolveAuthoredObservedFallback(observedRoutes); + if (fallback.kind === "incompatible") { + return fallback.resolution; + } + return resolveSingleObservedModelRoute({ ...context, observed: fallback.route }); + } + return resolveSingleObservedModelRoute(context); + } + if (observedRoutes.length <= 1) { + return resolveSingleObservedModelRoute({ ...context, observed: observedRoutes[0] }); + } + + const resolutions = observedRoutes.map((observed) => + resolveSingleObservedModelRoute({ ...context, observed }), + ); + const incompatible = resolutions + .filter((resolution) => resolution.kind === "incompatible") + .toSorted((a, b) => a.code.localeCompare(b.code) || a.message.localeCompare(b.message))[0]; + if (incompatible) { + return incompatible; + } + + const routesByKey = new Map(); + for (const resolution of resolutions) { + if (resolution.kind !== "routes") { + continue; + } + for (const candidate of resolution.routes) { + const key = routeCandidateKey(candidate); + const existing = routesByKey.get(key); + if (!existing || candidate.baseUrl.localeCompare(existing.baseUrl) < 0) { + routesByKey.set(key, candidate); + } + } + } + const routes = [...routesByKey.values()].toSorted(compareRouteCandidates); + const authRequirements = new Set(routes.map((candidate) => candidate.authRequirement)); + if (routes.length > authRequirements.size) { + return ambiguousObservedRouteGroup( + "Observed OpenAI routes contain multiple endpoints for the same authentication class.", + ); + } + const firstRoute = routes[0]; + if (!firstRoute) { + return resolveSingleObservedModelRoute(context); + } + return { + kind: "routes", + routes: routes as [ProviderModelRouteCandidate, ...ProviderModelRouteCandidate[]], + defaultRuntimeId: resolutions.some( + (resolution) => resolution.kind === "routes" && resolution.defaultRuntimeId === "openclaw", + ) + ? OPENAI_AGENT_RUNTIME_ID + : defaultRuntimeIdForRoute(firstRoute), + }; +} + export function normalizeConfig(params: { provider: string; providerConfig: ModelProviderConfig }) { return params.providerConfig; } diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index bfd50f9071bd..b61b1ab8eb88 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -117,6 +117,9 @@ export const pluginSdkDocMetadata = { "provider-catalog-live-runtime": { category: "provider", }, + "provider-model-types": { + category: "provider", + }, "runtime-store": { category: "runtime", }, @@ -135,6 +138,9 @@ export const pluginSdkDocMetadata = { "agent-runtime": { category: "runtime", }, + "agent-harness-runtime": { + category: "runtime", + }, "speech-core": { category: "provider", }, diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 81a21b511af9..8391735e0fc0 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -195,12 +195,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10541, + 10553, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5247, + 5249, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/agent-model-discovery.test.ts b/src/agents/agent-model-discovery.test.ts index 44252a5aa479..e85324f4e32d 100644 --- a/src/agents/agent-model-discovery.test.ts +++ b/src/agents/agent-model-discovery.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; function writeModelsJson(agentDir: string, modelId: string): void { @@ -36,4 +37,48 @@ describe("discoverModels", () => { expect(registry.getAll().some((model) => model.id === "new-model")).toBe(true); expect(registry.find("custom", "new-model")?.id).toBe("new-model"); }); + + it("preserves authored OpenAI Completions while normalizing models.json entries", () => { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-models-")); + fs.writeFileSync( + path.join(agentDir, "models.json"), + JSON.stringify({ + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + baseUrl: "https://api.openai.com/v1", + }, + ], + }, + }, + }), + ); + const config = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + baseUrl: "https://api.openai.com/v1", + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + const authStorage = discoverAuthStorage(agentDir, { skipCredentials: true }); + const registry = discoverModels(authStorage, agentDir, { config }); + + expect(registry.find("openai", "gpt-5.5")?.api).toBe("openai-completions"); + }); }); diff --git a/src/agents/agent-model-discovery.ts b/src/agents/agent-model-discovery.ts index d5adb3b9ec8f..5f49368eb15b 100644 --- a/src/agents/agent-model-discovery.ts +++ b/src/agents/agent-model-discovery.ts @@ -39,7 +39,11 @@ type DiscoverModelsOptions = { }; /** Applies plugin model normalization and transport hooks to discovered agent models. */ -export function normalizeDiscoveredAgentModel(value: T, agentDir: string): T { +export function normalizeDiscoveredAgentModel( + value: T, + agentDir: string, + options?: Pick, +): T { if (!isRecord(value)) { return value; } @@ -51,10 +55,15 @@ export function normalizeDiscoveredAgentModel(value: T, agentDir: string): T return value; } const model = value as unknown as DiscoveredProviderRuntimeModelLike; + const runtimeContext = { + ...(options?.config !== undefined ? { config: options.config } : {}), + ...(options?.workspaceDir !== undefined ? { workspaceDir: options.workspaceDir } : {}), + }; const pluginNormalized = normalizeProviderResolvedModelWithPlugin({ provider: model.provider, modelId: model.id, + ...runtimeContext, context: { provider: model.provider, modelId: model.id, @@ -66,6 +75,7 @@ export function normalizeDiscoveredAgentModel(value: T, agentDir: string): T applyProviderResolvedTransportWithPlugin({ provider: model.provider, modelId: model.id, + ...runtimeContext, context: { provider: model.provider, modelId: model.id, @@ -112,19 +122,15 @@ function createOpenClawModelRegistry( const shouldNormalize = options?.normalizeModels !== false; const findCache = new Map(); const normalizeEntry = (entry: Model) => - shouldNormalize ? normalizeDiscoveredAgentModel(entry, agentDir) : entry; + shouldNormalize ? normalizeDiscoveredAgentModel(entry, agentDir, options) : entry; registry.getAll = () => { const entries = getAll().filter((entry: Model) => matchesProviderFilter(entry)); - return shouldNormalize - ? entries.map((entry: Model) => normalizeDiscoveredAgentModel(entry, agentDir)) - : entries; + return shouldNormalize ? entries.map(normalizeEntry) : entries; }; registry.getAvailable = () => { const entries = getAvailable().filter((entry: Model) => matchesProviderFilter(entry)); - return shouldNormalize - ? entries.map((entry: Model) => normalizeDiscoveredAgentModel(entry, agentDir)) - : entries; + return shouldNormalize ? entries.map(normalizeEntry) : entries; }; registry.find = (provider: string, modelId: string) => { const normalizedProvider = normalizeProviderId(provider); diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index f3edcb0eb8d4..e5276b2f8ec1 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -20,6 +20,7 @@ export type ResolvedAgentConfig = { workspace?: string; agentDir?: string; model?: AgentEntry["model"]; + models?: AgentEntry["models"]; utilityModel?: AgentEntry["utilityModel"]; thinkingDefault?: AgentEntry["thinkingDefault"]; verboseDefault?: AgentDefaultsConfig["verboseDefault"]; @@ -129,6 +130,7 @@ export function resolveAgentConfig( typeof entry.model === "string" || (entry.model && typeof entry.model === "object") ? entry.model : undefined, + ...(entry.models ? { models: entry.models } : {}), utilityModel: readStringValue(entry.utilityModel), thinkingDefault: entry.thinkingDefault, verboseDefault: entry.verboseDefault ?? agentDefaults?.verboseDefault, diff --git a/src/agents/auth-profiles/order.test.ts b/src/agents/auth-profiles/order.test.ts index 69deb6403f02..d7714822adba 100644 --- a/src/agents/auth-profiles/order.test.ts +++ b/src/agents/auth-profiles/order.test.ts @@ -7,6 +7,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resetProviderAuthAliasMapCacheForTest } from "../provider-auth-aliases.js"; import { saveAuthProfileStore } from "./store.js"; import type { AuthProfileStore } from "./types.js"; @@ -42,7 +43,11 @@ vi.mock("./external-auth.js", () => ({ overlayExternalAuthProfiles: (store: T) => store, })); -import { isStoredCredentialCompatibleWithAuthProvider, resolveAuthProfileOrder } from "./order.js"; +import { + isStoredCredentialCompatibleWithAuthProvider, + resolveAuthProfileOrder, + resolveAuthProfileOrderWithMetadata, +} from "./order.js"; import { markAuthProfileSuccess } from "./profiles.js"; describe("resolveAuthProfileOrder", () => { @@ -274,6 +279,125 @@ describe("resolveAuthProfileOrder", () => { }); expect(order).toStrictEqual([]); + expect( + resolveAuthProfileOrderWithMetadata({ + cfg: { + auth: { + order: { + "fixture-provider": ["fixture-provider:missing"], + }, + }, + }, + store, + provider: "fixture-provider", + }), + ).toStrictEqual({ profileIds: [], hasExplicitOrder: true }); + }); + + it("reports an empty configured auth order as authoritative", () => { + const resolution = resolveAuthProfileOrderWithMetadata({ + cfg: { + auth: { + order: { + "fixture-provider": [], + }, + }, + }, + store: { + version: 1, + profiles: { + "fixture-provider:primary": { + type: "api_key", + provider: "fixture-provider", + key: "sk-primary", + }, + }, + }, + provider: "fixture-provider", + }); + + expect(resolution).toStrictEqual({ profileIds: [], hasExplicitOrder: true }); + }); + + it("does not apply a cooldown scoped to another model when ordering profiles", () => { + const store: AuthProfileStore = { + version: 1, + profiles: { + "fixture-provider:primary": { + type: "api_key", + provider: "fixture-provider", + key: "sk-primary", + }, + "fixture-provider:backup": { + type: "api_key", + provider: "fixture-provider", + key: "sk-backup", + }, + }, + usageStats: { + "fixture-provider:primary": { + cooldownUntil: Date.now() + 60_000, + cooldownReason: "rate_limit", + cooldownModel: "model-a", + }, + }, + }; + const cfg = { + auth: { + order: { + "fixture-provider": ["fixture-provider:primary", "fixture-provider:backup"], + }, + }, + } satisfies OpenClawConfig; + + expect( + resolveAuthProfileOrder({ + cfg, + store, + provider: "fixture-provider", + forModel: "model-b", + }), + ).toStrictEqual(["fixture-provider:primary", "fixture-provider:backup"]); + expect( + resolveAuthProfileOrder({ + cfg, + store, + provider: "fixture-provider", + forModel: "model-a", + }), + ).toStrictEqual(["fixture-provider:backup", "fixture-provider:primary"]); + }); + + it("keeps unresolved OAuth refs only in read-only profile ordering", () => { + const store: AuthProfileStore = { + version: 1, + profiles: { + "openai:legacy-ref": { + type: "oauth", + provider: "openai", + access: "", + refresh: "", + expires: 0, + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + id: "00000000000000000000000000000000", + }, + }, + }, + }; + + expect(resolveAuthProfileOrderWithMetadata({ store, provider: "openai" })).toEqual({ + profileIds: [], + hasExplicitOrder: false, + }); + expect( + resolveAuthProfileOrderWithMetadata({ + store, + provider: "openai", + readinessMode: "read-only", + }), + ).toEqual({ profileIds: ["openai:legacy-ref"], hasExplicitOrder: false }); }); it("lets Codex auth use friendly OpenAI auth order entries", async () => { diff --git a/src/agents/auth-profiles/order.ts b/src/agents/auth-profiles/order.ts index e3f85f4e3b0b..342f0c90ea74 100644 --- a/src/agents/auth-profiles/order.ts +++ b/src/agents/auth-profiles/order.ts @@ -239,15 +239,28 @@ export function resolveAuthProfileEligibility(params: { }; } -/** Resolves ordered auth profile candidates for a provider. */ -/** Resolve ordered usable auth profile ids for a provider. */ -export function resolveAuthProfileOrder(params: { +export type ResolveAuthProfileOrderParams = { cfg?: OpenClawConfig; store: AuthProfileStore; provider: string; preferredProfile?: string; -}): string[] { - const { cfg, store, provider, preferredProfile } = params; + /** Model that will consume the profile, for model-scoped cooldowns. */ + forModel?: string; + /** Read-only status keeps unresolved refs ordered so availability remains unknown. */ + readinessMode?: "execution" | "read-only"; +}; + +export type AuthProfileOrderResolution = { + profileIds: string[]; + /** An authored store/config order owns selection, including an empty result. */ + hasExplicitOrder: boolean; +}; + +/** Resolves ordered usable auth profiles plus whether an explicit order owns selection. */ +export function resolveAuthProfileOrderWithMetadata( + params: ResolveAuthProfileOrderParams, +): AuthProfileOrderResolution { + const { cfg, store, provider, preferredProfile, forModel } = params; const providerKey = normalizeProviderId(provider); const providerAuthKey = resolveProviderIdForAuth(provider, { config: cfg }); const now = Date.now(); @@ -318,17 +331,22 @@ export function resolveAuthProfileOrder(params: { const baseOrder = explicitOrder ?? (explicitProfiles.length > 0 ? explicitProfiles : storeProfiles); if (baseOrder.length === 0) { - return []; + return { profileIds: [], hasExplicitOrder: explicitOrder !== undefined }; } - const isValidProfile = (profileId: string): boolean => - resolveAuthProfileEligibility({ + const isValidProfile = (profileId: string): boolean => { + const eligibility = resolveAuthProfileEligibility({ cfg, store, provider, profileId, now, - }).eligible; + }); + return ( + eligibility.eligible || + (params.readinessMode === "read-only" && eligibility.reasonCode === "unresolved_ref") + ); + }; let filtered = baseOrder.filter(isValidProfile); let repairedFallbackToStoreProfiles = false; @@ -354,7 +372,7 @@ export function resolveAuthProfileOrder(params: { const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = []; for (const profileId of deduped) { - if (isProfileInCooldown(store, profileId)) { + if (isProfileInCooldown(store, profileId, now, forModel)) { const cooldownUntil = resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now; inCooldown.push({ profileId, cooldownUntil }); @@ -371,20 +389,31 @@ export function resolveAuthProfileOrder(params: { // Explicit user choice still wins when it is part of the filtered order. if (preferredProfile && ordered.includes(preferredProfile)) { - return [preferredProfile, ...ordered.filter((e) => e !== preferredProfile)]; + return { + profileIds: [preferredProfile, ...ordered.filter((e) => e !== preferredProfile)], + hasExplicitOrder: true, + }; } - return ordered; + return { profileIds: ordered, hasExplicitOrder: true }; } // Otherwise, use round-robin by lastUsed. lastGood is intentionally ignored // because prioritizing it would starve other healthy profiles. - const sorted = orderProfilesByMode(deduped, store); + const sorted = orderProfilesByMode(deduped, store, now, forModel); if (preferredProfile && sorted.includes(preferredProfile)) { - return [preferredProfile, ...sorted.filter((e) => e !== preferredProfile)]; + return { + profileIds: [preferredProfile, ...sorted.filter((e) => e !== preferredProfile)], + hasExplicitOrder: explicitOrder !== undefined, + }; } - return sorted; + return { profileIds: sorted, hasExplicitOrder: explicitOrder !== undefined }; +} + +/** Resolves ordered usable auth profile ids for a provider. */ +export function resolveAuthProfileOrder(params: ResolveAuthProfileOrderParams): string[] { + return resolveAuthProfileOrderWithMetadata(params).profileIds; } function resolveAuthOrder( @@ -421,15 +450,18 @@ function mergeAliasOrderWithNativeProfiles(params: { ); } -function orderProfilesByMode(order: string[], store: AuthProfileStore): string[] { - const now = Date.now(); - +function orderProfilesByMode( + order: string[], + store: AuthProfileStore, + now: number, + forModel?: string, +): string[] { // Partition into available and in-cooldown const available: string[] = []; const inCooldown: string[] = []; for (const profileId of order) { - if (isProfileInCooldown(store, profileId)) { + if (isProfileInCooldown(store, profileId, now, forModel)) { inCooldown.push(profileId); } else { available.push(profileId); diff --git a/src/agents/auth-profiles/read-only-availability.test.ts b/src/agents/auth-profiles/read-only-availability.test.ts new file mode 100644 index 000000000000..b8b276504d99 --- /dev/null +++ b/src/agents/auth-profiles/read-only-availability.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveStoredCredentialReadOnlyAvailability } from "./read-only-availability.js"; + +const cfg = { + secrets: { + providers: { + vault: { source: "env" }, + }, + }, +} satisfies OpenClawConfig; + +describe("resolveStoredCredentialReadOnlyAvailability", () => { + it("prefers explicit secret refs over retained inline values", () => { + expect( + resolveStoredCredentialReadOnlyAvailability({ + credential: { + type: "api_key", + provider: "test", + key: "kept", + keyRef: { source: "env", provider: "vault", id: "MISSING_KEY" }, + }, + cfg, + env: {}, + }), + ).toBeUndefined(); + expect( + resolveStoredCredentialReadOnlyAvailability({ + credential: { + type: "token", + provider: "test", + token: "kept", + tokenRef: { source: "env", provider: "vault", id: "MISSING_TOKEN" }, + }, + cfg, + env: {}, + }), + ).toBeUndefined(); + }); + + it("rejects expired static tokens before checking their secret ref", () => { + const now = Date.now(); + expect( + resolveStoredCredentialReadOnlyAvailability({ + credential: { + type: "token", + provider: "test", + token: "kept", + tokenRef: { source: "env", provider: "vault", id: "MISSING_TOKEN" }, + expires: now, + }, + cfg, + env: {}, + now, + }), + ).toBe(false); + expect( + resolveStoredCredentialReadOnlyAvailability({ + credential: { + type: "token", + provider: "test", + token: "kept", + expires: "invalid" as never, + }, + cfg, + env: {}, + now, + }), + ).toBe(false); + }); + + it("requires an explicit provider refresh capability for refresh-only OAuth", () => { + const credential = { + type: "oauth" as const, + provider: "test", + access: "", + refresh: "refresh", + expires: 0, + }; + expect( + resolveStoredCredentialReadOnlyAvailability({ credential, cfg, env: {} }), + ).toBeUndefined(); + expect( + resolveStoredCredentialReadOnlyAvailability({ + credential, + cfg, + env: {}, + canRefreshOAuth: true, + }), + ).toBe(true); + }); +}); diff --git a/src/agents/auth-profiles/read-only-availability.ts b/src/agents/auth-profiles/read-only-availability.ts new file mode 100644 index 000000000000..82c614cdd7a9 --- /dev/null +++ b/src/agents/auth-profiles/read-only-availability.ts @@ -0,0 +1,128 @@ +/** Pure, non-resolving credential availability checks shared by status and route selection. */ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + isSecretRef, + LEGACY_DOUBLE_UNDERSCORE_ENV_MARKER_PREFIX, + resolveSecretInputRef, +} from "../../config/types.secrets.js"; +import { + isValidSecretRef, + resolveDefaultSecretProviderAlias, + SINGLE_VALUE_FILE_REF_ID, +} from "../../secrets/ref-contract.js"; +import { + isKnownEnvApiKeyMarker, + isNonSecretApiKeyMarker, + SECRETREF_ENV_HEADER_MARKER_PREFIX, +} from "../model-auth-markers.js"; +import { hasUsableOAuthCredential, resolveTokenExpiryState } from "./credential-state.js"; +import type { AuthProfileCredential } from "./types.js"; + +type ReadOnlyCredentialAvailability = boolean | undefined; + +function hasSecret(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export function hasMalformedSecretInputSyntax(value: unknown): boolean { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + return ( + trimmed.startsWith(SECRETREF_ENV_HEADER_MARKER_PREFIX) || + trimmed.startsWith(LEGACY_DOUBLE_UNDERSCORE_ENV_MARKER_PREFIX) || + trimmed.startsWith("$") + ); +} + +export function resolveSecretRefReadOnlyAvailability( + value: unknown, + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): ReadOnlyCredentialAvailability { + if (!isSecretRef(value) || !isValidSecretRef(value)) { + return false; + } + const source = cfg.secrets?.providers?.[value.provider]; + if ( + (!source && + (value.source !== "env" || + value.provider !== resolveDefaultSecretProviderAlias(cfg, "env"))) || + (source && source.source !== value.source) + ) { + return false; + } + if (value.source === "env") { + return source?.source === "env" && source.allowlist && !source.allowlist.includes(value.id) + ? false + : hasSecret(env[value.id]) + ? true + : undefined; + } + if ( + value.source === "file" && + source?.source === "file" && + (source.mode === "singleValue") !== (value.id === SINGLE_VALUE_FILE_REF_ID) + ) { + return false; + } + return undefined; +} + +function resolveSecretInputReadOnlyAvailability( + value: unknown, + refValue: unknown, + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): ReadOnlyCredentialAvailability { + const { ref } = resolveSecretInputRef({ + value, + refValue, + defaults: cfg.secrets?.defaults, + }); + if (ref) { + return resolveSecretRefReadOnlyAvailability(ref, cfg, env); + } + if (!hasSecret(value)) { + return false; + } + if (hasMalformedSecretInputSyntax(value)) { + return false; + } + return isKnownEnvApiKeyMarker(value) + ? hasSecret(env[value.trim()]) + : isNonSecretApiKeyMarker(value) + ? undefined + : true; +} + +export function resolveStoredCredentialReadOnlyAvailability(params: { + credential: AuthProfileCredential; + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + now?: number; + canRefreshOAuth?: boolean; +}): ReadOnlyCredentialAvailability { + const { credential, cfg, env } = params; + const now = params.now ?? Date.now(); + if (credential.type === "api_key") { + return resolveSecretInputReadOnlyAvailability(credential.key, credential.keyRef, cfg, env); + } + if (credential.type === "token") { + const expiryState = resolveTokenExpiryState(credential.expires, now); + if (expiryState === "expired" || expiryState === "invalid_expires") { + return false; + } + return resolveSecretInputReadOnlyAvailability(credential.token, credential.tokenRef, cfg, env); + } + if (hasUsableOAuthCredential(credential, { now })) { + return true; + } + // Refresh material is runnable only when the caller owns a refresh path. + // Ref-only OAuth may hydrate from the runtime snapshot, so it stays unknown. + if (hasSecret(credential.refresh)) { + return params.canRefreshOAuth ? true : undefined; + } + return credential.oauthRef && !hasSecret(credential.access) ? undefined : false; +} diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index b4b7e8fb205a..b27924e428bf 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -2,7 +2,13 @@ import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; -import { looksLikeSecretSentinel, resolveSecretSentinel } from "../secrets/sentinel.js"; +import { + looksLikeSecretSentinel, + mintSecretSentinel, + resolveSecretSentinel, +} from "../secrets/sentinel.js"; +import type { AgentHarness } from "./harness/types.js"; +import type { AgentRuntimeAuthPlan } from "./runtime-plan/types.js"; const streamSimpleMock = vi.fn(); const readFileMock = vi.fn(); @@ -32,6 +38,11 @@ const executePreparedCliRunMock = vi.fn(); const diagDebugMock = vi.fn(); const ensureSelectedAgentHarnessPluginMock = vi.fn(); const loadTranscriptEventsMock = vi.fn(); +const shouldPreferExplicitConfigApiKeyAuthMock = vi.fn((..._args: unknown[]) => false); +const hasUsableCustomProviderApiKeyMock = vi.fn((..._args: unknown[]) => false); +const resolveProviderEntryApiKeyProfileReferenceMock = vi.fn((_params?: unknown): unknown => ({ + kind: "none", +})); vi.mock("../llm/stream.js", async () => { const original = await vi.importActual("../llm/stream.js"); @@ -80,7 +91,12 @@ vi.mock("./model-auth.js", () => ({ ensureAuthProfileStoreWithoutExternalProfiles: (...args: unknown[]) => ensureAuthProfileStoreWithoutExternalProfilesMock(...args), getApiKeyForModel: (...args: unknown[]) => getApiKeyForModelMock(...args), + hasUsableCustomProviderApiKey: (...args: unknown[]) => hasUsableCustomProviderApiKeyMock(...args), requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args), + resolveProviderEntryApiKeyProfileReference: (params: unknown) => + resolveProviderEntryApiKeyProfileReferenceMock(params), + shouldPreferExplicitConfigApiKeyAuth: (...args: unknown[]) => + shouldPreferExplicitConfigApiKeyAuthMock(...args), })); vi.mock("./model-runtime-aliases.js", () => ({ @@ -262,6 +278,50 @@ function mockDoneAnswer(text: string) { streamSimpleMock.mockReturnValue(makeAsyncEvents([createDoneEvent(text)])); } +function mockCliOutput(output: { text: string; rawText?: string }) { + const cleanup = vi.fn(async () => undefined); + const prepared = { prepared: true, preparedBackend: { cleanup } }; + prepareCliRunContextMock.mockResolvedValueOnce(prepared); + executePreparedCliRunMock.mockResolvedValueOnce(output); + return { cleanup, prepared }; +} + +function registerCodexSideQuestionHarness( + overrides: Partial> = {}, +) { + const runHarnessSideQuestion = vi.fn().mockResolvedValue({ text: "Codex side answer." }); + registerAgentHarness({ + id: "codex", + label: "Codex test harness", + supports: () => ({ supported: true, priority: 100 }), + runAttempt: vi.fn(), + runSideQuestion: runHarnessSideQuestion, + ...overrides, + }); + return runHarnessSideQuestion; +} + +function supportsPreparedOpenAIAuth(ctx: Parameters[0]) { + if (ctx.provider !== "openai") { + return { supported: false as const, reason: "Codex only supports OpenAI providers" }; + } + const preparedAuth = ctx.modelProvider?.preparedAuth; + if (preparedAuth?.requirement === "subscription") { + return preparedAuth.source === "profile" && + (preparedAuth.mode === "oauth" || preparedAuth.mode === "token") + ? { supported: true as const, priority: 100 } + : { supported: false as const, reason: "subscription auth is not reproducible" }; + } + if (preparedAuth?.requirement === "api-key") { + return preparedAuth.source !== "none" && + preparedAuth.source !== "harness" && + (preparedAuth.mode === "api-key" || preparedAuth.mode === "api_key") + ? { supported: true as const, priority: 100 } + : { supported: false as const, reason: "Platform auth is not reproducible" }; + } + return { supported: true as const, priority: 100 }; +} + function runSideQuestion(overrides: Partial = {}) { return runBtwSideQuestion({ cfg: {} as never, @@ -446,6 +506,20 @@ function expectSeedOnlyUserContext(context: unknown) { expectRecordFields(messages[1], { role: "user" }); } +function mockOpenAIPlatformProfile(): void { + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "profile-1": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + order: { openai: ["profile-1"] }, + }); +} + describe("runBtwSideQuestion", () => { beforeEach(() => { streamSimpleMock.mockReset(); @@ -476,6 +550,12 @@ describe("runBtwSideQuestion", () => { diagDebugMock.mockReset(); ensureSelectedAgentHarnessPluginMock.mockReset(); loadTranscriptEventsMock.mockReset(); + shouldPreferExplicitConfigApiKeyAuthMock.mockReset(); + shouldPreferExplicitConfigApiKeyAuthMock.mockReturnValue(false); + hasUsableCustomProviderApiKeyMock.mockReset(); + hasUsableCustomProviderApiKeyMock.mockReturnValue(false); + resolveProviderEntryApiKeyProfileReferenceMock.mockReset(); + resolveProviderEntryApiKeyProfileReferenceMock.mockReturnValue({ kind: "none" }); clearAgentHarnesses(); readFileMock.mockResolvedValue("mock transcript"); @@ -505,7 +585,12 @@ describe("runBtwSideQuestion", () => { }); ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: {} }); ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue({ version: 1, profiles: {} }); - getApiKeyForModelMock.mockResolvedValue({ apiKey: "secret", mode: "api-key", source: "test" }); + getApiKeyForModelMock.mockImplementation(async (params: { profileId?: string } = {}) => ({ + apiKey: "secret", + mode: "api-key", + source: params.profileId ? `profile:${params.profileId}` : "test", + ...(params.profileId ? { profileId: params.profileId } : {}), + })); requireApiKeyMock.mockReturnValue("secret"); resolveSessionAuthProfileOverrideMock.mockResolvedValue("profile-1"); getActiveEmbeddedRunSnapshotMock.mockReturnValue(undefined); @@ -610,28 +695,49 @@ describe("runBtwSideQuestion", () => { expect(ensureArgs?.[1]).toBe(DEFAULT_AGENT_DIR); expect(ensureArgs?.[2]).toEqual({ workspaceDir: "/tmp/workspace" }); expect(discoverModelsMock).toHaveBeenCalledWith(undefined, DEFAULT_AGENT_DIR, { + config: ensureArgs?.[0], workspaceDir: "/tmp/workspace", }); }); it("routes Codex-selected BTW questions through the harness side-question hook", async () => { - const codexSideQuestionMock = vi.fn().mockResolvedValue({ text: "Codex side answer." }); - registerAgentHarness({ - id: "codex", - label: "Codex test harness", - supports: ({ provider }) => - provider === "openai" - ? { supported: true, priority: 100 } - : { supported: false, reason: "Codex only supports OpenAI providers" }, - runAttempt: vi.fn(), - runSideQuestion: codexSideQuestionMock, + const supports = vi.fn(supportsPreparedOpenAIAuth); + const codexSideQuestionMock = registerCodexSideQuestionHarness({ + supports, }); resolveModelWithRegistryMock.mockReturnValue({ provider: "openai", id: "gpt-5.5", api: "openai-responses", + baseUrl: "https://api.openai.com/v1", }); resolveSessionAuthProfileOverrideMock.mockResolvedValue("openai:work"); + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai:work": { + type: "token", + provider: "openai", + token: "subscription-token", + expires: Date.now() + 60_000, + }, + }, + order: { openai: ["openai:work"] }, + }); + resolveModelAsyncMock.mockResolvedValue({ + model: { + provider: "openai", + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + }); + getApiKeyForModelMock.mockResolvedValue({ + apiKey: "subscription-token", + mode: "token", + source: "profile:openai:work", + profileId: "openai:work", + }); const result = await runSideQuestion({ provider: "openai", @@ -651,54 +757,328 @@ describe("runBtwSideQuestion", () => { expect(result).toEqual({ text: "Codex side answer." }); expect(codexSideQuestionMock).toHaveBeenCalledTimes(1); - const [[sideQuestionParams]] = codexSideQuestionMock.mock.calls as unknown as Array< - [ - { - provider?: string; - model?: string; - question?: string; - sessionId?: string; - agentId?: string; - workspaceDir?: string; - authProfileId?: string; - sandboxSessionKey?: string; - agentAccountId?: string; - groupId?: string; - groupChannel?: string; - groupSpace?: string; - spawnedBy?: string; - senderId?: string; - senderName?: string; - senderUsername?: string; - senderE164?: string; - toolsAllow?: string[]; - }, - ] - >; - expect(sideQuestionParams.provider).toBe("openai"); - expect(sideQuestionParams.model).toBe("gpt-5.5"); - expect(sideQuestionParams.question).toBe(DEFAULT_QUESTION); - expect(sideQuestionParams.sessionId).toBe("session-1"); - expect(sideQuestionParams.agentId).toBe("main"); - expect(sideQuestionParams.workspaceDir).toBe("/tmp/workspace"); - expect(sideQuestionParams.authProfileId).toBe("openai:work"); - expect(sideQuestionParams).toMatchObject({ - agentAccountId: "account-1", - sandboxSessionKey: "agent:main:runtime-policy", - groupId: "group-1", - groupChannel: "#ops", - groupSpace: "workspace-1", - spawnedBy: "agent:main:parent", - senderId: "sender-1", - senderName: "Rosita", - senderUsername: "rosita", - senderE164: "+15550001", - }); + expect(codexSideQuestionMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + model: "gpt-5.5", + question: DEFAULT_QUESTION, + sessionId: "session-1", + agentId: "main", + workspaceDir: "/tmp/workspace", + authProfileId: "openai:work", + agentAccountId: "account-1", + sandboxSessionKey: "agent:main:runtime-policy", + groupId: "group-1", + groupChannel: "#ops", + groupSpace: "workspace-1", + spawnedBy: "agent:main:parent", + senderId: "sender-1", + senderName: "Rosita", + senderUsername: "rosita", + senderE164: "+15550001", + runtimeModel: expect.objectContaining({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }), + }), + ); + expect(resolveModelAsyncMock).toHaveBeenCalledWith( + "openai", + "gpt-5.5", + DEFAULT_AGENT_DIR, + expect.any(Object), + expect.objectContaining({ authProfileMode: "token" }), + ); expect( (mockArg(codexSideQuestionMock, 0, 0) as { sessionFile?: string }).sessionFile, ).toContain("session-1.jsonl"); expect(streamSimpleMock).not.toHaveBeenCalled(); expect(registerProviderStreamForModelMock).not.toHaveBeenCalled(); + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: expect.objectContaining({ + preparedAuth: { + source: "profile", + mode: "token", + requirement: "subscription", + }, + }), + }), + ); + }); + + it("keeps an unprofiled subscription token on the OpenClaw BTW path", async () => { + const supports = vi.fn(supportsPreparedOpenAIAuth); + const codexSideQuestionMock = registerCodexSideQuestionHarness({ supports }); + const subscriptionModel = { + provider: "openai", + id: "gpt-5.5", + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + resolveModelWithRegistryMock.mockReturnValue(subscriptionModel); + resolveModelAsyncMock.mockResolvedValue({ model: subscriptionModel }); + resolveSessionAuthProfileOverrideMock.mockResolvedValue(undefined); + ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: {} }); + resolveProviderEntryApiKeyProfileReferenceMock.mockReturnValue({ kind: "literal" }); + getApiKeyForModelMock.mockResolvedValue({ + apiKey: "subscription-token", + mode: "token", + source: "models.json", + }); + requireApiKeyMock.mockReturnValue("subscription-token"); + mockDoneAnswer("OpenClaw side answer."); + + await expect( + runSideQuestion({ + cfg: { + models: { + providers: { + openai: { auth: "token", apiKey: "subscription-token" }, + }, + }, + } as never, + provider: "openai", + model: "gpt-5.5", + }), + ).resolves.toEqual({ text: "OpenClaw side answer." }); + + expect(codexSideQuestionMock).not.toHaveBeenCalled(); + expect(streamSimpleMock).toHaveBeenCalled(); + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: expect.objectContaining({ + preparedAuth: { + source: "direct", + mode: "token", + requirement: "subscription", + }, + }), + }), + ); + }); + + it("lets Codex reproduce an unprofiled Platform API key", async () => { + const supports = vi.fn(supportsPreparedOpenAIAuth); + const codexSideQuestionMock = registerCodexSideQuestionHarness({ supports }); + const platformModel = { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses" as const, + baseUrl: "https://api.openai.com/v1", + }; + resolveModelWithRegistryMock.mockReturnValue(platformModel); + resolveModelAsyncMock.mockResolvedValue({ model: platformModel }); + resolveSessionAuthProfileOverrideMock.mockResolvedValue(undefined); + ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: {} }); + resolveProviderEntryApiKeyProfileReferenceMock.mockReturnValue({ kind: "literal" }); + getApiKeyForModelMock.mockResolvedValue({ + apiKey: "platform-key", + mode: "api-key", + source: "models.json", + }); + + await expect( + runSideQuestion({ + cfg: { + models: { providers: { openai: { apiKey: "platform-key" } } }, + } as never, + provider: "openai", + model: "gpt-5.5", + }), + ).resolves.toEqual({ text: "Codex side answer." }); + + expect(codexSideQuestionMock).toHaveBeenCalledOnce(); + expect( + ( + mockArg(codexSideQuestionMock, 0, 0) as { + preparedRuntimeAuth?: { resolvedApiKey?: string }; + } + ).preparedRuntimeAuth?.resolvedApiKey, + ).toBe("platform-key"); + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: expect.objectContaining({ + preparedAuth: { + source: "direct", + mode: "api-key", + requirement: "api-key", + }, + }), + }), + ); + }); + + it("lets native Codex bootstrap auth without a host profile", async () => { + const supports = vi.fn((ctx: Parameters[0]) => { + if (ctx.modelProvider?.preparedAuth?.source !== "harness") { + return supportsPreparedOpenAIAuth(ctx); + } + return ctx.modelProvider.requestTransportOverrides === "none" && + ctx.modelProvider.runtimePolicy?.compatibleIds.includes("codex") + ? { supported: true as const, priority: 100 } + : { supported: false as const, reason: "deferred route support is missing" }; + }); + const codexSideQuestionMock = registerCodexSideQuestionHarness({ + authBootstrap: "harness", + supports, + }); + const platformModel = { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses" as const, + baseUrl: "https://api.openai.com/v1", + }; + resolveModelWithRegistryMock.mockReturnValue(platformModel); + resolveSessionAuthProfileOverrideMock.mockResolvedValue(undefined); + ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: {} }); + + await expect(runSideQuestion({ provider: "openai", model: "gpt-5.5" })).resolves.toEqual({ + text: "Codex side answer.", + }); + + expect(getApiKeyForModelMock).not.toHaveBeenCalled(); + expect(codexSideQuestionMock).toHaveBeenCalledOnce(); + const preparedRuntimeAuth = ( + mockArg(codexSideQuestionMock, 0, 0) as { + preparedRuntimeAuth?: { + plan?: AgentRuntimeAuthPlan; + authProfileStore?: { profiles?: Record }; + resolvedApiKey?: string; + }; + } + ).preparedRuntimeAuth; + expect(preparedRuntimeAuth?.plan).toMatchObject({ + harnessAuthProvider: "openai", + deferredRouteSupport: { + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + }); + expect(preparedRuntimeAuth?.plan?.modelRoute).toBeUndefined(); + expect(preparedRuntimeAuth?.plan?.forwardedAuthProfileId).toBeUndefined(); + expect(preparedRuntimeAuth?.resolvedApiKey).toBeUndefined(); + expect(Object.keys(preparedRuntimeAuth?.authProfileStore?.profiles ?? {})).toEqual([]); + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: expect.objectContaining({ + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + preparedAuth: { source: "harness" }, + }), + }), + ); + }); + + it("hands a Codex side question the resolved Platform backup after subscription failure", async () => { + const supports = vi.fn(supportsPreparedOpenAIAuth); + const codexSideQuestionMock = registerCodexSideQuestionHarness({ + supports, + }); + const subscriptionModel = { + provider: "openai", + id: "gpt-5.5", + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + const platformModel = { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses" as const, + baseUrl: "https://api.openai.com/v1", + }; + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai:subscription": { + type: "token", + provider: "openai", + token: "unresolved-token", + expires: Date.now() + 60_000, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + order: { openai: ["openai:subscription", "openai:platform"] }, + }); + resolveSessionAuthProfileOverrideMock.mockResolvedValue(undefined); + resolveModelWithRegistryMock.mockReturnValue(platformModel); + resolveModelAsyncMock.mockImplementation( + async ( + _provider: string, + _modelId: string, + _agentDir: string, + _config: unknown, + options?: { authProfileId?: string }, + ) => ({ + model: options?.authProfileId === "openai:subscription" ? subscriptionModel : platformModel, + }), + ); + getApiKeyForModelMock.mockImplementation(async (authParams: { profileId?: string }) => { + if (authParams.profileId === "openai:subscription") { + throw new Error("subscription credential resolution failed"); + } + return { + apiKey: "platform-key", + mode: "api-key", + source: "profile:openai:platform", + profileId: "openai:platform", + }; + }); + + await expect( + runSideQuestion({ + cfg: { + auth: { + order: { openai: ["openai:subscription", "openai:platform"] }, + }, + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + } as never, + provider: "openai", + model: "gpt-5.5", + sessionKey: DEFAULT_SESSION_KEY, + }), + ).resolves.toEqual({ text: "Codex side answer." }); + + const sideQuestionParams = mockArg(codexSideQuestionMock, 0, 0) as { + authProfileId?: string; + runtimeModel?: { api?: string; baseUrl?: string }; + preparedRuntimeAuth?: { + resolvedApiKey?: string; + plan?: { modelRoute?: { authRequirement?: string } }; + authProfileStore?: { profiles?: Record }; + }; + }; + expect(sideQuestionParams.runtimeModel).toMatchObject(platformModel); + expect(sideQuestionParams.authProfileId).toBeUndefined(); + expect(sideQuestionParams.preparedRuntimeAuth).toMatchObject({ + resolvedApiKey: "platform-key", + plan: { modelRoute: { authRequirement: "api-key" } }, + }); + expect( + Object.keys(sideQuestionParams.preparedRuntimeAuth?.authProfileStore?.profiles ?? {}), + ).toEqual([]); + expect(streamSimpleMock).not.toHaveBeenCalled(); + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: expect.objectContaining({ + preparedAuth: { + source: "profile", + mode: "api-key", + requirement: "api-key", + }, + }), + }), + ); }); it("keeps a model-locked session on its persisted harness for BTW", async () => { @@ -740,23 +1120,46 @@ describe("runBtwSideQuestion", () => { }); it("reselects the Codex hook after resolving legacy openai-codex route state", async () => { - const codexSideQuestionMock = vi.fn().mockResolvedValue({ text: "Codex side answer." }); - registerAgentHarness({ - id: "codex", - label: "Codex test harness", + const codexSideQuestionMock = registerCodexSideQuestionHarness({ supports: (ctx) => ctx.provider === "openai" ? { supported: true, priority: 100 } : { supported: false, reason: "openai only" }, - runAttempt: vi.fn(), - runSideQuestion: codexSideQuestionMock, }); resolveModelWithRegistryMock.mockReturnValue({ provider: "openai", id: "gpt-5.5", api: "openai-responses", + baseUrl: "https://api.openai.com/v1", }); resolveSessionAuthProfileOverrideMock.mockResolvedValue("openai-codex:user@example.test"); + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai-codex:user@example.test": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + order: { openai: ["openai-codex:user@example.test"] }, + }); + resolveModelAsyncMock.mockResolvedValue({ + model: { + provider: "openai", + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + }); + getApiKeyForModelMock.mockResolvedValue({ + apiKey: "subscription-token", + mode: "oauth", + source: "profile:openai-codex:user@example.test", + profileId: "openai-codex:user@example.test", + }); const result = await runSideQuestion({ cfg: { @@ -785,9 +1188,26 @@ describe("runBtwSideQuestion", () => { const sideQuestionParams = mockArg(codexSideQuestionMock, 0, 0) as { provider?: string; authProfileId?: string; + runtimeModel?: { api?: string; baseUrl?: string }; + preparedRuntimeAuth?: { + plan?: { modelRoute?: { api?: string; baseUrl?: string; authRequirement?: string } }; + authProfileStore?: { profiles?: Record }; + }; }; expect(sideQuestionParams.provider).toBe("openai"); expect(sideQuestionParams.authProfileId).toBe("openai-codex:user@example.test"); + expect(sideQuestionParams.runtimeModel).toMatchObject({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }); + expect(sideQuestionParams.preparedRuntimeAuth?.plan?.modelRoute).toMatchObject({ + api: sideQuestionParams.runtimeModel?.api, + baseUrl: sideQuestionParams.runtimeModel?.baseUrl, + authRequirement: "subscription", + }); + expect( + Object.keys(sideQuestionParams.preparedRuntimeAuth?.authProfileStore?.profiles ?? {}), + ).toEqual(["openai-codex:user@example.test"]); const authArgs = mockArg(resolveSessionAuthProfileOverrideMock, 0, 0) as { provider?: string; acceptedProviderIds?: string[]; @@ -799,19 +1219,21 @@ describe("runBtwSideQuestion", () => { }); it("prepares deny-all sender policy before calling a plugin side-question hook", async () => { - const codexSideQuestionMock = vi.fn().mockResolvedValue({ text: "Policy answer." }); - registerAgentHarness({ - id: "codex", - label: "Codex test harness", - supports: () => ({ supported: true, priority: 100 }), - runAttempt: vi.fn(), - runSideQuestion: codexSideQuestionMock, - }); + const codexSideQuestionMock = registerCodexSideQuestionHarness(); + mockOpenAIPlatformProfile(); resolveModelWithRegistryMock.mockReturnValue({ provider: "openai", id: "gpt-5.5", api: "openai-responses", }); + resolveModelAsyncMock.mockResolvedValue({ + model: { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + }); await runSideQuestion({ cfg: { channels: { @@ -839,19 +1261,21 @@ describe("runBtwSideQuestion", () => { }); it("prepares a narrow global policy before calling a plugin side-question hook", async () => { - const codexSideQuestionMock = vi.fn().mockResolvedValue({ text: "Policy answer." }); - registerAgentHarness({ - id: "codex", - label: "Codex test harness", - supports: () => ({ supported: true, priority: 100 }), - runAttempt: vi.fn(), - runSideQuestion: codexSideQuestionMock, - }); + const codexSideQuestionMock = registerCodexSideQuestionHarness(); + mockOpenAIPlatformProfile(); resolveModelWithRegistryMock.mockReturnValue({ provider: "openai", id: "gpt-5.5", api: "openai-responses", }); + resolveModelAsyncMock.mockResolvedValue({ + model: { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + }); await runSideQuestion({ cfg: { tools: { allow: ["message"] } } as never, @@ -948,12 +1372,7 @@ describe("runBtwSideQuestion", () => { }); it("runs CLI-runtime alias BTW as an ephemeral CLI side question", async () => { - const cleanup = vi.fn(async () => undefined); - prepareCliRunContextMock.mockResolvedValueOnce({ - prepared: true, - preparedBackend: { cleanup }, - }); - executePreparedCliRunMock.mockResolvedValueOnce({ text: "CLI side answer." }); + const { cleanup, prepared } = mockCliOutput({ text: "CLI side answer." }); const result = await runSideQuestion({ cfg: { @@ -988,10 +1407,7 @@ describe("runBtwSideQuestion", () => { expect(prepareParams.extraSystemPrompt).toContain("Answer only the side question"); expect(prepareParams.prompt).toContain(""); expect(prepareParams.prompt).toContain(""); - expect(executePreparedCliRunMock).toHaveBeenCalledWith({ - prepared: true, - preparedBackend: { cleanup }, - }); + expect(executePreparedCliRunMock).toHaveBeenCalledWith(prepared); expect(cleanup).toHaveBeenCalledTimes(1); expect(getApiKeyForModelMock).not.toHaveBeenCalled(); expect(streamSimpleMock).not.toHaveBeenCalled(); @@ -999,12 +1415,7 @@ describe("runBtwSideQuestion", () => { }); it("preserves the explicit no-timeout override for CLI-runtime BTW", async () => { - const cleanup = vi.fn(async () => undefined); - prepareCliRunContextMock.mockResolvedValueOnce({ - prepared: true, - preparedBackend: { cleanup }, - }); - executePreparedCliRunMock.mockResolvedValueOnce({ text: "CLI side answer." }); + mockCliOutput({ text: "CLI side answer." }); await runSideQuestion({ cfg: { @@ -1030,12 +1441,7 @@ describe("runBtwSideQuestion", () => { }); it("runs auth-order-selected CLI BTW through the CLI side-question path", async () => { - const cleanup = vi.fn(async () => undefined); - prepareCliRunContextMock.mockResolvedValueOnce({ - prepared: true, - preparedBackend: { cleanup }, - }); - executePreparedCliRunMock.mockResolvedValueOnce({ text: "CLI auth-order side answer." }); + const { cleanup } = mockCliOutput({ text: "CLI auth-order side answer." }); const result = await runSideQuestion({ cfg: { @@ -1066,12 +1472,7 @@ describe("runBtwSideQuestion", () => { }); it("does not expose raw CLI BTW output when transformed text is empty", async () => { - const cleanup = vi.fn(async () => undefined); - prepareCliRunContextMock.mockResolvedValueOnce({ - prepared: true, - preparedBackend: { cleanup }, - }); - executePreparedCliRunMock.mockResolvedValueOnce({ + const { cleanup } = mockCliOutput({ text: " ", rawText: "raw untransformed answer", }); @@ -1097,12 +1498,7 @@ describe("runBtwSideQuestion", () => { }); it("does not let an auto-selected stale direct profile suppress auth-order CLI BTW", async () => { - const cleanup = vi.fn(async () => undefined); - prepareCliRunContextMock.mockResolvedValueOnce({ - prepared: true, - preparedBackend: { cleanup }, - }); - executePreparedCliRunMock.mockResolvedValueOnce({ text: "Claude CLI answer." }); + const { cleanup } = mockCliOutput({ text: "Claude CLI answer." }); const result = await runSideQuestion({ cfg: { @@ -1134,56 +1530,13 @@ describe("runBtwSideQuestion", () => { expect(streamSimpleMock).not.toHaveBeenCalled(); }); - it("uses an auto-selected session CLI auth profile for CLI BTW", async () => { - const cleanup = vi.fn(async () => undefined); - prepareCliRunContextMock.mockResolvedValueOnce({ - prepared: true, - preparedBackend: { cleanup }, - }); - executePreparedCliRunMock.mockResolvedValueOnce({ text: "Session Claude CLI answer." }); - - const result = await runSideQuestion({ - cfg: { - auth: { - order: { anthropic: ["anthropic:api"] }, - profiles: { - "anthropic:api": { provider: "anthropic", mode: "api_key" }, - "anthropic:auto-cli": { provider: "claude-cli", mode: "oauth" }, - }, - }, - } as never, - sessionEntry: createSessionEntry({ - authProfileOverride: "anthropic:auto-cli", - authProfileOverrideSource: "auto", - }), - }); - - expect(result).toEqual({ text: "Session Claude CLI answer." }); - const prepareParams = mockArg(prepareCliRunContextMock, 0, 0) as { - provider?: string; - authProfileId?: string; - executionMode?: string; - }; - expect(prepareParams.provider).toBe("claude-cli"); - expect(prepareParams.executionMode).toBe("side-question"); - expect(prepareParams.authProfileId).toBe("anthropic:auto-cli"); - expect(cleanup).toHaveBeenCalledTimes(1); - expect(getApiKeyForModelMock).not.toHaveBeenCalled(); - expect(streamSimpleMock).not.toHaveBeenCalled(); - }); - it("preserves auto-selected session CLI BTW routing before resolving runtime auth", async () => { - const cleanup = vi.fn(async () => undefined); + const { cleanup } = mockCliOutput({ text: "Session Claude CLI answer." }); const sessionEntry = createSessionEntry({ authProfileOverride: "anthropic:auto-cli", authProfileOverrideSource: "auto", }); const sessionStore = { [DEFAULT_SESSION_KEY]: sessionEntry }; - prepareCliRunContextMock.mockResolvedValueOnce({ - prepared: true, - preparedBackend: { cleanup }, - }); - executePreparedCliRunMock.mockResolvedValueOnce({ text: "Session Claude CLI answer." }); resolveSessionAuthProfileOverrideMock.mockImplementation( async (params: { sessionEntry?: SessionEntry }) => { if (params.sessionEntry) { @@ -1267,12 +1620,325 @@ describe("runBtwSideQuestion", () => { allowKeychainPrompt: false, }); expectRecordFields(mockArg(getApiKeyForModelMock, 0, 0), { - profileId: undefined, + profileId: "anthropic:claude-cli", store: claudeAuthStore, }); }); - it("keeps user-locked static Anthropic auth for BTW", async () => { + it("rematerializes the direct model when automatic auth rotates to a SecretRef backup", async () => { + const authStorage = { id: "btw-auth-storage" }; + const modelRegistry = { id: "btw-model-registry" }; + const authStore = { + version: 1 as const, + profiles: { + "anthropic:primary": { + type: "api_key" as const, + provider: "anthropic", + key: "primary-key", + }, + "anthropic:backup": { + type: "api_key" as const, + provider: "anthropic", + keyRef: { + source: "file" as const, + provider: "vault", + id: "/anthropic/backup", + }, + }, + }, + order: { anthropic: ["anthropic:primary", "anthropic:backup"] }, + }; + const rotatedModel = { + provider: "anthropic", + id: DEFAULT_MODEL, + api: "anthropic-messages" as const, + baseUrl: "https://backup.example.test", + name: "Backup profile model", + }; + discoverAuthStorageMock.mockReturnValue(authStorage); + discoverModelsMock.mockReturnValue(modelRegistry); + resolveModelWithRegistryMock.mockReturnValue({ + provider: "anthropic", + id: DEFAULT_MODEL, + api: "anthropic-messages", + baseUrl: "https://primary.example.test", + name: "Primary profile model", + }); + resolveModelAsyncMock.mockResolvedValue({ + model: rotatedModel, + authStorage, + modelRegistry, + }); + ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue(authStore); + resolveSessionAuthProfileOverrideMock.mockResolvedValue(undefined); + getApiKeyForModelMock.mockImplementation(async (authParams: { profileId?: string } = {}) => { + if (authParams.profileId === "anthropic:primary") { + throw new Error("primary credential resolution failed"); + } + if (authParams.profileId === "anthropic:backup") { + return { + apiKey: mintSecretSentinel("backup-secret", { label: "btw-backup" }), + mode: "api-key", + source: "profile:anthropic:backup", + profileId: "anthropic:backup", + }; + } + throw new Error(`unexpected profile: ${authParams.profileId ?? "none"}`); + }); + requireApiKeyMock.mockReturnValue("backup-secret"); + mockDoneAnswer("Backup answer."); + + await expect( + runSideQuestion({ + cfg: { + secrets: { + providers: { + vault: { source: "file", path: "/tmp/btw-secrets.json", mode: "json" }, + }, + }, + } as never, + }), + ).resolves.toEqual({ text: "Backup answer." }); + + expect( + getApiKeyForModelMock.mock.calls.map( + ([authParams]) => (authParams as { profileId?: string }).profileId, + ), + ).toEqual(["anthropic:primary", "anthropic:backup"]); + expect(resolveModelAsyncMock).toHaveBeenCalledWith( + "anthropic", + DEFAULT_MODEL, + DEFAULT_AGENT_DIR, + expect.any(Object), + expect.objectContaining({ + authStorage, + modelRegistry, + authProfileId: "anthropic:backup", + authProfileMode: "api_key", + skipAgentDiscovery: true, + }), + ); + const preparedAuthContext = expectRecordFields( + (mockArg(prepareProviderRuntimeAuthMock, 0, 0) as { context?: unknown }).context, + { + provider: "anthropic", + modelId: DEFAULT_MODEL, + model: rotatedModel, + profileId: "anthropic:backup", + }, + ); + expect(preparedAuthContext.apiKey).toBe("backup-secret"); + expectRecordFields(mockArg(streamSimpleMock, 0, 0), { + name: "Backup profile model", + baseUrl: "https://backup.example.test", + }); + }); + + it("falls through an unresolved subscription route to the ordered Platform route", async () => { + const authStorage = { id: "btw-openai-auth-storage" }; + const modelRegistry = { id: "btw-openai-model-registry" }; + const subscriptionModel = { + provider: "openai", + id: "gpt-5.5", + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + name: "Subscription model", + }; + const platformModel = { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses" as const, + baseUrl: "https://api.openai.com/v1", + name: "Platform model", + }; + const authStore = { + version: 1 as const, + profiles: { + "openai:subscription": { + type: "token" as const, + provider: "openai", + token: "unresolved-subscription-token", + expires: Date.now() + 60_000, + }, + "openai:platform": { + type: "api_key" as const, + provider: "openai", + key: "platform-key", + }, + }, + order: { openai: ["openai:subscription", "openai:platform"] }, + }; + discoverAuthStorageMock.mockReturnValue(authStorage); + discoverModelsMock.mockReturnValue(modelRegistry); + resolveModelWithRegistryMock.mockReturnValue(platformModel); + resolveModelAsyncMock.mockImplementation( + async ( + _provider: string, + _modelId: string, + _agentDir: string, + _config: unknown, + options?: { authProfileId?: string }, + ) => ({ + model: options?.authProfileId === "openai:subscription" ? subscriptionModel : platformModel, + authStorage, + modelRegistry, + }), + ); + ensureAuthProfileStoreMock.mockReturnValue(authStore); + resolveSessionAuthProfileOverrideMock.mockResolvedValue(undefined); + getApiKeyForModelMock.mockImplementation(async (authParams: { profileId?: string } = {}) => { + if (authParams.profileId === "openai:subscription") { + throw new Error("subscription credential resolution failed"); + } + if (authParams.profileId === "openai:platform") { + return { + apiKey: "platform-key", + mode: "api-key", + source: "profile:openai:platform", + profileId: "openai:platform", + }; + } + throw new Error(`unexpected profile: ${authParams.profileId ?? "none"}`); + }); + requireApiKeyMock.mockReturnValue("platform-key"); + mockDoneAnswer("Platform fallback answer."); + + await expect( + runSideQuestion({ + cfg: { + auth: { + order: { openai: ["openai:subscription", "openai:platform"] }, + }, + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + }, + }, + }, + } as never, + provider: "openai", + model: "gpt-5.5", + }), + ).resolves.toEqual({ text: "Platform fallback answer." }); + + expect( + getApiKeyForModelMock.mock.calls.map( + ([authParams]) => (authParams as { profileId?: string }).profileId, + ), + ).toEqual(["openai:subscription", "openai:platform"]); + expect( + resolveModelAsyncMock.mock.calls.map( + (call) => (call[4] as { authProfileId?: string }).authProfileId, + ), + ).toEqual(["openai:subscription", "openai:platform"]); + expectRecordFields(mockArg(streamSimpleMock, 0, 0), { + name: "Platform model", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + }); + + it("uses a same-route literal fallback only after its prepared profile tier fails", async () => { + const platformModel = { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses" as const, + baseUrl: "https://api.openai.com/v1", + name: "Platform model", + }; + const authStore = { + version: 1 as const, + profiles: { + "openai:broken": { + type: "api_key" as const, + provider: "openai", + key: "broken-profile-key", + }, + }, + order: { openai: ["openai:broken"] }, + }; + resolveModelWithRegistryMock.mockReturnValue(platformModel); + resolveModelAsyncMock.mockResolvedValue({ model: platformModel }); + ensureAuthProfileStoreMock.mockReturnValue(authStore); + resolveSessionAuthProfileOverrideMock.mockResolvedValue(undefined); + resolveProviderEntryApiKeyProfileReferenceMock.mockReturnValue({ kind: "literal" }); + getApiKeyForModelMock.mockImplementation( + async (authParams: { profileId?: string; allowAuthProfileFallback?: boolean }) => { + if (authParams.profileId === "openai:broken") { + throw new Error("profile key could not be resolved"); + } + if (authParams.profileId === undefined && authParams.allowAuthProfileFallback === false) { + return { + apiKey: "literal-key", + mode: "api-key", + source: "models.json", + }; + } + throw new Error("unexpected auth lookup"); + }, + ); + requireApiKeyMock.mockReturnValue("literal-key"); + mockDoneAnswer("Literal fallback answer."); + + await expect( + runSideQuestion({ + cfg: { + auth: { order: { openai: ["openai:broken"] } }, + models: { + providers: { + openai: { apiKey: "literal-key" }, + }, + }, + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + }, + }, + }, + } as never, + provider: "openai", + model: "gpt-5.5", + }), + ).resolves.toEqual({ text: "Literal fallback answer." }); + + expect( + getApiKeyForModelMock.mock.calls.map(([authParams]) => { + const lookup = authParams as { + profileId?: string; + allowAuthProfileFallback?: boolean; + }; + return { + profileId: lookup.profileId, + allowAuthProfileFallback: lookup.allowAuthProfileFallback, + }; + }), + ).toEqual([ + { profileId: "openai:broken", allowAuthProfileFallback: undefined }, + { profileId: undefined, allowAuthProfileFallback: false }, + ]); + expectRecordFields(mockArg(streamSimpleMock, 0, 0), { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + }); + + it.each([ + { label: "explicit", source: "user" as const }, + { label: "legacy source-less", source: undefined }, + ])("keeps $label user-locked static Anthropic auth for BTW", async ({ source }) => { + const staticAuthStore = { + version: 1 as const, + profiles: { + "anthropic:api": { + type: "api_key" as const, + provider: "anthropic", + key: "static-key", + }, + }, + }; + ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValueOnce(staticAuthStore); getApiKeyForModelMock.mockResolvedValueOnce({ apiKey: "static-key", mode: "api-key", @@ -1295,55 +1961,19 @@ describe("runBtwSideQuestion", () => { } as never, sessionEntry: createSessionEntry({ authProfileOverride: "anthropic:api", - authProfileOverrideSource: "user", + authProfileOverrideSource: source, }), }); expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); - expectRecordFields(mockArg(getApiKeyForModelMock, 0, 0), { - profileId: "anthropic:api", - }); - expect((mockArg(getApiKeyForModelMock, 0, 0) as { store?: unknown }).store).toBeUndefined(); - expectRecordFields( - (mockArg(prepareProviderRuntimeAuthMock, 0, 0) as { context?: unknown }).context, - { - profileId: "anthropic:api", - authMode: "api-key", - }, + expect(ensureAuthProfileStoreWithoutExternalProfilesMock).toHaveBeenCalledWith( + DEFAULT_AGENT_DIR, + { allowKeychainPrompt: false }, ); - }); - - it("keeps legacy source-less user-locked Anthropic auth for BTW", async () => { - getApiKeyForModelMock.mockResolvedValueOnce({ - apiKey: "static-key", - mode: "api-key", - source: "profile:anthropic:api", - profileId: "anthropic:api", - }); - requireApiKeyMock.mockReturnValueOnce("static-key"); - resolveSessionAuthProfileOverrideMock.mockResolvedValueOnce("anthropic:api"); - mockDoneAnswer("Legacy static answer."); - - await runSideQuestion({ - cfg: { - auth: { - order: { anthropic: ["anthropic:claude-cli"] }, - profiles: { - "anthropic:api": { provider: "anthropic", mode: "api_key" }, - "anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" }, - }, - }, - } as never, - sessionEntry: createSessionEntry({ - authProfileOverride: "anthropic:api", - }), - }); - - expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); expectRecordFields(mockArg(getApiKeyForModelMock, 0, 0), { profileId: "anthropic:api", + store: staticAuthStore, }); - expect((mockArg(getApiKeyForModelMock, 0, 0) as { store?: unknown }).store).toBeUndefined(); expectRecordFields( (mockArg(prepareProviderRuntimeAuthMock, 0, 0) as { context?: unknown }).context, { @@ -1360,6 +1990,14 @@ describe("runBtwSideQuestion", () => { api: "openai-responses", baseUrl: "https://api.individual.githubcopilot.com", }); + resolveModelAsyncMock.mockResolvedValue({ + model: { + provider: "github-copilot", + id: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://api.individual.githubcopilot.com", + }, + }); getApiKeyForModelMock.mockResolvedValue({ apiKey: "github-token", mode: "token", @@ -1457,7 +2095,7 @@ describe("runBtwSideQuestion", () => { const resolverParams = expectRecordFields(mockArg(resolveEmbeddedAgentStreamFnMock, 0, 0), { sessionId: "session-1", resolvedApiKey: "secret", - authProfileId: "profile-1", + authProfileId: undefined, }); expect(resolverParams.providerStreamFn).toBeUndefined(); expectRecordFields(resolverParams.model, { @@ -1483,7 +2121,7 @@ describe("runBtwSideQuestion", () => { providerStreamFn: undefined, sessionId: "session-1", resolvedApiKey: "secret", - authProfileId: "profile-1", + authProfileId: undefined, }), ); expect(streamSimpleMock).toHaveBeenCalledTimes(1); @@ -1879,21 +2517,13 @@ describe("runBtwSideQuestion", () => { expect(result).toEqual({ text: MATH_ANSWER }); }); - it("returns the BTW answer without appending transcript custom entries", async () => { + it("returns the BTW answer without transcript writes or persistence warnings", async () => { mockDoneAnswer(MATH_ANSWER); const result = await runMathSideQuestion(); expect(result).toEqual({ text: MATH_ANSWER }); expect(buildSessionContextMock).toHaveBeenCalledTimes(1); - }); - - it("does not log transcript persistence warnings because BTW no longer writes to disk", async () => { - mockDoneAnswer(MATH_ANSWER); - - const result = await runMathSideQuestion(); - - expect(result).toEqual({ text: MATH_ANSWER }); expect(diagDebugMock).not.toHaveBeenCalled(); }); diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 4ae34a44f44a..18df6e721606 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -25,11 +25,12 @@ import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js" import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; import { resolveExternalCliAuthOverlayScopeFromSelection } from "./auth-profiles/external-cli-auth-selection.js"; import { resolveSessionAuthProfileOverride } from "./auth-profiles/session-override.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; import { readBtwTranscriptMessages, resolveBtwSessionTranscriptPath } from "./btw-transcript.js"; import { executePreparedCliRun } from "./cli-runner/execute.runtime.js"; import { prepareCliRunContext } from "./cli-runner/prepare.runtime.js"; import { EmbeddedBlockChunker, type BlockReplyChunking } from "./embedded-agent-block-chunker.js"; -import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js"; +import { resolveModelAsync, resolveModelWithRegistry } from "./embedded-agent-runner/model.js"; import { getActiveEmbeddedRunSnapshot } from "./embedded-agent-runner/runs.js"; import { resolveEmbeddedAgentStreamFn } from "./embedded-agent-runner/stream-resolution.js"; import { ensureSelectedAgentHarnessPlugin } from "./harness/runtime-plugin.js"; @@ -37,7 +38,13 @@ import { resolveAvailableAgentHarnessPolicy, resolvePluginHarnessPolicyToolsAllow, selectAgentHarness, + selectAgentHarnessForPreparedModelProviders, + type AgentHarnessPreparedModelProvider, } from "./harness/selection.js"; +import { + resolveAgentHarnessPreparedAuthSupport, + resolveAgentHarnessPreparedRouteSupport, +} from "./harness/support.js"; import type { AgentHarness } from "./harness/types.js"; import { resolveImageSanitizationLimits, @@ -47,7 +54,6 @@ import { ensureAuthProfileStore, ensureAuthProfileStoreWithoutExternalProfiles, applySecretRefHeaderSentinels, - getApiKeyForModel, requireApiKey, } from "./model-auth.js"; import { @@ -55,13 +61,24 @@ import { resolveCliRuntimeExecutionProvider, } from "./model-runtime-aliases.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; -import { listOpenAIAuthProfileProvidersForAgentRuntime } from "./openai-routing.js"; +import { + isOpenAIProvider, + listOpenAIAuthProfileProvidersForAgentRuntime, +} from "./openai-routing.js"; import { applyPreparedRuntimeAuthToModel } from "./provider-request-config.js"; import { protectPreparedProviderRuntimeAuth, unwrapSecretSentinelsForProviderEgress, } from "./provider-secret-egress.js"; import { registerProviderStreamForModel } from "./provider-stream.js"; +import { materializePreparedRuntimeModel } from "./runtime-plan/materialize-model.js"; +import { prepareAgentRuntimeAuth } from "./runtime-plan/prepare-auth.js"; +import { + resolvePreparedRuntimeAuthAttempts, + resolvePreparedRuntimeModelAuth, + scopeAuthProfileStoreToPreparedPlan, +} from "./runtime-plan/resolve-auth.js"; +import type { AgentRuntimeAuthPlan } from "./runtime-plan/types.js"; import { resolveSessionRuntimeOverrideForProvider } from "./session-runtime-compat.js"; import { stripToolResultDetails } from "./session-transcript-repair.js"; import { resolveAgentTimeoutMs } from "./timeout.js"; @@ -100,12 +117,82 @@ function resolveReturnedAuthProfileSource( if (!authProfileId?.trim()) { return undefined; } + if (sessionEntry?.authProfileOverride?.trim() !== authProfileId) { + return "auto"; + } return ( - sessionEntry?.authProfileOverrideSource ?? - (typeof sessionEntry?.authProfileOverrideCompactionCount === "number" ? "auto" : "user") + sessionEntry.authProfileOverrideSource ?? + (typeof sessionEntry.authProfileOverrideCompactionCount === "number" ? "auto" : "user") ); } +// Planning and immediate resolution share one scoped snapshot so provider +// bindings and cooldown decisions cannot diverge inside a side question. +function resolveBtwAuthProfileStore(params: { + cfg: OpenClawConfig; + provider: string; + modelId: string; + agentId?: string; + agentDir: string; + workspaceDir?: string; + authProfileId?: string; + authProfileIdSource?: "auto" | "user"; +}): { + store: AuthProfileStore; + ignoreAutoPreferredProfile: boolean; +} { + if (isOpenAIProvider(params.provider)) { + return { + store: ensureAuthProfileStore(params.agentDir, { + externalCliProviderIds: ["openai"], + allowKeychainPrompt: false, + }), + ignoreAutoPreferredProfile: false, + }; + } + + const userLockedAuthProfileId = + params.authProfileIdSource === "user" ? params.authProfileId : undefined; + let externalCliAuthScope = resolveExternalCliAuthOverlayScopeFromSelection({ + provider: params.provider, + cfg: params.cfg, + agentId: params.agentId, + modelId: params.modelId, + workspaceDir: params.workspaceDir, + userLockedAuthProfileId, + }); + let store: AuthProfileStore; + if (externalCliAuthScope.providerIds) { + store = ensureAuthProfileStore(params.agentDir, { + externalCliProviderIds: externalCliAuthScope.providerIds, + allowKeychainPrompt: false, + }); + } else { + store = ensureAuthProfileStoreWithoutExternalProfiles(params.agentDir, { + allowKeychainPrompt: false, + }); + externalCliAuthScope = resolveExternalCliAuthOverlayScopeFromSelection({ + provider: params.provider, + cfg: params.cfg, + agentId: params.agentId, + modelId: params.modelId, + workspaceDir: params.workspaceDir, + store, + userLockedAuthProfileId, + }); + if (externalCliAuthScope.providerIds) { + store = ensureAuthProfileStore(params.agentDir, { + externalCliProviderIds: externalCliAuthScope.providerIds, + allowKeychainPrompt: false, + }); + } + } + return { + store, + ignoreAutoPreferredProfile: externalCliAuthScope.ignoreAutoPreferredProfile, + }; +} + function buildBtwQuestionPrompt(question: string, inFlightPrompt?: string): string { const lines = [ "Answer this side question only.", @@ -303,6 +390,79 @@ async function toSimpleContextMessages(params: { ) as Message[]; } +type BtwRuntimeAuthPreparation = ReturnType; + +type BtwRuntimeModelMaterialization = { + cfg: OpenClawConfig; + provider: string; + modelId: string; + agentDir: string; + workspaceDir?: string; + authStorage: ReturnType; + modelRegistry: ReturnType; +}; + +async function materializeBtwRuntimeModel( + params: BtwRuntimeModelMaterialization & { + plan: AgentRuntimeAuthPlan; + model: Model; + forceResolve?: boolean; + }, +): Promise { + return ( + (await materializePreparedRuntimeModel({ + plan: params.plan, + provider: params.provider, + modelId: params.modelId, + config: params.cfg, + model: params.model, + ...(params.forceResolve !== undefined ? { forceResolve: params.forceResolve } : {}), + resolveModel: ({ config, authProfileId, authProfileMode }) => + resolveModelAsync(params.provider, params.modelId, params.agentDir, config, { + authStorage: params.authStorage, + modelRegistry: params.modelRegistry, + skipAgentDiscovery: true, + allowBundledStaticCatalogFallback: true, + preferBundledStaticCatalogTransport: true, + workspaceDir: params.workspaceDir, + authProfileId, + authProfileMode, + }), + })) ?? params.model + ); +} + +async function resolveBtwPreparedRuntimeAuth( + params: BtwRuntimeModelMaterialization & { + preparation: BtwRuntimeAuthPreparation; + model: Model; + authProfileStore: AuthProfileStore; + }, +) { + return resolvePreparedRuntimeAuthAttempts({ + attempts: params.preparation.attempts, + store: params.authProfileStore, + modelId: params.modelId, + model: params.model, + materializeModel: ({ plan, model, forceResolve }) => + materializeBtwRuntimeModel({ ...params, plan, model, forceResolve }), + resolveAuth: async ({ attempt, model }) => + await resolvePreparedRuntimeModelAuth({ + plan: attempt.plan, + model, + cfg: params.cfg, + store: params.authProfileStore, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + ...(attempt.allowAuthProfileFallback !== undefined + ? { allowAuthProfileFallback: attempt.allowAuthProfileFallback } + : {}), + secretSentinels: true, + }), + errorMessage: "BTW prepared auth attempts could not be resolved.", + }); +} + async function resolveRuntimeModel(params: { cfg: OpenClawConfig; provider: string; @@ -315,16 +475,25 @@ async function resolveRuntimeModel(params: { sessionKey?: string; storePath?: string; isNewSession: boolean; + harnessId?: string; + harnessAuthBootstrap?: AgentHarness["authBootstrap"]; }): Promise<{ model: Model; authProfileId?: string; authProfileIdSource?: "auto" | "user"; + authProfileStore: AuthProfileStore; + runtimeAuthPreparation: BtwRuntimeAuthPreparation; + authStorage: ReturnType; + modelRegistry: ReturnType; }> { const modelsOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : undefined; await ensureOpenClawModelsJson(params.cfg, params.agentDir, modelsOptions); const authStorage = discoverAuthStorage(params.agentDir); - const modelRegistry = discoverModels(authStorage, params.agentDir, modelsOptions); - const model = resolveModelWithRegistry({ + const modelRegistry = discoverModels(authStorage, params.agentDir, { + config: params.cfg, + ...modelsOptions, + }); + let model = resolveModelWithRegistry({ provider: params.provider, modelId: params.model, modelRegistry, @@ -336,20 +505,16 @@ async function resolveRuntimeModel(params: { const runtimeProvider = model.provider; const runtimeModelId = model.id; + const acceptedProviderIds = listOpenAIAuthProfileProvidersForAgentRuntime({ + provider: runtimeProvider, + harnessRuntime: params.harnessId, + agentHarnessId: params.harnessId, + config: params.cfg, + }); const authProfileId = await resolveSessionAuthProfileOverride({ cfg: params.cfg, provider: runtimeProvider, - acceptedProviderIds: listOpenAIAuthProfileProvidersForAgentRuntime({ - provider: runtimeProvider, - harnessRuntime: resolveAvailableAgentHarnessPolicy({ - provider: runtimeProvider, - modelId: runtimeModelId, - config: params.cfg, - agentId: params.agentId, - sessionKey: params.sessionKey, - }).runtime, - config: params.cfg, - }), + acceptedProviderIds, agentDir: params.agentDir, sessionEntry: params.sessionEntry, sessionStore: params.sessionStore, @@ -357,10 +522,55 @@ async function resolveRuntimeModel(params: { storePath: params.storePath, isNewSession: params.isNewSession, }); + const authProfileIdSource = resolveReturnedAuthProfileSource(params.sessionEntry, authProfileId); + const authProfileStoreSelection = resolveBtwAuthProfileStore({ + cfg: params.cfg, + provider: runtimeProvider, + modelId: runtimeModelId, + agentId: params.agentId, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + authProfileId, + authProfileIdSource, + }); + const effectiveAuthProfileId = + authProfileStoreSelection.ignoreAutoPreferredProfile && authProfileIdSource !== "user" + ? undefined + : authProfileId; + const runtimeAuthPreparation = prepareAgentRuntimeAuth({ + provider: runtimeProvider, + modelId: runtimeModelId, + modelApi: model.api, + modelBaseUrl: model.baseUrl, + config: params.cfg, + env: process.env, + workspaceDir: params.workspaceDir, + authProfileStore: authProfileStoreSelection.store, + sessionAuthProfileId: effectiveAuthProfileId, + sessionAuthProfileSource: authProfileIdSource, + harnessId: params.harnessId, + harnessRuntime: params.harnessId, + harnessAuthBootstrap: params.harnessAuthBootstrap, + }); + model = await materializeBtwRuntimeModel({ + cfg: params.cfg, + provider: runtimeProvider, + modelId: runtimeModelId, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + authStorage, + modelRegistry, + plan: runtimeAuthPreparation.plan, + model, + }); return { model, - authProfileId, - authProfileIdSource: resolveReturnedAuthProfileSource(params.sessionEntry, authProfileId), + authProfileId: runtimeAuthPreparation.plan.forwardedAuthProfileId, + authProfileIdSource: runtimeAuthPreparation.plan.forwardedAuthProfileSource, + authProfileStore: authProfileStoreSelection.store, + runtimeAuthPreparation, + authStorage, + modelRegistry, }; } @@ -492,7 +702,11 @@ export async function runBtwSideQuestion( }); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId); const preparedHarnesses = new Map(); - const prepareHarness = async (provider: string, modelId: string): Promise => { + const prepareHarness = async ( + provider: string, + modelId: string, + modelProvider?: AgentHarnessPreparedModelProvider, + ): Promise => { const agentHarnessId = isModelSelectionLocked(params.sessionEntry) ? params.sessionEntry.agentHarnessId : undefined; @@ -504,7 +718,16 @@ export async function runBtwSideQuestion( cfg: params.cfg, }); const selectedHarnessId = agentHarnessId ?? agentHarnessRuntimeOverride ?? "configured"; - const key = `${provider}/${modelId}/${selectedHarnessId}`; + const key = [ + `${provider}/${modelId}/${selectedHarnessId}`, + modelProvider?.api ?? "", + modelProvider?.baseUrl ?? "", + modelProvider?.requestTransportOverrides ?? "", + modelProvider?.runtimePolicy?.compatibleIds.join(",") ?? "", + modelProvider?.preparedAuth?.source ?? "", + modelProvider?.preparedAuth?.mode ?? "", + modelProvider?.preparedAuth?.requirement ?? "", + ].join("\0"); const cached = preparedHarnesses.get(key); if (cached) { return cached; @@ -519,7 +742,7 @@ export async function runBtwSideQuestion( ...(agentHarnessId ? { agentHarnessId } : {}), ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), }); - const harness = selectAgentHarness({ + const selectionParams = { provider, modelId, config: params.cfg, @@ -527,7 +750,13 @@ export async function runBtwSideQuestion( sessionKey: params.sessionKey, ...(agentHarnessId ? { agentHarnessId } : {}), ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), - }); + }; + const harness = modelProvider + ? selectAgentHarnessForPreparedModelProviders({ + ...selectionParams, + modelProviders: [modelProvider], + }) + : selectAgentHarness(selectionParams); preparedHarnesses.set(key, harness); return harness; }; @@ -547,19 +776,28 @@ export async function runBtwSideQuestion( sessionKey: params.sessionKey, storePath: params.storePath, isNewSession: params.isNewSession, + harnessId: harness.id, + harnessAuthBootstrap: harness.authBootstrap, }); } return runtimeSelection; }; + type BtwHarnessSideQuestionDispatch = + | { kind: "handled"; payload: ReplyPayload } + | { + kind: "openclaw"; + harness: AgentHarness; + runtime: Awaited>; + resolvedAttempt: Awaited>; + }; + let preparedOpenClawFallback: + | Extract + | undefined; const runHarnessSideQuestion = async ( selectedHarness: AgentHarness, runtime: Awaited>, - ): Promise => { - if (!selectedHarness.runSideQuestion) { - throw new Error( - `Selected agent harness "${selectedHarness.id}" does not support /btw side questions.`, - ); - } + routeFinalized = false, + ): Promise => { const toolsAllow = resolvePluginHarnessPolicyToolsAllow({ config: params.cfg, sessionKey: params.sessionKey, @@ -579,25 +817,157 @@ export async function runBtwSideQuestion( senderUsername: params.senderUsername, senderE164: params.senderE164, }); + const authProfileStoreSelection = + selectedHarness.id === harness.id + ? undefined + : resolveBtwAuthProfileStore({ + cfg: params.cfg, + provider: runtime.model.provider, + modelId: runtime.model.id, + agentId: sessionAgentId, + agentDir: params.agentDir, + workspaceDir, + authProfileId: runtime.authProfileId, + authProfileIdSource: runtime.authProfileIdSource, + }); + const runtimeAuthPreparation = authProfileStoreSelection + ? prepareAgentRuntimeAuth({ + provider: runtime.model.provider, + modelId: runtime.model.id, + modelApi: runtime.model.api, + modelBaseUrl: runtime.model.baseUrl, + config: params.cfg, + env: process.env, + workspaceDir, + authProfileStore: authProfileStoreSelection.store, + sessionAuthProfileId: + authProfileStoreSelection.ignoreAutoPreferredProfile && + runtime.authProfileIdSource !== "user" + ? undefined + : runtime.authProfileId, + sessionAuthProfileSource: runtime.authProfileIdSource, + harnessId: selectedHarness.id, + harnessRuntime: selectedHarness.id, + harnessAuthBootstrap: selectedHarness.authBootstrap, + }) + : runtime.runtimeAuthPreparation; + const selectedAuthProfileStore = authProfileStoreSelection?.store ?? runtime.authProfileStore; + const implicitHarnessAuthPlan = + selectedHarness.authBootstrap === "harness" && + runtimeAuthPreparation.attempts.length === 1 && + runtimeAuthPreparation.attempts[0]?.kind === "implicit" && + runtimeAuthPreparation.attempts[0].plan.harnessAuthProvider + ? runtimeAuthPreparation.attempts[0].plan + : undefined; + // A native harness owns this deferred auth decision. Resolving it through + // OpenClaw would incorrectly require a host credential before handoff. + const resolvedAttempt = implicitHarnessAuthPlan + ? { plan: implicitHarnessAuthPlan, model: runtime.model } + : await resolveBtwPreparedRuntimeAuth({ + preparation: runtimeAuthPreparation, + model: runtime.model, + cfg: params.cfg, + provider: runtime.model.provider, + modelId: runtime.model.id, + agentDir: params.agentDir, + workspaceDir, + authStorage: runtime.authStorage, + modelRegistry: runtime.modelRegistry, + authProfileStore: selectedAuthProfileStore, + }); + const runtimeAuthPlan = resolvedAttempt.plan; + const runtimeModel = resolvedAttempt.model; + const finalizedHarness = await prepareHarness(runtimeModel.provider, runtimeModel.id, { + api: runtimeModel.api, + baseUrl: runtimeModel.baseUrl, + ...resolveAgentHarnessPreparedRouteSupport(runtimeAuthPlan), + preparedAuth: resolveAgentHarnessPreparedAuthSupport({ plan: runtimeAuthPlan }), + }); + if (finalizedHarness.id !== selectedHarness.id) { + if (routeFinalized) { + throw new Error("Agent harness selection changed after route materialization."); + } + return runHarnessSideQuestion( + finalizedHarness, + { + ...runtime, + model: runtimeModel, + runtimeAuthPreparation, + authProfileStore: selectedAuthProfileStore, + }, + true, + ); + } + if (!selectedHarness.runSideQuestion) { + if (selectedHarness.id !== "openclaw" || !("auth" in resolvedAttempt)) { + throw new Error( + `Selected agent harness "${selectedHarness.id}" does not support /btw side questions.`, + ); + } + return { + kind: "openclaw", + harness: selectedHarness, + runtime: { + ...runtime, + model: runtimeModel, + authProfileId: runtimeAuthPlan.forwardedAuthProfileId, + authProfileIdSource: runtimeAuthPlan.forwardedAuthProfileSource, + authProfileStore: selectedAuthProfileStore, + runtimeAuthPreparation, + }, + resolvedAttempt, + }; + } + const resolvedApiKey = + runtimeAuthPlan.modelRoute?.authRequirement === "api-key" && "auth" in resolvedAttempt + ? resolvedAttempt.auth.apiKey?.trim() + : undefined; const result = await selectedHarness.runSideQuestion({ ...params, - provider: runtime.model.provider, - model: runtime.model.id, - runtimeModel: runtime.model, + provider: runtimeModel.provider, + model: runtimeModel.id, + runtimeModel, + preparedRuntimeAuth: { + plan: runtimeAuthPlan, + authProfileStore: scopeAuthProfileStoreToPreparedPlan( + selectedAuthProfileStore, + runtimeAuthPlan, + ), + authStorage: runtime.authStorage, + modelRegistry: runtime.modelRegistry, + ...(resolvedApiKey + ? { + resolvedApiKey: unwrapSecretSentinelsForProviderEgress( + resolvedApiKey, + "BTW harness handoff", + ), + } + : {}), + }, sessionId, sessionFile, agentId: sessionAgentId, workspaceDir, ...(toolsAllow ? { toolsAllow } : {}), - authProfileId: runtime.authProfileId, - authProfileIdSource: runtime.authProfileIdSource, + authProfileId: + runtimeAuthPlan.modelRoute?.authRequirement === "api-key" + ? undefined + : runtimeAuthPlan.forwardedAuthProfileId, + authProfileIdSource: + runtimeAuthPlan.modelRoute?.authRequirement === "api-key" + ? undefined + : runtimeAuthPlan.forwardedAuthProfileSource, }); - return { text: result.text }; + return { kind: "handled", payload: { text: result.text } }; }; if (harness.runSideQuestion) { - return runHarnessSideQuestion(harness, await resolveRuntimeSelection()); + const dispatch = await runHarnessSideQuestion(harness, await resolveRuntimeSelection()); + if (dispatch.kind === "handled") { + return dispatch.payload; + } + preparedOpenClawFallback = dispatch; } - if (harness.id === "codex") { + if (harness.id === "codex" && !harness.runSideQuestion) { throw new Error(`Selected agent harness "${harness.id}" does not support /btw side questions.`); } @@ -693,74 +1063,62 @@ export async function runBtwSideQuestion( }); } - const runtimeSelectionForHarness = await resolveRuntimeSelection(); + const initialOpenClawFallback = preparedOpenClawFallback; + const runtimeSelectionForHarness = + initialOpenClawFallback?.runtime ?? (await resolveRuntimeSelection()); // Model resolution can canonicalize a legacy provider alias, so reselect against the resolved // provider/model instead of reusing the raw route's selection. - const runtimeHarness = await prepareHarness( - runtimeSelectionForHarness.model.provider, - runtimeSelectionForHarness.model.id, - ); + const runtimeHarness = + initialOpenClawFallback?.harness ?? + (await prepareHarness( + runtimeSelectionForHarness.model.provider, + runtimeSelectionForHarness.model.id, + )); if (runtimeHarness.runSideQuestion) { - return runHarnessSideQuestion(runtimeHarness, runtimeSelectionForHarness); + const dispatch = await runHarnessSideQuestion(runtimeHarness, runtimeSelectionForHarness); + if (dispatch.kind === "handled") { + return dispatch.payload; + } + preparedOpenClawFallback = dispatch; } - if (runtimeHarness.id === "codex") { + if (runtimeHarness.id === "codex" && !runtimeHarness.runSideQuestion) { throw new Error( `Selected agent harness "${runtimeHarness.id}" does not support /btw side questions.`, ); } - const { model, authProfileId, authProfileIdSource } = runtimeSelectionForHarness; - let externalCliAuthScope = resolveExternalCliAuthOverlayScopeFromSelection({ - provider: model.provider, - cfg: params.cfg, - agentId: sessionAgentId, - modelId: model.id, - workspaceDir, - userLockedAuthProfileId: authProfileIdSource === "user" ? authProfileId : undefined, - }); - if (!externalCliAuthScope.providerIds) { - const noExternalAuthStore = ensureAuthProfileStoreWithoutExternalProfiles(params.agentDir, { - allowKeychainPrompt: false, - }); - externalCliAuthScope = resolveExternalCliAuthOverlayScopeFromSelection({ - provider: model.provider, + const finalizedOpenClawFallback = preparedOpenClawFallback; + const effectiveRuntimeSelection = + finalizedOpenClawFallback?.runtime ?? runtimeSelectionForHarness; + const { authStorage, model, modelRegistry, authProfileStore, runtimeAuthPreparation } = + effectiveRuntimeSelection; + const resolvedAttempt = + finalizedOpenClawFallback?.resolvedAttempt ?? + (await resolveBtwPreparedRuntimeAuth({ + preparation: runtimeAuthPreparation, + model, cfg: params.cfg, - agentId: sessionAgentId, + provider: model.provider, modelId: model.id, + agentDir: params.agentDir, workspaceDir, - store: noExternalAuthStore, - userLockedAuthProfileId: authProfileIdSource === "user" ? authProfileId : undefined, - }); - } - const authStore = externalCliAuthScope.providerIds - ? ensureAuthProfileStore(params.agentDir, { - externalCliProviderIds: externalCliAuthScope.providerIds, - allowKeychainPrompt: false, - }) - : undefined; - const effectiveAuthProfileId = - externalCliAuthScope.ignoreAutoPreferredProfile && authProfileIdSource !== "user" - ? undefined - : authProfileId; - const apiKeyInfo = await getApiKeyForModel({ - model, - cfg: params.cfg, - profileId: effectiveAuthProfileId, - ...(authStore ? { store: authStore } : {}), - agentDir: params.agentDir, - secretSentinels: true, - }); - const resolvedAuthProfileId = apiKeyInfo.profileId ?? effectiveAuthProfileId; - let runtimeModel = model; + authStorage, + modelRegistry, + authProfileStore, + })); + const apiKeyInfo = resolvedAttempt.auth; + const resolvedRuntimeAuthPlan = resolvedAttempt.plan; + const resolvedAuthProfileId = resolvedRuntimeAuthPlan.forwardedAuthProfileId; + let runtimeModel = resolvedAttempt.model; let apiKey = apiKeyInfo.mode === "aws-sdk" && !apiKeyInfo.apiKey ? undefined - : requireApiKey(apiKeyInfo, model.provider); + : requireApiKey(apiKeyInfo, runtimeModel.provider); if (apiKey) { const preparedAuth = protectPreparedProviderRuntimeAuth({ - provider: model.provider, + provider: runtimeModel.provider, preparedAuth: await prepareProviderRuntimeAuth({ - provider: model.provider, + provider: runtimeModel.provider, config: params.cfg, workspaceDir, env: process.env, @@ -769,9 +1127,9 @@ export async function runBtwSideQuestion( agentDir: params.agentDir, workspaceDir, env: process.env, - provider: model.provider, - modelId: model.id, - model, + provider: runtimeModel.provider, + modelId: runtimeModel.id, + model: runtimeModel, apiKey: unwrapSecretSentinelsForProviderEgress(apiKey, "provider runtime auth exchange"), authMode: apiKeyInfo.mode, profileId: resolvedAuthProfileId, diff --git a/src/agents/embedded-agent-runner/compact.abort-signal.test.ts b/src/agents/embedded-agent-runner/compact.abort-signal.test.ts index addca0a80ebe..7df6cf99faa9 100644 --- a/src/agents/embedded-agent-runner/compact.abort-signal.test.ts +++ b/src/agents/embedded-agent-runner/compact.abort-signal.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; vi.mock("../model-fallback.js", () => ({ + resolveModelCandidateChain: (params: { provider: string; model: string }) => [ + { provider: params.provider, model: params.model }, + ], runWithModelFallback: vi.fn(async (params: Record) => ({ result: { ok: true, compacted: false, reason: "no-op" }, provider: params.provider, diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index 9c5270fb6c17..97cc01a48661 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -3,12 +3,22 @@ */ import { vi, type Mock } from "vitest"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; +import type { AuthProfileStore } from "../auth-profiles/types.js"; import { clearAgentHarnesses } from "../harness/registry.js"; +import type { AgentHarness } from "../harness/types.js"; +import type { ModelAuthMode } from "../model-auth.js"; import type { AgentRuntimePlan, BuildAgentRuntimePlanParams } from "../runtime-plan/types.js"; import type { CompactionTranscriptRotation } from "./compaction-successor-transcript.js"; type MockResolvedModel = { - model: { provider: string; api: string; id: string; input: unknown[] }; + model: { + provider: string; + api: string; + baseUrl?: string; + id: string; + input: unknown[]; + contextWindow?: number; + }; error: null; authStorage: { setRuntimeApiKey: Mock<(provider?: string, apiKey?: string) => void> }; modelRegistry: Record; @@ -44,12 +54,22 @@ export const resolveContextEngineMock = vi.fn(async () => ({ })); export const resolveModelMock: Mock< (provider?: string, modelId?: string, agentDir?: string, cfg?: unknown) => MockResolvedModel -> = vi.fn((_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({ - model: { provider: "openai", api: "responses", id: "fake", input: [] }, +> = vi.fn((provider?: string, modelId?: string, _agentDir?: string, _cfg?: unknown) => ({ + model: { + provider: provider ?? "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + id: modelId ?? "fake", + input: [], + }, error: null, authStorage: { setRuntimeApiKey: vi.fn() }, modelRegistry: {}, })); +export const resolveModelAsyncMock = vi.fn( + async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => + resolveModelMock(provider, modelId, agentDir, cfg), +); export const sessionCompactImpl = vi.fn(async () => ({ summary: "summary", firstKeptEntryId: "entry-1", @@ -57,9 +77,12 @@ export const sessionCompactImpl = vi.fn(async () => ({ details: { ok: true }, })); export const triggerInternalHook: Mock<(event?: unknown) => void> = vi.fn(); -const sanitizeSessionHistoryMock = vi.fn( +export const sanitizeSessionHistoryMock = vi.fn( async (params: { messages: unknown[] }) => params.messages, ); +export const validateReplayTurnsMock = vi.fn( + async ({ messages }: { messages: unknown[] }) => messages, +); export const getMemorySearchManagerMock: Mock< (params?: unknown) => Promise > = vi.fn(async () => ({ @@ -82,6 +105,25 @@ export const resolveSessionAgentIdsMock = vi.fn(() => ({ })); export const estimateTokensMock = vi.fn((_message?: unknown) => 10); export const resolveAgentHarnessPolicyMock = vi.fn(() => ({ runtime: "openclaw" })); +function createSelectedAgentHarnessMock(params: { + agentHarnessId?: string; + agentHarnessRuntimeOverride?: string; +}): AgentHarness { + const configured = resolveAgentHarnessPolicyMock() as { runtime?: string }; + const id = + params.agentHarnessId ?? params.agentHarnessRuntimeOverride ?? configured.runtime ?? "openclaw"; + return { + id, + label: `${id} test harness`, + ...(id === "codex" ? { authBootstrap: "harness" as const } : {}), + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + }; +} +export const selectAgentHarnessMock = vi.fn(createSelectedAgentHarnessMock); +export const selectAgentHarnessForPreparedModelProvidersMock = vi.fn( + createSelectedAgentHarnessMock, +); export const resolveContextWindowInfoMock = vi.fn(() => ({ tokens: 128_000 })); function createDefaultSessionMessages(): unknown[] { return [ @@ -146,6 +188,7 @@ function createMockToolDefinitions(tools: unknown[] = []) { }); } export const createOpenClawCodingToolsMock = vi.fn(() => []); +export const buildEmbeddedExtensionFactoriesMock = vi.fn(() => []); export const guardSessionManagerMock = vi.fn(() => ({ flushPendingToolResults: vi.fn(), })); @@ -164,8 +207,44 @@ export const buildEmbeddedSystemPromptMock = vi.fn(() => ""); export const resolveEmbeddedAgentStreamFnMock: Mock< (params?: unknown) => MockEmbeddedAgentStreamFn > = vi.fn((_params?: unknown) => vi.fn()); +export const getApiKeyForModelMock: Mock< + (params?: { profileId?: string; allowAuthProfileFallback?: boolean }) => Promise<{ + apiKey: string; + mode: ModelAuthMode; + source: string; + profileId?: string; + }> +> = vi.fn(async (params?: { profileId?: string }) => ({ + apiKey: "test", + mode: "api-key", + source: params?.profileId ? `profile:${params.profileId}` : "test harness", + ...(params?.profileId ? { profileId: params.profileId } : {}), +})); +export const resolveProviderEntryApiKeyProfileReferenceMock: Mock<() => unknown> = vi.fn(() => ({ + kind: "none", +})); +export const shouldPreferExplicitConfigApiKeyAuthMock = vi.fn(() => false); export const registerProviderStreamForModelMock: Mock<(params?: unknown) => unknown> = vi.fn(); export const applyExtraParamsToAgentMock = vi.fn(() => ({ effectiveExtraParams: {} })); +function createDefaultCompactionAuthStore(): AuthProfileStore { + return { + version: 1, + profiles: { + "openai:test": { + type: "api_key", + provider: "openai", + key: "test", + }, + }, + order: { openai: ["openai:test"] }, + }; +} + +export const ensureAuthProfileStoreMock: Mock<() => AuthProfileStore> = vi.fn( + createDefaultCompactionAuthStore, +); +export const ensureAuthProfileStoreWithoutExternalProfilesMock: Mock<() => AuthProfileStore> = + vi.fn(createDefaultCompactionAuthStore); const resolveAgentTransportOverrideMock: Mock<(params?: unknown) => string | undefined> = vi.fn( () => undefined, ); @@ -245,6 +324,14 @@ function createCompactHooksRuntimePlan(params: BuildAgentRuntimePlanParams): Age ...(params.sessionAuthProfileId ? { forwardedAuthProfileId: params.sessionAuthProfileId } : {}), + ...(params.sessionAuthProfileId && params.sessionAuthProfileSource + ? { forwardedAuthProfileSource: params.sessionAuthProfileSource } + : {}), + ...(params.sessionAuthProfileCandidateIds?.length + ? { forwardedAuthProfileCandidateIds: params.sessionAuthProfileCandidateIds } + : {}), + ...(params.authProfileMode ? { selectedAuthMode: params.authProfileMode } : {}), + ...(params.modelRoute ? { modelRoute: params.modelRoute } : {}), }, prompt: { provider: params.provider, @@ -285,6 +372,10 @@ function createCompactHooksRuntimePlan(params: BuildAgentRuntimePlanParams): Age }; } +export const buildAgentRuntimePlanMock = vi.fn((params: BuildAgentRuntimePlanParams) => + createCompactHooksRuntimePlan(params), +); + const emptyPluginMetadataSnapshot: PluginMetadataSnapshot = { policyHash: "", index: { @@ -329,6 +420,12 @@ export function resetCompactSessionStateMocks(): void { sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => { return params.messages; }); + validateReplayTurnsMock.mockReset(); + validateReplayTurnsMock.mockImplementation(async ({ messages }: { messages: unknown[] }) => { + return messages; + }); + buildEmbeddedExtensionFactoriesMock.mockReset(); + buildEmbeddedExtensionFactoriesMock.mockReturnValue([]); getMemorySearchManagerMock.mockReset(); getMemorySearchManagerMock.mockResolvedValue({ @@ -359,10 +456,27 @@ export function resetCompactSessionStateMocks(): void { })); resolveEmbeddedAgentStreamFnMock.mockReset(); resolveEmbeddedAgentStreamFnMock.mockImplementation((_params?: unknown) => vi.fn()); + getApiKeyForModelMock.mockReset(); + getApiKeyForModelMock.mockImplementation(async (params?: { profileId?: string }) => ({ + apiKey: "test", + mode: "api-key", + source: params?.profileId ? `profile:${params.profileId}` : "test harness", + ...(params?.profileId ? { profileId: params.profileId } : {}), + })); + resolveProviderEntryApiKeyProfileReferenceMock.mockReset(); + resolveProviderEntryApiKeyProfileReferenceMock.mockReturnValue({ kind: "none" }); + shouldPreferExplicitConfigApiKeyAuthMock.mockReset(); + shouldPreferExplicitConfigApiKeyAuthMock.mockReturnValue(false); registerProviderStreamForModelMock.mockReset(); registerProviderStreamForModelMock.mockReturnValue(undefined); applyExtraParamsToAgentMock.mockReset(); applyExtraParamsToAgentMock.mockReturnValue({ effectiveExtraParams: {} }); + ensureAuthProfileStoreMock.mockReset(); + ensureAuthProfileStoreMock.mockImplementation(createDefaultCompactionAuthStore); + ensureAuthProfileStoreWithoutExternalProfilesMock.mockReset(); + ensureAuthProfileStoreWithoutExternalProfilesMock.mockImplementation( + createDefaultCompactionAuthStore, + ); resolveAgentTransportOverrideMock.mockReset(); resolveAgentTransportOverrideMock.mockReturnValue(undefined); resolveSandboxContextMock.mockReset(); @@ -371,6 +485,12 @@ export function resetCompactSessionStateMocks(): void { maybeCompactAgentHarnessSessionMock.mockResolvedValue(undefined); resolveAgentHarnessPolicyMock.mockReset(); resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); + selectAgentHarnessMock.mockReset(); + selectAgentHarnessMock.mockImplementation(createSelectedAgentHarnessMock); + selectAgentHarnessForPreparedModelProvidersMock.mockReset(); + selectAgentHarnessForPreparedModelProvidersMock.mockImplementation( + createSelectedAgentHarnessMock, + ); resolveContextWindowInfoMock.mockReset(); resolveContextWindowInfoMock.mockReturnValue({ tokens: 128_000 }); rotateTranscriptAfterCompactionMock.mockReset(); @@ -385,6 +505,10 @@ export function resetCompactSessionStateMocks(): void { ? ["ACP compact command guidance."] : ["Main compact command guidance."], ); + buildAgentRuntimePlanMock.mockReset(); + buildAgentRuntimePlanMock.mockImplementation((params: BuildAgentRuntimePlanParams) => + createCompactHooksRuntimePlan(params), + ); buildEmbeddedSystemPromptMock.mockReset(); buildEmbeddedSystemPromptMock.mockReturnValue(""); } @@ -416,12 +540,23 @@ export function resetCompactHooksHarnessMocks(): void { compactWithSafetyTimeoutMock.mockImplementation(runCompactWithSafetyTimeoutMock); resolveModelMock.mockReset(); - resolveModelMock.mockReturnValue({ - model: { provider: "openai", api: "responses", id: "fake", input: [] }, + resolveModelMock.mockImplementation((provider?: string, modelId?: string) => ({ + model: { + provider: provider ?? "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + id: modelId ?? "fake", + input: [], + }, error: null, authStorage: { setRuntimeApiKey: vi.fn() }, modelRegistry: {}, - }); + })); + resolveModelAsyncMock.mockReset(); + resolveModelAsyncMock.mockImplementation( + async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => + resolveModelMock(provider, modelId, agentDir, cfg), + ); resolveAgentHarnessPolicyMock.mockReset(); resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); resolveContextWindowInfoMock.mockReset(); @@ -514,6 +649,16 @@ export async function loadCompactHooksHarness(): Promise<{ ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined), })); + vi.doMock("../harness/selection.js", async () => { + const actual = + await vi.importActual("../harness/selection.js"); + return { + ...actual, + selectAgentHarness: selectAgentHarnessMock, + selectAgentHarnessForPreparedModelProviders: selectAgentHarnessForPreparedModelProvidersMock, + }; + }); + vi.doMock("../../plugins/provider-runtime.js", () => ({ prepareProviderRuntimeAuth: vi.fn(async () => ({ resolvedApiKey: undefined })), resolveProviderReasoningOutputModeWithPlugin: vi.fn(() => undefined), @@ -577,17 +722,19 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("../model-auth.js", () => ({ applyAuthHeaderOverride: vi.fn((model: unknown) => model), applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), - ensureAuthProfileStoreWithoutExternalProfiles: vi.fn(() => ({})), + ensureAuthProfileStore: ensureAuthProfileStoreMock, + ensureAuthProfileStoreWithoutExternalProfiles: + ensureAuthProfileStoreWithoutExternalProfilesMock, formatMissingAuthError: vi.fn( (auth: { mode: string; source: string }, provider: string) => `No API key resolved for provider "${provider}" (auth mode: ${auth.mode}, checked: ${auth.source}).`, ), - getApiKeyForModel: vi.fn(async () => ({ - apiKey: "test", - mode: "env", - source: "test harness", - })), + getApiKeyForModel: (params: { profileId?: string; allowAuthProfileFallback?: boolean }) => + getApiKeyForModelMock(params), + hasUsableCustomProviderApiKey: vi.fn(() => false), + resolveProviderEntryApiKeyProfileReference: resolveProviderEntryApiKeyProfileReferenceMock, resolveModelAuthMode: vi.fn(() => "env"), + shouldPreferExplicitConfigApiKeyAuth: shouldPreferExplicitConfigApiKeyAuthMock, })); vi.doMock("../sandbox.js", () => ({ @@ -621,8 +768,25 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("../../process/command-queue.js", () => ({ enqueueCommandInLane: enqueueCommandInLaneMock, clearCommandLane: vi.fn(() => 0), + GatewayDrainingError: class GatewayDrainingError extends Error {}, + isGatewayDraining: vi.fn(() => false), })); + vi.doMock("../../tasks/detached-task-runtime.js", async () => { + const actual = await vi.importActual( + "../../tasks/detached-task-runtime.js", + ); + return { + ...actual, + // Deferred-maintenance lifecycle tests isolate queue ownership from the + // file-backed task registry, which has separate integration coverage. + createQueuedTaskRun: vi.fn((params: { runId?: string }) => ({ + taskId: `test-task:${params.runId ?? "deferred"}`, + runId: params.runId, + })), + }; + }); + vi.doMock("./lanes.js", () => ({ resolveSessionLane: vi.fn(() => "test-session-lane"), resolveEmbeddedSessionLane: vi.fn(() => "test-session-lane"), @@ -684,7 +848,7 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("./replay-history.js", () => ({ sanitizeSessionHistory: sanitizeSessionHistoryMock, - validateReplayTurns: vi.fn(async ({ messages }: { messages: unknown[] }) => messages), + validateReplayTurns: validateReplayTurnsMock, })); vi.doMock("./tool-schema-runtime.js", () => ({ @@ -755,7 +919,7 @@ export async function loadCompactHooksHarness(): Promise<{ })); vi.doMock("./extensions.js", () => ({ - buildEmbeddedExtensionFactories: vi.fn(() => []), + buildEmbeddedExtensionFactories: buildEmbeddedExtensionFactoriesMock, })); vi.doMock("./history.js", () => ({ @@ -796,9 +960,7 @@ export async function loadCompactHooksHarness(): Promise<{ })); vi.doMock("../runtime-plan/build.js", () => ({ - buildAgentRuntimePlan: vi.fn((params: BuildAgentRuntimePlanParams) => - createCompactHooksRuntimePlan(params), - ), + buildAgentRuntimePlan: buildAgentRuntimePlanMock, })); vi.doMock("../../plugins/memory-runtime.js", () => ({ @@ -862,10 +1024,7 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("./model.js", () => ({ buildModelAliasLines: vi.fn(() => []), resolveModel: resolveModelMock, - resolveModelAsync: vi.fn( - async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => - resolveModelMock(provider, modelId, agentDir, cfg), - ), + resolveModelAsync: resolveModelAsyncMock, })); vi.doMock("./session-manager-cache.js", () => ({ diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 2e33cbcf0c52..6adc4e666bb4 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -5,6 +5,7 @@ import { createReplyOperation } from "../../auto-reply/reply/reply-run-registry. import { applyExtraParamsToAgentMock, applyAgentCompactionSettingsFromConfigMock, + buildAgentRuntimePlanMock, buildEmbeddedSystemPromptMock, contextEngineCompactMock, compactWithSafetyTimeoutMock, @@ -12,8 +13,10 @@ import { createPreparedEmbeddedAgentSettingsManagerMock, createOpenClawCodingToolsMock, enqueueCommandInLaneMock, + ensureAuthProfileStoreMock, ensureRuntimePluginsLoaded, estimateTokensMock, + getApiKeyForModelMock, getMemorySearchManagerMock, guardSessionManagerMock, hookRunner, @@ -22,15 +25,20 @@ import { maybeCompactAgentHarnessSessionMock, resolveAgentHarnessPolicyMock, registerProviderStreamForModelMock, + resolveProviderEntryApiKeyProfileReferenceMock, resolveContextWindowInfoMock, resolveContextEngineMock, resolveEmbeddedAgentStreamFnMock, resolveMemorySearchConfigMock, + resolveModelAsyncMock, resolveModelMock, resolveSandboxContextMock, resolveSessionAgentIdMock, resolveSessionAgentIdsMock, rotateTranscriptAfterCompactionMock, + selectAgentHarnessForPreparedModelProvidersMock, + selectAgentHarnessMock, + shouldPreferExplicitConfigApiKeyAuthMock, resetCompactHooksHarnessMocks, resetCompactSessionStateMocks, sessionAbortCompactionMock, @@ -151,22 +159,41 @@ function findMockCall(mock: ReturnType, predicate: (arg: unknown[] return call; } -function mockResolvedModel(params?: { supportsTools?: boolean; input?: string[] }) { +function mockResolvedModel(params?: { + supportsTools?: boolean; + input?: string[]; + contextWindow?: number; +}) { resolveModelMock.mockReset(); - resolveModelMock.mockReturnValue({ - model: { - provider: "openai", - api: "responses", - id: "fake", - input: params?.input ?? [], - ...(params?.supportsTools === undefined - ? {} - : { compat: { supportsTools: params.supportsTools } }), + resolveModelMock.mockImplementation( + (provider = "openai", modelId = "fake", _agentDir?: string, cfg?: unknown) => { + const providerConfig = ( + cfg as + | { + models?: { + providers?: Record; + }; + } + | undefined + )?.models?.providers?.[provider]; + return { + model: { + provider, + api: providerConfig?.api ?? "openai-responses", + baseUrl: providerConfig?.baseUrl?.trim() || "https://api.openai.com/v1", + id: modelId, + input: params?.input ?? [], + ...(params?.contextWindow === undefined ? {} : { contextWindow: params.contextWindow }), + ...(params?.supportsTools === undefined + ? {} + : { compat: { supportsTools: params.supportsTools } }), + }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }; }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - }); + ); } function compactionConfig(mode: "await" | "off" | "async") { @@ -193,6 +220,39 @@ function wrappedCompactionArgs(overrides: Record = {}) { }; } +function createPreparedCodexCompactionPlans(modelId = "gpt-5.5") { + const modelRoute = { + provider: "openai", + modelId, + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["codex"] }, + } as const; + const runtimeAuthPlan = { + providerForAuth: "openai", + modelId, + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + selectedAuthMode: "api-key", + modelRoute, + } as const; + return { + modelRoute, + runtimeAuthPlan, + runtimePlan: { + resolvedRef: { + provider: "openai", + modelId, + modelApi: "openai-responses", + harnessId: "codex", + }, + auth: runtimeAuthPlan, + } as never, + }; +} + const sessionHook = (action: string): SessionHookEvent | undefined => triggerInternalHook.mock.calls.find((call) => { const event = call[0] as SessionHookEvent | undefined; @@ -289,6 +349,301 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { expect(sessionCompactImpl).not.toHaveBeenCalled(); }); + it("preserves prepared runtime plans for the normalized primary compaction candidate", async () => { + const { modelRoute, runtimeAuthPlan, runtimePlan } = createPreparedCodexCompactionPlans(); + + const result = await compactEmbeddedAgentSessionDirect({ + ...wrappedCompactionArgs({ provider: " OpenAI ", model: "gpt-5.5" }), + modelFallbacksOverride: ["anthropic/claude-fallback"], + runtimeAuthPlan, + runtimePlan, + }); + + expect(result).toMatchObject({ ok: true }); + expect(selectAgentHarnessForPreparedModelProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + modelId: "gpt-5.5", + agentHarnessRuntimeOverride: "codex", + modelProviders: [ + expect.objectContaining({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + preparedAuth: expect.objectContaining({ source: "direct" }), + }), + ], + }), + ); + expect(selectAgentHarnessMock).not.toHaveBeenCalled(); + expect(buildAgentRuntimePlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + modelId: "gpt-5.5", + harnessId: "codex", + modelRoute, + }), + ); + }); + + it("rebuilds runtime plans for an actual compaction fallback candidate", async () => { + const { runtimeAuthPlan, runtimePlan } = createPreparedCodexCompactionPlans(); + sessionCompactImpl + .mockRejectedValueOnce( + Object.assign(new Error("primary compaction rate limited"), { + status: 429, + code: "rate_limit_exceeded", + }), + ) + .mockResolvedValueOnce({ + summary: "rebuilt fallback summary", + firstKeptEntryId: "entry-fallback", + tokensBefore: 120, + details: { ok: true }, + }); + + const result = await compactEmbeddedAgentSessionDirect({ + ...wrappedCompactionArgs({ provider: "openai", model: "gpt-5.5" }), + modelFallbacksOverride: ["anthropic/claude-fallback"], + runtimeAuthPlan, + runtimePlan, + }); + + expect(result).toMatchObject({ ok: true, result: { summary: "rebuilt fallback summary" } }); + const fallbackPlanCall = findMockCall(buildAgentRuntimePlanMock, ([input]) => { + const fields = input as { provider?: string; modelId?: string } | undefined; + return fields?.provider === "anthropic" && fields.modelId === "claude-fallback"; + }); + expectRecordFields(fallbackPlanCall[0], { + provider: "anthropic", + modelId: "claude-fallback", + harnessId: "openclaw", + modelRoute: undefined, + }); + }); + + it("rematerializes the downstream model for a resolved backup profile", async () => { + getApiKeyForModelMock + .mockRejectedValueOnce(new Error("missing SecretRef")) + .mockResolvedValueOnce({ + apiKey: "backup-key", + mode: "api-key", + source: "profile:openai:backup", + profileId: "openai:backup", + }); + + await compactEmbeddedAgentSessionDirect( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + runtimeAuthPlan: { + providerForAuth: "openai", + modelId: "gpt-5.5", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:missing", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["openai:missing", "openai:backup"], + selectedAuthMode: "api-key", + }, + }), + ); + + expect( + getApiKeyForModelMock.mock.calls.map( + ([params]) => (params as { profileId?: string }).profileId, + ), + ).toEqual(["openai:missing", "openai:backup"]); + expect( + resolveModelAsyncMock.mock.calls.some((call) => { + const options = (call as unknown as readonly unknown[])[4] as + | { authProfileId?: string } + | undefined; + return options?.authProfileId === "openai:backup"; + }), + ).toBe(true); + expect(resolveEmbeddedAgentStreamFnMock).toHaveBeenCalledWith( + expect.objectContaining({ authProfileId: "openai:backup" }), + ); + expect(buildAgentRuntimePlanMock).toHaveBeenCalledWith( + expect.objectContaining({ sessionAuthProfileId: "openai:backup" }), + ); + }); + + it("falls through a failed subscription auth route to the prepared Platform route", async () => { + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai:subscription": { + type: "token", + provider: "openai", + token: "subscription-token", + expires: Date.now() + 60_000, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + order: { openai: ["openai:subscription", "openai:platform"] }, + }); + getApiKeyForModelMock.mockImplementation(async (authParams = {}) => { + if (authParams.profileId === "openai:subscription") { + throw new Error("subscription credential resolution failed"); + } + if (authParams.profileId === "openai:platform") { + return { + apiKey: "platform-key", + mode: "api-key", + source: "profile:openai:platform", + profileId: "openai:platform", + }; + } + throw new Error(`unexpected profile: ${authParams.profileId ?? "none"}`); + }); + + const result = await compactEmbeddedAgentSessionDirect( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + config: { + auth: { order: { openai: ["openai:subscription", "openai:platform"] } }, + agents: { + defaults: { + models: { "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } } }, + }, + }, + }, + }), + ); + + expect(result.ok).toBe(true); + expect(getApiKeyForModelMock.mock.calls.map(([authParams]) => authParams?.profileId)).toEqual([ + "openai:subscription", + "openai:platform", + ]); + expectRecordFields(mockCallArg(createAgentSessionMock), { + model: expect.objectContaining({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }), + }); + expect(buildAgentRuntimePlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionAuthProfileId: "openai:platform", + modelRoute: expect.objectContaining({ + api: "openai-responses", + authRequirement: "api-key", + }), + }), + ); + }); + + it("uses a prepared direct API-key fallback only after its profile tier fails", async () => { + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai:broken": { + type: "api_key", + provider: "openai", + key: "broken-profile-key", + }, + }, + order: { openai: ["openai:broken"] }, + }); + resolveProviderEntryApiKeyProfileReferenceMock.mockReturnValue({ kind: "literal" }); + shouldPreferExplicitConfigApiKeyAuthMock.mockReturnValue(false); + getApiKeyForModelMock.mockImplementation(async (authParams = {}) => { + if (authParams.profileId === "openai:broken") { + throw new Error("profile key could not be resolved"); + } + if (authParams.profileId === undefined && authParams.allowAuthProfileFallback === false) { + return { + apiKey: "literal-key", + mode: "api-key", + source: "models.json", + }; + } + throw new Error("unexpected auth lookup"); + }); + + const result = await compactEmbeddedAgentSessionDirect( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + config: { + auth: { order: { openai: ["openai:broken"] } }, + models: { + providers: { + openai: { apiKey: "literal-key", baseUrl: "", models: [] }, + }, + }, + agents: { + defaults: { + models: { "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } } }, + }, + }, + }, + }), + ); + + expect(result.ok).toBe(true); + expect( + getApiKeyForModelMock.mock.calls.map(([authParams]) => ({ + profileId: authParams?.profileId, + allowAuthProfileFallback: authParams?.allowAuthProfileFallback, + })), + ).toEqual([ + { profileId: "openai:broken", allowAuthProfileFallback: undefined }, + { profileId: undefined, allowAuthProfileFallback: false }, + ]); + expect(buildAgentRuntimePlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileMode: "api-key", + sessionAuthProfileId: undefined, + modelRoute: expect.objectContaining({ + api: "openai-responses", + authRequirement: "api-key", + }), + }), + ); + }); + + it("replans manual compaction once when the full attempt set changes harness", async () => { + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + order: { openai: ["openai:platform"] }, + }); + selectAgentHarnessMock.mockReturnValueOnce({ + id: "codex", + label: "Codex test harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + } as never); + selectAgentHarnessForPreparedModelProvidersMock.mockReturnValue({ + id: "openclaw", + label: "OpenClaw test harness", + supports: () => ({ supported: true }), + runAttempt: vi.fn(), + } as never); + + const result = await compactEmbeddedAgentSessionDirect( + wrappedCompactionArgs({ provider: "openai", model: "gpt-5.5" }), + ); + + expect(result.ok).toBe(true); + expect(selectAgentHarnessForPreparedModelProvidersMock).toHaveBeenCalledTimes(2); + expect(buildAgentRuntimePlanMock).toHaveBeenCalledWith( + expect.objectContaining({ harnessId: "openclaw", harnessRuntime: "openclaw" }), + ); + }); + it("bootstraps runtime plugins with the resolved workspace", async () => { // This assertion only cares about bootstrap wiring, so stop before the // rest of the compaction pipeline can pull in unrelated runtime surfaces. @@ -707,12 +1062,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); it("uses the session model fallback chain when overflow compaction fails", async () => { - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); sessionCompactImpl .mockRejectedValueOnce( Object.assign(new Error("primary compaction rate limited"), { @@ -769,12 +1118,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); it("keeps model-locked OpenClaw compaction on its exact model without fallbacks", async () => { - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); sessionCompactImpl.mockRejectedValueOnce( Object.assign(new Error("primary compaction rate limited"), { status: 429 }), ); @@ -810,12 +1153,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { it("revalidates immutable Ultra for each compaction fallback candidate", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); sessionCompactImpl .mockRejectedValueOnce( Object.assign(new Error("primary compaction rate limited"), { @@ -862,11 +1199,25 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); it("preserves Codex OAuth across same-provider OpenAI compaction fallbacks", async () => { - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, + mockResolvedModel(); + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai:default": { + type: "oauth", + provider: "openai", + access: "test-access", + refresh: "test-refresh", + expires: Date.now() + 60_000, + }, + }, + order: { openai: ["openai:default"] }, + }); + getApiKeyForModelMock.mockImplementation(async (params?: { profileId?: string }) => ({ + apiKey: "test-oauth", + mode: "oauth", + source: `profile:${params?.profileId ?? "openai:default"}`, + profileId: params?.profileId ?? "openai:default", })); sessionCompactImpl .mockRejectedValueOnce( @@ -888,15 +1239,15 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp/workspace", provider: "openai", - model: "gpt-primary", + model: "gpt-5.5", authProfileId: "openai:default", trigger: "overflow", - modelFallbacksOverride: ["openai/gpt-fallback"], + modelFallbacksOverride: ["openai/gpt-5.4-mini"], config: { agents: { defaults: { model: { - primary: "openai/gpt-primary", + primary: "openai/gpt-5.5", fallbacks: [], }, }, @@ -908,25 +1259,19 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { expect(result.result?.summary).toBe("oauth fallback summary"); findMockCall( resolveModelMock, - ([provider, modelId]) => provider === "openai" && modelId === "gpt-primary", + ([provider, modelId]) => provider === "openai" && modelId === "gpt-5.5", ); findMockCall( resolveModelMock, - ([provider, modelId]) => provider === "openai" && modelId === "gpt-fallback", + ([provider, modelId]) => provider === "openai" && modelId === "gpt-5.4-mini", ); expectRecordFields(mockCallArg(resolveEmbeddedAgentStreamFnMock, 1), { authProfileId: "openai:default", }); }); - it("uses the selected Codex runtime provider for OpenAI compaction", async () => { + it("keeps custom OpenAI-compatible compaction on OpenAI logical context", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "codex" }); - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", @@ -939,12 +1284,11 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { config: { models: { providers: { - openai: { models: [{ id: "gpt-5.5", contextWindow: 350_000 }] }, - }, - }, - auth: { - order: { - openai: ["openai:work"], + openai: { + api: "openai-responses", + baseUrl: "https://example.test/v1", + models: [{ id: "gpt-5.5", contextWindow: 350_000 }], + }, }, }, agents: { defaults: { embeddedHarness: { runtime: "codex" } } }, @@ -954,6 +1298,29 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { expect(result.ok).toBe(true); expect(mockCallArg(resolveModelMock)).toBe("openai"); expect(mockCallArg(resolveModelMock, 0, 1)).toBe("gpt-5.5"); + const sessionOptions = expectRecordFields(mockCallArg(createAgentSessionMock), {}); + expectRecordFields(sessionOptions.model, { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://example.test/v1", + }); + expect(buildAgentRuntimePlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + model: expect.objectContaining({ + api: "openai-responses", + baseUrl: "https://example.test/v1", + }), + modelRoute: expect.objectContaining({ + api: "openai-responses", + baseUrl: "https://example.test/v1", + authRequirement: "api-key", + }), + }), + ); expectRecordFields(mockCallArg(resolveContextWindowInfoMock), { provider: "openai", modelId: "gpt-5.5", @@ -965,12 +1332,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { runtime: "codex", runtimeSource: "model", } as never); - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", @@ -990,6 +1351,16 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { expect(resolveAgentHarnessPolicyMock).toHaveBeenCalledWith( expect.objectContaining({ provider: "openai", modelId: "fake-model" }), ); + expect(selectAgentHarnessForPreparedModelProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ + modelProviders: expect.arrayContaining([ + expect.objectContaining({ + preparedAuth: expect.objectContaining({ source: "profile" }), + runtimePolicy: expect.objectContaining({ compatibleIds: ["openclaw", "codex"] }), + }), + ]), + }), + ); expect(mockCallArg(resolveModelMock)).toBe("openai"); expectRecordFields(mockCallArg(resolveContextWindowInfoMock), { provider: "openai", @@ -997,50 +1368,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); }); - it("keeps custom OpenAI-compatible compaction on OpenAI context config", async () => { - resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "codex" }); - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [], contextWindow: 1_000_000 }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); - - const result = await compactEmbeddedAgentSessionDirect({ - sessionId: "session-1", - sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp/workspace", - provider: "openai", - model: "gpt-5.5", - agentHarnessId: "codex", - config: { - models: { - providers: { - openai: { models: [{ id: "gpt-5.5", contextWindow: 350_000 }] }, - }, - }, - agents: { defaults: { embeddedHarness: { runtime: "codex" } } }, - } as never, - }); - - expect(result.ok).toBe(true); - expect(mockCallArg(resolveModelMock)).toBe("openai"); - expect(mockCallArg(resolveModelMock, 0, 1)).toBe("gpt-5.5"); - expectRecordFields(mockCallArg(resolveContextWindowInfoMock), { - provider: "openai", - modelId: "gpt-5.5", - }); - }); - it("preserves direct OpenAI API-key compaction when OpenClaw runtime is active", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", @@ -1064,14 +1393,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { expect(mockCallArg(resolveModelMock, 0, 1)).toBe("gpt-5.5"); }); - it("routes OpenAI compaction model overrides through Codex OAuth when Codex runtime is active", async () => { + it("uses the compaction model override with a pinned Codex harness", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "codex" }); - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", @@ -1107,13 +1430,26 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); }); - it("uses Codex auth for runtime model loading while preserving OpenAI context config", async () => { + it("materializes subscription-auth OpenAI compaction while preserving logical context", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [], contextWindow: 1_000_000 }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, + mockResolvedModel({ contextWindow: 1_000_000 }); + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai:work": { + type: "oauth", + provider: "openai", + access: "test-access", + refresh: "test-refresh", + expires: Date.now() + 60_000, + }, + }, + }); + getApiKeyForModelMock.mockImplementation(async (params?: { profileId?: string }) => ({ + apiKey: "test-oauth", + mode: "oauth", + source: `profile:${params?.profileId ?? "openai:work"}`, + profileId: params?.profileId ?? "openai:work", })); const result = await compactEmbeddedAgentSessionDirect({ @@ -1124,22 +1460,39 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { provider: "openai", model: "gpt-5.5", authProfileId: "openai:work", + authProfileIdSource: "user", config: { models: { providers: { openai: { models: [{ id: "gpt-5.5", contextWindow: 350_000 }] }, }, }, - auth: { - order: { - openai: ["openai:work"], - }, - }, } as never, }); expect(result.ok).toBe(true); expect(mockCallArg(resolveModelMock)).toBe("openai"); + const sessionOptions = expectRecordFields(mockCallArg(createAgentSessionMock), {}); + expectRecordFields(sessionOptions.model, { + provider: "openai", + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }); + expect(buildAgentRuntimePlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-chatgpt-responses", + sessionAuthProfileId: "openai:work", + sessionAuthProfileSource: "user", + modelRoute: expect.objectContaining({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + }), + }), + ); expectRecordFields(mockCallArg(resolveContextWindowInfoMock), { provider: "openai", modelId: "gpt-5.5", @@ -1147,12 +1500,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); it("keeps compaction fallback selection ephemeral", async () => { - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); sessionCompactImpl .mockRejectedValueOnce(Object.assign(new Error("400 invalid request body"), { status: 400 })) .mockResolvedValueOnce({ @@ -1213,12 +1560,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); it("preserves explicit compaction.model behavior without session fallback", async () => { - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); sessionCompactImpl.mockRejectedValueOnce( Object.assign(new Error("400 invalid request body"), { status: 400 }), ); @@ -1256,12 +1597,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); it("preserves compaction failure status and code metadata", async () => { - resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({ - model: { provider, api: "responses", id: modelId, input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })); sessionCompactImpl.mockRejectedValueOnce( Object.assign(new Error("primary compaction rate limited"), { status: 429, @@ -1844,6 +2179,43 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { + function mockQueuedRouteAwareModel( + defaultApi: "openai-responses" | "openai-chatgpt-responses" = "openai-responses", + ) { + resolveModelMock.mockImplementation( + (provider = "openai", modelId = "gpt-5.5", _agentDir?: string, cfg?: unknown) => { + const providerConfig = ( + cfg as + | { + models?: { + providers?: Record; + }; + } + | undefined + )?.models?.providers?.[provider]; + const api = providerConfig?.api ?? defaultApi; + const subscription = api === "openai-chatgpt-responses"; + return { + model: { + provider, + id: modelId, + api, + baseUrl: + providerConfig?.baseUrl ?? + (subscription + ? "https://chatgpt.com/backend-api/codex" + : "https://api.openai.com/v1"), + contextWindow: subscription ? 272_000 : 1_050_000, + input: [], + }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }; + }, + ); + } + beforeEach(() => { hookRunner.hasHooks.mockReset(); hookRunner.runBeforeCompaction.mockReset(); @@ -1861,6 +2233,81 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { result: { summary: "engine-summary", tokensAfter: 50 }, }); mockResolvedModel(); + mockQueuedRouteAwareModel(); + }); + + it("disposes the context engine once when route materialization rejects", async () => { + const dispose = vi.fn(async () => {}); + const authStorage = { setRuntimeApiKey: vi.fn() }; + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: true }, + compact: contextEngineCompactMock, + dispose, + } as never); + resolveModelAsyncMock + .mockResolvedValueOnce({ + model: { + provider: "openai", + id: "fake", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + input: [], + }, + error: null, + authStorage, + modelRegistry: {}, + }) + .mockRejectedValueOnce(new Error("route materialization failed")); + + await expect( + compactEmbeddedAgentSession( + wrappedCompactionArgs({ + provider: "openai", + model: "fake", + runtimeAuthPlan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: "api-key", + modelRoute: { + provider: "openai", + modelId: "fake", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }, + }), + ), + ).rejects.toThrow("route materialization failed"); + expect(dispose).toHaveBeenCalledTimes(1); + expect(enqueueCommandInLaneMock).not.toHaveBeenCalled(); + }); + + it("disposes the context engine safely when primary native compaction throws", async () => { + const dispose = vi.fn(async () => { + throw new Error("dispose failed"); + }); + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: contextEngineCompactMock, + dispose, + } as never); + maybeCompactAgentHarnessSessionMock.mockRejectedValueOnce( + new Error("native compaction failed"), + ); + + await expect( + compactEmbeddedAgentSession( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + agentHarnessId: "codex", + }), + ), + ).rejects.toThrow("native compaction failed"); + expect(dispose).toHaveBeenCalledTimes(1); + expect(enqueueCommandInLaneMock).not.toHaveBeenCalled(); }); it("binds context-engine compaction runtime LLM to the session agent", async () => { @@ -2109,6 +2556,16 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { it("passes resolved OpenAI runtime context to context-engine compaction", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "codex" }); + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai:p1": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + }); maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({ ok: true, compacted: true, @@ -2124,6 +2581,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { provider: "openai", model: "gpt-5.4", authProfileId: "openai:p1", + authProfileIdSource: "user", currentTokenCount: 333, }), ); @@ -2191,6 +2649,165 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { }); }); + it("keeps queued native auth candidates uncollapsed until native resolution", async () => { + resolveAgentHarnessPolicyMock.mockReturnValue({ + runtime: "native", + runtimeSource: "model", + } as never); + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai:subscription": { + type: "token", + provider: "openai", + token: "subscription-token", + expires: Date.now() + 60_000, + }, + }, + order: { openai: ["openai:subscription"] }, + }); + resolveProviderEntryApiKeyProfileReferenceMock.mockReturnValue({ kind: "literal" }); + shouldPreferExplicitConfigApiKeyAuthMock.mockReturnValue(false); + maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({ + ok: true, + compacted: true, + result: { + summary: "harness", + firstKeptEntryId: "entry-1", + tokensBefore: 100, + }, + }); + + const result = await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + agentHarnessId: "native", + config: { + models: { + providers: { + openai: { + auth: "api-key", + apiKey: "literal-key", + models: [{ id: "gpt-5.5", contextWindow: 350_000 }], + }, + }, + }, + }, + }), + ); + + expect(result.ok).toBe(true); + expect(selectAgentHarnessForPreparedModelProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ + modelProviders: expect.arrayContaining([ + expect.objectContaining({ + api: "openai-chatgpt-responses", + preparedAuth: expect.objectContaining({ source: "profile" }), + }), + expect.objectContaining({ + api: "openai-responses", + preparedAuth: expect.objectContaining({ source: "direct" }), + }), + ]), + }), + ); + const nativeParams = mockCallArg(maybeCompactAgentHarnessSessionMock) as { + runtimeAuthPlan?: unknown; + runtimePlan?: unknown; + }; + expect(nativeParams.runtimeAuthPlan).toBeUndefined(); + expect(nativeParams.runtimePlan).toBeUndefined(); + }); + + it("keeps cross-route direct fallback available through queued legacy compaction", async () => { + const authStore = { + version: 1 as const, + profiles: { + "openai:subscription": { + type: "token" as const, + provider: "openai", + token: "subscription-token", + expires: Date.now() + 60_000, + }, + }, + order: { openai: ["openai:subscription"] }, + }; + ensureAuthProfileStoreMock.mockReturnValue(authStore); + resolveProviderEntryApiKeyProfileReferenceMock.mockReturnValue({ kind: "literal" }); + shouldPreferExplicitConfigApiKeyAuthMock.mockReturnValue(false); + getApiKeyForModelMock.mockImplementation(async (authParams = {}) => { + if (authParams.profileId === "openai:subscription") { + throw new Error("subscription credential resolution failed"); + } + if (authParams.allowAuthProfileFallback === false) { + return { apiKey: "literal-key", mode: "api-key", source: "models.json" }; + } + throw new Error("unexpected auth lookup"); + }); + const legacyCompact = vi.fn( + async (compactParams: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + tokenBudget?: number; + force?: boolean; + customInstructions?: string; + runtimeContext?: Record; + }) => { + const directParams = { + ...compactParams.runtimeContext, + sessionId: compactParams.sessionId, + sessionKey: compactParams.sessionKey, + sessionFile: compactParams.sessionFile, + tokenBudget: compactParams.tokenBudget, + force: compactParams.force, + customInstructions: compactParams.customInstructions, + workspaceDir: TEST_WORKSPACE_DIR, + } as Parameters[0]; + return await compactEmbeddedAgentSessionDirect(directParams); + }, + ); + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: legacyCompact, + } as never); + + const result = await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + config: { + models: { + providers: { + openai: { + auth: "api-key", + apiKey: "literal-key", + models: [{ id: "gpt-5.5" }], + }, + }, + }, + agents: { + defaults: { + models: { "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } } }, + }, + }, + }, + }), + ); + + expect(result.ok).toBe(true); + expect( + getApiKeyForModelMock.mock.calls.map(([authParams]) => ({ + profileId: authParams?.profileId, + allowAuthProfileFallback: authParams?.allowAuthProfileFallback, + })), + ).toEqual([ + { profileId: "openai:subscription", allowAuthProfileFallback: undefined }, + { profileId: undefined, allowAuthProfileFallback: false }, + ]); + }); + it("uses explicit Codex runtime policy for queued native compaction", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "codex", @@ -2240,6 +2857,46 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { }); }); + it("normalizes an omitted manual target before native harness compaction", async () => { + resolveAgentHarnessPolicyMock.mockReturnValue({ + runtime: "codex", + runtimeSource: "model", + } as never); + maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({ + ok: true, + compacted: true, + result: { summary: "harness", firstKeptEntryId: "entry-1", tokensBefore: 100 }, + }); + + const result = await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + config: { + agents: { + defaults: { + compaction: { model: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + }, + }), + ); + + expect(result.ok).toBe(true); + expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + model: "gpt-5.5", + runtimeModel: expect.objectContaining({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }), + }), + { nativeCompactionRequest: "after_context_engine" }, + ); + }); + it("preserves concrete OpenClaw pins over explicit Codex policy for queued compaction", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "codex", @@ -2333,6 +2990,120 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { }); }); + it("materializes the selected route before deriving compaction context budget", async () => { + resolveAgentHarnessPolicyMock.mockReturnValue({ + runtime: "codex", + runtimeSource: "model", + } as never); + resolveContextWindowInfoMock.mockImplementation((input?: { modelContextWindow?: number }) => ({ + tokens: input?.modelContextWindow ?? 128_000, + })); + ensureAuthProfileStoreMock.mockReturnValue({ + version: 1, + profiles: { + "openai:token": { + type: "token", + provider: "openai", + token: "subscription-token", + }, + }, + order: { openai: ["openai:token"] }, + }); + maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({ + ok: true, + compacted: true, + result: { summary: "harness", firstKeptEntryId: "entry-1", tokensBefore: 100 }, + }); + + await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + authProfileId: "openai:token", + authProfileIdSource: "auto", + agentHarnessId: "codex", + }), + ); + + expect(resolveModelAsyncMock).toHaveBeenLastCalledWith( + "openai", + "gpt-5.5", + expect.any(String), + expect.objectContaining({ + models: { + providers: { + openai: expect.objectContaining({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }), + }, + }, + }), + expect.objectContaining({ authProfileMode: "token" }), + ); + expect(contextEngineCompactMock).toHaveBeenCalledWith( + expect.objectContaining({ tokenBudget: 272_000 }), + ); + const compactArg = mockCallArg(contextEngineCompactMock) as { + runtimeContext?: Record; + }; + expectRecordFields(compactArg.runtimeContext, { + provider: "openai", + runtimeProvider: undefined, + model: "gpt-5.5", + }); + expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: "openai:token", + authProfileIdSource: "auto", + contextTokenBudget: 272_000, + runtimeModel: expect.objectContaining({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 272_000, + }), + runtimeAuthPlan: undefined, + }), + { nativeCompactionRequest: "after_context_engine" }, + ); + }); + + it("prepares queued native harness auth without a host profile", async () => { + resolveAgentHarnessPolicyMock.mockReturnValue({ + runtime: "codex", + runtimeSource: "model", + } as never); + ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: {} }); + maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({ + ok: true, + compacted: true, + result: { summary: "harness", firstKeptEntryId: "entry-1", tokensBefore: 100 }, + }); + + await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + agentHarnessId: "codex", + }), + ); + + expect(selectAgentHarnessForPreparedModelProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ + modelProviders: expect.arrayContaining([ + expect.objectContaining({ + preparedAuth: expect.objectContaining({ source: "harness" }), + runtimePolicy: expect.objectContaining({ compatibleIds: ["openclaw", "codex"] }), + }), + ]), + }), + ); + expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ runtimeAuthPlan: undefined }), + { nativeCompactionRequest: "after_context_engine" }, + ); + }); + it("does not route queued compaction through implicit Codex policy alone", async () => { resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "codex" }); maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({ @@ -2371,7 +3142,23 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { }); }); - it("keeps queued custom OpenAI-compatible compaction on OpenAI context config", async () => { + it("uses a prepared harness binding for queued custom OpenAI Responses compaction", async () => { + const modelRoute = { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://example.test/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + } as const; + const runtimeAuthPlan = { + providerForAuth: "openai", + modelId: "gpt-5.5", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + selectedAuthMode: "api-key", + modelRoute, + } as const; resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "codex" }); maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({ ok: true, @@ -2388,10 +3175,12 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { provider: "openai", model: "gpt-5.5", agentHarnessId: "codex", + runtimeAuthPlan, config: { models: { providers: { openai: { + api: "openai-responses", baseUrl: "https://example.test/v1", models: [{ id: "gpt-5.5", contextWindow: 350_000 }], }, @@ -2407,6 +3196,55 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { provider: "openai", modelId: "gpt-5.5", }); + expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + model: "gpt-5.5", + agentHarnessId: "codex", + runtimeModel: expect.objectContaining({ + api: "openai-responses", + baseUrl: "https://example.test/v1", + }), + runtimeAuthPlan: expect.objectContaining({ modelRoute }), + }), + { nativeCompactionRequest: "after_context_engine" }, + ); + const compactArg = mockCallArg(contextEngineCompactMock) as { + runtimeContext?: Record; + }; + expectRecordFields(compactArg.runtimeContext, { + provider: "openai", + runtimeProvider: undefined, + model: "gpt-5.5", + }); + }); + + it("keeps queued custom OpenAI Responses compaction embedded without a harness binding", async () => { + resolveAgentHarnessPolicyMock.mockReturnValue({ + runtime: "openclaw", + runtimeSource: "implicit", + } as never); + + const result = await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + provider: "openai", + model: "gpt-5.5", + config: { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://example.test/v1", + models: [{ id: "gpt-5.5", contextWindow: 350_000 }], + }, + }, + }, + }, + }), + ); + + expect(result.ok).toBe(true); + expect(contextEngineCompactMock).toHaveBeenCalledTimes(1); expect(maybeCompactAgentHarnessSessionMock).not.toHaveBeenCalled(); const compactArg = mockCallArg(contextEngineCompactMock) as { runtimeContext?: Record; diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index 79646bbab845..f6c7524fc26f 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -38,8 +38,28 @@ import { isRecoverableNativeHarnessBindingFailure } from "../harness/compaction- import { maybeCompactAgentHarnessSession } from "../harness/compaction.js"; import { resolveAgentHarnessPolicy } from "../harness/policy.js"; import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js"; +import { + selectAgentHarness, + selectAgentHarnessForPreparedModelProviders, + type AgentHarnessPreparedModelProvider, +} from "../harness/selection.js"; +import { + resolveAgentHarnessPreparedAuthSupport, + resolveAgentHarnessPreparedRouteSupport, +} from "../harness/support.js"; +import { + ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles, +} from "../model-auth.js"; import { isOpenAIProvider } from "../openai-routing.js"; import { resolveAgentRunSessionTarget } from "../run-session-target.js"; +import { materializePreparedRuntimeModel } from "../runtime-plan/materialize-model.js"; +import { + agentRuntimeAuthPlanMatchesTarget, + prepareAgentRuntimeAuth, + type PreparedAgentRuntimeAuthAttempt, +} from "../runtime-plan/prepare-auth.js"; +import type { AgentRuntimeAuthPlan } from "../runtime-plan/types.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { SessionManager } from "../sessions/index.js"; import { DEFERRED_CONTEXT_ENGINE_COMPACTION_REASON } from "./compact-reasons.js"; @@ -47,6 +67,7 @@ import type { CompactEmbeddedAgentSessionParams } from "./compact.types.js"; import { asCompactionHookRunner, runPostCompactionSideEffects } from "./compaction-hooks.js"; import { buildEmbeddedCompactionRuntimeContext, + resolveCompactionHarnessRuntime, resolveEmbeddedCompactionTarget, } from "./compaction-runtime-context.js"; import { @@ -99,6 +120,27 @@ const MANUAL_COMPACTION_ACTIVE_RUN_REASON = "manual compaction unavailable while another embedded run is active"; const COMPACTION_ABORTED_REASON = "compaction aborted"; +function buildQueuedCompactionHarnessModelProvider(params: { + model?: ProviderRuntimeModel; + plan?: AgentRuntimeAuthPlan; + attempt?: PreparedAgentRuntimeAuthAttempt; +}): AgentHarnessPreparedModelProvider { + const route = params.plan?.modelRoute; + return { + api: route?.api ?? params.model?.api, + baseUrl: route?.baseUrl ?? params.model?.baseUrl, + ...resolveAgentHarnessPreparedRouteSupport(params.plan), + ...(params.plan + ? { + preparedAuth: resolveAgentHarnessPreparedAuthSupport({ + plan: params.plan, + source: params.attempt?.kind === "implicit" ? undefined : params.attempt?.kind, + }), + } + : {}), + }; +} + function createCompactionAbortedResult(): EmbeddedAgentCompactResult { return { ok: false, @@ -156,7 +198,7 @@ async function disposeContextEngine(contextEngine: ContextEngine): Promise try { await contextEngine.dispose?.(); } catch (err) { - log.warn("context engine dispose failed after deferred maintenance", { + log.warn("context engine dispose failed", { errorMessage: formatErrorMessage(err), }); } @@ -196,7 +238,6 @@ async function deferOwningContextEngineBudgetCompaction(params: { } if (!deferredScheduled || deferredScheduleFailure) { - await disposeContextEngine(params.contextEngine); log.warn( `[compaction] failed to schedule context-engine-owned budget compaction background maintenance ` + `(sessionKey=${params.compactParams.sessionKey ?? params.compactParams.sessionId}` + @@ -314,6 +355,33 @@ async function compactEmbeddedAgentSessionImpl( agentDir, workspaceDir: resolvedWorkspaceDir, }); + let disposeContextEngineOnExit = true; + try { + // Retain engine ownership until the queued path settles. Explicit cleanup + // or accepted background maintenance may release it from this call. + return await compactResolvedContextEngine( + params, + contextEngine, + agentDir, + resolvedWorkspaceDir, + () => { + disposeContextEngineOnExit = false; + }, + ); + } finally { + if (disposeContextEngineOnExit) { + await disposeContextEngine(contextEngine); + } + } +} + +async function compactResolvedContextEngine( + params: CompactEmbeddedAgentSessionParams, + contextEngine: ContextEngine, + agentDir: string, + resolvedWorkspaceDir: string, + releaseContextEngineOwnership: () => void, +): Promise { const runtimePolicySessionKey = params.sandboxSessionKey ?? params.sessionKey; const runtimePolicyAgentId = params.sandboxSessionKey && parseAgentSessionKey(params.sandboxSessionKey) @@ -328,9 +396,11 @@ async function compactEmbeddedAgentSessionImpl( defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); + const policyProvider = policyCompactionTarget.provider ?? DEFAULT_PROVIDER; + const policyModelId = policyCompactionTarget.model ?? DEFAULT_MODEL; const configuredHarnessPolicy = resolveAgentHarnessPolicy({ - provider: policyCompactionTarget.provider ?? DEFAULT_PROVIDER, - modelId: policyCompactionTarget.model ?? DEFAULT_MODEL, + provider: policyProvider, + modelId: policyModelId, config: params.config, agentId: runtimePolicyAgentId, sessionKey: runtimePolicySessionKey, @@ -349,7 +419,6 @@ async function compactEmbeddedAgentSessionImpl( params.modelSelectionLocked === true && (!lockedHarnessRuntime || lockedHarnessRuntime === "auto") ) { - await contextEngine.dispose?.(); return lockedCompactionRuntimeFailure(); } // A model lock makes the persisted harness authoritative. Config may select @@ -357,7 +426,13 @@ async function compactEmbeddedAgentSessionImpl( const selectedHarnessRuntime = params.modelSelectionLocked === true ? lockedHarnessRuntime - : (params.agentHarnessId ?? configuredHarnessRuntime); + : resolveCompactionHarnessRuntime({ + boundHarnessRuntime: params.agentHarnessId, + preparedRuntimePlan: params.runtimePlan, + configuredHarnessRuntime, + provider: policyProvider, + modelId: policyModelId, + }); const lockedNativeHarness = params.modelSelectionLocked === true && selectedHarnessRuntime !== "openclaw"; const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ @@ -379,32 +454,169 @@ async function compactEmbeddedAgentSessionImpl( nativeHarnessCompaction: resolvedCompactionTarget.nativeHarnessCompaction, selectedHarnessRuntime, }); - if (attemptNativeHarnessCompaction) { - await ensureSelectedAgentHarnessPlugin({ - config: params.config, + let effectiveRuntimeModel: ProviderRuntimeModel | undefined; + let preparedHarnessRuntime = selectedHarnessRuntime; + let preparedParams = params; + try { + if (attemptNativeHarnessCompaction) { + await ensureSelectedAgentHarnessPlugin({ + config: params.config, + provider: ceProvider, + modelId: ceModelId, + agentId: runtimePolicyAgentId, + sessionKey: runtimePolicySessionKey, + agentHarnessId: params.agentHarnessId, + agentHarnessRuntimeOverride: selectedHarnessRuntime, + workspaceDir: resolvedWorkspaceDir, + }); + } + const { + model: ceModel, + authStorage, + modelRegistry, + } = await resolveModelAsync(ceRuntimeProvider, ceModelId, agentDir, params.config); + const ceRuntimeModel = ceModel as ProviderRuntimeModel | undefined; + const providedRuntimeAuthPlan = params.runtimeAuthPlan ?? params.runtimePlan?.auth; + const runtimeAuthProfileStore = isOpenAIProvider(ceProvider) + ? ensureAuthProfileStore(agentDir, { + externalCliProviderIds: ["openai"], + allowKeychainPrompt: false, + }) + : ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }); + const reusableRuntimeAuthPlan = + providedRuntimeAuthPlan && + agentRuntimeAuthPlanMatchesTarget(providedRuntimeAuthPlan, { + provider: ceProvider, + modelId: ceModelId, + }) + ? providedRuntimeAuthPlan + : undefined; + const compactionHarnessRuntimeOverride = selectedHarnessRuntime ?? "openclaw"; + const selectHarnessForPreparedAttempts = ( + attempts: readonly PreparedAgentRuntimeAuthAttempt[], + ) => + selectAgentHarnessForPreparedModelProviders({ + provider: ceProvider, + modelId: ceModelId, + modelProviders: attempts.map((attempt) => + buildQueuedCompactionHarnessModelProvider({ + model: ceRuntimeModel, + plan: attempt.plan, + attempt, + }), + ), + config: params.config, + agentId: runtimePolicyAgentId, + sessionKey: runtimePolicySessionKey, + agentHarnessId: params.agentHarnessId, + agentHarnessRuntimeOverride: compactionHarnessRuntimeOverride, + }); + const initialHarness = reusableRuntimeAuthPlan + ? undefined + : selectAgentHarness({ + provider: ceProvider, + modelId: ceModelId, + modelProvider: buildQueuedCompactionHarnessModelProvider({ model: ceRuntimeModel }), + config: params.config, + agentId: runtimePolicyAgentId, + sessionKey: runtimePolicySessionKey, + agentHarnessId: params.agentHarnessId, + agentHarnessRuntimeOverride: compactionHarnessRuntimeOverride, + }); + const prepareRuntimeAuth = (harness: ReturnType) => + prepareAgentRuntimeAuth({ + provider: ceProvider, + modelId: ceModelId, + modelApi: ceRuntimeModel?.api, + modelBaseUrl: ceRuntimeModel?.baseUrl, + config: params.config, + env: process.env, + agentDir, + workspaceDir: resolvedWorkspaceDir, + authProfileStore: runtimeAuthProfileStore, + sessionAuthProfileId: resolvedCompactionTarget.authProfileId, + sessionAuthProfileSource: params.authProfileIdSource, + harnessId: harness.id, + harnessRuntime: harness.id, + harnessAuthBootstrap: harness.authBootstrap, + }); + let runtimeAuthPreparation = reusableRuntimeAuthPlan + ? { + plan: reusableRuntimeAuthPlan, + attempts: [{ kind: "implicit" as const, plan: reusableRuntimeAuthPlan }], + } + : prepareRuntimeAuth(initialHarness!); + let selectedPreparedHarness = selectHarnessForPreparedAttempts(runtimeAuthPreparation.attempts); + if (!reusableRuntimeAuthPlan && selectedPreparedHarness.id !== initialHarness?.id) { + runtimeAuthPreparation = prepareRuntimeAuth(selectedPreparedHarness); + const confirmedHarness = selectHarnessForPreparedAttempts(runtimeAuthPreparation.attempts); + if (confirmedHarness.id !== selectedPreparedHarness.id) { + throw new Error( + `Prepared queued compaction auth routes did not converge on one agent harness for ${ceProvider}/${ceModelId}.`, + ); + } + selectedPreparedHarness = confirmedHarness; + } + preparedHarnessRuntime = selectedPreparedHarness.id; + const runtimeAuthPlan = runtimeAuthPreparation.plan; + effectiveRuntimeModel = await materializePreparedRuntimeModel({ + plan: runtimeAuthPlan, provider: ceProvider, modelId: ceModelId, - agentId: runtimePolicyAgentId, - sessionKey: runtimePolicySessionKey, - agentHarnessRuntimeOverride: selectedHarnessRuntime, - workspaceDir: resolvedWorkspaceDir, + config: params.config, + model: ceRuntimeModel, + resolveModel: async ({ config, authProfileId, authProfileMode }) => { + const resolved = await resolveModelAsync(ceRuntimeProvider, ceModelId, agentDir, config, { + authStorage, + modelRegistry, + skipAgentDiscovery: true, + allowBundledStaticCatalogFallback: true, + preferBundledStaticCatalogTransport: true, + workspaceDir: resolvedWorkspaceDir, + authProfileId, + authProfileMode, + }); + return { ...resolved, model: resolved.model as ProviderRuntimeModel | undefined }; + }, }); + preparedParams = { + ...params, + provider: ceProvider, + model: ceModelId, + agentHarnessId: preparedHarnessRuntime, + ...(reusableRuntimeAuthPlan + ? { + authProfileId: runtimeAuthPlan.forwardedAuthProfileId, + authProfileIdSource: runtimeAuthPlan.forwardedAuthProfileSource, + runtimeAuthPlan, + } + : { + // Native compaction resolves this full attempt set itself. Legacy + // compaction must re-plan too; forwarding one generated plan would + // collapse cross-route and direct fallback before either dispatch. + authProfileId: resolvedCompactionTarget.authProfileId, + authProfileIdSource: resolvedCompactionTarget.authProfileId + ? params.authProfileIdSource + : undefined, + runtimeAuthPlan: undefined, + runtimePlan: undefined, + }), + }; + } catch (err) { + await disposeContextEngine(contextEngine); + releaseContextEngineOwnership(); + throw err; } - const { model: ceModel } = await resolveModelAsync( - ceRuntimeProvider, - ceModelId, - agentDir, - params.config, - ); - const ceRuntimeModel = ceModel as ProviderRuntimeModel | undefined; const resolvedContextTokenBudget = normalizeContextTokenBudget( resolveContextWindowInfo({ cfg: params.config, provider: ceContextConfigProvider, modelId: ceModelId, - modelContextTokens: readAgentModelContextTokens(ceModel), - modelContextWindow: ceRuntimeModel?.contextWindow, + modelContextTokens: readAgentModelContextTokens(effectiveRuntimeModel), + modelContextWindow: effectiveRuntimeModel?.contextWindow, defaultTokens: DEFAULT_CONTEXT_TOKENS, }).tokens, ) ?? DEFAULT_CONTEXT_TOKENS; @@ -414,9 +626,9 @@ async function compactEmbeddedAgentSessionImpl( resolvedContextTokenBudget, ); const contextEngineRuntimeContext = buildCompactionContextEngineRuntimeContext({ - params, + params: preparedParams, agentDir, - harnessRuntime: selectedHarnessRuntime, + harnessRuntime: preparedHarnessRuntime, contextTokenBudget, contextEnginePluginId: resolveContextEngineOwnerPluginId(contextEngine), }); @@ -433,19 +645,18 @@ async function compactEmbeddedAgentSessionImpl( const harnessResult = attemptNativeHarnessCompaction && (!contextEngineOwnsCompaction || lockedNativeHarness) ? await maybeCompactAgentHarnessSession({ - ...params, + ...preparedParams, + runtimeModel: effectiveRuntimeModel, contextEngine, contextTokenBudget, contextEngineRuntimeContext, }) : undefined; if (lockedNativeHarness) { - await contextEngine.dispose?.(); return harnessResult ?? lockedCompactionRuntimeFailure(selectedHarnessRuntime); } if (harnessResult) { if (!shouldFallbackAfterHarnessCompaction(harnessResult)) { - await contextEngine.dispose?.(); return harnessResult; } log.warn( @@ -454,22 +665,26 @@ async function compactEmbeddedAgentSessionImpl( } if ( shouldDeferOwningContextEngineBudgetCompaction({ - compactParams: params, + compactParams: preparedParams, contextEngine, }) ) { - return await deferOwningContextEngineBudgetCompaction({ - compactParams: params, + const deferredResult = await deferOwningContextEngineBudgetCompaction({ + compactParams: preparedParams, contextEngine, contextEngineRuntimeContext, contextEngineRuntimeSettings, }); + if (deferredResult.ok) { + releaseContextEngineOwnership(); + } + return deferredResult; } const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId); const globalLane = resolveGlobalLane(params.lane); const enqueueGlobal = params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts)); - return enqueueCommandInLane(sessionLane, () => + return await enqueueCommandInLane(sessionLane, () => enqueueGlobal(async () => { let checkpointSnapshot: CapturedCompactionCheckpointSnapshot | null | undefined; let checkpointSnapshotRetained = false; @@ -727,9 +942,10 @@ async function compactEmbeddedAgentSessionImpl( // the harness could still be compacting the same session. secondaryNativeHarnessCompaction = await maybeCompactAgentHarnessSession( { - ...params, + ...preparedParams, sessionId: postCompactionSessionId, sessionFile: postCompactionSessionFile, + runtimeModel: effectiveRuntimeModel, contextEngine, contextTokenBudget, contextEngineRuntimeContext, @@ -756,7 +972,7 @@ async function compactEmbeddedAgentSessionImpl( } } const secondaryNativeDetailsKey = - normalizeOptionalAgentRuntimeId(selectedHarnessRuntime) === "codex" + normalizeOptionalAgentRuntimeId(preparedHarnessRuntime) === "codex" ? "codexNativeCompaction" : "nativeHarnessCompaction"; return { @@ -787,7 +1003,6 @@ async function compactEmbeddedAgentSessionImpl( if (!checkpointSnapshotRetained) { await compactionCheckpointStore.cleanupSnapshot(checkpointSnapshot); } - await contextEngine.dispose?.(); } }), ); @@ -830,6 +1045,8 @@ function buildCompactionContextEngineRuntimeContext(params: { currentThreadTs: params.params.currentThreadTs, currentMessageId: params.params.currentMessageId, authProfileId: params.params.authProfileId, + authProfileIdSource: params.params.authProfileIdSource, + runtimeAuthPlan: params.params.runtimeAuthPlan, workspaceDir: params.params.workspaceDir, cwd: params.params.cwd, agentDir: params.agentDir, diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 7fbd317405ec..f19943c6bc89 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -93,17 +93,32 @@ import { pickFallbackThinkingLevel } from "../embedded-agent-helpers.js"; import { coerceToFailoverError, describeFailoverError } from "../failover-error.js"; import { resolveAgentHarnessPolicy } from "../harness/policy.js"; import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js"; +import { + selectAgentHarness, + selectAgentHarnessForPreparedModelProviders, + type AgentHarnessPreparedModelProvider, +} from "../harness/selection.js"; +import { + resolveAgentHarnessPreparedAuthSupport, + resolveAgentHarnessPreparedRouteSupport, +} from "../harness/support.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js"; import { applyAuthHeaderOverride, applyLocalNoAuthHeaderOverride, - getApiKeyForModel, + ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles, MissingProviderAuthError, resolveModelAuthMode, } from "../model-auth.js"; -import { isFallbackSummaryError, runWithModelFallback } from "../model-fallback.js"; +import { + isFallbackSummaryError, + resolveModelCandidateChain, + runWithModelFallback, +} from "../model-fallback.js"; import { supportsModelTools } from "../model-tool-support.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; +import { isOpenAIProvider } from "../openai-routing.js"; import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js"; import { resolveAgentPromptSurfaceForSessionKey } from "../prompt-surface.js"; import { applyPreparedRuntimeAuthToModel } from "../provider-request-config.js"; @@ -118,7 +133,17 @@ import { } from "../run-session-target.js"; import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js"; import { buildAgentRuntimePlan } from "../runtime-plan/build.js"; -import type { AgentRuntimePlan } from "../runtime-plan/types.js"; +import { materializePreparedRuntimeModel } from "../runtime-plan/materialize-model.js"; +import { + agentRuntimeAuthPlanMatchesTarget, + prepareAgentRuntimeAuth, + type PreparedAgentRuntimeAuthAttempt, +} from "../runtime-plan/prepare-auth.js"; +import { + resolvePreparedRuntimeAuthAttempts, + resolvePreparedRuntimeModelAuth, +} from "../runtime-plan/resolve-auth.js"; +import type { AgentRuntimeAuthPlan, AgentRuntimePlan } from "../runtime-plan/types.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import type { AgentMessage } from "../runtime/index.js"; import { resolveSandboxContext } from "../sandbox.js"; @@ -157,7 +182,10 @@ import { runBeforeCompactionHooks, runPostCompactionSideEffects, } from "./compaction-hooks.js"; -import { resolveEmbeddedCompactionTarget } from "./compaction-runtime-context.js"; +import { + resolveCompactionHarnessRuntime, + resolveEmbeddedCompactionTarget, +} from "./compaction-runtime-context.js"; import { compactWithSafetyTimeout, resolveCompactionTimeoutMs, @@ -332,6 +360,27 @@ function resolveCompactionProviderStream(params: { }); } +function buildCompactionHarnessModelProvider(params: { + model: ProviderRuntimeModel; + plan?: AgentRuntimeAuthPlan; + attempt?: PreparedAgentRuntimeAuthAttempt; +}): AgentHarnessPreparedModelProvider { + const route = params.plan?.modelRoute; + return { + api: route?.api ?? params.model.api, + baseUrl: route?.baseUrl ?? params.model.baseUrl, + ...resolveAgentHarnessPreparedRouteSupport(params.plan), + ...(params.plan + ? { + preparedAuth: resolveAgentHarnessPreparedAuthSupport({ + plan: params.plan, + source: params.attempt?.kind === "implicit" ? undefined : params.attempt?.kind, + }), + } + : {}), + }; +} + function normalizeObservedTokenCount(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) @@ -528,6 +577,12 @@ export async function compactEmbeddedAgentSessionDirect( const primaryModel = resolvedCompactionTarget.model ?? DEFAULT_MODEL; const requestedPrimaryProvider = params.provider?.trim() || DEFAULT_PROVIDER; const fallbacksOverride = resolveCompactionFallbacksOverride(params); + const resolvedPrimaryCandidate = resolveModelCandidateChain({ + cfg: params.config, + provider: primaryProvider, + model: primaryModel, + fallbacksOverride, + })[0]; const fallbackAgentId = resolveSessionAgentIds({ sessionKey: params.sandboxSessionKey ?? params.sessionKey, config: params.config, @@ -560,8 +615,13 @@ export async function compactEmbeddedAgentSessionDirect( classifyResult: ({ result, provider, model }) => classifyCompactionFallbackResult(result, provider, model), run: async (provider, model) => { + const isPrimaryCandidate = + provider === resolvedPrimaryCandidate?.provider && + model === resolvedPrimaryCandidate.model; const preservesPrimaryAuth = - provider === primaryProvider || provider === requestedPrimaryProvider; + isPrimaryCandidate || + provider === primaryProvider || + provider === requestedPrimaryProvider; const authProfileId = preservesPrimaryAuth ? params.authProfileId : undefined; const candidateThinkLevel = resolveCandidateThinkingLevel({ cfg: params.config, @@ -577,7 +637,12 @@ export async function compactEmbeddedAgentSessionDirect( provider, model, authProfileId, + authProfileIdSource: preservesPrimaryAuth ? params.authProfileIdSource : undefined, thinkLevel: candidateThinkLevel, + // The primary attempt retains its already prepared atomic plan. An + // actual fallback may change route/auth class and must rebuild it. + runtimeAuthPlan: isPrimaryCandidate ? params.runtimeAuthPlan : undefined, + runtimePlan: isPrimaryCandidate ? params.runtimePlan : undefined, }); }, }); @@ -633,9 +698,11 @@ async function compactEmbeddedAgentSessionDirectOnce( defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); + const policyProvider = policyCompactionTarget.provider ?? DEFAULT_PROVIDER; + const policyModelId = policyCompactionTarget.model ?? DEFAULT_MODEL; const configuredHarnessPolicy = resolveAgentHarnessPolicy({ - provider: policyCompactionTarget.provider ?? DEFAULT_PROVIDER, - modelId: policyCompactionTarget.model ?? DEFAULT_MODEL, + provider: policyProvider, + modelId: policyModelId, config: params.config, agentId: runtimePolicyAgentId, sessionKey: runtimePolicySessionKey, @@ -646,7 +713,15 @@ async function compactEmbeddedAgentSessionDirectOnce( !isDefaultAgentRuntimeId(configuredHarnessPolicy.runtime) ? configuredHarnessPolicy.runtime : undefined; - const selectedHarnessRuntime = params.agentHarnessId ?? configuredHarnessRuntime; + const boundHarnessRuntime = normalizeOptionalAgentRuntimeId(params.agentHarnessId); + const selectedHarnessRuntime = resolveCompactionHarnessRuntime({ + boundHarnessRuntime, + preparedRuntimePlan: params.runtimePlan, + configuredHarnessRuntime, + provider: policyProvider, + modelId: policyModelId, + }); + const selectedHarnessRuntimeOverride = boundHarnessRuntime ? undefined : selectedHarnessRuntime; const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ config: params.config, provider: params.provider, @@ -664,6 +739,7 @@ async function compactEmbeddedAgentSessionDirectOnce( const contextConfigProvider = resolvedCompactionTarget.contextProvider ?? provider; const modelId = resolvedCompactionTarget.model ?? DEFAULT_MODEL; const authProfileId = resolvedCompactionTarget.authProfileId; + const providedRuntimeAuthPlan = params.runtimeAuthPlan ?? params.runtimePlan?.auth; if (runtimeProvider !== provider || selectedHarnessRuntime) { await ensureSelectedAgentHarnessPlugin({ config: params.config, @@ -671,7 +747,8 @@ async function compactEmbeddedAgentSessionDirectOnce( modelId, agentId: runtimePolicyAgentId, sessionKey: runtimePolicySessionKey, - agentHarnessRuntimeOverride: selectedHarnessRuntime, + agentHarnessId: boundHarnessRuntime, + agentHarnessRuntimeOverride: selectedHarnessRuntimeOverride, workspaceDir: resolvedWorkspace, }); } @@ -716,19 +793,149 @@ async function compactEmbeddedAgentSessionDirectOnce( const reason = error ?? `Unknown model: ${runtimeProvider}/${modelId}`; return fail(reason); } - let runtimeModel = model; - let apiKeyInfo: Awaited> | null; - let hasRuntimeAuthExchange = false; - try { - apiKeyInfo = await getApiKeyForModel({ - model: runtimeModel, - cfg: params.config, - profileId: authProfileId, + const runtimeAuthProfileStore = isOpenAIProvider(provider) + ? ensureAuthProfileStore(agentDir, { + externalCliProviderIds: ["openai"], + allowKeychainPrompt: false, + }) + : ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }); + const reusableRuntimeAuthPlan = + providedRuntimeAuthPlan && + agentRuntimeAuthPlanMatchesTarget(providedRuntimeAuthPlan, { provider, modelId }) + ? providedRuntimeAuthPlan + : undefined; + const compactionHarnessRuntimeOverride = + selectedHarnessRuntimeOverride ?? (selectedHarnessRuntime ? undefined : "openclaw"); + const selectHarnessForPreparedAttempts = (attempts: readonly PreparedAgentRuntimeAuthAttempt[]) => + selectAgentHarnessForPreparedModelProviders({ + provider, + modelId, + modelProviders: attempts.map((authAttempt) => + buildCompactionHarnessModelProvider({ + model, + plan: authAttempt.plan, + attempt: authAttempt, + }), + ), + config: params.config, + agentId: runtimePolicyAgentId, + sessionKey: runtimePolicySessionKey, + agentHarnessId: boundHarnessRuntime, + agentHarnessRuntimeOverride: compactionHarnessRuntimeOverride, + }); + const initialHarness = reusableRuntimeAuthPlan + ? undefined + : selectAgentHarness({ + provider, + modelId, + modelProvider: buildCompactionHarnessModelProvider({ model }), + config: params.config, + agentId: runtimePolicyAgentId, + sessionKey: runtimePolicySessionKey, + agentHarnessId: boundHarnessRuntime, + agentHarnessRuntimeOverride: compactionHarnessRuntimeOverride, + }); + const prepareRuntimeAuth = (harness: ReturnType) => + prepareAgentRuntimeAuth({ + provider, + modelId, + modelApi: model.api, + modelBaseUrl: model.baseUrl, + config: params.config, + env: process.env, agentDir, workspaceDir: resolvedWorkspace, - secretSentinels: true, + authProfileStore: runtimeAuthProfileStore, + sessionAuthProfileId: authProfileId, + sessionAuthProfileSource: params.authProfileIdSource, + harnessId: harness.id, + harnessRuntime: harness.id, + harnessAuthBootstrap: harness.authBootstrap, }); - + let runtimeAuthPreparation = reusableRuntimeAuthPlan + ? { + plan: reusableRuntimeAuthPlan, + attempts: [{ kind: "implicit" as const, plan: reusableRuntimeAuthPlan }], + } + : prepareRuntimeAuth(initialHarness!); + let selectedPreparedHarness = selectHarnessForPreparedAttempts(runtimeAuthPreparation.attempts); + if (!reusableRuntimeAuthPlan && selectedPreparedHarness.id !== initialHarness?.id) { + runtimeAuthPreparation = prepareRuntimeAuth(selectedPreparedHarness); + const confirmedHarness = selectHarnessForPreparedAttempts(runtimeAuthPreparation.attempts); + if (confirmedHarness.id !== selectedPreparedHarness.id) { + throw new Error( + `Prepared compaction auth routes did not converge on one agent harness for ${provider}/${modelId}.`, + ); + } + selectedPreparedHarness = confirmedHarness; + } + const preparedHarnessRuntime = selectedPreparedHarness.id; + const resolvePreparedModel = ({ + config, + authProfileId: profileId, + authProfileMode, + }: Parameters< + Parameters>[0]["resolveModel"] + >[0]) => + resolveModelAsync(runtimeProvider, modelId, agentDir, config, { + authStorage, + modelRegistry, + skipAgentDiscovery: true, + allowBundledStaticCatalogFallback: true, + preferBundledStaticCatalogTransport: true, + workspaceDir: resolvedWorkspace, + authProfileId: profileId, + authProfileMode, + }); + const materializeAuthAttemptModel = async (materializeParams: { + plan: AgentRuntimeAuthPlan; + model: ProviderRuntimeModel; + forceResolve?: boolean; + }): Promise => + (await materializePreparedRuntimeModel({ + plan: materializeParams.plan, + provider, + modelId, + config: params.config, + model: materializeParams.model, + forceResolve: materializeParams.forceResolve, + resolveModel: resolvePreparedModel, + })) ?? materializeParams.model; + const resolveRuntimeAuthAttempt = () => + resolvePreparedRuntimeAuthAttempts({ + attempts: runtimeAuthPreparation.attempts, + store: runtimeAuthProfileStore, + modelId, + model, + materializeModel: materializeAuthAttemptModel, + resolveAuth: async ({ attempt: preparedAttempt, model: attemptModel }) => + await resolvePreparedRuntimeModelAuth({ + plan: preparedAttempt.plan, + model: attemptModel, + cfg: params.config, + store: runtimeAuthProfileStore, + agentDir, + workspaceDir: resolvedWorkspace, + ...(preparedAttempt.allowAuthProfileFallback !== undefined + ? { allowAuthProfileFallback: preparedAttempt.allowAuthProfileFallback } + : {}), + secretSentinels: true, + }), + errorMessage: `Prepared compaction auth attempts could not be resolved for ${provider}/${modelId}.`, + }); + let resolvedAuthAttempt: Awaited>; + try { + resolvedAuthAttempt = await resolveRuntimeAuthAttempt(); + } catch (err) { + return fail(formatErrorMessage(err), err); + } + let runtimeModel = resolvedAuthAttempt.model; + const apiKeyInfo = resolvedAuthAttempt.auth; + const resolvedRuntimeAuthPlan = resolvedAuthAttempt.plan; + let hasRuntimeAuthExchange = false; + try { if (!apiKeyInfo.apiKey) { if (apiKeyInfo.mode !== "aws-sdk") { throw new MissingProviderAuthError(runtimeModel.provider, apiKeyInfo); @@ -905,23 +1112,30 @@ async function compactEmbeddedAgentSessionDirectOnce( hasRuntimeAuthExchange ? null : apiKeyInfo, params.config, ); - const runtimePlan = - params.runtimePlan ?? + const reuseFullRuntimePlan = params.runtimePlan?.auth === resolvedRuntimeAuthPlan; + const preparedRuntimePlan = + (reuseFullRuntimePlan ? params.runtimePlan : undefined) ?? buildAgentRuntimePlan({ provider, modelId, model: effectiveModel, modelApi: effectiveModel.api, - harnessId: params.agentHarnessId, - harnessRuntime: selectedHarnessRuntime, - authProfileProvider: authProfileId?.split(":", 1)[0], - sessionAuthProfileId: authProfileId, + harnessId: preparedHarnessRuntime, + harnessRuntime: preparedHarnessRuntime, + authProfileMode: resolvedRuntimeAuthPlan.selectedAuthMode, + sessionAuthProfileId: resolvedRuntimeAuthPlan.forwardedAuthProfileId, + sessionAuthProfileSource: resolvedRuntimeAuthPlan.forwardedAuthProfileSource, + sessionAuthProfileCandidateIds: resolvedRuntimeAuthPlan.forwardedAuthProfileCandidateIds, + modelRoute: resolvedRuntimeAuthPlan.modelRoute, config: params.config, workspaceDir: effectiveWorkspace, agentDir, agentId: effectiveSkillAgentId, thinkingLevel: mapThinkingLevelForProvider(thinkLevel), }); + const runtimePlan = reuseFullRuntimePlan + ? preparedRuntimePlan + : { ...preparedRuntimePlan, auth: resolvedRuntimeAuthPlan }; const runAbortController = new AbortController(); const spawnWorkspaceDir = @@ -953,9 +1167,9 @@ async function compactEmbeddedAgentSessionDirectOnce( senderUsername: params.senderUsername, senderE164: params.senderE164, senderIsOwner: params.senderIsOwner, - modelProvider: model.provider, + modelProvider: effectiveModel.provider, modelId, - modelApi: model.api, + modelApi: effectiveModel.api, modelContextWindowTokens: contextTokenBudget, workspaceDir: effectiveWorkspace, cwd: effectiveCwd, @@ -963,7 +1177,7 @@ async function compactEmbeddedAgentSessionDirectOnce( skillsSnapshot: skillsSnapshotForRun, sandboxToolPolicy: sandbox?.tools, }); - const toolsEnabled = supportsModelTools(runtimeModel); + const toolsEnabled = supportsModelTools(effectiveModel); const toolsRaw = toolsEnabled ? createOpenClawCodingTools({ exec: { @@ -1000,24 +1214,24 @@ async function compactEmbeddedAgentSessionDirectOnce( config: params.config, abortSignal: runAbortController.signal, sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, - modelProvider: model.provider, + modelProvider: effectiveModel.provider, modelId, modelHasVision: effectiveModel.input?.includes("image") ?? false, modelCompat: extractModelCompat(effectiveModel), - modelApi: model.api, + modelApi: effectiveModel.api, modelContextWindowTokens: contextTokenBudget, skillsSnapshot: skillsSnapshotForRun, skillUsagePaths, conversationCapabilityProfile: runtimeCapabilityProfile, - modelAuthMode: resolveModelAuthMode(model.provider, params.config, undefined, { + modelAuthMode: resolveModelAuthMode(effectiveModel.provider, params.config, undefined, { workspaceDir: effectiveWorkspace, }), }) : []; const runtimePlanModelContext = { workspaceDir: effectiveWorkspace, - modelApi: model.api, - model, + modelApi: effectiveModel.api, + model: effectiveModel, }; const normalizableToolProjection = filterProviderNormalizableTools( toolsEnabled ? toolsRaw : [], @@ -1173,8 +1387,8 @@ async function compactEmbeddedAgentSessionDirectOnce( workspaceDir: effectiveWorkspace, env: process.env, modelId, - modelApi: model.api, - model, + modelApi: effectiveModel.api, + model: effectiveModel, }); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); @@ -1290,9 +1504,9 @@ async function compactEmbeddedAgentSessionDirectOnce( contextWindowTokens: contextTokenBudget, allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults, missingToolResultText: - model.api === "openai-responses" || - model.api === "azure-openai-responses" || - model.api === "openai-chatgpt-responses" + effectiveModel.api === "openai-responses" || + effectiveModel.api === "azure-openai-responses" || + effectiveModel.api === "openai-chatgpt-responses" ? "aborted" : undefined, allowedToolNames, @@ -1321,7 +1535,7 @@ async function compactEmbeddedAgentSessionDirectOnce( sessionManager, provider, modelId, - model, + model: effectiveModel, }); const resourceLoader = createEmbeddedAgentResourceLoader({ cwd: effectiveCwd, @@ -1449,14 +1663,14 @@ async function compactEmbeddedAgentSessionDirectOnce( const prior = await sanitizeSessionHistory({ messages: session.messages, - modelApi: model.api, + modelApi: effectiveModel.api, modelId, provider, allowedToolNames, config: params.config, workspaceDir: effectiveWorkspace, env: process.env, - model, + model: effectiveModel, sessionManager, sessionId: params.sessionId, policy: transcriptPolicy, @@ -1464,13 +1678,13 @@ async function compactEmbeddedAgentSessionDirectOnce( }); const validated = await validateReplayTurns({ messages: prior, - modelApi: model.api, + modelApi: effectiveModel.api, modelId, provider, config: params.config, workspaceDir: effectiveWorkspace, env: process.env, - model, + model: effectiveModel, sessionId: params.sessionId, policy: transcriptPolicy, }); @@ -1491,9 +1705,9 @@ async function compactEmbeddedAgentSessionDirectOnce( const limited = transcriptPolicy.repairToolUseResultPairing ? sanitizeToolUseResultPairing(truncated, { erroredAssistantResultPolicy: "drop", - ...(model.api === "openai-responses" || - model.api === "azure-openai-responses" || - model.api === "openai-chatgpt-responses" + ...(effectiveModel.api === "openai-responses" || + effectiveModel.api === "azure-openai-responses" || + effectiveModel.api === "openai-chatgpt-responses" ? { missingToolResultText: "aborted" } : {}), }) diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index 0f8cd18b3eb8..0ee92cefb9db 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -11,7 +11,7 @@ import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js import type { SkillSnapshot } from "../../skills/types.js"; import type { ExecElevatedDefaults, ExecToolDefaults } from "../bash-tools.exec-types.js"; import type { AgentRunSessionTarget } from "../run-session-target.js"; -import type { AgentRuntimePlan } from "../runtime-plan/types.js"; +import type { AgentRuntimeAuthPlan, AgentRuntimePlan } from "../runtime-plan/types.js"; export type CompactEmbeddedAgentSessionParams = { sessionId: string; @@ -38,6 +38,7 @@ export type CompactEmbeddedAgentSessionParams = { senderUsername?: string; senderE164?: string; authProfileId?: string; + authProfileIdSource?: "auto" | "user"; /** Host-resolved provider credential for native harness compaction. */ resolvedApiKey?: string; /** Group id for channel-level tool policy resolution. */ @@ -76,6 +77,8 @@ export type CompactEmbeddedAgentSessionParams = { modelSelectionLocked?: boolean; /** OpenClaw-owned runtime policy prepared for this compaction path. */ runtimePlan?: AgentRuntimePlan; + /** Host-prepared route and credential selection for native harness compaction. */ + runtimeAuthPlan?: AgentRuntimeAuthPlan; thinkLevel?: ThinkLevel; reasoningLevel?: ReasoningLevel; execOverrides?: Pick; diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts index e0212928455e..473e3b2abe8c 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts @@ -5,6 +5,7 @@ import { addSession, resetProcessRegistryForTests } from "../bash-process-regist import { createProcessSessionFixture } from "../bash-process-registry.test-helpers.js"; import { buildEmbeddedCompactionRuntimeContext, + resolveCompactionHarnessRuntime, resolveEmbeddedCompactionTarget, } from "./compaction-runtime-context.js"; @@ -313,6 +314,86 @@ describe("buildEmbeddedCompactionRuntimeContext", () => { expect(result.runtimeProvider).toBeUndefined(); }); + it("carries only a target-matching prepared auth plan into compaction context", () => { + const runtimeAuthPlan = { + providerForAuth: "openai", + modelId: "gpt-5.5", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:work", + forwardedAuthProfileSource: "user", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + } as const; + + const matching = buildEmbeddedCompactionRuntimeContext({ + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + provider: "openai", + modelId: "gpt-5.5", + authProfileId: "openai:work", + authProfileIdSource: "user", + runtimeAuthPlan, + }); + const mismatched = buildEmbeddedCompactionRuntimeContext({ + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + provider: "openai", + modelId: "gpt-5.4", + runtimeAuthPlan, + }); + + expect(matching.runtimeAuthPlan).toBe(runtimeAuthPlan); + expect(matching.authProfileIdSource).toBe("user"); + expect(mismatched.runtimeAuthPlan).toBeUndefined(); + }); + + it("resolves compaction harness ownership from bound, prepared, then configured facts", () => { + const preparedRuntimePlan = { + resolvedRef: { + provider: "openai", + modelId: "gpt-5.5", + harnessId: "codex", + }, + auth: { + providerForAuth: "openai", + modelId: "gpt-5.5", + authProfileProviderForAuth: "openai", + }, + } as never; + + expect( + resolveCompactionHarnessRuntime({ + boundHarnessRuntime: "copilot", + preparedRuntimePlan, + configuredHarnessRuntime: "custom", + provider: "openai", + modelId: "gpt-5.5", + }), + ).toBe("copilot"); + expect( + resolveCompactionHarnessRuntime({ + preparedRuntimePlan, + configuredHarnessRuntime: "custom", + provider: "openai", + modelId: "gpt-5.5", + }), + ).toBe("codex"); + expect( + resolveCompactionHarnessRuntime({ + preparedRuntimePlan, + configuredHarnessRuntime: "custom", + provider: "openai", + modelId: "gpt-5.4", + }), + ).toBe("custom"); + }); + it("preserves direct OpenAI compaction for the OpenClaw runtime", () => { const result = resolveEmbeddedCompactionTarget({ config: { @@ -335,12 +416,17 @@ describe("buildEmbeddedCompactionRuntimeContext", () => { expect(result.authProfileId).toBeUndefined(); }); - it("preserves custom OpenAI-compatible compaction providers", () => { + it.each([ + { selection: "implicit OpenClaw", harnessRuntime: undefined, nativeCompaction: undefined }, + { selection: "bound OpenClaw", harnessRuntime: "openclaw", nativeCompaction: undefined }, + { selection: "bound Codex", harnessRuntime: "codex", nativeCompaction: true }, + ])("keeps $selection ownership for custom OpenAI Responses compaction", (fixture) => { const result = resolveEmbeddedCompactionTarget({ config: { models: { providers: { openai: { + api: "openai-responses", baseUrl: "https://example.test/v1", models: [{ id: "gpt-5.5" }], }, @@ -349,14 +435,14 @@ describe("buildEmbeddedCompactionRuntimeContext", () => { } as unknown as OpenClawConfig, provider: "openai", modelId: "gpt-5.5", - harnessRuntime: "codex", + harnessRuntime: fixture.harnessRuntime, defaultProvider: "openai", defaultModel: "gpt-5.5", }); expect(result.provider).toBe("openai"); expect(result.runtimeProvider).toBeUndefined(); expect(result.contextProvider).toBeUndefined(); - expect(result.nativeHarnessCompaction).toBeUndefined(); + expect(result.nativeHarnessCompaction).toBe(fixture.nativeCompaction); expect(result.model).toBe("gpt-5.5"); expect(result.authProfileId).toBeUndefined(); }); diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.ts index 83186ab5cb72..4fedd1b10f7f 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.ts @@ -6,7 +6,7 @@ import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SkillSnapshot } from "../../skills/types.js"; -import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; +import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { listActiveProcessSessionReferences, type ActiveProcessSessionReference, @@ -18,10 +18,9 @@ import { inferUniqueProviderFromConfiguredModels, resolveModelRefFromString, } from "../model-selection-shared.js"; -import { - openAIProviderUsesCodexRuntimeByDefault, - resolveSelectedOpenAIRuntimeProvider, -} from "../openai-routing.js"; +import { resolveSelectedOpenAIRuntimeProvider } from "../openai-routing.js"; +import { agentRuntimeAuthPlanMatchesTarget } from "../runtime-plan/prepare-auth.js"; +import type { AgentRuntimeAuthPlan, AgentRuntimePlan } from "../runtime-plan/types.js"; type EmbeddedCompactionRuntimeContext = { sessionKey?: string; @@ -34,6 +33,8 @@ type EmbeddedCompactionRuntimeContext = { currentThreadTs?: string; currentMessageId?: string | number; authProfileId?: string; + authProfileIdSource?: "auto" | "user"; + runtimeAuthPlan?: AgentRuntimeAuthPlan; agentHarnessId?: string; modelSelectionLocked?: boolean; workspaceDir: string; @@ -91,13 +92,14 @@ export function resolveEmbeddedCompactionTarget(params: { if (!targetProvider) { return {}; } - const useCodexHarnessRuntime = shouldUseCodexRuntimeProviderForCompaction({ - config: params.config, - provider: targetProvider, - harnessRuntime: params.harnessRuntime, - modelSelectionLocked: params.modelSelectionLocked, - }); - const harnessRuntime = useCodexHarnessRuntime ? params.harnessRuntime : "openclaw"; + const selectedHarnessRuntime = normalizeOptionalAgentRuntimeId(params.harnessRuntime); + // Compaction follows the concrete session or prepared-plan owner. Provider + // defaults choose new runs; they cannot move an existing transcript. + const useNativeHarnessRuntime = + selectedHarnessRuntime !== undefined && + selectedHarnessRuntime !== "openclaw" && + !isDefaultAgentRuntimeId(selectedHarnessRuntime); + const harnessRuntime = useNativeHarnessRuntime ? selectedHarnessRuntime : "openclaw"; const runtimeProvider = resolveSelectedOpenAIRuntimeProvider({ provider: targetProvider, harnessRuntime: harnessRuntime ?? undefined, @@ -107,8 +109,8 @@ export function resolveEmbeddedCompactionTarget(params: { const routedRuntimeProvider = runtimeProvider === targetProvider ? undefined : runtimeProvider; return { runtimeProvider: routedRuntimeProvider, - contextProvider: useCodexHarnessRuntime ? routedRuntimeProvider : undefined, - ...(useCodexHarnessRuntime ? { nativeHarnessCompaction: true } : {}), + contextProvider: useNativeHarnessRuntime ? routedRuntimeProvider : undefined, + ...(useNativeHarnessRuntime ? { nativeHarnessCompaction: true } : {}), }; }; if (!override) { @@ -233,24 +235,34 @@ function hasBareConfiguredModelForProvider(params: { }); } -function shouldUseCodexRuntimeProviderForCompaction(params: { - config?: OpenClawConfig; +/** Resolves the concrete harness already bound to this exact compaction target. */ +export function resolveCompactionHarnessRuntime(params: { + boundHarnessRuntime?: string | null; + preparedRuntimePlan?: AgentRuntimePlan; + configuredHarnessRuntime?: string | null; provider: string; - harnessRuntime?: string | null; - modelSelectionLocked?: boolean; -}): boolean { - if (normalizeOptionalAgentRuntimeId(params.harnessRuntime) !== "codex") { - return false; + modelId: string; +}): string | undefined { + const boundHarnessRuntime = normalizeOptionalAgentRuntimeId(params.boundHarnessRuntime); + if (boundHarnessRuntime) { + return boundHarnessRuntime; } - // A persisted lock makes the selected native harness authoritative. Local - // provider config must not reroute compaction away from that owner. - if (params.modelSelectionLocked === true) { - return true; + const preparedRuntimePlan = params.preparedRuntimePlan; + if ( + preparedRuntimePlan && + agentRuntimeAuthPlanMatchesTarget(preparedRuntimePlan.auth, { + provider: params.provider, + modelId: params.modelId, + }) + ) { + const preparedHarnessRuntime = normalizeOptionalAgentRuntimeId( + preparedRuntimePlan.resolvedRef.harnessId, + ); + if (preparedHarnessRuntime) { + return preparedHarnessRuntime; + } } - if (!openAIProviderUsesCodexRuntimeByDefault(params)) { - return false; - } - return true; + return normalizeOptionalAgentRuntimeId(params.configuredHarnessRuntime); } export function buildEmbeddedCompactionRuntimeContext(params: { @@ -264,6 +276,8 @@ export function buildEmbeddedCompactionRuntimeContext(params: { currentThreadTs?: string | null; currentMessageId?: string | number | null; authProfileId?: string | null; + authProfileIdSource?: "auto" | "user"; + runtimeAuthPlan?: AgentRuntimeAuthPlan; workspaceDir: string; cwd?: string | null; agentDir: string; @@ -293,6 +307,16 @@ export function buildEmbeddedCompactionRuntimeContext(params: { modelSelectionLocked: params.modelSelectionLocked, }); const agentHarnessId = params.harnessRuntime?.trim() || undefined; + const runtimeAuthPlan = + params.runtimeAuthPlan && + resolved.provider && + resolved.model && + agentRuntimeAuthPlanMatchesTarget(params.runtimeAuthPlan, { + provider: resolved.provider, + modelId: resolved.model, + }) + ? params.runtimeAuthPlan + : undefined; const processScopeKey = params.sessionKey?.trim(); const activeProcessSessions = params.activeProcessSessions ?? @@ -310,6 +334,8 @@ export function buildEmbeddedCompactionRuntimeContext(params: { currentThreadTs: params.currentThreadTs ?? undefined, currentMessageId: params.currentMessageId ?? undefined, authProfileId: resolved.authProfileId, + authProfileIdSource: params.authProfileIdSource, + runtimeAuthPlan, agentHarnessId, modelSelectionLocked: params.modelSelectionLocked, workspaceDir: params.workspaceDir, diff --git a/src/agents/embedded-agent-runner/extra-params.ts b/src/agents/embedded-agent-runner/extra-params.ts index 64e8dbce5f78..840310a8edb9 100644 --- a/src/agents/embedded-agent-runner/extra-params.ts +++ b/src/agents/embedded-agent-runner/extra-params.ts @@ -33,8 +33,8 @@ import { wrapProviderStreamFn as wrapProviderStreamFnRuntime, } from "../../plugins/provider-hook-runtime.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; +import { resolveModelExtraParamSources } from "../model-extra-params.js"; import { canonicalizeMaxTokensParam, resolveMaxTokensParam } from "../model-max-tokens-params.js"; -import { legacyModelKey, modelKey } from "../model-selection-normalize.js"; import { detectOpenAICompletionsCompat } from "../openai-completions-compat.js"; import { supportsGptParallelToolCallsPayload } from "../provider-api-families.js"; import { resolveProviderRequestPolicyConfig } from "../provider-request-config.js"; @@ -92,17 +92,13 @@ export function resolveExtraParams(params: { modelId: string; agentId?: string; }): Record | undefined { - const defaultParams = params.cfg?.agents?.defaults?.params ?? undefined; - const canonicalKey = modelKey(params.provider, params.modelId); - const legacyKey = legacyModelKey(params.provider, params.modelId); - const configuredModels = params.cfg?.agents?.defaults?.models; - const modelConfig = - configuredModels?.[canonicalKey] ?? (legacyKey ? configuredModels?.[legacyKey] : undefined); - const globalParams = modelConfig?.params ? { ...modelConfig.params } : undefined; - const agentParams = - params.agentId && params.cfg?.agents?.list - ? params.cfg.agents.list.find((agent) => agent.id === params.agentId)?.params - : undefined; + const { defaultParams, modelParams, agentParams } = resolveModelExtraParamSources({ + config: params.cfg, + provider: params.provider, + modelId: params.modelId, + agentId: params.agentId, + }); + const globalParams = modelParams ? { ...modelParams } : undefined; const merged = Object.assign({}, defaultParams, globalParams, agentParams); const resolvedParallelToolCalls = resolveAliasedParamValue( diff --git a/src/agents/embedded-agent-runner/model-discovery-cache.ts b/src/agents/embedded-agent-runner/model-discovery-cache.ts index 516bd7318fd1..dbbe8ad2f495 100644 --- a/src/agents/embedded-agent-runner/model-discovery-cache.ts +++ b/src/agents/embedded-agent-runner/model-discovery-cache.ts @@ -3,6 +3,7 @@ */ import { statSync } from "node:fs"; import path from "node:path"; +import { resolveRuntimeConfigCacheKey } from "../../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { @@ -89,6 +90,9 @@ function discoveryFingerprint( localAuth: authFingerprint(params.agentDir), inheritedAuth: inheritedAuthDir ? authFingerprint(inheritedAuthDir) : undefined, modelsJson: fileFingerprint(path.join(params.agentDir, "models.json")), + // Discovery normalization can project provider/model route facts from config. + // Tie the registry snapshot to that same runtime config generation. + runtimeConfig: params.config ? resolveRuntimeConfigCacheKey(params.config) : undefined, pluginMetadata: pluginMetadataFingerprint(params.pluginMetadataSnapshot), pluginModelCatalogs: pluginModelCatalogFingerprint(params.agentDir), }); @@ -167,7 +171,12 @@ export function discoverCachedAgentStores( const pluginMetadataSnapshot = resolvePluginMetadataSnapshotForDiscovery(options); const cacheKey = JSON.stringify({ agentDir, inheritedAuthDir }); - const fingerprint = discoveryFingerprint({ agentDir, inheritedAuthDir, pluginMetadataSnapshot }); + const fingerprint = discoveryFingerprint({ + agentDir, + config: options.config, + inheritedAuthDir, + pluginMetadataSnapshot, + }); const cached = DISCOVERY_STORE_CACHE.get(cacheKey); if (cached?.fingerprint === fingerprint) { cached.lastUsedAt = Date.now(); diff --git a/src/agents/embedded-agent-runner/model.test.ts b/src/agents/embedded-agent-runner/model.test.ts index 0fbba9045239..8c304ae26319 100644 --- a/src/agents/embedded-agent-runner/model.test.ts +++ b/src/agents/embedded-agent-runner/model.test.ts @@ -309,6 +309,49 @@ describe("resolveModel", () => { expect(discoverModels).toHaveBeenCalledTimes(1); }); + it("invalidates agent discovery stores when provider route config changes", async () => { + mockDiscoveredModel(discoverModels, { + provider: "openai", + modelId: "gpt-5.5", + templateModel: { + provider: "openai", + ...makeModel("gpt-5.5"), + }, + }); + const providerConfig = (api: "openai-responses" | "openai-completions") => + ({ + models: { + providers: { + openai: { + api, + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }) as OpenClawConfig; + + const first = await resolveModelAsync( + "openai", + "gpt-5.5", + "/tmp/agent", + providerConfig("openai-responses"), + { runtimeHooks: createRuntimeHooks() }, + ); + const second = await resolveModelAsync( + "openai", + "gpt-5.5", + "/tmp/agent", + providerConfig("openai-completions"), + { runtimeHooks: createRuntimeHooks() }, + ); + + expectResolvedModel(first); + expectResolvedModel(second); + expect(discoverAuthStorage).toHaveBeenCalledTimes(2); + expect(discoverModels).toHaveBeenCalledTimes(2); + }); + it("invalidates agent discovery stores when generated plugin catalogs change", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-cache-plugin-")); const agentDir = path.join(rootDir, "agent"); @@ -418,6 +461,50 @@ describe("resolveModel", () => { ); }); + it.each(["sync", "async"] as const)( + "passes config into %s model discovery when auth storage is prebuilt", + async (mode) => { + const agentDir = `/tmp/agent-configured-${mode}`; + const workspaceDir = `/tmp/workspace-configured-${mode}`; + const authStorage = { mocked: true } as never; + const cfg = { + models: { + providers: { + openai: { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5.5", baseUrl: "https://api.openai.com/v1" }], + }, + }, + }, + } as unknown as OpenClawConfig; + mockDiscoveredModel(discoverModels, { + provider: "openai", + modelId: "gpt-5.5", + templateModel: { + provider: "openai", + ...makeModel("gpt-5.5"), + }, + }); + + const options = { + authStorage, + workspaceDir, + runtimeHooks: createRuntimeHooks(), + }; + const result = + mode === "sync" + ? resolveModel("openai", "gpt-5.5", agentDir, cfg, options) + : await resolveModelAsync("openai", "gpt-5.5", agentDir, cfg, options); + + expectResolvedModel(result); + expect(discoverModels).toHaveBeenCalledWith(authStorage, agentDir, { + config: cfg, + workspaceDir, + }); + }, + ); + it("invalidates agent discovery stores when implicit main auth changes without config", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-cache-state-")); const agentDir = path.join(rootDir, "agents", "worker", "agent"); @@ -842,6 +929,46 @@ describe("resolveModel", () => { expect(shouldPreferProviderRuntimeResolvedModel).toHaveBeenCalled(); }); + it("keeps the prepared auth mode through async provider model resolution", async () => { + const baseRuntimeHooks = createRuntimeHooks(); + const prepareProviderDynamicModel = vi.fn(baseRuntimeHooks.prepareProviderDynamicModel); + const runProviderDynamicModel = vi.fn((params: { context: { authProfileMode?: string } }) => ({ + provider: "openai", + ...makeModel("gpt-5.5"), + api: + params.context.authProfileMode === "api_key" + ? ("openai-responses" as const) + : ("openai-chatgpt-responses" as const), + baseUrl: + params.context.authProfileMode === "api_key" + ? "https://api.openai.com/v1" + : "https://chatgpt.com/backend-api", + })); + + const result = await resolveModelAsync("openai", "gpt-5.5", "/tmp/agent", undefined, { + authProfileMode: "api_key", + runtimeHooks: { + ...baseRuntimeHooks, + prepareProviderDynamicModel, + runProviderDynamicModel, + }, + skipAgentDiscovery: true, + }); + + expectRecordFields(expectResolvedModel(result), { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + expectRecordFields(mockCallArg(prepareProviderDynamicModel).context, { + authProfileMode: "api_key", + }); + expectRecordFields(mockCallArg(runProviderDynamicModel).context, { + authProfileMode: "api_key", + }); + }); + it("looks up each static fallback candidate with its own normalized model id", async () => { resolveBundledStaticCatalogModelMock.mockImplementation(({ provider, modelId }) => ({ provider, @@ -3284,6 +3411,44 @@ describe("resolveModel", () => { }); }); + it("threads the model id through inline configured transport normalization", () => { + const normalizeProviderTransportWithPlugin = vi.fn(() => undefined); + const cfg = { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [ + { + ...makeModel("gpt-5.5"), + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + const result = resolveModel("openai", "gpt-5.5", "/tmp/agent", cfg, { + authStorage: { mocked: true } as never, + modelRegistry: discoverModels({ mocked: true } as never, "/tmp/agent"), + runtimeHooks: { + ...createRuntimeHooks(), + normalizeProviderTransportWithPlugin, + }, + }); + + expectResolvedModel(result); + expect(normalizeProviderTransportWithPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + modelId: "gpt-5.5", + context: expect.objectContaining({ modelId: "gpt-5.5" }), + }), + ); + }); + it("prefers configured provider api metadata over discovered registry model", () => { mockDiscoveredModel(discoverModels, { provider: "onehub", diff --git a/src/agents/embedded-agent-runner/model.ts b/src/agents/embedded-agent-runner/model.ts index 46e4d3945442..47aa44dd2300 100644 --- a/src/agents/embedded-agent-runner/model.ts +++ b/src/agents/embedded-agent-runner/model.ts @@ -31,7 +31,10 @@ import { shouldSuppressBuiltInModel, shouldUnconditionallySuppress, } from "../model-suppression.js"; -import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../openai-routing.js"; +import { + canonicalizeOpenAIModelId, + listOpenAIAuthProfileProvidersForAgentRuntime, +} from "../openai-routing.js"; import { attachModelProviderLocalService } from "../provider-local-service.js"; import { attachModelProviderRequestTransport, @@ -168,17 +171,17 @@ function discoverCachedAgentStoresForAgent( } function canonicalizeLegacyResolvedModel(params: { provider: string; model: Model }): Model { - if ( - normalizeProviderId(params.provider) !== "openai" || - params.model.id.trim().toLowerCase() !== "gpt-5.4-codex" - ) { + const canonicalModelId = canonicalizeOpenAIModelId(params.provider, params.model.id); + if (canonicalModelId === params.model.id) { return params.model; } return { ...params.model, - id: "gpt-5.4", + id: canonicalModelId, name: - params.model.name.trim().toLowerCase() === "gpt-5.4-codex" ? "gpt-5.4" : params.model.name, + canonicalizeOpenAIModelId(params.provider, params.model.name) === canonicalModelId + ? canonicalModelId + : params.model.name, }; } @@ -908,6 +911,7 @@ function resolveExplicitModelWithRegistry(params: { if (inlineMatch?.api) { const transport = resolveProviderTransport({ provider, + modelId, api: inlineMatch.api, baseUrl: inlineMatch.baseUrl ?? providerConfig?.baseUrl, cfg, @@ -1068,9 +1072,11 @@ function resolveExplicitModelWithRegistry(params: { function resolveDynamicModelAuthProfile(params: { provider: string; + modelId: string; cfg?: OpenClawConfig; agentDir?: string; authProfileId?: string; + authProfileMode?: AuthProfileCredential["type"] | "aws-sdk"; preferredProfile?: string; }): { authProfileId?: string; @@ -1085,11 +1091,14 @@ function resolveDynamicModelAuthProfile(params: { const configuredMode = params.cfg?.auth?.profiles?.[explicitProfileId]?.mode; return { authProfileId: explicitProfileId, - ...(credential?.type || configuredMode - ? { authProfileMode: credential?.type ?? configuredMode } + ...(params.authProfileMode || credential?.type || configuredMode + ? { authProfileMode: params.authProfileMode ?? credential?.type ?? configuredMode } : {}), }; } + if (params.authProfileMode) { + return { authProfileMode: params.authProfileMode }; + } const order = [ ...new Set( listOpenAIAuthProfileProvidersForAgentRuntime({ @@ -1101,6 +1110,7 @@ function resolveDynamicModelAuthProfile(params: { store, provider, preferredProfile: params.preferredProfile, + forModel: params.modelId, }), ), ), @@ -1127,6 +1137,7 @@ function resolvePluginDynamicModelWithRegistry(params: { agentDir?: string; workspaceDir?: string; authProfileId?: string; + authProfileMode?: AuthProfileCredential["type"] | "aws-sdk"; preferredProfile?: string; runtimeHooks?: ProviderRuntimeHooks; }): Model | undefined { @@ -1141,9 +1152,11 @@ function resolvePluginDynamicModelWithRegistry(params: { : undefined; const authProfile = resolveDynamicModelAuthProfile({ provider, + modelId, cfg, agentDir, authProfileId: params.authProfileId, + authProfileMode: params.authProfileMode, preferredProfile: params.preferredProfile, }); const preferDiscoveredModelMetadata = shouldCompareProviderRuntimeResolvedModel({ @@ -1201,6 +1214,7 @@ function resolveRuntimePreferredSuppressedModel(params: { agentDir?: string; workspaceDir?: string; authProfileId?: string; + authProfileMode?: AuthProfileCredential["type"] | "aws-sdk"; preferredProfile?: string; runtimeHooks?: ProviderRuntimeHooks; }): Model | undefined { @@ -1512,6 +1526,7 @@ export function resolveModelWithRegistry(params: { agentDir?: string; workspaceDir?: string; authProfileId?: string; + authProfileMode?: AuthProfileCredential["type"] | "aws-sdk"; preferredProfile?: string; runtimeHooks?: ProviderRuntimeHooks; skipConfiguredFallback?: boolean; @@ -1576,6 +1591,7 @@ export function resolveModel( skipProviderRuntimeHooks?: boolean; workspaceDir?: string; authProfileId?: string; + authProfileMode?: AuthProfileCredential["type"] | "aws-sdk"; preferredProfile?: string; }, ): { @@ -1596,7 +1612,10 @@ export function resolveModel( const modelRegistry = options?.modelRegistry ?? cachedStores?.modelRegistry ?? - discoverModels(authStorage, resolvedAgentDir); + discoverModels(authStorage, resolvedAgentDir, { + ...(cfg ? { config: cfg } : {}), + ...(workspaceDir ? { workspaceDir } : {}), + }); const runtimeHooks = resolveRuntimeHooks(options); const model = resolveModelWithRegistry({ provider: normalizedRef.provider, @@ -1606,6 +1625,7 @@ export function resolveModel( agentDir: resolvedAgentDir, workspaceDir, authProfileId: options?.authProfileId, + authProfileMode: options?.authProfileMode, preferredProfile: options?.preferredProfile, runtimeHooks, }); @@ -1643,6 +1663,7 @@ export async function resolveModelAsync( skipAgentDiscovery?: boolean; workspaceDir?: string; authProfileId?: string; + authProfileMode?: AuthProfileCredential["type"] | "aws-sdk"; preferredProfile?: string; }, ): Promise<{ @@ -1671,7 +1692,10 @@ export async function resolveModelAsync( options?.modelRegistry ?? emptyDiscoveryStores?.modelRegistry ?? cachedStores?.modelRegistry ?? - discoverModels(authStorage, resolvedAgentDir); + discoverModels(authStorage, resolvedAgentDir, { + ...(cfg ? { config: cfg } : {}), + ...(workspaceDir ? { workspaceDir } : {}), + }); const runtimeHooks = resolveRuntimeHooks(options); const explicitModel = resolveExplicitModelWithRegistry({ provider: normalizedRef.provider, @@ -1691,6 +1715,7 @@ export async function resolveModelAsync( agentDir: resolvedAgentDir, workspaceDir, authProfileId: options?.authProfileId, + authProfileMode: options?.authProfileMode, preferredProfile: options?.preferredProfile, runtimeHooks, }); @@ -1713,9 +1738,11 @@ export async function resolveModelAsync( const providerConfig = resolveConfiguredProviderConfig(cfg, normalizedRef.provider); const authProfile = resolveDynamicModelAuthProfile({ provider: normalizedRef.provider, + modelId: normalizedRef.model, cfg, agentDir: resolvedAgentDir, authProfileId: options?.authProfileId, + authProfileMode: options?.authProfileMode, preferredProfile: options?.preferredProfile, }); let staticCatalogLookup: Promise | undefined; @@ -1791,6 +1818,7 @@ export async function resolveModelAsync( agentDir: resolvedAgentDir, workspaceDir, authProfileId: options?.authProfileId, + authProfileMode: options?.authProfileMode, preferredProfile: options?.preferredProfile, runtimeHooks, ...(options?.allowBundledStaticCatalogFallback ? { skipConfiguredFallback: true } : {}), diff --git a/src/agents/embedded-agent-runner/run.before-agent-finalize.test.ts b/src/agents/embedded-agent-runner/run.before-agent-finalize.test.ts index 28c2de107e56..c68632ac6586 100644 --- a/src/agents/embedded-agent-runner/run.before-agent-finalize.test.ts +++ b/src/agents/embedded-agent-runner/run.before-agent-finalize.test.ts @@ -7,6 +7,7 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + useOpenAIPlatformAuthFixture, warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -56,6 +57,7 @@ describe("runEmbeddedAgent before_agent_finalize", () => { beforeEach(() => { resetRunOverflowCompactionHarnessMocks(); + useOpenAIPlatformAuthFixture(); mockedGlobalHookRunner.hasHooks.mockImplementation( (hookName: string) => hookName === "before_agent_finalize", ); diff --git a/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts b/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts index 5430292ce5b9..162e0e4bd848 100644 --- a/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts +++ b/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts @@ -13,6 +13,7 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + useOpenAIPlatformAuthFixture, warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; @@ -26,6 +27,7 @@ describe("runEmbeddedAgent Codex server_error fallback handoff", () => { beforeEach(() => { resetRunOverflowCompactionHarnessMocks(); + useOpenAIPlatformAuthFixture(); mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); }); diff --git a/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts b/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts index c1a027c4a24f..1b20ade8139f 100644 --- a/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts +++ b/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts @@ -7,11 +7,14 @@ import { loadRunOverflowCompactionHarness, MockedFailoverError, mockedClassifyFailoverReason, + mockedEnsureAuthProfileStore, + mockedEnsureAuthProfileStoreWithoutExternalProfiles, mockedFormatAssistantErrorText, mockedGlobalHookRunner, mockedIsFailoverAssistantError, mockedIsRateLimitAssistantError, mockedRunEmbeddedAttempt, + mockedResolveAuthProfileOrder, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, warmRunOverflowCompactionHarness, @@ -19,7 +22,7 @@ import { import type { EmbeddedRunAttemptResult } from "./run/types.js"; let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; -const DEEPSEEK_ERROR_MESSAGE = "429 deepseek rate limit"; +const DEEPSEEK_ERROR_MESSAGE = "429 insufficient quota"; const COMPACTION_REMOVED_ERROR_MESSAGE = "current candidate model unavailable"; type CurrentAttemptAssistantWithError = NonNullable< EmbeddedRunAttemptResult["currentAttemptAssistant"] @@ -47,6 +50,7 @@ function setupDeepseekFallbackErrorMatchers() { const assistant = args[0]; return isCurrentAttemptAssistant(assistant) && assistant.provider === "deepseek"; }); + mockedClassifyFailoverReason.mockReturnValue("rate_limit"); } function captureFormattedAssistant() { @@ -85,6 +89,30 @@ function makeCrossProviderFallbackConfig() { }); } +function useCrossProviderAuthFixture() { + const store = { + version: 1 as const, + profiles: { + "anthropic:test": { + type: "api_key" as const, + provider: "anthropic", + key: "anthropic-test-key", + }, + "deepseek:test": { + type: "api_key" as const, + provider: "deepseek", + key: "deepseek-test-key", + }, + }, + }; + mockedEnsureAuthProfileStore.mockReturnValue(store); + mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValue(store); + mockedResolveAuthProfileOrder.mockImplementation((params?: unknown) => { + const provider = (params as { provider?: string } | undefined)?.provider; + return provider && `${provider}:test` in store.profiles ? [`${provider}:test`] : []; + }); +} + function setupCompactionRemovedFallbackAttempt() { mockedIsFailoverAssistantError.mockImplementation((...args: unknown[]) => { const assistant = args[0]; @@ -114,6 +142,8 @@ function runCompactionRemovedFallbackAttempt() { agentHarnessRuntimeOverride: "openclaw", provider: "anthropic", model: "test-model", + authProfileId: "anthropic:test", + authProfileIdSource: "user", modelFallbacksOverride: ["deepseek/deepseek-chat"], }); } @@ -145,6 +175,7 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { beforeEach(() => { resetRunOverflowCompactionHarnessMocks(); + useCrossProviderAuthFixture(); mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); }); @@ -178,6 +209,8 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { agentHarnessRuntimeOverride: "openclaw", provider: "deepseek", model: "deepseek-chat", + authProfileId: "deepseek:test", + authProfileIdSource: "user", modelFallbacksOverride: ["deepseek/deepseek-chat"], }); @@ -227,6 +260,8 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { agentHarnessRuntimeOverride: "openclaw", provider: "deepseek", model: "deepseek-chat", + authProfileId: "deepseek:test", + authProfileIdSource: "user", modelFallbacksOverride: ["deepseek/deepseek-chat"], }); @@ -263,6 +298,8 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { agentHarnessRuntimeOverride: "openclaw", provider: "deepseek", model: "deepseek-chat", + authProfileId: "deepseek:test", + authProfileIdSource: "user", modelFallbacksOverride: ["deepseek/deepseek-chat"], }); @@ -271,7 +308,7 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { expect(result.meta.finalAssistantVisibleText).toBeUndefined(); expect(result.meta.agentMeta).toMatchObject({ provider: "deepseek", - model: "test-model", + model: "deepseek-chat", }); }); }); diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts index 46bc00229f12..31d63458465e 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts @@ -19,6 +19,7 @@ import { mockedSleepWithAbort, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + useOpenAIPlatformAuthFixture, warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import { @@ -60,6 +61,7 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { beforeEach(() => { resetRunOverflowCompactionHarnessMocks(); + useOpenAIPlatformAuthFixture(); mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); }); diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts index d350b6c52bc4..8bff8dbc5909 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts @@ -18,8 +18,10 @@ import type { PluginHookBeforePromptBuildResult, } from "../../plugins/types.js"; import { resetCommandQueueStateForTest } from "../../process/command-queue.js"; +import type { AuthProfileStore } from "../auth-profiles/types.js"; import type { FailoverReason } from "../embedded-agent-helpers/types.js"; import { clearAgentHarnesses, registerAgentHarness } from "../harness/registry.js"; +import type { ResolvedProviderAuth } from "../model-auth-runtime-shared.js"; import type { AgentRuntimePlan } from "../runtime-plan/types.js"; import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; import type { buildEmbeddedRunPayloads } from "./run/payloads.js"; @@ -60,18 +62,22 @@ type MockResolvedModel = { provider: string; contextWindow: number; api: string; + baseUrl?: string; reasoning?: boolean; }; -type MockResolveModelResult = { - model: MockResolvedModel; - error: null; +type MockAgentDiscoveryStores = { authStorage: { setRuntimeApiKey: ReturnType; }; modelRegistry: Record; }; +type MockResolveModelResult = MockAgentDiscoveryStores & { + model: MockResolvedModel; + error: null; +}; + export const mockedGlobalHookRunner = { hasHooks: vi.fn((_hookName: string) => false), runBeforeAgentReply: vi.fn( @@ -124,8 +130,8 @@ type MockRuntimePlan = Pick & { function makeMockRuntimePlan(): MockRuntimePlan { return { auth: { - authProfileProviderForAuth: "anthropic", - providerForAuth: "anthropic", + authProfileProviderForAuth: "openai", + providerForAuth: "openai", }, observability: { harnessId: "codex", @@ -144,20 +150,46 @@ export const mockedSleepWithAbort = vi.fn( async (_ms: number, _abortSignal?: AbortSignal) => undefined, ); export const mockedEnsureRuntimePluginsLoaded = vi.fn<(params?: unknown) => void>(); -export const mockedResolveModelAsync = vi.fn( - async (): Promise => ({ - model: { - id: "test-model", - provider: "anthropic", - contextWindow: 200000, - api: "messages", - }, - error: null, +function createMockAgentDiscoveryStores(): MockAgentDiscoveryStores { + return { authStorage: { setRuntimeApiKey: vi.fn(), }, modelRegistry: {}, - }), + }; +} + +export const mockedCreateEmptyAgentDiscoveryStores = vi.fn(createMockAgentDiscoveryStores); +function createMockResolvedModel( + provider = "anthropic", + modelId = "test-model", + cfg?: unknown, +): MockResolveModelResult { + const providerConfig = ( + cfg as { + models?: { providers?: Record }; + } + )?.models?.providers?.[provider]; + const usesOpenAITransport = provider === "openai" || provider === "codex"; + return { + model: { + id: modelId, + provider, + contextWindow: 200000, + api: providerConfig?.api ?? (usesOpenAITransport ? "openai-responses" : "messages"), + ...(providerConfig?.baseUrl + ? { baseUrl: providerConfig.baseUrl } + : usesOpenAITransport + ? { baseUrl: "https://api.openai.com/v1" } + : {}), + }, + error: null, + ...createMockAgentDiscoveryStores(), + }; +} +export const mockedResolveModelAsync = vi.fn( + async (provider?: string, modelId?: string, _agentDir?: string, cfg?: unknown) => + createMockResolvedModel(provider, modelId, cfg), ); export const mockedPrepareProviderRuntimeAuth = vi.fn(async () => undefined); export const mockedRunEmbeddedAttempt = @@ -289,25 +321,64 @@ export const mockedFormatContextWindowBlockMessage = vi.fn( (params: { guard: { tokens: number; source: string } }) => `Model context window too small (${params.guard.tokens} tokens; source=${params.guard.source}). Minimum is 1000.`, ); -export const mockedGetApiKeyForModel = vi.fn( - async ({ profileId }: { profileId?: string } = {}) => ({ - apiKey: "test-key", - profileId: profileId ?? "test-profile", - source: "test", - mode: "api-key" as const, - }), +type MockGetApiKeyForModelParams = { + profileId?: string; + model?: { api?: string }; +}; +export const mockedGetApiKeyForModel = vi.fn< + (params?: MockGetApiKeyForModelParams) => Promise +>(async ({ profileId }: MockGetApiKeyForModelParams = {}) => ({ + apiKey: "test-key", + profileId: profileId ?? "test-profile", + source: "test", + mode: "api-key", +})); +export const mockedIsProfileInCooldown = vi.fn( + (_store: unknown, _profileId: string, _now?: number, _modelId?: string) => false, ); export const mockedMarkAuthProfileFailure = vi.fn(async () => {}); -export const mockedEnsureAuthProfileStore = vi.fn(() => ({ version: 1 as const, profiles: {} })); -export const mockedEnsureAuthProfileStoreWithoutExternalProfiles = vi.fn( - (_agentDir?: string, _options?: { allowKeychainPrompt?: boolean }) => ({ - version: 1 as const, - profiles: {}, - }), -); +export const mockedEnsureAuthProfileStore = vi.fn<() => AuthProfileStore>(() => ({ + version: 1, + profiles: {}, +})); +export const mockedEnsureAuthProfileStoreWithoutExternalProfiles = vi.fn< + (_agentDir?: string, _options?: { allowKeychainPrompt?: boolean }) => AuthProfileStore +>((_agentDir?: string, _options?: { allowKeychainPrompt?: boolean }) => ({ + version: 1, + profiles: {}, +})); + +export function useOpenAIPlatformAuthFixture(): void { + const profileId = "openai:test"; + mockedEnsureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + [profileId]: { + type: "api_key", + provider: "openai", + key: "test-key", + }, + }, + order: { openai: [profileId] }, + }); + mockedResolveAuthProfileOrder.mockReturnValue([profileId]); +} export const mockedResolveAuthProfileOrder = vi.fn<(_params?: unknown) => string[]>( (_params?: unknown) => [], ); +type AuthProfileOrderResolution = ReturnType< + typeof import("../model-auth.js").resolveAuthProfileOrderWithMetadata +>; +export const mockedResolveAuthProfileOrderWithMetadata = vi.fn< + (_params?: unknown) => AuthProfileOrderResolution +>((params?: unknown) => ({ + profileIds: mockedResolveAuthProfileOrder(params), + hasExplicitOrder: false, +})); +export const mockedResolveProviderEntryApiKeyProfileReference = vi.fn< + (_params?: unknown) => unknown +>(() => ({ kind: "none" })); +export const mockedHasUsableCustomProviderApiKey = vi.fn(() => false); export const mockedMarkAuthProfileSuccess = vi.fn(async () => {}); export const mockedShouldPreferExplicitConfigApiKeyAuth = vi.fn(() => false); @@ -323,6 +394,7 @@ export const overflowBaseRunParams = { /** Reset every mocked runner dependency to the default successful no-op state. */ export function resetRunOverflowCompactionHarnessMocks(): void { + vi.unstubAllEnvs(); resetCommandQueueStateForTest(); clearAgentHarnesses(); registerAgentHarness({ @@ -365,20 +437,13 @@ export function resetRunOverflowCompactionHarnessMocks(): void { }); mockedEnsureRuntimePluginsLoaded.mockReset(); + mockedCreateEmptyAgentDiscoveryStores.mockReset(); + mockedCreateEmptyAgentDiscoveryStores.mockImplementation(createMockAgentDiscoveryStores); mockedResolveModelAsync.mockReset(); - mockedResolveModelAsync.mockResolvedValue({ - model: { - id: "test-model", - provider: "anthropic", - contextWindow: 200000, - api: "messages", - }, - error: null, - authStorage: { - setRuntimeApiKey: vi.fn(), - }, - modelRegistry: {}, - }); + mockedResolveModelAsync.mockImplementation( + async (provider?: string, modelId?: string, _agentDir?: string, cfg?: unknown) => + createMockResolvedModel(provider, modelId, cfg), + ); mockedPrepareProviderRuntimeAuth.mockReset(); mockedPrepareProviderRuntimeAuth.mockResolvedValue(undefined); mockedRunEmbeddedAttempt.mockReset(); @@ -496,13 +561,15 @@ export function resetRunOverflowCompactionHarnessMocks(): void { ); mockedGetApiKeyForModel.mockReset(); mockedGetApiKeyForModel.mockImplementation( - async ({ profileId }: { profileId?: string } = {}) => ({ + async ({ profileId }: MockGetApiKeyForModelParams = {}) => ({ apiKey: "test-key", profileId: profileId ?? "test-profile", source: "test", mode: "api-key", }), ); + mockedIsProfileInCooldown.mockReset(); + mockedIsProfileInCooldown.mockReturnValue(false); mockedMarkAuthProfileFailure.mockReset(); mockedMarkAuthProfileFailure.mockResolvedValue(undefined); mockedEnsureAuthProfileStore.mockReset(); @@ -514,6 +581,15 @@ export function resetRunOverflowCompactionHarnessMocks(): void { }); mockedResolveAuthProfileOrder.mockReset(); mockedResolveAuthProfileOrder.mockReturnValue([]); + mockedResolveAuthProfileOrderWithMetadata.mockReset(); + mockedResolveAuthProfileOrderWithMetadata.mockImplementation((params?: unknown) => ({ + profileIds: mockedResolveAuthProfileOrder(params), + hasExplicitOrder: false, + })); + mockedResolveProviderEntryApiKeyProfileReference.mockReset(); + mockedResolveProviderEntryApiKeyProfileReference.mockReturnValue({ kind: "none" }); + mockedHasUsableCustomProviderApiKey.mockReset(); + mockedHasUsableCustomProviderApiKey.mockReturnValue(false); mockedMarkAuthProfileSuccess.mockReset(); mockedMarkAuthProfileSuccess.mockResolvedValue(undefined); mockedShouldPreferExplicitConfigApiKeyAuth.mockReset(); @@ -603,12 +679,23 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ })); vi.doMock("../auth-profiles.js", () => ({ - isProfileInCooldown: vi.fn(() => false), + isProfileInCooldown: mockedIsProfileInCooldown, markAuthProfileFailure: mockedMarkAuthProfileFailure, markAuthProfileSuccess: mockedMarkAuthProfileSuccess, + resolveAuthProfileEligibility: vi.fn(() => ({ eligible: true, reasonCode: "ok" })), resolveProfilesUnavailableReason: vi.fn(() => undefined), })); + vi.doMock("../auth-profiles/order.js", async () => { + const actual = await vi.importActual( + "../auth-profiles/order.js", + ); + return { + ...actual, + resolveAuthProfileOrderWithMetadata: mockedResolveAuthProfileOrderWithMetadata, + }; + }); + vi.doMock("../usage.js", () => ({ normalizeUsage: vi.fn((usage?: unknown) => usage && typeof usage === "object" ? usage : undefined, @@ -767,6 +854,7 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ })); vi.doMock("./model.js", () => ({ + createEmptyAgentDiscoveryStores: mockedCreateEmptyAgentDiscoveryStores, resolveModelAsync: mockedResolveModelAsync, })); @@ -777,7 +865,10 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ ensureAuthProfileStoreWithoutExternalProfiles: mockedEnsureAuthProfileStoreWithoutExternalProfiles, getApiKeyForModel: mockedGetApiKeyForModel, + hasUsableCustomProviderApiKey: mockedHasUsableCustomProviderApiKey, resolveAuthProfileOrder: mockedResolveAuthProfileOrder, + resolveAuthProfileOrderWithMetadata: mockedResolveAuthProfileOrderWithMetadata, + resolveProviderEntryApiKeyProfileReference: mockedResolveProviderEntryApiKeyProfileReference, shouldPreferExplicitConfigApiKeyAuth: mockedShouldPreferExplicitConfigApiKeyAuth, })); diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 41dd24424dae..53fe4bd180f0 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -20,7 +20,11 @@ import { import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../../sessions/agent-harness-session-key.js"; import type { AgentHarness } from "../harness/types.js"; import type { AgentInternalEvent } from "../internal-events.js"; -import type { AgentRuntimePlan } from "../runtime-plan/types.js"; +import type { + AgentRuntimeAuthModelRoute, + AgentRuntimeAuthPlan, + AgentRuntimePlan, +} from "../runtime-plan/types.js"; import { makeAttemptResult, makeCompactionSuccess, @@ -42,6 +46,7 @@ import { mockedExtractObservedOverflowTokenCount, mockedGlobalHookRunner, mockedGetApiKeyForModel, + mockedIsProfileInCooldown, mockedIsLikelyContextOverflowError, mockedMarkAuthProfileSuccess, mockedPickFallbackThinkingLevel, @@ -49,6 +54,7 @@ import { mockedResolveContextWindowInfo, mockedResolveFailoverStatus, mockedResolveModelAsync, + mockedResolveProviderEntryApiKeyProfileReference, mockedRunContextEngineMaintenance, mockedRunEmbeddedAttempt, mockedSessionLikelyHasOversizedToolResults, @@ -56,16 +62,30 @@ import { mockedWaitForDeferredTurnMaintenanceForSession, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + useOpenAIPlatformAuthFixture, warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import type { RunEmbeddedAgentParams } from "./run/params.js"; import type { EmbeddedRunAttemptParams } from "./run/types.js"; let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; +type RuntimePlanAuthOverrides = Partial> & { + modelRoute?: AgentRuntimeAuthModelRoute; +}; type RuntimePlanOverrides = Partial> & { - auth?: Partial; + auth?: RuntimePlanAuthOverrides; resolvedRef?: Partial; }; + +function mergeRuntimePlanAuth( + base: AgentRuntimeAuthPlan, + overrides: RuntimePlanAuthOverrides | undefined, +): AgentRuntimeAuthPlan { + const { modelRoute: _baseModelRoute, ...baseFields } = base; + const { modelRoute, ...overrideFields } = overrides ?? {}; + const common = { ...baseFields, ...overrideFields }; + return modelRoute ? { ...common, modelRoute } : common; +} function makeForwardingCase(internalEvents: AgentInternalEvent[]) { // Forwarding cases prove request-scoped flags survive the overflow-compaction // route into the eventual embedded attempt. @@ -175,10 +195,7 @@ function makeForwardedRuntimePlan(overrides: RuntimePlanOverrides = {}): AgentRu return { ...basePlan, ...overrides, - auth: { - ...basePlan.auth, - ...overrides.auth, - }, + auth: mergeRuntimePlanAuth(basePlan.auth, overrides.auth), resolvedRef: { ...basePlan.resolvedRef, ...overrides.resolvedRef, @@ -230,6 +247,25 @@ function expectMockCallFields( return expectRecordFields(mockCallArg(mock, callIndex), expected); } +function queueOpenAIResolvedModel(params: { + api: "openai-responses" | "openai-chatgpt-responses"; + baseUrl: string; + authStorage: { setRuntimeApiKey: ReturnType }; +}): void { + mockedResolveModelAsync.mockResolvedValueOnce({ + model: { + id: "gpt-5.5", + provider: "openai", + contextWindow: 200_000, + api: params.api, + baseUrl: params.baseUrl, + }, + error: null, + authStorage: params.authStorage, + modelRegistry: {}, + }); +} + function expectRuntimePlanFields( runtimePlan: unknown, expected: { @@ -297,6 +333,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); it("reports hook-selected models as normal selected models, not fallbacks", async () => { + useOpenAIPlatformAuthFixture(); mockedGlobalHookRunner.hasHooks.mockImplementation( (hookName) => hookName === "before_model_resolve", ); @@ -324,6 +361,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); it("revalidates Ultra after a model hook replaces the selected model", async () => { + useOpenAIPlatformAuthFixture(); mockedGlobalHookRunner.hasHooks.mockImplementation( (hookName) => hookName === "before_model_resolve", ); @@ -498,10 +536,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { allowKeychainPrompt: false, }); expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); - expectMockCallFields(mockedResolveAuthProfileOrder, { - provider: "anthropic", - store: claudeAuthStore, - }); expectMockCallFields(mockedGetApiKeyForModel, { profileId: "anthropic:claude-cli", }); @@ -607,9 +641,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { allowKeychainPrompt: false, }); expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); - expectMockCallFields(mockedResolveAuthProfileOrder, { - preferredProfile: undefined, - }); expectMockCallFields(mockedGetApiKeyForModel, { profileId: "anthropic:claude-cli", }); @@ -674,9 +705,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { allowKeychainPrompt: false, }); expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); - expectMockCallFields(mockedResolveAuthProfileOrder, { - preferredProfile: undefined, - }); expectMockCallFields(mockedGetApiKeyForModel, { profileId: "anthropic:claude-cli", }); @@ -777,6 +805,17 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); it("keeps static Anthropic auth on the no-external auth profile store", async () => { + mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce({ + version: 1, + profiles: { + "anthropic:api": { + type: "api_key", + provider: "anthropic", + key: "static-key", + }, + }, + }); + mockedResolveAuthProfileOrder.mockReturnValueOnce(["anthropic:api"]); mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); await runEmbeddedAgent({ @@ -800,6 +839,9 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { expectRecordFields(mockCallArg(mockedEnsureAuthProfileStoreWithoutExternalProfiles, 0, 1), { allowKeychainPrompt: false, }); + expectMockCallFields(mockedGetApiKeyForModel, { + profileId: "anthropic:api", + }); }); it("keeps non-Codex plugin harnesses on the lightweight auth profile store", async () => { @@ -976,6 +1018,72 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { expect(attemptParams?.agentHarnessRuntimeOverride).toBe("openclaw"); }); + it("routes non-empty request stream params through OpenClaw before auth preparation", async () => { + useOpenAIPlatformAuthFixture(); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }, + streamParams: { maxTokens: 64 }, + runId: "request-stream-params-use-openclaw", + }); + + expectMockCallFields(mockedRunEmbeddedAttempt, { agentHarnessId: "openclaw" }); + const runtimePlanInput = expectMockCallFields(mockedBuildAgentRuntimePlan, { + harnessId: "openclaw", + }); + const preparedAuthPlan = expectRecordFields(runtimePlanInput.preparedAuthPlan, {}); + expectRecordFields(preparedAuthPlan.modelRoute, { + requestTransportOverrides: "present", + }); + }); + + it("keeps an empty request stream param record on the implicit Codex route", async () => { + useOpenAIPlatformAuthFixture(); + mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + + await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }, + streamParams: {}, + runId: "empty-request-stream-params-keep-codex", + }); + + expectMockCallFields(mockedRunEmbeddedAttempt, { agentHarnessId: "codex" }); + const runtimePlanInput = expectMockCallFields(mockedBuildAgentRuntimePlan, { + harnessId: "codex", + }); + const preparedAuthPlan = expectRecordFields(runtimePlanInput.preparedAuthPlan, {}); + expectRecordFields(preparedAuthPlan.modelRoute, { + requestTransportOverrides: "none", + }); + }); + it("keeps Ultra logical for the attempt and maps the runtime plan to max", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null })); @@ -1259,6 +1367,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { id: "codex", label: "Codex", supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", runAttempt: pluginRunAttempt, }); mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan); @@ -1296,6 +1405,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }, }; mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore); + mockedEnsureAuthProfileStore.mockReturnValue(codexAuthStore); try { await runEmbeddedAgent({ @@ -1398,6 +1508,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { runAttempt: pluginRunAttempt, }); mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore); + mockedEnsureAuthProfileStore.mockReturnValue(codexAuthStore); mockedResolveModelAsync.mockResolvedValueOnce({ model: { id: "gpt-5.4", @@ -1433,8 +1544,8 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { } expect(mockedGetApiKeyForModel).not.toHaveBeenCalled(); - expect(mockedEnsureAuthProfileStore).not.toHaveBeenCalled(); - expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).toHaveBeenCalled(); + expect(mockedEnsureAuthProfileStore).toHaveBeenCalledTimes(1); + expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1); expect(pluginRunAttempt).toHaveBeenCalledTimes(1); const pluginParams = expectMockCallFields(pluginRunAttempt, { @@ -1502,6 +1613,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { authBootstrap: "harness", runAttempt: pluginRunAttempt, }); + mockedEnsureAuthProfileStore.mockReturnValueOnce(codexAuthStore); mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore); mockedResolveModelAsync.mockResolvedValueOnce({ model: { @@ -1520,7 +1632,14 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { ...overflowBaseRunParams, provider: "openai", model: "gpt-5.4", - config: { agents: { defaults: { agentRuntime: { id: "codex" } } } }, + config: { + agents: { defaults: { agentRuntime: { id: "codex" } } }, + auth: { + profiles: { + "openai:work": { provider: "openai", mode: "api_key" }, + }, + }, + }, authProfileId: "openai:work", authProfileIdSource: "user", runId: "harness-secretref-auth-binding", @@ -1586,6 +1705,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { runAttempt: pluginRunAttempt, }); mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore); + mockedEnsureAuthProfileStore.mockReturnValue(codexAuthStore); mockedResolveModelAsync.mockResolvedValueOnce({ model: { id: "gpt-5.5", @@ -1598,6 +1718,12 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { modelRegistry: {}, }); mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan); + mockedGetApiKeyForModel.mockResolvedValueOnce({ + apiKey: "test-key", + profileId: "openai:work", + source: "test", + mode: "oauth", + }); try { await runEmbeddedAgent({ @@ -1689,19 +1815,135 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { authProfileId: undefined, resolvedApiKey: undefined, }); + const attempt = mockCallArg(pluginRunAttempt) as EmbeddedRunAttemptParams; + expect(attempt.runtimePlan?.auth.modelRoute).toBeUndefined(); + }); + + it.each([ + { + label: "literal provider key", + apiKey: "configured-platform-key" as unknown, + expectedApiKey: "configured-platform-key", + env: undefined, + }, + { + label: "provider SecretRef", + apiKey: { source: "env", provider: "default", id: "OPENAI_PLATFORM_KEY" } as unknown, + expectedApiKey: "secret-ref-platform-key", + env: { name: "OPENAI_PLATFORM_KEY", value: "secret-ref-platform-key" }, + }, + ])("bootstraps the prepared Platform route's $label for a Codex harness", async (testCase) => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const actualModelAuth = + await vi.importActual("../model-auth.js"); + const pluginRunAttempt = vi.fn(async () => + makeAttemptResult({ assistantTexts: ["ok"] }), + ); + const authStorage = { setRuntimeApiKey: vi.fn() }; + const modelRoute = { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses" as const, + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key" as const, + requestTransportOverrides: "none" as const, + }; + const runtimePlan = makeForwardedRuntimePlan({ + resolvedRef: { + provider: "openai", + modelId: "gpt-5.5", + harnessId: "codex", + }, + auth: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + selectedAuthMode: "api-key", + modelRoute, + }, + }); + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", + runAttempt: pluginRunAttempt, + }); + if (testCase.env) { + vi.stubEnv(testCase.env.name, testCase.env.value); + } else { + mockedResolveProviderEntryApiKeyProfileReference.mockReturnValue({ kind: "literal" }); + } + mockedEnsureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} }); + mockedResolveModelAsync.mockResolvedValue({ + model: { + id: "gpt-5.5", + provider: "openai", + contextWindow: 200_000, + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + error: null, + authStorage, + modelRegistry: {}, + }); + mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan); + mockedGetApiKeyForModel.mockImplementationOnce(async (params) => + actualModelAuth.getApiKeyForModel(params as never), + ); + + try { + await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { + agents: { + defaults: { + agentRuntime: { id: "codex" }, + }, + }, + models: { + providers: { + openai: { + api: "openai-responses", + apiKey: testCase.apiKey, + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + } as RunEmbeddedAgentParams["config"], + runId: `forced-codex-platform-${testCase.label.replaceAll(" ", "-")}`, + }); + } finally { + vi.unstubAllEnvs(); + clearAgentHarnesses(); + } + + expect(mockedGetApiKeyForModel).toHaveBeenCalledTimes(1); + expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1); + expect(authStorage.setRuntimeApiKey).toHaveBeenCalledTimes(1); + const pluginParams = expectMockCallFields(pluginRunAttempt, { + provider: "openai", + authProfileId: undefined, + resolvedApiKey: testCase.expectedApiKey, + }); + expectRuntimePlanFields(pluginParams.runtimePlan, { + auth: { + forwardedAuthProfileId: undefined, + selectedAuthMode: "api-key", + modelRoute, + }, + }); }); it("keeps missing OpenClaw auth fatal for a Codex harness without owned bootstrap", async () => { const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); - const { ProviderAuthError } = await import("../model-auth-runtime-shared.js"); const pluginRunAttempt = vi.fn(async () => makeAttemptResult({ assistantTexts: ["ok"] }), ); - const authError = new ProviderAuthError( - "missing-provider-auth", - "openai", - 'No API key found for provider "openai".', - ); clearAgentHarnesses(); registerAgentHarness({ id: "codex", @@ -1720,8 +1962,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { authStorage: { setRuntimeApiKey: vi.fn() }, modelRegistry: {}, }); - mockedGetApiKeyForModel.mockRejectedValueOnce(authError); - try { await expect( runEmbeddedAgent({ @@ -1737,12 +1977,13 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }, runId: "codex-harness-missing-managed-auth", }), - ).rejects.toBe(authError); + ).rejects.toThrow("No route-compatible authentication source is configured for openai."); } finally { clearAgentHarnesses(); } expect(pluginRunAttempt).not.toHaveBeenCalled(); + expect(mockedGetApiKeyForModel).not.toHaveBeenCalled(); }); it("loads the external Codex auth overlay before auto-selecting forced Codex runtime profiles", async () => { @@ -1795,7 +2036,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { supports: codexHarnessSupportsKnownProviders, runAttempt: pluginRunAttempt, }); - mockedEnsureAuthProfileStore.mockReturnValueOnce(codexAuthStore); + mockedEnsureAuthProfileStore.mockReturnValue(codexAuthStore); mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce({ version: 1, profiles: {}, @@ -1828,7 +2069,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { apiKey: "test-key", profileId, source: "test", - mode: "api-key", + mode: "oauth", }; }, ); @@ -1900,7 +2141,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { expect(harnessParams.toolAuthProfileStore).toBe(codexAuthStore); }); - it("refreshes bootstrapped Codex OAuth credentials when rotating profiles", async () => { + it("refreshes OAuth credentials for a compatible plugin without owned bootstrap", async () => { const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); const subscriptionLimit = new Error( "You've reached your Codex subscription usage limit. Next reset in 20 hours.", @@ -1930,7 +2171,6 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { auth: { providerForAuth: "openai", authProfileProviderForAuth: "openai", - harnessAuthProvider: "openai", forwardedAuthProfileId: "openai:sub", forwardedAuthProfileCandidateIds: ["openai:sub", "openai:backup"], }, @@ -1944,9 +2184,8 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { auth: { providerForAuth: "openai", authProfileProviderForAuth: "openai", - harnessAuthProvider: "openai", forwardedAuthProfileId: "openai:backup", - forwardedAuthProfileCandidateIds: ["openai:sub", "openai:backup"], + forwardedAuthProfileCandidateIds: ["openai:backup"], }, }); const codexAuthStore = { @@ -1971,12 +2210,12 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { clearAgentHarnesses(); registerAgentHarness({ id: "codex", - label: "Codex", + label: "Codex-compatible test harness", supports: codexHarnessSupportsKnownProviders, runAttempt: pluginRunAttempt, }); - mockedEnsureAuthProfileStore.mockReturnValueOnce(codexAuthStore); - mockedResolveAuthProfileOrder.mockReturnValueOnce(["openai:sub", "openai:backup"]); + mockedEnsureAuthProfileStore.mockReturnValue(codexAuthStore); + mockedResolveAuthProfileOrder.mockReturnValue(["openai:sub", "openai:backup"]); mockedResolveModelAsync.mockResolvedValueOnce({ model: { id: "gpt-5.5", @@ -1996,10 +2235,12 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { apiKey: profileId === "openai:backup" ? "backup-token" : "sub-token", profileId: profileId ?? "openai:sub", source: "test", - mode: "api-key", + mode: "oauth", }), ); - mockedCoerceToFailoverError.mockReturnValueOnce(normalizedLimit); + mockedCoerceToFailoverError.mockImplementation((error) => + error === subscriptionLimit ? normalizedLimit : null, + ); mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ message: err instanceof Error ? err.message : String(err), reason: err === normalizedLimit ? "rate_limit" : undefined, @@ -2019,7 +2260,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }, }, }, - runId: "forced-openai-chatgpt-responses-rotates-oauth", + runId: "generic-openai-harness-rotates-oauth", }); } finally { clearAgentHarnesses(); @@ -2043,6 +2284,14 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }, 1, ); + const firstAttempt = mockCallArg(pluginRunAttempt) as EmbeddedRunAttemptParams; + const secondAttempt = mockCallArg(pluginRunAttempt, 1) as EmbeddedRunAttemptParams; + expect(Object.keys(firstAttempt.authProfileStore.profiles)).toEqual([ + "openai:sub", + "openai:backup", + ]); + expect(Object.keys(secondAttempt.authProfileStore.profiles)).toEqual(["openai:backup"]); + expect(secondAttempt.authProfileStore).not.toBe(firstAttempt.authProfileStore); }); it("keeps auto-selected OpenAI Codex auth profiles for forced codex harness runs", async () => { @@ -2067,8 +2316,22 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { id: "codex", label: "Codex", supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", runAttempt: pluginRunAttempt, }); + mockedEnsureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:default": { + type: "oauth" as const, + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }); + mockedResolveAuthProfileOrder.mockReturnValueOnce(["openai:default"]); mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan); mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); @@ -2138,8 +2401,21 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { id: "codex", label: "Codex", supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", runAttempt: pluginRunAttempt, }); + mockedEnsureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:default": { + type: "oauth", + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }); mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan); mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); mockedResolveAuthProfileOrder.mockReturnValueOnce(["openai:default"]); @@ -2211,23 +2487,26 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { id: "codex", label: "Codex", supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", runAttempt: pluginRunAttempt, }); mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan); mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); mockedResolveAuthProfileOrder.mockReturnValueOnce(["openai:personal"]); - mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ - version: 1, + const friendlyAuthProfileStore = { + version: 1 as const, profiles: { "openai:personal": { - type: "oauth", + type: "oauth" as const, provider: "openai", access: "access", refresh: "refresh", expires: Date.now() + 60_000, }, }, - }); + }; + mockedEnsureAuthProfileStore.mockReturnValue(friendlyAuthProfileStore); + mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValue(friendlyAuthProfileStore); try { await runEmbeddedAgent({ @@ -2283,7 +2562,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); }); - it("rotates Codex harness auth profiles after a prompt-level subscription limit", async () => { + it("rotates Codex from subscription to a non-cooled Platform profile", async () => { const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); const subscriptionLimit = new Error( "You've reached your Codex subscription usage limit. Next reset in 20 hours.", @@ -2294,11 +2573,14 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { status: 429, }); let attemptCount = 0; + let cooldownRaceActive = false; const pluginRunAttempt = vi.fn(async () => { attemptCount += 1; - return attemptCount === 1 - ? makeAttemptResult({ promptError: subscriptionLimit }) - : makeAttemptResult({ assistantTexts: ["backup ok"], promptError: null }); + if (attemptCount === 1) { + cooldownRaceActive = true; + return makeAttemptResult({ promptError: subscriptionLimit }); + } + return makeAttemptResult({ assistantTexts: ["backup ok"], promptError: null }); }); const firstRuntimePlan = makeForwardedRuntimePlan({ resolvedRef: { @@ -2310,7 +2592,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { providerForAuth: "openai", harnessAuthProvider: "openai", forwardedAuthProfileId: "openai:sub", - forwardedAuthProfileCandidateIds: ["openai:sub", "openai:backup"], + forwardedAuthProfileCandidateIds: ["openai:sub"], }, }); const secondRuntimePlan = makeForwardedRuntimePlan({ @@ -2323,7 +2605,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { providerForAuth: "openai", harnessAuthProvider: "openai", forwardedAuthProfileId: "openai:backup", - forwardedAuthProfileCandidateIds: ["openai:sub", "openai:backup"], + forwardedAuthProfileCandidateIds: ["openai:backup"], }, }); clearAgentHarnesses(); @@ -2331,14 +2613,45 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { id: "codex", label: "Codex", supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", runAttempt: pluginRunAttempt, }); + const authStorage = { setRuntimeApiKey: vi.fn() }; + queueOpenAIResolvedModel({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authStorage, + }); + queueOpenAIResolvedModel({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authStorage, + }); + queueOpenAIResolvedModel({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authStorage, + }); mockedBuildAgentRuntimePlan .mockReturnValueOnce(firstRuntimePlan) .mockReturnValueOnce(secondRuntimePlan); - mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); - mockedResolveAuthProfileOrder.mockReturnValueOnce(["openai:sub", "openai:backup"]); - mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ + mockedGetApiKeyForModel.mockImplementation( + async ({ profileId, model }: { profileId?: string; model?: { api?: string } } = {}) => { + expect(profileId).toBe("openai:backup"); + expect(model?.api).toBe("openai-responses"); + return { + apiKey: "platform-key", + profileId, + source: `profile:${profileId}`, + mode: "api-key" as const, + }; + }, + ); + mockedResolveAuthProfileOrder.mockReturnValue(["openai:sub", "openai:cooled", "openai:backup"]); + mockedIsProfileInCooldown.mockImplementation( + (_store, profileId) => cooldownRaceActive && profileId === "openai:cooled", + ); + mockedEnsureAuthProfileStore.mockReturnValue({ version: 1, profiles: { "openai:sub": { @@ -2353,6 +2666,11 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { provider: "openai", key: "sk-test", }, + "openai:cooled": { + type: "api_key", + provider: "openai", + key: "sk-cooled", + }, }, }); mockedCoerceToFailoverError.mockReturnValueOnce(normalizedLimit); @@ -2383,7 +2701,12 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { clearAgentHarnesses(); } - expect(mockedGetApiKeyForModel).not.toHaveBeenCalled(); + expect(mockedGetApiKeyForModel).toHaveBeenCalledOnce(); + expect(authStorage.setRuntimeApiKey).toHaveBeenCalledOnce(); + expect(authStorage.setRuntimeApiKey).toHaveBeenCalledWith("openai", "platform-key"); + expect(mockedGetApiKeyForModel).not.toHaveBeenCalledWith( + expect.objectContaining({ profileId: "openai:cooled" }), + ); expect(pluginRunAttempt).toHaveBeenCalledTimes(2); const firstAttempt = expectMockCallFields(pluginRunAttempt, { provider: "openai", @@ -2402,19 +2725,443 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { expectRuntimePlanFields(firstAttempt.runtimePlan, { auth: { forwardedAuthProfileId: "openai:sub", - forwardedAuthProfileCandidateIds: ["openai:sub", "openai:backup"], + forwardedAuthProfileCandidateIds: ["openai:sub"], }, }); expectRuntimePlanFields(secondAttempt.runtimePlan, { auth: { forwardedAuthProfileId: "openai:backup", - forwardedAuthProfileCandidateIds: ["openai:sub", "openai:backup"], + forwardedAuthProfileCandidateIds: ["openai:backup"], }, }); + expectRecordFields(firstAttempt.model, { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }); + expectRecordFields(secondAttempt.model, { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + expectMockCallFields(mockedBuildAgentRuntimePlan, { + preparedAuthPlan: expect.objectContaining({ + modelRoute: expect.objectContaining({ + api: "openai-chatgpt-responses", + authRequirement: "subscription", + }), + }), + }); + expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledWith( + expect.objectContaining({ + preparedAuthPlan: expect.objectContaining({ + modelRoute: expect.objectContaining({ + api: "openai-responses", + authRequirement: "api-key", + }), + }), + }), + ); const firstAuthProfileStore = expectRecordFields(firstAttempt.authProfileStore, {}); const firstAuthProfiles = expectRecordFields(firstAuthProfileStore.profiles, {}); - expect(Object.keys(firstAuthProfiles)).toEqual(["openai:sub", "openai:backup"]); - expect(secondAttempt.authProfileStore).toBe(firstAttempt.authProfileStore); + expect(Object.keys(firstAuthProfiles)).toEqual(["openai:sub"]); + const secondAuthProfileStore = expectRecordFields(secondAttempt.authProfileStore, {}); + const secondAuthProfiles = expectRecordFields(secondAuthProfileStore.profiles, {}); + expect(Object.keys(secondAuthProfiles)).toEqual(["openai:backup"]); + expect(secondAuthProfileStore).not.toBe(firstAuthProfileStore); + expect(firstAttempt.resolvedApiKey).toBeUndefined(); + expect(secondAttempt.resolvedApiKey).toBe("platform-key"); + }); + + it("clears a Platform key when Codex rotates to a subscription profile", async () => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const platformLimit = new Error("Platform profile rate limited"); + const normalizedLimit = Object.assign(new Error(platformLimit.message), { + name: "FailoverError", + reason: "rate_limit", + status: 429, + }); + let attemptCount = 0; + const pluginRunAttempt = vi.fn(async () => { + attemptCount += 1; + return attemptCount === 1 + ? makeAttemptResult({ promptError: platformLimit }) + : makeAttemptResult({ assistantTexts: ["subscription ok"], promptError: null }); + }); + const platformPlan = makeForwardedRuntimePlan({ + resolvedRef: { provider: "openai", modelId: "gpt-5.5", harnessId: "codex" }, + auth: { + providerForAuth: "openai", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:platform", + forwardedAuthProfileCandidateIds: ["openai:platform"], + selectedAuthMode: "api_key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }, + }); + const subscriptionPlan = makeForwardedRuntimePlan({ + resolvedRef: { provider: "openai", modelId: "gpt-5.5", harnessId: "codex" }, + auth: { + providerForAuth: "openai", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:sub", + forwardedAuthProfileCandidateIds: ["openai:sub"], + selectedAuthMode: "oauth", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + }, + }, + }); + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", + runAttempt: pluginRunAttempt, + }); + const authStorage = { setRuntimeApiKey: vi.fn() }; + queueOpenAIResolvedModel({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authStorage, + }); + queueOpenAIResolvedModel({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authStorage, + }); + queueOpenAIResolvedModel({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authStorage, + }); + queueOpenAIResolvedModel({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authStorage, + }); + mockedBuildAgentRuntimePlan + .mockReturnValueOnce(platformPlan) + .mockReturnValueOnce(subscriptionPlan); + mockedGetApiKeyForModel.mockImplementation( + async ({ profileId, model }: { profileId?: string; model?: { api?: string } } = {}) => { + expect(profileId).toBe("openai:platform"); + expect(model?.api).toBe("openai-responses"); + return { + apiKey: "platform-key", + profileId, + source: `profile:${profileId}`, + mode: "api-key" as const, + }; + }, + ); + mockedResolveAuthProfileOrder.mockReturnValue(["openai:platform", "openai:sub"]); + mockedEnsureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + "openai:sub": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }); + mockedCoerceToFailoverError.mockImplementation((error) => + error === platformLimit ? normalizedLimit : null, + ); + mockedDescribeFailoverError.mockImplementation((error: unknown) => ({ + message: error instanceof Error ? error.message : String(error), + reason: error === normalizedLimit ? "rate_limit" : undefined, + status: error === normalizedLimit ? 429 : undefined, + code: undefined, + })); + + try { + await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { agents: { defaults: { agentRuntime: { id: "codex" } } } }, + runId: "forced-codex-platform-to-subscription", + }); + } finally { + clearAgentHarnesses(); + } + + expect(mockedGetApiKeyForModel).toHaveBeenCalledOnce(); + expect(pluginRunAttempt).toHaveBeenCalledTimes(2); + const firstAttempt = mockCallArg(pluginRunAttempt) as EmbeddedRunAttemptParams; + const secondAttempt = mockCallArg(pluginRunAttempt, 1) as EmbeddedRunAttemptParams; + expect(firstAttempt.resolvedApiKey).toBe("platform-key"); + expect(secondAttempt.resolvedApiKey).toBeUndefined(); + expect(Object.keys(firstAttempt.authProfileStore.profiles)).toEqual(["openai:platform"]); + expect(Object.keys(secondAttempt.authProfileStore.profiles)).toEqual(["openai:sub"]); + expect(secondAttempt.authProfileStore).not.toBe(firstAttempt.authProfileStore); + expectRecordFields(firstAttempt.model, { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + expectRecordFields(secondAttempt.model, { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }); + }); + + it("selects OpenClaw for a profile-to-direct subscription fallback plan", async () => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const subscriptionLimit = new Error("subscription profile exhausted"); + const normalizedLimit = Object.assign(new Error(subscriptionLimit.message), { + name: "FailoverError", + reason: "rate_limit", + status: 429, + }); + const pluginRunAttempt = vi.fn(); + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + authBootstrap: "harness", + supports: (context) => + context.modelProvider?.preparedAuth?.requirement === "subscription" && + context.modelProvider.preparedAuth.source !== "profile" + ? { supported: false, reason: "direct subscription auth is not reproducible" } + : { supported: true, priority: 100 }, + runAttempt: pluginRunAttempt, + }); + const authStorage = { setRuntimeApiKey: vi.fn() }; + mockedResolveModelAsync.mockResolvedValue({ + model: { + id: "gpt-5.5", + provider: "openai", + contextWindow: 200_000, + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + error: null, + authStorage, + modelRegistry: {}, + }); + mockedEnsureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "openai:sub": { + type: "oauth", + provider: "openai", + access: "profile-subscription-token", + refresh: "profile-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }); + mockedResolveAuthProfileOrder.mockReturnValue(["openai:sub"]); + mockedResolveProviderEntryApiKeyProfileReference.mockReturnValue({ kind: "literal" }); + mockedGetApiKeyForModel.mockImplementation(async ({ profileId }: { profileId?: string } = {}) => + profileId + ? { + apiKey: "profile-subscription-token", + profileId, + source: `profile:${profileId}`, + mode: "oauth" as const, + } + : { + apiKey: "direct-subscription-token", + source: "models.providers.openai", + mode: "oauth" as const, + }, + ); + const route = { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription" as const, + requestTransportOverrides: "none" as const, + }; + mockedBuildAgentRuntimePlan + .mockReturnValueOnce( + makeForwardedRuntimePlan({ + resolvedRef: { provider: "openai", modelId: "gpt-5.5", harnessId: "openclaw" }, + auth: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:sub", + forwardedAuthProfileCandidateIds: ["openai:sub"], + selectedAuthMode: "oauth", + modelRoute: route, + }, + }), + ) + .mockReturnValueOnce( + makeForwardedRuntimePlan({ + resolvedRef: { provider: "openai", modelId: "gpt-5.5", harnessId: "openclaw" }, + auth: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: "oauth", + modelRoute: route, + }, + }), + ); + mockedRunEmbeddedAttempt + .mockResolvedValueOnce(makeAttemptResult({ promptError: subscriptionLimit })) + .mockResolvedValueOnce( + makeAttemptResult({ assistantTexts: ["direct fallback ok"], promptError: null }), + ); + mockedCoerceToFailoverError.mockImplementation((error) => + error === subscriptionLimit ? normalizedLimit : null, + ); + mockedDescribeFailoverError.mockImplementation((error: unknown) => ({ + message: error instanceof Error ? error.message : String(error), + reason: error === normalizedLimit ? "rate_limit" : undefined, + status: error === normalizedLimit ? 429 : undefined, + code: undefined, + })); + + try { + await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + auth: "oauth", + apiKey: "configured-direct-subscription-token", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [], + }, + }, + }, + }, + runId: "implicit-codex-full-plan-falls-back-openclaw", + }); + } finally { + clearAgentHarnesses(); + } + + expect(pluginRunAttempt).not.toHaveBeenCalled(); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expectMockCallFields(mockedRunEmbeddedAttempt, { + agentHarnessId: "openclaw", + authProfileId: "openai:sub", + resolvedApiKey: "profile-subscription-token", + }); + expectMockCallFields( + mockedRunEmbeddedAttempt, + { + agentHarnessId: "openclaw", + authProfileId: undefined, + resolvedApiKey: "direct-subscription-token", + }, + 1, + ); + }); + + it("keeps a session-pinned native model out of prepared-route materialization", async () => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const pluginRunAttempt = vi.fn(async () => + makeAttemptResult({ assistantTexts: ["native ok"], promptError: null }), + ); + const authStore = { + version: 1 as const, + profiles: { + "openai:work": { + type: "api_key" as const, + provider: "openai", + key: "sk-work", + }, + }, + }; + const runtimePlan = makeForwardedRuntimePlan({ + resolvedRef: { provider: "openai", modelId: "gpt-native", harnessId: "codex" }, + auth: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:work", + forwardedAuthProfileCandidateIds: ["openai:work"], + selectedAuthMode: "api_key", + modelRoute: { + provider: "openai", + modelId: "gpt-native", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }, + }); + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", + runAttempt: pluginRunAttempt, + }); + mockedEnsureAuthProfileStore.mockReturnValue(authStore); + mockedResolveAuthProfileOrder.mockReturnValue(["openai:work"]); + mockedBuildAgentRuntimePlan.mockReturnValue(runtimePlan); + + try { + await runEmbeddedAgent({ + ...overflowBaseRunParams, + sessionKey: undefined, + provider: "openai", + model: "gpt-native", + agentHarnessId: "codex", + modelSelectionLocked: true, + authProfileId: "openai:work", + authProfileIdSource: "user", + config: { + agents: { defaults: { agentRuntime: { id: "codex" } } }, + }, + runId: "native-model-skips-route-materialization", + }); + } finally { + clearAgentHarnesses(); + } + + expect(mockedResolveModelAsync).not.toHaveBeenCalled(); + expect(pluginRunAttempt).toHaveBeenCalledOnce(); + const attempt = expectMockCallFields(pluginRunAttempt, { + agentHarnessId: "codex", + modelSelectionLocked: true, + authProfileId: "openai:work", + }); + expectRecordFields(attempt.model, { + id: "gpt-native", + api: "openai-responses", + baseUrl: "", + }); + expectMockCallFields(mockedBuildAgentRuntimePlan, { + preparedAuthPlan: expect.objectContaining({ + modelRoute: expect.objectContaining({ + provider: "openai", + modelId: "gpt-native", + }), + }), + }); }); it("blocks undersized models before dispatching a provider attempt", async () => { @@ -2465,7 +3212,60 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); }); + it("keeps implicit Codex overflow recovery out of generic compaction without a native compactor", async () => { + useOpenAIPlatformAuthFixture(); + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const overflowError = makeOverflowError(); + const pluginRunAttempt = vi.fn(async () => + makeAttemptResult({ + promptError: overflowError, + promptErrorSource: "prompt", + assistantTexts: [], + }), + ); + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", + runAttempt: pluginRunAttempt, + }); + + try { + await expect( + runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { + models: { + providers: { + openai: { + api: "openai-responses", + apiKey: "test-key", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }, + runId: "implicit-codex-overflow-owner", + }), + ).rejects.toThrow(overflowError.message); + } finally { + clearAgentHarnesses(); + } + + expect(pluginRunAttempt).toHaveBeenCalledOnce(); + const attemptParams = expectMockCallFields(pluginRunAttempt, { agentHarnessId: "codex" }); + expect(attemptParams.modelSelectionLocked).not.toBe(true); + expect(mockedIsLikelyContextOverflowError).toHaveBeenCalledWith(overflowError.message); + expect(mockedCompactDirect).not.toHaveBeenCalled(); + }); + it("preserves a locked OpenClaw model in overflow compaction context", async () => { + useOpenAIPlatformAuthFixture(); mockOverflowRetrySuccess({ runEmbeddedAttempt: mockedRunEmbeddedAttempt, compactDirect: mockedCompactDirect, @@ -2491,6 +3291,31 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); }); + it("preserves an explicit empty fallback override in overflow compaction context", async () => { + mockOverflowRetrySuccess({ + runEmbeddedAttempt: mockedRunEmbeddedAttempt, + compactDirect: mockedCompactDirect, + }); + + await runEmbeddedAgent({ + ...overflowBaseRunParams, + modelFallbacksOverride: [], + config: { + agents: { + defaults: { + model: { fallbacks: ["anthropic/claude-opus-4-6"] }, + }, + }, + }, + }); + + const compactParams = expectMockCallFields(mockedCompactDirect, {}); + expectRecordFields(compactParams.runtimeContext, { + trigger: "overflow", + modelFallbacksOverride: [], + }); + }); + it("threads prompt-cache runtime context into overflow compaction", async () => { mockedRunEmbeddedAttempt .mockResolvedValueOnce( @@ -3127,6 +3952,9 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { "test-profile": { provider: "anthropic", type: "oauth", + access: "access", + refresh: "refresh", + expires: Date.now() + 60_000, }, }, }); diff --git a/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts b/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts index dead6e753001..ddeea6589efe 100644 --- a/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts +++ b/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts @@ -9,6 +9,7 @@ import { mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + useOpenAIPlatformAuthFixture, warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; @@ -22,6 +23,7 @@ describe("runEmbeddedAgent prompt timeout fallback handoff", () => { beforeEach(() => { resetRunOverflowCompactionHarnessMocks(); + useOpenAIPlatformAuthFixture(); }); it("throws FailoverError for replay-safe harness-owned prompt timeouts when model fallbacks are configured", async () => { diff --git a/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts b/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts index 1bc9be273362..01086f8c1726 100644 --- a/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts @@ -1,5 +1,6 @@ // Coverage for timeout-triggered compaction and retry routing. -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentHarness } from "../harness/types.js"; import { makeAttemptResult, makeCompactionSuccess } from "./run.overflow-compaction.fixture.js"; import { loadRunOverflowCompactionHarness, @@ -13,6 +14,7 @@ import { mockedRunPostCompactionSideEffects, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, + useOpenAIPlatformAuthFixture, warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; @@ -21,6 +23,7 @@ let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; const useTwoAuthProfiles = () => { // Auth rotation assertions need deterministic profile order and API key // resolution across timeout compaction retries. + vi.stubEnv("ANTHROPIC_API_KEY", ""); mockedResolveAuthProfileOrder.mockReturnValue(["profile-a", "profile-b"]); mockedGetApiKeyForModel.mockImplementation(async ({ profileId } = {}) => ({ apiKey: `test-key-${profileId ?? "profile-a"}`, @@ -185,6 +188,51 @@ describe("timeout-triggered compaction", () => { expect(result.meta.agentMeta?.compactionTokensAfter).toBe(80_000); }); + it("leaves timeout recovery to a forced unlocked Codex compaction owner", async () => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const pluginRunAttempt = vi.fn(async () => + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { input: 150_000 }, + } as never, + }), + ); + const nativeCompact = vi.fn>(async () => ({ + ok: true, + compacted: false, + })); + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: (ctx) => + ctx.provider === "openai" ? { supported: true, priority: 100 } : { supported: false }, + authBootstrap: "harness", + runAttempt: pluginRunAttempt, + compact: nativeCompact, + }); + + const result = await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { + agents: { defaults: { agentRuntime: { id: "codex" } } }, + }, + runId: "forced-unlocked-codex-timeout-owner", + }).finally(() => { + clearAgentHarnesses(); + }); + + expect(pluginRunAttempt).toHaveBeenCalledOnce(); + expect(pluginRunAttempt.mock.calls[0]?.[0]).toMatchObject({ agentHarnessId: "codex" }); + expect(pluginRunAttempt.mock.calls[0]?.[0].modelSelectionLocked).not.toBe(true); + expect(mockedCompactDirect).not.toHaveBeenCalled(); + expect(nativeCompact).not.toHaveBeenCalled(); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); + it("retries the prompt after successful timeout compaction", async () => { // First attempt: timeout with high prompt usage mockedRunEmbeddedAttempt.mockResolvedValueOnce( @@ -226,6 +274,7 @@ describe("timeout-triggered compaction", () => { }); it("passes channel, thread, message, and sender context into timeout compaction", async () => { + useOpenAIPlatformAuthFixture(); mockedRunEmbeddedAttempt.mockResolvedValueOnce( makeAttemptResult({ timedOut: true, @@ -571,6 +620,14 @@ describe("timeout-triggered compaction", () => { usage: { input: 150000 }, } as never, }), + ) + // Normal failover gets one final attempt, but the compaction cap stays terminal. + .mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + aborted: true, + lastAssistant: { usage: { input: 150000 } } as never, + }), ); mockedCompactDirect .mockResolvedValueOnce({ @@ -595,7 +652,9 @@ describe("timeout-triggered compaction", () => { expect(secondCompact.runtimeContext?.authProfileId).toBe("profile-b"); expect(secondCompact.runtimeContext?.attempt).toBe(2); expect(secondCompact.runtimeContext?.maxAttempts).toBe(2); - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(3); + // After the compaction cap, normal failover gets one final un-compacted attempt. + expect(attemptCallAt(2).authProfileId).toBe("profile-a"); expect(result.payloads?.[0]?.isError).toBe(true); expect(result.payloads?.[0]?.text).toContain("timed out"); }); @@ -622,6 +681,13 @@ describe("timeout-triggered compaction", () => { usage: { input: 150000 }, } as never, }), + ) + .mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + aborted: true, + lastAssistant: { usage: { input: 150000 } } as never, + }), ); mockedCompactDirect .mockRejectedValueOnce(new Error("engine crashed")) @@ -630,9 +696,10 @@ describe("timeout-triggered compaction", () => { const result = await runEmbeddedAgent(overflowBaseRunParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(2); - expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(3); expect(attemptCallAt(0).authProfileId).toBe("profile-a"); expect(attemptCallAt(1).authProfileId).toBe("profile-b"); + expect(attemptCallAt(2).authProfileId).toBe("profile-a"); expect(result.payloads?.[0]?.isError).toBe(true); expect(result.payloads?.[0]?.text).toContain("timed out"); }); diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 5a7d50382c09..996610a9f56f 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -74,7 +74,6 @@ import { isProfileInCooldown, markAuthProfileFailure, markAuthProfileSuccess, - resolveAuthProfileEligibility, } from "../auth-profiles.js"; import { resolveExternalCliAuthOverlayScopeFromSelection } from "../auth-profiles/external-cli-auth-selection.js"; import { listActiveProcessSessionReferences } from "../bash-process-references.js"; @@ -122,7 +121,15 @@ import { resolveFastModeForElapsed, } from "../fast-mode.js"; import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js"; -import { agentHarnessBuildsOpenClawTools, selectAgentHarness } from "../harness/selection.js"; +import { + agentHarnessBuildsOpenClawTools, + selectAgentHarness, + selectAgentHarnessForPreparedModelProviders, +} from "../harness/selection.js"; +import { + resolveAgentHarnessPreparedAuthSupport, + resolveAgentHarnessPreparedRouteSupport, +} from "../harness/support.js"; import { LiveSessionModelSwitchError } from "../live-model-switch-error.js"; import { shouldSwitchToLiveModel, clearLiveModelSwitchPending } from "../live-model-switch.js"; import { @@ -131,8 +138,6 @@ import { ensureAuthProfileStore, ensureAuthProfileStoreWithoutExternalProfiles, type ResolvedProviderAuth, - resolveAuthProfileOrder, - shouldPreferExplicitConfigApiKeyAuth, } from "../model-auth.js"; import { buildModelAliasIndex, @@ -143,11 +148,9 @@ import { resolveThinkingDefault } from "../model-thinking-default.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; import { OPENAI_PROVIDER_ID, - listOpenAIAuthProfileProvidersForAgentRuntime, resolveContextConfigProviderForRuntime, resolveSelectedOpenAIRuntimeProvider, } from "../openai-routing.js"; -import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; import { hasOnlyAssistantReasoningContent } from "../replay-turn-classification.js"; import { runAgentCleanupStep } from "../run-cleanup-timeout.js"; import { @@ -155,9 +158,15 @@ import { resolveAgentRunSessionTarget, } from "../run-session-target.js"; import { createAgentRunDirectAbortError } from "../run-termination.js"; -import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js"; import { buildAgentRuntimePlan } from "../runtime-plan/build.js"; +import { materializePreparedRuntimeModel } from "../runtime-plan/materialize-model.js"; +import { + canRunPreparedAgentRuntimeAuthAttempt, + prepareAgentRuntimeAuth, + type PreparedAgentRuntimeAuthAttempt, +} from "../runtime-plan/prepare-auth.js"; import type { AgentRuntimePlan } from "../runtime-plan/types.js"; +import type { AgentRuntimeAuthPlan } from "../runtime-plan/types.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { resolveSessionSuspensionReason, @@ -205,7 +214,10 @@ import { shouldWarnEmbeddedRunStageSummary, } from "./run/attempt-stage-timing.js"; import { forgetPromptBuildDrainCacheForRun } from "./run/attempt.prompt-helpers.js"; -import { createEmbeddedRunAuthController } from "./run/auth-controller.js"; +import { + createEmbeddedRunAuthController, + resolveEmbeddedAuthCooldownProbePolicy, +} from "./run/auth-controller.js"; import { resolveAuthProfileFailureReason } from "./run/auth-profile-failure-policy.js"; import { runEmbeddedAttemptWithBackend } from "./run/backend.js"; import { @@ -708,6 +720,13 @@ function buildHandledReplyPayloads(reply?: ReplyPayload) { ]; } +/** Marks only request parameters that OpenClaw applies to provider egress. */ +function resolveRequestStreamTransportOverrides( + streamParams: RunEmbeddedAgentParams["streamParams"], +): "present" | undefined { + return streamParams && Object.keys(streamParams).length > 0 ? "present" : undefined; +} + function resolveInitialEmbeddedRunModel(params: { config: RunEmbeddedAgentParams["config"]; agentId?: string; @@ -1208,6 +1227,9 @@ async function runEmbeddedAgentInternal( modelId = hookSelection.modelId; const requestedModelId = modelId; const beforeAgentStartResult = hookSelection.beforeAgentStartResult; + const requestStreamTransportOverrides = resolveRequestStreamTransportOverrides( + params.streamParams, + ); startupStages.mark("hooks"); await ensureSelectedAgentHarnessPlugin({ provider, @@ -1217,18 +1239,26 @@ async function runEmbeddedAgentInternal( sessionKey: params.sessionKey, agentHarnessId: params.agentHarnessId, agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride, + requestTransportOverrides: requestStreamTransportOverrides, workspaceDir: resolvedWorkspace, }); - const agentHarness = selectAgentHarness({ + let agentHarness = selectAgentHarness({ provider, modelId, + ...(requestStreamTransportOverrides + ? { + modelProvider: { + requestTransportOverrides: requestStreamTransportOverrides, + }, + } + : {}), config: params.config, agentId: params.agentId, sessionKey: params.sessionKey, agentHarnessId: params.agentHarnessId, agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride, }); - const pluginHarnessOwnsTransport = agentHarness.id !== "openclaw"; + let pluginHarnessOwnsTransport = agentHarness.id !== "openclaw"; const expectedHarnessArtifact = params.expectedAgentHarnessRuntimeArtifact; if (expectedHarnessArtifact && expectedHarnessArtifact.harnessId !== agentHarness.id) { throw new Error( @@ -1344,34 +1374,122 @@ async function runEmbeddedAgentInternal( }); } let runtimeModel = model; - - const resolvedRuntimeModel = resolveEmbeddedRuntimeModelPolicy({ - cfg: params.config, - provider, - contextConfigProvider: resolveContextConfigProviderForRuntime({ - provider: modelConfigProvider, - runtimeId: agentHarness.id, - config: params.config, - }), - modelId, - runtimeModel, - nativeModelOwned, - }); - const contextTokenBudget = resolvedRuntimeModel.contextTokenBudget; - const contextWindowInfo = resolvedRuntimeModel.contextWindowInfo; - const outerContextTokenMeta = + const resolveEffectiveModel = (candidate: typeof runtimeModel) => + resolveEmbeddedRuntimeModelPolicy({ + cfg: params.config, + provider, + contextConfigProvider: resolveContextConfigProviderForRuntime({ + provider: modelConfigProvider, + runtimeId: agentHarness.id, + config: params.config, + }), + modelId, + runtimeModel: candidate, + nativeModelOwned, + }); + const initialResolvedRuntimeModel = resolveEffectiveModel(runtimeModel); + let contextTokenBudget = initialResolvedRuntimeModel.contextTokenBudget; + let contextWindowInfo = initialResolvedRuntimeModel.contextWindowInfo; + let outerContextTokenMeta: { contextTokens?: number } = contextTokenBudget === undefined ? {} : { contextTokens: contextTokenBudget }; - let effectiveModel = resolvedRuntimeModel.effectiveModel; + let effectiveModel = initialResolvedRuntimeModel.effectiveModel; + const applyResolvedRuntimeModel = ( + candidate: typeof runtimeModel, + resolved = resolveEffectiveModel(candidate), + ) => { + runtimeModel = candidate; + effectiveModel = resolved.effectiveModel; + contextTokenBudget = resolved.contextTokenBudget; + contextWindowInfo = resolved.contextWindowInfo; + outerContextTokenMeta = + contextTokenBudget === undefined ? {} : { contextTokens: contextTokenBudget }; + }; + const buildHarnessModelProvider = ( + candidate: typeof effectiveModel, + plan?: AgentRuntimeAuthPlan, + preparedAuthAttempt?: PreparedAgentRuntimeAuthAttempt, + ) => { + const route = plan?.modelRoute; + const routeSupport = resolveAgentHarnessPreparedRouteSupport(plan); + const requestTransportOverrides = + requestStreamTransportOverrides ?? routeSupport.requestTransportOverrides; + return { + api: route?.api ?? candidate.api, + baseUrl: route?.baseUrl ?? candidate.baseUrl, + ...(requestTransportOverrides ? { requestTransportOverrides } : {}), + ...(routeSupport.runtimePolicy ? { runtimePolicy: routeSupport.runtimePolicy } : {}), + ...(plan + ? { + preparedAuth: resolveAgentHarnessPreparedAuthSupport({ + plan, + ...(preparedAuthAttempt?.kind === "profile" || + preparedAuthAttempt?.kind === "direct" + ? { source: preparedAuthAttempt.kind } + : {}), + }), + } + : {}), + }; + }; + const selectHarnessForModel = ( + candidate: typeof effectiveModel, + plan?: AgentRuntimeAuthPlan, + preparedAuthAttempt?: PreparedAgentRuntimeAuthAttempt, + ) => { + const selected = selectAgentHarness({ + provider, + modelId, + modelProvider: buildHarnessModelProvider(candidate, plan, preparedAuthAttempt), + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + agentHarnessId: params.agentHarnessId, + agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride, + }); + if (nativeModelOwnedHarnessId && selected.id !== nativeModelOwnedHarnessId) { + throw new Error( + `Prepared model route changed the session-pinned agent harness from "${nativeModelOwnedHarnessId}" to "${selected.id}".`, + ); + } + return selected; + }; + const selectHarnessForPreparedAttempts = ( + candidate: typeof effectiveModel, + attempts: readonly PreparedAgentRuntimeAuthAttempt[], + ) => { + const selected = selectAgentHarnessForPreparedModelProviders({ + provider, + modelId, + modelProviders: attempts.map((attempt) => { + const route = attempt.plan.modelRoute; + const attemptModel = route + ? { ...candidate, api: route.api, baseUrl: route.baseUrl } + : candidate; + return buildHarnessModelProvider(attemptModel, attempt.plan, attempt); + }), + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + agentHarnessId: params.agentHarnessId, + agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride, + }); + if (nativeModelOwnedHarnessId && selected.id !== nativeModelOwnedHarnessId) { + throw new Error( + `Prepared auth routes changed the session-pinned agent harness from "${nativeModelOwnedHarnessId}" to "${selected.id}".`, + ); + } + return selected; + }; startupStages.mark("model-resolution"); notifyExecutionPhase("model_resolution", { provider, model: modelId }); - const pluginHarnessOwnsAuthBootstrap = - pluginHarnessOwnsTransport && agentHarness.authBootstrap === "harness"; - const pluginHarnessNeedsOpenClawAuthBootstrap = - pluginHarnessOwnsTransport && - !pluginHarnessOwnsAuthBootstrap && - provider === OPENAI_PROVIDER_ID && - effectiveModel.api === "openai-chatgpt-responses"; + // Route-aware support settles before the canonical auth decision. The + // materialized route below may confirm this choice, but cannot create a + // second profile/endpoint planner in the primary runner. + agentHarness = selectHarnessForModel(effectiveModel); + pluginHarnessOwnsTransport = agentHarness.id !== "openclaw"; + + const usesOpenAIAuthRouting = provider === OPENAI_PROVIDER_ID; const openClawNativeCodexResponsesNeedsAuthBootstrap = !pluginHarnessOwnsTransport && provider === OPENAI_PROVIDER_ID && @@ -1393,11 +1511,7 @@ async function runEmbeddedAgentInternal( params.authProfileIdSource === "user" ? params.authProfileId : undefined, }); let noExternalAuthStore: AuthProfileStore | undefined; - if ( - !pluginHarnessOwnsTransport && - !pluginHarnessNeedsOpenClawAuthBootstrap && - !piExternalCliAuthScope.providerIds - ) { + if (!pluginHarnessOwnsTransport && !piExternalCliAuthScope.providerIds) { noExternalAuthStore = ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { allowKeychainPrompt: false, }); @@ -1412,194 +1526,122 @@ async function runEmbeddedAgentInternal( params.authProfileIdSource === "user" ? params.authProfileId : undefined, }); } - const authStore = - pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap - ? createEmptyAuthProfileStore() - : pluginHarnessNeedsOpenClawAuthBootstrap - ? ensureAuthProfileStore(agentDir, { - externalCliProviderIds: [OPENAI_PROVIDER_ID], - allowKeychainPrompt: false, - }) - : piExternalCliAuthScope.providerIds - ? ensureAuthProfileStore(agentDir, { - externalCliProviderIds: piExternalCliAuthScope.providerIds, - allowKeychainPrompt: false, - }) - : (noExternalAuthStore ?? - ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { - allowKeychainPrompt: false, - })); - const attemptAuthProfileStore = - pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap + const attemptAuthProfileStore = usesOpenAIAuthRouting + ? ensureAuthProfileStore(agentDir, { + externalCliProviderIds: [OPENAI_PROVIDER_ID], + allowKeychainPrompt: false, + }) + : pluginHarnessOwnsTransport ? ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { allowKeychainPrompt: false, }) - : authStore; - const requestedProfileId = params.authProfileId?.trim(); - const requestedProfileIsUserLocked = params.authProfileIdSource === "user"; - const isForwardablePluginHarnessAuthProfile = ( - profileId: string | undefined, - ): profileId is string => { - if (!pluginHarnessOwnsTransport || !profileId) { - return false; - } - const credential = attemptAuthProfileStore.profiles?.[profileId]; - const runtimeAuthPlan = buildAgentRuntimeAuthPlan({ - provider, - authProfileProvider: credential?.provider ?? profileId.split(":", 1)[0], - authProfileMode: credential?.type, - sessionAuthProfileId: profileId, - config: params.config, - workspaceDir: resolvedWorkspace, - harnessId: agentHarness.id, - harnessRuntime: agentHarness.id, - allowHarnessAuthProfileForwarding: true, - }); - return runtimeAuthPlan.forwardedAuthProfileId === profileId; - }; - const resolvePluginHarnessProfileOrder = (): string[] => { - if (requestedProfileId && requestedProfileIsUserLocked) { - return isForwardablePluginHarnessAuthProfile(requestedProfileId) - ? [requestedProfileId] - : []; - } - if (!pluginHarnessOwnsTransport) { - return []; - } - const runtimeAuthPlan = buildAgentRuntimeAuthPlan({ - provider, - config: params.config, - workspaceDir: resolvedWorkspace, - harnessId: agentHarness.id, - harnessRuntime: agentHarness.id, - allowHarnessAuthProfileForwarding: true, - }); - const harnessAuthProvider = runtimeAuthPlan.harnessAuthProvider; - if (!harnessAuthProvider) { - return []; - } - const resolvedOrder = resolveAuthProfileOrder({ - cfg: params.config, - store: attemptAuthProfileStore, - provider: harnessAuthProvider, - }).filter(isForwardablePluginHarnessAuthProfile); - if (resolvedOrder.length > 0) { - return resolvedOrder; - } - if (requestedProfileId && isForwardablePluginHarnessAuthProfile(requestedProfileId)) { - return [requestedProfileId]; - } - return []; - }; - const pluginHarnessProfileOrder = pluginHarnessOwnsTransport - ? resolvePluginHarnessProfileOrder() - : []; - const resolvePluginHarnessPreferredProfileId = (): string | undefined => - pluginHarnessProfileOrder[0]; - const preferredProfileId = pluginHarnessOwnsTransport - ? resolvePluginHarnessPreferredProfileId() - : piExternalCliAuthScope.ignoreAutoPreferredProfile && !requestedProfileIsUserLocked + : piExternalCliAuthScope.providerIds + ? ensureAuthProfileStore(agentDir, { + externalCliProviderIds: piExternalCliAuthScope.providerIds, + allowKeychainPrompt: false, + }) + : (noExternalAuthStore ?? + ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + })); + const requestedProfileId = params.authProfileId?.trim() || undefined; + const lockedProfileId = + params.authProfileIdSource === "user" ? requestedProfileId : undefined; + const preferredProfileId = + piExternalCliAuthScope.ignoreAutoPreferredProfile && !lockedProfileId ? undefined : requestedProfileId; - let lockedProfileId = requestedProfileIsUserLocked ? preferredProfileId : undefined; - if (lockedProfileId) { - if (pluginHarnessOwnsTransport) { - if (!isForwardablePluginHarnessAuthProfile(lockedProfileId)) { - lockedProfileId = undefined; - } - } else { - const lockedProfile = authStore.profiles[lockedProfileId]; - const lockedProfileProvider = lockedProfile - ? resolveProviderIdForAuth(lockedProfile.provider, { - config: params.config, - workspaceDir: resolvedWorkspace, - }) - : undefined; - const runProvider = resolveProviderIdForAuth(provider, { - config: params.config, - workspaceDir: resolvedWorkspace, - }); - if (!lockedProfile || !lockedProfileProvider || lockedProfileProvider !== runProvider) { - lockedProfileId = undefined; - } - } - } - const forwardedPluginHarnessProfileId = - pluginHarnessOwnsTransport && - !lockedProfileId && - isForwardablePluginHarnessAuthProfile(preferredProfileId) - ? preferredProfileId - : undefined; - if (lockedProfileId && !pluginHarnessOwnsTransport) { - const eligibility = resolveAuthProfileEligibility({ - cfg: params.config, - store: authStore, + const createAuthPreparation = () => + prepareAgentRuntimeAuth({ provider, - profileId: lockedProfileId, + modelId, + modelApi: model.api, + modelBaseUrl: model.baseUrl, + requestTransportOverrides: requestStreamTransportOverrides, + config: params.config, + env: process.env, + agentDir, + workspaceDir: resolvedWorkspace, + authProfileStore: attemptAuthProfileStore, + sessionAuthProfileId: preferredProfileId, + sessionAuthProfileSource: params.authProfileIdSource, + harnessId: agentHarness.id, + harnessRuntime: agentHarness.id, + harnessAuthBootstrap: agentHarness.authBootstrap, + allowHarnessAuthProfileForwarding: true, + allowTransientCooldownProbe: params.allowTransientCooldownProbe === true, + resolveProviderPreferredProfileId: (context) => + resolveProviderAuthProfileId({ + provider, + config: params.config, + workspaceDir: resolvedWorkspace, + env: process.env, + context, + }), }); - if (!eligibility.eligible) { - throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`); + + const materializeAuthPlan = async (plan: AgentRuntimeAuthPlan) => { + // Native harness sessions own their model tuple. Route preparation may + // attest auth/transport, but must not rediscover or replace that model. + if (nativeModelOwned) { + return runtimeModel; + } + return ( + (await materializePreparedRuntimeModel({ + plan, + provider, + modelId, + config: params.config, + model: runtimeModel, + forceResolve: Boolean(plan.modelRoute), + resolveModel: ({ config, authProfileId, authProfileMode }) => + resolveModelAsync(provider, modelId, agentDir, config, { + authStorage, + modelRegistry, + skipAgentDiscovery: true, + allowBundledStaticCatalogFallback: true, + preferBundledStaticCatalogTransport: true, + workspaceDir: resolvedWorkspace, + authProfileId, + authProfileMode, + }), + })) ?? runtimeModel + ); + }; + let resolvedAuthPreparation = createAuthPreparation(); + let preparedAuthAttempts = resolvedAuthPreparation.attempts; + let activePreparedAuthPlan = resolvedAuthPreparation.plan; + applyResolvedRuntimeModel(await materializeAuthPlan(activePreparedAuthPlan)); + + const finalizedHarness = selectHarnessForPreparedAttempts( + effectiveModel, + preparedAuthAttempts, + ); + if (finalizedHarness.id !== agentHarness.id) { + agentHarness = finalizedHarness; + pluginHarnessOwnsTransport = agentHarness.id !== "openclaw"; + resolvedAuthPreparation = createAuthPreparation(); + preparedAuthAttempts = resolvedAuthPreparation.attempts; + activePreparedAuthPlan = resolvedAuthPreparation.plan; + applyResolvedRuntimeModel(await materializeAuthPlan(activePreparedAuthPlan)); + const confirmedHarness = selectHarnessForPreparedAttempts( + effectiveModel, + preparedAuthAttempts, + ); + if (confirmedHarness.id !== agentHarness.id) { + throw new Error( + `Prepared auth route did not converge on one agent harness for ${provider}/${modelId}.`, + ); } } - const profileOrder = shouldPreferExplicitConfigApiKeyAuth(params.config, provider) - ? [] - : [ - ...new Set( - listOpenAIAuthProfileProvidersForAgentRuntime({ - provider, - harnessRuntime: agentHarness.id, - agentHarnessId: agentHarness.id, - config: params.config, - }).flatMap((authProvider) => - resolveAuthProfileOrder({ - cfg: params.config, - store: authStore, - provider: authProvider, - preferredProfile: preferredProfileId, - }), - ), - ), - ]; - const providerPreferredProfileId = lockedProfileId - ? undefined - : resolveProviderAuthProfileId({ - provider, - config: params.config, - workspaceDir: resolvedWorkspace, - context: { - config: params.config, - agentDir, - workspaceDir: resolvedWorkspace, - provider, - modelId, - preferredProfileId, - lockedProfileId, - profileOrder, - authStore, - }, - }); - const providerOrderedProfiles = - providerPreferredProfileId && profileOrder.includes(providerPreferredProfileId) - ? [ - providerPreferredProfileId, - ...profileOrder.filter((profileId) => profileId !== providerPreferredProfileId), - ] - : profileOrder; - const profileCandidates = pluginHarnessOwnsTransport - ? lockedProfileId - ? [lockedProfileId] - : pluginHarnessProfileOrder.length > 0 - ? pluginHarnessProfileOrder - : [undefined] - : lockedProfileId - ? [lockedProfileId] - : providerOrderedProfiles.length > 0 - ? providerOrderedProfiles - : [undefined]; - const pluginHarnessForwardedProfileCandidates = pluginHarnessOwnsTransport - ? profileCandidates.filter(isForwardablePluginHarnessAuthProfile) - : []; - const profileFailureStore = pluginHarnessOwnsTransport ? attemptAuthProfileStore : authStore; + // A selected plugin harness owns context pressure with its native transcript, + // even if it cannot expose manual compaction. Generic recovery is OpenClaw-only. + const genericCompactionRecoveryAllowed = !pluginHarnessOwnsTransport; + const profileCandidates = preparedAuthAttempts.map((attempt) => attempt.profileId); + const forwardedPluginHarnessProfileId = pluginHarnessOwnsTransport + ? activePreparedAuthPlan.forwardedAuthProfileId + : undefined; + const profileFailureStore = attemptAuthProfileStore; let profileIndex = 0; const traceAttempts: TraceAttempt[] = []; const traceAttemptUsesFallback = (attempt: TraceAttempt): boolean => @@ -1668,7 +1710,89 @@ async function runEmbeddedAgentInternal( let lastProfileId: string | undefined; let runtimeAuthState: RuntimeAuthState | null = null; let runtimeAuthRefreshCancelled = false; + const pluginHarnessOwnsAuthBootstrap = + pluginHarnessOwnsTransport && agentHarness.authBootstrap === "harness"; + const preparedApiKeyRoute = activePreparedAuthPlan.modelRoute?.authRequirement === "api-key"; + const pluginHarnessHasPreparedApiKeyAttempt = preparedAuthAttempts.some( + (attempt) => attempt.plan.modelRoute?.authRequirement === "api-key", + ); + const pluginHarnessNeedsOpenClawAuthBootstrap = + pluginHarnessOwnsTransport && + usesOpenAIAuthRouting && + (preparedApiKeyRoute || + (!pluginHarnessOwnsAuthBootstrap && + profileCandidates.some((profileId) => Boolean(profileId)))); + const findPreparedAuthAttempt = (profileId: string | undefined, attemptIndex?: number) => { + const attempt = + attemptIndex === undefined + ? preparedAuthAttempts.find((candidate) => candidate.profileId === profileId) + : preparedAuthAttempts[attemptIndex]; + return attempt?.profileId === profileId ? attempt : undefined; + }; + let preparedProfileAttempted = false; + const prepareAuthAttempt = async (attempt: (typeof preparedAuthAttempts)[number]) => { + if ( + !canRunPreparedAgentRuntimeAuthAttempt({ + attempt, + priorProfileAttempted: preparedProfileAttempted, + }) + ) { + throw new Error( + `Prepared direct auth fallback cannot bypass unavailable profiles for ${provider}/${modelId}.`, + ); + } + const route = attempt.plan.modelRoute; + const nextRuntimeModel = route ? await materializeAuthPlan(attempt.plan) : runtimeModel; + const nextResolvedModel = resolveEffectiveModel(nextRuntimeModel); + const nextHarness = selectHarnessForPreparedAttempts( + nextResolvedModel.effectiveModel, + preparedAuthAttempts, + ); + if (nextHarness.id !== agentHarness.id) { + throw new Error( + `Prepared auth retry changed the selected agent harness for ${provider}/${modelId}.`, + ); + } + preparedProfileAttempted ||= attempt.kind === "profile"; + return { + runtimeModel: nextRuntimeModel, + authRequirement: route?.authRequirement, + allowAuthProfileFallback: attempt.allowAuthProfileFallback, + commit() { + // Model metadata and its prepared route/profile become active in + // the same auth-controller transition before dispatch. + applyResolvedRuntimeModel(nextRuntimeModel, nextResolvedModel); + activePreparedAuthPlan = attempt.plan; + }, + }; + }; + const hasPreparedAuthAttemptMetadata = preparedAuthAttempts.some( + (attempt) => attempt.plan.modelRoute || attempt.allowAuthProfileFallback !== undefined, + ); + const prepareModelForAuthProfile = + hasPreparedAuthAttemptMetadata && + (!pluginHarnessOwnsAuthBootstrap || pluginHarnessHasPreparedApiKeyAttempt) + ? async (profileId: string | undefined, attemptIndex?: number) => { + const attempt = findPreparedAuthAttempt(profileId, attemptIndex); + if (!attempt) { + throw new Error( + `Auth profile "${profileId ?? "(none)"}" is outside the prepared attempts for ${provider}/${modelId}.`, + ); + } + const prepared = await prepareAuthAttempt(attempt); + if (attempt.plan.modelRoute && !prepared.authRequirement) { + throw new Error(`Prepared route metadata is missing for ${provider}/${modelId}.`); + } + return { + runtimeModel: prepared.runtimeModel, + authRequirement: prepared.authRequirement, + allowAuthProfileFallback: prepared.allowAuthProfileFallback, + commit: () => prepared.commit(), + }; + } + : undefined; const { + applyAuthProfileCandidate, advanceAuthProfile, initializeAuthProfile, maybeRefreshRuntimeAuthForAuthError, @@ -1677,7 +1801,7 @@ async function runEmbeddedAgentInternal( config: params.config, agentDir, workspaceDir: resolvedWorkspace, - authStore, + authStore: attemptAuthProfileStore, authStorage, profileCandidates, lockedProfileId, @@ -1715,26 +1839,60 @@ async function runEmbeddedAgentInternal( setProfileIndex: (next) => { profileIndex = next; }, + ...(prepareModelForAuthProfile ? { prepareModelForAuthProfile } : {}), setThinkLevel: (next) => { thinkLevel = next; }, log, }); - const advancePluginHarnessAuthProfile = async (): Promise => { + const advancePluginHarnessAuthAttempt = async (): Promise => { if (!pluginHarnessOwnsTransport || lockedProfileId) { return false; } let nextIndex = profileIndex + 1; - while (nextIndex < profileCandidates.length) { - const candidate = profileCandidates[nextIndex]; - if (!candidate || !isForwardablePluginHarnessAuthProfile(candidate)) { + while (nextIndex < preparedAuthAttempts.length) { + const candidateAttempt = preparedAuthAttempts[nextIndex]; + if (!candidateAttempt) { nextIndex += 1; continue; } - if (isProfileInCooldown(attemptAuthProfileStore, candidate, undefined, modelId)) { + const candidate = candidateAttempt.profileId; + if ( + candidate && + isProfileInCooldown(attemptAuthProfileStore, candidate, undefined, modelId) + ) { nextIndex += 1; continue; } + if ( + !canRunPreparedAgentRuntimeAuthAttempt({ + attempt: candidateAttempt, + priorProfileAttempted: preparedProfileAttempted, + }) + ) { + return false; + } + if (candidateAttempt.plan.modelRoute?.authRequirement === "api-key") { + try { + await applyAuthProfileCandidate(candidate, nextIndex); + profileIndex = nextIndex; + thinkLevel = initialThinkLevel; + attemptedThinking.clear(); + return true; + } catch { + nextIndex += 1; + continue; + } + } + if (!candidate || candidateAttempt.plan.forwardedAuthProfileId !== candidate) { + nextIndex += 1; + continue; + } + const prepared = await prepareAuthAttempt(candidateAttempt); + stopRuntimeAuthRefreshTimer(); + apiKeyInfo = null; + runtimeAuthState = null; + prepared.commit(); profileIndex = nextIndex; lastProfileId = candidate; thinkLevel = initialThinkLevel; @@ -1743,10 +1901,9 @@ async function runEmbeddedAgentInternal( } return false; }; - const advanceAttemptAuthProfile = - pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap - ? advancePluginHarnessAuthProfile - : advanceAuthProfile; + const advanceAttemptAuthProfile = pluginHarnessOwnsAuthBootstrap + ? advancePluginHarnessAuthAttempt + : advanceAuthProfile; // Plugin harnesses own their model transport/auth. Running OpenClaw's generic // auth bootstrap here can turn synthetic provider markers into real @@ -1756,18 +1913,56 @@ async function runEmbeddedAgentInternal( } else if (lockedProfileId) { lastProfileId = lockedProfileId; } else if (forwardedPluginHarnessProfileId) { - lastProfileId = forwardedPluginHarnessProfileId; + const initialAttempt = preparedAuthAttempts[profileIndex]; + const initialProfileInCooldown = + initialAttempt?.kind === "profile" && + isProfileInCooldown( + attemptAuthProfileStore, + initialAttempt.profileId, + undefined, + modelId, + ); + const cooldownProbePolicy = resolveEmbeddedAuthCooldownProbePolicy({ + authStore: attemptAuthProfileStore, + profileCandidates, + lockedProfileId, + modelId, + allowTransientCooldownProbe: params.allowTransientCooldownProbe === true, + }); + if (initialProfileInCooldown && !cooldownProbePolicy.allowProbe) { + if (!(await advancePluginHarnessAuthAttempt())) { + throw new Error( + `Prepared auth profiles are temporarily unavailable for ${provider}/${modelId}.`, + ); + } + } else { + if (initialProfileInCooldown) { + log.warn( + `probing cooldowned auth profile for ${provider}/${modelId} due to ${cooldownProbePolicy.unavailableReason ?? "transient"} unavailability`, + ); + } + preparedProfileAttempted = initialAttempt?.kind === "profile"; + lastProfileId = forwardedPluginHarnessProfileId; + } } startupStages.mark("auth"); notifyExecutionPhase("auth", { provider, model: modelId }); - const runAttemptAuthProfileStore = pluginHarnessOwnsTransport - ? createScopedAuthProfileStore( - attemptAuthProfileStore, - pluginHarnessForwardedProfileCandidates.length > 0 - ? pluginHarnessForwardedProfileCandidates - : lastProfileId, - ) - : attemptAuthProfileStore; + const resolveRunAttemptAuthProfileStore = (): AuthProfileStore => { + if (!pluginHarnessOwnsTransport) { + return attemptAuthProfileStore; + } + const activePlan = activePreparedAuthPlan; + const activeProfileIds = activePlan.modelRoute + ? [ + activePlan.forwardedAuthProfileId, + ...(activePlan.forwardedAuthProfileCandidateIds ?? []), + ] + : [lastProfileId]; + return createScopedAuthProfileStore( + attemptAuthProfileStore, + activeProfileIds.filter((profileId): profileId is string => Boolean(profileId)), + ); + }; const harnessBuildsOpenClawTools = agentHarnessBuildsOpenClawTools(agentHarness.id); const { sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, @@ -2337,18 +2532,7 @@ async function runEmbeddedAgentInternal( modelApi: effectiveModel.api, harnessId: agentHarness.id, harnessRuntime: agentHarness.id, - allowHarnessAuthProfileForwarding: pluginHarnessOwnsTransport, - authProfileProvider: - (lastProfileId - ? attemptAuthProfileStore.profiles?.[lastProfileId]?.provider - : undefined) ?? lastProfileId?.split(":", 1)[0], - authProfileMode: lastProfileId - ? attemptAuthProfileStore.profiles?.[lastProfileId]?.type - : undefined, - sessionAuthProfileId: lastProfileId, - sessionAuthProfileCandidateIds: pluginHarnessOwnsTransport - ? pluginHarnessForwardedProfileCandidates - : undefined, + preparedAuthPlan: activePreparedAuthPlan, config: params.config, workspaceDir: resolvedWorkspace, agentDir, @@ -2380,6 +2564,7 @@ async function runEmbeddedAgentInternal( workspaceDir: resolvedWorkspace, }) : undefined; + const runAttemptAuthProfileStore = resolveRunAttemptAuthProfileStore(); if (!startupStagesEmitted) { startupStages.mark(EMBEDDED_RUN_ATTEMPT_DISPATCH_STAGE.runtimePlan); startupStages.mark(EMBEDDED_RUN_ATTEMPT_DISPATCH_STAGE.dispatch); @@ -2888,7 +3073,7 @@ async function runEmbeddedAgentInternal( throw new LiveSessionModelSwitchError(requestedSelection); } if ( - !nativeModelOwned && + genericCompactionRecoveryAllowed && contextTokenBudget !== undefined && timedOut && !timedOutDuringCompaction && @@ -2931,6 +3116,8 @@ async function runEmbeddedAgentInternal( currentThreadTs: params.currentThreadTs, currentMessageId: params.currentMessageId, authProfileId: lastProfileId, + authProfileIdSource: lockedProfileId ? "user" : "auto", + runtimeAuthPlan: runtimePlan.auth, workspaceDir: resolvedWorkspace, agentDir, config: params.config, @@ -3070,7 +3257,11 @@ async function runEmbeddedAgentInternal( })() : null; - if (contextOverflowError && !nativeModelOwned && contextTokenBudget !== undefined) { + if ( + contextOverflowError && + genericCompactionRecoveryAllowed && + contextTokenBudget !== undefined + ) { const overflowDiagId = createCompactionDiagId(); const errorText = contextOverflowError.text; const msgCount = attempt.messagesSnapshot?.length ?? 0; @@ -3151,6 +3342,8 @@ async function runEmbeddedAgentInternal( currentThreadTs: params.currentThreadTs, currentMessageId: params.currentMessageId, authProfileId: lastProfileId, + authProfileIdSource: lockedProfileId ? "user" : "auto", + runtimeAuthPlan: runtimePlan.auth, workspaceDir: resolvedWorkspace, agentDir, config: params.config, @@ -3160,6 +3353,7 @@ async function runEmbeddedAgentInternal( modelId, harnessRuntime: agentHarness.id, modelSelectionLocked: params.modelSelectionLocked, + modelFallbacksOverride: params.modelFallbacksOverride, thinkLevel, reasoningLevel: params.reasoningLevel, bashElevated: params.bashElevated, diff --git a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts index 0d6400bf411c..0200ff81beeb 100644 --- a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts @@ -602,6 +602,8 @@ type AfterTurnRuntimeContextAttempt = Pick< | "extraSystemPrompt" | "ownerNumbers" | "authProfileId" + | "authProfileIdSource" + | "runtimePlan" > & { sessionId?: EmbeddedRunAttemptParams["sessionId"]; }; @@ -658,6 +660,8 @@ export function buildAfterTurnRuntimeContext(params: { currentThreadTs: params.attempt.currentThreadTs, currentMessageId: params.attempt.currentMessageId, authProfileId: params.attempt.authProfileId, + authProfileIdSource: params.attempt.authProfileIdSource, + runtimeAuthPlan: params.attempt.runtimePlan?.auth, workspaceDir: params.workspaceDir, cwd: params.cwd, agentDir: params.agentDir, diff --git a/src/agents/embedded-agent-runner/run/attempt.test.ts b/src/agents/embedded-agent-runner/run/attempt.test.ts index 22bb6ee196ef..2bd776f6b836 100644 --- a/src/agents/embedded-agent-runner/run/attempt.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.test.ts @@ -3366,6 +3366,21 @@ describe("buildAfterTurnRuntimeContext", () => { }); it("uses primary model when compaction.model is not set", () => { + const runtimeAuthPlan = { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:p1", + forwardedAuthProfileSource: "user" as const, + modelRoute: { + provider: "openai", + modelId: "gpt-5.4", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription" as const, + requestTransportOverrides: "none" as const, + }, + }; const legacy = buildAfterTurnRuntimeContext({ attempt: { sessionKey: "agent:main:session:abc", @@ -3373,6 +3388,8 @@ describe("buildAfterTurnRuntimeContext", () => { messageProvider: "slack", agentAccountId: "acct-1", authProfileId: "openai:p1", + authProfileIdSource: "user", + runtimePlan: { auth: runtimeAuthPlan } as never, config: {} as OpenClawConfig, skillsSnapshot: undefined, provider: "openai", @@ -3389,6 +3406,8 @@ describe("buildAfterTurnRuntimeContext", () => { expect(legacy.provider).toBe("openai"); expect(legacy.model).toBe("gpt-5.4"); + expect(legacy.authProfileIdSource).toBe("user"); + expect(legacy.runtimeAuthPlan).toBe(runtimeAuthPlan); }); it("keeps the primary model for a locked after-turn runtime context", () => { diff --git a/src/agents/embedded-agent-runner/run/auth-controller.test.ts b/src/agents/embedded-agent-runner/run/auth-controller.test.ts index eeaf1c2cb9dd..96a6b8e18791 100644 --- a/src/agents/embedded-agent-runner/run/auth-controller.test.ts +++ b/src/agents/embedded-agent-runner/run/auth-controller.test.ts @@ -34,7 +34,10 @@ vi.mock("../../model-auth.js", async () => { }; }); -import { createEmbeddedRunAuthController } from "./auth-controller.js"; +import { + createEmbeddedRunAuthController, + resolveEmbeddedAuthCooldownProbePolicy, +} from "./auth-controller.js"; function createDeferred() { // Manual deferreds let refresh tests prove in-flight auth state and ordering. @@ -111,6 +114,9 @@ function createMutableEmbeddedRunAuthController(params: { authStore?: AuthProfileStore; fallbackConfigured?: boolean; warn?: (message: string) => void; + prepareModelForAuthProfile?: Parameters< + typeof createEmbeddedRunAuthController + >[0]["prepareModelForAuthProfile"]; }) { return createEmbeddedRunAuthController({ config: undefined, @@ -156,6 +162,9 @@ function createMutableEmbeddedRunAuthController(params: { setProfileIndex: (next) => { params.harness.profileIndex = next; }, + ...(params.prepareModelForAuthProfile + ? { prepareModelForAuthProfile: params.prepareModelForAuthProfile } + : {}), setThinkLevel: () => undefined, log: { debug: () => undefined, @@ -171,6 +180,75 @@ describe("createEmbeddedRunAuthController", () => { mocks.getApiKeyForModel.mockReset(); }); + it("commits a prepared route only after its credential resolves", async () => { + const harness = createMutableAuthControllerHarness(); + const selectedModel = { + ...createTestModel(), + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 272_000, + }; + mocks.getApiKeyForModel.mockImplementation(async ({ model }) => { + expect(model).toBe(selectedModel); + expect(harness.runtimeModel).not.toBe(selectedModel); + return { + apiKey: "subscription-token", + mode: "oauth" as const, + profileId: "openai:chatgpt", + source: "profile", + }; + }); + mocks.prepareProviderRuntimeAuth.mockResolvedValue(undefined); + + const controller = createMutableEmbeddedRunAuthController({ + harness, + setRuntimeApiKey: vi.fn(), + profileCandidates: ["openai:chatgpt"], + prepareModelForAuthProfile: async () => ({ + runtimeModel: selectedModel, + authRequirement: "subscription", + commit: () => { + harness.runtimeModel = selectedModel; + harness.effectiveModel = selectedModel; + }, + }), + }); + + await controller.initializeAuthProfile(); + expect(harness.runtimeModel).toBe(selectedModel); + expect(harness.lastProfileId).toBe("openai:chatgpt"); + }); + + it("rejects credentials whose class does not match the prepared route", async () => { + const harness = createMutableAuthControllerHarness(); + const commit = vi.fn(); + mocks.getApiKeyForModel.mockResolvedValue({ + apiKey: "platform-key", + mode: "api-key", + source: "config", + }); + + const controller = createMutableEmbeddedRunAuthController({ + harness, + setRuntimeApiKey: vi.fn(), + profileCandidates: ["default"], + prepareModelForAuthProfile: async () => ({ + runtimeModel: { + ...createTestModel(), + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + authRequirement: "subscription", + commit, + }), + }); + + await expect(controller.initializeAuthProfile()).rejects.toThrow( + "api-key credentials are incompatible with the selected subscription route", + ); + expect(commit).not.toHaveBeenCalled(); + }); + it("applies runtime request overrides on the first auth exchange", async () => { // Provider runtime auth can replace baseUrl, headers, and runtime API key in // one exchange; both runtime and effective models must see the override. @@ -221,6 +299,59 @@ describe("createEmbeddedRunAuthController", () => { expect(harness.runtimeAuthState?.profileId).toBe("default"); }); + it("clears prior runtime-auth transport overrides when rotating profiles", async () => { + const harness = createMutableAuthControllerHarness(); + const baseModel = { + ...createTestModel(), + headers: { "x-base": "base" }, + }; + harness.runtimeModel = baseModel; + harness.effectiveModel = baseModel; + const setRuntimeApiKey = vi.fn<(provider: string, apiKey: string) => void>(); + + mocks.getApiKeyForModel.mockImplementation(async ({ profileId }) => ({ + apiKey: `${String(profileId)}-source-key`, + mode: "api-key" as const, + profileId, + source: `profile:${String(profileId)}`, + })); + mocks.prepareProviderRuntimeAuth.mockImplementation(async ({ context }) => + context.profileId === "default" + ? { + apiKey: "default-runtime-key", + baseUrl: "https://default-runtime.example.com/v1", + request: { + auth: { + mode: "header" as const, + headerName: "x-profile-token", + value: "default-profile-token", + }, + }, + } + : undefined, + ); + + const controller = createMutableEmbeddedRunAuthController({ + harness, + setRuntimeApiKey, + profileCandidates: ["default", "backup"], + }); + + await controller.initializeAuthProfile(); + expect(harness.runtimeModel.baseUrl).toBe("https://default-runtime.example.com/v1"); + expect(harness.runtimeModel.headers?.["x-base"]).toBe("base"); + expectProtectedRuntimeValue( + harness.runtimeModel.headers?.["x-profile-token"], + "default-profile-token", + ); + + await controller.advanceAuthProfile(); + + expect(harness.runtimeModel.baseUrl).toBe("https://old.example.com/v1"); + expect(harness.runtimeModel.headers).toEqual({ "x-base": "base" }); + expect(setRuntimeApiKey).toHaveBeenLastCalledWith("custom-openai", "backup-source-key"); + }); + it("unwraps a sentinel for runtime auth exchange but keeps auth storage opaque", async () => { const harness = createMutableAuthControllerHarness(); const setRuntimeApiKey = vi.fn<(provider: string, apiKey: string) => void>(); @@ -363,6 +494,51 @@ describe("createEmbeddedRunAuthController", () => { }); }); + it("only enables transient cooldown probing when every automatic profile is transiently cooled", () => { + const now = Date.now(); + const createStore = ( + usageStats: NonNullable, + ): AuthProfileStore => ({ + version: 1, + profiles: { + first: { type: "api_key", provider: "custom-openai", key: "first-key" }, + second: { type: "api_key", provider: "custom-openai", key: "second-key" }, + }, + usageStats, + }); + const resolve = (authStore: AuthProfileStore) => + resolveEmbeddedAuthCooldownProbePolicy({ + authStore, + profileCandidates: ["first", "second"], + modelId: "test-model", + allowTransientCooldownProbe: true, + }); + + expect( + resolve( + createStore({ + first: { disabledUntil: now + 60_000, disabledReason: "rate_limit" }, + }), + ), + ).toEqual({ allowProbe: false, unavailableReason: null }); + expect( + resolve( + createStore({ + first: { disabledUntil: now + 60_000, disabledReason: "billing" }, + second: { disabledUntil: now + 60_000, disabledReason: "billing" }, + }), + ), + ).toEqual({ allowProbe: false, unavailableReason: "billing" }); + expect( + resolve( + createStore({ + first: { disabledUntil: now + 60_000, disabledReason: "rate_limit" }, + second: { disabledUntil: now + 60_000, disabledReason: "rate_limit" }, + }), + ), + ).toEqual({ allowProbe: true, unavailableReason: "rate_limit" }); + }); + it("rejects privileged runtime transport overrides on the first auth exchange", async () => { let runtimeModel = createTestModel(); diff --git a/src/agents/embedded-agent-runner/run/auth-controller.ts b/src/agents/embedded-agent-runner/run/auth-controller.ts index 0f0526eebd5e..8837f7306c39 100644 --- a/src/agents/embedded-agent-runner/run/auth-controller.ts +++ b/src/agents/embedded-agent-runner/run/auth-controller.ts @@ -4,6 +4,7 @@ import type { ThinkLevel } from "../../../auto-reply/thinking.js"; import { formatErrorMessage } from "../../../infra/errors.js"; import type { Model } from "../../../llm/types.js"; +import type { ProviderModelRouteAuthRequirement } from "../../../plugin-sdk/provider-model-types.js"; import { prepareProviderRuntimeAuth } from "../../../plugins/provider-runtime.js"; import { type AuthProfileStore, @@ -18,12 +19,13 @@ import { type FailoverReason, } from "../../embedded-agent-helpers.js"; import { FailoverError, resolveFailoverStatus } from "../../failover-error.js"; -import { shouldAllowCooldownProbeForReason } from "../../failover-policy.js"; +import { shouldUseTransientCooldownProbeSlot } from "../../failover-policy.js"; import { getApiKeyForModel, MissingProviderAuthError, type ResolvedProviderAuth, } from "../../model-auth.js"; +import { providerModelRouteAcceptsAuthMode } from "../../provider-model-route-auth.js"; import { applyPreparedRuntimeAuthToModel, type ModelProviderRequestTransportOverrides, @@ -53,6 +55,38 @@ type LogLike = { warn(message: string): void; }; +/** Decides whether one automatic profile may bypass its current cooldown. */ +export function resolveEmbeddedAuthCooldownProbePolicy(params: { + authStore: AuthProfileStore; + profileCandidates: Array; + lockedProfileId?: string; + modelId: string; + allowTransientCooldownProbe: boolean; +}): { allowProbe: boolean; unavailableReason: FailoverReason | null } { + const autoProfileCandidates = params.profileCandidates.filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.length > 0 && candidate !== params.lockedProfileId, + ); + const allAutoProfilesInCooldown = + autoProfileCandidates.length > 0 && + autoProfileCandidates.every((candidate) => + isProfileInCooldown(params.authStore, candidate, undefined, params.modelId), + ); + const unavailableReason = allAutoProfilesInCooldown + ? (resolveProfilesUnavailableReason({ + store: params.authStore, + profileIds: autoProfileCandidates, + }) ?? "unknown") + : null; + return { + allowProbe: + params.allowTransientCooldownProbe && + allAutoProfilesInCooldown && + shouldUseTransientCooldownProbeSlot(unavailableReason), + unavailableReason, + }; +} + /** * Coordinates auth profile selection, runtime auth preparation/refresh, and * profile failover for one embedded run. State is injected through accessors so @@ -86,9 +120,36 @@ export function createEmbeddedRunAuthController(params: { setRuntimeAuthRefreshCancelled(next: boolean): void; getProfileIndex(): number; setProfileIndex(next: number): void; + prepareModelForAuthProfile?( + profileId: string | undefined, + attemptIndex?: number, + ): Promise<{ + runtimeModel: Model; + authRequirement?: ProviderModelRouteAuthRequirement; + allowAuthProfileFallback?: boolean; + commit(): void; + }>; setThinkLevel(next: ThinkLevel): void; log: LogLike; }) { + // Runtime auth overlays are profile-scoped. Keep the pre-auth model so a + // later profile cannot inherit an earlier profile's endpoint or headers. + const baseRuntimeModel = params.getRuntimeModel(); + const baseEffectiveModel = params.getEffectiveModel(); + + const commitPreparedModel = ( + preparedModel: + | Awaited>> + | undefined, + ) => { + preparedModel?.commit(); + if (preparedModel?.authRequirement) { + return; + } + params.setRuntimeModel(baseRuntimeModel); + params.setEffectiveModel(baseEffectiveModel); + }; + const applyPreparedRuntimeRequestOverrides = (paramsForApply: { runtimeModel: Model; preparedAuth: { @@ -376,28 +437,51 @@ export function createEmbeddedRunAuthController(params: { throw new Error(message); }; - const resolveApiKeyForCandidate = async (candidate?: string) => { + const resolveApiKeyForCandidate = async ( + candidate?: string, + model = params.getRuntimeModel(), + allowAuthProfileFallback?: boolean, + ) => { return getApiKeyForModel({ - model: params.getRuntimeModel(), + model, cfg: params.config, profileId: candidate, store: params.authStore, agentDir: params.agentDir, workspaceDir: params.workspaceDir, lockedProfile: candidate != null && candidate === params.lockedProfileId, + allowAuthProfileFallback, secretSentinels: true, }); }; - const applyApiKeyInfo = async (candidate?: string): Promise => { - const apiKeyInfo = await resolveApiKeyForCandidate(candidate); + const applyApiKeyInfo = async (candidate?: string, attemptIndex?: number): Promise => { + const preparedModel = await params.prepareModelForAuthProfile?.(candidate, attemptIndex); + const apiKeyInfo = await resolveApiKeyForCandidate( + candidate, + preparedModel?.runtimeModel, + preparedModel?.allowAuthProfileFallback, + ); + if ( + preparedModel?.authRequirement && + !providerModelRouteAcceptsAuthMode({ + requirement: preparedModel.authRequirement, + mode: apiKeyInfo.mode ?? (apiKeyInfo.apiKey ? "api-key" : undefined), + }) + ) { + throw new Error( + `Resolved ${apiKeyInfo.mode ?? "unknown"} credentials are incompatible with the selected ${preparedModel.authRequirement} route for ${preparedModel.runtimeModel.provider}.`, + ); + } + // Preserve the checked source even when resolution fails before route commit. params.setApiKeyInfo(apiKeyInfo); const resolvedProfileId = apiKeyInfo.profileId ?? candidate; if (!apiKeyInfo.apiKey) { if (apiKeyInfo.mode !== "aws-sdk") { - const runtimeModel = params.getRuntimeModel(); + const runtimeModel = preparedModel?.runtimeModel ?? params.getRuntimeModel(); throw new MissingProviderAuthError(runtimeModel.provider, apiKeyInfo); } + commitPreparedModel(preparedModel); // AWS SDK auth via IMDS / instance role / ECS task role: no explicit API // key is available but the SDK default credential chain can resolve // credentials at runtime. We must still call setRuntimeApiKey so that @@ -445,6 +529,7 @@ export function createEmbeddedRunAuthController(params: { params.setLastProfileId(resolvedProfileId); return; } + commitPreparedModel(preparedModel); let runtimeAuthHandled = false; const runtimeModel = params.getRuntimeModel(); const preparedAuth = await prepareRuntimeAuthForModel({ @@ -492,7 +577,7 @@ export function createEmbeddedRunAuthController(params: { continue; } try { - await applyApiKeyInfo(candidate); + await applyApiKeyInfo(candidate, nextIndex); params.setProfileIndex(nextIndex); params.setThinkLevel(params.initialThinkLevel); params.attemptedThinking.clear(); @@ -509,28 +594,14 @@ export function createEmbeddedRunAuthController(params: { const initializeAuthProfile = async () => { try { - const autoProfileCandidates = params.profileCandidates.filter( - (candidate): candidate is string => - typeof candidate === "string" && - candidate.length > 0 && - candidate !== params.lockedProfileId, - ); const modelId = params.getModelId(); - const allAutoProfilesInCooldown = - autoProfileCandidates.length > 0 && - autoProfileCandidates.every((candidate) => - isProfileInCooldown(params.authStore, candidate, undefined, modelId), - ); - const unavailableReason = allAutoProfilesInCooldown - ? (resolveProfilesUnavailableReason({ - store: params.authStore, - profileIds: autoProfileCandidates, - }) ?? "unknown") - : null; - const allowTransientCooldownProbe = - params.allowTransientCooldownProbe && - allAutoProfilesInCooldown && - shouldAllowCooldownProbeForReason(unavailableReason); + const cooldownProbePolicy = resolveEmbeddedAuthCooldownProbePolicy({ + authStore: params.authStore, + profileCandidates: params.profileCandidates, + lockedProfileId: params.lockedProfileId, + modelId, + allowTransientCooldownProbe: params.allowTransientCooldownProbe, + }); let didTransientCooldownProbe = false; while (params.getProfileIndex() < params.profileCandidates.length) { @@ -540,17 +611,20 @@ export function createEmbeddedRunAuthController(params: { candidate !== params.lockedProfileId && isProfileInCooldown(params.authStore, candidate, undefined, modelId); if (inCooldown) { - if (allowTransientCooldownProbe && !didTransientCooldownProbe) { + if (cooldownProbePolicy.allowProbe && !didTransientCooldownProbe) { didTransientCooldownProbe = true; params.log.warn( - `probing cooldowned auth profile for ${params.getProvider()}/${modelId} due to ${unavailableReason ?? "transient"} unavailability`, + `probing cooldowned auth profile for ${params.getProvider()}/${modelId} due to ${cooldownProbePolicy.unavailableReason ?? "transient"} unavailability`, ); } else { params.setProfileIndex(params.getProfileIndex() + 1); continue; } } - await applyApiKeyInfo(params.profileCandidates[params.getProfileIndex()]); + await applyApiKeyInfo( + params.profileCandidates[params.getProfileIndex()], + params.getProfileIndex(), + ); break; } if (params.getProfileIndex() >= params.profileCandidates.length) { @@ -593,6 +667,7 @@ export function createEmbeddedRunAuthController(params: { }; return { + applyAuthProfileCandidate: applyApiKeyInfo, advanceAuthProfile, initializeAuthProfile, maybeRefreshRuntimeAuthForAuthError, diff --git a/src/agents/harness/compaction.ts b/src/agents/harness/compaction.ts index 089fd7790654..0134d88129d3 100644 --- a/src/agents/harness/compaction.ts +++ b/src/agents/harness/compaction.ts @@ -2,8 +2,6 @@ import type { Model } from "openclaw/plugin-sdk/llm"; /** * Routes compaction through selected native agent harnesses when supported. */ -import { formatErrorMessage } from "../../infra/errors.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveUserPath } from "../../utils.js"; import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; @@ -11,14 +9,39 @@ import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js"; import type { CompactEmbeddedAgentSessionParams } from "../embedded-agent-runner/compact.types.js"; import { resolveModelAsync } from "../embedded-agent-runner/model.js"; import type { EmbeddedAgentCompactResult } from "../embedded-agent-runner/types.js"; -import { applySecretRefHeaderSentinels, getApiKeyForModel } from "../model-auth.js"; +import { + applySecretRefHeaderSentinels, + ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles, +} from "../model-auth.js"; import { isCliRuntimeAliasForProvider, isCliRuntimeProvider } from "../model-runtime-aliases.js"; +import { isOpenAIProvider } from "../openai-routing.js"; import { unwrapModelHeaderSentinelsForProviderEgress, unwrapSecretSentinelsForProviderEgress, } from "../provider-secret-egress.js"; +import { materializePreparedRuntimeModel } from "../runtime-plan/materialize-model.js"; +import { + agentRuntimeAuthPlanMatchesTarget, + prepareAgentRuntimeAuth, + type PreparedAgentRuntimeAuth, + type PreparedAgentRuntimeAuthAttempt, +} from "../runtime-plan/prepare-auth.js"; +import { + resolvePreparedRuntimeAuthAttempts, + resolvePreparedRuntimeModelAuth, +} from "../runtime-plan/resolve-auth.js"; +import type { AgentRuntimeAuthPlan } from "../runtime-plan/types.js"; import { resolveAgentHarnessPolicy as resolveConfiguredAgentHarnessPolicy } from "./policy.js"; -import { selectAgentHarness } from "./selection.js"; +import { + selectAgentHarness, + selectAgentHarnessForPreparedModelProviders, + type AgentHarnessPreparedModelProvider, +} from "./selection.js"; +import { + resolveAgentHarnessPreparedAuthSupport, + resolveAgentHarnessPreparedRouteSupport, +} from "./support.js"; import type { AgentHarness, AgentHarnessCompactParams, @@ -31,8 +54,6 @@ import type { * CLI runtimes and OpenClaw-native compaction stay on the embedded runner path; plugin harnesses * can opt in through their `compact` hook. */ -const log = createSubsystemLogger("agents/harness"); - type NativeCompactionRequest = "after_context_engine"; type InternalAgentHarnessCompactionOptions = { @@ -48,6 +69,11 @@ type InternalAgentHarnessCompactionCapability = { }; type InternalAgentHarness = AgentHarness & InternalAgentHarnessCompactionCapability; +type HarnessCompactionResolvedAuth = { apiKey?: string }; + +function runtimePlanRequiresHostApiKey(plan?: AgentRuntimeAuthPlan): boolean { + return plan?.modelRoute?.authRequirement === "api-key"; +} function resolveHarnessCompactIdentity(params: CompactEmbeddedAgentSessionParams): { agentDir: string; @@ -64,53 +90,271 @@ function resolveHarnessCompactIdentity(params: CompactEmbeddedAgentSessionParams }; } +function stripHarnessOwnedAuthInputs( + params: CompactEmbeddedAgentSessionParams, +): CompactEmbeddedAgentSessionParams { + const result = { ...params }; + delete result.resolvedApiKey; + delete result.runtimeModel; + return result; +} + +function buildHarnessCompactionModelProvider(params: { + model?: Model; + plan?: AgentRuntimeAuthPlan; + attempt?: PreparedAgentRuntimeAuthAttempt; +}): AgentHarnessPreparedModelProvider { + const route = params.plan?.modelRoute; + return { + api: route?.api ?? params.model?.api, + baseUrl: route?.baseUrl ?? params.model?.baseUrl, + ...resolveAgentHarnessPreparedRouteSupport(params.plan), + ...(params.plan + ? { + preparedAuth: resolveAgentHarnessPreparedAuthSupport({ + plan: params.plan, + source: params.attempt?.kind === "implicit" ? undefined : params.attempt?.kind, + }), + } + : {}), + }; +} + async function resolveHarnessCompactApiKey(params: { agentDir: string; compactParams: CompactEmbeddedAgentSessionParams; -}): Promise<{ apiKey?: string; runtimeModel?: Model }> { - const { agentDir, compactParams } = params; - const existing = compactParams.resolvedApiKey?.trim(); + initialHarness: AgentHarness; + agentId: string; + sessionKey?: string; + pinnedHarnessId?: string; +}): Promise<{ + harness: AgentHarness; + apiKey?: string; + runtimeModel?: Model; + runtimeAuthPlan?: AgentRuntimeAuthPlan; +}> { + const { agentDir, compactParams, initialHarness } = params; if (!compactParams.provider?.trim() || !compactParams.model?.trim()) { - return existing ? { apiKey: existing } : {}; + const existing = compactParams.resolvedApiKey?.trim(); + return existing ? { harness: initialHarness, apiKey: existing } : { harness: initialHarness }; } - const authProfileId = compactParams.authProfileId?.trim() || undefined; + const provider = compactParams.provider; + const modelId = compactParams.model; + const providedRuntimeAuthPlan = compactParams.runtimeAuthPlan ?? compactParams.runtimePlan?.auth; + const reusableRuntimeAuthPlan = + providedRuntimeAuthPlan && + agentRuntimeAuthPlanMatchesTarget(providedRuntimeAuthPlan, { provider, modelId }) + ? providedRuntimeAuthPlan + : undefined; const workspaceDir = resolveUserPath(compactParams.workspaceDir); - const { model } = await resolveModelAsync( - compactParams.provider, - compactParams.model, - agentDir, - compactParams.config, - { - authProfileId, + const callerRuntimeModel = compactParams.runtimeModel; + const fallbackResolution = ( + harness: AgentHarness, + runtimeModel?: Model, + runtimeAuthPlan?: AgentRuntimeAuthPlan, + ) => { + if (harness.authBootstrap === "harness" && !runtimeAuthPlan) { + throw new Error( + `Unable to prepare a route-locked native compaction attempt for ${provider}/${modelId}; refusing harness-owned ambient auth.`, + ); + } + const apiKey = compactParams.resolvedApiKey?.trim() || undefined; + return { + harness, + ...(apiKey ? { apiKey } : {}), + ...(runtimeModel ? { runtimeModel } : {}), + ...(runtimeAuthPlan ? { runtimeAuthPlan } : {}), + }; + }; + const selectPreparedHarness = ( + attempts: readonly PreparedAgentRuntimeAuthAttempt[], + preparedModel?: Model, + ) => + selectAgentHarnessForPreparedModelProviders({ + provider, + modelId, + modelProviders: attempts.map((attempt) => + buildHarnessCompactionModelProvider({ + model: preparedModel, + plan: attempt.plan, + attempt, + }), + ), + config: compactParams.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + agentHarnessId: params.pinnedHarnessId, + }); + if (reusableRuntimeAuthPlan) { + const reusableAttempts = [{ kind: "implicit" as const, plan: reusableRuntimeAuthPlan }]; + const reusableHarness = selectPreparedHarness(reusableAttempts, callerRuntimeModel); + if ( + (reusableHarness.authBootstrap === "harness" || + reusableRuntimeAuthPlan.harnessAuthProvider) && + !runtimePlanRequiresHostApiKey(reusableRuntimeAuthPlan) + ) { + return fallbackResolution(reusableHarness, callerRuntimeModel, reusableRuntimeAuthPlan); + } + } + const resolvePreparedModel = ({ + config, + authProfileId: profileId, + authProfileMode, + }: Parameters>[0]["resolveModel"]>[0]) => + resolveModelAsync(provider, modelId, agentDir, config, { + authProfileId: profileId, + authProfileMode, + skipAgentDiscovery: true, + allowBundledStaticCatalogFallback: true, + preferBundledStaticCatalogTransport: true, workspaceDir, - }, - ); + }); + let model = callerRuntimeModel; if (!model) { - return existing ? { apiKey: existing } : {}; + try { + model = ( + await resolveModelAsync(provider, modelId, agentDir, compactParams.config, { + authProfileId: + reusableRuntimeAuthPlan?.forwardedAuthProfileId ?? + compactParams.authProfileId?.trim() ?? + undefined, + workspaceDir, + }) + ).model; + } catch { + return fallbackResolution(initialHarness); + } } - const runtimeModel = applySecretRefHeaderSentinels(model, compactParams.config); - if (existing) { - return { apiKey: existing, runtimeModel }; + if (!model) { + return fallbackResolution(initialHarness); } - try { - const apiKeyInfo = await getApiKeyForModel({ - model: runtimeModel, - cfg: compactParams.config, - profileId: authProfileId, + const runtimeAuthProfileStore = isOpenAIProvider(provider) + ? ensureAuthProfileStore(agentDir, { + externalCliProviderIds: ["openai"], + allowKeychainPrompt: false, + }) + : ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }); + const prepareRuntimeAuth = (harness: AgentHarness) => + prepareAgentRuntimeAuth({ + provider, + modelId, + modelApi: model.api, + modelBaseUrl: model.baseUrl, + config: compactParams.config, + env: process.env, agentDir, workspaceDir, - secretSentinels: true, + authProfileStore: runtimeAuthProfileStore, + sessionAuthProfileId: compactParams.authProfileId, + sessionAuthProfileSource: compactParams.authProfileIdSource, + harnessId: harness.id, + harnessRuntime: harness.id, + harnessAuthBootstrap: harness.authBootstrap, }); - return { - apiKey: apiKeyInfo.apiKey?.trim() || undefined, - runtimeModel, + let preparation: PreparedAgentRuntimeAuth; + if (reusableRuntimeAuthPlan) { + preparation = { + plan: reusableRuntimeAuthPlan, + attempts: [{ kind: "implicit", plan: reusableRuntimeAuthPlan }], }; - } catch (err) { - log.debug("agent harness compaction credential lookup failed", { - error: formatErrorMessage(err), - }); - return { runtimeModel }; + } else { + try { + preparation = prepareRuntimeAuth(initialHarness); + } catch { + return fallbackResolution(initialHarness, model); + } } + let harness = params.pinnedHarnessId + ? initialHarness + : selectPreparedHarness(preparation.attempts, model); + if (!params.pinnedHarnessId && !reusableRuntimeAuthPlan && harness.id !== initialHarness.id) { + try { + preparation = prepareRuntimeAuth(harness); + } catch { + return fallbackResolution(harness, model); + } + const confirmedHarness = selectPreparedHarness(preparation.attempts, model); + if (confirmedHarness.id !== harness.id) { + throw new Error( + `Prepared native compaction auth routes did not converge on one agent harness for ${provider}/${modelId}.`, + ); + } + harness = confirmedHarness; + } + const materializeModel = async (input: { + plan: AgentRuntimeAuthPlan; + model: Model; + forceResolve?: boolean; + }) => { + const materialized = await materializePreparedRuntimeModel({ + plan: input.plan, + provider, + modelId, + config: compactParams.config, + model: input.model, + forceResolve: input.forceResolve, + rejectMismatchedModel: true, + resolveModel: resolvePreparedModel, + }); + if (!materialized) { + throw new Error(`Unable to materialize ${provider}/${modelId} for native compaction.`); + } + return applySecretRefHeaderSentinels(materialized, compactParams.config); + }; + let resolved; + try { + resolved = await resolvePreparedRuntimeAuthAttempts({ + attempts: preparation.attempts, + store: runtimeAuthProfileStore, + modelId, + model, + materializeModel, + resolveAuth: async ({ attempt, model: attemptModel }) => { + if ( + (harness.authBootstrap === "harness" || attempt.plan.harnessAuthProvider) && + !runtimePlanRequiresHostApiKey(attempt.plan) + ) { + return { plan: attempt.plan, auth: {} }; + } + const hasAutomaticPreparedCandidates = + attempt.plan.forwardedAuthProfileSource === "auto" && + Boolean( + attempt.plan.forwardedAuthProfileId || + attempt.plan.forwardedAuthProfileCandidateIds?.length, + ); + const existing = hasAutomaticPreparedCandidates + ? undefined + : compactParams.resolvedApiKey?.trim(); + if (existing) { + return { plan: attempt.plan, auth: { apiKey: existing } }; + } + const auth = await resolvePreparedRuntimeModelAuth({ + plan: attempt.plan, + model: attemptModel, + cfg: compactParams.config, + store: runtimeAuthProfileStore, + agentDir, + workspaceDir, + ...(attempt.allowAuthProfileFallback !== undefined + ? { allowAuthProfileFallback: attempt.allowAuthProfileFallback } + : {}), + secretSentinels: true, + }); + return { plan: auth.plan, auth: { apiKey: auth.auth.apiKey?.trim() || undefined } }; + }, + errorMessage: `Prepared native compaction auth attempts could not be resolved for ${provider}/${modelId}.`, + }); + } catch { + return fallbackResolution(harness, model, preparation.plan); + } + return { + harness, + apiKey: resolved.auth.apiKey, + runtimeModel: resolved.model, + runtimeAuthPlan: resolved.plan, + }; } /** Runs harness-provided compaction when the selected runtime supports it. */ @@ -133,6 +377,22 @@ export async function maybeCompactAgentHarnessSession( params.sandboxSessionKey && parseAgentSessionKey(params.sandboxSessionKey) ? undefined : params.agentId; + const runtimeAuthPlan = params.runtimeAuthPlan ?? params.runtimePlan?.auth; + const modelRoute = runtimeAuthPlan?.modelRoute; + if ( + runtimeAuthPlan && + modelRoute && + (!params.provider || + !params.model || + !agentRuntimeAuthPlanMatchesTarget(runtimeAuthPlan, { + provider: params.provider, + modelId: params.model, + })) + ) { + throw new Error( + `Prepared runtime auth route ${modelRoute.provider}/${modelRoute.modelId} does not match the compaction target ${params.provider ?? "unknown"}/${params.model ?? "unknown"}.`, + ); + } const runtime = resolveConfiguredAgentHarnessPolicy({ provider: params.provider, modelId: params.model, @@ -149,14 +409,68 @@ export async function maybeCompactAgentHarnessSession( ) { return undefined; } - const harness = selectAgentHarness({ + const harnessSelectionParams = { provider: params.provider ?? "", modelId: params.model, config: params.config, agentId: runtimePolicyAgentId, sessionKey: runtimePolicySessionKey, agentHarnessId: pinnedHarnessId, + }; + let harness = runtimeAuthPlan + ? selectAgentHarnessForPreparedModelProviders({ + ...harnessSelectionParams, + modelProviders: [ + buildHarnessCompactionModelProvider({ + model: params.runtimeModel, + plan: runtimeAuthPlan, + }), + ], + }) + : selectAgentHarness(harnessSelectionParams); + const initialInternalHarness = harness as InternalAgentHarness; + if ( + options.nativeCompactionRequest === "after_context_engine" && + !initialInternalHarness.compactAfterContextEngine + ) { + return undefined; + } + if (!options.nativeCompactionRequest && !harness.compact) { + if (harness.id !== "openclaw") { + return { + ok: false, + compacted: false, + reason: `Agent harness "${harness.id}" does not support compaction.`, + failure: { reason: "unsupported_harness_compaction" }, + }; + } + return undefined; + } + const compactIdentity = resolveHarnessCompactIdentity(params); + let resolvedRuntimeAuthPlan = runtimeAuthPlan; + const compactParams = { + ...params, + agentDir: compactIdentity.agentDir, + agentId: compactIdentity.agentId, + ...(resolvedRuntimeAuthPlan + ? { + runtimeAuthPlan: resolvedRuntimeAuthPlan, + ...(params.runtimePlan + ? { runtimePlan: { ...params.runtimePlan, auth: resolvedRuntimeAuthPlan } } + : {}), + } + : {}), + }; + const resolved = await resolveHarnessCompactApiKey({ + agentDir: compactIdentity.agentDir, + compactParams, + initialHarness: harness, + agentId: compactIdentity.agentId, + sessionKey: runtimePolicySessionKey, + pinnedHarnessId, }); + harness = resolved.harness; + resolvedRuntimeAuthPlan = resolved.runtimeAuthPlan ?? resolvedRuntimeAuthPlan; const internalHarness = harness as InternalAgentHarness; const shouldCompactAfterContextEngine = options.nativeCompactionRequest === "after_context_engine"; @@ -174,30 +488,35 @@ export async function maybeCompactAgentHarnessSession( } return undefined; } - const compactIdentity = resolveHarnessCompactIdentity(params); - const compactParams = { - ...params, - agentDir: compactIdentity.agentDir, - agentId: compactIdentity.agentId, - }; - let resolvedApiKey = compactParams.resolvedApiKey?.trim() || undefined; - let runtimeModel: Model | undefined; - try { - const resolved = await resolveHarnessCompactApiKey({ - agentDir: compactIdentity.agentDir, - compactParams, - }); - resolvedApiKey = resolved.apiKey; - runtimeModel = resolved.runtimeModel; - } catch (err) { - log.debug("agent harness compaction credential lookup failed", { - error: formatErrorMessage(err), - }); - } + // Native runtimes own subscription login, but a provider-locked Platform + // route must receive the exact host-prepared key selected for this attempt. + const harnessOwnsAuth = + harness.authBootstrap === "harness" && !runtimePlanRequiresHostApiKey(resolvedRuntimeAuthPlan); + const resolvedApiKey = harnessOwnsAuth ? undefined : resolved.apiKey; + const runtimeModel = resolved.runtimeModel; + const compactParamsWithResolvedAuth = resolvedRuntimeAuthPlan + ? { + ...compactParams, + authProfileId: resolvedRuntimeAuthPlan.forwardedAuthProfileId, + authProfileIdSource: resolvedRuntimeAuthPlan.forwardedAuthProfileSource, + runtimeAuthPlan: resolvedRuntimeAuthPlan, + ...(compactParams.runtimePlan + ? { + runtimePlan: { + ...compactParams.runtimePlan, + auth: resolvedRuntimeAuthPlan, + }, + } + : {}), + } + : compactParams; + const handoffCompactParams = harnessOwnsAuth + ? stripHarnessOwnedAuthInputs(compactParamsWithResolvedAuth) + : compactParamsWithResolvedAuth; const resolvedCompactParams = resolvedApiKey || runtimeModel ? { - ...compactParams, + ...handoffCompactParams, ...(resolvedApiKey ? { resolvedApiKey: unwrapSecretSentinelsForProviderEgress( @@ -215,7 +534,7 @@ export async function maybeCompactAgentHarnessSession( } : {}), } - : compactParams; + : handoffCompactParams; if (shouldCompactAfterContextEngine) { return internalHarness.compactAfterContextEngine?.(resolvedCompactParams); } diff --git a/src/agents/harness/policy.test.ts b/src/agents/harness/policy.test.ts new file mode 100644 index 000000000000..a8f15287c97d --- /dev/null +++ b/src/agents/harness/policy.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveAgentHarnessPolicy } from "./policy.js"; + +function openAIProviderConfig(overrides: Record): OpenClawConfig { + return { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [], + ...overrides, + }, + }, + }, + } as OpenClawConfig; +} + +describe("resolveAgentHarnessPolicy", () => { + it.each([ + { + name: "official Responses route", + params: { config: openAIProviderConfig({}) }, + runtime: "codex", + }, + { + name: "HTTP official Responses route", + params: { config: openAIProviderConfig({ baseUrl: "http://api.openai.com/v1" }) }, + runtime: "openclaw", + }, + { + name: "HTTP official ChatGPT route", + params: { + config: openAIProviderConfig({ + api: "openai-chatgpt-responses", + baseUrl: "http://chatgpt.com/backend-api/codex", + }), + }, + runtime: "openclaw", + }, + { + name: "custom endpoint", + params: { config: openAIProviderConfig({ baseUrl: "https://relay.example.test/v1" }) }, + runtime: "openclaw", + }, + { + name: "authored Completions route", + params: { config: openAIProviderConfig({ api: "openai-completions" }) }, + runtime: "openclaw", + }, + { + name: "request override", + params: { config: openAIProviderConfig({ headers: { "x-route": "custom" } }) }, + runtime: "openclaw", + }, + ])("uses the provider-owned runtime for $name", ({ params, runtime }) => { + expect( + resolveAgentHarnessPolicy({ + provider: "openai", + modelId: "gpt-5.5", + env: {}, + ...params, + }), + ).toEqual({ runtime, runtimeSource: "implicit" }); + }); + + it("keeps explicit runtime policy authoritative", () => { + const config = openAIProviderConfig({ agentRuntime: { id: "codex" } }); + config.agents = { defaults: { params: { temperature: 0.2 } } }; + expect( + resolveAgentHarnessPolicy({ + provider: "openai", + modelId: "gpt-5.5", + config, + env: {}, + }), + ).toEqual({ runtime: "codex", runtimeSource: "provider" }); + }); + + it.each(["default", "auto"] as const)( + "treats configured %s runtime policy as implicit route selection", + (runtime) => { + expect( + resolveAgentHarnessPolicy({ + provider: "anthropic", + modelId: "claude-sonnet-4-6", + config: { + models: { + providers: { + anthropic: { + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + agentRuntime: { id: runtime }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + }), + ).toEqual({ runtime: "auto", runtimeSource: "implicit" }); + expect( + resolveAgentHarnessPolicy({ + provider: "openai", + modelId: "gpt-5.5", + config: openAIProviderConfig({ agentRuntime: { id: runtime } }), + env: {}, + }), + ).toEqual({ runtime: "codex", runtimeSource: "implicit" }); + const customConfig = openAIProviderConfig({ + baseUrl: "https://relay.example.test/v1", + }); + customConfig.agents = { + defaults: { + models: { "openai/gpt-5.5": { agentRuntime: { id: runtime } } }, + }, + }; + expect( + resolveAgentHarnessPolicy({ + provider: "openai", + modelId: "gpt-5.5", + config: customConfig, + env: {}, + }), + ).toEqual({ runtime: "openclaw", runtimeSource: "implicit" }); + }, + ); + + it.each([ + { + name: "global params", + agents: { defaults: { params: { temperature: 0.2 } } }, + agentId: undefined, + sessionKey: undefined, + }, + { + name: "model params", + agents: { + defaults: { + models: { "openai/gpt-5.5": { params: { text_verbosity: "low" } } }, + }, + }, + agentId: undefined, + sessionKey: undefined, + }, + { + name: "agent params", + agents: { list: [{ id: "writer", params: { temperature: 0.2 } }] }, + agentId: "writer", + sessionKey: undefined, + }, + { + name: "session agent params", + agents: { list: [{ id: "writer", params: { temperature: 0.2 } }] }, + agentId: undefined, + sessionKey: "agent:writer:main", + }, + ])("keeps $name on OpenClaw", ({ agents, agentId, sessionKey }) => { + const config = openAIProviderConfig({}); + config.agents = agents; + expect( + resolveAgentHarnessPolicy({ + provider: "openai", + modelId: "gpt-5.5", + config, + agentId, + sessionKey, + env: {}, + }), + ).toEqual({ runtime: "openclaw", runtimeSource: "implicit" }); + }); + + it("keeps prepared request overrides on OpenClaw", () => { + expect( + resolveAgentHarnessPolicy({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "present", + env: {}, + }), + ).toEqual({ runtime: "openclaw", runtimeSource: "implicit" }); + }); + + it("applies global request params before a concrete model is selected", () => { + const config = openAIProviderConfig({}); + config.agents = { defaults: { params: { temperature: 0.2 } } }; + expect(resolveAgentHarnessPolicy({ provider: "openai", config, env: {} })).toEqual({ + runtime: "openclaw", + runtimeSource: "implicit", + }); + }); + + it.each([ + { + name: "later route facts fill an omitted adapter", + models: [{ id: "gpt-5.5" }, { id: "gpt-5.5", api: "openai-completions" }], + runtime: "openclaw", + }, + { + name: "a provider-looking native id stays distinct", + models: [ + { id: "openai/gpt-5.5", api: "openai-responses" }, + { id: "gpt-5.5", api: "openai-completions" }, + ], + runtime: "openclaw", + }, + { + name: "an authored empty header map stays authoritative", + models: [ + { id: "gpt-5.5", headers: {} }, + { id: "gpt-5.5", headers: { "x-route": "custom" } }, + ], + runtime: "codex", + }, + { + name: "later headers fill an omitted header map", + models: [{ id: "gpt-5.5" }, { id: "gpt-5.5", headers: { "x-route": "custom" } }], + runtime: "openclaw", + }, + ])("keeps duplicate model config aligned: $name", ({ models, runtime }) => { + expect( + resolveAgentHarnessPolicy({ + provider: "openai", + modelId: "gpt-5.5", + config: openAIProviderConfig({ models }), + env: {}, + }), + ).toEqual({ runtime, runtimeSource: "implicit" }); + }); +}); diff --git a/src/agents/harness/policy.ts b/src/agents/harness/policy.ts index 1179d37f40ce..24d01f9ea16d 100644 --- a/src/agents/harness/policy.ts +++ b/src/agents/harness/policy.ts @@ -2,10 +2,11 @@ * Resolves configured native harness policy for agent ids. */ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { ProviderRouteOverridePresence } from "../../plugin-sdk/provider-model-types.js"; import { AUTO_AGENT_RUNTIME_ID, type EmbeddedAgentRuntime } from "../agent-runtime-id.js"; import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { resolveModelRuntimePolicy } from "../model-runtime-policy.js"; -import { openAIProviderUsesCodexRuntimeByDefault } from "../openai-routing.js"; +import { resolveOpenAIImplicitAgentRuntime } from "../openai-routing.js"; /** * Effective runtime policy for selecting the agent harness that should execute a turn. @@ -19,6 +20,9 @@ export type AgentHarnessPolicy = { export function resolveAgentHarnessPolicy(params: { provider?: string; modelId?: string; + modelApi?: string | null; + modelBaseUrl?: unknown; + requestTransportOverrides?: ProviderRouteOverridePresence; config?: OpenClawConfig; agentId?: string; sessionKey?: string; @@ -32,19 +36,29 @@ export function resolveAgentHarnessPolicy(params: { sessionKey: params.sessionKey, }); const configuredRuntime = normalizeOptionalAgentRuntimeId(configured.policy?.id); - const runtimeSource = configured.source ?? "implicit"; const runtime = configuredRuntime && configuredRuntime !== "default" ? configuredRuntime : AUTO_AGENT_RUNTIME_ID; - if ( - openAIProviderUsesCodexRuntimeByDefault({ provider: params.provider, config: params.config }) - ) { - if (runtime === "auto") { - return { runtime: "codex", runtimeSource }; - } + const runtimeSource = + runtime === AUTO_AGENT_RUNTIME_ID ? "implicit" : (configured.source ?? "implicit"); + if (runtime !== "auto") { return { runtime, runtimeSource }; } + const openAIImplicitRuntime = resolveOpenAIImplicitAgentRuntime({ + provider: params.provider, + modelId: params.modelId, + api: params.modelApi, + baseUrl: params.modelBaseUrl, + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + env: params.env, + requestTransportOverrides: params.requestTransportOverrides, + }); + if (openAIImplicitRuntime) { + return { runtime: openAIImplicitRuntime, runtimeSource }; + } return { runtime, runtimeSource, diff --git a/src/agents/harness/runtime-plugin.ts b/src/agents/harness/runtime-plugin.ts index 54c47f9e2673..f21a8ae62bc7 100644 --- a/src/agents/harness/runtime-plugin.ts +++ b/src/agents/harness/runtime-plugin.ts @@ -2,6 +2,7 @@ * Ensures runtime plugins required by selected native harnesses are installed. */ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { ProviderRouteOverridePresence } from "../../plugin-sdk/provider-model-types.js"; import { withActivatedPluginIds } from "../../plugins/activation-context.js"; import { resolveManifestActivationPlan } from "../../plugins/activation-planner.js"; import { resolveEffectivePluginActivationState } from "../../plugins/config-state.js"; @@ -159,6 +160,7 @@ export async function ensureSelectedAgentHarnessPlugin(params: { sessionKey?: string; agentHarnessId?: string; agentHarnessRuntimeOverride?: string; + requestTransportOverrides?: ProviderRouteOverridePresence; workspaceDir: string; }): Promise { const pinnedHarnessId = normalizeOptionalAgentRuntimeId(params.agentHarnessId); @@ -169,6 +171,7 @@ export async function ensureSelectedAgentHarnessPlugin(params: { config: params.config, agentId: params.agentId, sessionKey: params.sessionKey, + requestTransportOverrides: params.requestTransportOverrides, }); const requestedRuntime = pinnedHarnessId ?? runtimeOverride; const runtime = diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 6d367c03bc90..64f71936dc69 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -23,7 +23,13 @@ import { resolvePluginHarnessPolicyToolsAllow, runAgentHarnessAttempt, selectAgentHarness, + selectAgentHarnessForPreparedModelProviders, } from "./selection.js"; +import { + buildAgentHarnessSupportContext, + resolveAgentHarnessPreparedAuthSupport, + resolveAgentHarnessPreparedRouteSupport, +} from "./support.js"; import type { AgentHarness, AgentHarnessCompactParams, @@ -34,6 +40,8 @@ const agentRunAttempt = vi.fn(async () => createAttemptResult("openclaw"), ); const compactAuthMocks = vi.hoisted(() => ({ + ensureAuthProfileStore: vi.fn(), + ensureAuthProfileStoreWithoutExternalProfiles: vi.fn(), getApiKeyForModel: vi.fn(), resolveModelAsync: vi.fn(), })); @@ -61,8 +69,12 @@ vi.mock("./builtin-openclaw.js", () => ({ runAttempt: agentRunAttempt, }), })); -vi.mock("../model-auth.js", () => ({ +vi.mock("../model-auth.js", async (importOriginal) => ({ + ...(await importOriginal()), applySecretRefHeaderSentinels: (model: unknown) => model, + ensureAuthProfileStore: compactAuthMocks.ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles: + compactAuthMocks.ensureAuthProfileStoreWithoutExternalProfiles, getApiKeyForModel: compactAuthMocks.getApiKeyForModel, })); vi.mock("../embedded-agent-runner/model.js", () => ({ @@ -76,6 +88,11 @@ const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME; beforeEach(() => { clearAgentHarnesses(); + compactAuthMocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} }); + compactAuthMocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ + version: 1, + profiles: {}, + }); compactAuthMocks.resolveModelAsync.mockResolvedValue({ model: { id: "gpt-5.5", provider: "openai" }, }); @@ -113,6 +130,8 @@ afterEach(() => { agentRunAttempt.mockClear(); compactAuthMocks.resolveModelAsync.mockReset(); compactAuthMocks.getApiKeyForModel.mockReset(); + compactAuthMocks.ensureAuthProfileStore.mockReset(); + compactAuthMocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReset(); providerOwnerMocks.resolveProviderRefOwnership.mockReset(); if (originalRuntime == null) { delete process.env.OPENCLAW_AGENT_RUNTIME; @@ -294,6 +313,71 @@ function agentModelRuntimeConfig( } as OpenClawConfig; } +type CompactSessionParams = Parameters[0]; + +const OPENAI_PLATFORM_ROUTE = { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", +} as const; + +const OPENAI_CHATGPT_ROUTE = { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", +} as const; + +function createCompactionParams( + overrides: Partial = {}, +): CompactSessionParams { + return { + sessionId: "session-1", + sessionKey: "agent:main:main", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + provider: "openai", + model: "gpt-5.5", + ...overrides, + }; +} + +function registerTestCompactor( + options: { + id?: string; + provider?: string; + authBootstrap?: AgentHarness["authBootstrap"]; + supports?: AgentHarness["supports"]; + result?: AgentHarnessCompactResult; + } = {}, +) { + const id = options.id ?? "codex"; + const provider = options.provider ?? "openai"; + const compact = vi.fn>( + async () => options.result ?? { ok: true, compacted: false }, + ); + registerAgentHarness( + { + id, + label: id, + supports: + options.supports ?? + ((ctx) => + ctx.provider === provider ? { supported: true, priority: 100 } : { supported: false }), + runAttempt: vi.fn(async () => createAttemptResult(id)), + compact, + ...(options.authBootstrap ? { authBootstrap: options.authBootstrap } : {}), + }, + { ownerPluginId: id }, + ); + return compact; +} + describe("runAgentHarnessAttempt", () => { it.each(["codex", "copilot"] as const)( "binds the host Crestodian tool to the %s SDK construction path without leaking authority", @@ -544,6 +628,59 @@ describe("runAgentHarnessAttempt", () => { expect(agentRunAttempt).not.toHaveBeenCalled(); }); + it("projects deferred route support into the final attempt selection", async () => { + const supports = vi.fn((ctx: Parameters[0]) => + ctx.modelProvider?.preparedAuth?.source === "harness" && + ctx.modelProvider.requestTransportOverrides === "none" && + ctx.modelProvider.runtimePolicy?.compatibleIds.includes("codex") + ? { supported: true as const, priority: 100 } + : { supported: false as const, reason: "prepared route support is missing" }, + ); + registerAgentHarness( + { + id: "codex", + label: "Codex", + supports, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }, + { ownerPluginId: "codex" }, + ); + const params = createAttemptParams(); + params.provider = "openai"; + params.modelId = "gpt-5.5"; + params.model = { + id: "gpt-5.5", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + } as Model; + params.agentHarnessRuntimeOverride = "codex"; + params.runtimePlan = { + auth: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + deferredRouteSupport: { + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + }, + } as never; + + await expect(runAgentHarnessAttempt(params)).resolves.toMatchObject({ + sessionIdUsed: "codex", + }); + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: expect.objectContaining({ + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + preparedAuth: { source: "harness" }, + }), + }), + ); + }); + it("surfaces a forced plugin harness failure instead of replaying through OpenClaw", async () => { registerFailingCodexHarness(); @@ -1068,6 +1205,591 @@ describe("selectAgentHarness", () => { ); }); + it("merges prepared model route facts with configured request policy", () => { + const supports = vi.fn(() => ({ + supported: false as const, + reason: "unsupported test provider", + })); + const config = { + models: { + providers: { + "custom-proxy": { + api: "openai-completions", + baseUrl: "https://provider.example/v1", + request: { auth: { mode: "provider-default" as const } }, + agentRuntime: { id: "copilot" }, + models: [ + { + id: "gpt-test", + name: "GPT Test", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8_192, + maxTokens: 1_024, + }, + ], + }, + }, + }, + } as OpenClawConfig; + registerAgentHarness({ + id: "copilot", + label: "Copilot", + supports, + runAttempt: vi.fn(async () => createAttemptResult("copilot")), + }); + + expect(() => + selectAgentHarness({ + provider: "custom-proxy", + modelId: "gpt-test", + modelProvider: { + api: "openai-responses", + baseUrl: "https://model.example/v1", + }, + config, + agentHarnessRuntimeOverride: "copilot", + }), + ).toThrow("unsupported test provider"); + + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "custom-proxy", + modelId: "gpt-test", + modelProvider: expect.objectContaining({ + api: "openai-responses", + baseUrl: "https://model.example/v1", + requestTransportOverrides: "present", + request: { auth: { mode: "provider-default" } }, + }), + }), + ); + }); + + it("projects a self-qualified model adapter and transport into harness capability checks", () => { + const config = { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "openai/gpt-5.5", + api: "openai-completions", + headers: { "x-model-route": "custom" }, + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + buildAgentHarnessSupportContext({ + provider: "openai", + modelId: "gpt-5.5", + requestedRuntime: "codex", + config, + }).modelProvider, + ).toMatchObject({ + api: "openai-completions", + requestTransportOverrides: "present", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }); + }); + + it("projects canonical model transport overrides for a shipped alias", () => { + const config = { + models: { + providers: { + openai: { + models: [ + { + id: "gpt-5.4", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + headers: { "x-model-route": "custom" }, + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + buildAgentHarnessSupportContext({ + provider: "openai", + modelId: "gpt-5.4-codex", + requestedRuntime: "codex", + config, + }).modelProvider, + ).toMatchObject({ + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "present", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }); + }); + + it("projects provider-owned compatibility for an official OpenAI route", () => { + expect( + buildAgentHarnessSupportContext({ + provider: "openai", + modelId: "gpt-5.5", + modelProvider: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + requestedRuntime: "codex", + }).modelProvider, + ).toMatchObject({ + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }); + }); + + it.each([ + { + label: "default", + config: { agents: { defaults: { params: { store: false } } } }, + identity: {}, + }, + { + label: "model", + config: { + agents: { + defaults: { + models: { "openai/gpt-5.5": { params: { store: false } } }, + }, + }, + }, + identity: {}, + }, + { + label: "agent", + config: { + agents: { list: [{ id: "worker", params: { store: false } }] }, + }, + identity: { sessionKey: "agent:worker:main" }, + }, + ] as const)( + "projects $label agent request params into harness support", + ({ config, identity }) => { + expect( + buildAgentHarnessSupportContext({ + provider: "openai", + modelId: "gpt-5.5", + modelProvider: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "none", + }, + requestedRuntime: "codex", + config: config as OpenClawConfig, + ...identity, + }).modelProvider, + ).toMatchObject({ + requestTransportOverrides: "present", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }); + }, + ); + + it("rejects explicit Codex when agent request params cannot be reproduced", () => { + const supports = vi.fn((ctx: Parameters[0]) => + ctx.modelProvider?.requestTransportOverrides === "present" + ? { supported: false as const, reason: "authored request params are unsupported" } + : { supported: true as const }, + ); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }); + + expect(() => + selectAgentHarness({ + provider: "openai", + modelId: "gpt-5.5", + modelProvider: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + config: { agents: { defaults: { params: { store: false } } } }, + agentHarnessRuntimeOverride: "codex", + }), + ).toThrow("authored request params are unsupported"); + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: expect.objectContaining({ requestTransportOverrides: "present" }), + }), + ); + }); + + it("keeps request-scoped transport overrides on the implicit OpenClaw runtime", () => { + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: () => ({ supported: true, priority: 100 }), + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }); + const config = { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + } satisfies OpenClawConfig; + const modelProvider = { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "present" as const, + }; + + expect( + resolveAvailableAgentHarnessPolicy({ + provider: "openai", + modelId: "gpt-5.5", + modelProvider, + config, + }), + ).toEqual({ runtime: "openclaw", runtimeSource: "implicit" }); + expect( + selectAgentHarness({ + provider: "openai", + modelId: "gpt-5.5", + modelProvider, + config, + }).id, + ).toBe("openclaw"); + expect( + selectAgentHarness({ + provider: "openai", + modelId: "gpt-5.5", + modelProvider: { + api: modelProvider.api, + baseUrl: modelProvider.baseUrl, + }, + config, + }).id, + ).toBe("codex"); + }); + + it("falls back only for implicitly selected Codex transport rejection", () => { + const supports = vi.fn((ctx: Parameters[0]) => + ctx.modelProvider?.requestTransportOverrides === "present" + ? { + supported: false as const, + reason: "custom provider request transport", + } + : { supported: true as const }, + ); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }); + const config = { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + headers: { "x-route": "custom" }, + models: [], + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveAvailableAgentHarnessPolicy({ provider: "openai", modelId: "gpt-5.5", config }), + ).toEqual({ runtime: "openclaw", runtimeSource: "implicit" }); + expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.5", config }).id).toBe( + "openclaw", + ); + expect(() => + selectAgentHarness({ + provider: "openai", + modelId: "gpt-5.5", + config, + agentHarnessRuntimeOverride: "codex", + }), + ).toThrow("custom provider request transport"); + }); + + it("falls back only for implicitly selected route-runtime incompatibility", () => { + const supports = vi.fn((ctx: Parameters[0]) => + ctx.modelProvider?.runtimePolicy?.compatibleIds.includes("codex") + ? { supported: true as const } + : { supported: false as const, reason: "native runtime is incompatible with route" }, + ); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }); + const modelProvider = { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "none" as const, + runtimePolicy: { compatibleIds: ["openclaw"] }, + }; + + expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.5", modelProvider }).id).toBe( + "openclaw", + ); + expect(() => + selectAgentHarness({ + provider: "openai", + modelId: "gpt-5.5", + modelProvider, + agentHarnessRuntimeOverride: "codex", + }), + ).toThrow("native runtime is incompatible with route"); + }); + + it("does not infer native support for an indeterminate OpenAI route", () => { + const supports = vi.fn((ctx: Parameters[0]) => + ctx.modelProvider?.runtimePolicy + ? { supported: true as const } + : { supported: false as const, reason: "route compatibility is undeclared" }, + ); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }); + + expect(selectAgentHarness({ provider: "openai", modelId: "gpt-future" }).id).toBe("openclaw"); + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: expect.objectContaining({ runtimePolicy: undefined }), + }), + ); + }); + + it("projects a harness-owned auth plan as a closed harness source", () => { + const deferredRouteSupport = { + requestTransportOverrides: "none" as const, + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }; + expect( + resolveAgentHarnessPreparedAuthSupport({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + deferredRouteSupport, + }, + }), + ).toEqual({ source: "harness" }); + expect( + resolveAgentHarnessPreparedRouteSupport({ + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + deferredRouteSupport, + }), + ).toEqual(deferredRouteSupport); + expect( + resolveAgentHarnessPreparedRouteSupport({ + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + }), + ).toEqual({}); + expect( + resolveAgentHarnessPreparedAuthSupport({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + selectedAuthMode: "api-key", + }, + }), + ).toEqual({ source: "direct", mode: "api-key" }); + }); + + it("keeps finalized native selection for declared deferred harness-owned auth", () => { + const supports = vi.fn((ctx: Parameters[0]) => + ctx.modelProvider?.preparedAuth?.source === "harness" && + ctx.modelProvider.preparedAuth.requirement === undefined && + ctx.modelProvider.runtimePolicy?.compatibleIds.includes("codex") + ? { supported: true as const } + : { supported: false as const }, + ); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }); + + expect( + selectAgentHarness({ + provider: "openai", + modelId: "gpt-future", + modelProvider: { + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + preparedAuth: { source: "harness" }, + }, + agentHarnessRuntimeOverride: "codex", + }).id, + ).toBe("codex"); + expect(supports).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: expect.objectContaining({ + preparedAuth: { source: "harness" }, + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }), + }), + ); + }); + + it("selects one harness compatible with every prepared model provider", () => { + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: (ctx) => + ctx.modelProvider?.runtimePolicy?.compatibleIds.includes("codex") + ? { supported: true } + : { supported: false, reason: "prepared retry route is incompatible" }, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }); + const compatible = { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "none" as const, + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + preparedAuth: { source: "direct" as const, mode: "api-key", requirement: "api-key" as const }, + }; + const incompatible = { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "none" as const, + runtimePolicy: { compatibleIds: ["openclaw"] }, + preparedAuth: { source: "direct" as const, mode: "api-key", requirement: "api-key" as const }, + }; + const base = { provider: "openai", modelId: "gpt-5.5" }; + + expect( + selectAgentHarnessForPreparedModelProviders({ + ...base, + modelProviders: [compatible, compatible], + }).id, + ).toBe("codex"); + expect( + selectAgentHarnessForPreparedModelProviders({ + ...base, + modelProviders: [compatible, incompatible], + }).id, + ).toBe("openclaw"); + }); + + it.each([ + ["explicit", { agentHarnessRuntimeOverride: "codex" }], + ["pinned", { agentHarnessId: "codex" }], + ] as const)("fails closed when a %s harness cannot own every prepared route", (_label, pin) => { + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: (ctx) => + ctx.modelProvider?.runtimePolicy?.compatibleIds.includes("codex") + ? { supported: true } + : { supported: false, reason: "prepared retry route is incompatible" }, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }); + + expect(() => + selectAgentHarnessForPreparedModelProviders({ + provider: "openai", + modelId: "gpt-5.5", + modelProviders: [ + { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + ], + ...pin, + }), + ).toThrow("prepared retry route is incompatible"); + }); + + it.each([ + { + label: "a finalized route with undeclared compatibility", + modelProvider: { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + }, + expectsRuntimePolicy: false, + }, + { + label: "prepared auth", + modelProvider: { + preparedAuth: { + source: "none" as const, + requirement: "subscription" as const, + }, + }, + expectsRuntimePolicy: false, + }, + ])( + "validates a session-pinned harness against $label", + ({ modelProvider, expectsRuntimePolicy }) => { + const supports = vi.fn((ctx: Parameters[0]) => { + const preparedAuth = ctx.modelProvider?.preparedAuth; + const reproducible = + ctx.modelProvider?.runtimePolicy !== undefined && preparedAuth?.source !== "none"; + return reproducible + ? { supported: true as const } + : { + supported: false as const, + reason: "native runtime cannot reproduce prepared facts", + }; + }); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }); + + expect(() => + selectAgentHarnessForPreparedModelProviders({ + provider: "openai", + modelId: "gpt-5.5", + modelProviders: [modelProvider], + agentHarnessId: "codex", + }), + ).toThrow("native runtime cannot reproduce prepared facts"); + expect(supports).toHaveBeenCalledOnce(); + expect(Boolean(supports.mock.calls[0]?.[0].modelProvider?.runtimePolicy)).toBe( + expectsRuntimePolicy, + ); + }, + ); + it("honors explicit OpenClaw runtime overrides when selecting a harness", async () => { registerSuccessfulCodexHarness(); @@ -1129,6 +1851,70 @@ describe("selectAgentHarness", () => { expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.4" }).id).toBe("openclaw"); }); + it.each(["default", "auto"] as const)( + "falls back from configured %s to OpenClaw when implicit Codex is unavailable or unsupported", + (runtime) => { + const config = providerRuntimeConfig("openai", runtime); + expect(resolveAgentHarnessPolicy({ provider: "openai", modelId: "gpt-5.4", config })).toEqual( + { runtime: "codex", runtimeSource: "implicit" }, + ); + expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.4", config }).id).toBe( + "openclaw", + ); + + const supports = vi.fn(() => ({ supported: false as const, reason: "unsupported route" })); + registerAgentHarness( + { + id: "codex", + label: "Codex", + supports, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }, + { ownerPluginId: "codex" }, + ); + expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.4", config }).id).toBe( + "openclaw", + ); + expect(supports).toHaveBeenCalledOnce(); + }, + ); + + it.each(["default", "auto"] as const)( + "keeps a custom OpenAI route on implicit OpenClaw with configured %s", + (runtime) => { + const supports = vi.fn(() => ({ supported: true as const, priority: 100 })); + registerAgentHarness( + { + id: "codex", + label: "Codex", + supports, + runAttempt: vi.fn(async () => createAttemptResult("codex")), + }, + { ownerPluginId: "codex" }, + ); + const config = { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://relay.example.test/v1", + agentRuntime: { id: runtime }, + models: [], + }, + }, + }, + } as OpenClawConfig; + + expect(resolveAgentHarnessPolicy({ provider: "openai", modelId: "gpt-5.4", config })).toEqual( + { runtime: "openclaw", runtimeSource: "implicit" }, + ); + expect(selectAgentHarness({ provider: "openai", modelId: "gpt-5.4", config }).id).toBe( + "openclaw", + ); + expect(supports).not.toHaveBeenCalled(); + }, + ); + it("ignores legacy agentRuntime as a runtime policy source", () => { const config = { agents: { @@ -1222,6 +2008,113 @@ describe("selectAgentHarness", () => { ).resolves.toBeUndefined(); }); + it("keeps host auth on the built-in OpenClaw compaction fallback", async () => { + await expect( + maybeCompactAgentHarnessSession( + createCompactionParams({ + agentHarnessId: "openclaw", + authProfileId: "openai:work", + authProfileIdSource: "user", + runtimeAuthPlan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:work", + forwardedAuthProfileSource: "user", + selectedAuthMode: "api_key", + }, + }), + ), + ).resolves.toBeUndefined(); + }); + + it("uses the prepared custom route when selecting a compaction harness", async () => { + const compact = registerTestCompactor({ + supports: (ctx) => + ctx.modelProvider?.api === OPENAI_CHATGPT_ROUTE.api && + ctx.modelProvider.baseUrl === OPENAI_CHATGPT_ROUTE.baseUrl + ? { supported: true, priority: 100 } + : { supported: false }, + }); + + await expect( + maybeCompactAgentHarnessSession( + createCompactionParams({ + model: "gpt-5.5-custom", + runtimeAuthPlan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + modelRoute: { + ...OPENAI_PLATFORM_ROUTE, + modelId: "gpt-5.5-custom", + baseUrl: "https://relay.example.test/v1", + }, + }, + }), + ), + ).resolves.toBeUndefined(); + expect(compact).not.toHaveBeenCalled(); + }); + + it("uses the concrete prepared route without replacing harness auth bootstrap", async () => { + const compact = registerTestCompactor({ + authBootstrap: "harness", + supports: (ctx) => + ctx.modelProvider?.api === OPENAI_CHATGPT_ROUTE.api && + ctx.modelProvider.baseUrl === OPENAI_CHATGPT_ROUTE.baseUrl + ? { supported: true, priority: 100 } + : { supported: false }, + }); + + await expect( + maybeCompactAgentHarnessSession( + createCompactionParams({ + runtimeAuthPlan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + modelRoute: OPENAI_CHATGPT_ROUTE, + }, + }), + ), + ).resolves.toEqual({ ok: true, compacted: false }); + + expect(compactAuthMocks.resolveModelAsync).not.toHaveBeenCalled(); + expect(compactAuthMocks.getApiKeyForModel).not.toHaveBeenCalled(); + expect(compact).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeAuthPlan: expect.objectContaining({ + modelRoute: OPENAI_CHATGPT_ROUTE, + }), + }), + ); + }); + + it("forwards the prepared Platform key through harness-owned compaction", async () => { + const compact = registerTestCompactor({ authBootstrap: "harness" }); + + await expect( + maybeCompactAgentHarnessSession( + createCompactionParams({ + resolvedApiKey: "test-key", + runtimeAuthPlan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + harnessAuthProvider: "openai", + selectedAuthMode: "api-key", + modelRoute: OPENAI_PLATFORM_ROUTE, + }, + }), + ), + ).resolves.toEqual({ ok: true, compacted: false }); + + expect(compact).toHaveBeenCalledWith( + expect.objectContaining({ + resolvedApiKey: "test-key", + runtimeAuthPlan: expect.objectContaining({ modelRoute: OPENAI_PLATFORM_ROUTE }), + }), + ); + }); + it("keeps pinned plugin compaction when the outer provider no longer matches", async () => { const compact = vi.fn>(async () => ({ ok: true, @@ -1283,7 +2176,6 @@ describe("selectAgentHarness", () => { }, { ownerPluginId: "codex" }, ); - await expect( maybeCompactAgentHarnessSession({ sessionId: "session-1", @@ -1293,6 +2185,7 @@ describe("selectAgentHarness", () => { provider: "openai", model: "gpt-5.5", authProfileId: "main-profile", + resolvedApiKey: "test-key", agentHarnessId: "codex", config: { agents: { @@ -1497,6 +2390,21 @@ describe("selectAgentHarness", () => { ); }); + it("fails closed when route preparation cannot protect harness-owned compaction auth", async () => { + compactAuthMocks.resolveModelAsync.mockRejectedValue(new Error("model lookup unavailable")); + const compact = registerTestCompactor({ authBootstrap: "harness" }); + + await expect( + maybeCompactAgentHarnessSession( + createCompactionParams({ + agentHarnessId: "codex", + resolvedApiKey: "must-not-reach-ambient-auth", + }), + ), + ).rejects.toThrow("refusing harness-owned ambient auth"); + expect(compact).not.toHaveBeenCalled(); + }); + it("passes runtime model and default credentials to compaction when auth profile id is absent", async () => { compactAuthMocks.resolveModelAsync.mockResolvedValue({ model: { @@ -1553,7 +2461,6 @@ describe("selectAgentHarness", () => { baseUrl: "https://proxy.example/v1", id: "proxy-model", }), - profileId: undefined, workspaceDir: "/tmp/workspace", }), ); diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index 7d29598046e9..e3dd00caa26c 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -39,13 +39,54 @@ import { type AgentHarnessPolicy, } from "./policy.js"; import { getRegisteredAgentHarness, listRegisteredAgentHarnesses } from "./registry.js"; -import { buildAgentHarnessSupportContext, compareHarnessSupport } from "./support.js"; -import type { AgentHarness, AgentHarnessSupport } from "./types.js"; +import { + buildAgentHarnessSupportContext, + compareHarnessSupport, + resolveAgentHarnessPreparedAuthSupport, + resolveAgentHarnessPreparedRouteSupport, +} from "./support.js"; +import type { AgentHarness, AgentHarnessSupport, AgentHarnessSupportContext } from "./types.js"; const log = createSubsystemLogger("agents/harness"); export { resolveAgentHarnessPolicy } from "./policy.js"; export type { AgentHarnessPolicy }; +type AgentHarnessAvailabilityParams = { + provider?: string; + modelId?: string; + modelProvider?: AgentHarnessSupportContext["modelProvider"]; + config?: OpenClawConfig; + agentId?: string; + sessionKey?: string; + env?: NodeJS.ProcessEnv; + preparedModelProvider?: boolean; +}; + +type AgentHarnessSelectionParams = { + provider: string; + modelId?: string; + modelProvider?: AgentHarnessSupportContext["modelProvider"]; + config?: OpenClawConfig; + agentId?: string; + sessionKey?: string; + agentHarnessId?: string; + agentHarnessRuntimeOverride?: string; +}; + +type AgentHarnessSelectionDecisionParams = AgentHarnessSelectionParams & { + /** Finalized route/auth facts must always pass harness support, including persisted pins. */ + preparedModelProvider?: boolean; +}; + +export type AgentHarnessPreparedModelProvider = NonNullable< + AgentHarnessSupportContext["modelProvider"] +>; + +type AgentHarnessAvailabilityDecision = + | { kind: "available"; policy: AgentHarnessPolicy } + | { kind: "implicit-unavailable"; policy: AgentHarnessPolicy } + | { kind: "implicit-unsupported"; policy: AgentHarnessPolicy }; + const PLUGIN_HARNESS_SENDER_DENY_ALL_PROMPT = "Tool and file actions are disabled for this sender by chat policy. If asked to edit files or use tools, say this sender is not allowed by policy; do not imply retrying will help."; const PLUGIN_HARNESS_GROUP_DENY_ALL_PROMPT = @@ -71,6 +112,8 @@ type AgentHarnessSelectionDecision = { | "forced_plugin" // Implicit Codex preference found no registered Codex harness, so OpenClaw handled the run. | "implicit_plugin_unavailable_openclaw" + // Implicit Codex preference cannot reproduce the prepared transport, so OpenClaw handled it. + | "implicit_plugin_unsupported_openclaw" // Provider-owned CLI runtime aliases have no agent harness plugin counterpart. | "cli_runtime_passthrough_openclaw" // Auto mode chose a registered plugin harness that supports the provider/model. @@ -115,43 +158,92 @@ function listPluginAgentHarnesses(): AgentHarness[] { return listRegisteredAgentHarnesses().map((entry) => entry.harness); } -export function resolveAvailableAgentHarnessPolicy(params: { - provider?: string; - modelId?: string; - config?: OpenClawConfig; - agentId?: string; - sessionKey?: string; - env?: NodeJS.ProcessEnv; -}): AgentHarnessPolicy { - return applyAgentHarnessAvailabilityPolicy(resolveConfiguredAgentHarnessPolicy(params)); +export function resolveAvailableAgentHarnessPolicy( + params: AgentHarnessAvailabilityParams, +): AgentHarnessPolicy { + return resolveAgentHarnessAvailabilityDecision(params).policy; } -function applyAgentHarnessAvailabilityPolicy(policy: AgentHarnessPolicy): AgentHarnessPolicy { - if ( - policy.runtime === "codex" && - policy.runtimeSource === "implicit" && - !getRegisteredAgentHarness("codex") - ) { +function resolveAgentHarnessAvailabilityDecision( + params: AgentHarnessAvailabilityParams, +): AgentHarnessAvailabilityDecision { + const policy = resolveConfiguredAgentHarnessPolicy({ + ...params, + modelApi: params.modelProvider?.api, + modelBaseUrl: params.modelProvider?.baseUrl, + requestTransportOverrides: params.modelProvider?.requestTransportOverrides, + }); + if (policy.runtime !== "codex" || policy.runtimeSource !== "implicit") { + return { kind: "available", policy }; + } + const codexHarness = getRegisteredAgentHarness("codex"); + if (!codexHarness) { return { - ...policy, - runtime: "openclaw", + kind: "implicit-unavailable", + policy: { ...policy, runtime: "openclaw" }, }; } - return policy; + const provider = params.provider?.trim(); + if (!provider) { + return { kind: "available", policy }; + } + const support = codexHarness.harness.supports( + buildAgentHarnessSupportContext({ + provider, + modelId: params.modelId, + modelProvider: params.modelProvider, + requestedRuntime: policy.runtime, + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + preparedModelProvider: params.preparedModelProvider, + }), + ); + if (support.supported) { + return { kind: "available", policy }; + } + return { + kind: "implicit-unsupported", + policy: { ...policy, runtime: "openclaw" }, + }; } -export function selectAgentHarness(params: { - provider: string; - modelId?: string; - config?: OpenClawConfig; - agentId?: string; - sessionKey?: string; - agentHarnessId?: string; - agentHarnessRuntimeOverride?: string; -}): AgentHarness { +export function selectAgentHarness(params: AgentHarnessSelectionParams): AgentHarness { return selectAgentHarnessDecision(params).harness; } +/** Selects one harness that can preserve every prepared route/auth retry candidate. */ +export function selectAgentHarnessForPreparedModelProviders( + params: Omit & { + modelProviders: readonly AgentHarnessPreparedModelProvider[]; + }, +): AgentHarness { + const { modelProviders, ...selectionParams } = params; + if (modelProviders.length === 0) { + return selectAgentHarness(selectionParams); + } + const decisions = modelProviders.map((modelProvider) => + selectAgentHarnessDecision({ + ...selectionParams, + modelProvider, + preparedModelProvider: true, + }), + ); + const first = decisions[0]; + if ( + !first || + decisions.every((decision) => decision.selectedHarnessId === first.selectedHarnessId) + ) { + return first?.harness ?? selectAgentHarness(selectionParams); + } + // Only implicit/auto selection can produce different supported harnesses. One embedded + // runtime owns the complete retry set; explicit and pinned plugins fail during probing above. + return ( + decisions.find((decision) => decision.selectedHarnessId === "openclaw")?.harness ?? + createOpenClawAgentHarness() + ); +} + /** Returns whether a plugin harness constructs OpenClaw tools inside its runtime. */ export function agentHarnessBuildsOpenClawTools(harnessId: string): boolean { return harnessId === "codex" || harnessId === "copilot"; @@ -162,47 +254,63 @@ export function agentHarnessExposesOpenClawTools(harnessId: string): boolean { return harnessId === "openclaw" || agentHarnessBuildsOpenClawTools(harnessId); } -function selectAgentHarnessDecision(params: { - provider: string; - modelId?: string; - config?: OpenClawConfig; - agentId?: string; - sessionKey?: string; - agentHarnessId?: string; - agentHarnessRuntimeOverride?: string; -}): AgentHarnessSelectionDecision { - const resolvedPolicy = resolveConfiguredAgentHarnessPolicy(params); +function selectAgentHarnessDecision( + params: AgentHarnessSelectionDecisionParams, +): AgentHarnessSelectionDecision { const pinnedHarnessId = normalizeOptionalAgentRuntimeId(params.agentHarnessId); const runtimeOverride = normalizeOptionalAgentRuntimeId(params.agentHarnessRuntimeOverride); - const selectedRuntimeOverride = pinnedHarnessId ?? runtimeOverride; - const policy = - selectedRuntimeOverride && !isDefaultAgentRuntimeId(selectedRuntimeOverride) - ? ({ - ...resolvedPolicy, - runtime: selectedRuntimeOverride, - runtimeSource: "model", - } as AgentHarnessPolicy) - : resolvedPolicy; + const requestedRuntimeOverride = pinnedHarnessId ?? runtimeOverride; + const selectedRuntimeOverride = + requestedRuntimeOverride && !isDefaultAgentRuntimeId(requestedRuntimeOverride) + ? requestedRuntimeOverride + : undefined; + // Persisted ownership and explicit model policy are already authoritative. + // Avoid probing implicit harness support before those overrides are applied. + const availability: AgentHarnessAvailabilityDecision = selectedRuntimeOverride + ? { + kind: "available", + policy: resolveConfiguredAgentHarnessPolicy({ + ...params, + modelApi: params.modelProvider?.api, + modelBaseUrl: params.modelProvider?.baseUrl, + requestTransportOverrides: params.modelProvider?.requestTransportOverrides, + }), + } + : resolveAgentHarnessAvailabilityDecision(params); + const resolvedPolicy = availability.policy; + const policy = selectedRuntimeOverride + ? ({ + ...resolvedPolicy, + runtime: selectedRuntimeOverride, + runtimeSource: "model", + } as AgentHarnessPolicy) + : resolvedPolicy; // OpenClaw's built-in harness is intentionally not part of the plugin candidate list. Explicit plugin // runtimes fail closed; only `auto` may route an unmatched turn to OpenClaw. const pluginHarnesses = listPluginAgentHarnesses(); const openClawHarness = createOpenClawAgentHarness(); const runtime = policy.runtime; if (runtime === "openclaw") { + const selectedReason = selectedRuntimeOverride + ? "forced_openclaw" + : availability.kind === "implicit-unavailable" + ? "implicit_plugin_unavailable_openclaw" + : availability.kind === "implicit-unsupported" + ? "implicit_plugin_unsupported_openclaw" + : "forced_openclaw"; return buildSelectionDecision({ harness: openClawHarness, policy, - selectedReason: "forced_openclaw", + selectedReason, candidates: listHarnessCandidates(pluginHarnesses), }); } if (runtime !== "auto") { const forced = pluginHarnesses.find((entry) => entry.id === runtime); if (forced) { - // A persisted harness owns the existing transcript. Provider/model fields are only - // routing metadata for native sessions and may change with channel or heartbeat config. - // Keep the pinned harness authoritative; if it is unavailable, fail closed below. - if (pinnedHarnessId === runtime) { + // A persisted harness owns the native transcript before route/auth preparation. The + // finalized entrypoint sets preparedModelProvider and must always revalidate that owner. + if (pinnedHarnessId === runtime && !params.preparedModelProvider) { return buildSelectionDecision({ harness: forced, policy, @@ -213,8 +321,12 @@ function selectAgentHarnessDecision(params: { const supportContext = buildAgentHarnessSupportContext({ provider: params.provider, modelId: params.modelId, + modelProvider: params.modelProvider, requestedRuntime: runtime, config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + preparedModelProvider: params.preparedModelProvider, providerOwnership: resolveProviderRefOwnership({ provider: params.provider, config: params.config, @@ -283,8 +395,12 @@ function selectAgentHarnessDecision(params: { const supportContext = buildAgentHarnessSupportContext({ provider: params.provider, modelId: params.modelId, + modelProvider: params.modelProvider, requestedRuntime: runtime, config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + preparedModelProvider: params.preparedModelProvider, providerOwnership: resolveProviderRefOwnership({ provider: params.provider, config: params.config, @@ -337,11 +453,18 @@ export async function runAgentHarnessAttempt( const selection = selectAgentHarnessDecision({ provider: params.provider, modelId: params.modelId, + modelProvider: { + api: params.model.api, + baseUrl: params.model.baseUrl, + ...resolveAgentHarnessPreparedRouteSupport(params.runtimePlan?.auth), + preparedAuth: resolveAgentHarnessPreparedAuthSupport({ plan: params.runtimePlan?.auth }), + }, config: params.config, agentId: params.agentId, sessionKey: params.sessionKey, agentHarnessId: params.agentHarnessId, agentHarnessRuntimeOverride: params.agentHarnessRuntimeOverride, + preparedModelProvider: params.runtimePlan?.auth !== undefined, }); const harness = selection.harness; if (internalParams.crestodianTool && !isCrestodianOnlyAllowlist(internalParams.toolsAllow)) { diff --git a/src/agents/harness/support.ts b/src/agents/harness/support.ts index 311b27e63e13..b8e3480eda52 100644 --- a/src/agents/harness/support.ts +++ b/src/agents/harness/support.ts @@ -1,41 +1,164 @@ -import { findNormalizedProviderValue } from "@openclaw/model-catalog-core/provider-id"; +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { + resolveMergedModelProviderConfig, + resolveMergedModelProviderModels, + resolveModelProviderRouteOverridePresence, +} from "../../config/model-provider-config.js"; +import type { ModelApi } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { + ProviderModelRouteRuntimePolicy, + ProviderRouteOverridePresence, +} from "../../plugin-sdk/provider-model-types.js"; +import { resolveProviderModelRoutes } from "../../plugins/provider-model-routes.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { hasModelExtraParams } from "../model-extra-params.js"; +import { canonicalizeProviderModelId } from "../provider-model-route.js"; +import type { AgentRuntimeAuthPlan } from "../runtime-plan/types.js"; import { listRegisteredAgentHarnesses } from "./registry.js"; -import type { AgentHarness, AgentHarnessSupport, AgentHarnessSupportContext } from "./types.js"; +import type { + AgentHarness, + AgentHarnessPreparedAuthSupport, + AgentHarnessSupport, + AgentHarnessSupportContext, +} from "./types.js"; type HarnessProviderOwnership = | { status: "unowned" } | { status: "owned" | "ambiguous"; pluginIds: readonly string[] }; +/** Projects one prepared auth attempt into a secret-free native-runtime support fact. */ +export function resolveAgentHarnessPreparedAuthSupport(params: { + plan?: AgentRuntimeAuthPlan; + source?: AgentHarnessPreparedAuthSupport["source"]; +}): AgentHarnessPreparedAuthSupport | undefined { + const plan = params.plan; + if (!plan) { + return undefined; + } + const source = + params.source ?? + (plan.forwardedAuthProfileId + ? "profile" + : plan.selectedAuthMode + ? "direct" + : plan.harnessAuthProvider + ? "harness" + : "none"); + return { + source, + ...(plan.selectedAuthMode ? { mode: plan.selectedAuthMode } : {}), + ...(plan.modelRoute ? { requirement: plan.modelRoute.authRequirement } : {}), + }; +} + +/** Projects the concrete or deferred prepared route into native-runtime support facts. */ +export function resolveAgentHarnessPreparedRouteSupport( + plan?: AgentRuntimeAuthPlan, +): Pick< + NonNullable, + "requestTransportOverrides" | "runtimePolicy" +> { + const support = plan?.modelRoute ?? plan?.deferredRouteSupport; + return support + ? { + requestTransportOverrides: support.requestTransportOverrides, + runtimePolicy: support.runtimePolicy, + } + : {}; +} + /** Builds the provider/model facts passed to registered harness support probes. */ export function buildAgentHarnessSupportContext(params: { provider: string; modelId?: string; + /** Prepared provider facts take precedence over config rediscovery. */ + modelProvider?: AgentHarnessSupportContext["modelProvider"]; requestedRuntime: AgentHarnessSupportContext["requestedRuntime"]; config?: OpenClawConfig; + agentId?: string; + sessionKey?: string; + /** Finalized route/auth selection; missing runtimePolicy stays undeclared. */ + preparedModelProvider?: boolean; /** Prepared selection fact; read-only projections omit it to avoid plugin metadata discovery. */ providerOwnership?: HarnessProviderOwnership; }): AgentHarnessSupportContext { - const providerConfig = findNormalizedProviderValue( - params.config?.models?.providers, - params.provider, - ); - const modelConfig = params.modelId - ? providerConfig?.models?.find((entry) => entry.id === params.modelId) + const providerConfig = resolveMergedModelProviderConfig(params.config, params.provider); + const modelId = params.modelId ? normalizeModelId(params.provider, params.modelId) : undefined; + const modelConfig = modelId + ? resolveMergedModelProviderModels({ + models: providerConfig?.models, + normalizeModelId: (configuredModelId) => + normalizeModelId(params.provider, configuredModelId), + }).get(modelId) : undefined; + const agentId = + params.agentId ?? + (params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined); + const hasConfiguredParams = hasModelExtraParams({ + config: params.config, + provider: params.provider, + modelId: params.modelId, + agentId, + }); + const configuredModelProvider = providerConfig + ? { + api: modelConfig?.api ?? providerConfig.api ?? "openai-responses", + baseUrl: modelConfig?.baseUrl ?? providerConfig.baseUrl, + azureApiVersion: readStringParam( + modelConfig?.params?.azureApiVersion ?? providerConfig.params?.azureApiVersion, + ), + request: providerConfig.request, + requestTransportOverrides: resolveModelProviderRouteOverridePresence({ + provider: params.provider, + modelId: params.modelId, + config: params.config, + canonicalizeModelId: (configuredModelId) => + canonicalizeProviderModelId(params.provider, configuredModelId), + }), + } + : undefined; + const requestTransportOverrides: ProviderRouteOverridePresence = + params.modelProvider?.requestTransportOverrides === "present" || + configuredModelProvider?.requestTransportOverrides === "present" || + hasConfiguredParams + ? "present" + : "none"; + const modelProviderFacts = + params.modelProvider || configuredModelProvider || hasConfiguredParams + ? { + api: params.modelProvider?.api ?? configuredModelProvider?.api, + baseUrl: params.modelProvider?.baseUrl ?? configuredModelProvider?.baseUrl, + azureApiVersion: + params.modelProvider?.azureApiVersion ?? configuredModelProvider?.azureApiVersion, + request: params.modelProvider?.request ?? configuredModelProvider?.request, + preparedAuth: params.modelProvider?.preparedAuth, + requestTransportOverrides, + } + : undefined; + // Finalized routes carry the owner decision. Earlier selection resolves the same provider + // artifact once so an indeterminate route cannot regain provider-id-only native support. + const routeRuntimeContract = params.modelProvider?.runtimePolicy + ? { owned: true, policy: params.modelProvider.runtimePolicy } + : params.preparedModelProvider + ? { owned: true } + : resolveHarnessRouteRuntimePolicy({ + provider: params.provider, + modelId: params.modelId, + modelProvider: modelProviderFacts, + config: params.config, + }); + const modelProvider = + modelProviderFacts || routeRuntimeContract.owned + ? { + ...modelProviderFacts, + runtimePolicy: params.modelProvider?.runtimePolicy ?? routeRuntimeContract.policy, + } + : undefined; return { provider: params.provider, modelId: params.modelId, - modelProvider: providerConfig - ? { - api: modelConfig?.api ?? providerConfig.api ?? "openai-responses", - baseUrl: modelConfig?.baseUrl ?? providerConfig.baseUrl, - azureApiVersion: readStringParam( - modelConfig?.params?.azureApiVersion ?? providerConfig.params?.azureApiVersion, - ), - request: providerConfig.request, - } - : undefined, + modelProvider, requestedRuntime: params.requestedRuntime, ...(params.providerOwnership ? { @@ -47,11 +170,50 @@ export function buildAgentHarnessSupportContext(params: { }; } +function resolveHarnessRouteRuntimePolicy(params: { + provider: string; + modelId?: string; + modelProvider?: AgentHarnessSupportContext["modelProvider"]; + config?: OpenClawConfig; +}): { owned: boolean; policy?: ProviderModelRouteRuntimePolicy } { + const resolution = resolveProviderModelRoutes({ + provider: params.provider, + modelId: params.modelId, + api: params.modelProvider?.api as ModelApi | undefined, + baseUrl: params.modelProvider?.baseUrl, + config: params.config, + requestTransportOverrides: params.modelProvider?.requestTransportOverrides, + }); + if (!resolution) { + return { owned: false }; + } + if (resolution.kind !== "routes") { + return { owned: true }; + } + const policies = resolution.routes.map((route) => route.runtimePolicy); + const first = policies[0]; + if (!first || policies.some((policy) => !policy)) { + return { owned: true }; + } + return { + owned: true, + policy: { + compatibleIds: first.compatibleIds.filter( + (id, index, ids) => + ids.indexOf(id) === index && + policies.every((policy) => policy?.compatibleIds.includes(id)), + ), + }, + }; +} + /** Resolves the registered plugin harness that auto selection would choose. */ export function resolveAutoAgentHarnessId(params: { provider: string; modelId?: string; config?: OpenClawConfig; + agentId?: string; + sessionKey?: string; }): string | undefined { const supportContext = buildAgentHarnessSupportContext({ ...params, @@ -84,3 +246,14 @@ function isSupportedHarness(entry: { function readStringParam(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } + +function normalizeModelId(provider: string, modelId: string): string { + const trimmed = modelId.trim(); + const slashIndex = trimmed.indexOf("/"); + const unqualified = + slashIndex > 0 && + normalizeProviderId(trimmed.slice(0, slashIndex)) === normalizeProviderId(provider) + ? trimmed.slice(slashIndex + 1).trim() + : trimmed; + return canonicalizeProviderModelId(provider, unqualified); +} diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index 68eb87f60ac4..b3f48aab9e04 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -1,12 +1,23 @@ /** * Public native agent harness contracts and capability shapes. */ +import type { + ProviderModelRouteAuthRequirement, + ProviderModelRouteRuntimePolicy, + ProviderRouteOverridePresence, +} from "../../plugin-sdk/provider-model-types.js"; import type { AgentHarnessRuntimeArtifactBinding } from "./runtime-artifact.types.js"; export type { AgentHarnessRuntimeArtifactBinding, ExpectedAgentHarnessRuntimeArtifact, } from "./runtime-artifact.types.js"; + +export type AgentHarnessPreparedAuthSupport = { + source: "profile" | "direct" | "harness" | "none"; + mode?: string; + requirement?: ProviderModelRouteAuthRequirement; +}; export type AgentHarnessSupportContext = { provider: string; modelId?: string; @@ -14,6 +25,12 @@ export type AgentHarnessSupportContext = { api?: string; baseUrl?: string; azureApiVersion?: string; + /** Secret-free projection of request behavior a native harness must reproduce. */ + requestTransportOverrides?: ProviderRouteOverridePresence; + /** Provider-owned native-runtime compatibility for the prepared route. */ + runtimePolicy?: ProviderModelRouteRuntimePolicy; + /** Secret-free auth source the native runtime must reproduce for this attempt. */ + preparedAuth?: AgentHarnessPreparedAuthSupport; request?: { auth?: { mode?: unknown }; proxy?: unknown; @@ -45,12 +62,22 @@ export type AgentHarnessAuthBindingFingerprintParams = { agentDir: string; config?: import("../../config/types.openclaw.js").OpenClawConfig; }; +export type AgentHarnessSideQuestionPreparedRuntimeAuth = { + plan: import("../runtime-plan/types.js").AgentRuntimeAuthPlan; + authProfileStore: import("../auth-profiles/types.js").AuthProfileStore; + authStorage: import("../sessions/index.js").AuthStorage; + modelRegistry: import("../sessions/index.js").ModelRegistry; + /** Resolved host credential for an immutable API-key route only. */ + resolvedApiKey?: string; +}; export type AgentHarnessSideQuestionParams = { cfg: import("../../config/types.openclaw.js").OpenClawConfig; agentDir: string; provider: string; model: string; runtimeModel?: import("openclaw/plugin-sdk/llm").Model; + /** One atomic route/profile/store snapshot prepared before native dispatch. */ + preparedRuntimeAuth: AgentHarnessSideQuestionPreparedRuntimeAuth; question: string; sessionEntry: import("../../config/sessions.js").SessionEntry; sessionStore?: Record; diff --git a/src/agents/live-model-dynamic-candidates.test.ts b/src/agents/live-model-dynamic-candidates.test.ts index 2f12fe98cc64..8eef0f4a348f 100644 --- a/src/agents/live-model-dynamic-candidates.test.ts +++ b/src/agents/live-model-dynamic-candidates.test.ts @@ -12,8 +12,10 @@ const providerRuntimeMocks = vi.hoisted(() => ({ runProviderDynamicModel: vi.fn(), })); +const normalizeDiscoveredAgentModelMock = vi.hoisted(() => vi.fn((value: unknown) => value)); + vi.mock("./agent-model-discovery.js", () => ({ - normalizeDiscoveredAgentModel: (value: unknown) => value, + normalizeDiscoveredAgentModel: normalizeDiscoveredAgentModelMock, })); vi.mock("../plugins/provider-runtime.js", () => providerRuntimeMocks); @@ -49,6 +51,7 @@ function model(provider: string, id: string): Model { describe("appendPrioritizedDynamicLiveModels", () => { beforeEach(() => { + normalizeDiscoveredAgentModelMock.mockClear(); providerRuntimeMocks.prepareProviderDynamicModel.mockReset(); providerRuntimeMocks.prepareProviderDynamicModel.mockResolvedValue(undefined); providerRuntimeMocks.resolveProviderModernModelRef.mockReset(); @@ -159,9 +162,22 @@ describe("appendPrioritizedDynamicLiveModels", () => { : undefined, ); + const config = { + models: { + providers: { + [DYNAMIC_PROVIDER]: { + api: "openai-completions", + baseUrl: "https://configured.example/v1", + models: [], + }, + }, + }, + } as OpenClawConfig; const result = await appendPrioritizedDynamicLiveModels({ models: [], + config, agentDir: "/tmp/openclaw-agent", + workspaceDir: "/tmp/openclaw-workspace", modelRegistry: REGISTRY, refs: [{ provider: DYNAMIC_PROVIDER, id: "glm-5" }], }); @@ -171,5 +187,10 @@ describe("appendPrioritizedDynamicLiveModels", () => { ]); expect(providerRuntimeMocks.prepareProviderDynamicModel).toHaveBeenCalledTimes(1); expect(providerRuntimeMocks.runProviderDynamicModel).toHaveBeenCalledTimes(1); + expect(normalizeDiscoveredAgentModelMock).toHaveBeenCalledWith( + expect.objectContaining({ provider: DYNAMIC_PROVIDER, id: "glm-5" }), + "/tmp/openclaw-agent", + { config, workspaceDir: "/tmp/openclaw-workspace" }, + ); }); }); diff --git a/src/agents/live-model-dynamic-candidates.ts b/src/agents/live-model-dynamic-candidates.ts index d0be89536129..99e30528ae90 100644 --- a/src/agents/live-model-dynamic-candidates.ts +++ b/src/agents/live-model-dynamic-candidates.ts @@ -41,9 +41,13 @@ async function runProviderDynamicModelDefault( return runProviderDynamicModel(params); } -async function normalizeDynamicModelDefault(model: Model, agentDir: string): Promise { +async function normalizeDynamicModelDefault( + model: Model, + agentDir: string, + options: { config?: OpenClawConfig; workspaceDir?: string }, +): Promise { const { normalizeDiscoveredAgentModel } = await import("./agent-model-discovery.js"); - return normalizeDiscoveredAgentModel(model, agentDir); + return normalizeDiscoveredAgentModel(model, agentDir, options); } function liveModelKey(provider: string, id: string): string | null { @@ -73,7 +77,6 @@ export async function appendPrioritizedDynamicLiveModels(params: { }): Promise<{ models: Model[]; added: Model[] }> { const resolveDynamicModel = params.resolveDynamicModel ?? runProviderDynamicModelDefault; const prepareDynamicModel = params.prepareDynamicModel ?? prepareProviderDynamicModelDefault; - const normalizeModel = params.normalizeModel ?? normalizeDynamicModelDefault; const refs = params.refs ?? listPrioritizedHighSignalLiveModelRefs(); const seen = new Set(); for (const model of params.models) { @@ -122,7 +125,12 @@ export async function appendPrioritizedDynamicLiveModels(params: { if (!resolved) { continue; } - const model = await normalizeModel(resolved as Model, params.agentDir); + const model = params.normalizeModel + ? await params.normalizeModel(resolved as Model, params.agentDir) + : await normalizeDynamicModelDefault(resolved as Model, params.agentDir, { + config: params.config, + workspaceDir: params.workspaceDir, + }); const resolvedKey = liveModelKey(model.provider, model.id); // De-dupe against the resolved identity as well as the requested ref; hooks // may canonicalize provider ids or return aliases. diff --git a/src/agents/model-auth-availability.test.ts b/src/agents/model-auth-availability.test.ts new file mode 100644 index 000000000000..51b4c4d5b1c6 --- /dev/null +++ b/src/agents/model-auth-availability.test.ts @@ -0,0 +1,756 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { + ProviderModelRouteCandidate, + ProviderModelRouteResolution, +} from "../plugin-sdk/provider-model-types.js"; +import type { AuthProfileStore } from "./auth-profiles/types.js"; +import { + createModelAuthAvailabilityResolver, + type ModelAuthAvailabilityRef, +} from "./model-auth-availability.js"; +import type { createOpenAIModelRoutesResolver } from "./openai-model-routes.js"; + +const platformRoute = { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, +} satisfies ProviderModelRouteCandidate; + +const subscriptionRoute = { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, +} satisfies ProviderModelRouteCandidate; + +const dualRoutes = { + kind: "routes", + defaultRuntimeId: "codex", + routes: [platformRoute, subscriptionRoute], +} satisfies ProviderModelRouteResolution; + +function routeResolverFactory(resolution: ProviderModelRouteResolution | null) { + return (() => () => resolution) as typeof createOpenAIModelRoutesResolver; +} + +function authStore( + profiles: Record = {}, + order?: AuthProfileStore["order"], +): AuthProfileStore { + return { + version: 1, + profiles: profiles as AuthProfileStore["profiles"], + ...(order ? { order } : {}), + }; +} + +function evaluate(params: { + cfg?: OpenClawConfig | Record; + env?: NodeJS.ProcessEnv; + ref?: ModelAuthAvailabilityRef; + resolution?: ProviderModelRouteResolution | null; + store?: AuthProfileStore; + syntheticAuthProviderRefs?: readonly string[]; +}) { + return createModelAuthAvailabilityResolver({ + cfg: (params.cfg ?? {}) as OpenClawConfig, + authStore: params.store ?? authStore(), + env: params.env ?? {}, + routeResolverFactory: routeResolverFactory(params.resolution ?? dualRoutes), + syntheticAuthProviderRefs: params.syntheticAuthProviderRefs, + }).evaluateModelAuth("openai", params.ref); +} + +describe("createModelAuthAvailabilityResolver", () => { + it.each([ + { + label: "Platform API key", + profileId: "openai:platform", + profile: { + type: "api_key" as const, + provider: "openai", + key: "platform-key", + }, + selectedRoute: platformRoute, + selectedAuthMode: "api_key", + }, + { + label: "ChatGPT OAuth", + profileId: "openai:chatgpt", + profile: { + type: "oauth" as const, + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + selectedRoute: subscriptionRoute, + selectedAuthMode: "oauth", + }, + ])("selects a ready $label route", ({ profileId, profile, selectedAuthMode, selectedRoute }) => { + expect(evaluate({ store: authStore({ [profileId]: profile }) })).toMatchObject({ + availability: true, + evidence: "profile", + selectedAuthMode, + selectedProfileId: profileId, + selectedRoute, + }); + }); + + it("keeps a selected profile with missing credential material unavailable", () => { + expect( + evaluate({ + store: authStore({ + "openai:missing": { type: "api_key", provider: "openai", key: "" }, + }), + }), + ).toMatchObject({ + availability: false, + evidence: "profile", + selectedProfileId: "openai:missing", + selectedRoute: platformRoute, + }); + }); + + it("preserves the known physical route when an automatic tier is all cooldown", () => { + const store = authStore({ + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }); + store.usageStats = { + "openai:chatgpt": { cooldownUntil: Date.now() + 60_000 }, + }; + + expect(evaluate({ store })).toMatchObject({ + availability: false, + evidence: "profile", + selectedAuthMode: "oauth", + selectedProfileId: "openai:chatgpt", + selectedRoute: subscriptionRoute, + }); + }); + + it.each([ + { + label: "incompatible", + resolution: { + kind: "incompatible" as const, + code: "platform-only-model-on-chatgpt", + message: "Platform-only model", + }, + availability: false, + }, + { + label: "indeterminate", + resolution: { kind: "indeterminate" as const, defaultRuntimeId: "codex" }, + availability: undefined, + }, + ])("preserves an $label provider route decision", ({ availability, resolution }) => { + expect(evaluate({ resolution })).toEqual({ availability, routeResolution: resolution }); + }); + + it("projects route-independent auth-order failures for indeterminate routes", () => { + const resolution = { kind: "indeterminate" as const, defaultRuntimeId: "codex" }; + const cooldownStore = authStore({ + "openai:cooldown": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }); + cooldownStore.usageStats = { + "openai:cooldown": { cooldownUntil: Date.now() + 60_000 }, + }; + + expect(evaluate({ resolution, store: cooldownStore })).toMatchObject({ + availability: false, + evidence: "profile", + selectedProfileId: "openai:cooldown", + }); + expect( + evaluate({ + cfg: { auth: { order: { openai: [] } } }, + resolution, + }), + ).toMatchObject({ availability: false, evidence: "profile" }); + }); + + it("does not let ChatGPT OAuth satisfy a custom API-key endpoint", () => { + const customRoute = { + ...platformRoute, + baseUrl: "https://openai-compatible.example/v1", + } satisfies ProviderModelRouteCandidate; + const result = evaluate({ + resolution: { kind: "routes", defaultRuntimeId: "openclaw", routes: [customRoute] }, + store: authStore({ + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }), + }); + + expect(result).toMatchObject({ availability: false, selectedRoute: customRoute }); + expect(result.selectedProfileId).toBeUndefined(); + }); + + it.each([ + { + auth: "oauth" as const, + profile: { type: "api_key" as const, provider: "openai", key: "platform-key" }, + route: subscriptionRoute, + }, + { + auth: "api-key" as const, + profile: { + type: "oauth" as const, + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + route: platformRoute, + }, + ])( + "does not pair a $profile.type profile with configured $auth auth", + ({ auth, profile, route }) => { + const result = evaluate({ + cfg: { + models: { providers: { openai: { auth, baseUrl: "", models: [] } } }, + } as OpenClawConfig, + store: authStore({ "openai:wrong-route": profile }), + }); + + expect(result).toMatchObject({ availability: false, selectedRoute: route }); + expect(result.selectedProfileId).toBeUndefined(); + }, + ); + + it("uses explicit direct provider auth ahead of automatic profiles", () => { + const cfg = { + models: { + providers: { + openai: { + auth: "api-key", + apiKey: "configured-platform-key", + baseUrl: platformRoute.baseUrl, + models: [], + }, + }, + }, + } as OpenClawConfig; + + expect( + evaluate({ + cfg, + store: authStore({ + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }), + }), + ).toMatchObject({ + availability: true, + evidence: "provider-config", + selectedAuthMode: "api-key", + selectedRoute: platformRoute, + }); + }); + + it.each([ + { provider: "anthropic", mode: "api_key" as const }, + { provider: "openai", mode: "oauth" as const }, + ])("rejects a bound profile with conflicting $provider/$mode metadata", ({ mode, provider }) => { + expect( + evaluate({ + cfg: { + auth: { + profiles: { + "openai:bound": { provider, mode }, + }, + }, + models: { + providers: { + openai: { apiKey: "openai:bound", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + store: authStore({ + "openai:bound": { + type: "api_key", + provider: "openai", + key: "bound-platform-key", + }, + }), + }), + ).toMatchObject({ + availability: false, + evidence: "profile", + selectedAuthMode: "api_key", + selectedProfileId: "openai:bound", + selectedRoute: platformRoute, + }); + }); + + it("keeps an automatic Platform profile ahead of a non-explicit literal fallback", () => { + const cfg = { + models: { + providers: { + openai: { + apiKey: "configured-platform-key", + baseUrl: platformRoute.baseUrl, + models: [], + }, + }, + }, + } as OpenClawConfig; + + expect( + evaluate({ + cfg, + store: authStore({ + "openai:platform": { + type: "api_key", + provider: "openai", + key: "profile-key", + }, + }), + }), + ).toMatchObject({ + availability: true, + evidence: "profile", + selectedProfileId: "openai:platform", + selectedRoute: platformRoute, + }); + }); + + it("uses explicit OAuth mode for literal provider material", () => { + const cfg = { + models: { + providers: { + openai: { + auth: "oauth", + apiKey: "configured-oauth-token", + models: [], + }, + }, + }, + }; + + expect(evaluate({ cfg })).toMatchObject({ + availability: true, + evidence: "provider-config", + selectedAuthMode: "oauth", + selectedRoute: subscriptionRoute, + }); + }); + + it("uses configured OAuth direct material after an unavailable API profile", () => { + const cfg = { + models: { + providers: { + openai: { + auth: "oauth", + apiKey: "configured-oauth-token", + models: [], + }, + }, + }, + }; + + expect( + evaluate({ + cfg, + store: authStore({ + "openai:platform-missing": { + type: "api_key", + provider: "openai", + key: "", + }, + }), + }), + ).toMatchObject({ + availability: true, + evidence: "provider-config", + selectedAuthMode: "oauth", + selectedRoute: subscriptionRoute, + }); + }); + + it("treats preferred and locked profiles as distinct source-order facts", () => { + const store = authStore( + { + "openai:platform": { type: "api_key", provider: "openai", key: "platform-key" }, + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }, + { openai: ["openai:platform", "openai:chatgpt"] }, + ); + + expect(evaluate({ store, ref: { preferredProfileId: "openai:chatgpt" } })).toMatchObject({ + selectedProfileId: "openai:chatgpt", + selectedRoute: subscriptionRoute, + }); + expect( + evaluate({ + store, + ref: { + preferredProfileId: "openai:chatgpt", + lockedProfileId: "openai:platform", + }, + }), + ).toMatchObject({ + selectedProfileId: "openai:platform", + selectedRoute: platformRoute, + }); + }); + + it("falls through an unavailable preferred profile to the configured order", () => { + const store = authStore({ + "openai:platform": { type: "api_key", provider: "openai", key: "platform-key" }, + "openai:expired": { + type: "oauth", + provider: "openai", + access: "expired-access", + expires: Date.now() - 60_000, + }, + }); + + expect( + evaluate({ + cfg: { auth: { order: { openai: ["openai:platform", "openai:expired"] } } }, + store, + ref: { preferredProfileId: "openai:expired" }, + }), + ).toMatchObject({ + availability: true, + selectedProfileId: "openai:platform", + selectedRoute: platformRoute, + }); + }); + + it("classifies direct environment auth as Platform API-key evidence", () => { + expect(evaluate({ env: { OPENAI_API_KEY: "environment-key" } })).toMatchObject({ + availability: true, + evidence: "environment", + selectedAuthMode: "api-key", + selectedRoute: platformRoute, + }); + }); + + it("keeps ambient environment auth ahead of non-explicit provider material", () => { + expect( + evaluate({ + cfg: { + models: { + providers: { + openai: { apiKey: "configured-platform-key", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + env: { OPENAI_API_KEY: "environment-key" }, + }), + ).toMatchObject({ + availability: true, + evidence: "environment", + selectedAuthMode: "api-key", + selectedRoute: platformRoute, + }); + }); + + it.each([ + { + label: "Platform environment after unavailable OAuth", + env: { OPENAI_API_KEY: "environment-key" }, + profileId: "openai:oauth-missing", + profile: { type: "oauth" as const, provider: "openai", access: "", refresh: "" }, + route: platformRoute, + mode: "api-key", + }, + { + label: "OAuth environment after unavailable Platform auth", + cfg: { + models: { providers: { openai: { auth: "oauth", baseUrl: "", models: [] } } }, + } as OpenClawConfig, + env: { OPENAI_API_KEY: "environment-token" }, + profileId: "openai:platform-missing", + profile: { type: "api_key" as const, provider: "openai", key: "" }, + route: subscriptionRoute, + mode: "oauth", + }, + ])("selects $label", ({ cfg, env, mode, profile, profileId, route }) => { + expect(evaluate({ cfg, env, store: authStore({ [profileId]: profile }) })).toMatchObject({ + availability: true, + evidence: "environment", + selectedAuthMode: mode, + selectedRoute: route, + }); + }); + + it.each([ + { env: { OPENAI_API_KEY: "resolved-key" }, availability: true }, + { env: {}, availability: undefined }, + ])("reports a SecretRef profile as $availability", ({ availability, env }) => { + expect( + evaluate({ + env, + store: authStore({ + "openai:ref": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }), + }), + ).toMatchObject({ + availability, + evidence: "profile", + selectedProfileId: "openai:ref", + selectedRoute: platformRoute, + }); + }); + + it("keeps a ref-only OAuth profile indeterminate until runtime hydration", () => { + expect( + evaluate({ + store: authStore({ + "openai:legacy-ref": { + type: "oauth", + provider: "openai", + access: "", + refresh: "", + expires: 0, + oauthRef: { + source: "openclaw-credentials", + provider: "openai-codex", + id: "00000000000000000000000000000000", + }, + }, + }), + }), + ).toMatchObject({ + availability: undefined, + evidence: "profile", + selectedAuthMode: "oauth", + selectedProfileId: "openai:legacy-ref", + selectedRoute: subscriptionRoute, + }); + }); + + it("does not borrow usable auth from a later sibling route after an unresolved ordered profile", () => { + const result = evaluate({ + cfg: { auth: { order: { openai: ["openai:unknown", "openai:chatgpt"] } } }, + store: authStore({ + "openai:unknown": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "MISSING_OPENAI_KEY" }, + }, + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }), + }); + + expect(result).toMatchObject({ + availability: undefined, + evidence: "profile", + selectedProfileId: "openai:unknown", + selectedRoute: platformRoute, + }); + }); + + it("skips a definitively invalid profile before selecting a usable sibling route", () => { + expect( + evaluate({ + cfg: { auth: { order: { openai: ["openai:invalid", "openai:chatgpt"] } } }, + store: authStore({ + "openai:invalid": { type: "api_key", provider: "openai", key: "" }, + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }), + }), + ).toMatchObject({ + availability: true, + selectedProfileId: "openai:chatgpt", + selectedRoute: subscriptionRoute, + }); + }); + + it("passes one physical route group to auth selection for an unknown model", () => { + const resolveRoutes = vi.fn(() => dualRoutes); + const resolver = createModelAuthAvailabilityResolver({ + cfg: { auth: { order: { openai: ["openai:chatgpt", "openai:platform"] } } }, + authStore: authStore({ + "openai:platform": { type: "api_key", provider: "openai", key: "platform-key" }, + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }), + env: {}, + routeResolverFactory: (() => resolveRoutes) as typeof createOpenAIModelRoutesResolver, + }); + const observedRoutes = [ + { api: "openai-chatgpt-responses" as const, baseUrl: subscriptionRoute.baseUrl }, + { api: "openai-responses" as const, baseUrl: platformRoute.baseUrl }, + ]; + + expect( + resolver.evaluateModelAuth("openai", { + modelId: "gpt-future-observed", + observedRoutes, + }), + ).toMatchObject({ + availability: true, + selectedProfileId: "openai:chatgpt", + selectedRoute: subscriptionRoute, + }); + expect(resolveRoutes).toHaveBeenCalledOnce(); + expect(resolveRoutes).toHaveBeenCalledWith({ + modelId: "gpt-future-observed", + observedRoutes, + }); + }); + + it("keeps Codex synthetic auth indeterminate until the native account is read", () => { + const result = evaluate({ syntheticAuthProviderRefs: ["codex"] }); + expect(result).toMatchObject({ + availability: undefined, + evidence: "synthetic", + routeResolution: dualRoutes, + }); + expect(result).not.toHaveProperty("selectedAuthMode"); + expect(result).not.toHaveProperty("selectedRoute"); + }); + + it("does not let invalid automatic profile evidence block synthetic Codex ownership", () => { + expect( + evaluate({ + store: authStore({ + "openai:invalid": { type: "api_key", provider: "openai", key: "" }, + }), + syntheticAuthProviderRefs: ["codex"], + }), + ).toMatchObject({ + availability: undefined, + evidence: "synthetic", + routeResolution: dualRoutes, + }); + }); + + it("does not let Codex synthetic auth own an OpenClaw-only route", () => { + const openClawOnlyRoute = { + ...platformRoute, + runtimePolicy: { compatibleIds: ["openclaw"] }, + } satisfies ProviderModelRouteCandidate; + expect( + evaluate({ + resolution: { + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [openClawOnlyRoute], + }, + syntheticAuthProviderRefs: ["codex"], + }), + ).toMatchObject({ + availability: false, + selectedRoute: openClawOnlyRoute, + }); + }); + + it.each([ + { + label: "explicit", + cfg: { + models: { + providers: { + "amazon-bedrock": { + api: "bedrock-converse-stream", + auth: "aws-sdk", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + models: [], + }, + }, + }, + } as OpenClawConfig, + }, + { label: "implicit", cfg: {} }, + ])("keeps an $label Bedrock AWS SDK route ready", ({ cfg }) => { + const result = createModelAuthAvailabilityResolver({ + cfg, + authStore: authStore(), + env: {}, + }).evaluateModelAuth("amazon-bedrock", { api: "bedrock-converse-stream" }); + + expect(result).toMatchObject({ + availability: true, + evidence: "aws-sdk", + routeResolution: null, + selectedAuthMode: "aws-sdk", + }); + }); + + it("keeps a non-OpenAI provider SecretRef unresolved without reading it", () => { + const result = createModelAuthAvailabilityResolver({ + cfg: { + models: { + providers: { + anthropic: { + api: "anthropic-messages", + apiKey: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" }, + baseUrl: "https://api.anthropic.com", + models: [], + }, + }, + }, + secrets: { providers: { default: { source: "env" } } }, + }, + authStore: authStore(), + env: {}, + }).evaluateModelAuth("anthropic", { + modelId: "claude-sonnet-4-6", + api: "anthropic-messages", + }); + + expect(result).toMatchObject({ + availability: undefined, + evidence: "provider-config", + routeResolution: null, + selectedAuthMode: "api-key", + }); + }); +}); diff --git a/src/agents/model-auth-availability.ts b/src/agents/model-auth-availability.ts new file mode 100644 index 000000000000..9eadfabe637a --- /dev/null +++ b/src/agents/model-auth-availability.ts @@ -0,0 +1,1020 @@ +/** Read-only provider/model auth availability with provider-route selection. */ +import { + findNormalizedProviderValue, + normalizeProviderIdForAuth, +} from "@openclaw/model-catalog-core/provider-id"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { resolveMergedModelProviderConfig } from "../config/model-provider-config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { coerceSecretRef } from "../config/types.secrets.js"; +import type { + ProviderModelRouteAuthRequirement, + ProviderModelRouteCandidate, + ProviderModelRouteResolution, + ProviderModelRouteSource, +} from "../plugin-sdk/provider-model-types.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import { isValidSecretRef } from "../secrets/ref-contract.js"; +import { + isConfiguredAwsSdkAuthProfileForProvider, + getRuntimeAuthProfileStoreSnapshot, + resolveAuthProfileEligibility, +} from "./auth-profiles.js"; +import { hasUsableOAuthCredential } from "./auth-profiles/credential-state.js"; +import { resolveExternalCliAuthProfiles } from "./auth-profiles/external-cli-sync.js"; +import { + type AuthProfileOrderResolution, + resolveAuthProfileOrderWithMetadata, +} from "./auth-profiles/order.js"; +import { + hasMalformedSecretInputSyntax, + resolveSecretRefReadOnlyAvailability, + resolveStoredCredentialReadOnlyAvailability, +} from "./auth-profiles/read-only-availability.js"; +import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js"; +import { isProfileInCooldown } from "./auth-profiles/usage-state.js"; +import { resolveProviderEnvAuthLookupMaps } from "./model-auth-env-vars.js"; +import { resolveProviderEnvAuthEvidence } from "./model-auth-env.js"; +import { isKnownEnvApiKeyMarker, isSecretRefHeaderValueMarker } from "./model-auth-markers.js"; +import { + hasUsableCustomProviderApiKey, + hasRuntimeAvailableProviderAuth, + hasSyntheticLocalProviderAuthConfig, + resolveProviderEntryApiKeyProfileReference, + shouldPreferExplicitConfigApiKeyAuth, +} from "./model-auth.js"; +import { splitTrailingAuthProfile } from "./model-ref-profile.js"; +import { + createOpenAIModelRoutesResolver, + resolveConfiguredOpenAIAuthMode, + selectOpenAIModelRouteAuth, +} from "./openai-model-routes.js"; +import { + buildProviderModelAuthDirectSource, + buildProviderModelAuthSourcePlan, + fromProviderModelAuthReadiness, + toProviderModelAuthReadiness, + type ProviderModelAuthEvidence, + type ProviderModelAuthProfileSource, +} from "./provider-model-auth-source-plan.js"; +import { + resolveProviderModelRouteAuthRequirement, + selectProviderModelAuthSources, + type ProviderModelAuthSourceSelection, +} from "./provider-model-route-auth.js"; + +const OPENAI_PROVIDER_ID = "openai"; +const OPENAI_CODEX_RESPONSES_API = "openai-chatgpt-responses"; + +export type ModelAuthAvailability = boolean | undefined; +type ModelAuthAvailabilityEvidence = Exclude; +export type ModelAuthAvailabilityRef = { + modelId?: string; + api?: string | null; + baseUrl?: unknown; + /** All physical route rows observed for this logical provider/model pair. */ + observedRoutes?: readonly ProviderModelRouteSource[]; + /** Automatic session preference; considered before the configured profile order. */ + preferredProfileId?: string; + /** Explicit user/session lock; model-id suffixes are transport identity only. */ + lockedProfileId?: string; +}; +export type ModelAuthAvailabilityEvaluation = { + availability: ModelAuthAvailability; + routeResolution: ProviderModelRouteResolution | null; + selectedRoute?: ProviderModelRouteCandidate; + selectedProfileId?: string; + selectedAuthMode?: string; + evidence?: ModelAuthAvailabilityEvidence; +}; +export type ModelAuthAvailabilityResolver = { + evaluateModelAuth( + provider: string, + ref?: ModelAuthAvailabilityRef, + ): ModelAuthAvailabilityEvaluation; + resolveProviderAuthAvailability( + provider: string, + ref?: ModelAuthAvailabilityRef, + ): ModelAuthAvailability; + hasSyntheticAuth(provider: string): boolean; +}; +type CreateModelAuthAvailabilityResolverParams = { + cfg: OpenClawConfig; + authStore: AuthProfileStore; + agentDir?: string; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + syntheticAuthProviderRefs?: readonly string[]; + metadataSnapshot?: PluginMetadataSnapshot; + skipSetupProviderFallback?: boolean; + externalCliProviderIds?: readonly string[]; + routeResolverFactory?: typeof createOpenAIModelRoutesResolver; + allowPreparedRuntimeAuth?: boolean; +}; + +type AuthTarget = ModelAuthAvailabilityRef & { + authRequirement?: ProviderModelRouteAuthRequirement; +}; +type AuthSourceEvaluation = Pick< + ModelAuthAvailabilityEvaluation, + "availability" | "selectedAuthMode" | "evidence" | "selectedProfileId" +>; + +function hasSecret(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function modeAllowed(provider: string, target: AuthTarget, mode: string | undefined): boolean { + const requirement = resolveProviderModelRouteAuthRequirement(mode); + return target.authRequirement + ? requirement === target.authRequirement + : provider !== OPENAI_PROVIDER_ID || + target.api === undefined || + target.api === OPENAI_CODEX_RESPONSES_API || + requirement === "api-key"; +} + +function normalizeModelIdForProvider(provider: string, modelId: string): string | undefined { + const trimmed = splitTrailingAuthProfile(modelId).model.trim(); + if (!trimmed) { + return undefined; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0) { + return trimmed; + } + return normalizeProviderIdForAuth(trimmed.slice(0, slash)) === provider + ? trimmed.slice(slash + 1).trim() || undefined + : undefined; +} + +/** Builds one snapshot-scoped read-only auth evaluator. */ +export function createModelAuthAvailabilityResolver( + params: CreateModelAuthAvailabilityResolverParams, +): ModelAuthAvailabilityResolver { + const env = params.env ?? process.env; + const now = Date.now(); + const external = params.externalCliProviderIds?.length + ? resolveExternalCliAuthProfiles(params.authStore, { + allowKeychainPrompt: false, + providerIds: [...params.externalCliProviderIds], + }) + : []; + const store: AuthProfileStore = external.length + ? { + ...params.authStore, + profiles: { + ...params.authStore.profiles, + ...Object.fromEntries(external.map((item) => [item.profileId, item.credential])), + }, + } + : params.authStore; + const runtimeStore = + params.allowPreparedRuntimeAuth !== false + ? getRuntimeAuthProfileStoreSnapshot(params.agentDir) + : undefined; + const hydratedProfileIds = new Set(); + const sameSecretRef = ( + left: ReturnType, + right: ReturnType, + ) => + left !== null && + right !== null && + left.source === right.source && + left.provider === right.provider && + left.id === right.id; + const runtimeCredentialOverlay = ( + profileId: string, + credential: AuthProfileCredential, + ): AuthProfileCredential => { + const runtime = runtimeStore?.profiles[profileId]; + if (!runtime || credential.type !== runtime.type || credential.provider !== runtime.provider) { + return credential; + } + // The snapshot key plus profile id and provider/type establish runtime ownership. + // Only ref-only stubs bootstrap; inline persisted OAuth remains authoritative. + if ( + credential.type === "oauth" && + runtime.type === "oauth" && + credential.oauthRef && + !hasSecret(credential.access) && + !hasSecret(credential.refresh) && + hasUsableOAuthCredential(runtime, { now }) + ) { + return runtime; + } + if ( + credential.type === "api_key" && + runtime.type === "api_key" && + sameSecretRef( + coerceSecretRef(credential.keyRef ?? credential.key, params.cfg.secrets?.defaults), + coerceSecretRef(runtime.keyRef, params.cfg.secrets?.defaults), + ) && + hasSecret(runtime.key) + ) { + hydratedProfileIds.add(profileId); + return { ...credential, key: runtime.key }; + } + if ( + credential.type === "token" && + runtime.type === "token" && + sameSecretRef( + coerceSecretRef(credential.tokenRef ?? credential.token, params.cfg.secrets?.defaults), + coerceSecretRef(runtime.tokenRef, params.cfg.secrets?.defaults), + ) && + hasSecret(runtime.token) + ) { + hydratedProfileIds.add(profileId); + return { ...credential, token: runtime.token }; + } + return credential; + }; + const orderProfiles = runtimeStore + ? Object.fromEntries( + Object.entries(store.profiles).map(([profileId, credential]) => [ + profileId, + runtimeCredentialOverlay(profileId, credential), + ]), + ) + : store.profiles; + const orderBaseStore = + orderProfiles === store.profiles ? store : { ...store, profiles: orderProfiles }; + const orderStore: AuthProfileStore = orderBaseStore.usageStats + ? { + ...orderBaseStore, + usageStats: Object.fromEntries( + Object.entries(orderBaseStore.usageStats).map(([id, stats]) => [id, { ...stats }]), + ), + } + : orderBaseStore; + const { aliasMap, envCandidateMap, authEvidenceMap } = resolveProviderEnvAuthLookupMaps({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env, + metadataSnapshot: params.metadataSnapshot, + }); + const synthetic = new Set( + (params.syntheticAuthProviderRefs ?? []).map(normalizeProviderIdForAuth), + ); + if ( + resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model)?.split("/", 1)[0] === "codex" + ) { + synthetic.add("codex"); + } + const resolveRoutes = (params.routeResolverFactory ?? createOpenAIModelRoutesResolver)({ + config: params.cfg, + env, + }); + const envCache = new Map>(); + const orderCache = new Map(); + const normalizeProvider = (provider: string) => { + const normalized = normalizeProviderIdForAuth(provider); + return aliasMap[normalized] ?? normalized; + }; + const providerConfig = (provider: string) => + resolveMergedModelProviderConfig(params.cfg, provider); + const prepareAuthTarget = (provider: string, ref: ModelAuthAvailabilityRef): AuthTarget => { + const configured = providerConfig(provider); + const configuredModelId = ref.modelId + ? normalizeModelIdForProvider(provider, ref.modelId) + : undefined; + const configuredModel = configuredModelId + ? configured?.models?.find( + (model) => normalizeModelIdForProvider(provider, model.id) === configuredModelId, + ) + : undefined; + return { + ...ref, + api: ref.api ?? configuredModel?.api ?? configured?.api, + baseUrl: ref.baseUrl ?? configuredModel?.baseUrl ?? configured?.baseUrl, + }; + }; + const providerBinding = (provider: string) => + resolveProviderEntryApiKeyProfileReference({ + cfg: params.cfg, + provider, + store, + }); + const envAuth = (provider: string) => { + const normalized = normalizeProvider(provider); + if (!envCache.has(normalized)) { + envCache.set( + normalized, + resolveProviderEnvAuthEvidence(normalized, env, { + aliasMap, + candidateMap: envCandidateMap, + authEvidenceMap, + config: params.cfg, + workspaceDir: params.workspaceDir, + }), + ); + } + return envCache.get(normalized); + }; + const profileOrder = ( + provider: string, + forModel?: string, + preferredProfileId?: string, + lockedProfileId?: string, + ) => { + const normalized = normalizeProvider(provider); + const cacheKey = `${normalized}\u0000${forModel ?? ""}\u0000${preferredProfileId ?? ""}\u0000${lockedProfileId ?? ""}`; + const cached = orderCache.get(cacheKey); + if (cached) { + return cached; + } + const resolution = resolveAuthProfileOrderWithMetadata({ + cfg: params.cfg, + store: orderStore, + provider: normalized, + preferredProfile: preferredProfileId, + forModel, + readinessMode: "read-only", + }); + orderCache.set(cacheKey, resolution); + return resolution; + }; + const profileMode = (profileId: string) => + store.profiles[profileId]?.type ?? params.cfg.auth?.profiles?.[profileId]?.mode; + const profileCredential = ( + profileId: string, + credential = store.profiles[profileId], + ): AuthProfileCredential | undefined => { + return credential ? runtimeCredentialOverlay(profileId, credential) : undefined; + }; + const profileEligibleForReadOnlyAvailability = ( + provider: string, + profileId: string, + credential: AuthProfileCredential, + ) => { + const effectiveStore = + store.profiles[profileId] === credential + ? store + : { ...store, profiles: { ...store.profiles, [profileId]: credential } }; + const eligibility = resolveAuthProfileEligibility({ + cfg: params.cfg, + store: effectiveStore, + provider: normalizeProvider(provider), + profileId, + now, + }); + // Runtime execution still rejects unresolved refs. Browse/status keeps them + // structurally eligible so the read-only credential classifier can return unknown. + return eligibility.eligible || eligibility.reasonCode === "unresolved_ref"; + }; + const credentialAvailability = ( + provider: string, + credential: AuthProfileCredential, + target: AuthTarget, + ): ModelAuthAvailability => { + if (!modeAllowed(provider, target, credential.type)) { + return false; + } + return resolveStoredCredentialReadOnlyAvailability({ + credential, + cfg: params.cfg, + env, + now, + canRefreshOAuth: provider === OPENAI_PROVIDER_ID, + }); + }; + const resolvedProfileAvailability = ( + provider: string, + profileId: string, + credential: AuthProfileCredential, + target: AuthTarget, + ) => { + if (!hydratedProfileIds.has(profileId)) { + return credentialAvailability(provider, credential, target); + } + if (!modeAllowed(provider, target, credential.type)) { + return false; + } + return ( + credential.type !== "token" || credential.expires === undefined || credential.expires > now + ); + }; + const profileInCooldown = (profileId: string, target: AuthTarget) => { + const cooldownModel = target.modelId + ? splitTrailingAuthProfile(target.modelId).model + : undefined; + return isProfileInCooldown(store, profileId, now, cooldownModel); + }; + const profileAvailability = ( + provider: string, + profileId: string, + target: AuthTarget, + allowCooldown = false, + ): ModelAuthAvailability => { + if (!allowCooldown && profileInCooldown(profileId, target)) { + return false; + } + if (isConfiguredAwsSdkAuthProfileForProvider({ cfg: params.cfg, provider, profileId })) { + return modeAllowed(provider, target, "aws-sdk"); + } + const credential = profileCredential(profileId); + if (!credential || !profileEligibleForReadOnlyAvailability(provider, profileId, credential)) { + return false; + } + return resolvedProfileAvailability(provider, profileId, credential, target); + }; + const hasProfileEvidence = (provider: string) => { + const normalized = normalizeProvider(provider); + const configuredOrder = findNormalizedProviderValue(params.cfg.auth?.order, normalized); + if (configuredOrder !== undefined) { + return true; + } + if ( + Object.values(params.cfg.auth?.profiles ?? {}).some( + (profile) => normalizeProvider(profile.provider) === normalized, + ) + ) { + return true; + } + return Object.keys(store.profiles).some((profileId) => { + const reason = resolveAuthProfileEligibility({ + cfg: params.cfg, + store, + provider: normalized, + profileId, + }).reasonCode; + return reason !== "provider_mismatch" && reason !== "profile_missing"; + }); + }; + const firstProfileEvidenceId = (provider: string): string | undefined => { + const normalized = normalizeProvider(provider); + const configuredOrder = findNormalizedProviderValue(params.cfg.auth?.order, normalized); + const storedOrder = findNormalizedProviderValue(store.order, normalized); + const candidates = configuredOrder ?? storedOrder ?? Object.keys(store.profiles); + return candidates.find((profileId) => { + const reason = resolveAuthProfileEligibility({ + cfg: params.cfg, + store, + provider: normalized, + profileId, + }).reasonCode; + return reason !== "provider_mismatch" && reason !== "profile_missing"; + }); + }; + const unprofiledEvaluation = (provider: string, target: AuthTarget): AuthSourceEvaluation => { + const configured = providerConfig(provider); + if (configured?.auth === "aws-sdk") { + return { + availability: modeAllowed(provider, target, "aws-sdk"), + selectedAuthMode: "aws-sdk", + evidence: "aws-sdk", + }; + } + const apiKey = configured?.apiKey; + const configuredBearerMode = + configured?.auth === "api-key" || configured?.auth === "oauth" || configured?.auth === "token" + ? configured.auth + : "api-key"; + const apiKeyRef = coerceSecretRef(apiKey, params.cfg.secrets?.defaults); + if (!apiKeyRef && hasMalformedSecretInputSyntax(apiKey)) { + return { availability: false, evidence: "provider-config" }; + } + const binding = providerBinding(provider); + if (binding.kind === "profile") { + const credential = profileCredential(binding.profileId, binding.credential); + const cooldownModel = target.modelId + ? splitTrailingAuthProfile(target.modelId).model + : undefined; + const availability = + credential && + !isProfileInCooldown(store, binding.profileId, now, cooldownModel) && + profileEligibleForReadOnlyAvailability( + binding.credential.provider, + binding.profileId, + credential, + ) + ? resolvedProfileAvailability(provider, binding.profileId, credential, target) + : false; + return { + availability, + selectedProfileId: binding.profileId, + selectedAuthMode: credential?.type ?? binding.credential.type, + evidence: "profile", + }; + } + if (binding.kind === "profile-incompatible") { + return { availability: false, evidence: "profile" }; + } + if (binding.kind === "literal") { + return { + availability: modeAllowed(provider, target, configuredBearerMode), + selectedAuthMode: configuredBearerMode, + evidence: "provider-config", + }; + } + if (binding.kind === "marker") { + if (typeof apiKey === "string" && isKnownEnvApiKeyMarker(apiKey)) { + return { + availability: modeAllowed(provider, target, configuredBearerMode) + ? hasSecret(env[apiKey.trim()]) + : false, + selectedAuthMode: configuredBearerMode, + evidence: "environment", + }; + } + if (!modeAllowed(provider, target, configuredBearerMode)) { + return { + availability: false, + selectedAuthMode: configuredBearerMode, + evidence: "synthetic", + }; + } + if (hasUsableCustomProviderApiKey(params.cfg, provider, env)) { + return { + availability: true, + selectedAuthMode: configuredBearerMode, + evidence: "synthetic", + }; + } + const managed = typeof apiKey === "string" && isSecretRefHeaderValueMarker(apiKey); + return { + availability: managed + ? hasRuntimeAvailableProviderAuth({ + provider, + modelApi: target.api ?? undefined, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + env, + allowPluginSyntheticAuth: false, + }) || undefined + : undefined, + selectedAuthMode: configuredBearerMode, + evidence: managed ? "runtime" : "synthetic", + }; + } + if (apiKeyRef) { + if (!isValidSecretRef(apiKeyRef) || !modeAllowed(provider, target, configuredBearerMode)) { + return { + availability: false, + selectedAuthMode: configuredBearerMode, + evidence: "provider-config", + }; + } + const available = resolveSecretRefReadOnlyAvailability(apiKeyRef, params.cfg, env); + const runtimeAvailable = hasRuntimeAvailableProviderAuth({ + provider, + modelApi: target.api ?? undefined, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + env, + allowPluginSyntheticAuth: false, + }); + return { + availability: runtimeAvailable ? true : available, + selectedAuthMode: configuredBearerMode, + evidence: runtimeAvailable ? "runtime" : "provider-config", + }; + } + if (apiKey !== undefined && !(typeof apiKey === "string" && apiKey.trim() === "")) { + return { availability: false, evidence: "provider-config" }; + } + if ( + provider === "amazon-bedrock" && + (target.api === undefined || target.api === "bedrock-converse-stream") && + configured?.auth === undefined && + apiKey === undefined + ) { + return { + availability: modeAllowed(provider, target, "aws-sdk"), + selectedAuthMode: "aws-sdk", + evidence: "aws-sdk", + }; + } + const environment = envAuth(provider); + if (environment) { + if (provider === "amazon-bedrock" && environment.mode === "aws-sdk") { + return { + availability: modeAllowed(provider, target, "aws-sdk"), + selectedAuthMode: "aws-sdk", + evidence: "aws-sdk", + }; + } + const mode = configured?.auth ?? environment.mode; + return { + availability: modeAllowed(provider, target, mode), + selectedAuthMode: mode, + evidence: "environment", + }; + } + const hasCompatibleCodexSyntheticAuth = + provider === OPENAI_PROVIDER_ID && + synthetic.has("codex") && + (target.authRequirement === "subscription" || target.api === OPENAI_CODEX_RESPONSES_API); + if ( + hasSyntheticLocalProviderAuthConfig({ cfg: params.cfg, provider }) || + synthetic.has(normalizeProvider(provider)) || + hasCompatibleCodexSyntheticAuth + ) { + return { availability: undefined, evidence: "synthetic" }; + } + const hasConfiguredAuthEvidence = + configured?.auth !== undefined || + (apiKey !== undefined && !(typeof apiKey === "string" && apiKey.trim() === "")); + return { + availability: hasConfiguredAuthEvidence || hasProfileEvidence(provider) ? false : undefined, + selectedAuthMode: configured?.auth, + }; + }; + const directSource = (evaluation: AuthSourceEvaluation) => + buildProviderModelAuthDirectSource({ + mode: evaluation.selectedAuthMode, + availability: evaluation.availability, + evidence: evaluation.evidence ?? "none", + }); + const automaticProfileSource = ( + provider: string, + profileId: string, + target: AuthTarget, + ): ProviderModelAuthProfileSource => ({ + kind: "profile", + profileId, + mode: profileMode(profileId), + readiness: toProviderModelAuthReadiness(profileAvailability(provider, profileId, target, true)), + cooldown: profileInCooldown(profileId, target) ? "active" : "clear", + }); + const requiredProfileSource = ( + provider: string, + profileId: string, + target: AuthTarget, + ignoreCooldown: boolean, + ): ProviderModelAuthProfileSource => ({ + kind: "profile", + profileId, + mode: profileMode(profileId), + readiness: toProviderModelAuthReadiness( + profileAvailability(provider, profileId, target, ignoreCooldown), + ), + cooldown: "clear", + }); + const sourceEvaluation = (selection: ProviderModelAuthSourceSelection): AuthSourceEvaluation => { + if (selection.kind === "none") { + return { availability: undefined }; + } + const source = selection.source; + if (source.kind === "profile") { + return { + availability: + selection.kind === "unavailable" + ? false + : fromProviderModelAuthReadiness(source.readiness), + selectedProfileId: source.profileId, + selectedAuthMode: source.mode, + evidence: "profile", + }; + } + return { + availability: fromProviderModelAuthReadiness(source.readiness), + selectedAuthMode: source.mode, + ...(source.evidence === "none" ? {} : { evidence: source.evidence }), + }; + }; + const directPolicy = (provider: string, target: AuthTarget) => { + const configured = providerConfig(provider); + const binding = providerBinding(provider); + const apiKeyRef = coerceSecretRef(configured?.apiKey, params.cfg.secrets?.defaults); + const markerUsable = + binding.kind === "marker" && hasUsableCustomProviderApiKey(params.cfg, provider, env); + const hasDirectMaterial = binding.kind === "literal" || markerUsable || apiKeyRef !== null; + const required = + configured?.auth === "aws-sdk" || + markerUsable || + (hasDirectMaterial && shouldPreferExplicitConfigApiKeyAuth(params.cfg, provider)); + const environment = envAuth(provider); + const environmentMode = environment ? (configured?.auth ?? environment.mode) : undefined; + const direct = + !required && environmentMode + ? buildProviderModelAuthDirectSource({ + mode: environmentMode, + availability: modeAllowed(provider, target, environmentMode), + evidence: environmentMode === "aws-sdk" ? "aws-sdk" : "environment", + }) + : directSource(unprofiledEvaluation(provider, target)); + const hasDirectFallback = hasDirectMaterial || direct.evidence !== "none"; + return { + binding, + direct, + hasDirectMaterial, + hasDirectFallback, + markerUsable, + required, + }; + }; + const automaticSourceRejection = ( + provider: string, + ref: ModelAuthAvailabilityRef, + target: AuthTarget, + ) => { + if (ref.lockedProfileId?.trim()) { + return undefined; + } + const policy = directPolicy(provider, target); + if ( + policy.required || + policy.binding.kind === "profile" || + policy.binding.kind === "profile-incompatible" + ) { + return undefined; + } + const orderResolution = profileOrder( + provider, + ref.modelId, + ref.preferredProfileId, + ref.lockedProfileId, + ); + const decision = selectProviderModelAuthSources({ + provider, + plan: buildProviderModelAuthSourcePlan({ + profiles: orderResolution.profileIds.map((profileId) => + automaticProfileSource(provider, profileId, target), + ), + preferredProfileId: ref.preferredProfileId, + explicitOrder: orderResolution.hasExplicitOrder, + ...(policy.hasDirectFallback ? { fallback: policy.direct } : {}), + }), + }); + return decision.kind === "rejected" ? decision : undefined; + }; + const resolveProviderEvaluation = ( + rawProvider: string, + ref: ModelAuthAvailabilityRef = {}, + preparedTarget?: AuthTarget, + ): AuthSourceEvaluation => { + const provider = normalizeProviderIdForAuth(rawProvider); + const target = preparedTarget ?? prepareAuthTarget(provider, ref); + const profileLock = ref.lockedProfileId?.trim(); + const policy = directPolicy(provider, target); + if (!profileLock && policy.binding.kind === "profile-incompatible") { + return { availability: false, evidence: "profile" }; + } + const orderResolution = profileOrder( + provider, + ref.modelId, + ref.preferredProfileId, + ref.lockedProfileId, + ); + const boundProfileId = + !profileLock && policy.binding.kind === "profile" ? policy.binding.profileId : undefined; + const ownership = profileLock + ? { + reason: "user-lock" as const, + source: requiredProfileSource(provider, profileLock, target, true), + } + : boundProfileId + ? { + reason: "provider-binding" as const, + source: requiredProfileSource(provider, boundProfileId, target, false), + } + : policy.required + ? { reason: "configured-auth" as const, source: policy.direct } + : undefined; + const sourcePlan = buildProviderModelAuthSourcePlan({ + ...(ownership ? { ownership } : {}), + profiles: orderResolution.profileIds.map((profileId) => + automaticProfileSource(provider, profileId, target), + ), + preferredProfileId: ref.preferredProfileId, + explicitOrder: orderResolution.hasExplicitOrder, + ...(policy.hasDirectFallback ? { fallback: policy.direct } : {}), + }); + const decision = selectProviderModelAuthSources({ provider, plan: sourcePlan }); + if (decision.kind === "rejected") { + return { + availability: false, + ...(decision.source + ? { + selectedProfileId: decision.source.profileId, + selectedAuthMode: decision.source.mode, + } + : {}), + evidence: "profile", + }; + } + return sourceEvaluation(decision.selection); + }; + // Provider-only availability is the legacy fallback when no route artifact exists; + // it never claims a concrete OpenAI endpoint. + const resolveProviderAuthAvailability = (provider: string, ref: ModelAuthAvailabilityRef = {}) => + resolveProviderEvaluation(provider, ref).availability; + const evaluateModelAuth = ( + rawProvider: string, + ref: ModelAuthAvailabilityRef = {}, + ): ModelAuthAvailabilityEvaluation => { + const provider = normalizeProviderIdForAuth(rawProvider); + if (provider !== OPENAI_PROVIDER_ID) { + return { + ...resolveProviderEvaluation(provider, ref), + routeResolution: null, + }; + } + const routeResolution = resolveRoutes(ref); + if (!routeResolution) { + // Provider policy owns route validation. Null preserves the legacy fallback + // signal without rebuilding a partial OpenAI policy in core. + return { availability: undefined, routeResolution: null }; + } + if (routeResolution.kind === "incompatible") { + return { availability: false, routeResolution }; + } + if (routeResolution.kind === "indeterminate") { + const rejection = automaticSourceRejection(provider, ref, prepareAuthTarget(provider, ref)); + if (rejection) { + return { + availability: false, + routeResolution, + ...(rejection.source + ? { + evidence: "profile" as const, + selectedAuthMode: rejection.source.mode, + selectedProfileId: rejection.source.profileId, + } + : { evidence: "profile" as const }), + }; + } + return { availability: undefined, routeResolution }; + } + const modelLock = ref.lockedProfileId?.trim(); + const configuredAuthMode = resolveConfiguredOpenAIAuthMode(params.cfg); + const awsSdkTerminal = !modelLock && configuredAuthMode === "aws-sdk"; + const baseTarget = prepareAuthTarget(provider, ref); + const basePolicy = directPolicy(provider, baseTarget); + if (!modelLock && !awsSdkTerminal && basePolicy.binding.kind === "profile-incompatible") { + return { availability: false, routeResolution }; + } + const bindingProfileId = + !modelLock && !awsSdkTerminal && basePolicy.binding.kind === "profile" + ? basePolicy.binding.profileId + : undefined; + const selectedConfiguredMode = awsSdkTerminal + ? "aws-sdk" + : bindingProfileId + ? undefined + : (configuredAuthMode ?? (basePolicy.hasDirectMaterial ? "api-key" : undefined)); + const automaticRouteAuthMode = + basePolicy.hasDirectFallback && configuredAuthMode && !basePolicy.required + ? undefined + : selectedConfiguredMode; + const targetForMode = (mode: string | undefined): AuthTarget => { + const requirement = resolveProviderModelRouteAuthRequirement(mode); + const route = requirement + ? routeResolution.routes.find((candidate) => candidate.authRequirement === requirement) + : undefined; + return route + ? { + ...ref, + api: route.api, + baseUrl: route.baseUrl, + authRequirement: route.authRequirement, + } + : baseTarget; + }; + const policy = directPolicy( + provider, + targetForMode(selectedConfiguredMode ?? basePolicy.direct.mode), + ); + const orderResolution = profileOrder( + provider, + ref.modelId, + ref.preferredProfileId, + ref.lockedProfileId, + ); + let profileIds = orderResolution.profileIds; + if (profileIds.length === 0 && !modelLock && !bindingProfileId && !policy.required) { + const evidenceProfileId = firstProfileEvidenceId(provider); + if (evidenceProfileId) { + profileIds = [evidenceProfileId]; + } + } + const ownership = modelLock + ? { + reason: "user-lock" as const, + source: requiredProfileSource( + provider, + modelLock, + targetForMode(profileMode(modelLock)), + true, + ), + } + : bindingProfileId + ? { + reason: "provider-binding" as const, + source: requiredProfileSource( + provider, + bindingProfileId, + targetForMode(profileMode(bindingProfileId)), + false, + ), + } + : policy.required + ? { reason: "configured-auth" as const, source: policy.direct } + : undefined; + const sourcePlan = buildProviderModelAuthSourcePlan({ + ...(ownership ? { ownership } : {}), + profiles: profileIds.map((profileId) => + automaticProfileSource(provider, profileId, targetForMode(profileMode(profileId))), + ), + preferredProfileId: ref.preferredProfileId, + explicitOrder: orderResolution.hasExplicitOrder, + ...(policy.hasDirectFallback ? { fallback: policy.direct } : {}), + }); + const syntheticCodexOwnsAuth = + !modelLock && + !selectedConfiguredMode && + (policy.binding.kind === "none" || + (policy.binding.kind === "marker" && !policy.markerUsable)) && + sourcePlan.kind === "automatic" && + !sourcePlan.profiles.explicitOrder && + (sourcePlan.profiles.kind === "empty" || sourcePlan.profiles.kind === "all-unavailable") && + synthetic.has("codex") && + routeResolution.routes.every((route) => + route.runtimePolicy?.compatibleIds?.some( + (runtimeId) => runtimeId.trim().toLowerCase() === "codex", + ), + ); + const routeAuthDecision = selectOpenAIModelRouteAuth({ + resolution: routeResolution, + sourcePlan, + configuredAuthMode: automaticRouteAuthMode, + ...(syntheticCodexOwnsAuth ? { runtimeAuthOwner: { id: "codex" } } : {}), + }); + if (routeAuthDecision.kind === "deferred" && syntheticCodexOwnsAuth) { + return { availability: undefined, routeResolution, evidence: "synthetic" }; + } + if (routeAuthDecision.kind !== "selected") { + const rejectedSource = + routeAuthDecision.kind === "rejected" ? routeAuthDecision.source : undefined; + const projectRejectedSource = + routeAuthDecision.kind === "rejected" && + rejectedSource && + (routeAuthDecision.reason === "all-cooldown" || rejectedSource.readiness === "unavailable") + ? rejectedSource + : undefined; + const rejectedRequirement = resolveProviderModelRouteAuthRequirement(rejectedSource?.mode); + const rejectedRoute = + routeAuthDecision.kind === "rejected" ? routeAuthDecision.route : undefined; + const rejectedSourceRoute = rejectedRequirement + ? routeResolution.routes.find( + (candidate) => candidate.authRequirement === rejectedRequirement, + ) + : undefined; + const selectedRoute = + rejectedRoute ?? + rejectedSourceRoute ?? + (routeResolution.routes.length === 1 ? routeResolution.routes[0] : undefined); + return { + availability: false, + routeResolution, + ...(projectRejectedSource + ? { + selectedProfileId: projectRejectedSource.profileId, + selectedAuthMode: projectRejectedSource.mode, + evidence: "profile" as const, + } + : {}), + ...(selectedRoute ? { selectedRoute } : {}), + }; + } + const selectedRoute = routeAuthDecision.selection.route; + const evaluation = sourceEvaluation(routeAuthDecision.selection); + const syntheticSubscriptionRoute = routeResolution.routes.find( + (route) => route.authRequirement === "subscription", + ); + if ( + syntheticCodexOwnsAuth && + evaluation.availability !== true && + synthetic.has("codex") && + syntheticSubscriptionRoute + ) { + return { + availability: undefined, + routeResolution, + evidence: "synthetic", + }; + } + return { + ...evaluation, + availability: + evaluation.availability === undefined && !evaluation.evidence + ? false + : evaluation.availability, + routeResolution, + selectedRoute, + }; + }; + return { + evaluateModelAuth, + resolveProviderAuthAvailability, + hasSyntheticAuth: (provider) => + synthetic.has(normalizeProviderIdForAuth(provider)) || + synthetic.has(normalizeProvider(provider)) || + (normalizeProviderIdForAuth(provider) === OPENAI_PROVIDER_ID && synthetic.has("codex")) || + hasSyntheticLocalProviderAuthConfig({ + cfg: params.cfg, + provider: normalizeProviderIdForAuth(provider), + }), + }; +} diff --git a/src/agents/model-auth-env.provider-aliases.test.ts b/src/agents/model-auth-env.provider-aliases.test.ts index c3cac5d3c94b..f48cf7b327c6 100644 --- a/src/agents/model-auth-env.provider-aliases.test.ts +++ b/src/agents/model-auth-env.provider-aliases.test.ts @@ -1,6 +1,10 @@ // Verifies env API-key lookup through plugin provider-auth aliases. import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveEnvApiKey } from "./model-auth-env.js"; +import { + resolveEnvApiKey, + resolveProviderDirectAuthPlanningEvidence, + resolveProviderEnvAuthEvidence, +} from "./model-auth-env.js"; const pluginMetadataMocks = vi.hoisted(() => { const snapshot = { @@ -116,4 +120,35 @@ describe("resolveEnvApiKey provider auth aliases", () => { env, }); }); + + it("reports injected env evidence without returning material or loading provider setup", () => { + expect( + resolveProviderEnvAuthEvidence( + "cloud-alias", + { EXTERNAL_CLOUD_API_KEY: "secret" } as NodeJS.ProcessEnv, + { + aliasMap: { "cloud-alias": "external-cloud" }, + candidateMap: { "external-cloud": ["EXTERNAL_CLOUD_API_KEY"] }, + authEvidenceMap: {}, + }, + ), + ).toEqual({ mode: "api-key", source: "env: EXTERNAL_CLOUD_API_KEY" }); + expect(pluginMetadataMocks.getCurrentPluginMetadataSnapshot).not.toHaveBeenCalled(); + expect(pluginMetadataMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); + expect(setupRegistryMocks.resolvePluginSetupProvider).not.toHaveBeenCalled(); + }); + + it("retains setup-provider fallback as deferred planning evidence without loading it", () => { + expect( + resolveProviderDirectAuthPlanningEvidence("cloud-alias", {} as NodeJS.ProcessEnv, { + aliasMap: { "cloud-alias": "external-cloud" }, + candidateMap: { "external-cloud": ["EXTERNAL_CLOUD_API_KEY"] }, + authEvidenceMap: {}, + setupProviderFallbackRefs: ["external-cloud"], + }), + ).toEqual({ kind: "setup-provider", mode: "api-key", source: "setup provider" }); + expect(pluginMetadataMocks.getCurrentPluginMetadataSnapshot).not.toHaveBeenCalled(); + expect(pluginMetadataMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); + expect(setupRegistryMocks.resolvePluginSetupProvider).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/model-auth-env.ts b/src/agents/model-auth-env.ts index be75724956a6..50c8c2a24b3b 100644 --- a/src/agents/model-auth-env.ts +++ b/src/agents/model-auth-env.ts @@ -20,12 +20,27 @@ export type EnvApiKeyResult = { source: string; }; +export type ProviderEnvAuthEvidence = { + mode: "api-key" | "aws-sdk" | "oauth"; + source: string; +}; + +/** Secret-free direct-auth fact retained for runtime credential resolution. */ +export type ProviderDirectAuthPlanningEvidence = + | ({ kind: "environment" } & ProviderEnvAuthEvidence) + | { + kind: "setup-provider"; + mode: "api-key"; + source: "setup provider"; + }; + export type EnvApiKeyLookupOptions = { config?: OpenClawConfig; workspaceDir?: string; aliasMap?: Readonly>; candidateMap?: Readonly>; authEvidenceMap?: Readonly>; + setupProviderFallbackRefs?: readonly string[]; skipSetupProviderFallback?: boolean; }; @@ -91,6 +106,96 @@ function resolveAuthEvidence( return null; } +/** Reports env/local auth presence without returning or resolving credential material. */ +export function resolveProviderEnvAuthEvidence( + provider: string, + env: NodeJS.ProcessEnv = process.env, + options: EnvApiKeyLookupOptions = {}, +): ProviderEnvAuthEvidence | null { + const providerId = normalizeProviderIdForAuth(provider); + const lookupMaps = + !options.aliasMap || !options.candidateMap || !options.authEvidenceMap + ? resolveProviderEnvAuthLookupMaps({ + config: options.config, + workspaceDir: options.workspaceDir, + env, + }) + : undefined; + const aliasMap = options.aliasMap ?? lookupMaps?.aliasMap ?? {}; + const normalized = aliasMap[providerId] ?? providerId; + const candidateMap = options.candidateMap ?? lookupMaps?.envCandidateMap ?? {}; + const authEvidenceMap = options.authEvidenceMap ?? lookupMaps?.authEvidenceMap ?? {}; + const applied = new Set(getShellEnvAppliedKeys()); + + for (const envVar of candidateMap[normalized] ?? []) { + if (!normalizeOptionalSecretInput(env[envVar])) { + continue; + } + const mode = + normalized === "amazon-bedrock" && envVar.startsWith("AWS_") + ? "aws-sdk" + : envVar.includes("OAUTH_TOKEN") + ? "oauth" + : "api-key"; + return { + mode, + source: applied.has(envVar) ? `shell env: ${envVar}` : `env: ${envVar}`, + }; + } + + for (const evidence of authEvidenceMap[normalized] ?? []) { + if (!hasRequiredAuthEvidenceEnv(evidence, env) || !hasLocalFileAuthEvidence(evidence, env)) { + continue; + } + return { + mode: normalized === "amazon-bedrock" ? "aws-sdk" : "api-key", + source: evidence.source ?? "local auth evidence", + }; + } + return null; +} + +/** + * Plans direct auth without loading a provider runtime or resolving credential material. + * Setup-provider refs are deferred evidence only; runtime lookup still decides availability. + */ +export function resolveProviderDirectAuthPlanningEvidence( + provider: string, + env: NodeJS.ProcessEnv = process.env, + options: EnvApiKeyLookupOptions = {}, +): ProviderDirectAuthPlanningEvidence | null { + const lookupMaps = + !options.aliasMap || + !options.candidateMap || + !options.authEvidenceMap || + !options.setupProviderFallbackRefs + ? resolveProviderEnvAuthLookupMaps({ + config: options.config, + workspaceDir: options.workspaceDir, + env, + }) + : undefined; + const aliasMap = options.aliasMap ?? lookupMaps?.aliasMap ?? {}; + const candidateMap = options.candidateMap ?? lookupMaps?.envCandidateMap ?? {}; + const authEvidenceMap = options.authEvidenceMap ?? lookupMaps?.authEvidenceMap ?? {}; + const concrete = resolveProviderEnvAuthEvidence(provider, env, { + aliasMap, + candidateMap, + authEvidenceMap, + }); + if (concrete) { + return { kind: "environment", ...concrete }; + } + + const providerId = normalizeProviderIdForAuth(provider); + const normalized = aliasMap[providerId] ?? providerId; + const setupProviderFallbackRefs = + options.setupProviderFallbackRefs ?? lookupMaps?.setupProviderFallbackRefs ?? []; + return setupProviderFallbackRefs.some((ref) => normalizeProviderIdForAuth(ref) === normalized) + ? { kind: "setup-provider", mode: "api-key", source: "setup provider" } + : null; +} + /** Resolve an API key or auth-evidence marker for a provider from environment state. */ export function resolveEnvApiKey( provider: string, diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index f725305fd240..5459c7adaffd 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -1328,6 +1328,102 @@ describe("resolveApiKeyForProvider", () => { }); }); + it("preserves explicit subscription modes for literal provider credentials", async () => { + for (const mode of ["oauth", "token"] as const) { + const provider = `custom-${mode}`; + const resolved = await getApiKeyForModel({ + model: { + id: "subscription-model", + provider, + api: "openai-completions", + } as Model, + cfg: { + models: { + providers: { + [provider]: { + auth: mode, + apiKey: "configured-subscription-credential", + baseUrl: "https://subscription.example/v1", + models: [], + }, + }, + }, + }, + store: { version: 1, profiles: {} }, + }); + + expect(resolved).toMatchObject({ + apiKey: "configured-subscription-credential", + source: "models.json", + mode, + }); + } + }); + + it("does not reinterpret explicit OpenAI oauth material as a Platform API key", async () => { + await expect( + getApiKeyForModel({ + model: { + id: "platform-model", + provider: "openai", + api: "openai-responses", + } as Model, + cfg: { + models: { + providers: { + openai: { + auth: "oauth", + apiKey: "configured-subscription-credential", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }, + store: { version: 1, profiles: {} }, + }), + ).rejects.toThrow('No API key found for provider "openai"'); + }); + + it("preserves token mode for an env-backed provider SecretRef", async () => { + await withEnv( + "OPENCLAW_TEST_PROVIDER_SUBSCRIPTION_TOKEN", + "env-subscription-credential", + async () => { + const resolved = await getApiKeyForModel({ + model: { + id: "subscription-model", + provider: "custom-token-env", + api: "openai-completions", + } as Model, + cfg: { + models: { + providers: { + "custom-token-env": { + auth: "token", + apiKey: { + source: "env", + provider: "default", + id: "OPENCLAW_TEST_PROVIDER_SUBSCRIPTION_TOKEN", + }, + baseUrl: "https://subscription.example/v1", + models: [], + }, + }, + }, + }, + store: { version: 1, profiles: {} }, + }); + + expect(resolved).toMatchObject({ + apiKey: "env-subscription-credential", + mode: "token", + }); + expect(resolved.source).toContain("OPENCLAW_TEST_PROVIDER_SUBSCRIPTION_TOKEN"); + }, + ); + }); + it("prefers explicit api-key provider SecretRef config over ambient auth profiles", async () => { const sourceConfig = { models: { @@ -1379,6 +1475,52 @@ describe("resolveApiKeyForProvider", () => { }); }); + it("preserves oauth mode for a managed provider SecretRef", async () => { + const sourceConfig = { + models: { + providers: { + "custom-oauth-ref": { + api: "openai-completions" as const, + auth: "oauth" as const, + apiKey: { source: "file", provider: "vault", id: "/custom/oauth" } as const, + baseUrl: "https://subscription.example/v1", + models: [], + }, + }, + }, + }; + setRuntimeConfigSnapshot( + { + models: { + providers: { + "custom-oauth-ref": { + ...sourceConfig.models.providers["custom-oauth-ref"], + apiKey: "resolved-oauth-credential", + }, + }, + }, + }, + sourceConfig, + ); + + const resolved = await getApiKeyForModel({ + model: { + id: "subscription-model", + provider: "custom-oauth-ref", + api: "openai-completions", + } as Model, + cfg: sourceConfig, + store: { version: 1, profiles: {} }, + secretSentinels: true, + }); + + expectSecretSentinelAuth(resolved, { + value: "resolved-oauth-credential", + source: "models.providers.custom-oauth-ref", + mode: "oauth", + }); + }); + it("prefers non-secret local env markers over ambient profiles", async () => { const resolved = await withEnv("OLLAMA_API_KEY", "ollama-local", () => resolveApiKeyForProvider({ diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 2f70c672f017..c7220e3d6e1a 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -16,6 +16,7 @@ import { hashRuntimeConfigValue, selectApplicableRuntimeConfig, } from "../config/config.js"; +import { resolveMergedModelProviderConfig } from "../config/model-provider-config.js"; import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { coerceSecretRef } from "../config/types.secrets.js"; @@ -74,6 +75,7 @@ export { ensureAuthProfileStoreWithoutExternalProfiles, resolveAuthProfileOrder, } from "./auth-profiles.js"; +export { resolveAuthProfileOrderWithMetadata } from "./auth-profiles/order.js"; export { formatMissingAuthError, isMissingProviderAuthError, @@ -157,30 +159,20 @@ function resolveConfigAwareEnvApiKey( cfg: OpenClawConfig | undefined, provider: string, workspaceDir?: string, + skipSetupProviderFallback?: boolean, ): EnvApiKeyResult | null { - return resolveEnvApiKey(provider, process.env, { config: cfg, workspaceDir }); + return resolveEnvApiKey(provider, process.env, { + config: cfg, + workspaceDir, + ...(skipSetupProviderFallback ? { skipSetupProviderFallback: true } : {}), + }); } function resolveProviderConfig( cfg: OpenClawConfig | undefined, provider: string, ): ModelProviderConfig | undefined { - const providers = cfg?.models?.providers ?? {}; - const direct = providers[provider] as ModelProviderConfig | undefined; - if (direct) { - return direct; - } - const normalized = normalizeProviderId(provider); - if (normalized === provider) { - const matched = Object.entries(providers).find( - ([key]) => normalizeProviderId(key) === normalized, - ); - return matched?.[1]; - } - return ( - (providers[normalized] as ModelProviderConfig | undefined) ?? - Object.entries(providers).find(([key]) => normalizeProviderId(key) === normalized)?.[1] - ); + return resolveMergedModelProviderConfig(cfg, provider); } /** Builds stable env/synthetic auth lookup data for repeated provider checks. */ @@ -408,6 +400,19 @@ function resolveProviderAuthOverride( return undefined; } +function resolveDirectProviderCredentialMode(params: { + cfg: OpenClawConfig | undefined; + provider: string; + inferredMode: ResolvedProviderAuth["mode"]; +}): ResolvedProviderAuth["mode"] { + const configuredMode = resolveProviderAuthOverride(params.cfg, params.provider); + // apiKey is the generic provider credential slot. Explicit subscription + // strategy classifies its literal, SecretRef, and env material as one route. + return configuredMode === "oauth" || configuredMode === "token" + ? configuredMode + : params.inferredMode; +} + function shouldUseImplicitAwsSdkAuth(params: { cfg: OpenClawConfig | undefined; provider: string; @@ -775,7 +780,11 @@ function resolveLiteralProviderConfigApiKeyAuth(params: { return { apiKey, source: `models.providers.${params.provider}`, - mode: "api-key", + mode: resolveDirectProviderCredentialMode({ + cfg: params.cfg, + provider: params.provider, + inferredMode: "api-key", + }), }; } @@ -1019,8 +1028,12 @@ function resolveSyntheticLocalProviderAuth(params: { provider: string; modelApi?: string; secretSentinels?: boolean; + allowPluginSyntheticAuth?: boolean; }): ResolvedProviderAuth | null { - const syntheticProviderAuth = resolveProviderSyntheticRuntimeAuth(params); + // Prepared direct attempts may use local no-auth config, but must not widen + // back into an unprepared plugin-owned credential source. + const syntheticProviderAuth = + params.allowPluginSyntheticAuth === false ? {} : resolveProviderSyntheticRuntimeAuth(params); if (syntheticProviderAuth.auth) { return syntheticProviderAuth.auth; } @@ -1140,6 +1153,11 @@ export async function resolveApiKeyForProvider(params: { lockedProfile?: boolean; forceRefresh?: boolean; credentialPrecedence?: ProviderCredentialPrecedence; + /** Skip implicit profile discovery for a prepared env/config fallback attempt. */ + allowAuthProfileFallback?: boolean; + /** Skip plugin setup fallback when the prepared route already excludes it. */ + skipSetupProviderFallback?: boolean; + modelId?: string; modelApi?: string; /** Keep SecretRef-backed model credentials opaque until a sentinel-aware transport boundary. */ secretSentinels?: boolean; @@ -1226,7 +1244,7 @@ export async function resolveApiKeyForProvider(params: { return result; } - if (cfg?.auth?.profiles || cfg?.auth?.order) { + if (params.allowAuthProfileFallback !== false && (cfg?.auth?.profiles || cfg?.auth?.order)) { scopedStore ??= resolveScopedAuthProfileStore({ agentDir, cfg, @@ -1238,6 +1256,7 @@ export async function resolveApiKeyForProvider(params: { store: scopedStore, provider, preferredProfile, + forModel: params.modelId, }); for (const candidate of configuredProfileOrder) { const awsSdkProfileAuth = resolveConfiguredAwsSdkProfileAuth({ @@ -1260,11 +1279,18 @@ export async function resolveApiKeyForProvider(params: { } if (params.credentialPrecedence === "env-first") { - const envResolved = resolveConfigAwareEnvApiKey(cfg, provider, params.workspaceDir); + const envResolved = resolveConfigAwareEnvApiKey( + cfg, + provider, + params.workspaceDir, + params.skipSetupProviderFallback, + ); if (envResolved) { - const resolvedMode: ResolvedProviderAuth["mode"] = envResolved.source.includes("OAUTH_TOKEN") - ? "oauth" - : "api-key"; + const resolvedMode = resolveDirectProviderCredentialMode({ + cfg, + provider, + inferredMode: envResolved.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key", + }); if ( !isAuthModeAllowedForModel({ provider, @@ -1370,7 +1396,12 @@ export async function resolveApiKeyForProvider(params: { mode: "api-key", }; } - const localMarkerEnv = resolveConfigAwareEnvApiKey(cfg, provider, params.workspaceDir); + const localMarkerEnv = resolveConfigAwareEnvApiKey( + cfg, + provider, + params.workspaceDir, + params.skipSetupProviderFallback, + ); if (localMarkerEnv && isNonSecretApiKeyMarker(localMarkerEnv.apiKey)) { return { apiKey: localMarkerEnv.apiKey, @@ -1386,12 +1417,16 @@ export async function resolveApiKeyForProvider(params: { provider, preferredProfile, }); - const order = resolveAuthProfileOrder({ - cfg, - store, - provider, - preferredProfile, - }); + const order = + params.allowAuthProfileFallback === false + ? [] + : resolveAuthProfileOrder({ + cfg, + store, + provider, + preferredProfile, + forModel: params.modelId, + }); let deferredAuthProfileResult: ResolvedProviderAuth | null = null; let refreshFailure: OAuthRefreshFailureError | undefined; for (const candidate of order) { @@ -1481,11 +1516,18 @@ export async function resolveApiKeyForProvider(params: { } } - const envResolved = resolveConfigAwareEnvApiKey(cfg, provider, params.workspaceDir); + const envResolved = resolveConfigAwareEnvApiKey( + cfg, + provider, + params.workspaceDir, + params.skipSetupProviderFallback, + ); if (envResolved) { - const resolvedMode: ResolvedProviderAuth["mode"] = envResolved.source.includes("OAUTH_TOKEN") - ? "oauth" - : "api-key"; + const resolvedMode = resolveDirectProviderCredentialMode({ + cfg, + provider, + inferredMode: envResolved.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key", + }); if ( isAuthModeAllowedForModel({ provider, @@ -1513,7 +1555,14 @@ export async function resolveApiKeyForProvider(params: { provider, secretSentinels: params.secretSentinels, }); - if (managedRuntimeAuth) { + if ( + managedRuntimeAuth && + isAuthModeAllowedForModel({ + provider, + modelApi: params.modelApi, + mode: managedRuntimeAuth.mode, + }) + ) { return managedRuntimeAuth; } @@ -1523,8 +1572,14 @@ export async function resolveApiKeyForProvider(params: { secretSentinels: params.secretSentinels, }); if (customKey) { - const result = { apiKey: customKey.apiKey, source: customKey.source, mode: "api-key" as const }; - return result; + const mode = resolveDirectProviderCredentialMode({ + cfg, + provider, + inferredMode: "api-key", + }); + if (isAuthModeAllowedForModel({ provider, modelApi: params.modelApi, mode })) { + return { apiKey: customKey.apiKey, source: customKey.source, mode }; + } } if (deferredAuthProfileResult) { @@ -1536,6 +1591,7 @@ export async function resolveApiKeyForProvider(params: { provider, modelApi: params.modelApi, secretSentinels: params.secretSentinels, + allowPluginSyntheticAuth: params.allowAuthProfileFallback !== false, }); if (syntheticLocalAuth) { return syntheticLocalAuth; @@ -1547,12 +1603,13 @@ export async function resolveApiKeyForProvider(params: { const hasInlineConfiguredModels = Array.isArray(providerConfig?.models) && providerConfig.models.length > 0; - const owningPluginIds = !hasInlineConfiguredModels - ? resolveOwningPluginIdsForProviderRef({ - provider, - config: cfg, - }) - : undefined; + const owningPluginIds = + params.allowAuthProfileFallback !== false && !hasInlineConfiguredModels + ? resolveOwningPluginIdsForProviderRef({ + provider, + config: cfg, + }) + : undefined; if (owningPluginIds?.length) { const pluginMissingAuthMessage = buildProviderMissingAuthMessageWithPlugin({ provider, @@ -1662,6 +1719,7 @@ export async function hasAvailableAuthForProvider(params: { store?: AuthProfileStore; agentDir?: string; workspaceDir?: string; + modelId?: string; modelApi?: string; }): Promise { const { provider, cfg, preferredProfile } = params; @@ -1700,6 +1758,7 @@ export async function hasAvailableAuthForProvider(params: { store, provider, preferredProfile, + forModel: params.modelId, }); for (const candidate of order) { try { @@ -1752,6 +1811,8 @@ export async function getApiKeyForModel(params: { workspaceDir?: string; lockedProfile?: boolean; credentialPrecedence?: ProviderCredentialPrecedence; + allowAuthProfileFallback?: boolean; + skipSetupProviderFallback?: boolean; secretSentinels?: boolean; }): Promise { return resolveApiKeyForProvider({ @@ -1764,6 +1825,9 @@ export async function getApiKeyForModel(params: { workspaceDir: params.workspaceDir, lockedProfile: params.lockedProfile, credentialPrecedence: params.credentialPrecedence, + allowAuthProfileFallback: params.allowAuthProfileFallback, + skipSetupProviderFallback: params.skipSetupProviderFallback, + modelId: params.model.id, modelApi: params.model.api, secretSentinels: params.secretSentinels, }); diff --git a/src/agents/model-catalog-browse.test.ts b/src/agents/model-catalog-browse.test.ts index a885e1d68016..18c4ff059c5a 100644 --- a/src/agents/model-catalog-browse.test.ts +++ b/src/agents/model-catalog-browse.test.ts @@ -5,14 +5,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js"; -import { loadModelCatalogForBrowse } from "./model-catalog-browse.js"; -import type { ModelCatalogEntry } from "./model-catalog.types.js"; +import { loadModelCatalogSnapshotForBrowse } from "./model-catalog-browse.js"; +import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; const DEFAULT_MODEL_CATALOG_BROWSE_TIMEOUT_MS = 750; -const readOnlyCatalog: ModelCatalogEntry[] = [ - { id: "gpt-readonly", name: "GPT Readonly", provider: "openai" }, -]; -const fullCatalog: ModelCatalogEntry[] = [{ id: "gpt-full", name: "GPT Full", provider: "openai" }]; +const readOnlyCatalog: ModelCatalogSnapshot = { + entries: [{ id: "gpt-readonly", name: "GPT Readonly", provider: "openai" }], + routeVariants: [{ id: "gpt-readonly", name: "GPT Readonly", provider: "openai" }], +}; +const fullCatalog: ModelCatalogSnapshot = { + entries: [{ id: "gpt-full", name: "GPT Full", provider: "openai" }], + routeVariants: [{ id: "gpt-full", name: "GPT Full", provider: "openai" }], +}; function config(params: { providerWildcard?: boolean } = {}): OpenClawConfig { return { @@ -28,7 +32,7 @@ function config(params: { providerWildcard?: boolean } = {}): OpenClawConfig { } as OpenClawConfig; } -describe("loadModelCatalogForBrowse", () => { +describe("loadModelCatalogSnapshotForBrowse", () => { beforeEach(() => { vi.useRealTimers(); }); @@ -44,7 +48,7 @@ describe("loadModelCatalogForBrowse", () => { readOnly ? readOnlyCatalog : fullCatalog, ); - await expect(loadModelCatalogForBrowse({ cfg: config(), loadCatalog })).resolves.toBe( + await expect(loadModelCatalogSnapshotForBrowse({ cfg: config(), loadCatalog })).resolves.toBe( readOnlyCatalog, ); @@ -57,7 +61,7 @@ describe("loadModelCatalogForBrowse", () => { ); await expect( - loadModelCatalogForBrowse({ cfg: config(), view: "all", loadCatalog }), + loadModelCatalogSnapshotForBrowse({ cfg: config(), view: "all", loadCatalog }), ).resolves.toBe(fullCatalog); expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false }); @@ -69,7 +73,7 @@ describe("loadModelCatalogForBrowse", () => { ); await expect( - loadModelCatalogForBrowse({ cfg: config({ providerWildcard: true }), loadCatalog }), + loadModelCatalogSnapshotForBrowse({ cfg: config({ providerWildcard: true }), loadCatalog }), ).resolves.toBe(readOnlyCatalog); expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true }); @@ -81,7 +85,7 @@ describe("loadModelCatalogForBrowse", () => { ); await expect( - loadModelCatalogForBrowse({ + loadModelCatalogSnapshotForBrowse({ cfg: config({ providerWildcard: true }), view: "configured", loadCatalog, @@ -94,9 +98,9 @@ describe("loadModelCatalogForBrowse", () => { it("returns an empty catalog when read-only catalog loading times out with provider wildcards", async () => { vi.useFakeTimers(); const onTimeout = vi.fn(); - const loadCatalog = vi.fn(() => new Promise(() => {})); + const loadCatalog = vi.fn(() => new Promise(() => {})); - const resultPromise = loadModelCatalogForBrowse({ + const resultPromise = loadModelCatalogSnapshotForBrowse({ cfg: config({ providerWildcard: true }), loadCatalog, timeoutMs: 5, @@ -104,7 +108,7 @@ describe("loadModelCatalogForBrowse", () => { }); await vi.advanceTimersByTimeAsync(5); - await expect(resultPromise).resolves.toEqual([]); + await expect(resultPromise).resolves.toEqual({ entries: [], routeVariants: [] }); expect(onTimeout).toHaveBeenCalledExactlyOnceWith(5); }); @@ -114,7 +118,7 @@ describe("loadModelCatalogForBrowse", () => { const clearTimeout = vi.spyOn(globalThis, "clearTimeout"); const loadCatalog = vi.fn(async () => readOnlyCatalog); - const resultPromise = loadModelCatalogForBrowse({ + const resultPromise = loadModelCatalogSnapshotForBrowse({ cfg: config(), loadCatalog, timeoutMs: Number.NaN, @@ -135,7 +139,7 @@ describe("loadModelCatalogForBrowse", () => { const clearTimeout = vi.spyOn(globalThis, "clearTimeout"); const loadCatalog = vi.fn(async () => readOnlyCatalog); - const resultPromise = loadModelCatalogForBrowse({ + const resultPromise = loadModelCatalogSnapshotForBrowse({ cfg: config(), loadCatalog, timeoutMs: Number.MAX_SAFE_INTEGER, diff --git a/src/agents/model-catalog-browse.ts b/src/agents/model-catalog-browse.ts index 3d7c93b82867..0b85de00db75 100644 --- a/src/agents/model-catalog-browse.ts +++ b/src/agents/model-catalog-browse.ts @@ -6,7 +6,7 @@ import { resolveTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { ModelCatalogEntry } from "./model-catalog.types.js"; +import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; import { parseConfiguredModelVisibilityEntries } from "./model-selection-shared.js"; /** @@ -38,14 +38,14 @@ function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number { ); } -/** Loads catalog entries for browse views, using read-only discovery unless full catalog is required. */ -export async function loadModelCatalogForBrowse(params: { +async function loadCatalogForBrowse(params: { cfg: OpenClawConfig; view?: ModelCatalogBrowseView; - loadCatalog: (params: { readOnly: boolean }) => Promise; + loadCatalog: (params: { readOnly: boolean }) => Promise; + empty: T; timeoutMs?: number; onTimeout?: (timeoutMs: number) => void; -}): Promise { +}): Promise { const view = params.view ?? "default"; if (modelCatalogBrowseRequiresFullDiscovery({ cfg: params.cfg, view })) { return await params.loadCatalog({ readOnly: false }); @@ -53,25 +53,36 @@ export async function loadModelCatalogForBrowse(params: { let timeout: NodeJS.Timeout | undefined; const timeoutMs = resolveModelCatalogBrowseTimeoutMs(params.timeoutMs); - const timedOut = Symbol("model-catalog-browse-timeout"); const catalogPromise = params.loadCatalog({ readOnly: true }); - const timeoutPromise = new Promise((resolve) => { - timeout = globalThis.setTimeout(() => resolve(timedOut), timeoutMs); + const catalogResult = catalogPromise.then((value) => ({ kind: "catalog" as const, value })); + const timeoutPromise = new Promise<{ kind: "timeout" }>((resolve) => { + timeout = globalThis.setTimeout(() => resolve({ kind: "timeout" }), timeoutMs); timeout.unref?.(); }); try { - const result = await Promise.race([catalogPromise, timeoutPromise]); - if (result === timedOut) { + const result = await Promise.race([catalogResult, timeoutPromise]); + if (result.kind === "timeout") { // The browse path may return partial/empty results; keep late catalog failures off stderr. catalogPromise.catch(() => undefined); params.onTimeout?.(timeoutMs); - return []; + return params.empty; } - return result; + return result.value; } finally { if (timeout) { globalThis.clearTimeout(timeout); } } } + +/** Loads an explicit logical/physical catalog snapshot for route-aware browse surfaces. */ +export function loadModelCatalogSnapshotForBrowse(params: { + cfg: OpenClawConfig; + view?: ModelCatalogBrowseView; + loadCatalog: (params: { readOnly: boolean }) => Promise; + timeoutMs?: number; + onTimeout?: (timeoutMs: number) => void; +}): Promise { + return loadCatalogForBrowse({ ...params, empty: { entries: [], routeVariants: [] } }); +} diff --git a/src/agents/model-catalog-route.test.ts b/src/agents/model-catalog-route.test.ts new file mode 100644 index 000000000000..e7f85ac9dbc3 --- /dev/null +++ b/src/agents/model-catalog-route.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ProviderModelRouteCandidate } from "../plugin-sdk/provider-model-types.js"; +import { + findModelCatalogRouteDonor, + type ModelCatalogRoutePolicy, + projectModelCatalogEntryForRoute, + resolveConfiguredModelCatalogOverrides, +} from "./model-catalog-route.js"; +import type { ModelCatalogEntry } from "./model-catalog.types.js"; + +const matchesRoute = (entry: ModelCatalogEntry, route: ProviderModelRouteCandidate) => + entry.api === route.api && entry.baseUrl === route.baseUrl; +const routePolicy: ModelCatalogRoutePolicy = { + resolveIdentity: (entry) => ({ id: entry.id, key: `${entry.provider}/${entry.id}` }), + matchesRoute, +}; + +const platformRoute = { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", +} as const satisfies ProviderModelRouteCandidate; + +const chatGPTRoute = { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", +} as const satisfies ProviderModelRouteCandidate; + +const platformEntry: ModelCatalogEntry = { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_000_000, + contextTokens: 272_000, + reasoning: true, + input: ["text", "image"], + params: { platformOnly: true }, + compat: { supportsTools: false }, +}; + +const chatGPTEntry: ModelCatalogEntry = { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 400_000, + contextTokens: 300_000, + reasoning: true, + input: ["text"], + params: { chatGPTOnly: true }, + compat: { supportsTools: true }, +}; + +describe("projectModelCatalogEntryForRoute", () => { + it("finds the exact selected-route donor regardless of catalog order", () => { + expect( + findModelCatalogRouteDonor({ + entry: platformEntry, + route: chatGPTRoute, + policy: routePolicy, + catalog: [platformEntry, chatGPTEntry], + }), + ).toBe(chatGPTEntry); + }); + + it("prefers the physical route donor over a matching merged logical row", () => { + const logicalEntry: ModelCatalogEntry = { + ...chatGPTEntry, + compat: { supportsTools: false }, + params: { logicalOnly: true }, + }; + + expect( + findModelCatalogRouteDonor({ + entry: logicalEntry, + route: chatGPTRoute, + policy: routePolicy, + catalog: [platformEntry, chatGPTEntry], + }), + ).toBe(chatGPTEntry); + }); + + it("projects one physical row onto the selected route capabilities", () => { + expect( + projectModelCatalogEntryForRoute({ + entry: platformEntry, + projection: { kind: "selected", route: platformRoute, policy: routePolicy }, + catalog: [platformEntry, chatGPTEntry], + }), + ).toEqual({ + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_000_000, + contextTokens: 272_000, + reasoning: true, + input: ["text", "image"], + }); + + expect( + projectModelCatalogEntryForRoute({ + entry: platformEntry, + projection: { kind: "selected", route: chatGPTRoute, policy: routePolicy }, + catalog: [platformEntry, chatGPTEntry], + }), + ).toEqual({ + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 400_000, + contextTokens: 300_000, + reasoning: true, + input: ["text"], + }); + }); + + it("omits sibling-route capabilities when no selected-route row exists", () => { + expect( + projectModelCatalogEntryForRoute({ + entry: platformEntry, + projection: { kind: "selected", route: chatGPTRoute, policy: routePolicy }, + catalog: [platformEntry], + }), + ).toEqual({ + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }); + }); + + it("returns the physical row unchanged for unmanaged models", () => { + expect( + projectModelCatalogEntryForRoute({ + entry: platformEntry, + projection: { kind: "unmanaged" }, + }), + ).toBe(platformEntry); + }); + + it("removes physical route facts while managed selection is unresolved", () => { + expect( + projectModelCatalogEntryForRoute({ + entry: platformEntry, + projection: { kind: "unresolved", policy: routePolicy }, + }), + ).toEqual({ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }); + }); + + it("does not copy private route policy facts into the catalog row", () => { + const projected = projectModelCatalogEntryForRoute({ + entry: platformEntry, + projection: { kind: "selected", route: chatGPTRoute, policy: routePolicy }, + catalog: [chatGPTEntry], + }); + expect(projected).not.toHaveProperty("authRequirement"); + expect(projected).not.toHaveProperty("requestTransportOverrides"); + expect(projected).not.toHaveProperty("params"); + expect(projected).not.toHaveProperty("compat"); + }); + + it("applies explicit logical context overrides after physical route selection", () => { + const cfg = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5.5", contextTokens: 160_000 }], + }, + }, + }, + } as unknown as OpenClawConfig; + const overrides = resolveConfiguredModelCatalogOverrides({ cfg, entry: platformEntry }); + + expect( + projectModelCatalogEntryForRoute({ + entry: platformEntry, + projection: { kind: "selected", route: chatGPTRoute, policy: routePolicy }, + catalog: [platformEntry], + ...(overrides ? { overrides } : {}), + }), + ).toEqual({ + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + contextTokens: 160_000, + }); + }); + + it("merges logical overrides from canonical duplicate model rows", () => { + const cfg = { + models: { + providers: { + openai: { + models: [ + { id: "openai/gpt-5.5", name: "Configured GPT-5.5" }, + { id: "gpt-5.5", name: "Ignored duplicate name", contextTokens: 160_000 }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + const canonicalPolicy: ModelCatalogRoutePolicy = { + ...routePolicy, + resolveIdentity: (entry) => { + const id = entry.id.replace(/^openai\//u, ""); + return { id, key: `${entry.provider}/${id}` }; + }, + }; + + expect( + resolveConfiguredModelCatalogOverrides({ + cfg, + entry: platformEntry, + policy: canonicalPolicy, + }), + ).toEqual({ name: "Configured GPT-5.5", contextTokens: 160_000 }); + }); + + it("preserves literal provider-scoped model ids", () => { + const cfg = { + models: { + providers: { + openai: { + models: [{ id: "openai/acme-model", name: "Configured Acme" }], + }, + }, + }, + } as unknown as OpenClawConfig; + const literalEntry = { ...platformEntry, id: "openai/acme-model" }; + + expect( + resolveConfiguredModelCatalogOverrides({ + cfg, + entry: literalEntry, + policy: routePolicy, + }), + ).toEqual({ name: "Configured Acme" }); + }); +}); diff --git a/src/agents/model-catalog-route.ts b/src/agents/model-catalog-route.ts new file mode 100644 index 000000000000..f50c323e4ee1 --- /dev/null +++ b/src/agents/model-catalog-route.ts @@ -0,0 +1,174 @@ +/** Projects physical catalog rows for browse/presentation; never runtime execution. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { + resolveMergedModelProviderConfig, + resolveMergedModelProviderModels, +} from "../config/model-provider-config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ProviderModelRouteCandidate } from "../plugin-sdk/provider-model-types.js"; +import type { ModelCatalogEntry } from "./model-catalog.types.js"; +import { splitTrailingAuthProfile } from "./model-ref-profile.js"; + +export type ModelCatalogRouteMatcher = ( + entry: ModelCatalogEntry, + route: ProviderModelRouteCandidate, +) => boolean; + +export type ModelCatalogLogicalIdentity = { id: string; key: string }; + +/** Provider-owned catalog equivalence and exact physical-route matching. */ +export type ModelCatalogRoutePolicy = { + resolveIdentity( + entry: Pick, + ): ModelCatalogLogicalIdentity | null; + matchesRoute: ModelCatalogRouteMatcher; +}; + +export type ModelCatalogRouteProjection = + | { kind: "unmanaged" } + | { kind: "unresolved"; policy: ModelCatalogRoutePolicy } + | { + kind: "selected"; + route: ProviderModelRouteCandidate; + policy: ModelCatalogRoutePolicy; + }; + +export type ModelCatalogLogicalOverrides = Partial< + Pick +>; + +function normalizeExactModelId(value: string): string { + return splitTrailingAuthProfile(value).model.trim().toLowerCase(); +} + +/** Reads explicit logical capability overrides without re-resolving auth. */ +export function resolveConfiguredModelCatalogOverrides(params: { + cfg: OpenClawConfig; + entry: Pick; + policy?: ModelCatalogRoutePolicy; +}): ModelCatalogLogicalOverrides | undefined { + const provider = normalizeProviderId(params.entry.provider); + const providerConfig = resolveMergedModelProviderConfig(params.cfg, provider); + if (!providerConfig) { + return undefined; + } + const configuredIdentity = params.policy?.resolveIdentity(params.entry); + const normalizeConfiguredModelId = (modelId: string) => + params.policy?.resolveIdentity({ provider: params.entry.provider, id: modelId })?.key ?? + normalizeExactModelId(modelId); + const model = resolveMergedModelProviderModels({ + models: providerConfig.models, + normalizeModelId: normalizeConfiguredModelId, + }).get(configuredIdentity?.key ?? normalizeExactModelId(params.entry.id)); + const overrides: ModelCatalogLogicalOverrides = { + ...(model?.name ? { name: model.name } : {}), + ...(model?.contextWindow !== undefined + ? { contextWindow: model.contextWindow } + : providerConfig.contextWindow !== undefined + ? { contextWindow: providerConfig.contextWindow } + : {}), + ...(model?.contextTokens !== undefined + ? { contextTokens: model.contextTokens } + : providerConfig.contextTokens !== undefined + ? { contextTokens: providerConfig.contextTokens } + : {}), + ...(model?.reasoning !== undefined ? { reasoning: model.reasoning } : {}), + ...(model?.input !== undefined ? { input: model.input } : {}), + }; + return Object.keys(overrides).length > 0 ? overrides : undefined; +} + +function sameLogicalModel( + a: ModelCatalogEntry, + identity: ModelCatalogLogicalIdentity, + policy: ModelCatalogRoutePolicy, +): boolean { + return policy.resolveIdentity(a)?.key === identity.key; +} + +function logicalIdentity(entry: ModelCatalogEntry, id: string, name?: string): ModelCatalogEntry { + return { + id, + name: name ?? id, + provider: entry.provider, + ...(entry.alias ? { alias: entry.alias } : {}), + }; +} + +function applyLogicalOverrides( + entry: ModelCatalogEntry, + overrides: ModelCatalogLogicalOverrides | undefined, +): ModelCatalogEntry { + return overrides ? { ...entry, ...overrides } : entry; +} + +/** Finds the exact physical row that supplied a selected provider route. */ +export function findModelCatalogRouteDonor(params: { + entry: ModelCatalogEntry; + route: ProviderModelRouteCandidate; + policy: ModelCatalogRoutePolicy; + catalog?: readonly ModelCatalogEntry[]; +}): ModelCatalogEntry | undefined { + const identity = params.policy.resolveIdentity(params.entry); + const physicalDonor = identity + ? params.catalog?.find( + (candidate) => + sameLogicalModel(candidate, identity, params.policy) && + params.policy.matchesRoute(candidate, params.route), + ) + : undefined; + if (physicalDonor) { + return physicalDonor; + } + return params.policy.matchesRoute(params.entry, params.route) ? params.entry : undefined; +} + +/** + * Builds one allowlisted logical catalog row. + * + * Selected-route capabilities come only from a physical row accepted by the + * provider-owned matcher. Unresolved managed routes expose identity only. + * Auth, runtime, request overrides, and other private transport facts never + * enter the returned catalog shape. + */ +export function projectModelCatalogEntryForRoute(params: { + entry: ModelCatalogEntry; + projection: ModelCatalogRouteProjection; + catalog?: readonly ModelCatalogEntry[]; + overrides?: ModelCatalogLogicalOverrides; +}): ModelCatalogEntry { + if (params.projection.kind === "unmanaged") { + return params.entry; + } + const identity = params.projection.policy.resolveIdentity(params.entry) ?? { + id: splitTrailingAuthProfile(params.entry.id).model, + key: `${normalizeProviderId(params.entry.provider)}/${normalizeExactModelId(params.entry.id)}`, + }; + if (params.projection.kind === "unresolved") { + return applyLogicalOverrides( + logicalIdentity(params.entry, identity.id, params.entry.name), + params.overrides, + ); + } + + const { policy, route } = params.projection; + const donor = findModelCatalogRouteDonor({ + entry: params.entry, + route, + policy, + catalog: params.catalog, + }); + const projected = logicalIdentity(params.entry, identity.id, donor?.name ?? params.entry.name); + return applyLogicalOverrides( + { + ...projected, + api: route.api, + baseUrl: route.baseUrl, + ...(donor?.contextWindow !== undefined ? { contextWindow: donor.contextWindow } : {}), + ...(donor?.contextTokens !== undefined ? { contextTokens: donor.contextTokens } : {}), + ...(donor?.reasoning !== undefined ? { reasoning: donor.reasoning } : {}), + ...(donor?.input !== undefined ? { input: donor.input } : {}), + }, + params.overrides, + ); +} diff --git a/src/agents/model-catalog-state-cache.test.ts b/src/agents/model-catalog-state-cache.test.ts index 1dc4a623d336..e40fe3e57b61 100644 --- a/src/agents/model-catalog-state-cache.test.ts +++ b/src/agents/model-catalog-state-cache.test.ts @@ -7,6 +7,7 @@ import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { buildAgentModelCatalogCacheKey, readCachedAgentModelCatalog, + readCachedAgentModelCatalogSnapshot, writeCachedAgentModelCatalog, } from "./model-catalog-state-cache.js"; @@ -62,6 +63,46 @@ describe("model catalog state cache", () => { ).toEqual(entries); }); + it("round-trips physical route variants atomically", () => { + const entries = [{ provider: "openai", id: "gpt-5.4-nano", name: "Platform" }]; + const routeVariants = [ + { ...entries[0], api: "openai-responses" }, + { ...entries[0], name: "ChatGPT", api: "openai-chatgpt-responses" }, + ]; + writeCachedAgentModelCatalog({ + agentDir: "/agent/main", + catalogKey: "variant-key", + entries, + routeVariants, + nowMs: 1_000, + }); + + expect( + readCachedAgentModelCatalogSnapshot({ + agentDir: "/agent/main", + catalogKey: "variant-key", + nowMs: 1_000, + }), + ).toEqual({ entries, routeVariants }); + }); + + it("treats legacy entry-only cache rows as a provenance miss", () => { + writeCachedAgentModelCatalog({ + agentDir: "/agent/main", + catalogKey: "legacy-key", + entries: [{ provider: "openai", id: "gpt-5.4-nano", name: "Collapsed" }], + nowMs: 1_000, + }); + + expect( + readCachedAgentModelCatalogSnapshot({ + agentDir: "/agent/main", + catalogKey: "legacy-key", + nowMs: 1_000, + }), + ).toBeUndefined(); + }); + it("rejects stale or mismatched agent catalog rows", () => { writeCachedAgentModelCatalog({ agentDir: "/agent/main", diff --git a/src/agents/model-catalog-state-cache.ts b/src/agents/model-catalog-state-cache.ts index 2b63ce40ac94..df09540ddb58 100644 --- a/src/agents/model-catalog-state-cache.ts +++ b/src/agents/model-catalog-state-cache.ts @@ -19,6 +19,12 @@ type AgentModelCatalogDatabase = Pick(database.db); @@ -124,6 +131,22 @@ export function readCachedAgentModelCatalog( } } +export function readCachedAgentModelCatalog( + params: ReadCachedAgentModelCatalogParams, +): unknown[] | undefined { + return readCachedAgentModelCatalogPayload(params)?.entries as unknown[] | undefined; +} + +/** Reads only provenance-complete snapshots; legacy entry-only rows refresh. */ +export function readCachedAgentModelCatalogSnapshot( + params: ReadCachedAgentModelCatalogParams, +): CachedAgentModelCatalogSnapshot | undefined { + const payload = readCachedAgentModelCatalogPayload(params); + return payload && Array.isArray(payload.routeVariants) + ? { entries: [...payload.entries], routeVariants: [...payload.routeVariants] } + : undefined; +} + export function writeCachedAgentModelCatalog(params: WriteCachedAgentModelCatalogParams): void { if (params.entries.length === 0) { return; @@ -133,6 +156,7 @@ export function writeCachedAgentModelCatalog(params: WriteCachedAgentModelCatalo const rawJson = JSON.stringify({ version: AGENT_MODEL_CATALOG_CACHE_VERSION, entries: params.entries, + ...(params.routeVariants ? { routeVariants: params.routeVariants } : {}), } satisfies CachedAgentModelCatalogPayload); runOpenClawStateWriteTransaction((database) => { const db = getNodeSqliteKysely(database.db); diff --git a/src/agents/model-catalog-visibility.test.ts b/src/agents/model-catalog-visibility.test.ts index 1975d9195f34..f76883df0979 100644 --- a/src/agents/model-catalog-visibility.test.ts +++ b/src/agents/model-catalog-visibility.test.ts @@ -2,234 +2,149 @@ * Regression coverage for model catalog visibility filtering. * Keeps provider/model allow and hide rules aligned with catalog row metadata. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { - isCodexRoutableOpenAIPlatformCatalogEntry, - resolveVisibleModelCatalog, + resolveLogicalModelCatalogEntryState, + resolveLogicalVisibleModelCatalog, } from "./model-catalog-visibility.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; +import { openAIModelCatalogRoutePolicy } from "./openai-model-routes.js"; -const normalizeProviderModelIdWithRuntimeMock = vi.hoisted(() => vi.fn()); +describe("resolveLogicalVisibleModelCatalog", () => { + const selectedRoute = { + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription" as const, + requestTransportOverrides: "none" as const, + }; + const platform: ModelCatalogEntry = { + provider: "openai", + id: "gpt-5.5", + name: "Platform GPT-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_000_000, + reasoning: true, + input: ["text", "image"], + }; + const chatGPT: ModelCatalogEntry = { + provider: "openai", + id: "gpt-5.5", + name: "ChatGPT GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 400_000, + reasoning: false, + input: ["text"], + }; -vi.mock("./provider-model-normalization.runtime.js", () => ({ - normalizeProviderModelIdWithRuntime: (params: unknown) => - normalizeProviderModelIdWithRuntimeMock(params), -})); - -describe("resolveVisibleModelCatalog", () => { - beforeEach(() => { - normalizeProviderModelIdWithRuntimeMock.mockReset(); - }); - - it("recognizes exact GPT-5.6 Codex ids without treating the API alias as routable", () => { - const entry = (id: string): ModelCatalogEntry => ({ - provider: "openai", - id, - name: id, - api: "openai-responses", - }); - - expect(isCodexRoutableOpenAIPlatformCatalogEntry(entry("gpt-5.6"))).toBe(false); - expect(isCodexRoutableOpenAIPlatformCatalogEntry(entry("gpt-5.6-sol"))).toBe(true); - expect(isCodexRoutableOpenAIPlatformCatalogEntry(entry("gpt-5.6-terra"))).toBe(true); - expect(isCodexRoutableOpenAIPlatformCatalogEntry(entry("gpt-5.6-luna"))).toBe(true); - }); - - it("can use static auth checks for gateway read-only model lists", async () => { - const authChecker = vi.fn((provider: string) => provider === "openai"); - const catalog: ModelCatalogEntry[] = [ - { provider: "anthropic", id: "claude-test", name: "Claude Test" }, - { provider: "openai", id: "gpt-test", name: "GPT Test" }, - ]; - const cfg = {} as OpenClawConfig; - - const result = await resolveVisibleModelCatalog({ - cfg, - catalog, - defaultProvider: "openai", - runtimeAuthDiscovery: false, - providerAuthChecker: authChecker, - }); - - expect(authChecker).toHaveBeenNthCalledWith(1, "anthropic"); - expect(authChecker).toHaveBeenNthCalledWith(2, "openai"); - expect(authChecker).toHaveBeenCalledTimes(2); - expect(result).toEqual([{ provider: "openai", id: "gpt-test", name: "GPT Test" }]); - }); - - it("keeps Codex-routable canonical OpenAI rows visible through Codex OAuth auth", async () => { - const authChecker = vi.fn( - (provider: string, api?: string) => api === "openai-chatgpt-responses", - ); - const catalog: ModelCatalogEntry[] = [ - { - provider: "openai", - id: "chat-latest", - name: "Chat Latest", - api: "openai-responses", - }, - { - provider: "openai", - id: "gpt-5.5", - name: "GPT 5.5", - api: "openai-responses", - }, - { - provider: "openai", - id: "gpt-5.4-codex", - name: "GPT 5.4 Codex", - api: "openai-responses", - }, - ]; - - const result = await resolveVisibleModelCatalog({ + it("dedupes physical routes after selected-route projection", async () => { + const catalog = [platform, chatGPT]; + const result = await resolveLogicalVisibleModelCatalog({ cfg: {} as OpenClawConfig, catalog, defaultProvider: "openai", - runtimeAuthDiscovery: false, - providerAuthChecker: authChecker, + view: "all", + routePolicy: openAIModelCatalogRoutePolicy, + evaluateEntry: async (entry) => + resolveLogicalModelCatalogEntryState({ + entry, + evaluation: { + availability: true, + routeResolution: { kind: "routes", routes: [selectedRoute] }, + selectedRoute, + }, + routePolicy: openAIModelCatalogRoutePolicy, + }), }); - expect(authChecker).toHaveBeenNthCalledWith(1, "openai", "openai-responses"); - expect(authChecker).toHaveBeenNthCalledWith(2, "openai", "openai-responses"); - expect(authChecker).toHaveBeenNthCalledWith(3, "openai", "openai-chatgpt-responses"); - expect(authChecker).toHaveBeenNthCalledWith(4, "openai", "openai-responses"); - expect(authChecker).toHaveBeenNthCalledWith(5, "openai", "openai-chatgpt-responses"); - expect(authChecker).toHaveBeenCalledTimes(5); expect(result).toEqual([ - { - provider: "openai", - id: "gpt-5.4-codex", - name: "GPT 5.4 Codex", - api: "openai-responses", - }, { provider: "openai", id: "gpt-5.5", - name: "GPT 5.5", - api: "openai-responses", + name: "ChatGPT GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 400_000, + reasoning: false, + input: ["text"], }, ]); }); - it("does not runtime-normalize unrestricted default browse", async () => { - normalizeProviderModelIdWithRuntimeMock.mockImplementation(() => "custom-modern-model"); - - const result = await resolveVisibleModelCatalog({ + it("omits physical capabilities while managed route selection is unresolved", async () => { + const result = await resolveLogicalVisibleModelCatalog({ cfg: {} as OpenClawConfig, - catalog: [{ provider: "custom-provider", id: "custom-legacy-model", name: "Custom Legacy" }], - defaultProvider: "custom-provider", - defaultModel: "custom-legacy-model", - runtimeAuthDiscovery: false, - providerAuthChecker: vi.fn(() => true), + catalog: [platform], + defaultProvider: "openai", + view: "all", + routePolicy: openAIModelCatalogRoutePolicy, + evaluateEntry: async (entry) => + resolveLogicalModelCatalogEntryState({ + entry, + evaluation: { + availability: false, + routeResolution: { kind: "indeterminate", defaultRuntimeId: "codex" }, + }, + routePolicy: openAIModelCatalogRoutePolicy, + }), }); - expect(result).toEqual([ - { provider: "custom-provider", id: "custom-legacy-model", name: "Custom Legacy" }, - ]); - expect(normalizeProviderModelIdWithRuntimeMock).not.toHaveBeenCalled(); + expect(result).toEqual([{ provider: "openai", id: "gpt-5.5", name: "Platform GPT-5.5" }]); }); - it("limits visible catalog to provider wildcard entries after default discovery", async () => { - const authChecker = vi.fn((provider: string) => provider !== "blocked"); - const catalog: ModelCatalogEntry[] = [ - { provider: "anthropic", id: "claude-test", name: "Claude Test" }, - { provider: "openai", id: "gpt-codex-test", name: "GPT Codex Test" }, - { provider: "vllm", id: "qwen-local", name: "Qwen Local" }, - { provider: "blocked", id: "blocked-test", name: "Blocked Test" }, - ]; + it.each([false, true])( + "projects one canonical nano row from reversed physical variants (reverse=%s)", + async (reverse) => { + const platformNano: ModelCatalogEntry = { + ...platform, + id: "gpt-5.4-nano", + name: "Platform Nano", + }; + const chatGPTNano: ModelCatalogEntry = { + ...chatGPT, + id: "gpt-5.4-nano", + name: "ChatGPT Nano", + }; + const routeVariants = reverse ? [platformNano, chatGPTNano] : [chatGPTNano, platformNano]; + const evaluateEntry = vi.fn( + async (entry: ModelCatalogEntry, _variants: readonly ModelCatalogEntry[]) => + resolveLogicalModelCatalogEntryState({ + entry, + evaluation: { + availability: true, + routeResolution: { kind: "routes", routes: [selectedRoute] }, + selectedRoute, + }, + routePolicy: openAIModelCatalogRoutePolicy, + }), + ); - const cfg = { - agents: { - defaults: { - models: { - "vllm/*": {}, - "openai/*": {}, - "blocked/*": {}, - }, + const result = await resolveLogicalVisibleModelCatalog({ + cfg: {} as OpenClawConfig, + catalog: [platformNano], + routeVariants, + defaultProvider: "openai", + view: "all", + routePolicy: openAIModelCatalogRoutePolicy, + evaluateEntry, + }); + + expect(evaluateEntry).toHaveBeenCalledOnce(); + expect(evaluateEntry.mock.calls[0]?.[1]).toEqual(routeVariants); + expect(result).toEqual([ + { + provider: "openai", + id: "gpt-5.4-nano", + name: "ChatGPT Nano", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 400_000, + reasoning: false, + input: ["text"], }, - }, - } as OpenClawConfig; - - const result = await resolveVisibleModelCatalog({ - cfg, - catalog, - defaultProvider: "anthropic", - runtimeAuthDiscovery: true, - providerAuthChecker: authChecker, - }); - - expect(authChecker).toHaveBeenNthCalledWith(1, "anthropic"); - expect(authChecker).toHaveBeenNthCalledWith(2, "openai"); - expect(authChecker).toHaveBeenNthCalledWith(3, "vllm"); - expect(authChecker).toHaveBeenNthCalledWith(4, "blocked"); - expect(authChecker).toHaveBeenCalledTimes(4); - expect(result).toEqual([ - { provider: "openai", id: "gpt-codex-test", name: "GPT Codex Test" }, - { provider: "vllm", id: "qwen-local", name: "Qwen Local" }, - ]); - expect(normalizeProviderModelIdWithRuntimeMock).not.toHaveBeenCalled(); - }, 240_000); - - it("uses runtime model normalization for exact allowlist entries", async () => { - normalizeProviderModelIdWithRuntimeMock.mockImplementation(({ provider, context }) => { - if ( - provider === "custom-provider" && - (context as { modelId?: string }).modelId === "custom-legacy-model" - ) { - return "custom-modern-model"; - } - return undefined; - }); - - const cfg = { - agents: { - defaults: { - models: { - "custom-provider/custom-legacy-model": {}, - }, - }, - }, - } as OpenClawConfig; - - const result = await resolveVisibleModelCatalog({ - cfg, - catalog: [{ provider: "custom-provider", id: "custom-modern-model", name: "Custom Modern" }], - defaultProvider: "anthropic", - runtimeAuthDiscovery: false, - providerAuthChecker: vi.fn(() => true), - }); - - expect(result).toEqual([ - { provider: "custom-provider", id: "custom-modern-model", name: "Custom Modern" }, - ]); - expect(normalizeProviderModelIdWithRuntimeMock).toHaveBeenCalled(); - }); - - it("does not broaden visibility when selected providers have no catalog rows", async () => { - const authChecker = vi.fn(() => true); - - const cfg = { - agents: { - defaults: { - models: { - "vllm/*": {}, - }, - }, - }, - } as OpenClawConfig; - - const result = await resolveVisibleModelCatalog({ - cfg, - catalog: [{ provider: "anthropic", id: "claude-test", name: "Claude Test" }], - defaultProvider: "anthropic", - runtimeAuthDiscovery: true, - providerAuthChecker: authChecker, - }); - - expect(authChecker).toHaveBeenCalledWith("anthropic"); - expect(authChecker).toHaveBeenCalledTimes(1); - expect(result).toEqual([]); - }); + ]); + }, + ); }); diff --git a/src/agents/model-catalog-visibility.ts b/src/agents/model-catalog-visibility.ts index 1604fada96d3..1943290a2911 100644 --- a/src/agents/model-catalog-visibility.ts +++ b/src/agents/model-catalog-visibility.ts @@ -3,77 +3,78 @@ * combines explicit policy, configured models, defaults, and runtime * auth-backed availability. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { + ModelAuthAvailabilityEvaluation, + ModelAuthAvailabilityRef, +} from "./model-auth-availability.js"; +import { + type ModelCatalogRoutePolicy, + type ModelCatalogRouteProjection, + projectModelCatalogEntryForRoute, + resolveConfiguredModelCatalogOverrides, +} from "./model-catalog-route.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; import { createProviderAuthChecker } from "./model-provider-auth.js"; import { buildConfiguredModelCatalog, dedupeModelCatalogEntries, + modelCatalogLogicalKey, } from "./model-selection-shared.js"; import { RUNTIME_MODEL_VISIBILITY_NORMALIZATION, createModelVisibilityPolicy, + type ModelVisibilityPolicy, } from "./model-visibility-policy.js"; type ModelCatalogVisibilityView = "default" | "configured" | "all"; -type ProviderAuthChecker = (provider: string, modelApi?: string) => boolean | Promise; -const OPENAI_PROVIDER_ID = "openai"; -const OPENAI_CODEX_RESPONSES_API = "openai-chatgpt-responses"; -const OPENAI_CODEX_ROUTABLE_MODEL_IDS = new Set([ - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.5", - "gpt-5.5-pro", - "gpt-5.4", - "gpt-5.4-codex", - "gpt-5.4-pro", - "gpt-5.4-mini", -]); - -function isPromiseLike(value: boolean | Promise): value is Promise { - return typeof value === "object" && value !== null && typeof value.then === "function"; -} - -export function isCodexRoutableOpenAIPlatformCatalogEntry(entry: ModelCatalogEntry): boolean { - // OpenAI platform entries for current Codex-routable ids can use the ChatGPT - // Responses auth path even when their catalog API is not already that API. - return ( - entry.provider.trim().toLowerCase() === OPENAI_PROVIDER_ID && - entry.api !== undefined && - entry.api !== OPENAI_CODEX_RESPONSES_API && - OPENAI_CODEX_ROUTABLE_MODEL_IDS.has(entry.id.trim().toLowerCase()) - ); -} - -async function resolveProviderAuthCheck( - providerAuthChecker: ProviderAuthChecker, +export type ModelCatalogAuthChecker = ( provider: string, - modelApi?: string, -): Promise { - const result = - modelApi === undefined - ? providerAuthChecker(provider) - : providerAuthChecker(provider, modelApi); - return isPromiseLike(result) ? await result : result; + ref?: ModelAuthAvailabilityRef, +) => boolean | Promise; +type ModelCatalogEntryAuthChecker = (entry: ModelCatalogEntry) => boolean | Promise; + +export type LogicalModelCatalogEntryState = { + authBacked: boolean; + compatible: boolean; + preferred: boolean; + routeManaged: boolean; + routeProjection: ModelCatalogRouteProjection; +}; + +/** Maps one shared auth evaluation into logical catalog selection state. */ +export function resolveLogicalModelCatalogEntryState(params: { + entry: ModelCatalogEntry; + evaluation: ModelAuthAvailabilityEvaluation; + authBacked?: boolean; + routePolicy: ModelCatalogRoutePolicy; +}): LogicalModelCatalogEntryState { + const routeManaged = params.evaluation.routeResolution !== null; + const selectedRoute = params.evaluation.selectedRoute; + const routeProjection: ModelCatalogRouteProjection = !routeManaged + ? { kind: "unmanaged" } + : selectedRoute + ? { kind: "selected", route: selectedRoute, policy: params.routePolicy } + : { kind: "unresolved", policy: params.routePolicy }; + return { + authBacked: params.authBacked ?? params.evaluation.availability === true, + compatible: params.evaluation.routeResolution?.kind !== "incompatible", + preferred: selectedRoute ? params.routePolicy.matchesRoute(params.entry, selectedRoute) : false, + routeManaged, + routeProjection, + }; } async function modelCatalogEntryHasProviderAuth( - providerAuthChecker: ProviderAuthChecker, + providerAuthChecker: ModelCatalogAuthChecker, entry: ModelCatalogEntry, ): Promise { - if (await resolveProviderAuthCheck(providerAuthChecker, entry.provider, entry.api)) { - return true; - } - // Codex-routable OpenAI models may be available through a sibling Responses - // auth route, so check that route before hiding the catalog entry. - return isCodexRoutableOpenAIPlatformCatalogEntry(entry) - ? await resolveProviderAuthCheck( - providerAuthChecker, - entry.provider, - OPENAI_CODEX_RESPONSES_API, - ) - : false; + return await providerAuthChecker(entry.provider, { + modelId: entry.id, + api: entry.api, + baseUrl: entry.baseUrl, + }); } function sortModelCatalogEntries(entries: ModelCatalogEntry[]): ModelCatalogEntry[] { @@ -82,11 +83,33 @@ function sortModelCatalogEntries(entries: ModelCatalogEntry[]): ModelCatalogEntr ); } +function resolveLogicalKey( + entry: Pick, + routePolicy: ModelCatalogRoutePolicy, +): string { + return routePolicy.resolveIdentity(entry)?.key ?? modelCatalogLogicalKey(entry); +} + +function dedupeLogicalModelCatalogEntries( + entries: readonly ModelCatalogEntry[], + routePolicy: ModelCatalogRoutePolicy, +) { + const seen = new Set(); + return entries.filter((entry) => { + const key = resolveLogicalKey(entry, routePolicy); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + /** * Resolve catalog entries visible for one view, honoring explicit visibility * policy, configured models, and providers with usable auth. */ -export async function resolveVisibleModelCatalog(params: { +type ResolveVisibleModelCatalogParams = { cfg: OpenClawConfig; catalog: ModelCatalogEntry[]; defaultProvider: string; @@ -97,8 +120,14 @@ export async function resolveVisibleModelCatalog(params: { env?: NodeJS.ProcessEnv; view?: ModelCatalogVisibilityView; runtimeAuthDiscovery?: boolean; - providerAuthChecker?: ProviderAuthChecker; -}): Promise { + providerAuthChecker?: ModelCatalogAuthChecker; + entryAuthChecker?: ModelCatalogEntryAuthChecker; +}; + +async function resolveVisibleModelCatalogWithPolicy( + params: ResolveVisibleModelCatalogParams, + policy: ModelVisibilityPolicy, +): Promise { if (params.view === "all") { return params.catalog; } @@ -107,20 +136,24 @@ export async function resolveVisibleModelCatalog(params: { const configuredCatalog = sortModelCatalogEntries( buildConfiguredModelCatalog({ cfg: params.cfg }), ); - const hasAuth = - params.providerAuthChecker ?? - createProviderAuthChecker({ - cfg: params.cfg, - workspaceDir: params.workspaceDir, - agentDir: params.agentDir, - agentId: params.agentId, - env: params.env, - allowPluginSyntheticAuth: params.runtimeAuthDiscovery, - discoverExternalCliAuth: params.runtimeAuthDiscovery, - }); + let checkEntryAuth = params.entryAuthChecker; + if (!checkEntryAuth) { + const providerAuthChecker = + params.providerAuthChecker ?? + createProviderAuthChecker({ + cfg: params.cfg, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + agentId: params.agentId, + env: params.env, + allowPluginSyntheticAuth: params.runtimeAuthDiscovery, + discoverExternalCliAuth: params.runtimeAuthDiscovery, + }); + checkEntryAuth = (entry) => modelCatalogEntryHasProviderAuth(providerAuthChecker, entry); + } const authBackedCatalog: ModelCatalogEntry[] = []; for (const entry of params.catalog) { - if (await modelCatalogEntryHasProviderAuth(hasAuth, entry)) { + if (await checkEntryAuth(entry)) { authBackedCatalog.push(entry); } } @@ -129,14 +162,6 @@ export async function resolveVisibleModelCatalog(params: { ); }; - const policy = createModelVisibilityPolicy({ - cfg: params.cfg, - catalog: params.catalog, - defaultProvider: params.defaultProvider, - defaultModel: params.defaultModel, - agentId: params.agentId, - ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, - }); // When policy allows wildcards, the default visible set includes configured // entries plus auth-backed entries. Otherwise the policy operates on explicit // catalog selections only. @@ -152,3 +177,159 @@ export async function resolveVisibleModelCatalog(params: { ), ); } + +/** Resolves logical rows while keeping provider-owned physical route precedence. */ +export async function resolveLogicalVisibleModelCatalog(params: { + cfg: OpenClawConfig; + catalog: ModelCatalogEntry[]; + defaultProvider: string; + defaultModel?: string; + agentId?: string; + workspaceDir?: string; + view?: ModelCatalogVisibilityView; + policy?: ModelVisibilityPolicy; + routePolicy: ModelCatalogRoutePolicy; + routeVariants?: readonly ModelCatalogEntry[]; + evaluateEntry( + entry: ModelCatalogEntry, + routeVariants: readonly ModelCatalogEntry[], + ): Promise; +}): Promise { + const policy = + params.policy ?? + createModelVisibilityPolicy({ + cfg: params.cfg, + catalog: params.catalog, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + agentId: params.agentId, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, + }); + const projectionCatalog = + params.routeVariants && params.routeVariants.length > 0 ? params.routeVariants : params.catalog; + const routeVariantsByKey = new Map(); + for (const entry of projectionCatalog) { + const key = resolveLogicalKey(entry, params.routePolicy); + const variants = routeVariantsByKey.get(key) ?? []; + variants.push(entry); + routeVariantsByKey.set(key, variants); + } + const resolveEntryRouteVariants = (entry: ModelCatalogEntry) => + routeVariantsByKey.get(resolveLogicalKey(entry, params.routePolicy)) ?? [entry]; + const stateByKey = new Map>(); + const evaluateEntry = async (entry: ModelCatalogEntry) => { + const key = resolveLogicalKey(entry, params.routePolicy); + let pending = stateByKey.get(key); + if (!pending) { + const variants = resolveEntryRouteVariants(entry); + pending = params.evaluateEntry(variants[0] ?? entry, variants); + stateByKey.set(key, pending); + } + const state = await pending; + const selectedRoute = + state.routeProjection.kind === "selected" ? state.routeProjection.route : undefined; + return { + ...state, + preferred: selectedRoute ? params.routePolicy.matchesRoute(entry, selectedRoute) : false, + }; + }; + const normalizePolicyKey = (key: string) => { + const slashIndex = key.indexOf("/"); + return slashIndex > 0 + ? resolveLogicalKey( + { provider: key.slice(0, slashIndex), id: key.slice(slashIndex + 1) }, + params.routePolicy, + ) + : key; + }; + const configuredKeys = new Set([...policy.configuredKeys].map(normalizePolicyKey)); + const retainedKeys = new Set([...policy.retainedKeys].map(normalizePolicyKey)); + const projectEntries = async (entries: readonly ModelCatalogEntry[]) => { + const projected = await Promise.all( + entries.map(async (entry) => { + const state = await evaluateEntry(entry); + const overrides = resolveConfiguredModelCatalogOverrides({ + cfg: params.cfg, + entry, + policy: params.routePolicy, + }); + return projectModelCatalogEntryForRoute({ + entry, + projection: state.routeProjection, + catalog: resolveEntryRouteVariants(entry), + ...(overrides ? { overrides } : {}), + }); + }), + ); + return sortModelCatalogEntries(dedupeLogicalModelCatalogEntries(projected, params.routePolicy)); + }; + if (params.view === "all") { + return await projectEntries(params.catalog); + } + + const catalogKeys = new Set( + params.catalog.map((entry) => resolveLogicalKey(entry, params.routePolicy)), + ); + const visible = ( + await resolveVisibleModelCatalogWithPolicy( + { + cfg: params.cfg, + catalog: params.catalog, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + agentId: params.agentId, + workspaceDir: params.workspaceDir, + view: params.view, + runtimeAuthDiscovery: false, + entryAuthChecker: async (entry) => (await evaluateEntry(entry)).authBacked, + }, + policy, + ) + ).filter((entry) => { + const key = resolveLogicalKey(entry, params.routePolicy); + return catalogKeys.has(key) || configuredKeys.has(key); + }); + const retained = params.catalog.filter((entry) => + retainedKeys.has(resolveLogicalKey(entry, params.routePolicy)), + ); + const preferredKeys = new Set( + [...visible, ...retained].map((entry) => resolveLogicalKey(entry, params.routePolicy)), + ); + const preferred: ModelCatalogEntry[] = []; + const routeBacked = new Set(); + for (const entry of params.catalog) { + const key = resolveLogicalKey(entry, params.routePolicy); + const preferredKey = preferredKeys.has(key); + const wildcardRoute = + policy.allowAny || policy.providerWildcards.has(normalizeProviderId(entry.provider)); + if (!preferredKey && !wildcardRoute) { + continue; + } + const state = await evaluateEntry(entry); + if (!state.compatible && !configuredKeys.has(key)) { + continue; + } + if (state.preferred && preferredKey) { + preferred.push(entry); + } + if (wildcardRoute && state.routeManaged && state.authBacked) { + routeBacked.add(entry); + } + } + + const kept: ModelCatalogEntry[] = []; + for (const entry of visible) { + const key = resolveLogicalKey(entry, params.routePolicy); + const state = await evaluateEntry(entry); + const configured = configuredKeys.has(key); + if ( + (state.compatible || configured) && + (!state.routeManaged || configured || routeBacked.has(entry)) + ) { + kept.push(entry); + } + } + // Physical route rows can share one logical provider/id. Selected-route rows + // must lead this merge so dedupe cannot retain sibling-route metadata instead. + return await projectEntries([...preferred, ...kept, ...retained, ...routeBacked]); +} diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 90b4ba2607ee..e0f75ec8f6a1 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -12,6 +12,7 @@ let findModelCatalogEntry: typeof import("./model-catalog.js").findModelCatalogE let findModelInCatalog: typeof import("./model-catalog.js").findModelInCatalog; let loadManifestModelCatalog: typeof import("./model-catalog.js").loadManifestModelCatalog; let loadModelCatalog: typeof import("./model-catalog.js").loadModelCatalog; +let loadModelCatalogSnapshot: typeof import("./model-catalog.js").loadModelCatalogSnapshot; let modelSupportsInput: typeof import("./model-catalog.js").modelSupportsInput; let resetModelCatalogCache: typeof import("./model-catalog.js").resetModelCatalogCache; let resetModelCatalogCacheForTest: typeof import("./model-catalog.js").resetModelCatalogCacheForTest; @@ -22,7 +23,10 @@ let loadPluginMetadataSnapshotMock: ReturnType Promise>>; let buildAgentModelCatalogCacheKeyMock: ReturnType; let buildModelsJsonSourceFingerprintMock: ReturnType; -let readCachedAgentModelCatalogMock: ReturnType; +let readCachedAgentModelCatalogMock: ReturnType< + typeof vi.fn<(params: { agentDir: string; catalogKey: string }) => unknown[] | undefined> +>; +let readCachedAgentModelCatalogSnapshotMock: ReturnType; let writeCachedAgentModelCatalogMock: ReturnType; vi.mock("./model-suppression.runtime.js", () => ({ @@ -282,11 +286,18 @@ describe("loadModelCatalog", () => { (input: { cacheScope?: { sourceFingerprint?: string } }) => `test-cache-key:${input.cacheScope?.sourceFingerprint ?? "none"}`, ); - readCachedAgentModelCatalogMock = vi.fn(() => undefined); + readCachedAgentModelCatalogMock = vi.fn< + (params: { agentDir: string; catalogKey: string }) => unknown[] | undefined + >(() => undefined); + readCachedAgentModelCatalogSnapshotMock = vi.fn((params) => { + const entries = readCachedAgentModelCatalogMock(params); + return entries ? { entries, routeVariants: entries } : undefined; + }); writeCachedAgentModelCatalogMock = vi.fn(); vi.doMock("./model-catalog-state-cache.js", () => ({ buildAgentModelCatalogCacheKey: buildAgentModelCatalogCacheKeyMock, readCachedAgentModelCatalog: readCachedAgentModelCatalogMock, + readCachedAgentModelCatalogSnapshot: readCachedAgentModelCatalogSnapshotMock, writeCachedAgentModelCatalog: writeCachedAgentModelCatalogMock, })); vi.doMock("./agent-scope.js", () => ({ @@ -337,6 +348,7 @@ describe("loadModelCatalog", () => { findModelInCatalog, loadManifestModelCatalog, loadModelCatalog, + loadModelCatalogSnapshot, modelSupportsInput, resetModelCatalogCache, resetModelCatalogCacheForTest, @@ -372,6 +384,11 @@ describe("loadModelCatalog", () => { buildAgentModelCatalogCacheKeyMock.mockClear(); readCachedAgentModelCatalogMock.mockReset(); readCachedAgentModelCatalogMock.mockReturnValue(undefined); + readCachedAgentModelCatalogSnapshotMock.mockReset(); + readCachedAgentModelCatalogSnapshotMock.mockImplementation((params) => { + const entries = readCachedAgentModelCatalogMock(params); + return entries ? { entries, routeVariants: entries } : undefined; + }); writeCachedAgentModelCatalogMock.mockClear(); }); @@ -440,7 +457,7 @@ describe("loadModelCatalog", () => { expect(discoverModels).toHaveBeenCalledWith( expect.anything(), "/tmp/openclaw", - expect.objectContaining({ workspaceDir: "/tmp/workspace-agent" }), + expect.objectContaining({ config, workspaceDir: "/tmp/workspace-agent" }), ); }); @@ -498,6 +515,7 @@ describe("loadModelCatalog", () => { agentDir: "/tmp/openclaw", catalogKey: "test-cache-key:source-fingerprint", entries: result, + routeVariants: result, }); }); @@ -511,6 +529,7 @@ describe("loadModelCatalog", () => { agentDir: "/tmp/openclaw", catalogKey: "test-cache-key:source-fingerprint", entries: result, + routeVariants: result, }); }); @@ -616,6 +635,7 @@ describe("loadModelCatalog", () => { agentDir: "/tmp/openclaw", catalogKey: "test-cache-key:post-refresh-source", entries: result, + routeVariants: result, }); }); @@ -825,6 +845,7 @@ describe("loadModelCatalog", () => { const entry = requireCatalogEntry(result, "openai", "gpt-test"); expect(entry.name).toBe("GPT Test"); + expect(entry.baseUrl).toBe("https://openai.example.com/v1"); expect(readCachedAgentModelCatalogMock).not.toHaveBeenCalled(); expect(prepareOpenClawModelsJsonSourceMock).not.toHaveBeenCalled(); expect(importAgentDiscoveryModule).not.toHaveBeenCalled(); @@ -947,6 +968,100 @@ describe("loadModelCatalog", () => { } }); + it("preserves sidecar and manifest physical routes in the read-only catalog", async () => { + const catalogPath = "/tmp/openclaw/plugins/openai/catalog.json"; + mkdirSync("/tmp/openclaw/plugins/openai", { recursive: true }); + writeFileSync(catalogPath, "{}"); + const metadataSnapshot = { + ...emptyPluginMetadataSnapshot(), + index: { + policyHash: "test-policy", + plugins: [{ pluginId: "openai", enabled: true, origin: "bundled" }], + }, + normalizePluginId: (id: string) => id, + owners: { + providers: new Map([["openai", ["openai"]]]), + modelCatalogProviders: new Map([["openai", ["openai"]]]), + setupProviders: new Map(), + }, + plugins: [ + { + id: "openai", + origin: "bundled", + providers: ["openai"], + modelCatalog: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.4-nano", + name: "Platform Nano", + input: ["text", "image"], + contextWindow: 1_000_000, + }, + ], + }, + }, + }, + }, + ], + }; + try { + readFileMock.mockImplementation(async (pathname: string) => { + if (pathname.endsWith("models.json")) { + return JSON.stringify({ providers: {} }); + } + if (pathname === catalogPath) { + return JSON.stringify({ + generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [ + { + id: "gpt-5.4-nano", + name: "ChatGPT Nano", + input: ["text"], + contextWindow: 400_000, + }, + ], + }, + }, + }); + } + throw Object.assign(new Error("not found"), { code: "ENOENT" }); + }); + currentPluginMetadataSnapshotMock.mockReturnValue(metadataSnapshot); + + const snapshot = await loadModelCatalogSnapshot({ + config: {} as OpenClawConfig, + readOnly: true, + metadataSnapshot: metadataSnapshot as never, + }); + const catalog = snapshot.entries; + + expect(catalog).toHaveLength(1); + expect(catalog[0]).toMatchObject({ name: "Platform Nano", api: "openai-responses" }); + expect(snapshot.routeVariants).toEqual([ + expect.objectContaining({ + name: "ChatGPT Nano", + api: "openai-chatgpt-responses", + contextWindow: 400_000, + }), + expect.objectContaining({ + name: "Platform Nano", + api: "openai-responses", + contextWindow: 1_000_000, + }), + ]); + } finally { + rmSync("/tmp/openclaw/plugins/openai", { recursive: true, force: true }); + } + }); + it("falls back to manifest catalog rows when persisted read-only catalog has no model rows", async () => { readFileMock.mockResolvedValueOnce( JSON.stringify({ @@ -1403,6 +1518,7 @@ describe("loadModelCatalog", () => { const result = await loadModelCatalog({ config: {} as OpenClawConfig }); const entry = requireCatalogEntry(result, "openai", "gpt-5.3-codex-spark"); expect(entry.name).toBe("GPT-5.3 Codex Spark Proxy"); + expect(entry.baseUrl).toBe("https://proxy.example.com/v1"); }); it("keeps available openai 5.1/5.2/5.3 built-ins in the catalog", async () => { @@ -1524,6 +1640,43 @@ describe("loadModelCatalog", () => { expect(entry.name).toBe("Gemini 3 Pro Preview"); }); + it("does not carry capabilities across a supplemental route change", async () => { + mockAgentDiscoveryModels([ + { + provider: "openai", + id: "gpt-5.5", + name: "Platform GPT-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_000_000, + reasoning: true, + input: ["text", "image"], + params: { platformOnly: true }, + compat: { supportsTemperature: false }, + }, + ]); + augmentCatalogMock.mockResolvedValueOnce([ + { + provider: "openai", + id: "gpt-5.5", + name: "ChatGPT GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + ]); + + const result = await loadModelCatalog({ config: {} as OpenClawConfig }); + + expect(requireCatalogEntry(result, "openai", "gpt-5.5")).toEqual({ + provider: "openai", + id: "gpt-5.5", + name: "ChatGPT GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + compat: undefined, + }); + }); + it("loads manifest catalog rows from the current metadata snapshot without provider runtime", () => { const snapshot = manifestModelCatalogSnapshot({ id: "external-fast", diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index a13cd725d40b..39760c7b3776 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -27,10 +27,14 @@ import { ensureAuthProfileStoreWithoutExternalProfiles } from "./auth-profiles.j import { modelSupportsInput as modelCatalogEntrySupportsInput } from "./model-catalog-lookup.js"; import { buildAgentModelCatalogCacheKey, - readCachedAgentModelCatalog, + readCachedAgentModelCatalogSnapshot, writeCachedAgentModelCatalog, } from "./model-catalog-state-cache.js"; -import type { ModelCatalogEntry, ModelInputType } from "./model-catalog.types.js"; +import type { + ModelCatalogEntry, + ModelCatalogSnapshot, + ModelInputType, +} from "./model-catalog.types.js"; import { resolveModelWorkspaceDir } from "./model-discovery-context.js"; import { modelKey, @@ -54,7 +58,11 @@ import { const log = createSubsystemLogger("model-catalog"); const AGENT_CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 128_000; -export type { ModelCatalogEntry, ModelInputType } from "./model-catalog.types.js"; +export type { + ModelCatalogEntry, + ModelCatalogSnapshot, + ModelInputType, +} from "./model-catalog.types.js"; export { findModelCatalogEntry, findModelInCatalog, @@ -77,8 +85,16 @@ type DiscoveredModel = { type AgentDiscoveryModule = typeof import("./agent-model-discovery.js"); -let modelCatalogPromise: Promise | null = null; -let loadedModelCatalogSnapshot: ModelCatalogEntry[] | undefined; +export type LoadModelCatalogParams = { + config?: OpenClawConfig; + useCache?: boolean; + cacheOnly?: boolean; + readOnly?: boolean; + metadataSnapshot?: PluginMetadataSnapshot; +}; + +let modelCatalogPromise: Promise | null = null; +let loadedModelCatalogSnapshot: ModelCatalogSnapshot | undefined; let loadedModelCatalogGeneration = -1; let modelCatalogGeneration = 0; let hasLoggedModelCatalogError = false; @@ -88,7 +104,6 @@ type ManifestModelCatalogCacheEntry = { rows: ModelCatalogEntry[]; }; let manifestModelCatalogCache = new WeakMap(); - function buildLoadModelCatalogStateCacheKey(params: { agentDir: string; config: OpenClawConfig; @@ -182,20 +197,68 @@ function mergeCatalogParams( return { ...base, ...override }; } +function normalizeCatalogRouteBaseUrl(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + try { + const url = new URL(value); + url.pathname = url.pathname.replace(/\/+$/u, "") || "/"; + return url.toString(); + } catch { + return value.replace(/\/+$/u, ""); + } +} + +function catalogRouteChanges(base: ModelCatalogEntry, overlay: ModelCatalogEntry): boolean { + if (overlay.api === undefined && overlay.baseUrl === undefined) { + return false; + } + return ( + (overlay.api !== undefined && base.api !== undefined && overlay.api !== base.api) || + (overlay.baseUrl !== undefined && + base.baseUrl !== undefined && + normalizeCatalogRouteBaseUrl(overlay.baseUrl) !== normalizeCatalogRouteBaseUrl(base.baseUrl)) + ); +} + +function clearRouteBoundCatalogMetadata(entry: ModelCatalogEntry): ModelCatalogEntry { + const { + contextWindow: _contextWindow, + contextTokens: _contextTokens, + reasoning: _reasoning, + input: _input, + params: _params, + compat: _compat, + mediaInput: _mediaInput, + ...routeNeutral + } = entry; + return routeNeutral; +} + function overlayCatalogMetadata( base: ModelCatalogEntry, overlay: ModelCatalogEntry, + options?: { preserveBaseName?: boolean }, ): ModelCatalogEntry { - const params = mergeCatalogParams(base.params, overlay.params); + // Catalog rows with one logical provider/id may describe different physical + // routes. Capabilities are atomic with their route; never carry them across + // an API/endpoint change when the new source omits those facts. + const routeChanged = catalogRouteChanges(base, overlay); + const routeBase = routeChanged ? clearRouteBoundCatalogMetadata(base) : base; + const params = mergeCatalogParams(routeBase.params, overlay.params); return { - ...base, + ...routeBase, + ...(routeChanged && !options?.preserveBaseName ? { name: overlay.name } : {}), ...(overlay.api !== undefined ? { api: overlay.api } : {}), + ...(overlay.baseUrl !== undefined ? { baseUrl: overlay.baseUrl } : {}), ...(overlay.contextWindow !== undefined ? { contextWindow: overlay.contextWindow } : {}), ...(overlay.contextTokens !== undefined ? { contextTokens: overlay.contextTokens } : {}), ...(overlay.reasoning !== undefined ? { reasoning: overlay.reasoning } : {}), ...(overlay.input !== undefined ? { input: overlay.input } : {}), ...(params ? { params } : {}), - compat: mergeCatalogCompat(base.compat, overlay.compat), + ...(overlay.mediaInput !== undefined ? { mediaInput: overlay.mediaInput } : {}), + compat: mergeCatalogCompat(routeBase.compat, overlay.compat), }; } @@ -209,7 +272,11 @@ function normalizeCatalogEntryContract(entry: ModelCatalogEntry): ModelCatalogEn return entry; } -function mergeCatalogEntries(models: ModelCatalogEntry[], entries: ModelCatalogEntry[]): void { +function mergeCatalogEntries( + models: ModelCatalogEntry[], + entries: ModelCatalogEntry[], + options?: { preserveBaseName?: boolean }, +): void { const indexByKey = new Map( models.map((entry, index) => [catalogEntryDedupeKey(entry.provider, entry.id), index]), ); @@ -223,11 +290,57 @@ function mergeCatalogEntries(models: ModelCatalogEntry[], entries: ModelCatalogE } const existing = models.at(existingIndex); if (existing) { - models[existingIndex] = overlayCatalogMetadata(existing, entry); + models[existingIndex] = overlayCatalogMetadata(existing, entry, options); } } } +function catalogRouteVariantKey(entry: ModelCatalogEntry): string { + return [ + catalogEntryDedupeKey(entry.provider, entry.id), + entry.api ?? "", + normalizeCatalogRouteBaseUrl(entry.baseUrl) ?? "", + ].join("\u0000"); +} + +type ModelCatalogRouteVariantCollector = { + entries: ModelCatalogEntry[]; + indexByKey: Map; +}; + +function createModelCatalogRouteVariantCollector(): ModelCatalogRouteVariantCollector { + return { entries: [], indexByKey: new Map() }; +} + +function mergeCatalogRouteVariants( + collector: ModelCatalogRouteVariantCollector, + entries: readonly ModelCatalogEntry[], +): void { + for (const entry of entries) { + const key = catalogRouteVariantKey(entry); + const existingIndex = collector.indexByKey.get(key); + if (existingIndex === undefined) { + collector.entries.push(entry); + collector.indexByKey.set(key, collector.entries.length - 1); + continue; + } + collector.entries[existingIndex] = overlayCatalogMetadata( + collector.entries[existingIndex], + entry, + ); + } +} + +function createModelCatalogSnapshot( + entries: ModelCatalogEntry[], + routeVariants: ModelCatalogRouteVariantCollector, +): ModelCatalogSnapshot { + return { + entries: sortModelCatalogEntries(entries), + routeVariants: sortModelCatalogEntries(routeVariants.entries), + }; +} + export function loadManifestModelCatalog(params: { config: OpenClawConfig; workspaceDir?: string; @@ -276,6 +389,9 @@ export function loadManifestModelCatalog(params: { provider: row.provider, api: row.api, }; + if (row.baseUrl) { + entry.baseUrl = row.baseUrl; + } const contextWindow = row.contextWindow ?? row.contextTokens; if (contextWindow) { entry.contextWindow = contextWindow; @@ -313,6 +429,7 @@ function normalizePersistedModelCatalogEntry( entry: Record, defaults?: { api?: ModelCatalogEntry["api"]; + baseUrl?: string; contextWindow?: number; contextTokens?: number; }, @@ -345,6 +462,7 @@ function normalizePersistedModelCatalogEntry( const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : false; const api = typeof entry?.api === "string" ? (entry.api as ModelCatalogEntry["api"]) : defaults?.api; + const baseUrl = normalizeOptionalString(entry?.baseUrl) ?? defaults?.baseUrl; const parsedInput = Array.isArray(entry?.input) ? entry.input.filter((value): value is ModelInputType => ["text", "image", "audio", "video", "document"].includes(String(value)), @@ -364,6 +482,7 @@ function normalizePersistedModelCatalogEntry( name, provider, ...(api ? { api } : {}), + ...(baseUrl ? { baseUrl } : {}), contextWindow, ...(contextTokens !== undefined ? { contextTokens } : {}), reasoning, @@ -416,11 +535,12 @@ async function loadReadOnlyPersistedProviderRows( async function loadReadOnlyPersistedModelCatalog(params?: { config?: OpenClawConfig; metadataSnapshot?: PluginMetadataSnapshot; -}): Promise { +}): Promise { const cfg = params?.config ?? getRuntimeConfig(); const agentDir = resolveDefaultAgentDir(cfg); const workspaceDir = resolveModelWorkspaceDir(cfg, undefined); const models: ModelCatalogEntry[] = []; + const routeVariants = createModelCatalogRouteVariantCollector(); const { buildShouldSuppressBuiltInModel } = await loadModelSuppression(); const shouldSuppressBuiltInModel = buildShouldSuppressBuiltInModel({ config: cfg }); let metadataSnapshot: PluginMetadataSnapshot | undefined = params?.metadataSnapshot; @@ -454,12 +574,14 @@ async function loadReadOnlyPersistedModelCatalog(params?: { typeof providerConfig?.api === "string" ? (providerConfig.api as ModelCatalogEntry["api"]) : undefined; + const providerBaseUrl = normalizeOptionalString(providerConfig?.baseUrl); for (const entry of providerConfig.models as Record[]) { const normalized = normalizePersistedModelCatalogEntry( providerRaw, entry, { api: providerApi, + baseUrl: providerBaseUrl, contextWindow: providerContextWindow, contextTokens: providerContextTokens, }, @@ -467,6 +589,7 @@ async function loadReadOnlyPersistedModelCatalog(params?: { ); if (normalized && !shouldSuppressBuiltInModel(normalized)) { models.push(normalized); + mergeCatalogRouteVariants(routeVariants, [normalized]); } } } @@ -474,15 +597,14 @@ async function loadReadOnlyPersistedModelCatalog(params?: { throw new Error("persisted model catalog has no usable model rows"); } try { - mergeCatalogEntries( - models, - loadManifestModelCatalog({ - config: cfg, - env: process.env, - fallbackToMetadataScan: false, - metadataSnapshot: getMetadataSnapshot(), - }), - ); + const manifestModels = loadManifestModelCatalog({ + config: cfg, + env: process.env, + fallbackToMetadataScan: false, + metadataSnapshot: getMetadataSnapshot(), + }); + mergeCatalogRouteVariants(routeVariants, manifestModels); + mergeCatalogEntries(models, manifestModels); } catch { // Persisted rows are still valid when manifest metadata is temporarily unavailable. } @@ -491,9 +613,10 @@ async function loadReadOnlyPersistedModelCatalog(params?: { manifestPlugins: hasConfiguredProviderModelRows(cfg) ? getManifestPlugins() : undefined, }); if (configuredModels.length > 0) { - mergeCatalogEntries(models, configuredModels); + mergeCatalogRouteVariants(routeVariants, configuredModels); + mergeCatalogEntries(models, configuredModels, { preserveBaseName: true }); } - return sortModelCatalogEntries(models); + return createModelCatalogSnapshot(models, routeVariants); } function hasConfiguredProviderRowsNeedingManifestLookup(cfg: OpenClawConfig): boolean { @@ -510,19 +633,19 @@ function hasConfiguredProviderRowsNeedingManifestLookup(cfg: OpenClawConfig): bo function loadReadOnlyStaticModelCatalog(params?: { config?: OpenClawConfig; metadataSnapshot?: PluginMetadataSnapshot; -}): ModelCatalogEntry[] { +}): ModelCatalogSnapshot { const cfg = params?.config ?? getRuntimeConfig(); const models: ModelCatalogEntry[] = []; + const routeVariants = createModelCatalogRouteVariantCollector(); try { - mergeCatalogEntries( - models, - loadManifestModelCatalog({ - config: cfg, - env: process.env, - fallbackToMetadataScan: false, - metadataSnapshot: params?.metadataSnapshot, - }), - ); + const manifestModels = loadManifestModelCatalog({ + config: cfg, + env: process.env, + fallbackToMetadataScan: false, + metadataSnapshot: params?.metadataSnapshot, + }); + mergeCatalogRouteVariants(routeVariants, manifestModels); + mergeCatalogEntries(models, manifestModels); } catch (error) { if (!hasLoggedReadOnlyStaticCatalogError) { hasLoggedReadOnlyStaticCatalogError = true; @@ -543,22 +666,20 @@ function loadReadOnlyStaticModelCatalog(params?: { manifestPlugins: configuredManifestPlugins, }); if (configuredModels.length > 0) { - mergeCatalogEntries(models, configuredModels); + mergeCatalogRouteVariants(routeVariants, configuredModels); + mergeCatalogEntries(models, configuredModels, { preserveBaseName: true }); } - return sortModelCatalogEntries(models); + return createModelCatalogSnapshot(models, routeVariants); } -export async function loadModelCatalog(params?: { - config?: OpenClawConfig; - useCache?: boolean; - cacheOnly?: boolean; - readOnly?: boolean; - metadataSnapshot?: PluginMetadataSnapshot; -}): Promise { +/** Loads logical entries together with browse-only physical route provenance. */ +export async function loadModelCatalogSnapshot( + params?: LoadModelCatalogParams, +): Promise { if (params?.cacheOnly === true) { return loadedModelCatalogGeneration === modelCatalogGeneration - ? (loadedModelCatalogSnapshot ?? []) - : []; + ? (loadedModelCatalogSnapshot ?? { entries: [], routeVariants: [] }) + : { entries: [], routeVariants: [] }; } const readOnly = params?.readOnly === true; if (readOnly) { @@ -581,6 +702,7 @@ export async function loadModelCatalog(params?: { const loadCatalog = async () => { const models: ModelCatalogEntry[] = []; + const routeVariants = createModelCatalogRouteVariantCollector(); const timingEnabled = shouldLogModelCatalogTiming(); const startMs = timingEnabled ? Date.now() : 0; const logStage = (stage: string, extra?: string) => { @@ -590,7 +712,6 @@ export async function loadModelCatalog(params?: { const suffix = extra ? ` ${extra}` : ""; log.info(`model-catalog stage=${stage} elapsedMs=${Date.now() - startMs}${suffix}`); }; - const sortModels = sortModelCatalogEntries; try { const cfg = params?.config ?? getRuntimeConfig(); const workspaceDir = resolveModelWorkspaceDir(cfg, undefined); @@ -623,12 +744,12 @@ export async function loadModelCatalog(params?: { workspaceDir, }); if (!readOnly && params?.useCache !== false) { - const cached = readCachedAgentModelCatalog({ agentDir, catalogKey }) as - | ModelCatalogEntry[] + const cachedSnapshot = readCachedAgentModelCatalogSnapshot({ agentDir, catalogKey }) as + | { entries: ModelCatalogEntry[]; routeVariants: ModelCatalogEntry[] } | undefined; - if (cached?.length) { - logStage("state-cache-hit", `entries=${cached.length}`); - return cached; + if (cachedSnapshot?.entries.length) { + logStage("state-cache-hit", `entries=${cachedSnapshot.entries.length}`); + return cachedSnapshot; } } if (!readOnly) { @@ -647,12 +768,13 @@ export async function loadModelCatalog(params?: { if (preparedCatalogKey !== catalogKey) { catalogKey = preparedCatalogKey; if (params?.useCache !== false) { - const cached = readCachedAgentModelCatalog({ agentDir, catalogKey }) as - | ModelCatalogEntry[] - | undefined; - if (cached?.length) { - logStage("state-cache-hit", `entries=${cached.length}`); - return cached; + const cachedSnapshot = readCachedAgentModelCatalogSnapshot({ + agentDir, + catalogKey, + }) as { entries: ModelCatalogEntry[]; routeVariants: ModelCatalogEntry[] } | undefined; + if (cachedSnapshot?.entries.length) { + logStage("state-cache-hit", `entries=${cachedSnapshot.entries.length}`); + return cachedSnapshot; } } } @@ -669,6 +791,7 @@ export async function loadModelCatalog(params?: { ); logStage("auth-storage-ready"); const registry = agentDiscovery.discoverModels(authStorage, agentDir, { + config: cfg, pluginMetadataSnapshot: getManifestMetadataSnapshot(), workspaceDir, }); @@ -710,27 +833,29 @@ export async function loadModelCatalog(params?: { const modelParams = entry?.params && typeof entry.params === "object" ? entry.params : undefined; const compat = entry?.compat && typeof entry.compat === "object" ? entry.compat : undefined; - models.push({ + const model = { id, name, provider, ...(api ? { api } : {}), + ...(baseUrl ? { baseUrl } : {}), contextWindow, ...(contextTokens !== undefined ? { contextTokens } : {}), reasoning, input, ...(modelParams ? { params: modelParams } : {}), compat, - }); + } satisfies ModelCatalogEntry; + models.push(model); + mergeCatalogRouteVariants(routeVariants, [model]); } - mergeCatalogEntries( - models, - loadManifestModelCatalog({ - config: cfg, - env: process.env, - metadataSnapshot: getManifestMetadataSnapshot(), - }), - ); + const manifestModels = loadManifestModelCatalog({ + config: cfg, + env: process.env, + metadataSnapshot: getManifestMetadataSnapshot(), + }); + mergeCatalogRouteVariants(routeVariants, manifestModels); + mergeCatalogEntries(models, manifestModels); logStage("manifest-models-merged", `entries=${models.length}`); const configuredModels = buildConfiguredModelCatalog({ cfg, @@ -739,7 +864,7 @@ export async function loadModelCatalog(params?: { let augmentEntries: ModelCatalogEntry[] | undefined; if (configuredModels.length > 0) { const entriesForAugment = [...models]; - mergeCatalogEntries(entriesForAugment, configuredModels); + mergeCatalogEntries(entriesForAugment, configuredModels, { preserveBaseName: true }); augmentEntries = entriesForAugment; } logStage("configured-models-prepared", `entries=${models.length}`); @@ -780,13 +905,15 @@ export async function loadModelCatalog(params?: { }), }); } + mergeCatalogRouteVariants(routeVariants, normalizedSupplemental); mergeCatalogEntries(models, normalizedSupplemental); } } logStage("plugin-models-merged", `entries=${models.length}`); if (configuredModels.length > 0) { - mergeCatalogEntries(models, configuredModels); + mergeCatalogRouteVariants(routeVariants, configuredModels); + mergeCatalogEntries(models, configuredModels, { preserveBaseName: true }); } logStage("configured-models-finalized", `entries=${models.length}`); @@ -797,16 +924,17 @@ export async function loadModelCatalog(params?: { } } - const sorted = sortModels(models); + const snapshot = createModelCatalogSnapshot(models, routeVariants); if (!readOnly) { writeCachedAgentModelCatalog({ agentDir, catalogKey, - entries: sorted, + entries: snapshot.entries, + routeVariants: snapshot.routeVariants, }); } - logStage("complete", `entries=${sorted.length}`); - return sorted; + logStage("complete", `entries=${snapshot.entries.length}`); + return snapshot; } catch (error) { if (!hasLoggedModelCatalogError) { hasLoggedModelCatalogError = true; @@ -817,9 +945,9 @@ export async function loadModelCatalog(params?: { modelCatalogPromise = null; } if (models.length > 0) { - return sortModels(models); + return createModelCatalogSnapshot(models, routeVariants); } - return []; + return { entries: [], routeVariants: [] }; } }; @@ -828,21 +956,28 @@ export async function loadModelCatalog(params?: { } const loadGeneration = modelCatalogGeneration; - const publishedPromise = loadCatalog().then((catalog) => { + const publishedPromise = loadCatalog().then((snapshot) => { if ( - catalog.length > 0 && + snapshot.entries.length > 0 && modelCatalogGeneration === loadGeneration && modelCatalogPromise === publishedPromise ) { - loadedModelCatalogSnapshot = catalog; + loadedModelCatalogSnapshot = snapshot; loadedModelCatalogGeneration = loadGeneration; } - return catalog; + return snapshot; }); modelCatalogPromise = publishedPromise; return publishedPromise; } +/** Loads the deduplicated logical catalog for runtime and legacy consumers. */ +export async function loadModelCatalog( + params?: LoadModelCatalogParams, +): Promise { + return (await loadModelCatalogSnapshot(params)).entries; +} + /** * Check if a model supports image input based on its catalog entry. */ diff --git a/src/agents/model-catalog.types.ts b/src/agents/model-catalog.types.ts index f92b3950943d..5ff40b6b3d64 100644 --- a/src/agents/model-catalog.types.ts +++ b/src/agents/model-catalog.types.ts @@ -15,6 +15,8 @@ export type ModelCatalogEntry = { provider: string; alias?: string; api?: ModelApi; + /** Private transport provenance for route matching; never project directly to clients. */ + baseUrl?: string; contextWindow?: number; contextTokens?: number; reasoning?: boolean; @@ -23,3 +25,9 @@ export type ModelCatalogEntry = { compat?: ModelCompatConfig; mediaInput?: ModelMediaInputConfig; }; + +/** Logical catalog rows plus the physical variants used for route selection. */ +export type ModelCatalogSnapshot = { + entries: ModelCatalogEntry[]; + routeVariants: ModelCatalogEntry[]; +}; diff --git a/src/agents/model-extra-params.ts b/src/agents/model-extra-params.ts new file mode 100644 index 000000000000..f2d3c5afdd30 --- /dev/null +++ b/src/agents/model-extra-params.ts @@ -0,0 +1,45 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { modelKey } from "../shared/model-key.js"; + +type ModelExtraParamSources = { + defaultParams?: Record; + modelParams?: Record; + agentParams?: Record; +}; + +function legacyModelKey(provider: string, modelId: string): string | undefined { + const rawKey = `${provider.trim()}/${modelId.trim()}`; + const canonicalKey = modelKey(provider, modelId); + return rawKey === canonicalKey ? undefined : rawKey; +} + +/** Resolves the config records merged into one model request. */ +export function resolveModelExtraParamSources(params: { + config?: OpenClawConfig; + provider: string; + modelId?: string; + agentId?: string; +}): ModelExtraParamSources { + const defaultParams = params.config?.agents?.defaults?.params; + const configuredModels = params.config?.agents?.defaults?.models; + const canonicalKey = params.modelId ? modelKey(params.provider, params.modelId) : undefined; + const legacyKey = params.modelId ? legacyModelKey(params.provider, params.modelId) : undefined; + const modelParams = canonicalKey + ? (configuredModels?.[canonicalKey]?.params ?? + (legacyKey ? configuredModels?.[legacyKey]?.params : undefined)) + : undefined; + const agentParams = params.agentId + ? params.config?.agents?.list?.find((agent) => agent.id === params.agentId)?.params + : undefined; + return { defaultParams, modelParams, agentParams }; +} + +/** Returns whether embedded OpenClaw would apply authored request parameters. */ +export function hasModelExtraParams( + params: Parameters[0], +): boolean { + const sources = resolveModelExtraParamSources(params); + return [sources.defaultParams, sources.modelParams, sources.agentParams].some( + (source) => source !== undefined && Object.keys(source).length > 0, + ); +} diff --git a/src/agents/model-provider-auth.test.ts b/src/agents/model-provider-auth.test.ts index 6aa35fb2353a..e26a7b97139c 100644 --- a/src/agents/model-provider-auth.test.ts +++ b/src/agents/model-provider-auth.test.ts @@ -6,6 +6,10 @@ import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { AuthProfileStore } from "./auth-profiles.js"; +import type { + ModelAuthAvailabilityEvaluation, + ModelAuthAvailabilityRef, +} from "./model-auth-availability.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; import { publishProviderAuthWarmSnapshot } from "./model-provider-auth-state.js"; @@ -35,6 +39,20 @@ const modelAuthMocks = vi.hoisted(() => ({ >(), })); +const modelAuthAvailabilityMocks = vi.hoisted(() => { + const evaluateModelAuth = vi.fn< + (provider: string, ref?: ModelAuthAvailabilityRef) => ModelAuthAvailabilityEvaluation + >(() => ({ availability: false, routeResolution: null })); + return { + evaluateModelAuth, + createModelAuthAvailabilityResolver: vi.fn((_params: unknown) => ({ + evaluateModelAuth, + resolveProviderAuthAvailability: vi.fn(() => false), + hasSyntheticAuth: vi.fn(() => false), + })), + }; +}); + const authProfilesMocks = vi.hoisted(() => ({ ensureAuthProfileStore: vi.fn(() => ({ profiles: {} })), ensureAuthProfileStoreWithoutExternalProfiles: vi.fn(() => ({ profiles: {} })), @@ -56,6 +74,11 @@ vi.mock("./model-auth.js", () => ({ hasRuntimeAvailableProviderAuth: modelAuthMocks.hasRuntimeAvailableProviderAuth, })); +vi.mock("./model-auth-availability.js", () => ({ + createModelAuthAvailabilityResolver: + modelAuthAvailabilityMocks.createModelAuthAvailabilityResolver, +})); + vi.mock("./auth-profiles.js", () => ({ ensureAuthProfileStore: authProfilesMocks.ensureAuthProfileStore, ensureAuthProfileStoreWithoutExternalProfiles: @@ -97,6 +120,10 @@ describe("prepared provider auth state", () => { afterEach(() => { clearCurrentProviderAuthState(); vi.clearAllMocks(); + modelAuthAvailabilityMocks.evaluateModelAuth.mockReturnValue({ + availability: false, + routeResolution: null, + }); }); it("reuses prepared runtime auth lookup data while warming providers", async () => { @@ -259,7 +286,7 @@ describe("prepared provider auth state", () => { expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(2); }); - it("does not prepare synthetic auth refs when plugin synthetic auth is disabled", async () => { + it("keeps provider-only OpenAI checks on the legacy auth path", async () => { const cfg = {} as OpenClawConfig; modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(false); @@ -269,6 +296,7 @@ describe("prepared provider auth state", () => { discoverExternalCliAuth: false, }); + await expect(hasAuth("openai")).resolves.toBe(false); await expect(hasAuth("openai")).resolves.toBe(false); expect(modelAuthMocks.createRuntimeProviderAuthLookup).toHaveBeenCalledWith({ @@ -282,6 +310,138 @@ describe("prepared provider auth state", () => { expect(runtimeLookup).toBe( modelAuthMocks.createRuntimeProviderAuthLookup.mock.results[0]?.value, ); + expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).toHaveBeenCalledTimes(1); + expect(modelAuthAvailabilityMocks.createModelAuthAvailabilityResolver).not.toHaveBeenCalled(); + expect(modelAuthAvailabilityMocks.evaluateModelAuth).not.toHaveBeenCalled(); + }); + + it("preserves explicit prepared runtime auth while keeping disabled discovery isolated", async () => { + const cfg = {} as OpenClawConfig; + const hasAuth = createProviderAuthChecker({ + cfg, + allowPluginSyntheticAuth: false, + discoverExternalCliAuth: false, + allowPreparedRuntimeAuth: true, + }); + + await hasAuth("openai", { + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + + expect(modelAuthAvailabilityMocks.createModelAuthAvailabilityResolver).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + allowPreparedRuntimeAuth: true, + syntheticAuthProviderRefs: [], + }), + ); + const resolverParams = + modelAuthAvailabilityMocks.createModelAuthAvailabilityResolver.mock.calls[0]?.[0]; + expect(resolverParams).not.toHaveProperty("externalCliProviderIds"); + }); + + it("keeps tuple-aware null-artifact checks indeterminate with broad auth enabled", async () => { + const cfg = {} as OpenClawConfig; + const hasAuth = createProviderAuthChecker({ cfg }); + + await expect(hasAuth("openai", { modelId: "gpt-5.5" })).resolves.toBe(false); + + expect(modelAuthMocks.createRuntimeProviderAuthLookup).toHaveBeenCalledWith({ + cfg, + workspaceDir: undefined, + env: undefined, + includePluginSyntheticAuth: true, + }); + expect(modelAuthAvailabilityMocks.createModelAuthAvailabilityResolver).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + allowPreparedRuntimeAuth: true, + externalCliProviderIds: ["openai"], + syntheticAuthProviderRefs: [], + }), + ); + expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).not.toHaveBeenCalled(); + }); + + it("caches OpenAI auth by the complete route tuple", async () => { + const hasAuth = createProviderAuthChecker({ cfg: {} as OpenClawConfig }); + const platformRef = { + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }; + + await hasAuth("openai", platformRef); + await hasAuth("openai", { ...platformRef }); + await hasAuth("openai", { + ...platformRef, + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }); + + expect(modelAuthAvailabilityMocks.evaluateModelAuth).toHaveBeenCalledTimes(2); + }); + + it("exposes the cached route evaluation alongside the boolean checker", async () => { + const evaluation = { + availability: true, + routeResolution: null, + evidence: "profile" as const, + }; + modelAuthAvailabilityMocks.evaluateModelAuth.mockReturnValue(evaluation); + const hasAuth = createProviderAuthChecker({ cfg: {} as OpenClawConfig }); + const ref = { + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }; + + await expect(hasAuth.evaluateModelAuth("openai", ref)).resolves.toBe(evaluation); + await expect(hasAuth("openai", { ...ref })).resolves.toBe(true); + expect(modelAuthAvailabilityMocks.evaluateModelAuth).toHaveBeenCalledOnce(); + }); + + it("uses shared model auth evaluation for a non-OpenAI AWS SDK model", async () => { + const evaluation = { + availability: true, + routeResolution: null, + selectedAuthMode: "aws-sdk", + evidence: "aws-sdk" as const, + }; + modelAuthAvailabilityMocks.evaluateModelAuth.mockReturnValue(evaluation); + const hasAuth = createProviderAuthChecker({ cfg: {} as OpenClawConfig }); + const ref = { + modelId: "us.anthropic.claude-sonnet-4-5", + api: "bedrock-converse-stream", + }; + + await expect(hasAuth.evaluateModelAuth("amazon-bedrock", ref)).resolves.toBe(evaluation); + await expect(hasAuth("amazon-bedrock", { ...ref })).resolves.toBe(true); + expect(modelAuthAvailabilityMocks.evaluateModelAuth).toHaveBeenCalledWith( + "amazon-bedrock", + ref, + ); + expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).not.toHaveBeenCalled(); + }); + + it("does not let legacy provider auth override an unresolved model SecretRef", async () => { + const evaluation = { + availability: undefined, + routeResolution: null, + selectedAuthMode: "api-key", + evidence: "provider-config" as const, + }; + modelAuthAvailabilityMocks.evaluateModelAuth.mockReturnValue(evaluation); + modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(true); + const hasAuth = createProviderAuthChecker({ cfg: {} as OpenClawConfig }); + const ref = { modelId: "claude-sonnet-4-6", api: "anthropic-messages" }; + + await expect(hasAuth.evaluateModelAuth("anthropic", ref)).resolves.toBe(evaluation); + await expect(hasAuth("anthropic", { ...ref })).resolves.toBe(false); + expect(modelAuthAvailabilityMocks.evaluateModelAuth).toHaveBeenCalledWith("anthropic", ref); + expect(modelAuthMocks.hasRuntimeAvailableProviderAuth).not.toHaveBeenCalled(); }); it("uses an explicit agent auth store directory for provider auth checks", async () => { diff --git a/src/agents/model-provider-auth.ts b/src/agents/model-provider-auth.ts index 4e9e01a83b03..bd12f4ddabc8 100644 --- a/src/agents/model-provider-auth.ts +++ b/src/agents/model-provider-auth.ts @@ -24,6 +24,12 @@ import { listProfilesForProvider, type AuthProfileStore, } from "./auth-profiles.js"; +import { + createModelAuthAvailabilityResolver, + type ModelAuthAvailabilityEvaluation, + type ModelAuthAvailabilityRef, + type ModelAuthAvailabilityResolver, +} from "./model-auth-availability.js"; import { createRuntimeProviderAuthLookup, hasAvailableAuthForProvider, @@ -218,7 +224,17 @@ export async function hasAuthForModelProvider(params: { return false; } -/** Creates a cached provider-auth checker bound to one agent/runtime context. */ +export type ProviderModelAuthChecker = (( + provider: string, + ref?: ModelAuthAvailabilityRef, +) => Promise) & { + evaluateModelAuth( + provider: string, + ref?: ModelAuthAvailabilityRef, + ): Promise; +}; + +/** Creates a cached provider-auth evaluator bound to one agent/runtime context. */ export function createProviderAuthChecker(params: { cfg?: OpenClawConfig; workspaceDir?: string; @@ -228,38 +244,104 @@ export function createProviderAuthChecker(params: { allowPluginSyntheticAuth?: boolean; discoverExternalCliAuth?: boolean; allowPreparedRuntimeAuth?: boolean; -}): (provider: string, modelApi?: string) => Promise { - const authCache = new Map(); +}): ProviderModelAuthChecker { + const authCache = new Map>(); let runtimeAuthLookup: RuntimeProviderAuthLookup | undefined; - return async (provider: string, modelApi?: string) => { - const key = normalizeProviderId(provider); - const cacheKey = modelApi === undefined ? key : `${key}\0${modelApi}`; - const cached = authCache.get(cacheKey); - if (cached !== undefined) { - return cached; + let modelAuthResolver: ModelAuthAvailabilityResolver | undefined; + const resolveModelAuthResolver = () => { + if (modelAuthResolver) { + return modelAuthResolver; } - const value = await hasAuthForModelProvider({ - provider: key, - modelApi, + const agentDir = + params.agentDir ?? + (params.agentId && params.cfg + ? resolveAgentDir(params.cfg, params.agentId, params.env) + : undefined); + const authStore = ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }); + runtimeAuthLookup ??= createRuntimeProviderAuthLookup({ cfg: params.cfg, workspaceDir: params.workspaceDir, - agentDir: params.agentDir, - agentId: params.agentId, env: params.env, - allowPluginSyntheticAuth: params.allowPluginSyntheticAuth, - discoverExternalCliAuth: params.discoverExternalCliAuth, - allowPreparedRuntimeAuth: params.allowPreparedRuntimeAuth, - resolveRuntimeAuthLookup: () => - (runtimeAuthLookup ??= createRuntimeProviderAuthLookup({ - cfg: params.cfg, - workspaceDir: params.workspaceDir, - env: params.env, - includePluginSyntheticAuth: params.allowPluginSyntheticAuth !== false, - })), + includePluginSyntheticAuth: params.allowPluginSyntheticAuth !== false, }); - authCache.set(cacheKey, value); - return value; + modelAuthResolver = createModelAuthAvailabilityResolver({ + cfg: params.cfg ?? {}, + authStore, + agentDir, + workspaceDir: params.workspaceDir, + env: params.env, + skipSetupProviderFallback: true, + allowPreparedRuntimeAuth: + params.allowPreparedRuntimeAuth === true || + (params.discoverExternalCliAuth !== false && params.allowPluginSyntheticAuth !== false), + syntheticAuthProviderRefs: runtimeAuthLookup.syntheticAuthProviderRefs, + ...(params.discoverExternalCliAuth === false ? {} : { externalCliProviderIds: ["openai"] }), + }); + return modelAuthResolver; }; + const evaluateModelAuth = ( + provider: string, + ref: ModelAuthAvailabilityRef = {}, + ): Promise => { + const key = normalizeProviderId(provider); + const hasRouteFacts = + ref.modelId !== undefined || + ref.api !== undefined || + ref.baseUrl !== undefined || + ref.observedRoutes !== undefined; + const cacheKey = hasRouteFacts + ? `${key}\0${hashRuntimeConfigValue(ref as unknown as OpenClawConfig)}` + : key; + const cached = authCache.get(cacheKey); + if (cached) { + return cached; + } + const resolveLegacyProviderAuth = () => + hasAuthForModelProvider({ + provider: key, + modelApi: typeof ref.api === "string" ? ref.api : undefined, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + agentId: params.agentId, + env: params.env, + allowPluginSyntheticAuth: params.allowPluginSyntheticAuth, + discoverExternalCliAuth: params.discoverExternalCliAuth, + allowPreparedRuntimeAuth: params.allowPreparedRuntimeAuth, + resolveRuntimeAuthLookup: () => + (runtimeAuthLookup ??= createRuntimeProviderAuthLookup({ + cfg: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + includePluginSyntheticAuth: params.allowPluginSyntheticAuth !== false, + })), + }); + const evaluation = Promise.resolve().then( + async (): Promise => { + if (hasRouteFacts) { + return resolveModelAuthResolver().evaluateModelAuth(key, ref); + } + return { + availability: await resolveLegacyProviderAuth(), + routeResolution: null, + }; + }, + ); + authCache.set(cacheKey, evaluation); + void evaluation.catch(() => { + if (authCache.get(cacheKey) === evaluation) { + authCache.delete(cacheKey); + } + }); + return evaluation; + }; + return Object.assign( + async (provider: string, ref: ModelAuthAvailabilityRef = {}) => + (await evaluateModelAuth(provider, ref)).availability === true, + { evaluateModelAuth }, + ); } function serializeProviderAuthStates( diff --git a/src/agents/model-selection-config.ts b/src/agents/model-selection-config.ts new file mode 100644 index 000000000000..62c395e85142 --- /dev/null +++ b/src/agents/model-selection-config.ts @@ -0,0 +1,55 @@ +/** Pure configured-model selection helpers safe for config validation. */ +import { toAgentModelListLike } from "../config/model-input.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "./agent-scope.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; +import type { ModelManifestNormalizationContext, ModelRef } from "./model-selection-normalize.js"; +import { normalizeModelSelection, resolveConfiguredModelRef } from "./model-selection-shared.js"; + +export function resolveDefaultModelForAgent( + params: { + cfg: OpenClawConfig; + agentId?: string; + allowPluginNormalization?: boolean; + } & ModelManifestNormalizationContext, +): ModelRef { + const agentModelOverride = params.agentId + ? resolveAgentEffectiveModelPrimary(params.cfg, params.agentId) + : undefined; + const cfg = + agentModelOverride && agentModelOverride.length > 0 + ? { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...params.cfg.agents?.defaults, + model: { + ...toAgentModelListLike(params.cfg.agents?.defaults?.model), + primary: agentModelOverride, + }, + }, + }, + } + : params.cfg; + return resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + allowPluginNormalization: params.allowPluginNormalization, + manifestPlugins: params.manifestPlugins, + }); +} + +export function resolveSubagentConfiguredModelSelection(params: { + cfg: OpenClawConfig; + agentId: string; + includeAgentPrimary?: boolean; +}): string | undefined { + const agentConfig = resolveAgentConfig(params.cfg, params.agentId); + return ( + normalizeModelSelection(agentConfig?.subagents?.model) ?? + normalizeModelSelection(params.cfg.agents?.defaults?.subagents?.model) ?? + (params.includeAgentPrimary === false ? undefined : normalizeModelSelection(agentConfig?.model)) + ); +} diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index f1e9abea3887..6931cda4c1a8 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -945,6 +945,7 @@ export function buildAllowedModelSetWithFallbacks( allowAny: boolean; allowedCatalog: ModelCatalogEntry[]; allowedKeys: Set; + configuredCatalog: ModelCatalogEntry[]; } { const metadata = buildModelCatalogMetadata({ cfg: params.cfg, @@ -998,6 +999,7 @@ export function buildAllowedModelSetWithFallbacks( allowAny: true, allowedCatalog: catalog, allowedKeys: catalogKeys, + configuredCatalog, }; } @@ -1103,10 +1105,11 @@ export function buildAllowedModelSetWithFallbacks( allowAny: true, allowedCatalog: catalog, allowedKeys: catalogKeys, + configuredCatalog, }; } - return { allowAny: false, allowedCatalog, allowedKeys }; + return { allowAny: false, allowedCatalog, allowedKeys, configuredCatalog }; } /** Status of a candidate model against catalog and configured allowlist state. */ @@ -1342,6 +1345,9 @@ export function buildConfiguredModelCatalog(params: { id, name, api: model.api ?? provider.api, + ...((model.baseUrl ?? provider.baseUrl) + ? { baseUrl: model.baseUrl ?? provider.baseUrl } + : {}), contextWindow, contextTokens, reasoning, @@ -1502,6 +1508,8 @@ export type ModelVisibilityPolicy = { allowAny: boolean; allowedCatalog: ModelCatalogEntry[]; allowedKeys: Set; + configuredKeys: ReadonlySet; + retainedKeys: ReadonlySet; exactModelRefs: readonly string[]; providerWildcards: ReadonlySet; hasConfiguredEntries: boolean; @@ -1516,6 +1524,13 @@ export type ModelVisibilityPolicy = { }) => ModelCatalogEntry[]; }; +/** Canonical logical identity shared by visibility and physical route rows. */ +export function modelCatalogLogicalKey(entry: Pick): string { + const provider = normalizeProviderId(entry.provider); + const model = splitTrailingAuthProfile(entry.id).model; + return normalizeLowercaseStringOrEmpty(modelKey(provider, model)); +} + export function dedupeModelCatalogEntries( entries: readonly ModelCatalogEntry[], ): ModelCatalogEntry[] { @@ -1541,12 +1556,57 @@ export function createModelVisibilityPolicyWithFallbacks( defaultProvider: string; defaultModel?: string; fallbackModels: readonly string[]; + additionalConfiguredModelRefs?: readonly string[]; allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext, ): ModelVisibilityPolicy { const visibility = parseConfiguredModelVisibilityEntries({ cfg: params.cfg }); const allowed = buildAllowedModelSetWithFallbacks(params); + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + manifestPlugins: params.manifestPlugins, + }); + const configuredKeys = new Set(allowed.configuredCatalog.map(modelCatalogLogicalKey)); + const retainedKeys = new Set(); + const addConfiguredRef = (raw: string | undefined, retained: boolean) => { + if (!raw?.trim() || parseProviderWildcardModelRef(raw)) { + return; + } + const resolved = resolveModelRefFromString({ + cfg: params.cfg, + raw, + defaultProvider: params.defaultProvider, + aliasIndex, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + manifestPlugins: params.manifestPlugins, + }); + if (!resolved) { + return; + } + const key = modelCatalogLogicalKey({ + provider: resolved.ref.provider, + id: resolved.ref.model, + }); + configuredKeys.add(key); + if (retained) { + retainedKeys.add(key); + } + }; + for (const raw of [ + ...visibility.exactModelRefs, + ...(params.additionalConfiguredModelRefs ?? []), + ]) { + addConfiguredRef(raw, false); + } + addConfiguredRef(params.defaultModel, true); + for (const fallback of params.fallbackModels) { + addConfiguredRef(fallback, true); + } const allowsKey = (key: string): boolean => allowed.allowAny || isModelKeyAllowedBySet(allowed.allowedKeys, key); const exactConfiguredKeys = new Set(); @@ -1567,6 +1627,8 @@ export function createModelVisibilityPolicyWithFallbacks( allowAny: allowed.allowAny, allowedCatalog: allowed.allowedCatalog, allowedKeys: allowed.allowedKeys, + configuredKeys, + retainedKeys, exactModelRefs: visibility.exactModelRefs, providerWildcards: visibility.providerWildcards, hasConfiguredEntries: visibility.hasEntries, diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 51c9496a09e4..5e1647b02440 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -1183,6 +1183,7 @@ describe("model-selection", () => { { provider: "ollama", id: "existing", name: "Existing" }, { api: "ollama", + baseUrl: "http://127.0.0.1:11434", compat: undefined, contextTokens: undefined, provider: "ollama", @@ -1465,7 +1466,11 @@ describe("model-selection", () => { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5 (Configured)", alias: "Kimi K2.5 (NVIDIA)", + api: undefined, + baseUrl: "https://nvidia.example.com", contextWindow: 32_000, + contextTokens: undefined, + input: undefined, reasoning: true, compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] }, }, diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 590b95ee65c7..699b9000265f 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -8,18 +8,17 @@ import { import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, - toAgentModelListLike, } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - resolveAgentConfig, - resolveAgentEffectiveModelPrimary, - resolveAgentModelFallbacksOverride, -} from "./agent-scope.js"; -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; +import { resolveAgentModelFallbacksOverride } from "./agent-scope.js"; +import { DEFAULT_PROVIDER } from "./defaults.js"; import { findModelInCatalog } from "./model-catalog-lookup.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; +import { + resolveDefaultModelForAgent, + resolveSubagentConfiguredModelSelection, +} from "./model-selection-config.js"; export { resolveThinkingDefault, resolveThinkingDefaultWithRuntimeCatalog, @@ -60,6 +59,8 @@ export type { ModelAliasIndex, ModelManifestNormalizationContext, ModelRef, Mode export type { ThinkLevel } from "../auto-reply/thinking.shared.js"; +export { resolveDefaultModelForAgent, resolveSubagentConfiguredModelSelection }; + export { buildConfiguredAllowlistKeys, buildConfiguredModelCatalog, @@ -214,41 +215,6 @@ export function resolveAllowlistModelKey( return resolveAllowlistModelKeyFromShared({ cfg, raw, defaultProvider, manifestPlugins }); } -export function resolveDefaultModelForAgent( - params: { - cfg: OpenClawConfig; - agentId?: string; - allowPluginNormalization?: boolean; - } & ModelManifestNormalizationContext, -): ModelRef { - const agentModelOverride = params.agentId - ? resolveAgentEffectiveModelPrimary(params.cfg, params.agentId) - : undefined; - const cfg = - agentModelOverride && agentModelOverride.length > 0 - ? { - ...params.cfg, - agents: { - ...params.cfg.agents, - defaults: { - ...params.cfg.agents?.defaults, - model: { - ...toAgentModelListLike(params.cfg.agents?.defaults?.model), - primary: agentModelOverride, - }, - }, - }, - } - : params.cfg; - return resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - allowPluginNormalization: params.allowPluginNormalization, - manifestPlugins: params.manifestPlugins, - }); -} - export async function canonicalizeCaseOnlyCatalogModelRef(params: { raw: string | undefined; cfg?: OpenClawConfig; @@ -323,19 +289,6 @@ function resolveAllowedFallbacks(params: { cfg: OpenClawConfig; agentId?: string return resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model); } -export function resolveSubagentConfiguredModelSelection(params: { - cfg: OpenClawConfig; - agentId: string; - includeAgentPrimary?: boolean; -}): string | undefined { - const agentConfig = resolveAgentConfig(params.cfg, params.agentId); - return ( - normalizeModelSelection(agentConfig?.subagents?.model) ?? - normalizeModelSelection(params.cfg.agents?.defaults?.subagents?.model) ?? - (params.includeAgentPrimary === false ? undefined : normalizeModelSelection(agentConfig?.model)) - ); -} - /** * Resolve a normalized model string through a pre-built alias index, returning * a fully qualified `provider/model` string. If the value is already qualified diff --git a/src/agents/model-visibility-policy.ts b/src/agents/model-visibility-policy.ts index 75f2506931d8..f3d34f891379 100644 --- a/src/agents/model-visibility-policy.ts +++ b/src/agents/model-visibility-policy.ts @@ -3,7 +3,7 @@ */ import { resolveAgentModelFallbackValues } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveAgentModelFallbacksOverride } from "./agent-scope.js"; +import { resolveAgentConfig, resolveAgentModelFallbacksOverride } from "./agent-scope.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; import type { ModelManifestNormalizationContext } from "./model-selection-normalize.js"; import { @@ -46,6 +46,9 @@ export function createModelVisibilityPolicy( cfg: params.cfg, agentId: params.agentId, }), + additionalConfiguredModelRefs: params.agentId + ? Object.keys(resolveAgentConfig(params.cfg, params.agentId)?.models ?? {}) + : [], // Model visibility is used by lightweight status/list paths. Keep plugin // manifest normalization opt-in so those paths do not load plugin runtime // metadata unless a caller explicitly needs it. diff --git a/src/agents/openai-model-routes.test.ts b/src/agents/openai-model-routes.test.ts new file mode 100644 index 000000000000..bce3b9e129ec --- /dev/null +++ b/src/agents/openai-model-routes.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + openAIModelCatalogRoutePolicy, + resolveConfiguredOpenAIAuthMode, + resolveOpenAIModelRoutes, + selectOpenAIModelRouteAuth, +} from "./openai-model-routes.js"; +import { buildProviderModelAuthSourcePlan } from "./provider-model-auth-source-plan.js"; + +describe("OpenAI model route adapter", () => { + it("normalizes profile-qualified model ids", () => { + expect( + resolveOpenAIModelRoutes({ + provider: "OpenAI", + modelId: "gpt-5.5@work", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + env: {}, + }), + ).toMatchObject({ + kind: "routes", + defaultRuntimeId: "codex", + routes: [ + { api: "openai-responses", authRequirement: "api-key" }, + { api: "openai-chatgpt-responses", authRequirement: "subscription" }, + ], + }); + }); + + it("ignores other providers", () => { + expect(resolveOpenAIModelRoutes({ provider: "anthropic", modelId: "gpt-5.5" })).toBeNull(); + }); + + it("delegates configured auth and route selection to generic owners", () => { + const config = { + models: { + providers: { + openai: { auth: "oauth", models: [] }, + }, + }, + } as unknown as OpenClawConfig; + const resolution = resolveOpenAIModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + config, + env: {}, + }); + if (!resolution || resolution.kind !== "routes") { + throw new Error("expected OpenAI routes"); + } + expect(resolveConfiguredOpenAIAuthMode(config)).toBe("oauth"); + expect( + selectOpenAIModelRouteAuth({ + resolution, + configuredAuthMode: "oauth", + sourcePlan: buildProviderModelAuthSourcePlan({ + profiles: [ + { + kind: "profile", + profileId: "openai:chatgpt", + mode: "oauth", + readiness: "unknown", + cooldown: "clear", + }, + ], + }), + }), + ).toMatchObject({ + kind: "selected", + selection: { + source: { profileId: "openai:chatgpt" }, + route: { authRequirement: "subscription" }, + }, + }); + }); + + it("uses the provider-owned logical catalog identity", () => { + expect( + openAIModelCatalogRoutePolicy.resolveIdentity({ + provider: "OpenAI", + id: "openai/gpt-5.4-codex@work", + }), + ).toEqual({ id: "gpt-5.4", key: "openai/gpt-5.4" }); + expect( + openAIModelCatalogRoutePolicy.resolveIdentity({ provider: "custom", id: "custom/model" }), + ).toBeNull(); + }); +}); diff --git a/src/agents/openai-model-routes.ts b/src/agents/openai-model-routes.ts new file mode 100644 index 000000000000..2d052644d9b7 --- /dev/null +++ b/src/agents/openai-model-routes.ts @@ -0,0 +1,90 @@ +/** Cold adapter for provider-owned OpenAI model route facts. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { resolveMergedModelProviderConfig } from "../config/model-provider-config.js"; +import type { ModelApi } from "../config/types.models.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { + ProviderModelRouteResolution, + ProviderModelRouteSource, + ProviderRouteOverridePresence, +} from "../plugin-sdk/provider-model-types.js"; +import { createProviderModelRoutesResolver } from "../plugins/provider-model-routes.js"; +import { splitTrailingAuthProfile } from "./model-ref-profile.js"; +import type { ProviderModelAuthSourcePlan } from "./provider-model-auth-source-plan.js"; +import { selectProviderModelRouteAuth } from "./provider-model-route-auth.js"; +import { createProviderModelCatalogRoutePolicy } from "./provider-model-route.js"; + +const OPENAI_PROVIDER_ID = "openai"; + +export function createOpenAIModelRoutesResolver(params: { + config?: OpenClawConfig; + env?: Readonly>; + requestTransportOverrides?: ProviderRouteOverridePresence; +}) { + const resolveRoutes = createProviderModelRoutesResolver({ + provider: OPENAI_PROVIDER_ID, + config: params.config, + env: params.env, + requestTransportOverrides: params.requestTransportOverrides, + }); + return (observed: { + modelId?: string; + api?: string | null; + baseUrl?: unknown; + observedRoutes?: readonly ProviderModelRouteSource[]; + }) => + resolveRoutes({ + modelId: observed.modelId ? splitTrailingAuthProfile(observed.modelId).model : undefined, + observedRoutes: + observed.observedRoutes ?? + (observed.api != null || (observed.baseUrl !== undefined && observed.baseUrl !== null) + ? [ + { + api: observed.api as ModelApi | null | undefined, + baseUrl: observed.baseUrl, + }, + ] + : undefined), + }); +} + +/** Returns the authored OpenAI provider auth mode, if one exists. */ +export function resolveConfiguredOpenAIAuthMode(config?: OpenClawConfig): string | undefined { + return resolveMergedModelProviderConfig(config, OPENAI_PROVIDER_ID)?.auth; +} + +export function selectOpenAIModelRouteAuth(params: { + resolution: Parameters[0]["resolution"]; + sourcePlan: ProviderModelAuthSourcePlan; + configuredAuthMode?: string; + runtimeAuthOwner?: { id: string }; +}) { + return selectProviderModelRouteAuth({ provider: OPENAI_PROVIDER_ID, ...params }); +} + +export const openAIModelCatalogRoutePolicy = + createProviderModelCatalogRoutePolicy(OPENAI_PROVIDER_ID); + +/** Resolves provider-owned OpenAI route state without loading the full provider runtime. */ +export function resolveOpenAIModelRoutes(params: { + provider?: string; + modelId?: string; + api?: string | null; + baseUrl?: unknown; + config?: OpenClawConfig; + env?: Readonly>; + requestTransportOverrides?: ProviderRouteOverridePresence; +}): ProviderModelRouteResolution | null { + if (normalizeProviderId(params.provider ?? "") !== OPENAI_PROVIDER_ID) { + return null; + } + return createOpenAIModelRoutesResolver({ + config: params.config, + env: params.env, + requestTransportOverrides: params.requestTransportOverrides, + })({ + modelId: params.modelId, + api: params.api as ModelApi | null | undefined, + baseUrl: params.baseUrl, + }); +} diff --git a/src/agents/openai-routing.test.ts b/src/agents/openai-routing.test.ts index 0e255d47617a..c4c42ece32af 100644 --- a/src/agents/openai-routing.test.ts +++ b/src/agents/openai-routing.test.ts @@ -1,18 +1,33 @@ // Verifies OpenAI model selections route between OpenClaw and Codex runtimes. -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime, modelSelectionShouldEnsureCodexPlugin, - openAIProviderUsesCodexRuntimeByDefault, + resolveOpenAIImplicitAgentRuntime, resolveContextConfigProviderForRuntime, resolveOpenAIRuntimeProvider, resolveSelectedOpenAIRuntimeProvider, } from "./openai-routing.js"; describe("OpenAI runtime routing policy", () => { + beforeEach(() => { + vi.stubEnv("OPENAI_BASE_URL", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("uses Codex by default for official OpenAI agent model selections", () => { - expect(openAIProviderUsesCodexRuntimeByDefault({ provider: "openai" })).toBe(true); + expect(resolveOpenAIImplicitAgentRuntime({ provider: "openai", env: {} })).toBe("codex"); + expect( + resolveOpenAIImplicitAgentRuntime({ + provider: "openai", + modelId: "gpt-5.4-nano", + env: {}, + }), + ).toBe("codex"); expect( modelSelectionShouldEnsureCodexPlugin({ model: "openai/gpt-5.5", @@ -21,6 +36,62 @@ describe("OpenAI runtime routing policy", () => { ).toBe(true); }); + it("maps provider route facts onto a closed implicit runtime", () => { + expect( + resolveOpenAIImplicitAgentRuntime({ provider: "openai", modelId: "gpt-5.6", env: {} }), + ).toBe("codex"); + expect( + resolveOpenAIImplicitAgentRuntime({ + provider: "openai", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex/responses", + env: {}, + }), + ).toBe("codex"); + expect( + resolveOpenAIImplicitAgentRuntime({ + provider: "openai", + modelId: "gpt-5.5", + config: { + models: { + providers: { + openai: { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }, + env: {}, + }), + ).toBe("openclaw"); + expect( + resolveOpenAIImplicitAgentRuntime({ + provider: "openai", + baseUrl: "https://direct.example.test/v1", + env: {}, + }), + ).toBe("openclaw"); + }); + + it("lets the provider owner interpret its environment", () => { + expect( + resolveOpenAIImplicitAgentRuntime({ + provider: "openai", + env: { OPENAI_BASE_URL: "https://relay.example.test/v1" }, + }), + ).toBe("openclaw"); + }); + + it("fails closed to OpenClaw when the provider artifact is unavailable", () => { + vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_PLUGINS", "1"); + expect(resolveOpenAIImplicitAgentRuntime({ provider: "openai", modelId: "gpt-5.5" })).toBe( + "openclaw", + ); + expect(modelSelectionShouldEnsureCodexPlugin({ model: "openai/gpt-5.5" })).toBe(false); + }); + it("does not force Codex for custom OpenAI-compatible base URLs", () => { // A custom baseUrl means the provider key is only OpenAI-compatible, not official OpenAI. const config = { @@ -34,7 +105,7 @@ describe("OpenAI runtime routing policy", () => { }, } satisfies OpenClawConfig; - expect(openAIProviderUsesCodexRuntimeByDefault({ provider: "openai", config })).toBe(false); + expect(resolveOpenAIImplicitAgentRuntime({ provider: "openai", config })).toBe("openclaw"); expect(modelSelectionShouldEnsureCodexPlugin({ model: "openai/gpt-5.5", config })).toBe(false); expect( resolveContextConfigProviderForRuntime({ @@ -99,7 +170,7 @@ describe("OpenAI runtime routing policy", () => { }, } satisfies OpenClawConfig; - expect(openAIProviderUsesCodexRuntimeByDefault({ provider: "openai", config })).toBe(false); + expect(resolveOpenAIImplicitAgentRuntime({ provider: "openai", config })).toBe("openclaw"); expect(modelSelectionShouldEnsureCodexPlugin({ model: "openai/gpt-5.5", config })).toBe(false); }); diff --git a/src/agents/openai-routing.ts b/src/agents/openai-routing.ts index 1faec5fb38b8..27749e022e25 100644 --- a/src/agents/openai-routing.ts +++ b/src/agents/openai-routing.ts @@ -5,67 +5,75 @@ */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ProviderRouteOverridePresence } from "../plugin-sdk/provider-model-types.js"; +import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js"; +import { hasModelExtraParams } from "./model-extra-params.js"; import { resolveModelRuntimePolicy } from "./model-runtime-policy.js"; +import { resolveOpenAIModelRoutes } from "./openai-model-routes.js"; +import { canonicalizeProviderModelId } from "./provider-model-route.js"; /** Canonical provider id for OpenAI-hosted model routes. */ export const OPENAI_PROVIDER_ID = "openai"; export const OPENAI_CODEX_PROVIDER_ID = OPENAI_PROVIDER_ID; -// OpenAI defaults to Codex runtime only for the official API endpoint. Custom -// base URLs keep their configured provider behavior. -function isOfficialOpenAIBaseUrl(baseUrl: unknown): boolean { - if (typeof baseUrl !== "string" || !baseUrl.trim()) { - return true; - } - try { - const url = new URL(baseUrl.trim()); - return ( - url.protocol === "https:" && - url.hostname.toLowerCase() === "api.openai.com" && - (url.pathname === "" || - url.pathname === "/" || - url.pathname === "/v1" || - url.pathname === "/v1/") - ); - } catch { - return false; - } -} - -function resolveOpenAIProviderConfig(config: OpenClawConfig | undefined) { - const providers = config?.models?.providers; - if (!providers) { - return undefined; - } - const direct = providers.openai; - if (direct) { - return direct; - } - for (const [providerId, providerConfig] of Object.entries(providers)) { - if (normalizeProviderId(providerId) === OPENAI_PROVIDER_ID) { - return providerConfig; - } - } - return undefined; -} - -function openAIProviderUsesCustomBaseUrl(config: OpenClawConfig | undefined): boolean { - return !isOfficialOpenAIBaseUrl(resolveOpenAIProviderConfig(config)?.baseUrl); -} - /** Returns true for provider ids that normalize to OpenAI. */ export function isOpenAIProvider(provider: string | undefined): boolean { const normalized = normalizeProviderId(provider ?? ""); return normalized === OPENAI_PROVIDER_ID; } -/** Returns whether OpenAI should use the Codex runtime default for this config. */ -export function openAIProviderUsesCodexRuntimeByDefault(params: { +/** Canonicalizes shipped OpenAI model aliases at runtime boundaries. */ +export function canonicalizeOpenAIModelId(provider: string | undefined, modelId: string): string { + return isOpenAIProvider(provider) + ? canonicalizeProviderModelId(OPENAI_PROVIDER_ID, modelId) + : modelId; +} + +/** Resolves the provider-owned implicit runtime for one concrete OpenAI route. */ +export function resolveOpenAIImplicitAgentRuntime(params: { provider?: string; + modelId?: string; + api?: string | null; + baseUrl?: unknown; config?: OpenClawConfig; -}): boolean { - return isOpenAIProvider(params.provider) && !openAIProviderUsesCustomBaseUrl(params.config); + agentId?: string; + sessionKey?: string; + env?: Readonly>; + requestTransportOverrides?: ProviderRouteOverridePresence; +}): "codex" | "openclaw" | null { + if (!isOpenAIProvider(params.provider)) { + return null; + } + const modelId = params.modelId; + const agentId = + params.agentId ?? + (params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined); + const hasConfiguredParams = hasModelExtraParams({ + config: params.config, + provider: params.provider ?? OPENAI_PROVIDER_ID, + modelId, + agentId, + }); + const requestTransportOverrides = + params.requestTransportOverrides === "present" || hasConfiguredParams ? "present" : "none"; + const resolution = resolveOpenAIModelRoutes({ + provider: params.provider, + modelId, + api: params.api, + baseUrl: params.baseUrl, + config: params.config, + env: params.env, + requestTransportOverrides, + }); + if (!resolution) { + // Endpoint and adapter ownership stays in the provider artifact. Without + // that policy, keep credentials and traffic on the core OpenClaw runtime. + return "openclaw"; + } + return resolution.kind !== "incompatible" && resolution.defaultRuntimeId === "codex" + ? "codex" + : "openclaw"; } /** Parses the provider portion from a provider/model ref. */ @@ -104,7 +112,14 @@ export function modelSelectionShouldEnsureCodexPlugin(params: { if (configuredRuntime && !isDefaultAgentRuntimeId(configuredRuntime)) { return configuredRuntime === "codex"; } - return !openAIProviderUsesCustomBaseUrl(params.config); + return ( + resolveOpenAIImplicitAgentRuntime({ + provider, + modelId, + config: params.config, + agentId: params.agentId, + }) === "codex" + ); } /** Lists auth-profile providers for an OpenAI runtime route. */ diff --git a/src/agents/provider-model-auth-source-plan.ts b/src/agents/provider-model-auth-source-plan.ts new file mode 100644 index 000000000000..05549b3c0d51 --- /dev/null +++ b/src/agents/provider-model-auth-source-plan.ts @@ -0,0 +1,147 @@ +export type ProviderModelAuthReadiness = "ready" | "unknown" | "unavailable"; + +export type ProviderModelAuthEvidence = + | "aws-sdk" + | "environment" + | "none" + | "profile" + | "provider-config" + | "runtime" + | "synthetic"; + +export type ProviderModelAuthProfileSource = { + kind: "profile"; + profileId: string; + provider?: string; + mode?: string; + readiness: ProviderModelAuthReadiness; + cooldown: "active" | "clear"; +}; + +export type ProviderModelAuthDirectSource = { + kind: "direct"; + mode?: string; + readiness: ProviderModelAuthReadiness; + evidence: ProviderModelAuthEvidence; +}; + +export type ProviderModelAuthSource = + | ProviderModelAuthProfileSource + | ProviderModelAuthDirectSource; + +export type ProviderModelAuthRequiredReason = "configured-auth" | "provider-binding" | "user-lock"; + +export type ProviderModelAuthAutomaticProfiles = + | { kind: "empty"; explicitOrder: boolean } + | { + kind: "usable"; + explicitOrder: boolean; + profiles: readonly ProviderModelAuthProfileSource[]; + } + | { + kind: "all-unavailable"; + explicitOrder: boolean; + first: ProviderModelAuthProfileSource; + } + | { + kind: "all-cooldown"; + explicitOrder: boolean; + first: ProviderModelAuthProfileSource; + }; + +export type ProviderModelAuthSourcePlan = + | { + kind: "required"; + reason: ProviderModelAuthRequiredReason; + source: ProviderModelAuthSource; + } + | { + kind: "automatic"; + profiles: ProviderModelAuthAutomaticProfiles; + orderedProfiles: readonly ProviderModelAuthProfileSource[]; + allowCooldown: boolean; + fallback?: ProviderModelAuthDirectSource; + }; + +export function toProviderModelAuthReadiness( + availability: boolean | undefined, +): ProviderModelAuthReadiness { + return availability === true ? "ready" : availability === false ? "unavailable" : "unknown"; +} + +export function fromProviderModelAuthReadiness( + readiness: ProviderModelAuthReadiness, +): boolean | undefined { + return readiness === "ready" ? true : readiness === "unavailable" ? false : undefined; +} + +/** Creates a source fact without retaining credential material. */ +export function buildProviderModelAuthDirectSource(params: { + mode?: string; + availability?: boolean; + evidence: ProviderModelAuthEvidence; +}): ProviderModelAuthDirectSource { + return { + kind: "direct", + mode: params.mode, + readiness: toProviderModelAuthReadiness(params.availability), + evidence: params.evidence, + }; +} + +function reorderPreferredProfile( + profiles: readonly ProviderModelAuthProfileSource[], + preferredProfileId: string | undefined, +): ProviderModelAuthProfileSource[] { + if (!preferredProfileId) { + return [...profiles]; + } + const preferred = profiles.find((profile) => profile.profileId === preferredProfileId); + return preferred + ? [preferred, ...profiles.filter((profile) => profile.profileId !== preferredProfileId)] + : [...profiles]; +} + +/** Applies source precedence and automatic-tier readiness/cooldown policy once. */ +export function buildProviderModelAuthSourcePlan(params: { + ownership?: { + reason: ProviderModelAuthRequiredReason; + source: ProviderModelAuthSource; + }; + profiles: readonly ProviderModelAuthProfileSource[]; + preferredProfileId?: string; + explicitOrder?: boolean; + fallback?: ProviderModelAuthDirectSource; + allowCooldown?: boolean; +}): ProviderModelAuthSourcePlan { + if (params.ownership) { + return { kind: "required", ...params.ownership }; + } + const explicitOrder = params.explicitOrder === true; + const ordered = reorderPreferredProfile(params.profiles, params.preferredProfileId); + let profiles: ProviderModelAuthAutomaticProfiles; + if (ordered.length === 0) { + profiles = { kind: "empty", explicitOrder }; + } else { + const available = ordered.filter((profile) => profile.readiness !== "unavailable"); + if (available.length === 0) { + profiles = { kind: "all-unavailable", explicitOrder, first: ordered[0] }; + } else { + const outsideCooldown = available.filter((profile) => profile.cooldown === "clear"); + if (outsideCooldown.length > 0) { + profiles = { kind: "usable", explicitOrder, profiles: outsideCooldown }; + } else if (params.allowCooldown) { + profiles = { kind: "usable", explicitOrder, profiles: available.slice(0, 1) }; + } else { + profiles = { kind: "all-cooldown", explicitOrder, first: available[0] }; + } + } + } + return { + kind: "automatic", + profiles, + orderedProfiles: ordered, + allowCooldown: params.allowCooldown === true, + ...(params.fallback ? { fallback: params.fallback } : {}), + }; +} diff --git a/src/agents/provider-model-route-auth.test.ts b/src/agents/provider-model-route-auth.test.ts new file mode 100644 index 000000000000..c3f8cf6b06f9 --- /dev/null +++ b/src/agents/provider-model-route-auth.test.ts @@ -0,0 +1,408 @@ +import { describe, expect, it } from "vitest"; +import { + buildProviderModelAuthSourcePlan, + type ProviderModelAuthDirectSource, + type ProviderModelAuthProfileSource, +} from "./provider-model-auth-source-plan.js"; +import { + resolveProviderModelRouteMaterializationAuthMode, + selectProviderModelRouteAuth, +} from "./provider-model-route-auth.js"; + +const routes = { + kind: "routes", + routes: [ + { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + ], +} as const; + +function profile( + profileId: string, + mode: string, + readiness: ProviderModelAuthProfileSource["readiness"], + cooldown: ProviderModelAuthProfileSource["cooldown"] = "clear", +): ProviderModelAuthProfileSource { + return { kind: "profile", profileId, mode, readiness, cooldown }; +} + +function direct(mode: string): ProviderModelAuthDirectSource { + return { + kind: "direct", + mode, + readiness: "ready", + evidence: "provider-config", + }; +} + +describe("provider model route auth", () => { + it.each([ + ["api-key", "api-key", "api_key"], + ["api_key", "api-key", "api_key"], + ["aws-sdk", "api-key", "aws-sdk"], + ["oauth", "subscription", "oauth"], + ["token", "subscription", "token"], + [undefined, "api-key", "api_key"], + [undefined, "subscription", "oauth"], + ] as const)("materializes %s for a %s route as %s", (mode, requirement, expected) => { + expect(resolveProviderModelRouteMaterializationAuthMode({ mode, requirement })).toBe(expected); + }); + + it.each([ + { + label: "pins an unknown source before a ready sibling route", + profiles: [ + profile("openai:unknown", "oauth", "unknown"), + profile("openai:platform", "api_key", "ready"), + ], + expectedProfileId: "openai:unknown", + expectedRoute: "subscription", + expectedAttempts: ["openai:unknown", "openai:platform"], + }, + { + label: "keeps the first ordered source when a later same-route source is ready", + profiles: [ + profile("openai:unknown", "oauth", "unknown"), + profile("openai:platform", "api_key", "ready"), + profile("openai:subscription", "token", "ready"), + ], + expectedProfileId: "openai:unknown", + expectedRoute: "subscription", + expectedAttempts: ["openai:unknown", "openai:subscription", "openai:platform"], + }, + { + label: "drops a proven-unavailable source before route selection", + profiles: [ + profile("openai:invalid", "api_key", "unavailable"), + profile("openai:subscription", "oauth", "ready"), + ], + expectedProfileId: "openai:subscription", + expectedRoute: "subscription", + expectedAttempts: ["openai:subscription"], + }, + ])("$label", ({ expectedAttempts, expectedProfileId, expectedRoute, profiles }) => { + const decision = selectProviderModelRouteAuth({ + provider: "openai", + resolution: routes, + sourcePlan: buildProviderModelAuthSourcePlan({ profiles }), + }); + expect(decision).toMatchObject({ + kind: "selected", + selection: { + kind: "selected", + source: { kind: "profile", profileId: expectedProfileId }, + route: { authRequirement: expectedRoute }, + }, + }); + if (decision.kind !== "selected") { + throw new Error("expected selected route"); + } + expect( + decision.attempts.map((attempt) => + attempt.kind === "profile" ? attempt.source.profileId : "direct", + ), + ).toEqual(expectedAttempts); + }); + + it("keeps profile and direct fallback attempts distinct on one route", () => { + const decision = selectProviderModelRouteAuth({ + provider: "openai", + resolution: routes, + configuredAuthMode: "api-key", + sourcePlan: buildProviderModelAuthSourcePlan({ + profiles: [profile("openai:platform", "api_key", "unknown")], + fallback: direct("api-key"), + }), + }); + expect(decision).toMatchObject({ + kind: "selected", + attempts: [ + { + kind: "profile", + source: { profileId: "openai:platform" }, + sameRouteProfileIds: ["openai:platform"], + }, + { kind: "direct", allowAuthProfileFallback: false }, + ], + }); + }); + + it("omits an incompatible direct fallback when a compatible profile exists", () => { + const decision = selectProviderModelRouteAuth({ + provider: "openai", + resolution: { ...routes, routes: [routes.routes[1]] }, + sourcePlan: buildProviderModelAuthSourcePlan({ + profiles: [profile("openai:chatgpt", "oauth", "ready")], + fallback: direct("api-key"), + }), + }); + + expect(decision).toMatchObject({ + kind: "selected", + selection: { + source: { kind: "profile", profileId: "openai:chatgpt" }, + route: { authRequirement: "subscription" }, + }, + }); + if (decision.kind !== "selected") { + throw new Error("expected selected route"); + } + expect(decision.attempts).toEqual([ + expect.objectContaining({ + kind: "profile", + source: expect.objectContaining({ profileId: "openai:chatgpt" }), + }), + ]); + }); + + it("does not attach a direct API key to a configured subscription route", () => { + const decision = selectProviderModelRouteAuth({ + provider: "openai", + resolution: routes, + configuredAuthMode: "oauth", + sourcePlan: buildProviderModelAuthSourcePlan({ + profiles: [profile("openai:chatgpt", "oauth", "ready")], + fallback: direct("api-key"), + }), + }); + + expect(decision).toMatchObject({ + kind: "selected", + selection: { route: { authRequirement: "subscription" } }, + }); + if (decision.kind !== "selected") { + throw new Error("expected selected route"); + } + expect(decision.attempts).toHaveLength(1); + expect(decision.attempts[0]).toMatchObject({ kind: "profile" }); + }); + + it("fails an all-cooldown tier closed before direct fallback", () => { + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: routes, + sourcePlan: buildProviderModelAuthSourcePlan({ + profiles: [profile("openai:cooldown", "api_key", "ready", "active")], + fallback: direct("api-key"), + }), + }), + ).toMatchObject({ + kind: "rejected", + reason: "all-cooldown", + source: { profileId: "openai:cooldown" }, + }); + }); + + it.each([undefined, "api-key"] as const)( + "does not let a clear wrong-route profile hide a cooldown compatible tier (%s)", + (configuredAuthMode) => { + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: { ...routes, routes: [routes.routes[0]] }, + configuredAuthMode, + sourcePlan: buildProviderModelAuthSourcePlan({ + profiles: [ + profile("openai:chatgpt", "oauth", "ready"), + profile("openai:platform", "api_key", "ready", "active"), + ], + fallback: direct("api-key"), + }), + }), + ).toMatchObject({ + kind: "rejected", + reason: "all-cooldown", + source: { profileId: "openai:platform" }, + }); + }, + ); + + it.each([ + { label: "empty", profiles: [] }, + { + label: "all unavailable", + profiles: [profile("openai:invalid", "api_key", "unavailable")], + }, + ])("rejects an $label explicit order before direct fallback", ({ profiles }) => { + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: routes, + sourcePlan: buildProviderModelAuthSourcePlan({ + profiles, + explicitOrder: true, + fallback: direct("api-key"), + }), + }), + ).toMatchObject({ kind: "rejected", reason: "explicit-order" }); + }); + + it("keeps a required profile authoritative over configured auth", () => { + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: routes, + configuredAuthMode: "api-key", + sourcePlan: buildProviderModelAuthSourcePlan({ + ownership: { + reason: "provider-binding", + source: profile("openai:bound", "token", "unknown"), + }, + profiles: [], + }), + }), + ).toMatchObject({ + kind: "selected", + selection: { + source: { profileId: "openai:bound" }, + route: { authRequirement: "subscription" }, + }, + }); + }); + + it.each([ + { configuredAuthMode: "oauth", profileMode: "api_key", route: "subscription" }, + { configuredAuthMode: "api-key", profileMode: "oauth", route: "api-key" }, + ])( + "rejects a $profileMode profile for a configured $configuredAuthMode route", + ({ configuredAuthMode, profileMode, route }) => { + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: routes, + configuredAuthMode, + sourcePlan: buildProviderModelAuthSourcePlan({ + profiles: [profile("openai:wrong-route", profileMode, "ready")], + }), + }), + ).toMatchObject({ + kind: "rejected", + reason: "configured-auth", + source: { profileId: "openai:wrong-route" }, + route: { authRequirement: route }, + }); + }, + ); + + it("rejects configured auth without a validated harness credential mode", () => { + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: routes, + configuredAuthMode: "oauth", + runtimeAuthOwner: { id: "codex" }, + sourcePlan: buildProviderModelAuthSourcePlan({ profiles: [] }), + }), + ).toMatchObject({ + kind: "rejected", + reason: "configured-auth", + route: { authRequirement: "subscription" }, + }); + }); + + it("defers an ambiguous no-profile route to an explicit harness auth owner", () => { + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: routes, + runtimeAuthOwner: { id: "codex" }, + sourcePlan: buildProviderModelAuthSourcePlan({ profiles: [] }), + }), + ).toEqual({ + kind: "deferred", + reason: "runtime-auth-owner", + routeSupport: { + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + }); + }); + + it.each([ + ["Platform", routes.routes[0], profile("openai:chatgpt", "oauth", "ready")], + ["subscription", routes.routes[1], profile("openai:platform", "api_key", "ready")], + ] as const)( + "does not defer a concrete %s route to unvalidated native auth", + (_label, route, source) => { + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: { ...routes, routes: [route] }, + runtimeAuthOwner: { id: "codex" }, + sourcePlan: buildProviderModelAuthSourcePlan({ profiles: [source] }), + }), + ).toMatchObject({ + kind: "rejected", + reason: "configured-auth", + source: { profileId: source.profileId }, + }); + }, + ); + + it("rejects a runtime owner that cannot reproduce every candidate route", () => { + const incompatibleRoutes = { + ...routes, + routes: [ + routes.routes[0], + { ...routes.routes[1], runtimePolicy: { compatibleIds: ["openclaw"] } }, + ], + } as const; + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: incompatibleRoutes, + runtimeAuthOwner: { id: "codex" }, + sourcePlan: buildProviderModelAuthSourcePlan({ profiles: [] }), + }), + ).toMatchObject({ kind: "rejected", reason: "configured-auth" }); + }); + + it("fails closed when any deferred route omits runtime compatibility", () => { + const undeclaredRoutes = { + ...routes, + routes: [routes.routes[0], { ...routes.routes[1], runtimePolicy: undefined }], + } as const; + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: undeclaredRoutes, + runtimeAuthOwner: { id: "codex" }, + sourcePlan: buildProviderModelAuthSourcePlan({ profiles: [] }), + }), + ).toMatchObject({ kind: "rejected", reason: "configured-auth" }); + }); + + it("aggregates request overrides across every deferred route", () => { + const overrideRoutes = { + ...routes, + routes: [routes.routes[0], { ...routes.routes[1], requestTransportOverrides: "present" }], + } as const; + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: overrideRoutes, + runtimeAuthOwner: { id: "openclaw" }, + sourcePlan: buildProviderModelAuthSourcePlan({ profiles: [] }), + }), + ).toMatchObject({ + kind: "deferred", + routeSupport: { + requestTransportOverrides: "present", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + }); + }); +}); diff --git a/src/agents/provider-model-route-auth.ts b/src/agents/provider-model-route-auth.ts new file mode 100644 index 000000000000..d1a871b426e1 --- /dev/null +++ b/src/agents/provider-model-route-auth.ts @@ -0,0 +1,458 @@ +import type { + ProviderModelRouteAuthRequirement, + ProviderModelRouteCandidate, + ProviderModelRouteResolution, + ProviderModelRouteRuntimePolicy, + ProviderRouteOverridePresence, +} from "../plugin-sdk/provider-model-types.js"; +import type { + ProviderModelAuthDirectSource, + ProviderModelAuthProfileSource, + ProviderModelAuthSource, + ProviderModelAuthSourcePlan, +} from "./provider-model-auth-source-plan.js"; +import { buildProviderModelAuthSourcePlan } from "./provider-model-auth-source-plan.js"; + +export type ProviderModelAuthSourceSelection = + | { kind: "selected"; source: ProviderModelAuthSource } + | { kind: "unavailable"; source: ProviderModelAuthProfileSource } + | { kind: "none" }; + +export type ProviderModelAuthLogicalAttempt = + | { kind: "profile"; source: ProviderModelAuthProfileSource } + | { + kind: "direct"; + source: ProviderModelAuthDirectSource; + allowAuthProfileFallback: false; + }; + +export type ProviderModelRouteAuthAttempt = + | { + kind: "profile"; + source: ProviderModelAuthProfileSource; + route: ProviderModelRouteCandidate; + /** Remaining exact-route candidates for this physical attempt. */ + sameRouteProfileIds: readonly string[]; + } + | { + kind: "direct"; + source: ProviderModelAuthDirectSource; + route: ProviderModelRouteCandidate; + allowAuthProfileFallback: false; + }; + +export type ProviderModelAuthSourceDecision = + | { + kind: "selected"; + selection: ProviderModelAuthSourceSelection; + attempts: readonly ProviderModelAuthLogicalAttempt[]; + } + | { + kind: "rejected"; + reason: "all-cooldown" | "explicit-order"; + message: string; + source?: ProviderModelAuthProfileSource; + }; + +type ProviderModelRouteAuthDecision = + | { + kind: "selected"; + selection: ProviderModelAuthSourceSelection & { route: ProviderModelRouteCandidate }; + attempts: readonly ProviderModelRouteAuthAttempt[]; + } + | { + kind: "deferred"; + reason: "runtime-auth-owner"; + routeSupport: { + requestTransportOverrides: ProviderRouteOverridePresence; + runtimePolicy: ProviderModelRouteRuntimePolicy; + }; + } + | { + kind: "rejected"; + reason: "all-cooldown" | "configured-auth" | "explicit-order" | "required-profile"; + message: string; + source?: ProviderModelAuthProfileSource; + route?: ProviderModelRouteCandidate; + }; + +export type ProviderModelRouteMaterializationAuthMode = "api_key" | "aws-sdk" | "oauth" | "token"; + +/** Normalizes stored/runtime auth syntax for profile-scoped model lookup. */ +export function resolveProviderModelMaterializationAuthMode( + mode: string | undefined, +): ProviderModelRouteMaterializationAuthMode | undefined { + switch (mode) { + case "api-key": + case "api_key": + return "api_key"; + case "aws-sdk": + case "oauth": + case "token": + return mode; + default: + return undefined; + } +} + +/** Maps runtime/stored credential modes onto the provider route contract. */ +export function resolveProviderModelRouteAuthRequirement( + mode: string | undefined, +): ProviderModelRouteAuthRequirement | undefined { + switch (mode) { + case "api-key": + case "api_key": + case "aws-sdk": + return "api-key"; + case "oauth": + case "token": + return "subscription"; + default: + return undefined; + } +} + +export function providerModelRouteAcceptsAuthMode(params: { + requirement: ProviderModelRouteAuthRequirement; + mode: string | undefined; +}): boolean { + return resolveProviderModelRouteAuthRequirement(params.mode) === params.requirement; +} + +/** Preserves an exact credential mode while normalizing authored api-key syntax. */ +export function resolveProviderModelRouteMaterializationAuthMode(params: { + mode?: string; + requirement: ProviderModelRouteAuthRequirement; +}): ProviderModelRouteMaterializationAuthMode { + return ( + resolveProviderModelMaterializationAuthMode(params.mode) ?? + (params.requirement === "api-key" ? "api_key" : "oauth") + ); +} + +function directAttempt(source: ProviderModelAuthDirectSource): ProviderModelAuthLogicalAttempt { + return { kind: "direct", source, allowAuthProfileFallback: false }; +} + +function selectReadyProfile( + profiles: readonly ProviderModelAuthProfileSource[], +): ProviderModelAuthProfileSource | undefined { + const first = profiles[0]; + if (!first || first.readiness !== "unknown") { + return first; + } + return profiles.find((profile) => profile.readiness === "ready") ?? first; +} + +/** Selects logical auth sources without resolving a provider-owned route. */ +export function selectProviderModelAuthSources(params: { + provider: string; + plan: ProviderModelAuthSourcePlan; +}): ProviderModelAuthSourceDecision { + if (params.plan.kind === "required") { + const source = params.plan.source; + return { + kind: "selected", + selection: { kind: "selected", source }, + attempts: [source.kind === "profile" ? { kind: "profile", source } : directAttempt(source)], + }; + } + + const { fallback, profiles } = params.plan; + if (profiles.kind === "all-cooldown") { + return { + kind: "rejected", + reason: "all-cooldown", + message: `Auth profile "${profiles.first.profileId}" is temporarily unavailable for ${params.provider}.`, + source: profiles.first, + }; + } + if ( + profiles.explicitOrder && + (profiles.kind === "empty" || profiles.kind === "all-unavailable") + ) { + return { + kind: "rejected", + reason: "explicit-order", + message: `Explicit auth order for ${params.provider} has no usable profiles.`, + ...(profiles.kind === "all-unavailable" ? { source: profiles.first } : {}), + }; + } + if (profiles.kind === "usable") { + const winner = selectReadyProfile(profiles.profiles); + return { + kind: "selected", + selection: winner ? { kind: "selected", source: winner } : { kind: "none" }, + attempts: [ + ...profiles.profiles.map((source) => ({ kind: "profile" as const, source })), + ...(fallback ? [directAttempt(fallback)] : []), + ], + }; + } + if (fallback) { + return { + kind: "selected", + selection: { kind: "selected", source: fallback }, + attempts: [directAttempt(fallback)], + }; + } + return { + kind: "selected", + selection: + profiles.kind === "all-unavailable" + ? { kind: "unavailable", source: profiles.first } + : { kind: "none" }, + attempts: [], + }; +} + +function reject( + reason: Extract["reason"], + message: string, + source?: ProviderModelAuthProfileSource, + route?: ProviderModelRouteCandidate, +): ProviderModelRouteAuthDecision { + return { + kind: "rejected", + reason, + message, + ...(source ? { source } : {}), + ...(route ? { route } : {}), + }; +} + +function routeForMode( + resolution: Extract, + mode: string | undefined, +): ProviderModelRouteCandidate | undefined { + const requirement = resolveProviderModelRouteAuthRequirement(mode); + return requirement + ? resolution.routes.find((candidate) => candidate.authRequirement === requirement) + : undefined; +} + +function resolveDeferredRouteSupport( + resolution: Extract, +): Extract["routeSupport"] { + const seenRuntimeIds = new Set(); + const compatibleIds = (resolution.routes[0].runtimePolicy?.compatibleIds ?? []).flatMap((id) => { + const normalizedId = id.trim().toLowerCase(); + if ( + !normalizedId || + seenRuntimeIds.has(normalizedId) || + !resolution.routes.every((route) => + route.runtimePolicy?.compatibleIds.some( + (candidateId) => candidateId.trim().toLowerCase() === normalizedId, + ), + ) + ) { + return []; + } + seenRuntimeIds.add(normalizedId); + return [normalizedId]; + }); + return { + requestTransportOverrides: resolution.routes.some( + (route) => route.requestTransportOverrides === "present", + ) + ? "present" + : "none", + runtimePolicy: { compatibleIds }, + }; +} + +/** Selects one route and emits source-distinct, exact-route physical attempts. */ +export function selectProviderModelRouteAuth(params: { + provider: string; + resolution: Extract; + sourcePlan: ProviderModelAuthSourcePlan; + configuredAuthMode?: string; + /** Explicit native auth owner allowed to defer an otherwise unowned route. */ + runtimeAuthOwner?: { id: string }; +}): ProviderModelRouteAuthDecision { + const requiredProfile = + params.sourcePlan.kind === "required" && params.sourcePlan.source.kind === "profile" + ? params.sourcePlan.source + : undefined; + const configuredMode = + params.sourcePlan.kind === "required" + ? params.sourcePlan.source.kind === "direct" + ? params.sourcePlan.source.mode + : undefined + : params.configuredAuthMode; + const configuredRoute = routeForMode(params.resolution, configuredMode); + if ( + configuredMode && + resolveProviderModelRouteAuthRequirement(configuredMode) && + !configuredRoute + ) { + return reject( + "configured-auth", + `Configured ${params.provider} authentication is not compatible with the selected model route.`, + ); + } + + const configuredRequirement = + configuredRoute?.authRequirement ?? + (params.resolution.routes.length === 1 + ? params.resolution.routes[0]?.authRequirement + : undefined); + const effectiveSourcePlan = + params.sourcePlan.kind === "automatic" && configuredRequirement + ? buildProviderModelAuthSourcePlan({ + profiles: params.sourcePlan.orderedProfiles.filter( + (profile) => + resolveProviderModelRouteAuthRequirement(profile.mode) === configuredRequirement, + ), + explicitOrder: params.sourcePlan.profiles.explicitOrder, + allowCooldown: params.sourcePlan.allowCooldown, + ...(params.sourcePlan.fallback ? { fallback: params.sourcePlan.fallback } : {}), + }) + : params.sourcePlan; + const sourceDecision = selectProviderModelAuthSources({ + provider: params.provider, + plan: effectiveSourcePlan, + }); + if (sourceDecision.kind === "rejected") { + return reject( + sourceDecision.reason, + sourceDecision.message, + sourceDecision.source, + configuredRoute, + ); + } + + const logicalProfiles = sourceDecision.attempts.flatMap((attempt) => + attempt.kind === "profile" ? [attempt.source] : [], + ); + const routeProfileAttempts = logicalProfiles.flatMap((source) => { + const route = routeForMode(params.resolution, source.mode); + if (!route || (configuredRequirement && route.authRequirement !== configuredRequirement)) { + return []; + } + return [{ source, route }]; + }); + if (requiredProfile && routeProfileAttempts.length === 0) { + const accepted = params.resolution.routes + .map((candidate) => candidate.authRequirement) + .filter((value, index, values) => values.indexOf(value) === index) + .join(" or "); + return reject( + "required-profile", + `Auth profile "${requiredProfile.profileId}" is not compatible with ${params.provider}; the selected model route requires ${accepted} authentication.`, + requiredProfile, + ); + } + if ( + effectiveSourcePlan.kind === "automatic" && + effectiveSourcePlan.profiles.explicitOrder && + logicalProfiles.length > 0 && + routeProfileAttempts.length === 0 + ) { + return reject( + "explicit-order", + `Explicit auth order has no route-compatible profiles for ${params.provider}.`, + ); + } + + const winner = routeProfileAttempts[0]; + const directSource = sourceDecision.attempts.find( + (attempt): attempt is Extract => + attempt.kind === "direct", + )?.source; + const directSourceRoute = directSource + ? routeForMode(params.resolution, directSource.mode) + : undefined; + const directRoute = + directSourceRoute && + (!configuredRequirement || directSourceRoute.authRequirement === configuredRequirement) + ? directSourceRoute + : undefined; + if (directSource && directSource.mode && !directRoute && !winner) { + return reject( + "configured-auth", + `Configured ${params.provider} authentication is not compatible with the selected model route.`, + ); + } + let rejectedProfile: ProviderModelAuthProfileSource | undefined; + if (sourceDecision.selection.kind === "unavailable") { + rejectedProfile = sourceDecision.selection.source; + } else if ( + sourceDecision.selection.kind === "selected" && + sourceDecision.selection.source.kind === "profile" + ) { + rejectedProfile = sourceDecision.selection.source; + } else if (effectiveSourcePlan !== params.sourcePlan && params.sourcePlan.kind === "automatic") { + rejectedProfile = params.sourcePlan.orderedProfiles[0]; + } + const hasCompatibleAuthWinner = Boolean(winner || (directSource && directRoute)); + if (!hasCompatibleAuthWinner) { + const routeSupport = resolveDeferredRouteSupport(params.resolution); + const normalizedRuntimeAuthOwner = params.runtimeAuthOwner?.id.trim().toLowerCase(); + const runtimeAuthOwnerIsCompatible = + Boolean(normalizedRuntimeAuthOwner) && + routeSupport.runtimePolicy.compatibleIds.includes(normalizedRuntimeAuthOwner ?? ""); + if (params.resolution.routes.length > 1 && runtimeAuthOwnerIsCompatible && !configuredRoute) { + return { kind: "deferred", reason: "runtime-auth-owner", routeSupport }; + } + return reject( + "configured-auth", + configuredRoute + ? `Configured ${params.provider} authentication has no compatible credential source for the selected model route.` + : `No route-compatible authentication source is configured for ${params.provider}.`, + rejectedProfile, + configuredRoute, + ); + } + const selectedRoute = winner?.route ?? directRoute; + if (!selectedRoute) { + return reject( + "configured-auth", + `No route-compatible authentication source is configured for ${params.provider}.`, + ); + } + + const sameRouteAttempts = winner + ? routeProfileAttempts.filter( + (attempt) => attempt.route.authRequirement === winner.route.authRequirement, + ) + : []; + const crossRouteAttempts = winner + ? routeProfileAttempts.filter( + (attempt) => attempt.route.authRequirement !== winner.route.authRequirement, + ) + : routeProfileAttempts; + const orderedProfileAttempts = [...sameRouteAttempts, ...crossRouteAttempts]; + const attempts: ProviderModelRouteAuthAttempt[] = orderedProfileAttempts.map( + (attempt, index) => ({ + kind: "profile", + source: attempt.source, + route: attempt.route, + sameRouteProfileIds: orderedProfileAttempts + .slice(index) + .filter((candidate) => candidate.route.authRequirement === attempt.route.authRequirement) + .map((candidate) => candidate.source.profileId), + }), + ); + if (directSource && directRoute) { + attempts.push({ + kind: "direct", + source: directSource, + route: directRoute, + allowAuthProfileFallback: false, + }); + } + + const selection: ProviderModelAuthSourceSelection = winner + ? { kind: "selected", source: winner.source } + : directSource + ? { kind: "selected", source: directSource } + : sourceDecision.selection.kind === "unavailable" + ? sourceDecision.selection + : { kind: "none" }; + return { + kind: "selected", + selection: { ...selection, route: selectedRoute }, + attempts, + }; +} diff --git a/src/agents/provider-model-route.test.ts b/src/agents/provider-model-route.test.ts new file mode 100644 index 000000000000..7a0d7d9a6f93 --- /dev/null +++ b/src/agents/provider-model-route.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + createProviderModelCatalogRoutePolicy, + modelMatchesProviderModelRoute, + projectProviderModelRouteConfig, +} from "./provider-model-route.js"; + +const platformRoute = { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", +} as const; + +describe("provider model route consumers", () => { + it("matches exact and owner-canonical endpoint spellings", () => { + for (const baseUrl of [ + "https://api.openai.com/v1/", + "https://api.openai.com", + "https://api.openai.com:443/v1", + ]) { + expect( + modelMatchesProviderModelRoute({ + provider: "openai", + api: "openai-responses", + baseUrl, + route: platformRoute, + }), + ).toBe(true); + } + expect( + modelMatchesProviderModelRoute({ + provider: "openai", + api: "openai-completions", + baseUrl: platformRoute.baseUrl, + route: platformRoute, + }), + ).toBe(false); + }); + + it("projects a selected route onto only the normalized provider owner", () => { + const config = projectProviderModelRouteConfig({ + provider: "openai", + config: { + models: { + providers: { + openai: { + auth: "oauth", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + " OpenAI ": { + auth: "api-key", + api: "openai-completions", + baseUrl: "https://legacy.example.test/v1", + models: [], + }, + }, + }, + }, + route: platformRoute, + }); + + expect(Object.keys(config.models?.providers ?? {})).toEqual(["openai"]); + expect(config.models?.providers?.openai).toMatchObject({ + auth: "api-key", + api: platformRoute.api, + baseUrl: platformRoute.baseUrl, + models: [], + }); + }); + + it("creates provider-scoped logical catalog policy", () => { + const policy = createProviderModelCatalogRoutePolicy("openai"); + expect(policy.resolveIdentity({ provider: "OpenAI", id: "gpt-5.4-codex" })).toEqual({ + id: "gpt-5.4", + key: "openai/gpt-5.4", + }); + expect(policy.resolveIdentity({ provider: "openai", id: "openai/acme-model" })).toEqual({ + id: "openai/acme-model", + key: "openai/openai/acme-model", + }); + expect(policy.resolveIdentity({ provider: "anthropic", id: "gpt-5.4-codex" })).toBeNull(); + }); +}); diff --git a/src/agents/provider-model-route.ts b/src/agents/provider-model-route.ts new file mode 100644 index 000000000000..e686218a6c0f --- /dev/null +++ b/src/agents/provider-model-route.ts @@ -0,0 +1,140 @@ +/** Generic core consumers for provider-owned model route facts. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { resolveMergedModelProviderEntry } from "../config/model-provider-config.js"; +import type { ModelApi, ModelProviderConfig } from "../config/types.models.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ProviderModelRouteCandidate } from "../plugin-sdk/provider-model-types.js"; +import { + resolveProviderModelCatalogId, + resolveProviderModelRoutes, +} from "../plugins/provider-model-routes.js"; +import type { ModelCatalogRoutePolicy } from "./model-catalog-route.js"; +import { splitTrailingAuthProfile } from "./model-ref-profile.js"; + +/** Canonicalizes a model id only when its provider owns catalog equivalence. */ +export function canonicalizeProviderModelId(providerId: string, modelId: string): string { + const provider = normalizeProviderId(providerId); + return (provider && resolveProviderModelCatalogId({ provider, modelId })) || modelId; +} + +function normalizeRouteBaseUrl(value: string): string { + try { + const url = new URL(value); + url.pathname = url.pathname.replace(/\/+$/u, "") || "/"; + return url.toString(); + } catch { + return value.replace(/\/+$/u, ""); + } +} + +function routeTupleMatches( + source: { api?: string | null; baseUrl?: string }, + route: ProviderModelRouteCandidate, +): boolean { + return ( + source.api === route.api && + typeof source.baseUrl === "string" && + normalizeRouteBaseUrl(source.baseUrl) === normalizeRouteBaseUrl(route.baseUrl) + ); +} + +/** True when materialized model metadata belongs to the selected provider route. */ +export function modelMatchesProviderModelRoute(params: { + provider: string; + api?: string | null; + baseUrl?: string; + route: ProviderModelRouteCandidate; +}): boolean { + if (routeTupleMatches(params, params.route)) { + return true; + } + if ( + typeof params.api !== "string" || + !params.api.trim() || + params.api !== params.route.api || + typeof params.baseUrl !== "string" || + !params.baseUrl.trim() + ) { + return false; + } + + // Re-resolve through the owner only to canonicalize endpoint spelling. + const configuredProvider = { + api: params.api as ModelApi, + baseUrl: params.baseUrl, + models: [], + } satisfies ModelProviderConfig; + const provider = normalizeProviderId(params.provider); + const resolution = resolveProviderModelRoutes({ + provider, + config: { models: { providers: { [provider]: configuredProvider } } }, + }); + return ( + resolution?.kind === "routes" && + resolution.routes.some( + (candidate) => + candidate.authRequirement === params.route.authRequirement && + routeTupleMatches(candidate, params.route), + ) + ); +} + +/** Creates catalog equivalence and physical-route matching from provider facts. */ +export function createProviderModelCatalogRoutePolicy(providerId: string): ModelCatalogRoutePolicy { + const provider = normalizeProviderId(providerId); + return { + resolveIdentity: (entry) => { + if (normalizeProviderId(entry.provider) !== provider) { + return null; + } + const id = resolveProviderModelCatalogId({ + provider, + modelId: splitTrailingAuthProfile(entry.id).model, + }); + return id ? { id, key: `${provider}/${id}` } : null; + }, + matchesRoute: (entry, route) => + normalizeProviderId(entry.provider) === provider && + modelMatchesProviderModelRoute({ + provider, + api: entry.api, + baseUrl: entry.baseUrl, + route, + }), + }; +} + +/** Projects a selected route onto transient config used only for model materialization. */ +export function projectProviderModelRouteConfig(params: { + provider: string; + config?: OpenClawConfig; + route: ProviderModelRouteCandidate; +}): OpenClawConfig { + const provider = normalizeProviderId(params.provider); + const providers = params.config?.models?.providers ?? {}; + const providerEntry = resolveMergedModelProviderEntry(params.config, provider); + const providerKey = providerEntry?.providerKey ?? provider; + const providerConfig = providerEntry?.providerConfig ?? { models: [] }; + // Materialization exposes one selected-key owner so a normalized duplicate + // cannot resurrect a different route after selection. + const routeProviders = Object.fromEntries( + Object.entries(providers).filter( + ([candidate]) => normalizeProviderId(candidate) !== provider || candidate === providerKey, + ), + ); + return { + ...params.config, + models: { + ...params.config?.models, + providers: { + ...routeProviders, + [providerKey]: { + ...providerConfig, + auth: params.route.authRequirement === "subscription" ? "oauth" : "api-key", + api: params.route.api, + baseUrl: params.route.baseUrl, + }, + }, + }, + }; +} diff --git a/src/agents/runtime-plan/auth.test.ts b/src/agents/runtime-plan/auth.test.ts index 8ad21b82e615..ef3d41ce6d9e 100644 --- a/src/agents/runtime-plan/auth.test.ts +++ b/src/agents/runtime-plan/auth.test.ts @@ -101,4 +101,62 @@ describe("buildAgentRuntimeAuthPlan", () => { expect(plan.authProfileProviderForAuth).toBe("fixture"); expect(pluginRegistryMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); }); + + it("preserves the selected model route and locked profile source", () => { + const plan = buildAgentRuntimeAuthPlan({ + provider: "openai", + authProfileProvider: "openai", + authProfileMode: "token", + sessionAuthProfileId: "openai:work", + sessionAuthProfileSource: "user", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + }, + config: {}, + }); + + expect(plan).toMatchObject({ + forwardedAuthProfileId: "openai:work", + forwardedAuthProfileSource: "user", + selectedAuthMode: "token", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + authRequirement: "subscription", + }, + }); + }); + + it("does not forward profiles when the harness rejects host auth", () => { + const plan = buildAgentRuntimeAuthPlan({ + provider: "openai", + authProfileProvider: "openai", + authProfileMode: "api_key", + sessionAuthProfileId: "openai:work", + sessionAuthProfileSource: "auto", + sessionAuthProfileCandidateIds: ["openai:work"], + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + config: {}, + harnessId: "codex", + harnessRuntime: "codex", + allowHarnessAuthProfileForwarding: false, + }); + + expect(plan.forwardedAuthProfileId).toBeUndefined(); + expect(plan.forwardedAuthProfileCandidateIds).toBeUndefined(); + expect(plan.selectedAuthMode).toBeUndefined(); + }); }); diff --git a/src/agents/runtime-plan/auth.ts b/src/agents/runtime-plan/auth.ts index ed4ad91c5632..1ca0d91aa728 100644 --- a/src/agents/runtime-plan/auth.ts +++ b/src/agents/runtime-plan/auth.ts @@ -32,10 +32,14 @@ function resolveHarnessAuthProvider(params: { /** Builds the auth forwarding plan for one resolved agent runtime. */ export function buildAgentRuntimeAuthPlan(params: { provider: string; + modelId?: string; authProfileProvider?: string; authProfileMode?: string; sessionAuthProfileId?: string; + sessionAuthProfileSource?: "auto" | "user"; sessionAuthProfileCandidateIds?: string[]; + modelRoute?: AgentRuntimeAuthPlan["modelRoute"]; + deferredRouteSupport?: AgentRuntimeAuthPlan["deferredRouteSupport"]; config?: OpenClawConfig; workspaceDir?: string; metadataSnapshot?: Pick; @@ -71,16 +75,26 @@ export function buildAgentRuntimeAuthPlan(params: { const providerCanForwardProfile = !harnessProviderForAuth && providerForAuth === authProfileProviderForAuth; const canForwardProfile = providerCanForwardProfile || harnessCanForwardProfile; + const forwardedAuthProfileId = canForwardProfile ? params.sessionAuthProfileId : undefined; // Forward only when the selected provider/harness resolves to the same auth // owner as the stored session profile; otherwise the runtime must choose auth. return { providerForAuth, + ...(params.modelId ? { modelId: params.modelId } : {}), authProfileProviderForAuth, ...(harnessProviderForAuth ? { harnessAuthProvider: harnessProviderForAuth } : {}), - ...(canForwardProfile ? { forwardedAuthProfileId: params.sessionAuthProfileId } : {}), + ...(canForwardProfile ? { forwardedAuthProfileId } : {}), + ...(canForwardProfile && params.sessionAuthProfileId && params.sessionAuthProfileSource + ? { forwardedAuthProfileSource: params.sessionAuthProfileSource } + : {}), ...(canForwardProfile && params.sessionAuthProfileCandidateIds?.length ? { forwardedAuthProfileCandidateIds: params.sessionAuthProfileCandidateIds } : {}), - }; + ...(canForwardProfile && params.authProfileMode + ? { selectedAuthMode: params.authProfileMode } + : {}), + ...(params.modelRoute ? { modelRoute: params.modelRoute } : {}), + ...(params.deferredRouteSupport ? { deferredRouteSupport: params.deferredRouteSupport } : {}), + } satisfies AgentRuntimeAuthPlan; } diff --git a/src/agents/runtime-plan/build.ts b/src/agents/runtime-plan/build.ts index a0bbd548b4ea..db7c382c8eca 100644 --- a/src/agents/runtime-plan/build.ts +++ b/src/agents/runtime-plan/build.ts @@ -165,18 +165,23 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen runtimeHandle: params.providerRuntimeHandle, resolveWhenMissing: true, }); - const auth = buildAgentRuntimeAuthPlan({ - provider: params.provider, - authProfileProvider: params.authProfileProvider, - authProfileMode: params.authProfileMode, - sessionAuthProfileId: params.sessionAuthProfileId, - sessionAuthProfileCandidateIds: params.sessionAuthProfileCandidateIds, - config, - workspaceDir: params.workspaceDir, - harnessId: params.harnessId, - harnessRuntime: params.harnessRuntime, - allowHarnessAuthProfileForwarding: params.allowHarnessAuthProfileForwarding, - }); + const auth = + params.preparedAuthPlan ?? + buildAgentRuntimeAuthPlan({ + provider: params.provider, + modelId: params.modelId, + authProfileProvider: params.authProfileProvider, + authProfileMode: params.authProfileMode, + sessionAuthProfileId: params.sessionAuthProfileId, + sessionAuthProfileSource: params.sessionAuthProfileSource, + sessionAuthProfileCandidateIds: params.sessionAuthProfileCandidateIds, + modelRoute: params.modelRoute, + config, + workspaceDir: params.workspaceDir, + harnessId: params.harnessId, + harnessRuntime: params.harnessRuntime, + allowHarnessAuthProfileForwarding: params.allowHarnessAuthProfileForwarding, + }); const resolvedRef = { provider: params.provider, modelId: params.modelId, diff --git a/src/agents/runtime-plan/materialize-model.test.ts b/src/agents/runtime-plan/materialize-model.test.ts new file mode 100644 index 000000000000..b5fad0e3c331 --- /dev/null +++ b/src/agents/runtime-plan/materialize-model.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { materializePreparedRuntimeModel } from "./materialize-model.js"; +import type { AgentRuntimeAuthPlan } from "./types.js"; + +const plan: AgentRuntimeAuthPlan = { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:subscription", + selectedAuthMode: "token", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + }, +}; + +describe("materializePreparedRuntimeModel", () => { + it("reuses a model that already matches the prepared route", async () => { + const model = { + provider: "openai", + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + const resolveModel = vi.fn(); + + await expect( + materializePreparedRuntimeModel({ + plan, + provider: "openai", + modelId: "gpt-5.5", + model, + resolveModel, + }), + ).resolves.toBe(model); + expect(resolveModel).not.toHaveBeenCalled(); + }); + + it("re-resolves matching route metadata when the auth profile changes", async () => { + const model = { + provider: "openai", + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + const rematerialized = { ...model, name: "backup-profile-model" }; + const resolveModel = vi.fn(async () => ({ model: rematerialized })); + + await expect( + materializePreparedRuntimeModel({ + plan: { ...plan, forwardedAuthProfileId: "openai:backup" }, + provider: "openai", + modelId: "gpt-5.5", + model, + forceResolve: true, + resolveModel, + }), + ).resolves.toBe(rematerialized); + expect(resolveModel).toHaveBeenCalledWith( + expect.objectContaining({ authProfileId: "openai:backup" }), + ); + }); + + it("re-resolves route-less profile-scoped model metadata", async () => { + const model = { + provider: "clawrouter", + id: "private-model", + api: "anthropic-messages", + baseUrl: "https://router.example.test", + }; + const rematerialized = { ...model, name: "backup-profile-model" }; + const resolveModel = vi.fn(async () => ({ model: rematerialized })); + + await expect( + materializePreparedRuntimeModel({ + plan: { + providerForAuth: "clawrouter", + authProfileProviderForAuth: "clawrouter", + modelId: "private-model", + forwardedAuthProfileId: "clawrouter:backup", + selectedAuthMode: "api-key", + }, + provider: "clawrouter", + modelId: "private-model", + config: {} as OpenClawConfig, + model, + forceResolve: true, + resolveModel, + }), + ).resolves.toBe(rematerialized); + expect(resolveModel).toHaveBeenCalledWith({ + config: {}, + authProfileId: "clawrouter:backup", + authProfileMode: "api_key", + }); + }); + + it("projects the selected route and exact auth mode before resolving", async () => { + const resolved = { + provider: "openai", + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + const resolveModel = vi.fn(async () => ({ model: resolved })); + + await expect( + materializePreparedRuntimeModel({ + plan, + provider: "openai", + modelId: "gpt-5.5", + config: { models: { providers: {} } } as OpenClawConfig, + model: { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + resolveModel, + }), + ).resolves.toBe(resolved); + expect(resolveModel).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: "openai:subscription", + authProfileMode: "token", + config: expect.objectContaining({ + models: expect.objectContaining({ + providers: expect.objectContaining({ + openai: expect.objectContaining({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }), + }), + }), + }), + }), + ); + }); + + it("rejects provider metadata that uses a different official adapter", async () => { + const platformPlan: AgentRuntimeAuthPlan = { + ...plan, + forwardedAuthProfileId: "openai:key", + selectedAuthMode: "api_key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.4-nano", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }; + const model = { + provider: "openai", + id: "gpt-5.4-nano", + api: "openai-completions", + baseUrl: "https://api.openai.com", + }; + const resolveModel = vi.fn(); + + await expect( + materializePreparedRuntimeModel({ + plan: platformPlan, + provider: "openai", + modelId: "gpt-5.4-nano", + model, + rejectMismatchedModel: true, + resolveModel, + }), + ).rejects.toThrow("does not match its prepared api-key route"); + expect(resolveModel).not.toHaveBeenCalled(); + }); + + it("projects an authored Completions route without reusing Responses metadata", async () => { + const completionsPlan: AgentRuntimeAuthPlan = { + ...plan, + forwardedAuthProfileId: "openai:key", + selectedAuthMode: "api_key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }; + const resolved = { + provider: "openai", + id: "gpt-5.5", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + }; + const resolveModel = vi.fn(async () => ({ model: resolved })); + + await expect( + materializePreparedRuntimeModel({ + plan: completionsPlan, + provider: "openai", + modelId: "gpt-5.5", + config: { models: { providers: {} } } as OpenClawConfig, + model: { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + resolveModel, + }), + ).resolves.toBe(resolved); + expect(resolveModel).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: "openai:key", + authProfileMode: "api_key", + config: expect.objectContaining({ + models: expect.objectContaining({ + providers: expect.objectContaining({ + openai: expect.objectContaining({ + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + }), + }), + }), + }), + }), + ); + }); + + it("accepts the canonical model id for the shipped GPT-5.4 Codex alias", async () => { + const aliasPlan: AgentRuntimeAuthPlan = { + ...plan, + modelRoute: { + ...plan.modelRoute!, + modelId: "gpt-5.4-codex", + }, + }; + const model = { + provider: "openai", + id: "gpt-5.4", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + const resolveModel = vi.fn(); + + await expect( + materializePreparedRuntimeModel({ + plan: aliasPlan, + provider: "openai", + modelId: "gpt-5.4-codex", + model, + resolveModel, + }), + ).resolves.toBe(model); + expect(resolveModel).not.toHaveBeenCalled(); + }); + + it("does not reuse another model that shares the prepared transport", async () => { + const resolved = { + provider: "openai", + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + const resolveModel = vi.fn(async () => ({ model: resolved })); + + await expect( + materializePreparedRuntimeModel({ + plan, + provider: "openai", + modelId: "gpt-5.5", + model: { + provider: "openai", + id: "gpt-5.4", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + resolveModel, + }), + ).resolves.toBe(resolved); + expect(resolveModel).toHaveBeenCalledOnce(); + }); + + it("rejects mismatched targets and mismatched resolved tuples", async () => { + await expect( + materializePreparedRuntimeModel({ + plan, + provider: "openai", + modelId: "gpt-5.6", + resolveModel: vi.fn(), + }), + ).rejects.toThrow(/does not match target/u); + + await expect( + materializePreparedRuntimeModel({ + plan, + provider: "openai", + modelId: "gpt-5.5", + resolveModel: vi.fn(async () => ({ + model: { + provider: "openai", + id: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + })), + }), + ).rejects.toThrow(/prepared subscription route/u); + }); +}); diff --git a/src/agents/runtime-plan/materialize-model.ts b/src/agents/runtime-plan/materialize-model.ts new file mode 100644 index 000000000000..52afe6ed7a44 --- /dev/null +++ b/src/agents/runtime-plan/materialize-model.ts @@ -0,0 +1,136 @@ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + resolveProviderModelMaterializationAuthMode, + resolveProviderModelRouteMaterializationAuthMode, + type ProviderModelRouteMaterializationAuthMode, +} from "../provider-model-route-auth.js"; +import { + canonicalizeProviderModelId, + modelMatchesProviderModelRoute, + projectProviderModelRouteConfig, +} from "../provider-model-route.js"; +import type { AgentRuntimeAuthPlan } from "./types.js"; + +type RuntimeRouteModel = { + provider?: string; + id?: string; + api?: string | null; + baseUrl?: string; +}; + +function modelMatchesPreparedTarget(params: { + model: RuntimeRouteModel; + provider: string; + modelId: string; + route: NonNullable; +}): boolean { + const modelId = canonicalizeProviderModelId(params.provider, params.model.id ?? ""); + const targetModelId = canonicalizeProviderModelId(params.provider, params.modelId); + return ( + normalizeProviderId(params.model.provider ?? "") === normalizeProviderId(params.provider) && + modelId === targetModelId && + modelMatchesProviderModelRoute({ + provider: params.provider, + api: params.model.api, + baseUrl: params.model.baseUrl, + route: params.route, + }) + ); +} + +type PreparedRuntimeModelRequest = { + config: OpenClawConfig; + authProfileId?: string; + authProfileMode?: ProviderModelRouteMaterializationAuthMode; +}; + +/** Resolves the exact model tuple selected by a prepared runtime auth plan. */ +export async function materializePreparedRuntimeModel(params: { + plan: AgentRuntimeAuthPlan; + provider: string; + modelId: string; + config?: OpenClawConfig; + model?: Model; + /** Re-resolve when a later auth candidate changes credential-scoped model metadata. */ + forceResolve?: boolean; + rejectMismatchedModel?: boolean; + resolveModel( + request: PreparedRuntimeModelRequest, + ): Promise<{ model?: Model | null; error?: string }>; +}): Promise { + const route = params.plan.modelRoute; + if (!route && !params.forceResolve) { + return params.model; + } + if ( + route && + (normalizeProviderId(route.provider) !== normalizeProviderId(params.provider) || + canonicalizeProviderModelId(route.provider, route.modelId) !== + canonicalizeProviderModelId(params.provider, params.modelId)) + ) { + throw new Error( + `Prepared runtime auth route ${route.provider}/${route.modelId} does not match target ${params.provider}/${params.modelId}.`, + ); + } + const callerModelMatches = + params.model !== undefined && + normalizeProviderId(params.model.provider ?? "") === normalizeProviderId(params.provider) && + canonicalizeProviderModelId(params.provider, params.model.id ?? "") === + canonicalizeProviderModelId(params.provider, params.modelId) && + (!route || + modelMatchesPreparedTarget({ + model: params.model, + provider: params.provider, + modelId: params.modelId, + route, + })); + if (callerModelMatches && !params.forceResolve) { + return params.model; + } + if (params.model && !callerModelMatches && params.rejectMismatchedModel) { + throw new Error( + route + ? `Caller-provided ${params.provider}/${params.modelId} metadata does not match its prepared ${route.authRequirement} route.` + : `Caller-provided model metadata does not match ${params.provider}/${params.modelId}.`, + ); + } + + const resolved = await params.resolveModel({ + config: route + ? projectProviderModelRouteConfig({ + provider: params.provider, + config: params.config, + route, + }) + : (params.config ?? {}), + authProfileId: params.plan.forwardedAuthProfileId, + authProfileMode: route + ? resolveProviderModelRouteMaterializationAuthMode({ + mode: params.plan.selectedAuthMode, + requirement: route.authRequirement, + }) + : resolveProviderModelMaterializationAuthMode(params.plan.selectedAuthMode), + }); + if ( + !resolved.model || + normalizeProviderId(resolved.model.provider ?? "") !== normalizeProviderId(params.provider) || + canonicalizeProviderModelId(params.provider, resolved.model.id ?? "") !== + canonicalizeProviderModelId(params.provider, params.modelId) || + (route && + !modelMatchesPreparedTarget({ + model: resolved.model, + provider: params.provider, + modelId: params.modelId, + route, + })) + ) { + throw new Error( + resolved.error ?? + (route + ? `Unable to materialize ${params.provider}/${params.modelId} for its prepared ${route.authRequirement} route.` + : `Unable to rematerialize ${params.provider}/${params.modelId} for its resolved auth profile.`), + ); + } + return resolved.model; +} diff --git a/src/agents/runtime-plan/model-route.ts b/src/agents/runtime-plan/model-route.ts new file mode 100644 index 000000000000..e09ddc8c8ce0 --- /dev/null +++ b/src/agents/runtime-plan/model-route.ts @@ -0,0 +1,47 @@ +import type { AgentRuntimeAuthModelRoute } from "./types.js"; + +function normalizeRouteBaseUrl(value: string): string { + return value.replace(/\/+$/u, ""); +} + +function sameCompatibleRuntimeIds( + left: readonly string[] | undefined, + right: readonly string[] | undefined, +): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + const leftIds = new Set(left); + const rightIds = new Set(right); + if (leftIds.size !== rightIds.size) { + return false; + } + for (const id of leftIds) { + if (!rightIds.has(id)) { + return false; + } + } + return true; +} + +/** Compares the complete secret-free identity of two prepared model routes. */ +export function sameAgentRuntimeAuthModelRoute( + left: AgentRuntimeAuthModelRoute, + right: AgentRuntimeAuthModelRoute, +): boolean { + return ( + left.provider.trim().toLowerCase() === right.provider.trim().toLowerCase() && + left.modelId === right.modelId && + left.api === right.api && + left.authRequirement === right.authRequirement && + left.requestTransportOverrides === right.requestTransportOverrides && + sameCompatibleRuntimeIds( + left.runtimePolicy?.compatibleIds, + right.runtimePolicy?.compatibleIds, + ) && + normalizeRouteBaseUrl(left.baseUrl) === normalizeRouteBaseUrl(right.baseUrl) + ); +} diff --git a/src/agents/runtime-plan/prepare-auth.setup-provider.test.ts b/src/agents/runtime-plan/prepare-auth.setup-provider.test.ts new file mode 100644 index 000000000000..fd04259f4fcd --- /dev/null +++ b/src/agents/runtime-plan/prepare-auth.setup-provider.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { Model } from "../../llm/types.js"; +import type { AuthProfileStore } from "../auth-profiles/types.js"; +import { GCP_VERTEX_CREDENTIALS_MARKER } from "../model-auth-markers.js"; +import { prepareAgentRuntimeAuth } from "./prepare-auth.js"; +import { + resolvePreparedRuntimeAuthAttempts, + resolvePreparedRuntimeModelAuth, +} from "./resolve-auth.js"; + +const authLookupMocks = vi.hoisted(() => ({ + resolveProviderEnvAuthLookupMaps: vi.fn(() => ({ + aliasMap: {}, + envCandidateMap: {}, + authEvidenceMap: {}, + setupProviderFallbackRefs: ["anthropic-vertex"], + })), +})); + +const setupRegistryMocks = vi.hoisted(() => ({ + resolvePluginSetupProvider: vi.fn(() => ({ + resolveConfigApiKey: () => "gcp-vertex-credentials", + })), +})); + +vi.mock("../model-auth-env-vars.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveProviderEnvAuthLookupMaps: authLookupMocks.resolveProviderEnvAuthLookupMaps, +})); + +vi.mock("../../plugins/setup-registry.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolvePluginSetupProvider: setupRegistryMocks.resolvePluginSetupProvider, +})); + +describe("prepared setup-provider auth fallback", () => { + it("defers setup resolution until a prepared profile attempt fails", async () => { + const profileId = "anthropic-vertex:missing"; + const config = { + auth: { order: { "anthropic-vertex": [profileId] } }, + } as OpenClawConfig; + const store = { + version: 1, + profiles: { + [profileId]: { + type: "api_key", + provider: "anthropic-vertex", + keyRef: { + source: "env", + provider: "default", + id: "OPENCLAW_TEST_MISSING_VERTEX_KEY", + }, + }, + }, + order: { "anthropic-vertex": [profileId] }, + } satisfies AuthProfileStore; + const prepared = prepareAgentRuntimeAuth({ + provider: "anthropic-vertex", + modelId: "claude-sonnet-4-6", + config, + env: {}, + authProfileStore: store, + }); + + expect(setupRegistryMocks.resolvePluginSetupProvider).not.toHaveBeenCalled(); + expect(prepared.attempts).toMatchObject([ + { kind: "profile", profileId }, + { + kind: "direct", + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: true, + }, + ]); + + const model = { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + provider: "anthropic-vertex", + api: "anthropic-messages", + baseUrl: "https://example.invalid", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, + } as Model; + const resolved = await resolvePreparedRuntimeAuthAttempts({ + attempts: prepared.attempts, + store, + modelId: model.id, + model, + materializeModel: async ({ model: preparedModel }) => preparedModel, + resolveAuth: async ({ attempt, model: preparedModel }) => + resolvePreparedRuntimeModelAuth({ + plan: attempt.plan, + model: preparedModel, + cfg: config, + store, + }), + errorMessage: "prepared Anthropic Vertex auth failed", + }); + + expect(resolved.auth).toMatchObject({ + apiKey: GCP_VERTEX_CREDENTIALS_MARKER, + source: "gcloud adc", + mode: "api-key", + }); + expect(setupRegistryMocks.resolvePluginSetupProvider).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/agents/runtime-plan/prepare-auth.test.ts b/src/agents/runtime-plan/prepare-auth.test.ts new file mode 100644 index 000000000000..81f25e05e9bc --- /dev/null +++ b/src/agents/runtime-plan/prepare-auth.test.ts @@ -0,0 +1,2049 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { Model } from "../../llm/types.js"; +import type { AuthProfileStore } from "../auth-profiles.js"; +import { resolveAgentHarnessPreparedAuthSupport } from "../harness/support.js"; +import { getApiKeyForModel } from "../model-auth.js"; +import { + agentRuntimeAuthPlanMatchesTarget, + canRunPreparedAgentRuntimeAuthAttempt, + prepareAgentRuntimeAuth, + prepareAgentRuntimeAuthPlan, + preparedAgentRuntimeProfileAttemptHasCandidate, +} from "./prepare-auth.js"; + +function authStore( + profiles: AuthProfileStore["profiles"], + order?: AuthProfileStore["order"], +): AuthProfileStore { + return { version: 1, profiles, ...(order ? { order } : {}) }; +} + +function allCooldownOpenAIStore(): AuthProfileStore { + const store = authStore( + { + "openai:cooldown": { + type: "api_key", + provider: "openai", + key: "cooldown-key", + }, + }, + { openai: ["openai:cooldown"] }, + ); + store.usageStats = { + "openai:cooldown": { cooldownUntil: Date.now() + 60_000 }, + }; + return store; +} + +describe("prepareAgentRuntimeAuthPlan", () => { + it("keeps unknown no-observation models on the legacy auth plan", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.4-nano", + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: authStore({}), + }); + + expect(plan).toMatchObject({ + providerForAuth: "openai", + harnessAuthProvider: "openai", + }); + expect(plan.modelRoute).toBeUndefined(); + expect(plan.deferredRouteSupport).toBeUndefined(); + }); + + it("keeps a generic provider-entry binding ahead of an automatic backup", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + config: { + models: { + providers: { + xai: { apiKey: "xai:bound", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore( + { + "xai:bound": { + type: "api_key", + provider: "xai", + key: "bound-key", + }, + "xai:backup": { + type: "api_key", + provider: "xai", + key: "backup-key", + }, + }, + { xai: ["xai:backup", "xai:bound"] }, + ), + sessionAuthProfileId: "xai:backup", + sessionAuthProfileSource: "auto", + }); + + expect(plan).toMatchObject({ + providerForAuth: "xai", + forwardedAuthProfileId: "xai:bound", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["xai:bound"], + selectedAuthMode: "api_key", + }); + expect(plan.modelRoute).toBeUndefined(); + }); + + it("rejects a cooldowned generic provider-entry binding instead of using a backup", () => { + const store = authStore( + { + "xai:bound": { + type: "api_key", + provider: "xai", + key: "bound-key", + }, + "xai:backup": { + type: "api_key", + provider: "xai", + key: "backup-key", + }, + }, + { xai: ["xai:backup", "xai:bound"] }, + ); + store.usageStats = { + "xai:bound": { cooldownUntil: Date.now() + 60_000 }, + }; + + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + config: { + models: { + providers: { + xai: { apiKey: "xai:bound", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: store, + sessionAuthProfileId: "xai:backup", + sessionAuthProfileSource: "auto", + }), + ).toThrow(/temporarily unavailable/u); + }); + + it("keeps generic AWS SDK auth ahead of provider bindings and automatic profiles", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + config: { + models: { + providers: { + xai: { + auth: "aws-sdk", + apiKey: "xai:bound", + baseUrl: "", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({ + "xai:bound": { + type: "api_key", + provider: "xai", + key: "bound-key", + }, + "xai:backup": { + type: "api_key", + provider: "xai", + key: "backup-key", + }, + }), + sessionAuthProfileId: "xai:backup", + sessionAuthProfileSource: "auto", + }); + + expect(plan.forwardedAuthProfileId).toBeUndefined(); + expect(plan.forwardedAuthProfileCandidateIds).toBeUndefined(); + expect(plan.selectedAuthMode).toBe("aws-sdk"); + expect(plan.modelRoute).toBeUndefined(); + }); + + it("rotates a generic automatic profile past a model cooldown", () => { + const store = authStore( + { + "xai:p1": { + type: "api_key", + provider: "xai", + key: "p1-key", + }, + "xai:p2": { + type: "api_key", + provider: "xai", + key: "p2-key", + }, + "xai:p3": { + type: "api_key", + provider: "xai", + key: "p3-key", + }, + }, + { xai: ["xai:p1", "xai:p2", "xai:p3"] }, + ); + store.usageStats = { + "xai:p1": { + cooldownUntil: Date.now() + 60_000, + cooldownReason: "rate_limit", + cooldownModel: "grok-4", + }, + }; + + const plan = prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + env: {}, + authProfileStore: store, + sessionAuthProfileId: "xai:p1", + sessionAuthProfileSource: "auto", + }); + + expect(plan).toMatchObject({ + forwardedAuthProfileId: "xai:p2", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["xai:p2", "xai:p3"], + selectedAuthMode: "api_key", + }); + expect(plan.modelRoute).toBeUndefined(); + }); + + it("applies a provider-owned preferred profile without turning it into a lock", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + env: {}, + authProfileStore: authStore( + { + "xai:p1": { type: "api_key", provider: "xai", key: "p1-key" }, + "xai:p2": { type: "api_key", provider: "xai", key: "p2-key" }, + }, + { xai: ["xai:p1", "xai:p2"] }, + ), + resolveProviderPreferredProfileId: () => "xai:p2", + }); + + expect(plan).toMatchObject({ + forwardedAuthProfileId: "xai:p2", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["xai:p2", "xai:p1"], + }); + }); + + it("drops proven-unavailable generic candidates before forwarding fallbacks", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + config: { + secrets: { + providers: { + vault: { source: "file", path: "/tmp/secrets.json", mode: "json" }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore( + { + "xai:missing": { + type: "api_key", + provider: "xai", + keyRef: { source: "env", provider: "vault", id: "XAI_API_KEY" }, + }, + "xai:p2": { + type: "api_key", + provider: "xai", + key: "p2-key", + }, + "xai:p3": { + type: "api_key", + provider: "xai", + key: "p3-key", + }, + }, + { xai: ["xai:missing", "xai:p2", "xai:p3"] }, + ), + sessionAuthProfileId: "xai:missing", + sessionAuthProfileSource: "auto", + }); + + expect(plan).toMatchObject({ + forwardedAuthProfileId: "xai:p2", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["xai:p2", "xai:p3"], + }); + }); + + it("fails closed before resolving an all-cooldown generic order", () => { + const store = authStore( + { + "xai:p1": { + type: "api_key", + provider: "xai", + key: "p1-key", + }, + "xai:p2": { + type: "api_key", + provider: "xai", + key: "p2-key", + }, + }, + { xai: ["xai:p1", "xai:p2"] }, + ); + store.usageStats = { + "xai:p1": { cooldownUntil: Date.now() + 60_000 }, + "xai:p2": { cooldownUntil: Date.now() + 60_000 }, + }; + + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + env: {}, + authProfileStore: store, + sessionAuthProfileId: "xai:p1", + sessionAuthProfileSource: "auto", + }), + ).toThrow(/temporarily unavailable/u); + }); + + it("fails closed when an explicit generic order contains only missing profiles", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + config: { + auth: { order: { xai: ["xai:missing"] } }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({ + "xai:backup": { + type: "api_key", + provider: "xai", + key: "backup-key", + }, + }), + }), + ).toThrow(/explicit auth order.*no usable profiles/iu); + }); + + it("fails closed when an explicit generic order is empty", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + config: { + auth: { order: { xai: [] } }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({ + "xai:backup": { + type: "api_key", + provider: "xai", + key: "backup-key", + }, + }), + }), + ).toThrow(/explicit auth order.*no usable profiles/iu); + }); + + it("keeps a generic user lock as a singleton despite cooldown", () => { + const store = authStore( + { + "xai:p1": { + type: "api_key", + provider: "xai", + key: "p1-key", + }, + "xai:p2": { + type: "api_key", + provider: "xai", + key: "p2-key", + }, + }, + { xai: ["xai:p1", "xai:p2"] }, + ); + store.usageStats = { + "xai:p1": { cooldownUntil: Date.now() + 60_000 }, + }; + + const plan = prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + env: {}, + authProfileStore: store, + sessionAuthProfileId: "xai:p1", + sessionAuthProfileSource: "user", + }); + + expect(plan).toMatchObject({ + forwardedAuthProfileId: "xai:p1", + forwardedAuthProfileSource: "user", + forwardedAuthProfileCandidateIds: ["xai:p1"], + selectedAuthMode: "api_key", + }); + }); + + it("defers an ambiguous route when native Codex owns auth", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + harnessAuthBootstrap: "harness", + authProfileStore: authStore({}), + }); + + expect(plan.harnessAuthProvider).toBe("openai"); + expect(plan.modelRoute).toBeUndefined(); + expect(plan.deferredRouteSupport).toEqual({ + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }); + expect(resolveAgentHarnessPreparedAuthSupport({ plan })).toEqual({ source: "harness" }); + }); + + it("falls through an unusable env marker to an ordered API-key profile", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { apiKey: "OPENAI_API_KEY", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: authStore( + { + "openai:backup": { + type: "api_key", + provider: "openai", + key: "backup-key", + }, + }, + { openai: ["openai:backup"] }, + ), + }); + + expect(plan.forwardedAuthProfileId).toBe("openai:backup"); + expect(plan.modelRoute?.authRequirement).toBe("api-key"); + }); + + it("rejects a concrete route when an env marker has no usable credential", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { apiKey: "OPENAI_API_KEY", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: authStore({}), + }), + ).toThrow(/No route-compatible authentication source/u); + }); + + it("skips cooldowned automatic profiles before selecting a healthy backup", () => { + const store = authStore( + { + "openai:cooldown": { + type: "api_key", + provider: "openai", + key: "cooldown-key", + }, + "openai:backup": { + type: "api_key", + provider: "openai", + key: "backup-key", + }, + }, + { openai: ["openai:cooldown", "openai:backup"] }, + ); + store.usageStats = { + "openai:cooldown": { cooldownUntil: Date.now() + 60_000 }, + }; + + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: store, + sessionAuthProfileId: "openai:cooldown", + sessionAuthProfileSource: "auto", + }); + + expect(plan.forwardedAuthProfileId).toBe("openai:backup"); + expect(plan.forwardedAuthProfileCandidateIds).toEqual(["openai:backup"]); + }); + + it("does not bypass an all-cooldown auth order through native Codex auth", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: allCooldownOpenAIStore(), + }), + ).toThrow(/temporarily unavailable/u); + }); + + it("does not bypass an all-cooldown auth order through direct provider auth", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "DIRECT_OPENAI_KEY" }, + baseUrl: "", + models: [], + }, + }, + }, + secrets: { providers: { default: { source: "env" } } }, + } as OpenClawConfig, + env: { DIRECT_OPENAI_KEY: "sk-direct" }, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: allCooldownOpenAIStore(), + }), + ).toThrow(/temporarily unavailable/u); + }); + + it("does not let clear OAuth auth hide a cooldown Platform tier before literal fallback", () => { + const store = authStore( + { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + { openai: ["openai:chatgpt", "openai:platform"] }, + ); + store.usageStats = { + "openai:platform": { cooldownUntil: Date.now() + 60_000 }, + }; + + expect(() => + prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + config: { + models: { + providers: { + openai: { apiKey: "configured-platform-key", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: store, + }), + ).toThrow(/temporarily unavailable/u); + }); + + it("rejects an incompatible provider-bound profile before Codex forwarding", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://relay.example/v1", + config: { + models: { + providers: { + openai: { + api: "openai-responses", + apiKey: "relay:key", + baseUrl: "https://relay.example/v1", + models: [], + }, + relay: { + api: "openai-responses", + baseUrl: "https://relay.example/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: authStore({ + "relay:key": { + type: "api_key", + provider: "relay", + key: "relay-secret", + }, + }), + }), + ).toThrow(/has no usable credentials/u); + }); + + it("rejects an incompatible provider binding on a generic Codex plan", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "codex", + modelId: "gpt-5.4", + config: { + models: { + providers: { + codex: { + apiKey: "relay:key", + baseUrl: "https://relay.example/v1", + models: [], + }, + relay: { + baseUrl: "https://relay.example/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: authStore({ + "relay:key": { + type: "api_key", + provider: "relay", + key: "relay-secret", + }, + }), + }), + ).toThrow(/has no usable credentials/u); + }); + + it("selects the first compatible auth.order profile with its exact route", () => { + const preparation = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: authStore( + { + "openai:chatgpt": { + type: "token", + provider: "openai", + token: "subscription-token", + expires: Date.now() + 60_000, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + { openai: ["openai:chatgpt", "openai:platform"] }, + ), + }); + const plan = preparation.plan; + + expect(plan).toMatchObject({ + forwardedAuthProfileId: "openai:chatgpt", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["openai:chatgpt"], + selectedAuthMode: "token", + modelRoute: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + }, + }); + expect( + preparation.attempts.map((attempt) => ({ + profileId: attempt.profileId, + authRequirement: attempt.plan.modelRoute?.authRequirement, + })), + ).toEqual([ + { profileId: "openai:chatgpt", authRequirement: "subscription" }, + { profileId: "openai:platform", authRequirement: "api-key" }, + ]); + }); + + it("prepares every ordered same-route profile as an exhaustive fallback set", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { baseUrl: "https://api.openai.com/v1", models: [] }, + }, + }, + } as OpenClawConfig, + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: authStore( + { + "openai:missing": { + type: "api_key", + provider: "openai", + keyRef: { + source: "env", + provider: "default", + id: "OPENCLAW_TEST_MISSING_PREPARED_AUTH", + }, + }, + "openai:backup": { + type: "api_key", + provider: "openai", + key: "backup-key", + }, + }, + { openai: ["openai:missing", "openai:backup"] }, + ), + }); + + expect(plan).toMatchObject({ + forwardedAuthProfileId: "openai:missing", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["openai:missing", "openai:backup"], + selectedAuthMode: "api_key", + modelRoute: { authRequirement: "api-key" }, + }); + }); + + it("keeps same-route native candidates ahead of interleaved route fallbacks", () => { + const preparation = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + secrets: { + providers: { + vault: { source: "file", path: "/tmp/secrets.json", mode: "json" }, + }, + }, + } as OpenClawConfig, + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: authStore( + { + "openai:subscription-missing": { + type: "token", + provider: "openai", + tokenRef: { source: "file", provider: "vault", id: "/chatgpt/token" }, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + "openai:subscription-backup": { + type: "token", + provider: "openai", + token: "subscription-token", + }, + }, + { + openai: ["openai:subscription-missing", "openai:platform", "openai:subscription-backup"], + }, + ), + }); + + expect(preparation.plan.forwardedAuthProfileCandidateIds).toEqual([ + "openai:subscription-missing", + "openai:subscription-backup", + ]); + expect( + preparation.attempts.map((attempt) => ({ + profileId: attempt.profileId, + authRequirement: attempt.plan.modelRoute?.authRequirement, + })), + ).toEqual([ + { profileId: "openai:subscription-missing", authRequirement: "subscription" }, + { profileId: "openai:subscription-backup", authRequirement: "subscription" }, + { profileId: "openai:platform", authRequirement: "api-key" }, + ]); + }); + + it("skips a definitively invalid ordered profile before selecting a sibling route", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + auth: { order: { openai: ["openai:bad-key", "openai:chatgpt"] } }, + secrets: { + providers: { + vault: { source: "file", path: "/tmp/secrets.json", mode: "json" }, + }, + }, + } as OpenClawConfig, + env: {}, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: authStore( + { + "openai:bad-key": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "vault", id: "OPENAI_API_KEY" }, + }, + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 10 * 60_000, + }, + }, + { openai: ["openai:bad-key", "openai:chatgpt"] }, + ), + }); + + expect(plan).toMatchObject({ + forwardedAuthProfileId: "openai:chatgpt", + forwardedAuthProfileCandidateIds: ["openai:chatgpt"], + selectedAuthMode: "oauth", + modelRoute: { authRequirement: "subscription" }, + }); + }); + + it("rejects an all-invalid auth order before configured direct auth", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + auth: { order: { openai: ["openai:ordered"] } }, + secrets: { + providers: { + default: { source: "env" }, + vault: { source: "file", path: "/tmp/secrets.json", mode: "json" }, + }, + }, + models: { + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "DIRECT_OPENAI_KEY" }, + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: { DIRECT_OPENAI_KEY: "sk-direct" }, + harnessId: "codex", + harnessRuntime: "codex", + authProfileStore: authStore( + { + "openai:ordered": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "vault", id: "ORDERED_OPENAI_KEY" }, + }, + }, + { openai: ["openai:ordered"] }, + ), + }), + ).toThrow(/explicit auth order.*no usable profiles/iu); + }); + + it("keeps a user-locked profile authoritative and rejects the wrong route class", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + env: {}, + config: { + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [], + }, + }, + }, + } as OpenClawConfig, + sessionAuthProfileId: "openai:platform", + sessionAuthProfileSource: "user", + authProfileStore: authStore({ + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }), + }), + ).toThrow(/requires subscription authentication/u); + }); + + it("lets an explicit provider API key outrank automatic subscription profiles", () => { + const config = { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + auth: "api-key", + apiKey: "configured-platform-key", + models: [], + }, + }, + }, + } as OpenClawConfig; + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + config, + env: {}, + authProfileStore: authStore({ + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }), + }); + + expect(plan.forwardedAuthProfileId).toBeUndefined(); + expect(plan.modelRoute).toMatchObject({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + }); + }); + + it("rejects an official authored route with unvalidated native auth", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { + auth: "api-key", + apiKey: "configured-platform-key", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: { OPENAI_API_KEY: "ambient-platform-key" }, + authProfileStore: authStore( + { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + { openai: ["openai:chatgpt"] }, + ), + sessionAuthProfileId: "openai:chatgpt", + sessionAuthProfileSource: "auto", + harnessId: "codex", + harnessRuntime: "codex", + harnessAuthBootstrap: "harness", + allowHarnessAuthProfileForwarding: false, + }), + ).toThrow(/route-compatible authentication source/u); + }); + + it("rejects a user-locked profile when the harness cannot accept host auth", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + env: {}, + authProfileStore: authStore({ + "openai:work": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }), + sessionAuthProfileId: "openai:work", + sessionAuthProfileSource: "user", + harnessId: "codex", + harnessRuntime: "codex", + allowHarnessAuthProfileForwarding: false, + }), + ).toThrow(/native account instead/u); + }); + + it("honors the no-host-auth policy for non-Codex harnesses", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "xai", + modelId: "grok-4", + config: { + models: { + providers: { + xai: { auth: "api-key", apiKey: "xai-key", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({ + "xai:auto": { type: "api_key", provider: "xai", key: "profile-key" }, + }), + sessionAuthProfileId: "xai:auto", + sessionAuthProfileSource: "auto", + harnessId: "native-remote", + harnessRuntime: "native-remote", + allowHarnessAuthProfileForwarding: false, + }); + + expect(plan.forwardedAuthProfileId).toBeUndefined(); + expect(plan.selectedAuthMode).toBeUndefined(); + }); + + it("lets a provider-entry token profile binding outrank configured auth and auth.order", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { + auth: "api-key", + apiKey: "openai:bound", + baseUrl: "", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore( + { + "openai:bound": { + type: "token", + provider: "openai", + token: "subscription-token", + expires: Date.now() + 60_000, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + { openai: ["openai:platform", "openai:bound"] }, + ), + }); + + expect(plan).toMatchObject({ + forwardedAuthProfileId: "openai:bound", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["openai:bound"], + selectedAuthMode: "token", + modelRoute: { + api: "openai-chatgpt-responses", + authRequirement: "subscription", + }, + }); + }); + + it.each([ + { provider: "anthropic", mode: "api_key" as const }, + { provider: "openai", mode: "oauth" as const }, + ])("rejects a bound profile with conflicting $provider/$mode metadata", ({ mode, provider }) => { + expect(() => + prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + config: { + auth: { profiles: { "openai:bound": { provider, mode } } }, + models: { + providers: { + openai: { apiKey: "openai:bound", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({ + "openai:bound": { + type: "api_key", + provider: "openai", + key: "bound-platform-key", + }, + }), + }), + ).toThrow(/no usable credentials/u); + }); + + it("rejects an incompatible provider-entry profile without borrowing auth.order", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { + apiKey: "openai:oauth", + baseUrl: "", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore( + { + "openai:oauth": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + { openai: ["openai:platform"] }, + ), + }), + ).toThrow(/not a compatible bearer profile/u); + }); + + it("does not forward a cooldowned provider-entry profile", () => { + const store = authStore({ + "openai:bound": { + type: "token", + provider: "openai", + token: "subscription-token", + expires: Date.now() + 60_000, + }, + }); + store.usageStats = { + "openai:bound": { cooldownUntil: Date.now() + 60_000 }, + }; + + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { apiKey: "openai:bound", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: store, + }), + ).toThrow(/temporarily unavailable/u); + }); + + it("keeps an explicit AWS SDK auth mode ahead of provider-entry profile bindings", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { + auth: "aws-sdk", + apiKey: "openai:bound", + baseUrl: "", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({ + "openai:bound": { + type: "token", + provider: "openai", + token: "subscription-token", + expires: Date.now() + 60_000, + }, + }), + }); + + expect(plan.forwardedAuthProfileId).toBeUndefined(); + expect(plan.selectedAuthMode).toBe("aws-sdk"); + expect(plan.modelRoute).toMatchObject({ + api: "openai-responses", + authRequirement: "api-key", + }); + }); + + it("keeps AWS SDK auth terminal when an API-key SecretRef and ordered profile also exist", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { + auth: "aws-sdk", + apiKey: { source: "file", provider: "vault", id: "/openai/api-key" }, + baseUrl: "", + models: [], + }, + }, + }, + secrets: { + providers: { + vault: { source: "file", path: "/tmp/openai-secrets.json", mode: "json" }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore( + { + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + { openai: ["openai:platform"] }, + ), + }); + + expect(plan.forwardedAuthProfileId).toBeUndefined(); + expect(plan.forwardedAuthProfileCandidateIds).toBeUndefined(); + expect(plan.selectedAuthMode).toBe("aws-sdk"); + expect(plan.modelRoute).toMatchObject({ + api: "openai-responses", + authRequirement: "api-key", + }); + }); + + it("keeps an explicit SecretRef API key ahead of ordered API-key profiles", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + config: { + models: { + providers: { + openai: { + baseUrl: "", + models: [], + }, + " openai ": { + auth: "api-key", + apiKey: { source: "file", provider: "vault", id: "/openai/api-key" }, + baseUrl: "", + models: [], + }, + }, + }, + secrets: { + providers: { + vault: { source: "file", path: "/tmp/openai-secrets.json", mode: "json" }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore( + { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + { openai: ["openai:chatgpt", "openai:platform"] }, + ), + }); + + expect(plan.forwardedAuthProfileId).toBeUndefined(); + expect(plan.forwardedAuthProfileCandidateIds).toBeUndefined(); + expect(plan.selectedAuthMode).toBe("api-key"); + expect(plan.modelRoute).toMatchObject({ + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + }); + }); + + it("keeps profile auth ahead of a literal provider apiKey fallback", async () => { + const config = { + models: { + providers: { + openai: { + apiKey: "configured-platform-key", + baseUrl: "", + models: [], + }, + }, + }, + } as OpenClawConfig; + const store = authStore( + { + "openai:platform-backup": { + type: "api_key", + provider: "openai", + key: "profile-platform-key", + }, + }, + { openai: ["openai:platform-backup"] }, + ); + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + config, + env: {}, + authProfileStore: store, + }); + const plan = prepared.plan; + + expect(plan.forwardedAuthProfileId).toBe("openai:platform-backup"); + expect(plan.forwardedAuthProfileCandidateIds).toEqual(["openai:platform-backup"]); + expect(plan.selectedAuthMode).toBe("api_key"); + expect(plan.modelRoute).toMatchObject({ + api: "openai-responses", + authRequirement: "api-key", + }); + expect( + prepared.attempts.map((attempt) => ({ + kind: attempt.kind, + profileId: attempt.profileId, + allowAuthProfileFallback: attempt.allowAuthProfileFallback, + requiresPriorProfileAttempt: attempt.requiresPriorProfileAttempt, + forwardedAuthProfileId: attempt.plan.forwardedAuthProfileId, + })), + ).toEqual([ + { + kind: "profile", + profileId: "openai:platform-backup", + allowAuthProfileFallback: undefined, + requiresPriorProfileAttempt: undefined, + forwardedAuthProfileId: "openai:platform-backup", + }, + { + kind: "direct", + profileId: undefined, + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: true, + forwardedAuthProfileId: undefined, + }, + ]); + expect(prepared.attempts[1]?.plan).toMatchObject({ + selectedAuthMode: "api-key", + modelRoute: { + api: "openai-responses", + authRequirement: "api-key", + }, + }); + + const model = { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + api: plan.modelRoute?.api ?? "openai-responses", + baseUrl: plan.modelRoute?.baseUrl ?? "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 272_000, + maxTokens: 128_000, + } as Model; + const profileAttempt = prepared.attempts[0]; + const profileResolved = await getApiKeyForModel({ + model, + cfg: config, + profileId: profileAttempt?.profileId, + allowAuthProfileFallback: profileAttempt?.allowAuthProfileFallback, + store, + }); + + expect(profileResolved).toMatchObject({ + apiKey: "profile-platform-key", + profileId: "openai:platform-backup", + source: "profile:openai:platform-backup", + mode: "api-key", + }); + }); + + it("does not unlock direct fallback when every prepared profile cools down before dispatch", () => { + const store = authStore( + { + "openai:platform": { + type: "api_key", + provider: "openai", + key: "profile-platform-key", + }, + }, + { openai: ["openai:platform"] }, + ); + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + config: { + models: { + providers: { + openai: { apiKey: "configured-platform-key", baseUrl: "", models: [] }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: store, + }); + const profileAttempt = prepared.attempts[0]; + const directAttempt = prepared.attempts[1]; + if (profileAttempt?.kind !== "profile" || directAttempt?.kind !== "direct") { + throw new Error("expected profile and direct attempts"); + } + store.usageStats = { + "openai:platform": { cooldownUntil: Date.now() + 60_000 }, + }; + + expect( + preparedAgentRuntimeProfileAttemptHasCandidate({ + attempt: profileAttempt, + store, + modelId: "gpt-5.5", + }), + ).toBe(false); + expect( + canRunPreparedAgentRuntimeAuthAttempt({ + attempt: directAttempt, + priorProfileAttempted: false, + }), + ).toBe(false); + expect( + canRunPreparedAgentRuntimeAuthAttempt({ + attempt: directAttempt, + priorProfileAttempted: true, + }), + ).toBe(true); + }); + + it.each([ + { + label: "OAuth profile then ambient Platform key", + env: { OPENAI_API_KEY: "ambient-platform-key" }, + profileId: "openai:chatgpt", + profile: { + type: "oauth" as const, + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + requirements: ["subscription", "api-key"], + }, + { + label: "Platform profile then ambient OAuth token", + config: { + models: { providers: { openai: { auth: "oauth", baseUrl: "", models: [] } } }, + } as OpenClawConfig, + env: { OPENAI_API_KEY: "ambient-oauth-token" }, + profileId: "openai:platform", + profile: { + type: "api_key" as const, + provider: "openai", + key: "profile-platform-key", + }, + requirements: ["api-key", "subscription"], + }, + ])( + "prepares $label as distinct physical attempts", + ({ config, env, profile, profileId, requirements }) => { + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + config, + env, + authProfileStore: authStore({ [profileId]: profile }, { openai: [profileId] }), + }); + + expect(prepared.attempts.map((attempt) => attempt.plan.modelRoute?.authRequirement)).toEqual( + requirements, + ); + expect(prepared.attempts).toMatchObject([ + { kind: "profile", profileId }, + { + kind: "direct", + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: true, + }, + ]); + }, + ); + + it("resolves an env SecretRef on its prepared Platform route", async () => { + vi.stubEnv("OPENAI_PLATFORM_KEY", "secret-ref-platform-key"); + try { + const config = { + models: { + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_PLATFORM_KEY" }, + baseUrl: "", + models: [], + }, + }, + }, + } as OpenClawConfig; + const store = authStore({}); + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + config, + env: process.env, + authProfileStore: store, + }); + + expect(prepared.attempts).toEqual([ + { + kind: "direct", + plan: prepared.plan, + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: false, + }, + ]); + expect(prepared.plan).toMatchObject({ + forwardedAuthProfileId: undefined, + selectedAuthMode: "api-key", + modelRoute: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + }, + }); + + const resolved = await getApiKeyForModel({ + model: { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + api: prepared.plan.modelRoute?.api ?? "openai-responses", + baseUrl: prepared.plan.modelRoute?.baseUrl ?? "https://api.openai.com/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 272_000, + maxTokens: 128_000, + } as Model, + cfg: config, + profileId: prepared.attempts[0]?.profileId, + allowAuthProfileFallback: prepared.attempts[0]?.allowAuthProfileFallback, + store, + }); + + expect(resolved).toMatchObject({ + apiKey: "secret-ref-platform-key", + source: "env: OPENAI_PLATFORM_KEY (models.json secretref)", + mode: "api-key", + }); + expect(resolved.profileId).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("keeps a provider apiKey SecretRef after API-key-compatible profiles", () => { + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-chatgpt-responses", + modelBaseUrl: "https://chatgpt.com/backend-api/codex", + config: { + models: { + providers: { + openai: { + apiKey: { source: "file", provider: "vault", id: "/openai/api-key" }, + baseUrl: "", + models: [], + }, + }, + }, + secrets: { + providers: { + vault: { source: "file", path: "/tmp/openai-secrets.json", mode: "json" }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore( + { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 10 * 60_000, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }, + { openai: ["openai:chatgpt", "openai:platform"] }, + ), + }); + + expect(prepared.plan).toMatchObject({ + forwardedAuthProfileId: "openai:platform", + selectedAuthMode: "api_key", + modelRoute: { + api: "openai-responses", + authRequirement: "api-key", + }, + }); + expect(prepared.attempts).toMatchObject([ + { kind: "profile", profileId: "openai:platform" }, + { + kind: "direct", + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: true, + }, + ]); + }); + + it("uses explicit OAuth mode for literal provider material", () => { + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { + auth: "oauth", + apiKey: "configured-oauth-token", + baseUrl: "", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({}), + }); + + expect(prepared.plan).toMatchObject({ + selectedAuthMode: "oauth", + modelRoute: { + api: "openai-chatgpt-responses", + authRequirement: "subscription", + }, + }); + expect(prepared.attempts).toMatchObject([ + { + kind: "direct", + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: false, + }, + ]); + }); + + it("keeps an API profile ahead of configured OAuth direct material", () => { + const prepared = prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + config: { + models: { + providers: { + openai: { + auth: "oauth", + apiKey: "configured-oauth-token", + baseUrl: "", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({ + "openai:platform": { + type: "api_key", + provider: "openai", + key: "profile-platform-key", + }, + }), + }); + + expect(prepared.attempts.map((attempt) => attempt.plan.modelRoute?.authRequirement)).toEqual([ + "api-key", + "subscription", + ]); + expect(prepared.attempts).toMatchObject([ + { kind: "profile", profileId: "openai:platform" }, + { + kind: "direct", + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: true, + plan: { selectedAuthMode: "oauth" }, + }, + ]); + }); + + it("preserves explicit provider token auth before auth.order or route defaults", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { + auth: "token", + apiKey: "configured-subscription-token", + baseUrl: "", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({}), + }); + + expect(plan.forwardedAuthProfileId).toBeUndefined(); + expect(plan.selectedAuthMode).toBe("token"); + expect(plan.modelRoute).toMatchObject({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + }); + }); + + it.each([ + { + auth: "oauth" as const, + profile: { type: "api_key" as const, provider: "openai", key: "platform-key" }, + requirement: "subscription", + }, + { + auth: "api-key" as const, + profile: { + type: "oauth" as const, + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + requirement: "api-key", + }, + ])("rejects a $profile.type profile for configured $auth auth", ({ auth, profile }) => { + expect(() => + prepareAgentRuntimeAuth({ + provider: "openai", + modelId: "gpt-5.5", + config: { + models: { providers: { openai: { auth, baseUrl: "", models: [] } } }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({ "openai:wrong-route": profile }), + }), + ).toThrow(/no compatible credential source/u); + }); + + it("rejects configured harness-native auth without a compatible host source", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + config: { + models: { providers: { openai: { auth: "oauth", baseUrl: "", models: [] } } }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({}), + harnessId: "codex", + harnessRuntime: "codex", + harnessAuthBootstrap: "harness", + }), + ).toThrow(/no compatible credential source/u); + }); + + it("rejects configured provider auth that contradicts an authored route", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + config: { + models: { + providers: { + openai: { + auth: "oauth", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + authProfileStore: authStore({}), + }), + ).toThrow(/not compatible/u); + }); + + it("preserves an explicit environment endpoint in the selected route", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + modelApi: "openai-responses", + modelBaseUrl: "https://api.openai.com/v1", + env: { + OPENAI_API_KEY: "platform-key", + OPENAI_BASE_URL: "https://relay.example.test/v1", + }, + authProfileStore: authStore({}), + }); + + expect(plan.modelRoute).toEqual({ + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://relay.example.test/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }); + }); + + it("keeps the live codex virtual provider on a generic either-auth plan", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "codex", + modelId: "gpt-5.4", + env: {}, + authProfileStore: authStore({ + "openai:account": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }), + sessionAuthProfileId: "openai:account", + sessionAuthProfileSource: "user", + harnessId: "codex", + harnessRuntime: "codex", + }); + + expect(plan).toMatchObject({ + providerForAuth: "codex", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:account", + forwardedAuthProfileSource: "user", + }); + expect(plan.modelRoute).toBeUndefined(); + }); + + it("resolves automatic virtual Codex profiles from the OpenAI auth order", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "codex", + modelId: "gpt-5.4", + env: {}, + authProfileStore: authStore( + { + "openai:p1": { + type: "token", + provider: "openai", + token: "p1-token", + }, + "openai:p2": { + type: "api_key", + provider: "openai", + key: "p2-key", + }, + }, + { openai: ["openai:p1", "openai:p2"] }, + ), + sessionAuthProfileId: "openai:p1", + sessionAuthProfileSource: "auto", + harnessId: "codex", + harnessRuntime: "codex", + }); + + expect(plan).toMatchObject({ + providerForAuth: "codex", + harnessAuthProvider: "openai", + forwardedAuthProfileId: "openai:p1", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["openai:p1", "openai:p2"], + selectedAuthMode: "token", + }); + expect(plan.modelRoute).toBeUndefined(); + }); + + it("rejects a user-locked non-OpenAI profile on the virtual Codex provider", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "codex", + modelId: "gpt-5.4", + env: {}, + authProfileStore: authStore({ + "anthropic:work": { + type: "api_key", + provider: "anthropic", + key: "anthropic-key", + }, + }), + sessionAuthProfileId: "anthropic:work", + sessionAuthProfileSource: "user", + harnessId: "codex", + harnessRuntime: "codex", + }), + ).toThrow(/not configured for openai/u); + }); + + it("rejects unavailable user-locked OpenAI profiles on the virtual Codex provider", () => { + expect(() => + prepareAgentRuntimeAuthPlan({ + provider: "codex", + modelId: "gpt-5.4", + env: {}, + config: { + auth: { + profiles: { + "openai:missing": { provider: "openai", mode: "oauth" }, + }, + }, + } as OpenClawConfig, + authProfileStore: authStore({}), + sessionAuthProfileId: "openai:missing", + sessionAuthProfileSource: "user", + harnessId: "codex", + harnessRuntime: "codex", + }), + ).toThrow(/not configured for openai/u); + }); + + it("does not reuse a routed plan across compaction model overrides", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "openai", + modelId: "gpt-5.5", + env: { OPENAI_API_KEY: "platform-key" }, + authProfileStore: authStore({}), + }); + + expect( + agentRuntimeAuthPlanMatchesTarget(plan, { provider: "openai", modelId: "gpt-5.5" }), + ).toBe(true); + expect( + agentRuntimeAuthPlanMatchesTarget(plan, { provider: "openai", modelId: "gpt-5.6" }), + ).toBe(false); + }); + + it("does not reuse a generic plan across model-scoped auth decisions", () => { + const plan = prepareAgentRuntimeAuthPlan({ + provider: "anthropic", + modelId: "claude-sonnet-4-6", + env: {}, + authProfileStore: authStore({}), + }); + + expect( + agentRuntimeAuthPlanMatchesTarget(plan, { + provider: "anthropic", + modelId: "claude-sonnet-4-6", + }), + ).toBe(true); + expect( + agentRuntimeAuthPlanMatchesTarget(plan, { + provider: "anthropic", + modelId: "claude-opus-4-6", + }), + ).toBe(false); + }); +}); diff --git a/src/agents/runtime-plan/prepare-auth.ts b/src/agents/runtime-plan/prepare-auth.ts new file mode 100644 index 000000000000..bc0f2c7eb112 --- /dev/null +++ b/src/agents/runtime-plan/prepare-auth.ts @@ -0,0 +1,588 @@ +/** + * Prepares route-aware auth forwarding for auxiliary agent-runtime calls. + * Callers supply an already loaded credential snapshot; this module never + * resolves secrets or loads a provider runtime. + */ +import { resolveMergedModelProviderConfig } from "../../config/model-provider-config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { coerceSecretRef } from "../../config/types.secrets.js"; +import type { ProviderRouteOverridePresence } from "../../plugin-sdk/provider-model-types.js"; +import { + resolveAuthProfileEligibility, + resolveAuthProfileOrderWithMetadata, +} from "../auth-profiles/order.js"; +import { resolveStoredCredentialReadOnlyAvailability } from "../auth-profiles/read-only-availability.js"; +import type { AuthProfileStore } from "../auth-profiles/types.js"; +import { isProfileInCooldown } from "../auth-profiles/usage-state.js"; +import { resolveProviderDirectAuthPlanningEvidence } from "../model-auth-env.js"; +import { + hasUsableCustomProviderApiKey, + resolveProviderEntryApiKeyProfileReference, + shouldPreferExplicitConfigApiKeyAuth, +} from "../model-auth.js"; +import { resolveOpenAIModelRoutes, selectOpenAIModelRouteAuth } from "../openai-model-routes.js"; +import { + buildProviderModelAuthDirectSource, + buildProviderModelAuthSourcePlan, + type ProviderModelAuthDirectSource, + type ProviderModelAuthProfileSource, +} from "../provider-model-auth-source-plan.js"; +import { selectProviderModelAuthSources } from "../provider-model-route-auth.js"; +import { buildAgentRuntimeAuthPlan } from "./auth.js"; +import type { AgentRuntimeAuthPlan } from "./types.js"; + +type PrepareAgentRuntimeAuthPlanParams = { + provider: string; + modelId: string; + modelApi?: string | null; + modelBaseUrl?: unknown; + requestTransportOverrides?: ProviderRouteOverridePresence; + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + agentDir?: string; + workspaceDir?: string; + authProfileStore?: AuthProfileStore; + sessionAuthProfileId?: string; + sessionAuthProfileSource?: "auto" | "user"; + harnessId?: string; + harnessRuntime?: string; + harnessAuthBootstrap?: "harness"; + allowHarnessAuthProfileForwarding?: boolean; + allowTransientCooldownProbe?: boolean; + resolveProviderPreferredProfileId?(context: { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + provider: string; + modelId: string; + preferredProfileId?: string; + lockedProfileId?: string; + profileOrder: string[]; + authStore: AuthProfileStore; + }): string | undefined; +}; + +export type PreparedAgentRuntimeAuthAttempt = + | { + kind: "profile"; + plan: AgentRuntimeAuthPlan; + profileId: string; + allowAuthProfileFallback?: never; + requiresPriorProfileAttempt?: never; + } + | { + kind: "direct"; + plan: AgentRuntimeAuthPlan; + profileId?: never; + /** Direct lookup cannot re-enter automatic profile discovery. */ + allowAuthProfileFallback: false; + /** Fail closed when every prepared profile became cooldown-blocked before dispatch. */ + requiresPriorProfileAttempt: boolean; + } + | { + kind: "implicit"; + plan: AgentRuntimeAuthPlan; + profileId?: never; + allowAuthProfileFallback?: never; + requiresPriorProfileAttempt?: never; + }; + +export type PreparedAgentRuntimeAuth = { + plan: AgentRuntimeAuthPlan; + /** Ordered physical attempts; every route/profile tuple was selected by this planner. */ + attempts: readonly PreparedAgentRuntimeAuthAttempt[]; +}; + +/** Prevents a direct fallback from bypassing a prepared profile tier. */ +export function canRunPreparedAgentRuntimeAuthAttempt(params: { + attempt: PreparedAgentRuntimeAuthAttempt; + priorProfileAttempted: boolean; +}): boolean { + return ( + params.attempt.kind !== "direct" || + !params.attempt.requiresPriorProfileAttempt || + params.priorProfileAttempted + ); +} + +/** Rechecks automatic cooldowns immediately before a prepared profile attempt. */ +export function preparedAgentRuntimeProfileAttemptHasCandidate(params: { + attempt: PreparedAgentRuntimeAuthAttempt; + store: AuthProfileStore; + modelId: string; +}): boolean { + if (params.attempt.kind !== "profile") { + return false; + } + if (params.attempt.plan.forwardedAuthProfileSource === "user") { + return true; + } + const profileIds = params.attempt.plan.forwardedAuthProfileCandidateIds ?? [ + params.attempt.profileId, + ]; + return profileIds.some( + (profileId) => !isProfileInCooldown(params.store, profileId, undefined, params.modelId), + ); +} + +/** True when a prepared auth tuple can be reused for this exact compaction target. */ +export function agentRuntimeAuthPlanMatchesTarget( + plan: AgentRuntimeAuthPlan, + target: { provider: string; modelId: string }, +): boolean { + const route = plan.modelRoute; + const provider = route?.provider ?? plan.providerForAuth; + const modelId = route?.modelId ?? plan.modelId; + return ( + modelId !== undefined && + provider.trim().toLowerCase() === target.provider.trim().toLowerCase() && + modelId === target.modelId + ); +} + +function resolveProfile( + params: PrepareAgentRuntimeAuthPlanParams, + profileId: string, + options: { ignoreCooldown?: boolean } = {}, +): ProviderModelAuthProfileSource { + const credential = params.authProfileStore?.profiles[profileId]; + const configured = params.config?.auth?.profiles?.[profileId]; + const availability = credential + ? resolveStoredCredentialReadOnlyAvailability({ + credential, + cfg: params.config ?? {}, + env: params.env ?? process.env, + }) + : undefined; + return { + kind: "profile", + profileId, + provider: credential?.provider ?? configured?.provider, + mode: credential?.type ?? configured?.mode, + // Runtime materialization owns secret readiness; only proven-invalid facts are terminal here. + readiness: availability === false ? "unavailable" : "unknown", + cooldown: + !options.ignoreCooldown && + params.authProfileStore && + isProfileInCooldown(params.authProfileStore, profileId, undefined, params.modelId) + ? "active" + : "clear", + }; +} + +type ProviderEntryProfileParams = Pick< + PrepareAgentRuntimeAuthPlanParams, + "config" | "modelId" | "provider" +> & { + store: AuthProfileStore; +}; + +/** Applies terminal provider-entry credential policy before route selection. */ +function resolvePreparedProviderEntryApiKeyProfileReference(params: ProviderEntryProfileParams) { + const reference = resolveProviderEntryApiKeyProfileReference({ + cfg: params.config, + provider: params.provider, + store: params.store, + }); + if (reference.kind !== "profile") { + return reference; + } + const eligibility = resolveAuthProfileEligibility({ + cfg: params.config, + store: params.store, + provider: params.provider, + profileId: reference.profileId, + }); + if (!eligibility.eligible) { + throw new Error( + `Per-entry apiKey profile "${reference.profileId}" has no usable credentials for ${params.provider}.`, + ); + } + if (isProfileInCooldown(params.store, reference.profileId, undefined, params.modelId)) { + throw new Error( + `Auth profile "${reference.profileId}" is temporarily unavailable for ${params.provider}/${params.modelId}.`, + ); + } + return reference; +} + +/** Selects concrete provider routes and ordered credentials as one immutable preparation. */ +export function prepareAgentRuntimeAuth( + params: PrepareAgentRuntimeAuthPlanParams, +): PreparedAgentRuntimeAuth { + const requestedProfileId = params.sessionAuthProfileId?.trim() || undefined; + const lockedProfileId = + params.sessionAuthProfileSource === "user" ? requestedProfileId : undefined; + const harnessOwnsOpenAIAuth = + params.harnessId?.trim().toLowerCase() === "codex" || + params.harnessRuntime?.trim().toLowerCase() === "codex"; + const harnessAuthOwnerId = params.harnessId?.trim() || params.harnessRuntime?.trim(); + const runtimeAuthOwner = + harnessOwnsOpenAIAuth && params.harnessAuthBootstrap === "harness" && harnessAuthOwnerId + ? { id: harnessAuthOwnerId } + : undefined; + const harnessAllowsAuthProfileForwarding = params.allowHarnessAuthProfileForwarding !== false; + if (lockedProfileId && !harnessAllowsAuthProfileForwarding) { + throw new Error( + `Auth profile "${lockedProfileId}" cannot be forwarded to the selected agent harness. Configure that harness's native account instead.`, + ); + } + const store = params.authProfileStore; + const authProfileSelectionProvider = harnessOwnsOpenAIAuth ? "openai" : params.provider; + if (lockedProfileId) { + const eligibility = store + ? resolveAuthProfileEligibility({ + cfg: params.config, + store, + provider: authProfileSelectionProvider, + profileId: lockedProfileId, + }) + : { eligible: false }; + if (!eligibility.eligible) { + throw new Error( + `Auth profile "${lockedProfileId}" is not configured for ${authProfileSelectionProvider}.`, + ); + } + } + + const configuredProvider = resolveMergedModelProviderConfig(params.config, params.provider); + const configuredAuthMode = + lockedProfileId || !harnessAllowsAuthProfileForwarding ? undefined : configuredProvider?.auth; + const configuredAwsSdkAuth = configuredAuthMode === "aws-sdk"; + const providerHasApiKeySecretRef = + harnessAllowsAuthProfileForwarding && + Boolean(coerceSecretRef(configuredProvider?.apiKey, params.config?.secrets?.defaults)); + const providerBinding = + harnessAllowsAuthProfileForwarding && !lockedProfileId && store && !configuredAwsSdkAuth + ? resolvePreparedProviderEntryApiKeyProfileReference({ + config: params.config, + modelId: params.modelId, + provider: params.provider, + store, + }) + : { kind: "none" as const }; + if (providerBinding.kind === "profile-incompatible") { + throw new Error( + `Per-entry apiKey "${providerBinding.profileId}" is not a compatible bearer profile for ${params.provider}.`, + ); + } + const boundProfileId = providerBinding.kind === "profile" ? providerBinding.profileId : undefined; + const providerHasUsableMarker = + providerBinding.kind === "marker" && + hasUsableCustomProviderApiKey(params.config, params.provider, params.env); + const providerHasDirectMaterial = + !configuredAwsSdkAuth && + (providerBinding.kind === "literal" || providerHasUsableMarker || providerHasApiKeySecretRef); + const explicitConfigApiKeyAuth = shouldPreferExplicitConfigApiKeyAuth( + params.config, + params.provider, + ); + const providerBindingSuppressesProfiles = + (providerBinding.kind === "literal" && explicitConfigApiKeyAuth) || + providerHasUsableMarker || + (providerHasApiKeySecretRef && explicitConfigApiKeyAuth); + const providerBindingNeedsNonProfileFallback = + providerHasDirectMaterial && !providerBindingSuppressesProfiles; + // Explicit auth owns the physical route; apiKey is only its bearer material. + const selectedConfiguredAuthMode = + configuredAuthMode ?? (providerHasDirectMaterial ? "api-key" : undefined); + const selectedProfileId = lockedProfileId ?? boundProfileId; + const automaticOrderResolution = + !harnessAllowsAuthProfileForwarding || + selectedProfileId || + providerBindingSuppressesProfiles || + configuredAwsSdkAuth || + !store + ? { + profileIds: selectedProfileId ? [selectedProfileId] : [], + hasExplicitOrder: false, + } + : resolveAuthProfileOrderWithMetadata({ + cfg: params.config, + store, + provider: authProfileSelectionProvider, + preferredProfile: lockedProfileId ? undefined : requestedProfileId, + forModel: params.modelId, + readinessMode: "read-only", + }); + const providerPreferredProfileId = + harnessAllowsAuthProfileForwarding && + !selectedProfileId && + !providerBindingSuppressesProfiles && + !configuredAwsSdkAuth && + store + ? params.resolveProviderPreferredProfileId?.({ + config: params.config, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + provider: params.provider, + modelId: params.modelId, + preferredProfileId: lockedProfileId ? undefined : requestedProfileId, + lockedProfileId, + profileOrder: automaticOrderResolution.profileIds, + authStore: store, + }) + : undefined; + const resolvedOrderedProfileIds = + providerPreferredProfileId && + automaticOrderResolution.profileIds.includes(providerPreferredProfileId) + ? [ + providerPreferredProfileId, + ...automaticOrderResolution.profileIds.filter( + (profileId) => profileId !== providerPreferredProfileId, + ), + ] + : automaticOrderResolution.profileIds; + const directSource = ( + mode: string | undefined, + evidence: ProviderModelAuthDirectSource["evidence"] = providerHasUsableMarker + ? "runtime" + : "provider-config", + availability?: boolean, + ) => buildProviderModelAuthDirectSource({ mode, evidence, availability }); + const directPlanningCandidate = harnessAllowsAuthProfileForwarding + ? resolveProviderDirectAuthPlanningEvidence( + authProfileSelectionProvider, + params.env ?? process.env, + { + config: params.config, + workspaceDir: params.workspaceDir, + }, + ) + : null; + // OpenAI native account discovery is harness-owned synthetic auth, not a + // bearer credential for an OpenClaw request route. + const directPlanningEvidence = + directPlanningCandidate?.kind === "setup-provider" && + authProfileSelectionProvider.trim().toLowerCase() === "openai" + ? null + : directPlanningCandidate; + const directPlanningMode = directPlanningEvidence + ? (configuredAuthMode ?? directPlanningEvidence.mode) + : undefined; + const fallbackDirectSource = directPlanningMode + ? directSource( + directPlanningMode, + directPlanningEvidence?.kind === "environment" ? "environment" : "runtime", + directPlanningEvidence?.kind === "environment" ? true : undefined, + ) + : providerBindingNeedsNonProfileFallback + ? directSource(selectedConfiguredAuthMode) + : undefined; + const automaticRouteAuthMode = + fallbackDirectSource && configuredAuthMode && !providerBindingSuppressesProfiles + ? undefined + : selectedConfiguredAuthMode; + const ownership = selectedProfileId + ? { + reason: lockedProfileId ? ("user-lock" as const) : ("provider-binding" as const), + source: resolveProfile(params, selectedProfileId, { ignoreCooldown: true }), + } + : configuredAwsSdkAuth + ? { + reason: "configured-auth" as const, + source: directSource("aws-sdk"), + } + : providerBindingSuppressesProfiles + ? { + reason: "configured-auth" as const, + source: directSource(selectedConfiguredAuthMode), + } + : undefined; + const sourcePlan = buildProviderModelAuthSourcePlan({ + ...(ownership ? { ownership } : {}), + profiles: resolvedOrderedProfileIds.map((profileId) => resolveProfile(params, profileId)), + ...(providerPreferredProfileId ? { preferredProfileId: providerPreferredProfileId } : {}), + explicitOrder: automaticOrderResolution.hasExplicitOrder, + ...(fallbackDirectSource ? { fallback: fallbackDirectSource } : {}), + allowCooldown: params.allowTransientCooldownProbe, + }); + const resolution = resolveOpenAIModelRoutes({ + provider: params.provider, + modelId: params.modelId, + api: params.modelApi, + baseUrl: params.modelBaseUrl, + config: params.config, + env: params.env, + requestTransportOverrides: params.requestTransportOverrides, + }); + if (!resolution || resolution.kind === "indeterminate") { + const sourceDecision = selectProviderModelAuthSources({ + provider: authProfileSelectionProvider, + plan: sourcePlan, + }); + if (sourceDecision.kind === "rejected") { + if (sourceDecision.reason === "all-cooldown" && sourceDecision.source) { + throw new Error( + `Auth profile "${sourceDecision.source.profileId}" is temporarily unavailable for ${params.provider}/${params.modelId}.`, + ); + } + throw new Error(sourceDecision.message); + } + const buildGenericPlan = ( + attempt: (typeof sourceDecision.attempts)[number] | undefined, + candidateIndex: number, + ) => { + const profile = attempt?.kind === "profile" ? attempt.source : undefined; + const candidateIds = sourceDecision.attempts + .slice(candidateIndex) + .flatMap((candidate) => (candidate.kind === "profile" ? [candidate.source.profileId] : [])); + return buildAgentRuntimeAuthPlan({ + provider: params.provider, + modelId: params.modelId, + authProfileProvider: profile?.provider, + authProfileMode: + profile?.mode ?? + (attempt?.kind === "direct" ? attempt.source.mode : selectedConfiguredAuthMode), + sessionAuthProfileId: profile?.profileId, + sessionAuthProfileSource: profile + ? sourcePlan.kind === "required" && sourcePlan.reason === "user-lock" + ? "user" + : "auto" + : undefined, + sessionAuthProfileCandidateIds: candidateIds.length > 0 ? candidateIds : undefined, + config: params.config, + workspaceDir: params.workspaceDir, + harnessId: params.harnessId, + harnessRuntime: params.harnessRuntime, + allowHarnessAuthProfileForwarding: harnessAllowsAuthProfileForwarding, + }); + }; + const attempts: PreparedAgentRuntimeAuthAttempt[] = sourceDecision.attempts.map( + (attempt, index) => { + const plan = buildGenericPlan(attempt, index); + return attempt.kind === "profile" + ? { kind: "profile", plan, profileId: attempt.source.profileId } + : { + kind: "direct", + plan, + allowAuthProfileFallback: attempt.allowAuthProfileFallback, + requiresPriorProfileAttempt: sourceDecision.attempts + .slice(0, index) + .some((candidate) => candidate.kind === "profile"), + }; + }, + ); + const plan = attempts[0]?.plan ?? buildGenericPlan(undefined, 0); + if ( + selectedProfileId && + harnessOwnsOpenAIAuth && + plan.forwardedAuthProfileId !== selectedProfileId + ) { + throw new Error( + `Auth profile "${selectedProfileId}" cannot be forwarded to the codex runtime.`, + ); + } + return { + plan, + attempts: attempts.length > 0 ? attempts : [{ kind: "implicit", plan }], + }; + } + if (resolution.kind === "incompatible") { + throw new Error(resolution.message); + } + const toPreparedRoute = (route: (typeof resolution.routes)[number]) => ({ + provider: params.provider, + modelId: params.modelId, + api: route.api, + baseUrl: route.baseUrl, + authRequirement: route.authRequirement, + requestTransportOverrides: route.requestTransportOverrides, + runtimePolicy: route.runtimePolicy, + }); + const routeAuthDecision = selectOpenAIModelRouteAuth({ + resolution, + sourcePlan, + configuredAuthMode: automaticRouteAuthMode, + ...(runtimeAuthOwner ? { runtimeAuthOwner } : {}), + }); + if (routeAuthDecision.kind === "deferred") { + const plan = buildAgentRuntimeAuthPlan({ + provider: params.provider, + modelId: params.modelId, + config: params.config, + workspaceDir: params.workspaceDir, + harnessId: params.harnessId, + harnessRuntime: params.harnessRuntime, + allowHarnessAuthProfileForwarding: harnessAllowsAuthProfileForwarding, + deferredRouteSupport: routeAuthDecision.routeSupport, + }); + return { plan, attempts: [{ kind: "implicit", plan }] }; + } + if (routeAuthDecision.kind !== "selected") { + if ( + routeAuthDecision.kind === "rejected" && + routeAuthDecision.reason === "all-cooldown" && + routeAuthDecision.source + ) { + throw new Error( + `Auth profile "${routeAuthDecision.source.profileId}" is temporarily unavailable for ${params.provider}/${params.modelId}.`, + ); + } + throw new Error(routeAuthDecision.message); + } + const buildRoutedPlan = (attempt: (typeof routeAuthDecision.attempts)[number] | undefined) => { + const profile = attempt?.kind === "profile" ? attempt.source : undefined; + const route = attempt?.route ?? routeAuthDecision.selection.route; + return buildAgentRuntimeAuthPlan({ + provider: params.provider, + modelId: params.modelId, + authProfileProvider: profile?.provider, + authProfileMode: + profile?.mode ?? + (attempt?.kind === "direct" ? attempt.source.mode : selectedConfiguredAuthMode), + sessionAuthProfileId: profile?.profileId, + sessionAuthProfileSource: profile + ? sourcePlan.kind === "required" && sourcePlan.reason === "user-lock" + ? "user" + : "auto" + : undefined, + sessionAuthProfileCandidateIds: + attempt?.kind === "profile" ? [...attempt.sameRouteProfileIds] : undefined, + modelRoute: toPreparedRoute(route), + config: params.config, + workspaceDir: params.workspaceDir, + harnessId: params.harnessId, + harnessRuntime: params.harnessRuntime, + allowHarnessAuthProfileForwarding: harnessAllowsAuthProfileForwarding, + }); + }; + const attempts: PreparedAgentRuntimeAuthAttempt[] = routeAuthDecision.attempts.map( + (attempt, index) => { + const plan = buildRoutedPlan(attempt); + return attempt.kind === "profile" + ? { kind: "profile", plan, profileId: attempt.source.profileId } + : { + kind: "direct", + plan, + allowAuthProfileFallback: attempt.allowAuthProfileFallback, + requiresPriorProfileAttempt: routeAuthDecision.attempts + .slice(0, index) + .some((candidate) => candidate.kind === "profile"), + }; + }, + ); + const plan = attempts[0]?.plan ?? buildRoutedPlan(undefined); + for (const attempt of attempts) { + if ( + attempt.profileId && + harnessOwnsOpenAIAuth && + attempt.plan.forwardedAuthProfileId !== attempt.profileId + ) { + throw new Error( + `Auth profile "${attempt.profileId}" cannot be forwarded to the codex runtime.`, + ); + } + } + return { + plan, + attempts: attempts.length > 0 ? attempts : [{ kind: "implicit", plan }], + }; +} + +/** Returns the initial immutable plan for auxiliary consumers. */ +export function prepareAgentRuntimeAuthPlan( + params: PrepareAgentRuntimeAuthPlanParams, +): AgentRuntimeAuthPlan { + return prepareAgentRuntimeAuth(params).plan; +} diff --git a/src/agents/runtime-plan/resolve-auth.test.ts b/src/agents/runtime-plan/resolve-auth.test.ts new file mode 100644 index 000000000000..f95b9bdf3398 --- /dev/null +++ b/src/agents/runtime-plan/resolve-auth.test.ts @@ -0,0 +1,586 @@ +import type { Model } from "openclaw/plugin-sdk/llm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../auth-profiles.js"; +import { + resolvePreparedRuntimeAuthAttempts, + resolvePreparedRuntimeModelAuth, + scopeAuthProfileStoreToPreparedPlan, +} from "./resolve-auth.js"; + +vi.mock("../model-auth-env-vars.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveProviderEnvAuthLookupMaps: () => ({ + aliasMap: {}, + envCandidateMap: { openai: ["OPENAI_API_KEY", "OPENAI_OAUTH_TOKEN"] }, + authEvidenceMap: {}, + setupProviderFallbackRefs: [], + }), +})); + +const platformModel = { + id: "gpt-5.5", + name: "gpt-5.5", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text"], + reasoning: true, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_000, +} as Model; + +const subscriptionModel = { + ...platformModel, + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", +} as Model; + +function authStore(profiles: AuthProfileStore["profiles"]): AuthProfileStore { + return { version: 1, profiles }; +} + +describe("resolvePreparedRuntimeModelAuth", () => { + beforeEach(() => { + vi.stubEnv("OPENCLAW_TEST_MISSING_PREPARED_AUTH", ""); + vi.stubEnv("OPENCLAW_TEST_MISSING_BOUND_AUTH", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("removes profile and selection state after a Platform key is resolved", () => { + const store = { + ...authStore({ + "openai:subscription": { + type: "token", + provider: "openai", + token: "subscription-token", + expires: Date.now() + 60_000, + }, + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }), + order: { openai: ["openai:subscription", "openai:platform"] }, + lastGood: { openai: "openai:subscription" }, + usageStats: { "openai:subscription": { lastUsed: 1 } }, + runtimePersistedProfileIds: ["openai:subscription"], + runtimeExternalProfileIds: ["openai:subscription"], + runtimeExternalProfileIdsAuthoritative: true, + } satisfies AuthProfileStore; + + expect( + scopeAuthProfileStoreToPreparedPlan(store, { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:platform", + forwardedAuthProfileCandidateIds: ["openai:platform"], + selectedAuthMode: "api-key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.6", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }), + ).toMatchObject({ + profiles: {}, + order: { openai: [] }, + lastGood: {}, + usageStats: {}, + runtimePersistedProfileIds: [], + runtimeExternalProfileIds: [], + runtimeExternalProfileIdsAuthoritative: true, + }); + }); + + it("resolves a later same-route profile when the first SecretRef is unavailable", async () => { + const store = authStore({ + "openai:missing": { + type: "api_key", + provider: "openai", + keyRef: { + source: "env", + provider: "default", + id: "OPENCLAW_TEST_MISSING_PREPARED_AUTH", + }, + }, + "openai:backup": { + type: "api_key", + provider: "openai", + key: "backup-key", + }, + }); + + await expect( + resolvePreparedRuntimeModelAuth({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:missing", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["openai:missing", "openai:backup"], + selectedAuthMode: "api_key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }, + model: platformModel, + cfg: {}, + store, + secretSentinels: true, + }), + ).resolves.toMatchObject({ + auth: { + profileId: "openai:backup", + mode: "api-key", + }, + plan: { + forwardedAuthProfileId: "openai:backup", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["openai:backup"], + selectedAuthMode: "api-key", + }, + }); + }); + + it( + "does not borrow an unprepared API-key profile for direct subscription auth", + { timeout: 1_000 }, + async () => { + vi.stubEnv("OPENAI_API_KEY", ""); + const store = authStore({ + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }); + + await expect( + resolvePreparedRuntimeModelAuth({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: "token", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + }, + }, + model: subscriptionModel, + cfg: {}, + store, + secretSentinels: true, + }), + ).rejects.toThrow('No API key found for provider "openai"'); + }, + ); + + it("resolves an ambient Platform key without borrowing the OAuth-only full store", async () => { + vi.stubEnv("OPENAI_API_KEY", "ambient-platform-key"); + const store = authStore({ + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "subscription-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }); + + const resolved = await resolvePreparedRuntimeModelAuth({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: "api-key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }, + model: platformModel, + cfg: {}, + store, + secretSentinels: true, + }); + + expect(resolved).toMatchObject({ + auth: { + apiKey: "ambient-platform-key", + mode: "api-key", + }, + plan: { + forwardedAuthProfileId: undefined, + selectedAuthMode: "api-key", + }, + }); + expect(resolved.auth.source).toContain("OPENAI_API_KEY"); + }); + + it("keeps authored unpinned provider auth ahead of an opposite-route store", async () => { + const store = authStore({ + "openai:chatgpt": { + type: "token", + provider: "openai", + token: "subscription-token", + }, + }); + + await expect( + resolvePreparedRuntimeModelAuth({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: "api-key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }, + model: platformModel, + cfg: { + models: { + providers: { + openai: { + apiKey: "configured-platform-key", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }, + store, + secretSentinels: true, + }), + ).resolves.toMatchObject({ auth: { mode: "api-key" } }); + }); + + it("materializes authored OpenAI oauth without borrowing the API-only full store", async () => { + vi.stubEnv("OPENAI_API_KEY", ""); + const store = authStore({ + "openai:platform": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + }); + await expect( + resolvePreparedRuntimeModelAuth({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: "oauth", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + }, + }, + model: subscriptionModel, + cfg: { + models: { + providers: { + openai: { + auth: "oauth", + apiKey: "configured-subscription-credential", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [], + }, + }, + }, + }, + store, + secretSentinels: true, + }), + ).resolves.toMatchObject({ + auth: { + apiKey: "configured-subscription-credential", + source: "models.json", + mode: "oauth", + }, + plan: { + selectedAuthMode: "oauth", + modelRoute: { authRequirement: "subscription" }, + }, + }); + }); + + it("skips a prepared candidate whose stored credential class changed", async () => { + const store = authStore({ + "openai:changed": { + type: "api_key", + provider: "openai", + key: "platform-key", + }, + "openai:backup": { + type: "token", + provider: "openai", + token: "subscription-token", + expires: Date.now() + 60_000, + }, + }); + + await expect( + resolvePreparedRuntimeModelAuth({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:changed", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["openai:changed", "openai:backup"], + selectedAuthMode: "token", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + }, + }, + model: subscriptionModel, + cfg: {}, + store, + secretSentinels: true, + }), + ).resolves.toMatchObject({ + auth: { profileId: "openai:backup", mode: "token" }, + plan: { + forwardedAuthProfileId: "openai:backup", + forwardedAuthProfileCandidateIds: ["openai:backup"], + selectedAuthMode: "token", + }, + }); + }); + + it("skips an automatic candidate that cooled down after plan preparation", async () => { + const store = authStore({ + "openai:first": { + type: "api_key", + provider: "openai", + key: "first-key", + }, + "openai:backup": { + type: "api_key", + provider: "openai", + key: "backup-key", + }, + }); + const plan = { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto" as const, + forwardedAuthProfileCandidateIds: ["openai:first", "openai:backup"], + selectedAuthMode: "api_key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.5", + api: "openai-responses" as const, + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key" as const, + requestTransportOverrides: "none" as const, + }, + }; + store.usageStats = { + "openai:first": { + cooldownUntil: Date.now() + 60_000, + cooldownReason: "rate_limit", + cooldownModel: "gpt-5.5", + }, + }; + + await expect( + resolvePreparedRuntimeModelAuth({ + plan, + model: platformModel, + cfg: {}, + store, + secretSentinels: true, + }), + ).resolves.toMatchObject({ + auth: { profileId: "openai:backup" }, + plan: { + forwardedAuthProfileId: "openai:backup", + forwardedAuthProfileCandidateIds: ["openai:backup"], + }, + }); + }); + + it("fails closed when every prepared automatic candidate is in cooldown", async () => { + const store = authStore({ + "openai:first": { type: "api_key", provider: "openai", key: "first-key" }, + "openai:backup": { type: "api_key", provider: "openai", key: "backup-key" }, + }); + store.usageStats = Object.fromEntries( + ["openai:first", "openai:backup"].map((profileId) => [ + profileId, + { + cooldownUntil: Date.now() + 60_000, + cooldownReason: "rate_limit" as const, + cooldownModel: "gpt-5.5", + }, + ]), + ); + + await expect( + resolvePreparedRuntimeModelAuth({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["openai:first", "openai:backup"], + }, + model: platformModel, + cfg: {}, + store, + secretSentinels: true, + }), + ).rejects.toThrow("temporarily unavailable"); + }); + + it("does not unlock direct fallback when a profile cools during materialization", async () => { + const store = authStore({ + "openai:first": { type: "api_key", provider: "openai", key: "first-key" }, + }); + const profilePlan = { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:first", + forwardedAuthProfileSource: "auto" as const, + forwardedAuthProfileCandidateIds: ["openai:first"], + }; + const directPlan = { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + }; + const resolveAuth = vi.fn(async () => ({ plan: directPlan, auth: "unused" })); + const materializeModel = vi.fn(async () => { + store.usageStats = { + "openai:first": { cooldownUntil: Date.now() + 60_000 }, + }; + return platformModel; + }); + + await expect( + resolvePreparedRuntimeAuthAttempts({ + attempts: [ + { kind: "profile", plan: profilePlan, profileId: "openai:first" }, + { + kind: "direct", + plan: directPlan, + allowAuthProfileFallback: false, + requiresPriorProfileAttempt: true, + }, + ], + store, + modelId: "gpt-5.5", + model: platformModel, + materializeModel, + resolveAuth, + errorMessage: "prepared auth failed", + }), + ).rejects.toThrow("temporarily unavailable"); + expect(materializeModel).toHaveBeenCalledOnce(); + expect(resolveAuth).not.toHaveBeenCalled(); + }); + + it("keeps a single bound prepared profile terminal", async () => { + const store = authStore({ + "openai:bound": { + type: "api_key", + provider: "openai", + keyRef: { + source: "env", + provider: "default", + id: "OPENCLAW_TEST_MISSING_BOUND_AUTH", + }, + }, + "openai:unbound": { + type: "api_key", + provider: "openai", + key: "must-not-be-borrowed", + }, + }); + + await expect( + resolvePreparedRuntimeModelAuth({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:bound", + forwardedAuthProfileSource: "auto", + forwardedAuthProfileCandidateIds: ["openai:bound"], + }, + model: platformModel, + cfg: { + auth: { order: { openai: ["openai:bound", "openai:unbound"] } }, + }, + store, + secretSentinels: true, + }), + ).rejects.toThrow(); + }); + + it("keeps a user-locked profile terminal when environment auth is also present", async () => { + vi.stubEnv("OPENAI_API_KEY", "ambient-key"); + const store = authStore({ + "openai:locked": { + type: "api_key", + provider: "openai", + key: "codex-app-server", + }, + }); + + await expect( + resolvePreparedRuntimeModelAuth({ + plan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + forwardedAuthProfileId: "openai:locked", + forwardedAuthProfileSource: "user", + forwardedAuthProfileCandidateIds: ["openai:locked"], + }, + model: platformModel, + cfg: {}, + store, + secretSentinels: true, + }), + ).resolves.toMatchObject({ + auth: { profileId: "openai:locked" }, + plan: { + forwardedAuthProfileId: "openai:locked", + forwardedAuthProfileSource: "user", + forwardedAuthProfileCandidateIds: ["openai:locked"], + }, + }); + }); +}); diff --git a/src/agents/runtime-plan/resolve-auth.ts b/src/agents/runtime-plan/resolve-auth.ts new file mode 100644 index 000000000000..4a67810c9789 --- /dev/null +++ b/src/agents/runtime-plan/resolve-auth.ts @@ -0,0 +1,316 @@ +/** Resolves credentials for an immutable prepared runtime route. */ +import { toErrorObject } from "../../infra/errors.js"; +import type { AuthProfileStore } from "../auth-profiles/types.js"; +import { isProfileInCooldown } from "../auth-profiles/usage-state.js"; +import { getApiKeyForModel } from "../model-auth.js"; +import { providerModelRouteAcceptsAuthMode } from "../provider-model-route-auth.js"; +import { sameAgentRuntimeAuthModelRoute } from "./model-route.js"; +import { + canRunPreparedAgentRuntimeAuthAttempt, + preparedAgentRuntimeProfileAttemptHasCandidate, + type PreparedAgentRuntimeAuthAttempt, +} from "./prepare-auth.js"; +import type { AgentRuntimeAuthPlan } from "./types.js"; + +type PreparedRuntimeModelAuthResolution = Readonly<{ + auth: Awaited>; + plan: AgentRuntimeAuthPlan; +}>; + +type PreparedRuntimeAuthAttemptResolution = Readonly<{ + model: Model; + plan: AgentRuntimeAuthPlan; + auth: Auth; +}>; + +function listDistinctPreparedRuntimeAuthAttempts( + attempts: readonly PreparedAgentRuntimeAuthAttempt[], +): PreparedAgentRuntimeAuthAttempt[] { + return attempts.filter((attempt, index) => { + const route = attempt.plan.modelRoute; + return !attempts.slice(0, index).some((previous) => { + // Same-route profile candidates resolve as one tier. Direct auth remains + // distinct because it must never be swallowed by profile fallback. + if ( + (previous.allowAuthProfileFallback === false) !== + (attempt.allowAuthProfileFallback === false) + ) { + return false; + } + const previousRoute = previous.plan.modelRoute; + if (!route || !previousRoute) { + return !route && !previousRoute; + } + return sameAgentRuntimeAuthModelRoute(route, previousRoute); + }); + }); +} + +/** Resolves one complete prepared route/profile tuple without crossing retries mid-flight. */ +export async function resolvePreparedRuntimeAuthAttempts(params: { + attempts: readonly PreparedAgentRuntimeAuthAttempt[]; + store: AuthProfileStore; + modelId: string; + model: Model; + materializeModel(input: { + plan: AgentRuntimeAuthPlan; + model: Model; + forceResolve?: boolean; + }): Promise; + resolveAuth(input: { + attempt: PreparedAgentRuntimeAuthAttempt; + model: Model; + }): Promise<{ plan: AgentRuntimeAuthPlan; auth: Auth }>; + errorMessage: string; +}): Promise> { + let firstError: unknown; + let priorProfileAttempted = false; + for (const attempt of listDistinctPreparedRuntimeAuthAttempts(params.attempts)) { + if ( + !canRunPreparedAgentRuntimeAuthAttempt({ + attempt, + priorProfileAttempted, + }) + ) { + firstError ??= new Error("Prepared direct auth cannot bypass unavailable profiles."); + continue; + } + if ( + attempt.kind === "profile" && + !preparedAgentRuntimeProfileAttemptHasCandidate({ + attempt, + store: params.store, + modelId: params.modelId, + }) + ) { + firstError ??= new Error("Prepared runtime auth candidates are temporarily unavailable."); + continue; + } + try { + let model = await params.materializeModel({ + plan: attempt.plan, + model: params.model, + }); + if ( + attempt.kind === "profile" && + !preparedAgentRuntimeProfileAttemptHasCandidate({ + attempt, + store: params.store, + modelId: params.modelId, + }) + ) { + throw new Error("Prepared runtime auth candidates are temporarily unavailable."); + } + // Direct fallback unlocks only after credential resolution really ran; + // cooldown skips and route-materialization failures do not count. + const resolution = params.resolveAuth({ attempt, model }); + priorProfileAttempted ||= attempt.kind === "profile"; + const resolved = await resolution; + if (resolved.plan.forwardedAuthProfileId !== attempt.plan.forwardedAuthProfileId) { + model = await params.materializeModel({ + plan: resolved.plan, + model, + forceResolve: true, + }); + } + // Model, physical route, and credential become active together. + return { model, plan: resolved.plan, auth: resolved.auth }; + } catch (error) { + firstError ??= error; + } + } + throw toErrorObject(firstError, params.errorMessage); +} + +function scopeAuthStoreToPreparedCandidates( + store: AuthProfileStore, + profileIds: readonly string[], +): AuthProfileStore { + const profileIdSet = new Set(profileIds); + const profiles: AuthProfileStore["profiles"] = {}; + for (const profileId of profileIds) { + const profile = store.profiles[profileId]; + if (profile) { + profiles[profileId] = profile; + } + } + const order = store.order + ? Object.fromEntries( + Object.entries(store.order).map(([provider, ids]) => [ + provider, + ids.filter((profileId) => profileIdSet.has(profileId)), + ]), + ) + : undefined; + const lastGood = store.lastGood + ? Object.fromEntries( + Object.entries(store.lastGood).filter(([, profileId]) => profileIdSet.has(profileId)), + ) + : undefined; + const usageStats = store.usageStats + ? Object.fromEntries( + Object.entries(store.usageStats).filter(([profileId]) => profileIdSet.has(profileId)), + ) + : undefined; + const runtimePersistedProfileIds = store.runtimePersistedProfileIds?.filter((profileId) => + profileIdSet.has(profileId), + ); + const runtimeExternalProfileIds = store.runtimeExternalProfileIds?.filter((profileId) => + profileIdSet.has(profileId), + ); + return { + version: store.version, + profiles, + ...(order ? { order } : {}), + ...(lastGood ? { lastGood } : {}), + ...(usageStats ? { usageStats } : {}), + ...(runtimePersistedProfileIds ? { runtimePersistedProfileIds } : {}), + ...(runtimeExternalProfileIds || store.runtimeExternalProfileIdsAuthoritative === true + ? { + runtimeExternalProfileIds: runtimeExternalProfileIds ?? [], + ...(store.runtimeExternalProfileIdsAuthoritative === true + ? { runtimeExternalProfileIdsAuthoritative: true } + : {}), + } + : {}), + }; +} + +/** Restricts a native auth consumer to the profiles selected for one physical route. */ +export function scopeAuthProfileStoreToPreparedPlan( + store: AuthProfileStore, + plan: AgentRuntimeAuthPlan, +): AuthProfileStore { + const profileIds = + plan.modelRoute?.authRequirement === "api-key" + ? [] + : [plan.forwardedAuthProfileId, ...(plan.forwardedAuthProfileCandidateIds ?? [])].filter( + (profileId, index, values): profileId is string => { + return Boolean(profileId?.trim()) && values.indexOf(profileId) === index; + }, + ); + return scopeAuthStoreToPreparedCandidates(store, profileIds); +} + +function applyResolvedAuthToPlan(params: { + plan: AgentRuntimeAuthPlan; + auth: Awaited>; + candidates: string[]; +}): AgentRuntimeAuthPlan { + const profileId = params.auth.profileId?.trim(); + if (!profileId) { + return { + ...params.plan, + forwardedAuthProfileId: undefined, + forwardedAuthProfileSource: undefined, + forwardedAuthProfileCandidateIds: undefined, + selectedAuthMode: params.auth.mode, + }; + } + const resolvedIndex = params.candidates.indexOf(profileId); + const remainingCandidates = + resolvedIndex >= 0 ? params.candidates.slice(resolvedIndex) : [profileId]; + const source = params.plan.forwardedAuthProfileId + ? params.plan.forwardedAuthProfileSource + : "auto"; + return { + ...params.plan, + forwardedAuthProfileId: profileId, + forwardedAuthProfileSource: source, + forwardedAuthProfileCandidateIds: source === "auto" ? remainingCandidates : [profileId], + selectedAuthMode: params.auth.mode, + }; +} + +function assertResolvedAuthMatchesPreparedRoute(params: { + plan: AgentRuntimeAuthPlan; + auth: Awaited>; +}): void { + const route = params.plan.modelRoute; + if ( + !route || + providerModelRouteAcceptsAuthMode({ + requirement: route.authRequirement, + mode: params.auth.mode, + }) + ) { + return; + } + throw new Error( + `Resolved ${params.auth.mode} credentials are incompatible with the selected ${route.authRequirement} route for ${route.provider}.`, + ); +} + +/** Resolves prepared same-route candidates without pinning the first unresolved profile. */ +export async function resolvePreparedRuntimeModelAuth( + params: Omit[0], "profileId"> & { + plan: AgentRuntimeAuthPlan; + }, +): Promise { + const { plan, ...authParams } = params; + const candidates = [ + plan.forwardedAuthProfileId, + ...(plan.forwardedAuthProfileCandidateIds ?? []), + ].filter((profileId, index, values): profileId is string => { + return Boolean(profileId?.trim()) && values.indexOf(profileId) === index; + }); + if (candidates.length === 0) { + // The planner selected direct auth. Resolve only env/config material so an + // unrelated full store cannot replace or pre-reject that immutable source. + const auth = await getApiKeyForModel({ + ...authParams, + store: { version: 1, profiles: {} }, + lockedProfile: false, + allowAuthProfileFallback: false, + skipSetupProviderFallback: plan.modelRoute?.provider === "openai", + }); + assertResolvedAuthMatchesPreparedRoute({ plan, auth }); + return { auth, plan: applyResolvedAuthToPlan({ plan, auth, candidates }) }; + } + if (plan.forwardedAuthProfileSource !== "auto") { + const auth = await getApiKeyForModel({ + ...authParams, + profileId: plan.forwardedAuthProfileId, + lockedProfile: Boolean(plan.forwardedAuthProfileId), + }); + assertResolvedAuthMatchesPreparedRoute({ plan, auth }); + return { auth, plan: applyResolvedAuthToPlan({ plan, auth, candidates }) }; + } + + // Prepared automatic candidates remain exhaustive, but their cooldown state + // can change while work waits in a command lane. Recheck at credential use. + const store = params.store; + const currentCandidates = store + ? candidates.filter( + (profileId) => !isProfileInCooldown(store, profileId, undefined, params.model.id), + ) + : candidates; + if (currentCandidates.length === 0) { + throw new Error("Prepared runtime auth candidates are temporarily unavailable."); + } + const candidateStore = store + ? scopeAuthStoreToPreparedCandidates(store, currentCandidates) + : undefined; + + let firstError: unknown; + for (const profileId of currentCandidates) { + try { + const auth = await getApiKeyForModel({ + ...authParams, + profileId, + // This loop owns fallback order. Pin each lookup so the generic auth + // resolver cannot rescan or skip across the prepared candidate set. + lockedProfile: true, + ...(candidateStore ? { store: candidateStore } : {}), + }); + assertResolvedAuthMatchesPreparedRoute({ plan, auth }); + return { + auth, + plan: applyResolvedAuthToPlan({ plan, auth, candidates: currentCandidates }), + }; + } catch (error) { + firstError ??= error; + } + } + throw toErrorObject(firstError, "Prepared runtime auth candidates could not be resolved."); +} diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index 75abd38f6765..cc55838ced1c 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -4,6 +4,12 @@ * observability decisions shared across embedded-agent hot paths. */ import type { TSchema } from "typebox"; +import type { + ModelApi, + ProviderModelRouteRuntimePolicy, + ProviderRouteOverridePresence, +} from "../../plugin-sdk/provider-model-types.js"; +import type { AuthProfileStore } from "../auth-profiles/types.js"; import type { AgentTool } from "../runtime/index.js"; /** Runtime transport selected for one model attempt. */ @@ -381,13 +387,43 @@ export type AgentRuntimeResolvedRef = { transport?: AgentRuntimeTransport; }; +/** Concrete provider-owned route selected for one runtime attempt. */ +export type AgentRuntimeAuthModelRoute = { + provider: string; + modelId: string; + api: ModelApi; + baseUrl: string; + authRequirement: "api-key" | "subscription"; + /** Secret-free request behavior that the selected runtime must reproduce. */ + requestTransportOverrides: ProviderRouteOverridePresence; + /** Provider-owned native-runtime compatibility for this concrete route. */ + runtimePolicy?: ProviderModelRouteRuntimePolicy; +}; + +/** Common native-runtime support proven across every route left to the harness. */ +export type AgentRuntimeAuthDeferredRouteSupport = { + requestTransportOverrides: ProviderRouteOverridePresence; + runtimePolicy: ProviderModelRouteRuntimePolicy; +}; + /** Auth forwarding decision for one runtime attempt. */ export type AgentRuntimeAuthPlan = { providerForAuth: string; + /** Model whose order, cooldown, and route facts produced this plan. */ + modelId?: string; authProfileProviderForAuth: string; harnessAuthProvider?: string; + /** Preferred or user-locked profile; automatic selection may not have resolved its secret yet. */ forwardedAuthProfileId?: string; + forwardedAuthProfileSource?: "auto" | "user"; + /** Ordered exhaustive candidates for the selected route; a singleton is terminal. */ forwardedAuthProfileCandidateIds?: string[]; + /** Exact selected credential/config mode; secret-free route materialization input. */ + selectedAuthMode?: string; + /** Concrete provider-owned route selected before runtime dispatch. */ + modelRoute?: AgentRuntimeAuthModelRoute; + /** Secret-free support shared by every route deferred to harness-owned auth. */ + deferredRouteSupport?: AgentRuntimeAuthDeferredRouteSupport; }; /** Prompt transforms and provider contribution hooks for one runtime attempt. */ @@ -521,10 +557,15 @@ export type BuildAgentRuntimePlanParams = { harnessId?: string; harnessRuntime?: string; allowHarnessAuthProfileForwarding?: boolean; + /** Canonical route/auth decision prepared before attempt orchestration. */ + preparedAuthPlan?: AgentRuntimeAuthPlan; authProfileProvider?: string; authProfileMode?: string; sessionAuthProfileId?: string; + sessionAuthProfileSource?: "auto" | "user"; sessionAuthProfileCandidateIds?: string[]; + authProfileStore?: AuthProfileStore; + modelRoute?: AgentRuntimeAuthModelRoute; agentId?: string; thinkingLevel?: AgentRuntimeThinkLevel; extraParamsOverride?: Record; diff --git a/src/agents/thinking-runtime.test.ts b/src/agents/thinking-runtime.test.ts index ee8c0a3776df..05d3294b3484 100644 --- a/src/agents/thinking-runtime.test.ts +++ b/src/agents/thinking-runtime.test.ts @@ -65,7 +65,7 @@ describe("resolveEffectiveAgentRuntime", () => { ).toBe("openclaw"); }); - it("resolves residual auto through a registered Codex harness", () => { + it("keeps an authored custom route on OpenClaw before registered harness selection", () => { const supports = vi.fn(({ provider }) => provider === "openai" ? { supported: true, priority: 100 } : { supported: false }, ); @@ -94,13 +94,8 @@ describe("resolveEffectiveAgentRuntime", () => { provider: "openai", modelId: "gpt-5.6-luna", }), - ).toBe("codex"); - expect(supports).toHaveBeenCalledWith( - expect.not.objectContaining({ - providerOwnerStatus: expect.anything(), - providerOwnerPluginIds: expect.anything(), - }), - ); + ).toBe("openclaw"); + expect(supports).not.toHaveBeenCalled(); }); it("prefers explicit session overrides and treats legacy harness ids as observational", () => { diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts index e7f1e2ebc7ed..f32c86e81954 100644 --- a/src/agents/tools-effective-inventory.test.ts +++ b/src/agents/tools-effective-inventory.test.ts @@ -563,9 +563,11 @@ describe("resolveEffectiveToolInventory", () => { ); expect(effectiveInventoryState.normalizeTransportMock).toHaveBeenCalledWith( expect.objectContaining({ + modelId: "gpt-test", workspaceDir: "/tmp/workspace-main", context: expect.objectContaining({ config: expect.any(Object), + modelId: "gpt-test", workspaceDir: "/tmp/workspace-main", provider: "openai", api: "openai-completions", diff --git a/src/agents/tools-effective-inventory.ts b/src/agents/tools-effective-inventory.ts index 968aeca490cc..3cde0dcb1f62 100644 --- a/src/agents/tools-effective-inventory.ts +++ b/src/agents/tools-effective-inventory.ts @@ -116,12 +116,14 @@ function applyProviderTransportNormalization(params: { }): ProviderRuntimeModel { const normalized = normalizeProviderTransportWithPlugin({ provider: params.provider, + modelId: params.runtimeModel.id, config: params.cfg, workspaceDir: params.workspaceDir, context: { config: params.cfg, workspaceDir: params.workspaceDir, provider: params.provider, + modelId: params.runtimeModel.id, api: params.runtimeModel.api, baseUrl: params.runtimeModel.baseUrl, }, diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index 027a64d81237..2cbcd1d50e8e 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -574,6 +574,7 @@ describe("createPdfTool", () => { expect(modelsAgentDir).toBe(agentDir); expect(modelsOptions).toEqual({ workspaceDir }); expect(modelDiscovery.discoverModels).toHaveBeenCalledWith(expect.anything(), agentDir, { + config: modelsConfigArg, workspaceDir, }); expect(extractSpy).not.toHaveBeenCalled(); diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index 8bd15e7ad44f..7dfcc3a25db6 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -163,7 +163,10 @@ async function runPdfPrompt(params: { const modelsOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : undefined; await ensureOpenClawModelsJson(effectiveCfg, params.agentDir, modelsOptions); const authStorage = discoverAuthStorage(params.agentDir); - const modelRegistry = discoverModels(authStorage, params.agentDir, modelsOptions); + const modelRegistry = discoverModels(authStorage, params.agentDir, { + config: effectiveCfg, + ...modelsOptions, + }); let extractionCache: PdfExtractedContent[] | null = null; const getExtractions = async (): Promise => { diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index 6739dd9636d2..d9e0f1c1b019 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -132,6 +132,7 @@ type CompactEmbeddedAgentSessionParams = { agentId?: string; agentHarnessId?: string; authProfileId?: string; + authProfileIdSource?: "auto" | "user"; contextTokenBudget?: number; sessionKey?: string; sandboxSessionKey?: string; @@ -1524,38 +1525,43 @@ describe("runMemoryFlushIfNeeded", () => { expect(incrementCompactionCountMock).not.toHaveBeenCalled(); }); - it("passes resolved context budget and auth profile to preflight compaction", async () => { - const sessionFile = path.join(rootDir, "budget-session.jsonl"); - const sessionEntry: SessionEntry = { - sessionId: "session", - sessionFile, - updatedAt: Date.now(), - totalTokens: 245_000, - totalTokensFresh: true, - compactionCount: 0, - }; + it.each(["user", "auto"] as const)( + "passes resolved context budget and $authProfileIdSource auth profile to preflight compaction", + async (authProfileIdSource) => { + const sessionFile = path.join(rootDir, "budget-session.jsonl"); + const sessionEntry: SessionEntry = { + sessionId: "session", + sessionFile, + updatedAt: Date.now(), + totalTokens: 245_000, + totalTokensFresh: true, + compactionCount: 0, + }; - await runPreflightCompactionIfNeeded({ - cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, - followupRun: createTestFollowupRun({ - authProfileId: "anthropic:claude@martian.engineering", - provider: "anthropic", - model: "claude-opus-4-6", + await runPreflightCompactionIfNeeded({ + cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, + followupRun: createTestFollowupRun({ + authProfileId: "anthropic:claude@martian.engineering", + authProfileIdSource, + provider: "anthropic", + model: "claude-opus-4-6", + sessionKey: "agent:main:main", + }), + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 258_000, + sessionEntry, + sessionStore: { "agent:main:main": sessionEntry }, sessionKey: "agent:main:main", - }), - defaultModel: "anthropic/claude-opus-4-6", - agentCfgContextTokens: 258_000, - sessionEntry, - sessionStore: { "agent:main:main": sessionEntry }, - sessionKey: "agent:main:main", - isHeartbeat: false, - replyOperation: createReplyOperation(), - }); + isHeartbeat: false, + replyOperation: createReplyOperation(), + }); - const compactCall = requireCompactEmbeddedAgentSessionCall(); - expect(compactCall.authProfileId).toBe("anthropic:claude@martian.engineering"); - expect(compactCall.contextTokenBudget).toBe(258_000); - }); + const compactCall = requireCompactEmbeddedAgentSessionCall(); + expect(compactCall.authProfileId).toBe("anthropic:claude@martian.engineering"); + expect(compactCall.authProfileIdSource).toBe(authProfileIdSource); + expect(compactCall.contextTokenBudget).toBe(258_000); + }, + ); it("preflight compacts a fresh session when the current prompt estimate pushes the next request over budget", async () => { registerMemoryFlushPlanResolverForTest(() => ({ softThresholdTokens: 0, diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 80a880c81920..b0a4dc050ed8 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -968,6 +968,7 @@ export async function runPreflightCompactionIfNeeded(params: { provider: params.followupRun.run.provider, model: params.followupRun.run.model, authProfileId: params.followupRun.run.authProfileId, + authProfileIdSource: params.followupRun.run.authProfileIdSource, agentHarnessId: entry.sessionId === params.followupRun.run.sessionId ? entry.modelSelectionLocked === true diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts index 32b6a88bece7..e6ce5b813c62 100644 --- a/src/auto-reply/reply/commands-compact.test.ts +++ b/src/auto-reply/reply/commands-compact.test.ts @@ -197,6 +197,7 @@ describe("handleCompactCommand", () => { expect(call.senderE164).toBe("+15551234567"); expect(call.agentDir).toBe("/tmp/openclaw-agent-compact"); expect(call.authProfileId).toBe("github-copilot:work"); + expect(call.authProfileIdSource).toBe("user"); expect(vi.mocked(abortEmbeddedAgentRun)).not.toHaveBeenCalled(); expect(vi.mocked(waitForEmbeddedAgentRunEnd)).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index a89cee804c7f..f79137eff75b 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -272,6 +272,13 @@ export const handleCompactCommand: CommandHandler = async (params) => { provider: params.provider, model: params.model, authProfileId: targetSessionEntry.authProfileOverride, + authProfileIdSource: + targetSessionEntry.authProfileOverrideSource ?? + (targetSessionEntry.authProfileOverride + ? typeof targetSessionEntry.authProfileOverrideCompactionCount === "number" + ? "auto" + : "user" + : undefined), contextTokenBudget, agentHarnessId: targetSessionEntry.modelSelectionLocked === true diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index 298c120d2ac8..d0f6ea703d5e 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -22,10 +22,51 @@ const modelProviderAuthMocks = vi.hoisted(() => { const state = { authenticatedProviders: new Set(["anthropic", "google", "openai"]), createProviderAuthChecker: vi.fn(), + selectedRoute: undefined as + | { + api: "openai-responses" | "openai-chatgpt-responses"; + baseUrl: string; + authRequirement: "api-key" | "subscription"; + requestTransportOverrides: "none" | "present"; + } + | undefined, }; - state.createProviderAuthChecker.mockImplementation( - () => (provider: string) => state.authenticatedProviders.has(provider), - ); + state.createProviderAuthChecker.mockImplementation(() => { + type AuthRef = { + api?: string | null; + baseUrl?: unknown; + observedRoutes?: readonly { api?: string | null; baseUrl?: unknown }[]; + }; + const hasConflictingRoute = (ref?: AuthRef) => { + const routes = ref?.observedRoutes ?? []; + return [ref, ...routes].some( + (route) => + route?.api === "openai-chatgpt-responses" && + route.baseUrl === "https://api.openai.com/v1", + ); + }; + const checker = vi.fn((provider: string, ref?: AuthRef) => { + return state.authenticatedProviders.has(provider) && !hasConflictingRoute(ref); + }); + return Object.assign(checker, { + evaluateModelAuth: vi.fn(async (provider: string, ref?: AuthRef) => { + const incompatible = hasConflictingRoute(ref); + return { + availability: checker(provider, ref), + routeResolution: incompatible + ? { + kind: "incompatible" as const, + code: "conflicting-route-facts", + message: "Conflicting OpenAI route facts.", + } + : state.selectedRoute + ? { kind: "routes" as const, routes: [state.selectedRoute] as const } + : null, + ...(state.selectedRoute ? { selectedRoute: state.selectedRoute } : {}), + }; + }), + }); + }); return state; }); const normalizeProviderModelIdWithRuntimeMock = vi.hoisted(() => vi.fn()); @@ -73,6 +114,10 @@ function setFastModelsCliBackendDeps(): void { vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: modelCatalogMocks.loadModelCatalog, + loadModelCatalogSnapshot: async (...args: unknown[]) => { + const entries = await modelCatalogMocks.loadModelCatalog(...args); + return { entries, routeVariants: entries }; + }, })); vi.mock("../../agents/model-auth-label.js", () => ({ @@ -175,6 +220,7 @@ beforeEach(() => { normalizeProviderModelIdWithRuntimeMock.mockReset(); pluginMetadataMocks.snapshot = undefined; modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "google", "openai"]); + modelProviderAuthMocks.selectedRoute = undefined; modelProviderAuthMocks.createProviderAuthChecker.mockClear(); const registry = createTestRegistry([ ...textSurfaceModelsTestPlugins, @@ -359,6 +405,73 @@ describe("handleModelsCommand", () => { expect(allListResult?.reply?.text).toContain("- openai/gpt-4.1-mini"); }); + it("does not offer an OpenAI row with a conflicting API and endpoint", async () => { + modelCatalogMocks.loadModelCatalog.mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + }, + ]); + + const data = await buildModelsProviderData({ + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + } as OpenClawConfig); + + expect(data.byProvider.has("openai")).toBe(false); + const checker = modelProviderAuthMocks.createProviderAuthChecker.mock.results.at(-1)?.value; + expect(checker.evaluateModelAuth).toHaveBeenCalledWith( + "openai", + expect.objectContaining({ + modelId: "gpt-5.5", + observedRoutes: [ + expect.objectContaining({ + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + }), + ], + }), + ); + }); + + it("uses the selected route's logical model name", async () => { + modelProviderAuthMocks.selectedRoute = { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + }; + modelCatalogMocks.loadModelCatalog.mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "Platform GPT-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + { + provider: "openai", + id: "gpt-5.5", + name: "ChatGPT GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + ]); + + const data = await buildModelsProviderData( + { + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + } as OpenClawConfig, + undefined, + { view: "all" }, + ); + + expect(data.byProvider.get("openai")).toEqual(new Set(["gpt-5.5"])); + expect(data.modelNames.get("openai/gpt-5.5")).toBe("ChatGPT GPT-5.5"); + }); + it("shows plugin-normalized allowlist models in browse data", async () => { normalizeProviderModelIdWithRuntimeMock.mockImplementation(({ provider, context }) => { if ( diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index a4cc2b665829..4e2e18216063 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -12,11 +12,16 @@ import { import { listCliRuntimeModelBackendBindings } from "../../agents/cli-backends.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; -import { loadModelCatalogForBrowse } from "../../agents/model-catalog-browse.js"; -import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js"; -import { loadModelCatalog } from "../../agents/model-catalog.js"; +import { loadModelCatalogSnapshotForBrowse } from "../../agents/model-catalog-browse.js"; +import { + resolveLogicalModelCatalogEntryState, + resolveLogicalVisibleModelCatalog, + type ModelCatalogAuthChecker, +} from "../../agents/model-catalog-visibility.js"; +import { loadModelCatalogSnapshot } from "../../agents/model-catalog.js"; import { isRetiredModelPickerProvider } from "../../agents/model-picker-visibility.js"; import { createProviderAuthChecker } from "../../agents/model-provider-auth.js"; +import { modelCatalogLogicalKey } from "../../agents/model-selection-shared.js"; import { buildModelAliasIndex, normalizeProviderId, @@ -28,6 +33,7 @@ import { RUNTIME_MODEL_VISIBILITY_NORMALIZATION, createModelVisibilityPolicy, } from "../../agents/model-visibility-policy.js"; +import { openAIModelCatalogRoutePolicy } from "../../agents/openai-model-routes.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; @@ -168,11 +174,13 @@ export async function buildModelsProviderData( listCliRuntimeModelBackendBindings().map((binding) => normalizeProviderId(binding.runtime)), ); - const catalog = await loadModelCatalogForBrowse({ + const snapshot = await loadModelCatalogSnapshotForBrowse({ cfg, view: options.view ?? "default", - loadCatalog: ({ readOnly }) => loadModelCatalog({ config: cfg, readOnly, metadataSnapshot }), + loadCatalog: ({ readOnly }) => + loadModelCatalogSnapshot({ config: cfg, readOnly, metadataSnapshot }), }); + const catalog = snapshot.entries; const visibilityPolicy = createModelVisibilityPolicy({ cfg, catalog, @@ -181,18 +189,21 @@ export async function buildModelsProviderData( agentId, ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }); - const hasAuth: (provider: string) => Promise = - options.view === "all" - ? async () => true - : createProviderAuthChecker({ - cfg, - workspaceDir, - agentId, - allowPluginSyntheticAuth: false, - discoverExternalCliAuth: false, - allowPreparedRuntimeAuth: true, - }); - const visibleCatalog = await resolveVisibleModelCatalog({ + const authChecker = createProviderAuthChecker({ + cfg, + workspaceDir, + agentId, + allowPluginSyntheticAuth: false, + discoverExternalCliAuth: false, + allowPreparedRuntimeAuth: true, + }); + const logicalModelKey = (entry: { provider: string; id: string }) => + openAIModelCatalogRoutePolicy.resolveIdentity(entry)?.key ?? modelCatalogLogicalKey(entry); + // Configured/default rows may remain visible without auth, but must not + // reintroduce a model that its provider route contract rejected. + const incompatibleModelKeys = new Set(); + const hasAuth: ModelCatalogAuthChecker = options.view === "all" ? async () => true : authChecker; + const visibleCatalog = await resolveLogicalVisibleModelCatalog({ cfg, catalog, defaultProvider: resolvedDefault.provider, @@ -200,8 +211,27 @@ export async function buildModelsProviderData( agentId, workspaceDir, view: options.view, - runtimeAuthDiscovery: false, - providerAuthChecker: hasAuth, + routePolicy: openAIModelCatalogRoutePolicy, + routeVariants: snapshot.routeVariants, + evaluateEntry: async (entry, routeVariants) => { + const identity = openAIModelCatalogRoutePolicy.resolveIdentity(entry); + const evaluation = await authChecker.evaluateModelAuth(entry.provider, { + modelId: identity?.id ?? entry.id, + observedRoutes: routeVariants.map((variant) => ({ + api: variant.api, + baseUrl: variant.baseUrl, + })), + }); + if (evaluation.routeResolution?.kind === "incompatible") { + incompatibleModelKeys.add(logicalModelKey(entry)); + } + return resolveLogicalModelCatalogEntryState({ + entry, + evaluation, + authBacked: options.view === "all" || evaluation.availability === true, + routePolicy: openAIModelCatalogRoutePolicy, + }); + }, }); const aliasIndex = buildModelAliasIndex({ @@ -250,6 +280,13 @@ export async function buildModelsProviderData( if (!resolved) { return; } + if ( + incompatibleModelKeys.has( + logicalModelKey({ provider: resolved.ref.provider, id: resolved.ref.model }), + ) + ) { + return; + } add(resolved.ref.provider, resolved.ref.model); }; @@ -276,13 +313,20 @@ export async function buildModelsProviderData( }; for (const entry of visibleCatalog) { + if (incompatibleModelKeys.has(logicalModelKey(entry))) { + continue; + } add(entry.provider, entry.id); } for (const entry of catalog) { if ( usesUnfilteredCatalogModels(entry.provider, cliRuntimeProviders) && - (await hasAuth(entry.provider)) + (await hasAuth(entry.provider, { + modelId: entry.id, + api: entry.api, + baseUrl: entry.baseUrl, + })) ) { add(entry.provider, entry.id); } @@ -292,7 +336,13 @@ export async function buildModelsProviderData( addRawModelRef(raw); } - add(resolvedDefault.provider, resolvedDefault.model); + if ( + !incompatibleModelKeys.has( + logicalModelKey({ provider: resolvedDefault.provider, id: resolvedDefault.model }), + ) + ) { + add(resolvedDefault.provider, resolvedDefault.model); + } addModelConfigEntries(); const providers = [...byProvider.keys()].toSorted(); diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 114dbb06b11d..a76e38b6c70d 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -62,6 +62,7 @@ vi.mock("../../agents/auth-profiles.js", () => { }), ensureAuthProfileStore: store, ensureAuthProfileStoreWithoutExternalProfiles: store, + getRuntimeAuthProfileStoreSnapshot: store, isProfileInCooldown: () => false, listProfilesForProvider: (_store: unknown, provider: string) => Object.entries(authProfilesStoreMock.profiles) @@ -206,6 +207,8 @@ vi.mock("../../agents/model-auth.js", () => { provider: string; env?: NodeJS.ProcessEnv; }) => provider === "anthropic" && hasWorkspaceCredential(env), + hasSyntheticLocalProviderAuthConfig: () => false, + resolveProviderEntryApiKeyProfileReference: () => ({ kind: "none" }), resolveAuthProfileOrder: ({ provider }: { provider: string }) => Object.entries(authProfilesStoreMock.profiles) .filter(([, profile]) => profile.provider === provider) @@ -223,10 +226,12 @@ vi.mock("../../agents/model-auth.js", () => { return null; }, resolveUsableCustomProviderApiKey: () => null, + shouldPreferExplicitConfigApiKeyAuth: () => false, }; }); vi.mock("../../agents/provider-auth-aliases.js", () => ({ + resolveProviderAuthAliasMap: () => ({}), resolveProviderIdForAuth: (provider: string) => provider, })); @@ -327,12 +332,19 @@ vi.mock("../../agents/agent-scope.js", () => ({ resolveSessionAgentId: vi.fn(() => "main"), })); -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ +vi.mock("../../agents/model-catalog.js", () => { + const loadModelCatalog = vi.fn(async () => [ { provider: "anthropic", id: "claude-opus-4-6", name: "Claude Opus" }, { provider: "localai", id: "ultra-chat", name: "Ultra Chat" }, - ]), -})); + ]); + return { + loadModelCatalog, + loadModelCatalogSnapshot: async () => { + const entries = await loadModelCatalog(); + return { entries, routeVariants: entries }; + }, + }; +}); vi.mock("../../agents/sandbox.js", () => ({ resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })), diff --git a/src/commands/auth-choice.model-check.test.ts b/src/commands/auth-choice.model-check.test.ts index 4c0b64319028..d0baae8cc7ac 100644 --- a/src/commands/auth-choice.model-check.test.ts +++ b/src/commands/auth-choice.model-check.test.ts @@ -2,38 +2,61 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../agents/auth-profiles.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { warnIfModelConfigLooksOff } from "./auth-choice.model-check.js"; +import { + resolveDefaultModelAuthStatus, + warnIfModelConfigLooksOff, +} from "./auth-choice.model-check.js"; import { makePrompter } from "./setup/__tests__/test-utils.js"; const loadModelCatalog = vi.hoisted(() => vi.fn()); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog, +const modelCatalogMocks = vi.hoisted(() => ({ + routeVariants: undefined as unknown[] | undefined, })); +vi.mock("../agents/model-catalog.js", () => ({ + loadModelCatalogSnapshot: async (...args: unknown[]) => { + const entries = await loadModelCatalog(...args); + return { entries, routeVariants: modelCatalogMocks.routeVariants ?? entries }; + }, +})); + +const openAIRouteMocks = vi.hoisted(() => ({ + override: undefined as ((params: unknown) => unknown) | undefined, +})); +vi.mock("../agents/openai-model-routes.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveOpenAIModelRoutes: (params: Parameters[0]) => + openAIRouteMocks.override + ? openAIRouteMocks.override(params) + : actual.resolveOpenAIModelRoutes(params), + createOpenAIModelRoutesResolver: ( + params: Parameters[0], + ) => { + const resolveRoutes = actual.createOpenAIModelRoutesResolver(params); + return (ref: Parameters>[0]) => + openAIRouteMocks.override ? openAIRouteMocks.override(ref) : resolveRoutes(ref); + }, + }; +}); const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ version: 1, profiles: {} }))); -const listProfilesForProvider = vi.hoisted(() => - vi.fn<(store: AuthProfileStore, provider: string) => string[]>(() => []), -); -vi.mock("../agents/auth-profiles.js", () => ({ +vi.mock("../agents/auth-profiles.js", async (importOriginal) => ({ + ...(await importOriginal()), ensureAuthProfileStore, - listProfilesForProvider, -})); - -const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined)); -const hasUsableCustomProviderApiKey = vi.hoisted(() => vi.fn(() => false)); -vi.mock("../agents/model-auth.js", () => ({ - resolveEnvApiKey, - hasUsableCustomProviderApiKey, })); describe("warnIfModelConfigLooksOff", () => { beforeEach(() => { vi.clearAllMocks(); loadModelCatalog.mockResolvedValue([]); + modelCatalogMocks.routeVariants = undefined; + ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} }); + openAIRouteMocks.override = undefined; }); it("skips catalog validation when requested while keeping auth checks", async () => { - const note = vi.fn(async () => {}); + const note = vi.fn(async (_message: string) => {}); const prompter = makePrompter({ note }); const config = { agents: { @@ -47,16 +70,34 @@ describe("warnIfModelConfigLooksOff", () => { expect(loadModelCatalog).not.toHaveBeenCalled(); expect(ensureAuthProfileStore).toHaveBeenCalledOnce(); - expect(listProfilesForProvider).toHaveBeenCalledOnce(); - expect(listProfilesForProvider).toHaveBeenCalledWith({ version: 1, profiles: {} }, "openai"); + expect(ensureAuthProfileStore).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + allowKeychainPrompt: false, + externalCliProviderIds: ["openai"], + readOnly: true, + }), + ); expect(note).toHaveBeenCalledWith( 'No auth configured for provider "openai". The agent may fail until credentials are added. Run `openclaw models auth login --provider openai`, `openclaw configure`, or set an API key env var.', "Model check", ); }); + it("reports missing auth for generic providers without credential evidence", () => { + const config = { + agents: { defaults: { model: "anthropic/claude-sonnet-4-6" } }, + } as OpenClawConfig; + + expect(resolveDefaultModelAuthStatus(config, { env: {} })).toMatchObject({ + provider: "anthropic", + status: "missing", + hasAuth: false, + }); + }); + it("accepts Codex OAuth profiles for canonical OpenAI models using the Codex runtime", async () => { - const note = vi.fn(async () => {}); + const note = vi.fn(async (_message: string) => {}); const prompter = makePrompter({ note }); const store = { version: 1, @@ -71,9 +112,6 @@ describe("warnIfModelConfigLooksOff", () => { }, } satisfies AuthProfileStore; ensureAuthProfileStore.mockReturnValue(store); - listProfilesForProvider.mockImplementation((_store, provider) => - provider === "openai" ? ["openai:default"] : [], - ); const config = { agents: { defaults: { @@ -87,13 +125,10 @@ describe("warnIfModelConfigLooksOff", () => { await warnIfModelConfigLooksOff(config, prompter, { validateCatalog: false }); expect(note).not.toHaveBeenCalled(); - expect(listProfilesForProvider).toHaveBeenCalledWith(store, "openai"); - expect(resolveEnvApiKey).not.toHaveBeenCalled(); - expect(hasUsableCustomProviderApiKey).not.toHaveBeenCalled(); }); it("keeps custom OpenAI-compatible provider auth separate from Codex OAuth profiles", async () => { - const note = vi.fn(async () => {}); + const note = vi.fn(async (_message: string, _title?: string) => {}); const prompter = makePrompter({ note }); const store = { version: 1, @@ -108,9 +143,6 @@ describe("warnIfModelConfigLooksOff", () => { }, } satisfies AuthProfileStore; ensureAuthProfileStore.mockReturnValue(store); - listProfilesForProvider.mockImplementation((_store, provider) => - provider === "openai" ? ["openai:default"] : [], - ); const config = { agents: { defaults: { @@ -131,7 +163,6 @@ describe("warnIfModelConfigLooksOff", () => { await warnIfModelConfigLooksOff(config, prompter, { validateCatalog: false }); - expect(listProfilesForProvider).toHaveBeenCalledWith(store, "openai"); expect(note).toHaveBeenCalledWith( 'No auth configured for provider "openai". The agent may fail until credentials are added. Run `openclaw models auth login --provider openai`, `openclaw configure`, or set an API key env var.', "Model check", @@ -156,4 +187,211 @@ describe("warnIfModelConfigLooksOff", () => { useCache: false, }); }); + + it("accepts subscription auth but not key sources for gpt-5.3-codex-spark", async () => { + const config = { + agents: { defaults: { model: "openai/gpt-5.3-codex-spark" } }, + } as OpenClawConfig; + expect( + resolveDefaultModelAuthStatus(config, { env: { OPENAI_API_KEY: "api-key" } }), + ).toMatchObject({ + status: "missing", + hasAuth: false, + authRequirement: "subscription", + }); + + const note = vi.fn(async (_message: string) => {}); + await warnIfModelConfigLooksOff(config, makePrompter({ note }), { + validateCatalog: false, + env: { OPENAI_API_KEY: "api-key" }, + }); + const warning = note.mock.calls.flatMap(([message]) => message).join("\n"); + expect(warning).toContain("openclaw models auth login --provider openai"); + expect(warning).not.toContain("set an API key env var"); + + const store = { + version: 1, + profiles: { + "openai:subscription": { + type: "oauth", + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + } satisfies AuthProfileStore; + ensureAuthProfileStore.mockReturnValue(store); + expect(resolveDefaultModelAuthStatus(config)).toMatchObject({ status: "ready", hasAuth: true }); + }); + + it("maps incompatible route facts to status and recovery wording", async () => { + const store = { + version: 1, + profiles: { + "openai:subscription": { + type: "oauth", + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + } satisfies AuthProfileStore; + ensureAuthProfileStore.mockReturnValue(store); + const config = { + agents: { defaults: { model: "openai/gpt-5.6" } }, + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [], + }, + }, + }, + } as OpenClawConfig; + + expect(resolveDefaultModelAuthStatus(config)).toMatchObject({ + status: "incompatible", + hasAuth: false, + code: "platform-only-model-on-chatgpt", + }); + const note = vi.fn(async () => {}); + await warnIfModelConfigLooksOff(config, makePrompter({ note }), { + validateCatalog: false, + }); + + expect(note).toHaveBeenCalledWith( + 'Model route is incompatible for "openai/gpt-5.6": gpt-5.6 is available only through OpenAI Platform API-key authentication.', + "Model check", + ); + }); + + it("uses selected static ChatGPT catalog facts for auth checks", async () => { + const note = vi.fn(async () => {}); + const prompter = makePrompter({ note }); + const store = { + version: 1, + profiles: { + "openai:subscription": { + type: "oauth", + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + } satisfies AuthProfileStore; + ensureAuthProfileStore.mockReturnValue(store); + loadModelCatalog.mockResolvedValue([ + { + id: "gpt-5.4-nano", + name: "GPT 5.4 Nano", + provider: "openai", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + ]); + const config = { + agents: { defaults: { model: "openai/gpt-5.4-nano" } }, + } as OpenClawConfig; + + await warnIfModelConfigLooksOff(config, prompter); + + expect(note).not.toHaveBeenCalled(); + }); + + it("matches shipped OpenAI aliases to their canonical catalog model", async () => { + const note = vi.fn(async () => {}); + loadModelCatalog.mockResolvedValue([ + { + id: "gpt-5.4", + name: "GPT 5.4", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + ]); + const config = { + agents: { defaults: { model: "openai/gpt-5.4-codex" } }, + } as OpenClawConfig; + + await warnIfModelConfigLooksOff(config, makePrompter({ note }), { + env: { OPENAI_API_KEY: "api-key" }, + }); + + expect(note).not.toHaveBeenCalled(); + }); + + it.each([ + ["Platform first", false], + ["ChatGPT first", true], + ])("uses every physical route for a logical model: %s", async (_label, chatGPTFirst) => { + const platform = { + id: "gpt-5.4-nano", + name: "GPT 5.4 Nano", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }; + const chatGPT = { + ...platform, + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + loadModelCatalog.mockResolvedValue([platform]); + modelCatalogMocks.routeVariants = chatGPTFirst ? [chatGPT, platform] : [platform, chatGPT]; + ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "openai:subscription": { + type: "oauth", + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }); + const note = vi.fn(async () => {}); + const config = { + agents: { defaults: { model: "openai/gpt-5.4-nano" } }, + } as OpenClawConfig; + + await warnIfModelConfigLooksOff(config, makePrompter({ note })); + + expect(note).not.toHaveBeenCalled(); + }); + + it("reports an unknown static transport as indeterminate instead of missing auth", async () => { + const note = vi.fn(async () => {}); + const prompter = makePrompter({ note }); + const config = { + agents: { defaults: { model: "openai/gpt-5.4-nano" } }, + } as OpenClawConfig; + + expect(resolveDefaultModelAuthStatus(config)).toMatchObject({ + status: "indeterminate", + hasAuth: false, + }); + await warnIfModelConfigLooksOff(config, prompter, { validateCatalog: false }); + + expect(note).toHaveBeenCalledWith( + 'Auth readiness could not be confirmed for "openai/gpt-5.4-nano". Verify the selected model route and credential source before continuing.', + "Model check", + ); + }); + + it("keeps OpenAI route checks indeterminate when the route artifact is unavailable", () => { + openAIRouteMocks.override = () => null; + const config = { + agents: { defaults: { model: "openai/gpt-5.5" } }, + } as OpenClawConfig; + + expect(resolveDefaultModelAuthStatus(config)).toMatchObject({ + status: "indeterminate", + hasAuth: false, + }); + }); }); diff --git a/src/commands/auth-choice.model-check.ts b/src/commands/auth-choice.model-check.ts index b18d77ffd0a1..4ec2cca7db9f 100644 --- a/src/commands/auth-choice.model-check.ts +++ b/src/commands/auth-choice.model-check.ts @@ -1,125 +1,175 @@ // Post-selection model/auth sanity checks shown during onboarding and agent setup. -import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; -import type { AuthProfileCredential } from "../agents/auth-profiles/types.js"; -import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js"; -import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; -import { loadModelCatalog } from "../agents/model-catalog.js"; +import { normalizeProviderIdForAuth } from "@openclaw/model-catalog-core/provider-id"; +import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; +import { createModelAuthAvailabilityResolver } from "../agents/model-auth-availability.js"; +import { loadModelCatalogSnapshot, type ModelCatalogEntry } from "../agents/model-catalog.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; -import { - listOpenAIAuthProfileProvidersForAgentRuntime, - openAIProviderUsesCodexRuntimeByDefault, -} from "../agents/openai-routing.js"; import { buildProviderAuthRecoveryHint } from "../agents/provider-auth-recovery-hint.js"; +import { canonicalizeProviderModelId } from "../agents/provider-model-route.js"; +import type { ModelApi } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ProviderModelRouteAuthRequirement } from "../plugin-sdk/provider-model-types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -function resolveAuthProviderCandidates(params: { - config: OpenClawConfig; - provider: string; - modelId: string; - agentId?: string; -}): string[] { - const harnessPolicy = resolveAgentHarnessPolicy({ - provider: params.provider, - modelId: params.modelId, - config: params.config, - agentId: params.agentId, - }); - return [ - ...new Set([ - params.provider, - ...listOpenAIAuthProfileProvidersForAgentRuntime({ - provider: params.provider, - harnessRuntime: harnessPolicy.runtime, - config: params.config, - }), - ]), - ]; -} +export type ModelRouteObservation = { + api?: ModelApi | null; + baseUrl?: unknown; +}; -function resolveAcceptedAuthProfileTypes(params: { - config: OpenClawConfig; +export type DefaultModelAuthStatus = { provider: string; -}): readonly AuthProfileCredential["type"][] | undefined { - if ( - openAIProviderUsesCodexRuntimeByDefault({ - provider: params.provider, - config: params.config, - }) - ) { - return undefined; - } - return params.provider === "openai" ? ["api_key"] : undefined; -} - -function hasProfileForProvider(params: { - store: ReturnType; - provider: string; - acceptedTypes?: readonly AuthProfileCredential["type"][]; -}): boolean { - const profileIds = listProfilesForProvider(params.store, params.provider); - if (!params.acceptedTypes) { - return profileIds.length > 0; - } - const acceptedTypes = new Set(params.acceptedTypes); - return profileIds.some((profileId) => { - const profile = params.store.profiles[profileId]; - return profile ? acceptedTypes.has(profile.type) : false; - }); -} + model: string; +} & ( + | { status: "ready"; hasAuth: true } + | { + status: "missing"; + hasAuth: false; + authRequirement?: ProviderModelRouteAuthRequirement; + } + | { status: "indeterminate"; hasAuth: false } + | { status: "incompatible"; hasAuth: false; code: string; message: string } +); /** - * Resolve the default model ref and whether any usable credentials exist for - * it (auth profiles, provider env keys, or custom provider API keys). Shared - * by the onboarding model check and the finalize hatch gating. + * Resolve the default model ref and its auth readiness. A catalog observation + * makes transport-specific auth exact; absent observations remain + * indeterminate when provider facts cannot choose one route. Shared by the + * onboarding model check and the finalize hatch gating. */ export function resolveDefaultModelAuthStatus( config: OpenClawConfig, - options?: { agentId?: string; agentDir?: string }, -): { provider: string; model: string; hasAuth: boolean } { + options?: { + agentId?: string; + agentDir?: string; + env?: NodeJS.ProcessEnv; + observedRoutes?: readonly ModelRouteObservation[]; + }, +): DefaultModelAuthStatus { const ref = resolveDefaultModelForAgent({ cfg: config, agentId: options?.agentId, }); - const store = ensureAuthProfileStore(options?.agentDir); - const authProviders = resolveAuthProviderCandidates({ + const store = ensureAuthProfileStore(options?.agentDir, { + allowKeychainPrompt: false, config, - provider: ref.provider, + ...(ref.provider === "openai" ? { externalCliProviderIds: ["openai"] } : {}), + readOnly: true, + }); + const evaluation = createModelAuthAvailabilityResolver({ + cfg: config, + authStore: store, + ...(options?.agentDir ? { agentDir: options.agentDir } : {}), + ...(options?.env ? { env: options.env } : {}), + }).evaluateModelAuth(ref.provider, { modelId: ref.model, - agentId: options?.agentId, + ...(options?.observedRoutes?.length ? { observedRoutes: options.observedRoutes } : {}), }); - const acceptedTypes = resolveAcceptedAuthProfileTypes({ - config, + if (evaluation.routeResolution?.kind === "incompatible") { + return { + provider: ref.provider, + model: ref.model, + status: "incompatible", + hasAuth: false, + code: evaluation.routeResolution.code, + message: evaluation.routeResolution.message, + }; + } + const availability = evaluation.availability; + const authRequirement = evaluation.selectedRoute?.authRequirement; + if (availability === true) { + return { provider: ref.provider, model: ref.model, status: "ready", hasAuth: true }; + } + if ( + availability === undefined && + (normalizeProviderIdForAuth(ref.provider) === "openai" || + evaluation.routeResolution !== null || + evaluation.evidence !== undefined) + ) { + return { provider: ref.provider, model: ref.model, status: "indeterminate", hasAuth: false }; + } + return { provider: ref.provider, - }); - const hasAuth = - authProviders.some((provider) => hasProfileForProvider({ store, provider, acceptedTypes })) || - authProviders.some((provider) => resolveEnvApiKey(provider)) || - authProviders.some((provider) => hasUsableCustomProviderApiKey(config, provider)); - return { provider: ref.provider, model: ref.model, hasAuth }; + model: ref.model, + status: "missing", + hasAuth: false, + ...(authRequirement ? { authRequirement } : {}), + }; +} + +function catalogRouteObservation( + entry: ModelCatalogEntry | undefined, +): ModelRouteObservation | undefined { + if (!entry) { + return undefined; + } + const baseUrl = entry.baseUrl; + if (entry.api === undefined && baseUrl === undefined) { + return undefined; + } + return { + ...(entry.api !== undefined ? { api: entry.api } : {}), + ...(baseUrl !== undefined ? { baseUrl } : {}), + }; +} + +export type DefaultModelCatalogFacts = { + found: boolean; + observedRoutes?: readonly ModelRouteObservation[]; +}; + +/** Resolve logical model identity and every physical route represented by a catalog. */ +export function resolveDefaultModelCatalogFacts( + config: OpenClawConfig, + catalog: readonly ModelCatalogEntry[], + options?: { agentId?: string; routeVariants?: readonly ModelCatalogEntry[] }, +): DefaultModelCatalogFacts { + const ref = resolveDefaultModelForAgent({ cfg: config, agentId: options?.agentId }); + const provider = normalizeProviderIdForAuth(ref.provider); + const modelId = canonicalizeProviderModelId(provider, ref.model); + const matches = (entry: ModelCatalogEntry) => + normalizeProviderIdForAuth(entry.provider) === provider && + canonicalizeProviderModelId(provider, entry.id) === modelId; + const routeVariants = options?.routeVariants ?? catalog; + const observedRoutes = routeVariants + .filter(matches) + .map(catalogRouteObservation) + .filter((route): route is ModelRouteObservation => route !== undefined); + return { + found: catalog.some(matches) || routeVariants.some(matches), + ...(observedRoutes.length > 0 ? { observedRoutes } : {}), + }; } /** Warn when the selected default model is unknown or has no usable credentials. */ export async function warnIfModelConfigLooksOff( config: OpenClawConfig, prompter: WizardPrompter, - options?: { agentId?: string; agentDir?: string; validateCatalog?: boolean }, + options?: { + agentId?: string; + agentDir?: string; + validateCatalog?: boolean; + env?: NodeJS.ProcessEnv; + observedRoutes?: readonly ModelRouteObservation[]; + }, ) { const ref = resolveDefaultModelForAgent({ cfg: config, agentId: options?.agentId, }); const warnings: string[] = []; + const snapshot = + options?.validateCatalog === false + ? { entries: [], routeVariants: [] } + : await loadModelCatalogSnapshot({ config, useCache: false }); + const catalog = snapshot.entries; + const catalogFacts = resolveDefaultModelCatalogFacts(config, catalog, { + ...(options?.agentId ? { agentId: options.agentId } : {}), + routeVariants: snapshot.routeVariants, + }); + const observedRoutes = options?.observedRoutes ?? catalogFacts.observedRoutes; if (options?.validateCatalog !== false) { - const catalog = await loadModelCatalog({ - config, - useCache: false, - }); if (catalog.length > 0) { - const known = catalog.some( - (entry) => entry.provider === ref.provider && entry.id === ref.model, - ); - if (!known) { + if (!catalogFacts.found) { warnings.push( `Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`, ); @@ -127,20 +177,30 @@ export async function warnIfModelConfigLooksOff( } } - const { hasAuth } = resolveDefaultModelAuthStatus(config, { + const authStatus = resolveDefaultModelAuthStatus(config, { ...(options?.agentId ? { agentId: options.agentId } : {}), ...(options?.agentDir ? { agentDir: options.agentDir } : {}), + ...(options?.env ? { env: options.env } : {}), + ...(observedRoutes ? { observedRoutes } : {}), }); - if (!hasAuth) { + if (authStatus.status === "missing") { warnings.push( `No auth configured for provider "${ref.provider}". The agent may fail until credentials are added. ${buildProviderAuthRecoveryHint( { provider: ref.provider, config, - includeEnvVar: true, + includeEnvVar: authStatus.authRequirement !== "subscription", }, )}`, ); + } else if (authStatus.status === "incompatible") { + warnings.push( + `Model route is incompatible for "${ref.provider}/${ref.model}": ${authStatus.message}`, + ); + } else if (authStatus.status === "indeterminate") { + warnings.push( + `Auth readiness could not be confirmed for "${ref.provider}/${ref.model}". Verify the selected model route and credential source before continuing.`, + ); } if (warnings.length > 0) { diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 6e8bff20ea8e..72a8068634d4 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -1,6 +1,7 @@ // Public auth-choice barrel used by onboarding and agent setup commands. export { applyAuthChoice } from "./auth-choice.apply.js"; export { + resolveDefaultModelCatalogFacts, resolveDefaultModelAuthStatus, warnIfModelConfigLooksOff, } from "./auth-choice.model-check.js"; diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 3decfe4cbd94..2d2dcf695596 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -249,6 +249,68 @@ describe("collectCodexRouteWarnings", () => { ]); }); + it("requires the Codex plugin for automatic Platform-only gpt-5.6", () => { + const warnings = collectCodexRouteWarnings({ + cfg: { + plugins: { entries: { codex: { enabled: false } } }, + agents: { defaults: { model: { primary: "openai/gpt-5.6" } } }, + } as unknown as OpenClawConfig, + }); + + expect(warnings).toStrictEqual([ + [ + "- Codex runtime is selected, but the Codex plugin is disabled.", + "- agents.defaults.model.primary: openai/gpt-5.6 resolves to openai/gpt-5.6 with Codex runtime while the Codex plugin is disabled by config.", + "- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy.", + ].join("\n"), + ]); + }); + + it("requires the Codex plugin for automatic subscription-only Spark", () => { + const warnings = collectCodexRouteWarnings({ + cfg: { + plugins: { entries: { codex: { enabled: false } } }, + agents: { + defaults: { model: { primary: "openai/gpt-5.3-codex-spark" } }, + }, + } as unknown as OpenClawConfig, + }); + + expect(warnings).toStrictEqual([ + [ + "- Codex runtime is selected, but the Codex plugin is disabled.", + "- agents.defaults.model.primary: openai/gpt-5.3-codex-spark resolves to openai/gpt-5.3-codex-spark with Codex runtime while the Codex plugin is disabled by config.", + "- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy.", + ].join("\n"), + ]); + }); + + it("uses the doctor environment snapshot for implicit OpenAI routing", () => { + const cfg = { + plugins: { entries: { codex: { enabled: false } } }, + agents: { defaults: { model: { primary: "openai/gpt-5.4-nano" } } }, + } as unknown as OpenClawConfig; + + expect( + collectCodexRouteWarnings({ + cfg, + env: { OPENAI_BASE_URL: "https://proxy.example.invalid/v1" }, + }), + ).toStrictEqual([]); + expect( + collectCodexRouteWarnings({ + cfg, + env: { OPENAI_BASE_URL: "https://chatgpt.com/backend-api/codex" }, + }), + ).toStrictEqual([ + [ + "- Codex runtime is selected, but the Codex plugin is disabled.", + "- agents.defaults.model.primary: openai/gpt-5.4-nano resolves to openai/gpt-5.4-nano with Codex runtime while the Codex plugin is disabled by config.", + "- Run `openclaw doctor --fix`: it enables plugins.entries.codex, or set the affected OpenAI models to an OpenClaw runtime policy.", + ].join("\n"), + ]); + }); + it("warns when Codex runtime has OpenClaw compaction summarizer overrides", () => { const warnings = collectCodexRouteWarnings({ cfg: { @@ -2125,7 +2187,7 @@ describe("collectCodexRouteWarnings", () => { ).toBe("codex"); }); - it("re-enables the Codex plugin when a default heartbeat model uses Codex runtime", () => { + it("keeps Codex disabled when a bare heartbeat model inherits an Anthropic primary", () => { const result = maybeRepairCodexRoutes({ cfg: { plugins: { @@ -2145,6 +2207,31 @@ describe("collectCodexRouteWarnings", () => { shouldRepair: true, }); + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toStrictEqual([]); + expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(false); + }); + + it("re-enables the Codex plugin when a qualified default heartbeat uses Codex runtime", () => { + const result = maybeRepairCodexRoutes({ + cfg: { + plugins: { + entries: { + codex: { enabled: false }, + }, + }, + agents: { + defaults: { + model: "anthropic/claude-sonnet-4-6", + heartbeat: { + model: "openai/gpt-5.5", + }, + }, + }, + } as unknown as OpenClawConfig, + shouldRepair: true, + }); + expect(result.warnings).toStrictEqual([]); expect(result.changes).toStrictEqual([ "Enabled plugins.entries.codex because configured agent routes use Codex runtime.", @@ -2165,7 +2252,7 @@ describe("collectCodexRouteWarnings", () => { model: "anthropic/claude-sonnet-4-6", subagents: { model: { - primary: "gpt-5.5", + primary: "openai/gpt-5.5", }, }, }, @@ -2192,7 +2279,7 @@ describe("collectCodexRouteWarnings", () => { agents: { defaults: { heartbeat: { - model: "gpt-5.5", + model: "openai/gpt-5.5", }, }, list: [ @@ -2664,7 +2751,7 @@ describe("collectCodexRouteWarnings", () => { expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(false); }); - it("re-enables the Codex plugin when a channel model override uses Codex runtime", () => { + it("keeps Codex disabled when a bare channel model inherits an Anthropic primary", () => { const result = maybeRepairCodexRoutes({ cfg: { plugins: { @@ -2688,6 +2775,78 @@ describe("collectCodexRouteWarnings", () => { shouldRepair: true, }); + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toStrictEqual([]); + expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(false); + }); + + it("re-enables the Codex plugin when a qualified channel model uses Codex runtime", () => { + const result = maybeRepairCodexRoutes({ + cfg: { + plugins: { + entries: { + codex: { enabled: false }, + }, + }, + agents: { + defaults: { + model: "anthropic/claude-sonnet-4-6", + }, + }, + channels: { + modelByChannel: { + telegram: { + default: "openai/gpt-5.5", + }, + }, + }, + } as unknown as OpenClawConfig, + shouldRepair: true, + }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toStrictEqual([ + "Enabled plugins.entries.codex because configured agent routes use Codex runtime.", + ]); + expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true); + }); + + it("checks channel model runtime policy for every configured agent", () => { + const result = maybeRepairCodexRoutes({ + cfg: { + plugins: { + entries: { + codex: { enabled: false }, + }, + }, + agents: { + defaults: { + model: "anthropic/claude-sonnet-4-6", + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + }, + }, + list: [ + { id: "main" }, + { + id: "worker", + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + ], + }, + channels: { + modelByChannel: { + telegram: { + default: "openai/gpt-5.5", + }, + }, + }, + } as unknown as OpenClawConfig, + shouldRepair: true, + }); + expect(result.warnings).toStrictEqual([]); expect(result.changes).toStrictEqual([ "Enabled plugins.entries.codex because configured agent routes use Codex runtime.", diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index 4a199e267359..fa23f4a5cac8 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -10,7 +10,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../../agents/defaults.js"; import { splitTrailingAuthProfile } from "../../../agents/model-ref-profile.js"; import { normalizeConfiguredProviderCatalogModelId } from "../../../agents/model-ref-shared.js"; import { resolveModelRuntimePolicy } from "../../../agents/model-runtime-policy.js"; -import { openAIProviderUsesCodexRuntimeByDefault } from "../../../agents/openai-routing.js"; +import { configuredModelRouteNeedsCodex } from "../../../config/codex-plugin-diagnostics.js"; import { loadSessionStore, updateSessionStore } from "../../../config/sessions/store.js"; import { resolveAllAgentSessionStoreTargetsSync } from "../../../config/sessions/targets.js"; import type { SessionEntry } from "../../../config/sessions/types.js"; @@ -240,6 +240,7 @@ function modelRefUsesCodexRuntime(params: { cfg: OpenClawConfig; modelRef: string | undefined; agentId?: string; + env?: NodeJS.ProcessEnv; }): boolean { const effectiveModelRef = params.modelRef?.trim() || `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`; if (isOpenAICodexModelRef(effectiveModelRef)) { @@ -253,6 +254,7 @@ function modelRefUsesCodexRuntime(params: { agentId: params.agentId, }), agentId: params.agentId, + env: params.env, }); } @@ -279,7 +281,10 @@ function resolveRuntimeModelRef(params: { modelRef: effectiveModelRef, agentId: params.agentId, }) ?? - normalizeDefaultProviderModelRef(effectiveModelRef) + normalizeDefaultProviderModelRef( + effectiveModelRef, + resolveDefaultProviderForAliasContext({ cfg: params.cfg, agentId: params.agentId }), + ) ); } @@ -437,8 +442,11 @@ function providerCatalogModelMatches( return normalizeString(normalizedId) === normalizeString(modelId); } -function normalizeDefaultProviderModelRef(modelRef: string): string { - return modelRef.includes("/") ? modelRef : `${DEFAULT_PROVIDER}/${modelRef}`; +function normalizeDefaultProviderModelRef( + modelRef: string, + defaultProvider = DEFAULT_PROVIDER, +): string { + return modelRef.includes("/") ? modelRef : `${defaultProvider}/${modelRef}`; } function normalizeProviderModelRef(provider: string, modelId: string): string { @@ -471,6 +479,7 @@ function agentUsesCodexRuntimeForCompaction(params: { agentId?: string; currentRuntime?: string; inheritedModelRef?: string; + env?: NodeJS.ProcessEnv; }): boolean { const runtime = concreteRuntimeId(normalizeString(params.currentRuntime)); if (runtime) { @@ -480,6 +489,7 @@ function agentUsesCodexRuntimeForCompaction(params: { cfg: params.cfg, modelRef: readAgentPrimaryModelRef(params.agent, params.inheritedModelRef), agentId: params.agentId, + env: params.env, }); } @@ -492,6 +502,7 @@ function collectUnsupportedCodexCompactionOverridesForAgent(params: { inheritedModelRef?: string; inheritedCompaction?: unknown; inheritedCompactionPath?: string; + env?: NodeJS.ProcessEnv; }): UnsupportedCodexCompactionOverride[] { const agent = asMutableRecord(params.agent); const compaction = asMutableRecord(agent?.compaction); @@ -503,6 +514,7 @@ function collectUnsupportedCodexCompactionOverridesForAgent(params: { agentId: params.agentId, currentRuntime: params.currentRuntime, inheritedModelRef: params.inheritedModelRef, + env: params.env, }) ) { return []; @@ -538,6 +550,7 @@ function collectLegacyLosslessCompactionForAgent(params: { inheritedModelRef?: string; inheritedCompaction?: unknown; inheritedCompactionPath?: string; + env?: NodeJS.ProcessEnv; }): LegacyLosslessCompactionConfig[] { const agent = asMutableRecord(params.agent); const compaction = asMutableRecord(agent?.compaction); @@ -549,6 +562,7 @@ function collectLegacyLosslessCompactionForAgent(params: { agentId: params.agentId, currentRuntime: params.currentRuntime, inheritedModelRef: params.inheritedModelRef, + env: params.env, }) ) { return []; @@ -604,6 +618,7 @@ function dedupeLegacyLosslessCompactionConfigs( function collectLegacyLosslessCompactionConfigs(params: { cfg: OpenClawConfig; ignoreLegacyAgentRuntimePins?: boolean; + env?: NodeJS.ProcessEnv; }): LegacyLosslessCompactionConfig[] { const defaults = params.cfg.agents?.defaults; const defaultsRuntime = params.ignoreLegacyAgentRuntimePins @@ -616,6 +631,7 @@ function collectLegacyLosslessCompactionConfigs(params: { agent: defaults, path: "agents.defaults", currentRuntime: resolveRuntime({ defaultsRuntime }), + env: params.env, }); const agents = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : []; for (const [index, agent] of agents.entries()) { @@ -642,6 +658,7 @@ function collectLegacyLosslessCompactionConfigs(params: { inheritedModelRef: defaultModelRef, inheritedCompaction: defaultCompaction, inheritedCompactionPath: "agents.defaults.compaction", + env: params.env, }), ); } @@ -665,6 +682,7 @@ function dedupeUnsupportedCompactionOverrides( function collectUnsupportedCodexCompactionOverrides(params: { cfg: OpenClawConfig; ignoreLegacyAgentRuntimePins?: boolean; + env?: NodeJS.ProcessEnv; }): UnsupportedCodexCompactionOverride[] { const defaults = params.cfg.agents?.defaults; const defaultsRuntime = params.ignoreLegacyAgentRuntimePins @@ -677,6 +695,7 @@ function collectUnsupportedCodexCompactionOverrides(params: { agent: defaults, path: "agents.defaults", currentRuntime: resolveRuntime({ defaultsRuntime }), + env: params.env, }); const agents = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : []; for (const [index, agent] of agents.entries()) { @@ -703,6 +722,7 @@ function collectUnsupportedCodexCompactionOverrides(params: { inheritedModelRef: defaultModelRef, inheritedCompaction: defaultCompaction, inheritedCompactionPath: "agents.defaults.compaction", + env: params.env, }), ); } @@ -712,6 +732,7 @@ function collectUnsupportedCodexCompactionOverrides(params: { function getSharedDefaultCompactionOverrideConsumers(params: { cfg: OpenClawConfig; ignoreLegacyAgentRuntimePins?: boolean; + env?: NodeJS.ProcessEnv; }): SharedDefaultCompactionOverrideConsumers { const consumers: SharedDefaultCompactionOverrideConsumers = { model: false, provider: false }; const defaults = params.cfg.agents?.defaults; @@ -734,6 +755,7 @@ function getSharedDefaultCompactionOverrideConsumers(params: { currentRuntime: resolveRuntime({ defaultsRuntime: params.ignoreLegacyAgentRuntimePins ? undefined : defaultsRuntime, }), + env: params.env, }); if (!defaultUsesCodexCompaction) { consumers.model ||= Boolean(hasDefaultModel); @@ -776,6 +798,7 @@ function getSharedDefaultCompactionOverrideConsumers(params: { defaultsRuntime: params.ignoreLegacyAgentRuntimePins ? undefined : defaultsRuntime, }), inheritedModelRef, + env: params.env, }); if (!usesCodexCompaction) { consumers.model ||= inheritsDefaultModel; @@ -791,6 +814,7 @@ function getSharedDefaultCompactionOverrideConsumers(params: { function sharedDefaultLosslessCompactionHasNonCodexConsumer(params: { cfg: OpenClawConfig; ignoreLegacyAgentRuntimePins?: boolean; + env?: NodeJS.ProcessEnv; }): boolean { const defaults = params.cfg.agents?.defaults; const defaultCompaction = asMutableRecord(defaults?.compaction); @@ -808,6 +832,7 @@ function sharedDefaultLosslessCompactionHasNonCodexConsumer(params: { cfg: params.cfg, agent: defaults, currentRuntime: resolveRuntime({ defaultsRuntime }), + env: params.env, }); if (!defaultUsesCodexCompaction) { return true; @@ -837,6 +862,7 @@ function sharedDefaultLosslessCompactionHasNonCodexConsumer(params: { cfg: params.cfg, agent: agentRecord, agentId: id, + env: params.env, currentRuntime: resolveRuntime({ agentRuntime: params.ignoreLegacyAgentRuntimePins ? undefined @@ -1095,7 +1121,10 @@ function collectChannelAgentRuntimeModelRefs( return refs; } -function collectDisabledCodexPluginRouteHits(cfg: OpenClawConfig): DisabledCodexPluginRouteHit[] { +function collectDisabledCodexPluginRouteHits( + cfg: OpenClawConfig, + env?: NodeJS.ProcessEnv, +): DisabledCodexPluginRouteHit[] { if (!isCodexPluginUnavailableByConfig(cfg)) { return []; } @@ -1132,9 +1161,9 @@ function collectDisabledCodexPluginRouteHits(cfg: OpenClawConfig): DisabledCodex (ref) => !inheritedDefaultAuxRefs.includes(ref) && !inheritedDefaultModelPolicyRefs.includes(ref), ); + const channelRefs = collectChannelAgentRuntimeModelRefs(cfg); const candidateRefs: Array<{ path: string; modelRef: string; agentId?: string }> = - agents.length === 0 ? [...defaultRefs] : []; - candidateRefs.push(...collectChannelAgentRuntimeModelRefs(cfg)); + agents.length === 0 ? [...defaultRefs, ...channelRefs] : []; for (const [index, agent] of agents.entries()) { const agentRecord = asMutableRecord(agent); if (!agentRecord) { @@ -1147,6 +1176,9 @@ function collectDisabledCodexPluginRouteHits(cfg: OpenClawConfig): DisabledCodex const agentId = normalizeAgentId( typeof agentRecord.id === "string" ? agentRecord.id : undefined, ); + for (const ref of channelRefs) { + candidateRefs.push({ path: ref.path, modelRef: ref.modelRef, agentId }); + } const inheritedModelRefs = inheritedDefaultAuxRefs.filter((ref) => { if (ref.path === "agents.defaults.heartbeat.model") { return !normalizeString(asMutableRecord(agentRecord.heartbeat)?.model); @@ -1180,6 +1212,7 @@ function collectDisabledCodexPluginRouteHits(cfg: OpenClawConfig): DisabledCodex cfg, modelRef: ref.modelRef, agentId: ref.agentId, + env, }) ) { continue; @@ -1197,9 +1230,10 @@ function collectDisabledCodexPluginRouteHits(cfg: OpenClawConfig): DisabledCodex /** Find Codex-routed model refs that require the Codex plugin while it is disabled. */ export function collectDisabledCodexPluginRouteIssues( cfg: OpenClawConfig, + env?: NodeJS.ProcessEnv, ): DisabledCodexPluginRouteIssue[] { const blockedOutsideEntry = codexPluginIsBlockedOutsideEntry(cfg); - return collectDisabledCodexPluginRouteHits(cfg).map((hit) => ({ + return collectDisabledCodexPluginRouteHits(cfg, env).map((hit) => ({ path: hit.path, modelRef: hit.modelRef, canonicalModel: hit.canonicalModel, @@ -1497,6 +1531,7 @@ function resolveCurrentRuntimeIdForCanonicalModel(params: { cfg: OpenClawConfig; modelRef: string; agentId: string; + env?: NodeJS.ProcessEnv; }): string { const parsed = parseModelRef(params.modelRef); if (!parsed) { @@ -1513,9 +1548,11 @@ function resolveCurrentRuntimeIdForCanonicalModel(params: { if (configured) { return configured; } - return openAIProviderUsesCodexRuntimeByDefault({ - provider: parsed.provider, - config: params.cfg, + return configuredModelRouteNeedsCodex({ + cfg: params.cfg, + env: params.env ?? process.env, + agentId: params.agentId, + route: { provider: parsed.provider, modelId: parsed.modelId }, }) ? "codex" : "auto"; @@ -1555,6 +1592,7 @@ function shieldExplicitListedAgentRefsFromDefaultPolicy(params: { modelRef: string; targetRuntimeId: string; changes: string[]; + env?: NodeJS.ProcessEnv; }): void { for (const [index, agent] of (params.cfg.agents?.list ?? []).entries()) { if (!agentExplicitlyReferencesCanonicalModel(agent, params.modelRef)) { @@ -1565,6 +1603,7 @@ function shieldExplicitListedAgentRefsFromDefaultPolicy(params: { cfg: params.cfg, modelRef: params.modelRef, agentId: id, + env: params.env, }); if (runtimeId === params.targetRuntimeId) { continue; @@ -1597,6 +1636,7 @@ function rewriteAgentModelRefs(params: { rewrittenInheritedCompactionModels?: Map; runtimePolicyChanges: string[]; unsupportedCompactionChanges: string[]; + env?: NodeJS.ProcessEnv; }): void { if (!params.agent) { return; @@ -1614,6 +1654,7 @@ function rewriteAgentModelRefs(params: { isDefaults: params.path === "agents.defaults", preRepairCfg: params.preRepairCfg, changes: params.runtimePolicyChanges, + env: params.env, }); } }; @@ -1636,6 +1677,7 @@ function rewriteAgentModelRefs(params: { container: agent, key, path: `${params.path}.${key}`, + env: params.env, }); } } @@ -1646,6 +1688,7 @@ function rewriteAgentModelRefs(params: { container: asMutableRecord(agent.heartbeat), key: "model", path: `${params.path}.heartbeat.model`, + env: params.env, }); rewriteModelConfigSlotIfCanonicalCodexRuntime({ cfg: params.cfg, @@ -1654,6 +1697,7 @@ function rewriteAgentModelRefs(params: { container: asMutableRecord(agent.subagents), key: "model", path: `${params.path}.subagents.model`, + env: params.env, }); const compaction = asMutableRecord(agent.compaction); const inheritedCompaction = asMutableRecord(params.inheritedCompaction); @@ -1663,6 +1707,7 @@ function rewriteAgentModelRefs(params: { agentId: params.agentId, currentRuntime: params.currentRuntime, inheritedModelRef: params.inheritedModelRef, + env: params.env, }); if (usesCodexCompaction) { const effectiveCompactionProvider = compaction?.provider ?? inheritedCompaction?.provider; @@ -1711,6 +1756,7 @@ function rewriteAgentModelRefs(params: { isDefaults: params.path === "agents.defaults", preRepairCfg: params.preRepairCfg, changes: params.runtimePolicyChanges, + env: params.env, }); } } @@ -1740,6 +1786,7 @@ function rewriteAgentModelRefs(params: { container: compaction, key: "model", path: `${params.path}.compaction.model`, + env: params.env, }); } rewriteStringModelSlotIfCanonicalCodexRuntime({ @@ -1749,6 +1796,7 @@ function rewriteAgentModelRefs(params: { container: asMutableRecord(compaction?.memoryFlush), key: "model", path: `${params.path}.compaction.memoryFlush.model`, + env: params.env, }); for (const key of AGENT_MEDIA_MODEL_CONFIG_KEYS) { rewriteModelConfigSlot({ @@ -1887,6 +1935,7 @@ function preserveMigratedLosslessCodexRuntimePolicy(params: { hits: readonly LegacyLosslessCompactionConfig[]; summaryModel: string | undefined; changes: string[]; + env?: NodeJS.ProcessEnv; }): void { if (!params.summaryModel) { return; @@ -1917,6 +1966,7 @@ function preserveMigratedLosslessCodexRuntimePolicy(params: { modelRef: params.summaryModel, isDefaults: ownerPath === "agents.defaults", changes: params.changes, + env: params.env, }); } } @@ -1992,6 +2042,7 @@ function ensureLosslessLlmPolicy(params: { function maybeMigrateLegacyLosslessCompactionConfig(params: { cfg: OpenClawConfig; ignoreLegacyAgentRuntimePins?: boolean; + env?: NodeJS.ProcessEnv; }): string[] { const root = params.cfg as MutableRecord; const hits = collectLegacyLosslessCompactionConfigs(params); @@ -2050,6 +2101,7 @@ function maybeMigrateLegacyLosslessCompactionConfig(params: { hits, summaryModel, changes, + env: params.env, }); for (const hit of hits) { removeMigratedLosslessCompactionKey({ @@ -2250,6 +2302,7 @@ function ensureCodexRuntimePolicy(params: { isDefaults?: boolean; preRepairCfg?: OpenClawConfig; changes: string[]; + env?: NodeJS.ProcessEnv; }): void { const models = asMutableRecord(params.agent.models); const entry = asMutableRecord(models?.[params.modelRef]); @@ -2278,6 +2331,7 @@ function ensureCodexRuntimePolicy(params: { modelRef: params.modelRef, targetRuntimeId, changes: params.changes, + env: params.env, }); } if (pinnedRuntimeId) { @@ -2316,6 +2370,7 @@ function canonicalOpenAIModelUsesCodexRuntime(params: { cfg: OpenClawConfig; modelRef: string; agentId?: string; + env?: NodeJS.ProcessEnv; }): boolean { const slash = params.modelRef.indexOf("/"); if (slash <= 0 || slash >= params.modelRef.length - 1) { @@ -2325,18 +2380,12 @@ function canonicalOpenAIModelUsesCodexRuntime(params: { if (!parsed) { return false; } - const configured = normalizeRuntimeString( - resolveModelRuntimePolicy({ - config: params.cfg, - provider: parsed.provider, - modelId: parsed.modelId, - agentId: params.agentId, - }).policy?.id, - ); - if (configured && configured !== "auto" && configured !== "default") { - return configured === "codex"; - } - return openAIProviderUsesCodexRuntimeByDefault({ provider: parsed.provider, config: params.cfg }); + return configuredModelRouteNeedsCodex({ + cfg: params.cfg, + env: params.env ?? process.env, + ...(params.agentId ? { agentId: params.agentId } : {}), + route: { provider: parsed.provider, modelId: parsed.modelId }, + }); } function rewriteStringModelSlotIfCanonicalCodexRuntime(params: { @@ -2346,6 +2395,7 @@ function rewriteStringModelSlotIfCanonicalCodexRuntime(params: { container: MutableRecord | undefined; key: string; path: string; + env?: NodeJS.ProcessEnv; }): void { const value = params.container?.[params.key]; if (typeof value !== "string") { @@ -2358,6 +2408,7 @@ function rewriteStringModelSlotIfCanonicalCodexRuntime(params: { cfg: params.cfg, modelRef: canonicalModel, agentId: params.agentId, + env: params.env, }) ) { return; @@ -2377,6 +2428,7 @@ function rewriteModelConfigSlotIfCanonicalCodexRuntime(params: { container: MutableRecord | undefined; key: string; path: string; + env?: NodeJS.ProcessEnv; }): void { const value = params.container?.[params.key]; if (typeof value === "string") { @@ -2394,6 +2446,7 @@ function rewriteModelConfigSlotIfCanonicalCodexRuntime(params: { container: record, key: "primary", path: `${params.path}.primary`, + env: params.env, }); const fallbacks = Array.isArray(record.fallbacks) ? record.fallbacks : undefined; if (!fallbacks) { @@ -2410,6 +2463,7 @@ function rewriteModelConfigSlotIfCanonicalCodexRuntime(params: { cfg: params.cfg, modelRef: canonicalModel, agentId: params.agentId, + env: params.env, }) ) { continue; @@ -2470,6 +2524,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { cfg: OpenClawConfig; preserveSharedDefaultCompactionOverrides: SharedDefaultCompactionOverrideConsumers; ignoreLegacyAgentRuntimePins?: boolean; + env?: NodeJS.ProcessEnv; }): ConfigRouteRepairResult { const nextConfig = structuredClone(params.cfg); const preRepairCfg = params.cfg; @@ -2480,17 +2535,20 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { params.ignoreLegacyAgentRuntimePins ?? configRepairWouldClearLegacyRuntimePins({ cfg: nextConfig, + env: params.env, }); unsupportedCompactionChanges.push( ...maybeMigrateLegacyLosslessCompactionConfig({ cfg: nextConfig, ignoreLegacyAgentRuntimePins, + env: params.env, }), ); const preservedLegacyLosslessCompactionPaths = new Set( collectLegacyLosslessCompactionConfigs({ cfg: nextConfig, ignoreLegacyAgentRuntimePins, + env: params.env, }).flatMap((hit) => (hit.modelPath ? [hit.providerPath, hit.modelPath] : [hit.providerPath])), ); const defaultsRuntime = ignoreLegacyAgentRuntimePins @@ -2510,6 +2568,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { rewrittenInheritedCompactionModels, runtimePolicyChanges, unsupportedCompactionChanges, + env: params.env, }); const inheritedModelRef = readAgentPrimaryModelRef(nextConfig.agents?.defaults); const agents = Array.isArray(nextConfig.agents?.list) ? nextConfig.agents.list : []; @@ -2543,6 +2602,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { rewrittenInheritedCompactionModels, runtimePolicyChanges, unsupportedCompactionChanges, + env: params.env, }); } const channelsModelByChannel = asMutableRecord(nextConfig.channels?.modelByChannel); @@ -2559,6 +2619,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { container: targets, key: targetId, path: `channels.modelByChannel.${channelId}.${targetId}`, + env: params.env, }); } } @@ -2570,6 +2631,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { container: mapping as MutableRecord, key: "model", path: `hooks.mappings.${index}.model`, + env: params.env, }); } rewriteStringModelSlotIfCanonicalCodexRuntime({ @@ -2578,6 +2640,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { container: asMutableRecord(nextConfig.hooks?.gmail), key: "model", path: "hooks.gmail.model", + env: params.env, }); rewriteStringModelSlotIfCanonicalCodexRuntime({ cfg: nextConfig, @@ -2585,6 +2648,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { container: asMutableRecord(nextConfig.messages?.tts), key: "summaryModel", path: "messages.tts.summaryModel", + env: params.env, }); rewriteStringModelSlotIfCanonicalCodexRuntime({ cfg: nextConfig, @@ -2592,6 +2656,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { container: asMutableRecord(asMutableRecord(nextConfig.channels?.discord)?.voice), key: "model", path: "channels.discord.voice.model", + env: params.env, }); const shouldClearRuntimePins = hits.some((hit) => !isCompactionOnlyRouteHit(hit)); const runtimePinChanges = shouldClearRuntimePins @@ -2612,23 +2677,32 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { }; } -function configRepairWouldClearLegacyRuntimePins(params: { cfg: OpenClawConfig }): boolean { +function configRepairWouldClearLegacyRuntimePins(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): boolean { const dryRun = rewriteConfigModelRefsWithCompactionPolicy({ cfg: params.cfg, preserveSharedDefaultCompactionOverrides: { model: true, provider: true }, ignoreLegacyAgentRuntimePins: false, + env: params.env, }); return dryRun.changes.some((hit) => !isCompactionOnlyRouteHit(hit)); } -function rewriteConfigModelRefs(params: { cfg: OpenClawConfig }): ConfigRouteRepairResult { +function rewriteConfigModelRefs(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): ConfigRouteRepairResult { const preserveSharedDefaultCompactionOverrides = getSharedDefaultCompactionOverrideConsumers({ cfg: params.cfg, ignoreLegacyAgentRuntimePins: configRepairWouldClearLegacyRuntimePins(params), + env: params.env, }); return rewriteConfigModelRefsWithCompactionPolicy({ cfg: params.cfg, preserveSharedDefaultCompactionOverrides, + env: params.env, }); } @@ -2765,12 +2839,17 @@ export function collectCodexRouteWarnings(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }): string[] { + const env = params.env ?? process.env; const hits = collectConfigModelRefs(params.cfg); - const disabledCodexPluginHits = collectDisabledCodexPluginRouteHits(params.cfg); - const ignoreLegacyAgentRuntimePins = configRepairWouldClearLegacyRuntimePins(params); + const disabledCodexPluginHits = collectDisabledCodexPluginRouteHits(params.cfg, env); + const ignoreLegacyAgentRuntimePins = configRepairWouldClearLegacyRuntimePins({ + cfg: params.cfg, + env, + }); const legacyLosslessCompactionConfigs = collectLegacyLosslessCompactionConfigs({ cfg: params.cfg, ignoreLegacyAgentRuntimePins, + env, }); const legacyLosslessCompactionPaths = new Set( legacyLosslessCompactionConfigs.flatMap((hit) => @@ -2780,15 +2859,18 @@ export function collectCodexRouteWarnings(params: { const unsupportedCompactionOverrides = collectUnsupportedCodexCompactionOverrides({ cfg: params.cfg, ignoreLegacyAgentRuntimePins, + env, }).filter((hit) => !legacyLosslessCompactionPaths.has(hit.path)); const sharedDefaultCompactionConsumers = getSharedDefaultCompactionOverrideConsumers({ cfg: params.cfg, - ignoreLegacyAgentRuntimePins: configRepairWouldClearLegacyRuntimePins(params), + ignoreLegacyAgentRuntimePins, + env, }); const sharedLosslessDefaultHasNonCodexConsumer = sharedDefaultLosslessCompactionHasNonCodexConsumer({ cfg: params.cfg, ignoreLegacyAgentRuntimePins, + env, }); const warnings: string[] = []; warnings.push(...collectCodexAppServerCommandWarnings(params.cfg)); @@ -2869,16 +2951,22 @@ export function maybeRepairCodexRoutes(params: { shouldRepair: boolean; codexRuntimeReady?: boolean; }): { cfg: OpenClawConfig; warnings: string[]; changes: string[] } { + const env = params.env ?? process.env; const hits = collectConfigModelRefs(params.cfg); - const disabledCodexPluginHits = collectDisabledCodexPluginRouteHits(params.cfg); - const ignoreLegacyAgentRuntimePins = configRepairWouldClearLegacyRuntimePins(params); + const disabledCodexPluginHits = collectDisabledCodexPluginRouteHits(params.cfg, env); + const ignoreLegacyAgentRuntimePins = configRepairWouldClearLegacyRuntimePins({ + cfg: params.cfg, + env, + }); const unsupportedCompactionOverrides = collectUnsupportedCodexCompactionOverrides({ cfg: params.cfg, ignoreLegacyAgentRuntimePins, + env, }); const legacyLosslessCompactionConfigs = collectLegacyLosslessCompactionConfigs({ cfg: params.cfg, ignoreLegacyAgentRuntimePins, + env, }); if ( hits.length === 0 && @@ -2891,18 +2979,19 @@ export function maybeRepairCodexRoutes(params: { if (!params.shouldRepair) { return { cfg: params.cfg, - warnings: collectCodexRouteWarnings({ cfg: params.cfg, env: params.env }), + warnings: collectCodexRouteWarnings({ cfg: params.cfg, env }), changes: [], }; } const repaired = rewriteConfigModelRefs({ cfg: params.cfg, + env, }); const codexPluginRepair = enableCodexPluginForRequiredRoutes({ cfg: repaired.cfg, - routeHits: collectDisabledCodexPluginRouteHits(repaired.cfg), + routeHits: collectDisabledCodexPluginRouteHits(repaired.cfg, env), }); - const warnings = collectCodexRouteWarnings({ cfg: codexPluginRepair.cfg, env: params.env }); + const warnings = collectCodexRouteWarnings({ cfg: codexPluginRepair.cfg, env }); const changes = repaired.changes.length > 0 ? [ diff --git a/src/commands/doctor/shared/stale-plugin-config.test.ts b/src/commands/doctor/shared/stale-plugin-config.test.ts index 882268cdf4be..8c0f625b3b7b 100644 --- a/src/commands/doctor/shared/stale-plugin-config.test.ts +++ b/src/commands/doctor/shared/stale-plugin-config.test.ts @@ -433,6 +433,33 @@ describe("doctor stale plugin config helpers", () => { expect(maybeRepairStalePluginConfig(cfg)).toEqual({ config: cfg, changes: [] }); }); + it("uses the scan environment snapshot for implicit OpenAI routing", () => { + const cfg = { + plugins: { + entries: { + codex: {}, + }, + }, + } as OpenClawConfig; + + expect( + scanStalePluginConfig(cfg, { + OPENAI_BASE_URL: "https://proxy.example.invalid/v1", + }), + ).toStrictEqual([]); + expect( + scanStalePluginConfig(cfg, { + OPENAI_BASE_URL: "https://api.openai.com/v1", + }), + ).toEqual([ + { + pluginId: "codex", + pathLabel: "plugins.entries.codex", + surface: "entries", + }, + ]); + }); + it("keeps Codex entry diagnostics when OpenAI wildcard policy falls back to Codex", () => { const cfg = { models: { diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts index 8ce58b23ac3f..1d228ea7b4f7 100644 --- a/src/commands/doctor/shared/stale-plugin-config.ts +++ b/src/commands/doctor/shared/stale-plugin-config.ts @@ -39,11 +39,12 @@ function collectPluginRegistryState( cfg: OpenClawConfig, env?: NodeJS.ProcessEnv, ): StalePluginRegistryState { + const environment = env ?? process.env; const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); const registry = loadManifestMetadataSnapshot({ config: cfg, workspaceDir: workspaceDir ?? undefined, - env: env ?? process.env, + env: environment, }).manifestRegistry; const knownIds = new Set(registry.plugins.map((plugin) => plugin.id)); const installedIds = new Set(); @@ -54,7 +55,9 @@ function collectPluginRegistryState( } } try { - for (const pluginId of Object.keys(loadInstalledPluginIndexInstallRecordsSync({ env }))) { + for (const pluginId of Object.keys( + loadInstalledPluginIndexInstallRecordsSync({ env: environment }), + )) { const normalized = normalizePluginId(pluginId); if (normalized) { installedIds.add(normalized); @@ -99,12 +102,18 @@ export function scanStalePluginConfig( if (cfg.plugins?.enabled === false) { return []; } - return scanStalePluginConfigWithState(cfg, collectPluginRegistryState(cfg, env)); + const environment = env ?? process.env; + return scanStalePluginConfigWithState( + cfg, + collectPluginRegistryState(cfg, environment), + environment, + ); } function scanStalePluginConfigWithState( cfg: OpenClawConfig, registryState: StalePluginRegistryState, + env: NodeJS.ProcessEnv, ): StalePluginConfigHit[] { const plugins = asObjectRecord(cfg.plugins); const { knownIds } = registryState; @@ -152,7 +161,7 @@ function scanStalePluginConfigWithState( if (!pluginId || knownIds.has(pluginId) || registryState.knownChannelIds.has(pluginId)) { continue; } - if (pluginId === "codex" && shouldSuppressMissingCodexPluginDiagnostics(cfg)) { + if (pluginId === "codex" && shouldSuppressMissingCodexPluginDiagnostics(cfg, env)) { continue; } hits.push({ @@ -342,7 +351,8 @@ export function maybeRepairStalePluginConfig( if (cfg.plugins?.enabled === false) { return { config: cfg, changes: [] }; } - const registryState = collectPluginRegistryState(cfg, env); + const environment = env ?? process.env; + const registryState = collectPluginRegistryState(cfg, environment); if (registryState.hasDiscoveryErrors) { return { config: cfg, changes: [] }; } @@ -352,7 +362,7 @@ export function maybeRepairStalePluginConfig( .map((pluginId) => normalizePluginId(pluginId)) .filter((pluginId): pluginId is string => Boolean(pluginId)), ); - const hits = scanStalePluginConfigWithState(cfg, registryState).filter( + const hits = scanStalePluginConfigWithState(cfg, registryState, environment).filter( (hit) => !preservePluginIds.has(normalizePluginId(hit.pluginId)), ); if (hits.length === 0) { diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index bee925e80130..0abf9fe0219a 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -1,4 +1,5 @@ // Model picker tests cover catalog rows, provider metadata, backend defaults, and prompt choices. +import path from "node:path"; import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/model-catalog-types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { testing as cliBackendsTesting } from "../agents/cli-backends.js"; @@ -14,8 +15,14 @@ import { import { makePrompter } from "./setup/__tests__/test-utils.js"; const loadModelCatalog = vi.hoisted(() => vi.fn()); +const modelCatalogRouteVariants = vi.hoisted(() => ({ + value: undefined as readonly ModelCatalogEntry[] | undefined, +})); vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog, + loadModelCatalogSnapshot: async (...args: unknown[]) => { + const entries = await loadModelCatalog(...args); + return { entries, routeVariants: modelCatalogRouteVariants.value ?? entries }; + }, })); const loadStaticManifestCatalogRowsForList = vi.hoisted(() => @@ -120,17 +127,66 @@ vi.mock("../agents/model-auth.js", () => ({ hasRuntimeAvailableProviderAuth, })); +const providerAuthRoute = vi.hoisted(() => ({ + value: undefined as + | { + api: "openai-responses" | "openai-chatgpt-responses"; + baseUrl: string; + authRequirement: "api-key" | "subscription"; + requestTransportOverrides: "none" | "present"; + } + | undefined, +})); +const providerAuthEvaluations = vi.hoisted( + () => + new Map< + string, + { + availability: boolean | undefined; + routeResolution: null; + selectedAuthMode?: string; + evidence?: "aws-sdk" | "provider-config"; + } + >(), +); const createProviderAuthChecker = vi.hoisted(() => - vi.fn( - (params: { cfg?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv }) => - async (provider: string) => - hasRuntimeAvailableProviderAuth({ - provider, - cfg: params.cfg, - workspaceDir: params.workspaceDir, - env: params.env, - }), - ), + vi.fn((params: { cfg?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv }) => { + const checker = vi.fn( + async (provider: string, ref?: { api?: string | null; baseUrl?: unknown }) => { + const prepared = providerAuthEvaluations.get(provider); + if (prepared) { + return prepared.availability === true; + } + return ( + hasRuntimeAvailableProviderAuth({ + provider, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + }) && + !(ref?.api === "openai-chatgpt-responses" && ref.baseUrl === "https://api.openai.com/v1") + ); + }, + ); + const evaluateModelAuth = vi.fn( + async (provider: string, ref?: { api?: string | null; baseUrl?: unknown }) => { + const prepared = providerAuthEvaluations.get(provider); + if (prepared) { + return prepared; + } + const availability = await checker(provider, ref); + const selectedRoute = providerAuthRoute.value; + return { + availability, + routeResolution: selectedRoute + ? { kind: "routes" as const, routes: [selectedRoute] as const } + : null, + ...(selectedRoute ? { selectedRoute } : {}), + }; + }, + ); + return Object.assign(checker, { evaluateModelAuth }); + }), ); vi.mock("../agents/model-provider-auth.js", () => ({ createProviderAuthChecker, @@ -285,7 +341,12 @@ function providerCallProviders() { beforeEach(() => { delete process.env.OPENCLAW_LOCALE; + // Route hints exercise source policy even when a prior local build left stale dist artifacts. + vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.resolve("extensions")); vi.clearAllMocks(); + modelCatalogRouteVariants.value = undefined; + providerAuthRoute.value = undefined; + providerAuthEvaluations.clear(); cliBackendsTesting.setDepsForTest({ resolveRuntimeCliBackends: () => [ { @@ -348,6 +409,7 @@ beforeEach(() => { afterEach(() => { cliBackendsTesting.resetDepsForTest(); + vi.unstubAllEnvs(); }); describe("promptDefaultModel", () => { @@ -357,6 +419,8 @@ describe("promptDefaultModel", () => { provider: "openai", id: "gpt-5.5", name: "GPT-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", }, ]); @@ -374,6 +438,117 @@ describe("promptDefaultModel", () => { const options = pickerOptions(select as MockCallSource); const canonical = requireOption(options, "openai/gpt-5.5"); expect(canonical.hint).toContain("Codex runtime route"); + expect(canonical.hint).not.toContain("OpenClaw runtime route"); + }); + + it.each([ + ["default request params", { params: { temperature: 0.2 } }], + [ + "model request params", + { models: { "openai/gpt-5.5": { params: { text_verbosity: "low" } } } }, + ], + ] as const)( + "labels official OpenAI with %s as an OpenClaw runtime route", + async (_label, defaults) => { + loadModelCatalog.mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + ]); + const select = vi.fn(async (params) => params.initialValue as never); + + await promptDefaultModel({ + config: { agents: { defaults } } as OpenClawConfig, + prompter: makePrompter({ select }), + allowKeep: false, + includeManual: false, + ignoreAllowlist: true, + }); + + const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5"); + expect(option.hint).toContain("OpenClaw runtime route"); + expect(option.hint).not.toContain("Codex runtime route"); + }, + ); + + it.each([ + ["custom endpoint", "openai-responses", "https://example.test/v1"], + ["authored Completions", "openai-completions", "https://api.openai.com/v1"], + ] as const)("labels an OpenAI %s as an OpenClaw runtime route", async (_label, api, baseUrl) => { + loadModelCatalog.mockResolvedValue([ + { provider: "openai", id: "gpt-5.5", name: "GPT-5.5", api, baseUrl }, + ]); + const config = { + agents: { defaults: {} }, + models: { + providers: { + openai: { api, baseUrl, models: [configuredTextModel("gpt-5.5", "GPT-5.5")] }, + }, + }, + } as OpenClawConfig; + const select = vi.fn(async (params) => params.initialValue as never); + + await promptDefaultModel({ + config, + prompter: makePrompter({ select }), + allowKeep: false, + includeManual: false, + ignoreAllowlist: true, + }); + + const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5"); + expect(option.hint).toContain("OpenClaw runtime route"); + expect(option.hint).not.toContain("Codex runtime route"); + }); + + it("uses selected ChatGPT capabilities regardless of physical row order", async () => { + providerAuthRoute.value = { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + }; + const platform: ModelCatalogEntry = { + provider: "openai", + id: "gpt-5.5", + name: "Platform GPT-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_000_000, + reasoning: true, + input: ["text", "image"], + }; + const chatGPT: ModelCatalogEntry = { + provider: "openai", + id: "gpt-5.5", + name: "ChatGPT GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 400_000, + reasoning: false, + input: ["text"], + }; + loadModelCatalog.mockResolvedValue([platform]); + modelCatalogRouteVariants.value = [platform, chatGPT]; + const select = vi.fn(async (params) => params.initialValue as never); + + await promptDefaultModel({ + config: { agents: { defaults: {} } } as OpenClawConfig, + prompter: makePrompter({ select }), + allowKeep: false, + includeManual: false, + ignoreAllowlist: true, + }); + + const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5"); + expect(option.hint).toContain("ChatGPT GPT-5.5"); + expect(option.hint).toContain("ctx 400k"); + expect(option.hint).not.toContain("reasoning"); + expect(optionValues(pickerOptions(select as MockCallSource))).toEqual(["openai/gpt-5.5"]); }); it("hides unauthenticated catalog entries from default model choices", async () => { @@ -398,6 +573,40 @@ describe("promptDefaultModel", () => { expect(values).toEqual(["anthropic/claude-sonnet-4-6"]); }); + it("does not offer an OpenAI row with a conflicting API and endpoint", async () => { + loadModelCatalog.mockResolvedValue([ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" }, + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + }, + ]); + const select = vi.fn(async (params) => params.initialValue as never); + + await promptDefaultModel({ + config: { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + }, + prompter: makePrompter({ select }), + allowKeep: false, + includeManual: false, + ignoreAllowlist: true, + }); + + expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([ + "anthropic/claude-sonnet-4-6", + ]); + const checker = createProviderAuthChecker.mock.results.at(-1)?.value; + expect(checker).toHaveBeenCalledWith("openai", { + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://api.openai.com/v1", + }); + }); + it("keeps implicit Bedrock AWS SDK models visible without API-key auth", async () => { resolveEnvApiKey.mockReturnValue(null); loadModelCatalog.mockResolvedValue([ @@ -420,6 +629,60 @@ describe("promptDefaultModel", () => { expect(values).toEqual(["amazon-bedrock/us.anthropic.claude-sonnet-4-5"]); }); + it("shows AWS SDK models but hides unresolved non-OpenAI SecretRefs", async () => { + providerAuthEvaluations.set("amazon-bedrock", { + availability: true, + routeResolution: null, + selectedAuthMode: "aws-sdk", + evidence: "aws-sdk", + }); + providerAuthEvaluations.set("anthropic", { + availability: undefined, + routeResolution: null, + selectedAuthMode: "api-key", + evidence: "provider-config", + }); + loadModelCatalog.mockResolvedValue([ + { + provider: "amazon-bedrock", + id: "us.anthropic.claude-sonnet-4-5", + name: "Bedrock Claude", + api: "bedrock-converse-stream", + }, + { + provider: "anthropic", + id: "claude-sonnet-4-6", + name: "Anthropic Claude", + api: "anthropic-messages", + }, + ]); + const select = vi.fn(async (params) => params.initialValue as never); + + await promptDefaultModel({ + config: { agents: { defaults: {} } } as OpenClawConfig, + prompter: makePrompter({ select }), + allowKeep: false, + includeManual: false, + ignoreAllowlist: true, + }); + + expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([ + "amazon-bedrock/us.anthropic.claude-sonnet-4-5", + ]); + const authChecker = createProviderAuthChecker.mock.results.at(-1)?.value; + if (!authChecker) { + throw new Error("expected provider auth checker"); + } + expect(authChecker.evaluateModelAuth).toHaveBeenCalledWith("amazon-bedrock", { + modelId: "us.anthropic.claude-sonnet-4-5", + observedRoutes: [{ api: "bedrock-converse-stream", baseUrl: undefined }], + }); + expect(authChecker.evaluateModelAuth).toHaveBeenCalledWith("anthropic", { + modelId: "claude-sonnet-4-6", + observedRoutes: [{ api: "anthropic-messages", baseUrl: undefined }], + }); + }); + it("hides legacy runtime providers from default model choices", async () => { loadModelCatalog.mockResolvedValue([ { provider: "codex", id: "gpt-5.5", name: "GPT-5.5" }, @@ -1401,6 +1664,83 @@ describe("promptModelAllowlist", () => { ]); }); + it("preserves static OpenAI route facts for future model auth checks", async () => { + loadStaticManifestCatalogRowsForList.mockReturnValue([ + { + provider: "openai", + id: "gpt-future", + name: "GPT Future", + ref: "openai/gpt-future", + mergeKey: "openai:gpt-future", + source: "manifest", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text"], + reasoning: true, + status: "available", + }, + ]); + + const multiselect = createSelectAllMultiselect(); + await promptModelAllowlist({ + config: { agents: { defaults: {} } }, + prompter: makePrompter({ multiselect }), + preferredProvider: "openai", + }); + + const checker = createProviderAuthChecker.mock.results.at(-1)?.value; + expect(checker).toHaveBeenCalledWith("openai", { + modelId: "gpt-future", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([ + "openai/gpt-future", + ]); + }); + + it("uses the selected route for allowlist capability hints", async () => { + providerAuthRoute.value = { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + }; + loadModelCatalog.mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "Platform GPT-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_000_000, + reasoning: true, + input: ["text", "image"], + }, + { + provider: "openai", + id: "gpt-5.5", + name: "ChatGPT GPT-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 400_000, + reasoning: false, + input: ["text"], + }, + ]); + const multiselect = createSelectAllMultiselect(); + + await promptModelAllowlist({ + config: { agents: { defaults: {} } } as OpenClawConfig, + prompter: makePrompter({ multiselect }), + }); + + const option = requireOption(pickerOptions(multiselect as MockCallSource), "openai/gpt-5.5"); + expect(option.hint).toContain("ChatGPT GPT-5.5"); + expect(option.hint).toContain("ctx 400k"); + expect(option.hint).not.toContain("reasoning"); + }); + it("uses configured provider models for allowlist picker without loading the full catalog in replace mode", async () => { loadModelCatalog.mockResolvedValue([ { diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 13a17d00b096..f3957df6a716 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -81,18 +81,28 @@ vi.mock("../agents/auth-profiles/profile-list.js", () => ({ })); vi.mock("../agents/auth-profiles/store.js", () => ({ + getRuntimeAuthProfileStoreSnapshot: vi.fn(() => undefined), loadAuthProfileStoreWithoutExternalProfiles: ensureAuthProfileStore, + updateAuthProfileStoreWithLock: vi.fn(async () => ensureAuthProfileStore()), })); -vi.mock("../agents/model-auth.js", () => ({ - hasUsableCustomProviderApiKey, - hasSyntheticLocalProviderAuthConfig, - resolveAwsSdkEnvVarName, - resolveEnvApiKey, -})); +vi.mock("../agents/model-auth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hasUsableCustomProviderApiKey, + hasSyntheticLocalProviderAuthConfig, + resolveAwsSdkEnvVarName, + resolveEnvApiKey, + }; +}); vi.mock("../agents/model-catalog.js", () => ({ loadModelCatalog, + loadModelCatalogSnapshot: async (...args: Parameters) => { + const entries = await loadModelCatalog(...args); + return { entries, routeVariants: entries }; + }, })); vi.mock("../agents/embedded-agent-runner/model.js", () => ({ @@ -596,7 +606,7 @@ describe("models list/status", () => { expect(model.available).toBe(true); }); - it("models list all includes unauthenticated provider catalog rows", async () => { + it("models list all includes catalog rows with unknown auth availability", async () => { setDefaultZaiRegistry({ available: false }); hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); loadProviderCatalogModelsForList.mockResolvedValueOnce([MOONSHOT_MODEL]); @@ -613,7 +623,7 @@ describe("models list/status", () => { const model = payload.models[0]; expect(model.key).toBe("moonshot/kimi-k2.6"); expect(model.name).toBe("Kimi K2.6"); - expect(model.available).toBe(false); + expect(model.available).toBeNull(); expect(model.missing).toBe(false); }); @@ -780,7 +790,7 @@ describe("models list/status", () => { expect(model.missing).toBe(false); }); - it("toModelRow marks unavailable when cfg/authStore and availability are undefined", () => { + it("toModelRow keeps auth availability unknown when no evidence exists", () => { const row = toModelRow({ model: makeGoogleAntigravityTemplate( "claude-opus-4-6-thinking", @@ -789,10 +799,11 @@ describe("models list/status", () => { key: "google-antigravity/claude-opus-4-6-thinking", tags: [], availableKeys: undefined, + authAvailability: undefined, }); expect(row.missing).toBe(false); - expect(row.available).toBe(false); + expect(row.available).toBeNull(); }); }); diff --git a/src/commands/models/list.auth-index.test.ts b/src/commands/models/list.auth-index.test.ts index 3c3d6ce9c6c8..d11723dfdc10 100644 --- a/src/commands/models/list.auth-index.test.ts +++ b/src/commands/models/list.auth-index.test.ts @@ -1,10 +1,7 @@ -// Model auth index tests cover auth index loading while listing models. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; -import { withEnvAsync } from "../../test-utils/env.js"; +import type { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { createModelListAuthIndex } from "./list.auth-index.js"; type PluginSnapshotResult = { @@ -25,21 +22,6 @@ const pluginRegistryMocks = vi.hoisted(() => ({ ), })); -const envCandidateMocks = vi.hoisted(() => ({ - resolveProviderEnvAuthLookupMaps: vi.fn(), -})); - -vi.mock("../../agents/model-auth-env-vars.js", async (importOriginal) => { - const actual = await importOriginal(); - envCandidateMocks.resolveProviderEnvAuthLookupMaps.mockImplementation( - actual.resolveProviderEnvAuthLookupMaps, - ); - return { - ...actual, - resolveProviderEnvAuthLookupMaps: envCandidateMocks.resolveProviderEnvAuthLookupMaps, - }; -}); - vi.mock("../../plugins/plugin-registry.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -49,404 +31,142 @@ vi.mock("../../plugins/plugin-registry.js", async (importOriginal) => { }; }); -const emptyStore: AuthProfileStore = { - version: 1, - profiles: {}, -}; +const emptyStore: AuthProfileStore = { version: 1, profiles: {} }; -function modelConfig(id: string) { - return { - id, - name: id, - reasoning: false, - input: ["text" as const], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 8192, - maxTokens: 4096, - }; -} - -async function writeWorkspaceAuthEvidencePlugin(workspaceDir: string) { - const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-cloud"); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile(path.join(pluginDir, "index.ts"), "export default {}\n", "utf8"); - await fs.writeFile( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "workspace-cloud", - configSchema: { type: "object" }, - setup: { - providers: [ - { - id: "workspace-cloud", - authEvidence: [ - { - type: "local-file-with-env", - fileEnvVar: "WORKSPACE_CLOUD_CREDENTIALS", - credentialMarker: "workspace-cloud-local-credentials", - source: "workspace cloud credentials", - }, - ], - }, - ], - }, - }), - "utf8", - ); -} +const dualRouteResolverFactory = (() => () => ({ + kind: "routes", + defaultRuntimeId: "codex", + routes: [ + { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + ], +})) as typeof createOpenAIModelRoutesResolver; describe("createModelListAuthIndex", () => { beforeEach(() => { - envCandidateMocks.resolveProviderEnvAuthLookupMaps.mockClear(); - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockClear(); + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReset(); + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "persisted", + snapshot: { plugins: [] }, + diagnostics: [], + }); }); - it("normalizes auth aliases from profiles", () => { + it("forwards route-aware evaluation through the command adapter", () => { const index = createModelListAuthIndex({ cfg: {}, authStore: { version: 1, profiles: { - "byteplus:default": { + "openai:platform": { type: "api_key", - provider: "byteplus", - key: "sk-test", - }, - }, - }, - env: {}, - }); - - expect(index.hasProviderAuth("byteplus")).toBe(true); - expect(index.hasProviderAuth("byteplus-plan")).toBe(true); - }); - - it("records env-backed providers without resolving env candidates per row", () => { - const index = createModelListAuthIndex({ - cfg: {}, - authStore: emptyStore, - env: { - MOONSHOT_API_KEY: "sk-test", - }, - }); - - expect(index.hasProviderAuth("moonshot")).toBe(true); - expect(index.hasProviderAuth("openai")).toBe(false); - }); - - it("checks resolver-only env auth on demand", () => { - envCandidateMocks.resolveProviderEnvAuthLookupMaps.mockReturnValueOnce({ - aliasMap: {}, - envCandidateMap: {}, - authEvidenceMap: {}, - }); - const index = createModelListAuthIndex({ - cfg: {}, - authStore: emptyStore, - env: { - GOOGLE_CLOUD_API_KEY: "gcp-test", - }, - }); - - expect(index.hasProviderAuth("google-vertex")).toBe(true); - }); - - it("does not rediscover resolver-only env auth when a command metadata snapshot is supplied", () => { - envCandidateMocks.resolveProviderEnvAuthLookupMaps.mockReturnValueOnce({ - aliasMap: {}, - envCandidateMap: {}, - authEvidenceMap: {}, - }); - const metadataSnapshot = { - index: { plugins: [] }, - plugins: [], - }; - const index = createModelListAuthIndex({ - cfg: {}, - authStore: emptyStore, - env: { - GOOGLE_CLOUD_API_KEY: "gcp-test", - }, - metadataSnapshot: metadataSnapshot as unknown as Parameters< - typeof createModelListAuthIndex - >[0]["metadataSnapshot"], - }); - - expect(index.hasProviderAuth("google-vertex")).toBe(false); - }); - - it("uses trusted workspace plugin auth evidence when workspace scope is supplied", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-list-auth-index-")); - const workspaceDir = path.join(tempRoot, "workspace"); - const bundledDir = path.join(tempRoot, "bundled"); - const stateDir = path.join(tempRoot, "state"); - const credentialsPath = path.join(tempRoot, "credentials.json"); - await fs.mkdir(bundledDir, { recursive: true }); - await fs.mkdir(stateDir, { recursive: true }); - await fs.writeFile(credentialsPath, "{}", "utf8"); - await writeWorkspaceAuthEvidencePlugin(workspaceDir); - - try { - await withEnvAsync( - { - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, - OPENCLAW_STATE_DIR: stateDir, - WORKSPACE_CLOUD_CREDENTIALS: credentialsPath, - }, - async () => { - const cfg = { plugins: { allow: ["workspace-cloud"] } }; - const withoutWorkspace = createModelListAuthIndex({ - cfg, - authStore: emptyStore, - env: process.env, - }); - const withWorkspace = createModelListAuthIndex({ - cfg, - authStore: emptyStore, - workspaceDir, - env: process.env, - }); - - expect(withoutWorkspace.hasProviderAuth("workspace-cloud")).toBe(false); - expect(withWorkspace.hasProviderAuth("workspace-cloud")).toBe(true); - }, - ); - } finally { - await fs.rm(tempRoot, { recursive: true, force: true }); - } - }); - - it("records configured provider API keys", () => { - const index = createModelListAuthIndex({ - cfg: { - models: { - providers: { - "custom-openai": { - api: "openai-completions", - apiKey: "sk-configured", - baseUrl: "https://custom.example/v1", - models: [modelConfig("local-model")], - }, - }, - }, - }, - authStore: emptyStore, - env: {}, - }); - - expect(index.hasProviderAuth("custom-openai")).toBe(true); - }); - - it("treats OpenAI OAuth auth as usable for canonical OpenAI agent routes", () => { - const index = createModelListAuthIndex({ - cfg: {}, - authStore: { - version: 1, - profiles: { - "openai:default": { - type: "oauth", provider: "openai", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - }, - "openai:token": { - type: "token", - provider: "openai", - token: "token", + key: "platform-key", }, }, }, env: {}, + routeResolverFactory: dualRouteResolverFactory, }); - expect(index.hasProviderAuth("openai")).toBe(true); + expect(index.evaluateModelAuth("openai", { modelId: "gpt-5.5" })).toMatchObject({ + availability: true, + evidence: "profile", + selectedProfileId: "openai:platform", + selectedRoute: { authRequirement: "api-key" }, + }); }); - it("treats OpenAI token auth as usable for canonical OpenAI agent routes", () => { + it("uses enabled synthetic refs from a persisted plugin snapshot", () => { + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "persisted", + snapshot: { + plugins: [ + { enabled: true, syntheticAuthRefs: ["codex"] }, + { enabled: false, syntheticAuthRefs: ["disabled-provider"] }, + ], + }, + diagnostics: [], + }); const index = createModelListAuthIndex({ cfg: {}, - authStore: { - version: 1, - profiles: { - "openai:token": { - type: "token", - provider: "openai", - token: "token", - }, - }, - }, - env: {}, - }); - - expect(index.hasProviderAuth("openai")).toBe(true); - }); - - it("does not treat OpenAI OAuth auth as usable for custom OpenAI-compatible routes", () => { - const index = createModelListAuthIndex({ - cfg: { - models: { - providers: { - openai: { - api: "openai-completions", - baseUrl: "https://custom.example/v1", - models: [modelConfig("custom-model")], - }, - }, - }, - }, - authStore: { - version: 1, - profiles: { - "openai:default": { - type: "oauth", - provider: "openai", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - }, - }, - }, - env: {}, - }); - - expect(index.hasProviderAuth("openai")).toBe(false); - }); - - it("records configured local custom provider markers", () => { - const index = createModelListAuthIndex({ - cfg: { - models: { - providers: { - "local-openai": { - api: "openai-completions", - baseUrl: "http://127.0.0.1:8080/v1", - models: [modelConfig("local-model")], - }, - }, - }, - }, authStore: emptyStore, env: {}, + routeResolverFactory: dualRouteResolverFactory, }); - expect(index.hasProviderAuth("local-openai")).toBe(true); + const evaluation = index.evaluateModelAuth("openai", { modelId: "gpt-5.5" }); + expect(evaluation).toMatchObject({ + availability: undefined, + evidence: "synthetic", + }); + expect(evaluation).not.toHaveProperty("selectedRoute"); + expect(index.evaluateModelAuth("disabled-provider").availability).toBeUndefined(); }); - it("uses injected synthetic auth refs without loading provider runtime", () => { + it.each(["derived" as const, "persisted" as const])( + "does not trust unusable synthetic refs from a %s snapshot", + (source) => { + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source, + snapshot: { + plugins: [{ enabled: source === "derived", syntheticAuthRefs: ["codex"] }], + }, + diagnostics: [], + }); + const index = createModelListAuthIndex({ + cfg: {}, + authStore: emptyStore, + env: {}, + routeResolverFactory: dualRouteResolverFactory, + }); + + expect(index.evaluateModelAuth("openai", { modelId: "gpt-5.5" })).toMatchObject({ + availability: false, + }); + }, + ); + + it("uses explicit synthetic refs without loading plugin metadata", () => { const index = createModelListAuthIndex({ cfg: {}, authStore: emptyStore, env: {}, syntheticAuthProviderRefs: ["codex"], + routeResolverFactory: dualRouteResolverFactory, }); - expect(index.hasProviderAuth("codex")).toBe(true); + expect(index.evaluateModelAuth("openai").evidence).toBe("synthetic"); + expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); }); - it("uses an injected metadata snapshot index for synthetic auth refs", () => { + it("fails closed before loading refs from a diagnostic-bearing metadata snapshot", () => { const metadataSnapshot = { - index: { - plugins: [{ enabled: true, syntheticAuthRefs: ["codex"] }], - }, - plugins: [], - }; - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockImplementationOnce( - ({ index }: { index?: typeof metadataSnapshot.index } = {}) => ({ - source: "provided", - snapshot: index ?? { plugins: [] }, - diagnostics: [], - }), - ); - + index: { plugins: [] }, + plugins: [{ enabled: true, syntheticAuthRefs: ["codex"] }], + registryDiagnostics: [{ level: "error", message: "invalid plugin metadata" }], + } as unknown as PluginMetadataSnapshot; const index = createModelListAuthIndex({ cfg: {}, authStore: emptyStore, env: {}, - metadataSnapshot: metadataSnapshot as unknown as Parameters< - typeof createModelListAuthIndex - >[0]["metadataSnapshot"], + metadataSnapshot, + routeResolverFactory: dualRouteResolverFactory, }); - expect(index.hasProviderAuth("codex")).toBe(true); - expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith( - expect.objectContaining({ index: metadataSnapshot.index }), - ); - }); - - it("ignores synthetic auth refs from injected derived metadata snapshots", () => { - const metadataSnapshot = { - index: { - plugins: [{ enabled: true, syntheticAuthRefs: ["codex"] }], - }, - plugins: [], - registryDiagnostics: [ - { - level: "info", - code: "persisted-registry-missing", - message: "missing", - }, - ], - }; - - const index = createModelListAuthIndex({ - cfg: {}, - authStore: emptyStore, - env: {}, - metadataSnapshot: metadataSnapshot as unknown as Parameters< - typeof createModelListAuthIndex - >[0]["metadataSnapshot"], - }); - - expect(index.hasProviderAuth("codex")).toBe(false); - expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalledWith( - expect.objectContaining({ index: metadataSnapshot.index }), - ); - }); - - it("keeps synthetic auth refs exact instead of applying auth-choice aliases", () => { - const index = createModelListAuthIndex({ - cfg: {}, - authStore: emptyStore, - env: {}, - syntheticAuthProviderRefs: ["claude-cli"], - }); - - expect(index.hasProviderAuth("claude-cli")).toBe(true); - expect(index.hasProviderAuth("anthropic")).toBe(false); - }); - - it("ignores derived synthetic auth snapshots", () => { - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValueOnce({ - source: "derived", - snapshot: { - plugins: [{ enabled: true, syntheticAuthRefs: ["codex"] }], - }, - diagnostics: [], - }); - const index = createModelListAuthIndex({ - cfg: {}, - authStore: emptyStore, - env: {}, - }); - - expect(index.hasProviderAuth("codex")).toBe(false); - }); - - it("ignores disabled synthetic auth snapshot entries", () => { - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValueOnce({ - source: "persisted", - snapshot: { - plugins: [{ enabled: false, syntheticAuthRefs: ["codex"] }], - }, - diagnostics: [], - }); - const index = createModelListAuthIndex({ - cfg: {}, - authStore: emptyStore, - env: {}, - }); - - expect(index.hasProviderAuth("codex")).toBe(false); + expect(index.evaluateModelAuth("openai").availability).toBe(false); + expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/models/list.auth-index.ts b/src/commands/models/list.auth-index.ts index b4fb0d69dc76..51672d6a316f 100644 --- a/src/commands/models/list.auth-index.ts +++ b/src/commands/models/list.auth-index.ts @@ -1,61 +1,34 @@ -/** Auth availability index for `openclaw models list` rows. */ -import { normalizeProviderIdForAuth } from "@openclaw/model-catalog-core/provider-id"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; -import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; +/** Auth availability index for `openclaw models list` rows. */ import { - listProviderEnvAuthLookupKeys, - resolveProviderEnvAuthLookupMaps, -} from "../../agents/model-auth-env-vars.js"; -import { resolveEnvApiKey } from "../../agents/model-auth-env.js"; -import { resolveAwsSdkEnvVarName } from "../../agents/model-auth-runtime-shared.js"; -import { - hasSyntheticLocalProviderAuthConfig, - hasUsableCustomProviderApiKey, -} from "../../agents/model-auth.js"; -import { - OPENAI_CODEX_PROVIDER_ID, - OPENAI_PROVIDER_ID, - openAIProviderUsesCodexRuntimeByDefault, -} from "../../agents/openai-routing.js"; -import { resolveAgentModelPrimaryValue } from "../../config/model-input.js"; + createModelAuthAvailabilityResolver, + type ModelAuthAvailabilityEvaluation, + type ModelAuthAvailabilityRef, +} from "../../agents/model-auth-availability.js"; +import type { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { loadPluginRegistrySnapshotWithMetadata } from "../../plugins/plugin-registry.js"; +export type ModelListAuthRef = ModelAuthAvailabilityRef; +export type ModelListAuthEvaluation = ModelAuthAvailabilityEvaluation; + export type ModelListAuthIndex = { - hasProviderAuth(provider: string): boolean; - allowsProviderAuthAvailabilityFallback(provider: string): boolean; + evaluateModelAuth(provider: string, ref?: ModelListAuthRef): ModelListAuthEvaluation; }; -/** Inputs used to build the auth index without re-reading process-wide state. */ export type CreateModelListAuthIndexParams = { cfg: OpenClawConfig; authStore: AuthProfileStore; + agentDir?: string; workspaceDir?: string; env?: NodeJS.ProcessEnv; syntheticAuthProviderRefs?: readonly string[]; metadataSnapshot?: PluginMetadataSnapshot; + externalCliProviderIds?: readonly string[]; + routeResolverFactory?: typeof createOpenAIModelRoutesResolver; }; -function normalizeAuthProvider( - provider: string, - aliasMap: Readonly>, -): string { - const normalized = normalizeProviderIdForAuth(provider); - return aliasMap[normalized] ?? normalized; -} - -function normalizeStoredAuthProvider( - provider: string, - aliasMap: Readonly>, -): string { - const normalized = normalizeProviderIdForAuth(provider); - if (normalized === OPENAI_CODEX_PROVIDER_ID) { - return normalized; - } - return aliasMap[normalized] ?? normalized; -} - function listValidatedSyntheticAuthProviderRefs(params: { cfg: OpenClawConfig; workspaceDir?: string; @@ -79,160 +52,30 @@ function listValidatedSyntheticAuthProviderRefs(params: { .flatMap((plugin) => plugin.syntheticAuthRefs ?? []); } -/** Builds a provider-auth lookup from profiles, env, config, and synthetic plugin refs. */ +/** Builds one snapshot-scoped command adapter around the shared evaluator. */ export function createModelListAuthIndex( params: CreateModelListAuthIndexParams, ): ModelListAuthIndex { const env = params.env ?? process.env; - const lookupParams = { - config: params.cfg, + const resolver = createModelAuthAvailabilityResolver({ + cfg: params.cfg, + authStore: params.authStore, + agentDir: params.agentDir, workspaceDir: params.workspaceDir, env, metadataSnapshot: params.metadataSnapshot, - }; - const { aliasMap, envCandidateMap, authEvidenceMap } = - resolveProviderEnvAuthLookupMaps(lookupParams); - const skipSetupProviderFallback = params.metadataSnapshot !== undefined; - const authenticatedProviders = new Set(); - const syntheticAuthProviders = new Set(); - const envProviderAuthCache = new Map(); - const credentialAuthsProvider = (credential: AuthProfileCredential): boolean => { - const normalizedProvider = normalizeStoredAuthProvider(credential.provider, aliasMap); - if (normalizedProvider !== OPENAI_PROVIDER_ID) { - return true; - } - if (credential.type === "api_key") { - return true; - } - if (credential.type !== "oauth" && credential.type !== "token") { - return false; - } - // OpenAI OAuth/token profiles only authenticate provider rows when config - // routes OpenAI through Codex runtime semantics. - return openAIProviderUsesCodexRuntimeByDefault({ - provider: normalizedProvider, - config: params.cfg, - }); - }; - const addProvider = (provider: string | undefined) => { - if (!provider?.trim()) { - return; - } - authenticatedProviders.add(normalizeStoredAuthProvider(provider, aliasMap)); - }; - const addSyntheticProvider = (provider: string | undefined) => { - const normalized = provider?.trim() ? normalizeProviderIdForAuth(provider) : ""; - if (!normalized) { - return; - } - syntheticAuthProviders.add(normalized); - }; - - for (const credential of Object.values(params.authStore.profiles ?? {})) { - if (credentialAuthsProvider(credential)) { - addProvider(credential.provider); - } - } - - for (const provider of listProviderEnvAuthLookupKeys({ envCandidateMap, authEvidenceMap })) { - if ( - resolveEnvApiKey(provider, env, { - aliasMap, - candidateMap: envCandidateMap, - authEvidenceMap, - skipSetupProviderFallback, - config: params.cfg, - workspaceDir: params.workspaceDir, - }) - ) { - addProvider(provider); - } - } - - if (resolveAwsSdkEnvVarName(env)) { - addProvider("amazon-bedrock"); - } - - for (const provider of Object.keys(params.cfg.models?.providers ?? {})) { - if ( - hasUsableCustomProviderApiKey(params.cfg, provider, env) || - hasSyntheticLocalProviderAuthConfig({ cfg: params.cfg, provider }) - ) { - addProvider(provider); - } - } - const primaryModelProvider = resolveAgentModelPrimaryValue( - params.cfg.agents?.defaults?.model, - )?.split("/", 1)[0]; - if (primaryModelProvider === "codex") { - // A Codex primary model is a synthetic provider auth signal even when no - // normal provider key exists in the profile store. - addSyntheticProvider("codex"); - } - - for (const provider of params.syntheticAuthProviderRefs ?? - listValidatedSyntheticAuthProviderRefs({ - cfg: params.cfg, - workspaceDir: params.workspaceDir, - env, - metadataSnapshot: params.metadataSnapshot, - })) { - addSyntheticProvider(provider); - } - - const hasEnvProviderAuth = (provider: string): boolean => { - const normalized = normalizeAuthProvider(provider, aliasMap); - const cached = envProviderAuthCache.get(normalized); - if (cached !== undefined) { - return cached; - } - const hasPrecomputedCandidates = Object.hasOwn(envCandidateMap, normalized); - const hasPrecomputedEvidence = Object.hasOwn(authEvidenceMap, normalized); - const hasAuth = Boolean( - resolveEnvApiKey(provider, env, { - aliasMap, - candidateMap: - skipSetupProviderFallback || hasPrecomputedCandidates ? envCandidateMap : undefined, - authEvidenceMap: - skipSetupProviderFallback || hasPrecomputedEvidence ? authEvidenceMap : undefined, - skipSetupProviderFallback, - config: params.cfg, + externalCliProviderIds: params.externalCliProviderIds, + routeResolverFactory: params.routeResolverFactory, + syntheticAuthProviderRefs: + params.syntheticAuthProviderRefs ?? + listValidatedSyntheticAuthProviderRefs({ + cfg: params.cfg, workspaceDir: params.workspaceDir, + env, + metadataSnapshot: params.metadataSnapshot, }), - ); - envProviderAuthCache.set(normalized, hasAuth); - if (hasAuth) { - authenticatedProviders.add(normalized); - } - return hasAuth; - }; - - const hasOpenAICodexRuntimeAuth = (provider: string): boolean => { - const normalizedProvider = normalizeAuthProvider(provider, aliasMap); - return ( - openAIProviderUsesCodexRuntimeByDefault({ - provider: normalizedProvider, - config: params.cfg, - }) && - (authenticatedProviders.has(OPENAI_PROVIDER_ID) || - authenticatedProviders.has(OPENAI_CODEX_PROVIDER_ID)) - ); - }; - + }); return { - hasProviderAuth(provider: string): boolean { - const normalizedProvider = normalizeAuthProvider(provider, aliasMap); - const hasDirectAuth = - authenticatedProviders.has(normalizedProvider) || - syntheticAuthProviders.has(normalizeProviderIdForAuth(provider)) || - hasEnvProviderAuth(provider); - if (hasDirectAuth) { - return true; - } - return hasOpenAICodexRuntimeAuth(normalizedProvider); - }, - allowsProviderAuthAvailabilityFallback(provider: string): boolean { - return hasOpenAICodexRuntimeAuth(provider); - }, + evaluateModelAuth: (provider, ref) => resolver.evaluateModelAuth(provider, ref), }; } diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index f7b7de962c95..f8b1a84668b5 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -288,7 +288,8 @@ function installModelsListCommandForwardCompatMocks() { }, })); - vi.doMock("../../agents/auth-profiles/store.js", () => ({ + vi.doMock("../../agents/auth-profiles/store.js", async (importOriginal) => ({ + ...(await importOriginal()), loadAuthProfileStoreWithoutExternalProfiles: mocks.ensureAuthProfileStore, })); @@ -300,15 +301,21 @@ function installModelsListCommandForwardCompatMocks() { resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), })); - vi.doMock("../../agents/model-catalog.js", () => ({ + vi.doMock("../../agents/model-catalog.js", async (importOriginal) => ({ + ...(await importOriginal()), loadModelCatalog: mocks.loadModelCatalog, + loadModelCatalogSnapshot: async (...args: unknown[]) => { + const entries = await mocks.loadModelCatalog(...args); + return { entries, routeVariants: entries }; + }, })); vi.doMock("../../agents/embedded-agent-runner/model.js", () => ({ resolveModelWithRegistry: mocks.resolveModelWithRegistry, })); - vi.doMock("../../agents/model-auth.js", () => ({ + vi.doMock("../../agents/model-auth.js", async (importOriginal) => ({ + ...(await importOriginal()), hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), hasSyntheticLocalProviderAuthConfig: vi.fn().mockReturnValue(false), })); @@ -345,8 +352,10 @@ async function buildAllOpenAiCodexRows(opts: { supplementCatalog?: boolean } = { cfg: mocks.resolvedConfig, agentDir: "/tmp/openclaw-agent", authIndex: { - hasProviderAuth: (provider: string) => provider === "openai", - allowsProviderAuthAvailabilityFallback: () => false, + evaluateModelAuth: (provider: string) => ({ + availability: provider === "openai", + routeResolution: null, + }), }, availableKeys: loaded.availableKeys, configuredByKey: new Map(), @@ -802,14 +811,25 @@ describe("modelsListCommand forward-compat", () => { }); describe("availability fallback", () => { - it("marks synthetic codex gpt-5.4 rows as available when provider auth exists", async () => { + it("marks synthetic codex gpt-5.4 rows available with compatible OAuth auth", async () => { + const oauthConfig = { + agents: { defaults: { model: { primary: "openai/gpt-5.4" } } }, + models: { providers: { openai: {} } }, + }; + mocks.loadModelsConfigWithSource.mockResolvedValueOnce({ + sourceConfig: oauthConfig, + resolvedConfig: oauthConfig, + diagnostics: [], + }); mocks.ensureAuthProfileStore.mockReturnValueOnce({ version: 1, profiles: { "openai:default": { - type: "token", + type: "oauth", provider: "openai", - token: "codex-app-server", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, }, }, order: {}, @@ -845,7 +865,7 @@ describe("modelsListCommand forward-compat", () => { }); describe("--all catalog supplementation", () => { - it("uses the provider catalog fast path for Codex provider lists", async () => { + it("keeps provider-catalog Codex availability indeterminate without model auth", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadProviderCatalogModelsForList.mockResolvedValueOnce([ @@ -882,9 +902,9 @@ describe("modelsListCommand forward-compat", () => { staticOnly: true, }), ); - const rows = lastPrintedRows<{ key: string; available: boolean }>(); + const rows = lastPrintedRows<{ key: string; available: boolean | null }>(); expectRowKeys(rows, ["codex/gpt-5.4"]); - expectRowFields(rows, "codex/gpt-5.4", { available: true }); + expectRowFields(rows, "codex/gpt-5.4", { available: null }); }); it("uses manifest catalog rows before provider runtime catalog rows", async () => { @@ -1006,10 +1026,22 @@ describe("modelsListCommand forward-compat", () => { it("does not load broad provider runtime catalogs for unfiltered all-model lists", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); mocks.loadModelRegistry.mockResolvedValueOnce({ - models: [{ ...OPENAI_CODEX_MODEL }], + models: [ + { + ...OPENAI_CODEX_MODEL, + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + ], availableKeys: new Set(["openai/gpt-5.4"]), registry: { - getAll: () => [{ ...OPENAI_CODEX_MODEL }], + getAll: () => [ + { + ...OPENAI_CODEX_MODEL, + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + ], }, }); mocks.loadSupplementalManifestCatalogRowsForList.mockReturnValueOnce([ @@ -1212,6 +1244,28 @@ describe("modelsListCommand forward-compat", () => { it("uses provider runtime metadata for discovered codex gpt-5.5 rows", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); + const oauthConfig = { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + models: { providers: { openai: {} } }, + }; + mocks.loadModelsConfigWithSource.mockResolvedValueOnce({ + sourceConfig: oauthConfig, + resolvedConfig: oauthConfig, + diagnostics: [], + }); + mocks.ensureAuthProfileStore.mockReturnValueOnce({ + version: 1, + profiles: { + "openai:default": { + type: "oauth", + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }, + order: {}, + }); mocks.loadModelRegistry.mockResolvedValueOnce({ models: [ { @@ -1309,8 +1363,7 @@ describe("modelsListCommand forward-compat", () => { context: { cfg: mocks.resolvedConfig, authIndex: { - hasProviderAuth: () => false, - allowsProviderAuthAvailabilityFallback: () => false, + evaluateModelAuth: () => ({ availability: false, routeResolution: null }), }, availableKeys: new Set(["openai/gpt-5.4"]), configuredByKey: new Map(), diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index bd6e051daac3..5e31cc0f3187 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -106,11 +106,16 @@ export async function modelsListCommand( metadataSnapshot, }) : undefined; + const { entries } = resolveConfiguredEntries(cfg, metadataSnapshot); const authIndex = createModelListAuthIndex({ cfg, authStore, + agentDir, workspaceDir, metadataSnapshot, + // Default output can append authenticated catalog rows beyond the configured + // default, so keep the nonprompting OpenAI CLI overlay available in every view. + externalCliProviderIds: ["openai"], }); let modelRegistry: ModelRegistry | undefined; @@ -118,7 +123,6 @@ export async function modelsListCommand( let discoveredKeys = new Set(); let availableKeys: Set | undefined; let availabilityErrorMessage: string | undefined; - const { entries } = resolveConfiguredEntries(cfg, metadataSnapshot); const configuredByKey = new Map(entries.map((entry) => [entry.key, entry])); const enableSourcePlanCascade = Boolean(opts.all) || Boolean(providerFilter); // Full/provider-filtered lists may need runtime, manifest, and registry rows. diff --git a/src/commands/models/list.model-row.test.ts b/src/commands/models/list.model-row.test.ts index 6f7598ffd72a..fd3d275ea09a 100644 --- a/src/commands/models/list.model-row.test.ts +++ b/src/commands/models/list.model-row.test.ts @@ -24,6 +24,7 @@ describe("toModelRow", () => { } as never, key: "openrouter/openai/gpt-5.4", tags: [], + authAvailability: false, }); expect(row.contextWindow).toBe(400_000); @@ -35,12 +36,25 @@ describe("toModelRow", () => { model: OPENROUTER_MODEL as never, key: "openrouter/openai/gpt-5.4", tags: [], - hasAuthForProvider: (provider) => provider === "openrouter", + authAvailability: true, }); expect(row.available).toBe(true); }); + it("keeps authoritative route auth unknown despite provider-level registry auth", () => { + const row = toModelRow({ + model: OPENROUTER_MODEL as never, + key: "openai/gpt-5.5", + tags: [], + availableKeys: new Set(["openai/gpt-5.5"]), + authAvailability: undefined, + authAvailabilityAuthoritative: true, + }); + + expect(row.available).toBeNull(); + }); + it("marks bracketed IPv6 loopback base URLs as local", () => { for (const baseUrl of ["http://[::1]:11434/v1", "http://[::]:11434/v1"]) { const row = toModelRow({ @@ -51,6 +65,7 @@ describe("toModelRow", () => { } as never, key: "ollama/llama3.2", tags: [], + authAvailability: undefined, }); expect(row.local).toBe(true); @@ -69,6 +84,7 @@ describe("toModelRow", () => { key: "ollama/qwen3.6:35b-a3b", tags: [], availableKeys: new Set(["ollama/llama3.2"]), + authAvailability: undefined, }); expect(row.local).toBe(true); diff --git a/src/commands/models/list.model-row.ts b/src/commands/models/list.model-row.ts index 1922142e1824..26735472ec75 100644 --- a/src/commands/models/list.model-row.ts +++ b/src/commands/models/list.model-row.ts @@ -8,15 +8,13 @@ export type ListRowModel = { id: string; name: string; provider: string; - input: Array<"text" | "image" | "document">; + api?: string | null; + input?: Array<"text" | "image" | "document">; baseUrl?: string; contextWindow?: number | null; contextTokens?: number | null; }; -/** Provider-auth predicate used when model-level availability is unavailable. */ -export type ModelAuthAvailabilityResolver = (provider: string) => boolean; - /** Builds a display row, preserving configured tags and alias metadata. */ export function toModelRow(params: { model?: ListRowModel; @@ -24,8 +22,8 @@ export function toModelRow(params: { tags: string[]; aliases?: string[]; availableKeys?: Set; - allowProviderAvailabilityFallback?: boolean; - hasAuthForProvider?: ModelAuthAvailabilityResolver; + authAvailability: boolean | undefined; + authAvailabilityAuthoritative?: boolean; }): ModelRow { const { model, @@ -33,7 +31,8 @@ export function toModelRow(params: { tags, aliases = [], availableKeys, - allowProviderAvailabilityFallback = false, + authAvailability, + authAvailabilityAuthoritative = false, } = params; if (!model) { return { @@ -48,18 +47,17 @@ export function toModelRow(params: { }; } - const input = model.input.join("+") || "text"; + const input = model.input?.join("+") || "-"; const local = isLocalBaseUrl(model.baseUrl ?? ""); const modelIsAvailable = local || (availableKeys?.has(modelKey(model.provider, model.id)) ?? false); - // Local provider rows use their baseUrl as the auth marker. - // Otherwise prefer model-level registry availability when present. - // Fall back to provider-level auth heuristics only if registry availability isn't available, - // or if the caller marks this as a synthetic/forward-compat model that won't appear in getAvailable(). - const available = - availableKeys !== undefined && !allowProviderAvailabilityFallback + // Registry model availability remains authoritative unless the row is outside + // that inventory or provider-owned route facts select a physical auth route. + const available = authAvailabilityAuthoritative + ? (authAvailability ?? null) + : availableKeys !== undefined ? modelIsAvailable - : modelIsAvailable || (params.hasAuthForProvider?.(model.provider) ?? false); + : (authAvailability ?? (modelIsAvailable ? true : null)); const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : []; const mergedTags = new Set(tags); if (aliasTags.length > 0) { diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 0bc2409bc9e4..43df135584d5 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -15,8 +15,8 @@ import { listProfilesForProvider, resolveAuthProfileDisplayLabel, resolveAuthProfileEligibility, - resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; +import { resolveAuthProfileOrderWithMetadata } from "../../agents/auth-profiles/order.js"; import { describeFailoverError } from "../../agents/failover-error.js"; import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; @@ -341,10 +341,15 @@ export async function buildProbeTargets(params: { findNormalizedProviderValue(cfg?.auth?.order, providerKey) ); })(); - const allowedProfiles = - explicitOrder && explicitOrder.length > 0 - ? new Set(resolveAuthProfileOrder({ cfg, store, provider: providerKey })) - : null; + const orderResolution = resolveAuthProfileOrderWithMetadata({ + cfg, + store, + provider: providerKey, + forModel: model?.model, + }); + const allowedProfiles = orderResolution.hasExplicitOrder + ? new Set(orderResolution.profileIds) + : null; // Explicit auth.order both selects and documents profile eligibility; report // excluded profiles instead of silently skipping them. const filteredProfiles = profileFilter.size @@ -439,12 +444,17 @@ export async function buildProbeTargets(params: { if (profileFilter.size > 0) { continue; } - - const envKey = resolveEnvApiKey(providerKey, process.env, { - config: cfg, - workspaceDir, - }); const hasUsableModelsJsonKey = hasUsableCustomProviderApiKey(cfg, providerKey); + if (orderResolution.hasExplicitOrder && !hasUsableModelsJsonKey) { + continue; + } + + const envKey = orderResolution.hasExplicitOrder + ? null + : resolveEnvApiKey(providerKey, process.env, { + config: cfg, + workspaceDir, + }); if (!envKey && !hasUsableModelsJsonKey) { continue; } diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index f116fc3ec29b..3ac6e247e1a7 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -1,8 +1,9 @@ // Model list row tests cover rendered row construction for model listing output. -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelRow } from "./list.types.js"; const mocks = vi.hoisted(() => ({ + loadModelCatalogSnapshot: vi.fn(), normalizeProviderResolvedModelWithPlugin: vi.fn(() => undefined), shouldSuppressBuiltInModel: vi.fn(() => { throw new Error("runtime model suppression should be skipped"); @@ -15,17 +16,33 @@ vi.mock("../../agents/model-suppression.js", () => ({ shouldSuppressBuiltInModelFromManifest: mocks.shouldSuppressBuiltInModelFromManifest, })); +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalogSnapshot: mocks.loadModelCatalogSnapshot, +})); + vi.mock("../../plugins/provider-runtime.js", () => ({ normalizeProviderResolvedModelWithPlugin: mocks.normalizeProviderResolvedModelWithPlugin, })); -import { appendConfiguredProviderRows, appendProviderCatalogRows } from "./list.rows.js"; +import { + appendAuthenticatedCatalogRows, + appendConfiguredRows, + appendConfiguredProviderRows, + appendDiscoveredRows, + appendProviderCatalogRows, +} from "./list.rows.js"; const authIndex = { - hasProviderAuth: (provider: string) => provider === "codex", - allowsProviderAuthAvailabilityFallback: () => false, + evaluateModelAuth: (provider: string) => ({ + availability: provider === "codex", + routeResolution: null, + }), }; +function authEvaluation(availability: boolean | undefined) { + return { availability, routeResolution: null }; +} + function requireOnlyRow(rows: ModelRow[]): ModelRow { expect(rows).toHaveLength(1); const row = rows[0]; @@ -35,6 +52,203 @@ function requireOnlyRow(rows: ModelRow[]): ModelRow { return row; } +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("appendDiscoveredRows", () => { + it("does not borrow provider registry auth when an OpenAI route is unknown", async () => { + const rows: ModelRow[] = []; + + await appendDiscoveredRows({ + rows, + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + }, + ] as never, + context: { + cfg: {}, + agentDir: "/tmp/openclaw-agent", + authIndex: { evaluateModelAuth: () => authEvaluation(undefined) }, + configuredByKey: new Map(), + discoveredKeys: new Set(["openai/gpt-5.5"]), + availableKeys: new Set(["openai/gpt-5.5"]), + filter: { provider: "openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(requireOnlyRow(rows).available).toBeNull(); + }); + + it("projects the selected ChatGPT row regardless of physical row order", async () => { + const selectedRoute = { + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription" as const, + requestTransportOverrides: "none" as const, + }; + const rows: ModelRow[] = []; + + await appendDiscoveredRows({ + rows, + models: [ + { + id: "gpt-5.5", + name: "Platform GPT-5.5", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text", "image"], + reasoning: true, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, + { + id: "gpt-5.5", + name: "ChatGPT GPT-5.5", + provider: "openai", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }, + ] as never, + context: { + cfg: {}, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth: () => ({ + availability: true, + routeResolution: { kind: "routes", routes: [selectedRoute] }, + selectedRoute, + }), + }, + configuredByKey: new Map(), + discoveredKeys: new Set(["openai/gpt-5.5"]), + filter: { provider: "openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(requireOnlyRow(rows)).toMatchObject({ + name: "ChatGPT GPT-5.5", + input: "text", + contextWindow: 400_000, + available: true, + }); + }); + + it("omits physical capabilities while managed route selection is unresolved", async () => { + const rows: ModelRow[] = []; + + await appendDiscoveredRows({ + rows, + models: [ + { + id: "gpt-5.5", + name: "Platform GPT-5.5", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text", "image"], + reasoning: true, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, + ] as never, + context: { + cfg: {}, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth: () => ({ + availability: false, + routeResolution: { kind: "indeterminate", defaultRuntimeId: "codex" }, + }), + }, + configuredByKey: new Map(), + discoveredKeys: new Set(["openai/gpt-5.5"]), + filter: { provider: "openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(requireOnlyRow(rows)).toMatchObject({ + name: "Platform GPT-5.5", + input: "-", + contextWindow: null, + available: false, + }); + }); +}); + +describe("appendConfiguredRows", () => { + it("does not borrow discovered registry auth when an OpenAI route is unknown", async () => { + const rows: ModelRow[] = []; + + await appendConfiguredRows({ + rows, + entries: [ + { + key: "openai/gpt-5.5", + ref: { provider: "openai", model: "gpt-5.5" }, + tags: new Set(["default"]), + aliases: [], + }, + ], + context: { + cfg: { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }, + ], + }, + }, + }, + }, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth: () => ({ availability: undefined, routeResolution: null }), + }, + availableKeys: new Set(["openai/gpt-5.5"]), + configuredByKey: new Map(), + discoveredKeys: new Set(["openai/gpt-5.5"]), + filter: { provider: "openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(requireOnlyRow(rows).available).toBeNull(); + }); +}); + describe("appendProviderCatalogRows", () => { it("can skip runtime model-suppression hooks for provider-catalog fast paths", async () => { const rows: ModelRow[] = []; @@ -115,8 +329,7 @@ describe("appendProviderCatalogRows", () => { }, agentDir: "/tmp/openclaw-agent", authIndex: { - hasProviderAuth: () => false, - allowsProviderAuthAvailabilityFallback: () => false, + evaluateModelAuth: () => authEvaluation(false), }, configuredByKey: new Map(), discoveredKeys: new Set(), @@ -140,6 +353,9 @@ describe("appendProviderCatalogRows", () => { it("uses Codex auth availability for configured canonical OpenAI rows", async () => { const rows: ModelRow[] = []; + const evaluateModelAuth = vi.fn((_provider: string, ref: { modelId?: string }) => + authEvaluation(ref.modelId === "gpt-5.5"), + ); await appendProviderCatalogRows({ rows, @@ -165,8 +381,7 @@ describe("appendProviderCatalogRows", () => { }, agentDir: "/tmp/openclaw-agent", authIndex: { - hasProviderAuth: (provider: string) => provider === "openai", - allowsProviderAuthAvailabilityFallback: (provider: string) => provider === "openai", + evaluateModelAuth, }, configuredByKey: new Map([ [ @@ -190,6 +405,126 @@ describe("appendProviderCatalogRows", () => { expect(row.key).toBe("openai/gpt-5.5"); expect(row.available).toBe(true); expect(row.tags).toEqual(["configured"]); + expect(evaluateModelAuth).toHaveBeenCalledOnce(); + expect(evaluateModelAuth).toHaveBeenCalledWith("openai", { + modelId: "gpt-5.5", + observedRoutes: [ + { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + ], + }); + }); + + it("preserves unknown route auth instead of borrowing provider registry availability", async () => { + const rows: ModelRow[] = []; + + await appendProviderCatalogRows({ + rows, + seenKeys: new Set(), + catalogModels: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text", "image"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + }, + ], + context: { + cfg: {}, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth: () => authEvaluation(undefined), + }, + configuredByKey: new Map(), + discoveredKeys: new Set(["openai/gpt-5.5"]), + availableKeys: new Set(["openai/gpt-5.5"]), + filter: { provider: "openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(requireOnlyRow(rows).available).toBeNull(); + }); + + it("preserves registry-negative availability for non-route provider auth", async () => { + const rows: ModelRow[] = []; + + await appendProviderCatalogRows({ + rows, + seenKeys: new Set(), + catalogModels: [ + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + provider: "anthropic", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + }, + ], + context: { + cfg: {}, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth: () => authEvaluation(true), + }, + configuredByKey: new Map(), + discoveredKeys: new Set(["anthropic/claude-sonnet-4-6"]), + availableKeys: new Set(), + filter: { provider: "anthropic", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(requireOnlyRow(rows).available).toBe(false); + }); + + it("keeps unresolved native route auth unknown without positive registry evidence", async () => { + const rows: ModelRow[] = []; + + await appendProviderCatalogRows({ + rows, + seenKeys: new Set(), + catalogModels: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text", "image"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + }, + ], + context: { + cfg: {}, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth: () => authEvaluation(undefined), + }, + configuredByKey: new Map(), + discoveredKeys: new Set(), + filter: { provider: "openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(requireOnlyRow(rows).available).toBeNull(); }); }); @@ -241,4 +576,194 @@ describe("appendConfiguredProviderRows", () => { expect(mocks.normalizeProviderResolvedModelWithPlugin).toHaveBeenCalledOnce(); expect(requireOnlyRow(rows).input).toBe("text+image"); }); + + it("threads configured model route facts into auth availability", async () => { + const rows: ModelRow[] = []; + const evaluateModelAuth = vi.fn(() => authEvaluation(false)); + + await appendConfiguredProviderRows({ + rows, + seenKeys: new Set(), + context: { + cfg: { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.6", + name: "GPT-5.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_050_000, + maxTokens: 128_000, + }, + ], + }, + }, + }, + }, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth, + }, + availableKeys: new Set(["openai/gpt-5.6"]), + configuredByKey: new Map(), + discoveredKeys: new Set(["openai/gpt-5.6"]), + filter: { provider: "openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(requireOnlyRow(rows).available).toBe(false); + expect(evaluateModelAuth).toHaveBeenCalledOnce(); + expect(evaluateModelAuth).toHaveBeenCalledWith("openai", { + modelId: "gpt-5.6", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + }); + + it("preserves configured route facts when provider normalization omits them", async () => { + mocks.normalizeProviderResolvedModelWithPlugin.mockReturnValueOnce({ + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + input: ["text", "image"], + contextWindow: 400_000, + } as never); + const rows: ModelRow[] = []; + const evaluateModelAuth = vi.fn(() => authEvaluation(true)); + + await appendConfiguredProviderRows({ + rows, + seenKeys: new Set(), + context: { + cfg: { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }, + ], + }, + }, + }, + }, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth, + }, + configuredByKey: new Map(), + discoveredKeys: new Set(), + filter: { provider: "openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(requireOnlyRow(rows).available).toBe(true); + expect(evaluateModelAuth).toHaveBeenCalledOnce(); + expect(evaluateModelAuth).toHaveBeenCalledWith("openai", { + modelId: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + }); +}); + +describe("appendAuthenticatedCatalogRows", () => { + it("keeps runnable synthetic local catalog rows", async () => { + const entries = [ + { + id: "local-model", + name: "Local Model", + provider: "local-openai", + api: "openai-completions", + baseUrl: "http://127.0.0.1:8080/v1", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + }, + ]; + mocks.loadModelCatalogSnapshot.mockResolvedValueOnce({ entries, routeVariants: entries }); + const rows: ModelRow[] = []; + + await appendAuthenticatedCatalogRows({ + rows, + seenKeys: new Set(), + context: { + cfg: {}, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth: () => ({ + availability: undefined, + evidence: "synthetic", + routeResolution: null, + }), + }, + configuredByKey: new Map(), + discoveredKeys: new Set(), + filter: { provider: "local-openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(requireOnlyRow(rows)).toMatchObject({ + key: "local-openai/local-model", + local: true, + available: true, + }); + }); + + it("still drops catalog rows with unresolved non-synthetic auth", async () => { + const entries = [ + { + id: "remote-model", + name: "Remote Model", + provider: "remote-provider", + api: "openai-completions", + baseUrl: "https://models.example.test/v1", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + }, + ]; + mocks.loadModelCatalogSnapshot.mockResolvedValueOnce({ entries, routeVariants: entries }); + const rows: ModelRow[] = []; + + await appendAuthenticatedCatalogRows({ + rows, + seenKeys: new Set(), + context: { + cfg: {}, + agentDir: "/tmp/openclaw-agent", + authIndex: { + evaluateModelAuth: () => ({ availability: undefined, routeResolution: null }), + }, + configuredByKey: new Map(), + discoveredKeys: new Set(), + filter: { provider: "remote-provider", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(rows).toEqual([]); + }); }); diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index aff85ab30413..c9d02383d5c4 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -1,11 +1,21 @@ /** Row builders used by `openclaw models list` source orchestration. */ import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/model-catalog-types"; -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { + normalizeProviderId, + normalizeProviderIdForAuth, +} from "@openclaw/model-catalog-core/provider-id"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; +import { + projectModelCatalogEntryForRoute, + resolveConfiguredModelCatalogOverrides, +} from "../../agents/model-catalog-route.js"; +import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; +import { modelCatalogLogicalKey } from "../../agents/model-selection-shared.js"; import { shouldSuppressBuiltInModel, shouldSuppressBuiltInModelFromManifest, } from "../../agents/model-suppression.js"; +import { openAIModelCatalogRoutePolicy } from "../../agents/openai-model-routes.js"; import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ModelRegistry } from "../../llm/model-registry.js"; @@ -14,7 +24,11 @@ import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snaps import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { normalizeProviderResolvedModelWithPlugin } from "../../plugins/provider-runtime.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; -import type { ModelListAuthIndex } from "./list.auth-index.js"; +import type { + ModelListAuthEvaluation, + ModelListAuthIndex, + ModelListAuthRef, +} from "./list.auth-index.js"; import { isLocalBaseUrl } from "./list.local-url.js"; import type { ListRowModel } from "./list.model-row.js"; import { toModelRow } from "./list.model-row.js"; @@ -93,29 +107,139 @@ function matchesRowFilter( return true; } +type ModelCatalogLogicalRouteIndex = ReadonlyMap; + +function resolveCatalogLogicalKey(model: Pick): string { + return openAIModelCatalogRoutePolicy.resolveIdentity(model)?.key ?? modelCatalogLogicalKey(model); +} + +function createModelCatalogLogicalRouteIndex( + catalog: readonly ModelCatalogEntry[], +): ModelCatalogLogicalRouteIndex { + const index = new Map(); + for (const entry of catalog) { + const key = resolveCatalogLogicalKey(entry); + const variants = index.get(key) ?? []; + variants.push(entry); + index.set(key, variants); + } + return index; +} + +function resolveCatalogLogicalRoutes( + model: Pick, + routeIndex: ModelCatalogLogicalRouteIndex | undefined, +): readonly ModelCatalogEntry[] | undefined { + return routeIndex?.get(resolveCatalogLogicalKey(model)); +} + +function toModelAuthRef( + model: ListRowModel, + routeIndex?: ModelCatalogLogicalRouteIndex, +): ModelListAuthRef { + const identity = openAIModelCatalogRoutePolicy.resolveIdentity(model); + const observedRoutes = resolveCatalogLogicalRoutes(model, routeIndex)?.map((entry) => ({ + api: entry.api, + baseUrl: entry.baseUrl, + })); + return { + modelId: identity?.id ?? model.id, + ...(observedRoutes && observedRoutes.length > 0 + ? { observedRoutes } + : { api: model.api, baseUrl: model.baseUrl }), + }; +} + +function toCatalogProjectionEntry(model: ListRowModel): ModelCatalogEntry { + return { + id: model.id, + name: model.name, + provider: model.provider, + ...(typeof model.api === "string" ? { api: model.api as ModelCatalogEntry["api"] } : {}), + ...(model.baseUrl !== undefined ? { baseUrl: model.baseUrl } : {}), + ...(typeof model.contextWindow === "number" ? { contextWindow: model.contextWindow } : {}), + ...(typeof model.contextTokens === "number" ? { contextTokens: model.contextTokens } : {}), + ...(model.input !== undefined ? { input: model.input } : {}), + }; +} + +function hasSameCatalogRoute(left: ListRowModel, right: ListRowModel): boolean { + return left.api === right.api && left.baseUrl === right.baseUrl; +} + +function projectListRowModel(params: { + model: ListRowModel; + evaluation: ModelListAuthEvaluation; + cfg: OpenClawConfig; + routeIndex?: ModelCatalogLogicalRouteIndex; +}): ListRowModel { + const projection = + params.evaluation.routeResolution === null + ? ({ kind: "unmanaged" } as const) + : params.evaluation.selectedRoute + ? ({ + kind: "selected", + route: params.evaluation.selectedRoute, + policy: openAIModelCatalogRoutePolicy, + } as const) + : ({ kind: "unresolved", policy: openAIModelCatalogRoutePolicy } as const); + const entry = toCatalogProjectionEntry(params.model); + const overrides = resolveConfiguredModelCatalogOverrides({ + cfg: params.cfg, + entry, + policy: openAIModelCatalogRoutePolicy, + }); + const routeVariants = resolveCatalogLogicalRoutes(entry, params.routeIndex); + const projected = projectModelCatalogEntryForRoute({ + entry, + projection, + ...(routeVariants ? { catalog: routeVariants } : {}), + ...(overrides ? { overrides } : {}), + }); + return { + ...params.model, + name: projected.name, + api: projected.api, + baseUrl: projected.baseUrl, + input: projected.input?.filter( + (item): item is NonNullable[number] => + item === "text" || item === "image" || item === "document", + ), + contextWindow: projected.contextWindow, + contextTokens: projected.contextTokens, + }; +} + async function buildRow(params: { model: ListRowModel; key: string; context: RowBuilderContext; - allowProviderAvailabilityFallback?: boolean; + routeIndex?: ModelCatalogLogicalRouteIndex; + authEvaluation?: ModelListAuthEvaluation; + allowAuthAvailabilityOverride?: boolean; }): Promise { const configured = params.context.configuredByKey.get(params.key); - const allowProviderAvailabilityFallback = - params.allowProviderAvailabilityFallback === true || - (configured !== undefined && - params.context.authIndex.allowsProviderAuthAvailabilityFallback(params.model.provider)); - const shouldResolveProviderAuth = - params.context.availableKeys === undefined || allowProviderAvailabilityFallback; - return toModelRow({ + const authRef = toModelAuthRef(params.model, params.routeIndex); + const authEvaluation = + params.authEvaluation ?? + params.context.authIndex.evaluateModelAuth(params.model.provider, authRef); + const model = projectListRowModel({ model: params.model, + evaluation: authEvaluation, + cfg: params.context.cfg, + ...(params.routeIndex ? { routeIndex: params.routeIndex } : {}), + }); + return toModelRow({ + model, key: params.key, tags: configured ? Array.from(configured.tags) : [], aliases: configured?.aliases ?? [], availableKeys: params.context.availableKeys, - allowProviderAvailabilityFallback, - hasAuthForProvider: shouldResolveProviderAuth - ? (provider) => params.context.authIndex.hasProviderAuth(provider) - : undefined, + authAvailability: authEvaluation.availability, + authAvailabilityAuthoritative: + params.allowAuthAvailabilityOverride === true || + normalizeProviderIdForAuth(params.model.provider) === "openai" || + authEvaluation.routeResolution !== null, }); } @@ -165,7 +289,8 @@ function normalizeListRowWithProviderPlugin(params: { id: normalized.id, name: normalized.name, provider: normalized.provider, - baseUrl: normalized.baseUrl, + api: normalized.api ?? params.model.api, + baseUrl: normalized.baseUrl ?? params.model.baseUrl, input: toListRowInput(normalized.input), contextWindow: normalized.contextWindow, contextTokens: normalized.contextTokens, @@ -178,23 +303,40 @@ async function appendVisibleRow(params: { key: string; context: RowBuilderContext; seenKeys?: Set; - allowProviderAvailabilityFallback?: boolean; + authEvaluation?: ModelListAuthEvaluation; + routeIndex?: ModelCatalogLogicalRouteIndex; + allowAuthAvailabilityOverride?: boolean; skipSuppression?: boolean; normalizeWithProviderPlugin?: boolean; }): Promise { if (params.seenKeys?.has(params.key)) { return false; } - if (!matchesRowFilter(params.context, params.model)) { - return false; - } const model = params.normalizeWithProviderPlugin ? normalizeListRowWithProviderPlugin({ model: params.model, context: params.context, }) : params.model; - if (!params.skipSuppression && shouldSuppressListModel({ model, context: params.context })) { + const authEvaluation = + params.authEvaluation ?? + params.context.authIndex.evaluateModelAuth( + model.provider, + toModelAuthRef(model, params.routeIndex), + ); + const projectedModel = projectListRowModel({ + model, + evaluation: authEvaluation, + cfg: params.context.cfg, + ...(params.routeIndex ? { routeIndex: params.routeIndex } : {}), + }); + if (!matchesRowFilter(params.context, projectedModel)) { + return false; + } + if ( + !params.skipSuppression && + shouldSuppressListModel({ model: projectedModel, context: params.context }) + ) { return false; } params.rows.push( @@ -202,7 +344,9 @@ async function appendVisibleRow(params: { model, key: params.key, context: params.context, - allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback, + ...(params.routeIndex ? { routeIndex: params.routeIndex } : {}), + authEvaluation, + allowAuthAvailabilityOverride: params.allowAuthAvailabilityOverride, }), ); params.seenKeys?.add(params.key); @@ -229,6 +373,7 @@ function toConfiguredProviderListModel(params: { provider: params.provider, id: params.model.id, name: params.model.name ?? params.model.id, + api: params.model.api ?? params.providerConfig.api, baseUrl: params.model.baseUrl ?? params.providerConfig.baseUrl, input: resolveConfiguredModelInput({ model: params.model }), contextWindow: params.model.contextWindow ?? DEFAULT_CONTEXT_TOKENS, @@ -238,14 +383,17 @@ function toConfiguredProviderListModel(params: { function toListRowInput(input: readonly string[] | undefined): ListRowModel["input"] { const parsed = input?.filter( - (item): item is ListRowModel["input"][number] => + (item): item is NonNullable[number] => item === "text" || item === "image" || item === "document", ); return parsed?.length ? parsed : ["text"]; } function toManifestCatalogListModel( - row: Pick & { + row: Pick< + NormalizedModelCatalogRow, + "provider" | "id" | "name" | "api" | "baseUrl" | "contextWindow" | "contextTokens" + > & { input?: readonly string[]; }, ): ListRowModel { @@ -253,9 +401,11 @@ function toManifestCatalogListModel( provider: row.provider, id: row.id, name: row.name, + api: row.api, baseUrl: row.baseUrl, input: toListRowInput(row.input), contextWindow: row.contextWindow ?? DEFAULT_CONTEXT_TOKENS, + contextTokens: row.contextTokens, }; } @@ -320,8 +470,7 @@ export async function appendDiscoveredRows(params: { } return a.id.localeCompare(b.id); }); - - for (const model of sorted) { + const preparedModels = sorted.map((model) => { const key = modelKey(model.provider, model.id); const resolvedModel = params.modelRegistry && modelResolver @@ -337,12 +486,23 @@ export async function appendDiscoveredRows(params: { resolvedModel && modelKey(resolvedModel.provider, resolvedModel.id) === key ? resolvedModel : model; + return { key, model, rowModel }; + }); + const projectionCatalog = preparedModels.map(({ model, rowModel }) => + toCatalogProjectionEntry( + hasSameCatalogRoute(model as ListRowModel, rowModel) ? rowModel : (model as ListRowModel), + ), + ); + const routeIndex = createModelCatalogLogicalRouteIndex(projectionCatalog); + + for (const { key, rowModel } of preparedModels) { await appendVisibleRow({ rows: params.rows, model: rowModel, key, context: params.context, seenKeys, + routeIndex, skipSuppression: params.skipSuppression, }); } @@ -375,7 +535,7 @@ export async function appendConfiguredProviderRows(params: { key, context: params.context, seenKeys: params.seenKeys, - allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), + allowAuthAvailabilityOverride: true, normalizeWithProviderPlugin: true, }); } @@ -388,24 +548,35 @@ export async function appendAuthenticatedCatalogRows(params: { context: RowBuilderContext; seenKeys: Set; }): Promise { - const { loadModelCatalog } = await loadModelCatalogModule(); - const catalog = await loadModelCatalog({ + const { loadModelCatalogSnapshot } = await loadModelCatalogModule(); + const { entries: catalog, routeVariants } = await loadModelCatalogSnapshot({ config: params.context.cfg, readOnly: true, metadataSnapshot: params.context.metadataSnapshot, }); + const routeIndex = createModelCatalogLogicalRouteIndex(routeVariants); for (const entry of catalog) { - if (!params.context.authIndex.hasProviderAuth(entry.provider)) { + const model = toManifestCatalogListModel(entry); + const authEvaluation = params.context.authIndex.evaluateModelAuth( + entry.provider, + toModelAuthRef(model, routeIndex), + ); + const hasRunnableSyntheticAuth = + authEvaluation.availability === undefined && authEvaluation.evidence === "synthetic"; + if (authEvaluation.availability !== true && !hasRunnableSyntheticAuth) { continue; } const key = modelKey(entry.provider, entry.id); await appendVisibleRow({ rows: params.rows, - model: toManifestCatalogListModel(entry), + model, key, context: params.context, seenKeys: params.seenKeys, - allowProviderAvailabilityFallback: true, + routeIndex, + authEvaluation, + // Synthetic evidence admits local rows but does not override their URL-based availability. + allowAuthAvailabilityOverride: !hasRunnableSyntheticAuth, }); } } @@ -418,6 +589,10 @@ export async function appendModelCatalogRows(params: { catalogRows: readonly NormalizedModelCatalogRow[]; }): Promise { let appended = 0; + const projectionCatalog = params.catalogRows.map((row) => + toCatalogProjectionEntry(toManifestCatalogListModel(row)), + ); + const routeIndex = createModelCatalogLogicalRouteIndex(projectionCatalog); for (const catalogRow of params.catalogRows) { const key = modelKey(catalogRow.provider, catalogRow.id); if ( @@ -427,7 +602,8 @@ export async function appendModelCatalogRows(params: { key, context: params.context, seenKeys: params.seenKeys, - allowProviderAvailabilityFallback: true, + routeIndex, + allowAuthAvailabilityOverride: true, }) ) { appended += 1; @@ -456,15 +632,16 @@ export async function appendCatalogSupplementRows(params: { context: RowBuilderContext; seenKeys: Set; }): Promise { - const [{ loadModelCatalog }, { resolveModelWithRegistry }] = await Promise.all([ + const [modelCatalog, { resolveModelWithRegistry }] = await Promise.all([ loadModelCatalogModule(), loadModelResolverModule(), ]); - const catalog = await loadModelCatalog({ + const { entries: catalog, routeVariants } = await modelCatalog.loadModelCatalogSnapshot({ config: params.context.cfg, readOnly: true, metadataSnapshot: params.context.metadataSnapshot, }); + const routeIndex = createModelCatalogLogicalRouteIndex(routeVariants); for (const entry of catalog) { if (!matchesProviderFilter(params.context, entry.provider)) { continue; @@ -488,7 +665,8 @@ export async function appendCatalogSupplementRows(params: { key, context: params.context, seenKeys: params.seenKeys, - allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), + routeIndex, + allowAuthAvailabilityOverride: !params.context.discoveredKeys.has(key), }); } @@ -523,6 +701,10 @@ export async function appendProviderCatalogRows(params: { metadataSnapshot: params.context.metadataSnapshot, }); } + const projectionCatalog = catalogModels.map((model) => + toCatalogProjectionEntry(model as ListRowModel), + ); + const routeIndex = createModelCatalogLogicalRouteIndex(projectionCatalog); for (const model of catalogModels) { const key = modelKey(model.provider, model.id); if ( @@ -532,7 +714,8 @@ export async function appendProviderCatalogRows(params: { key, context: params.context, seenKeys: params.seenKeys, - allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), + routeIndex, + allowAuthAvailabilityOverride: !params.context.discoveredKeys.has(key), }) ) { appended += 1; @@ -567,32 +750,43 @@ export async function appendConfiguredRows(params: { const model = resolvedModel ? normalizeListRowWithProviderPlugin({ model: resolvedModel, context: params.context }) : resolvedModel; - if (params.context.filter.local && model && !isLocalBaseUrl(model.baseUrl ?? "")) { - continue; - } if (params.context.filter.local && !model) { continue; } - if (model && shouldSuppressListModel({ model, context: params.context })) { + const authEvaluation = model + ? params.context.authIndex.evaluateModelAuth(model.provider, toModelAuthRef(model)) + : undefined; + const projectedModel = + model && authEvaluation + ? projectListRowModel({ model, evaluation: authEvaluation, cfg: params.context.cfg }) + : model; + if ( + params.context.filter.local && + projectedModel && + !isLocalBaseUrl(projectedModel.baseUrl ?? "") + ) { + continue; + } + if ( + projectedModel && + shouldSuppressListModel({ model: projectedModel, context: params.context }) + ) { continue; } - const allowProviderAvailabilityFallback = - model && - (!params.context.discoveredKeys.has(modelKey(model.provider, model.id)) || - params.context.authIndex.allowsProviderAuthAvailabilityFallback(model.provider)); - const shouldResolveProviderAuth = - model && (params.context.availableKeys === undefined || allowProviderAvailabilityFallback); params.rows.push( toModelRow({ - model, + model: projectedModel, key: entry.key, tags: Array.from(entry.tags), aliases: entry.aliases, availableKeys: params.context.availableKeys, - allowProviderAvailabilityFallback: allowProviderAvailabilityFallback === true, - hasAuthForProvider: shouldResolveProviderAuth - ? (provider) => params.context.authIndex.hasProviderAuth(provider) - : undefined, + authAvailability: authEvaluation?.availability, + authAvailabilityAuthoritative: + Boolean( + model && !params.context.discoveredKeys.has(modelKey(model.provider, model.id)), + ) || + normalizeProviderIdForAuth(model?.provider ?? entry.ref.provider) === "openai" || + (authEvaluation !== undefined && authEvaluation.routeResolution !== null), }), ); } diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 2a58ee208d11..c55a9bfad296 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -1,6 +1,5 @@ /** Implementation of `openclaw models status`. */ import path from "node:path"; -import { findNormalizedProviderValue } from "@openclaw/model-catalog-core/provider-id"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { colorize, theme } from "../../../packages/terminal-core/src/theme.js"; import { @@ -15,21 +14,28 @@ import { DEFAULT_OAUTH_WARN_MS, formatRemainingShort, } from "../../agents/auth-health.js"; -import { evaluateStoredCredentialEligibility } from "../../agents/auth-profiles/credential-state.js"; -import { - resolveAuthProfileEligibility, - resolveAuthProfileOrder, -} from "../../agents/auth-profiles/order.js"; import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles/paths.js"; -import { ensureAuthProfileStoreWithoutExternalProfiles as ensureAuthProfileStore } from "../../agents/auth-profiles/store.js"; +import { + ensureAuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles, + getRuntimeAuthProfileStoreSnapshot, +} from "../../agents/auth-profiles/store.js"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; import { resolveProfileUnusableUntilForDisplay } from "../../agents/auth-profiles/usage.js"; +import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; +import { + createModelAuthAvailabilityResolver, + type ModelAuthAvailabilityEvaluation, + type ModelAuthAvailabilityResolver, +} from "../../agents/model-auth-availability.js"; import { listProviderEnvAuthLookupKeys, resolveProviderEnvAuthLookupMaps, } from "../../agents/model-auth-env-vars.js"; -import { resolveEnvApiKey, resolveUsableCustomProviderApiKey } from "../../agents/model-auth.js"; +import { resolveEnvApiKey } from "../../agents/model-auth.js"; +import { loadModelCatalogSnapshot } from "../../agents/model-catalog.js"; import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; +import { modelCatalogLogicalKey } from "../../agents/model-selection-shared.js"; import { buildModelAliasIndex, isCliProvider, @@ -38,11 +44,7 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; -import { - OPENAI_CODEX_PROVIDER_ID, - OPENAI_PROVIDER_ID, - openAIProviderUsesCodexRuntimeByDefault, -} from "../../agents/openai-routing.js"; +import { OPENAI_PROVIDER_ID } from "../../agents/openai-routing.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { requestExitAfterOneShotOutput } from "../../cli/one-shot-exit.js"; @@ -51,11 +53,13 @@ import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, } from "../../config/model-input.js"; +import { resolveMergedModelProviderConfig } from "../../config/model-provider-config.js"; import { parseStrictFiniteNumber, parseStrictPositiveInteger, } from "../../infra/parse-finite-number.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; +import type { ProviderModelRouteCandidate } from "../../plugin-sdk/provider-model-types.js"; import { captureCurrentPluginMetadataSnapshotState, getCurrentPluginMetadataSnapshot, @@ -115,6 +119,68 @@ type StatusSyntheticAuth = { expiresAt?: number; }; +type StatusProviderRouteAuth = + | { + /** Provider artifact unavailable; retain the shipped provider-wide behavior. */ + kind: "legacy"; + evaluation: ModelAuthAvailabilityEvaluation; + usesCodexRuntimeAuth: boolean; + } + | { + kind: "route"; + route: ProviderModelRouteCandidate; + evaluation: ModelAuthAvailabilityEvaluation; + usesCodexRuntimeAuth: boolean; + } + | { + kind: "indeterminate"; + evaluation: ModelAuthAvailabilityEvaluation; + usesCodexRuntimeAuth: boolean; + } + | { + kind: "incompatible"; + code: string; + message: string; + evaluation: ModelAuthAvailabilityEvaluation; + usesCodexRuntimeAuth: false; + }; + +type StatusProviderUseRef = { + provider: string; + model: string; + allowCodexRuntimeFallback: boolean; +}; + +type StatusProviderUse = { + provider: string; + model: string; + allowCodexRuntimeFallback: boolean; + routeAuth: StatusProviderRouteAuth; +}; + +type StatusModelRouteIssue = + | { + kind: "incompatible"; + provider: string; + model: string; + code: string; + message: string; + } + | { + kind: "indeterminate"; + provider: string; + model: string; + evidence?: ModelAuthAvailabilityEvaluation["evidence"]; + message: string; + } + | { + kind: "missing-auth"; + provider: string; + model: string; + authRequirement: ProviderModelRouteCandidate["authRequirement"]; + message: string; + }; + function loadProviderUsageRuntime(): Promise { return providerUsageRuntimeLoader.load(); } @@ -193,22 +259,6 @@ function installCommandPluginMetadataSnapshot(params: { }; } -function resolveProviderConfigForStatus( - cfg: Awaited>, - provider: string, -) { - const providers = cfg.models?.providers ?? {}; - const direct = providers[provider]; - if (direct) { - return direct; - } - const normalized = normalizeProviderId(provider); - return ( - providers[normalized] ?? - Object.entries(providers).find(([key]) => normalizeProviderId(key) === normalized)?.[1] - ); -} - function syntheticAuthCredential( provider: string, auth: StatusSyntheticAuth, @@ -223,7 +273,9 @@ function syntheticAuthCredential( key: auth.credential, }; } - if (auth.mode === "token") { + if (auth.mode === "token" || auth.mode === "oauth") { + // Plugin synthetic OAuth is already materialized as a bearer token. Keep + // it token-shaped so non-expiring credentials do not require refresh data. return { type: "token", provider, @@ -231,16 +283,7 @@ function syntheticAuthCredential( expires: auth.expiresAt, }; } - if (auth.expiresAt === undefined) { - return undefined; - } - return { - type: "oauth", - provider, - access: auth.credential ?? "", - refresh: "", - expires: auth.expiresAt, - }; + return undefined; } function finishModelsStatusOutput( @@ -346,19 +389,7 @@ export async function modelsStatusCommand( }, {}); const allowed = Object.keys(cfg.agents?.defaults?.models ?? {}); - const store = ensureAuthProfileStore(agentDir); const modelsPath = path.join(agentDir, "models.json"); - - const providersFromStore = new Set( - Object.values(store.profiles) - .map((profile) => normalizeProviderId(profile.provider)) - .filter((p): p is string => Boolean(p)), - ); - const providersFromConfig = new Set( - Object.keys(cfg.models?.providers ?? {}) - .map((p) => (typeof p === "string" ? normalizeProviderId(p) : "")) - .filter(Boolean), - ); const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER, @@ -377,17 +408,39 @@ export async function modelsStatusCommand( ...DISPLAY_MODEL_PARSE_OPTIONS, })?.ref; }; + const textUsesOpenAI = [defaultLabel, ...fallbacks].some( + (raw) => + normalizeProviderId(resolveStatusModelRef(raw)?.provider ?? "") === OPENAI_PROVIDER_ID, + ); + // Match execution's read-only, provider-scoped Codex CLI overlay. This lets + // status select the same OpenAI subscription profile without scanning + // unrelated external CLIs or prompting the keychain. + const store = textUsesOpenAI + ? ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + config: cfg, + externalCliProviderIds: [OPENAI_PROVIDER_ID], + readOnly: true, + }) + : ensureAuthProfileStoreWithoutExternalProfiles(agentDir); + const providersFromStore = new Set( + Object.values(store.profiles) + .map((profile) => normalizeProviderId(profile.provider)) + .filter((p): p is string => Boolean(p)), + ); + const providersFromConfig = new Set( + Object.keys(cfg.models?.providers ?? {}) + .map((p) => (typeof p === "string" ? normalizeProviderId(p) : "")) + .filter(Boolean), + ); const providersFromModels = new Set(); - const providerUses: Array<{ - provider: string; - model: string; - allowCodexRuntimeFallback: boolean; - }> = []; + const providerUseRefs: StatusProviderUseRef[] = []; const addProviderUse = (raw: string | undefined, allowCodexRuntimeFallback: boolean) => { const ref = resolveStatusModelRef(raw); if (ref?.provider) { - providerUses.push({ - provider: normalizeProviderId(ref.provider), + const provider = normalizeProviderId(ref.provider); + providerUseRefs.push({ + provider, model: ref.model, allowCodexRuntimeFallback, }); @@ -437,9 +490,134 @@ export async function modelsStatusCommand( registryDiagnostics: metadataSnapshot.registryDiagnostics, }).map((provider) => normalizeProviderId(provider)), ); + const catalog = await loadModelCatalogSnapshot({ + config: cfg, + readOnly: true, + metadataSnapshot, + }); + const routeSourcesByModel = new Map< + string, + Array<{ api?: (typeof catalog.routeVariants)[number]["api"]; baseUrl?: string }> + >(); + for (const entry of catalog.routeVariants) { + if (entry.api === undefined && entry.baseUrl === undefined) { + continue; + } + const key = modelCatalogLogicalKey(entry); + const sources = routeSourcesByModel.get(key) ?? []; + sources.push({ api: entry.api, baseUrl: entry.baseUrl }); + routeSourcesByModel.set(key, sources); + } + const createStatusAuthResolver = ( + authStore: Parameters[0]["authStore"], + ) => + createModelAuthAvailabilityResolver({ + cfg, + authStore, + agentDir, + workspaceDir, + env: process.env, + // A generic Codex runtime marker proves only that the harness can be + // contacted. It is not an OpenAI model credential. + syntheticAuthProviderRefs: [...syntheticAuthProviderRefs].filter( + (provider) => provider !== "codex", + ), + metadataSnapshot, + }); + let authResolver = createStatusAuthResolver(store); + const resolveProviderUses = (resolver: ModelAuthAvailabilityResolver): StatusProviderUse[] => + providerUseRefs.map((usage) => { + const observedRoutes = routeSourcesByModel.get( + modelCatalogLogicalKey({ provider: usage.provider, id: usage.model }), + ); + const ref = { + modelId: usage.model, + ...(observedRoutes ? { observedRoutes } : {}), + }; + // Image tools own their provider auth behavior. The text-route artifact + // must not reinterpret image auth as an OpenAI text transport. + const rawEvaluation: ModelAuthAvailabilityEvaluation = usage.allowCodexRuntimeFallback + ? resolver.evaluateModelAuth(usage.provider, ref) + : { + availability: resolver.resolveProviderAuthAvailability(usage.provider, ref), + routeResolution: null, + }; + const routeAuth: StatusProviderRouteAuth = (() => { + if (rawEvaluation.routeResolution?.kind === "incompatible") { + return { + kind: "incompatible", + code: rawEvaluation.routeResolution.code, + message: rawEvaluation.routeResolution.message, + evaluation: rawEvaluation, + usesCodexRuntimeAuth: false, + }; + } + const usesCodexRuntimeAuth = + usage.allowCodexRuntimeFallback && + resolveAgentHarnessPolicy({ + provider: usage.provider, + modelId: usage.model, + ...(rawEvaluation.selectedRoute + ? { + modelApi: rawEvaluation.selectedRoute.api, + modelBaseUrl: rawEvaluation.selectedRoute.baseUrl, + } + : {}), + config: cfg, + agentId: workspaceAgentId, + }).runtime === "codex"; + if ( + usesCodexRuntimeAuth && + usage.provider !== OPENAI_PROVIDER_ID && + usage.provider !== "codex" + ) { + return { + kind: "incompatible", + code: "unsupported-codex-runtime-provider", + message: `The Codex runtime does not support provider ${usage.provider}.`, + evaluation: rawEvaluation, + usesCodexRuntimeAuth: false, + }; + } + const evaluation = rawEvaluation; + if (evaluation.selectedRoute) { + return { + kind: "route", + route: evaluation.selectedRoute, + evaluation, + usesCodexRuntimeAuth, + }; + } + if ( + evaluation.routeResolution?.kind === "routes" || + evaluation.routeResolution?.kind === "indeterminate" + ) { + return { + kind: "indeterminate", + evaluation, + usesCodexRuntimeAuth, + }; + } + return { + kind: "legacy", + evaluation, + usesCodexRuntimeAuth, + }; + })(); + return { + provider: usage.provider, + model: usage.model, + allowCodexRuntimeFallback: usage.allowCodexRuntimeFallback, + routeAuth, + }; + }); + let providerUses = resolveProviderUses(authResolver); const syntheticAuthByProvider = new Map(); + const runtimeSyntheticAuthByProvider = new Map(); const cliRuntimeAuthUsages = providerUses - .filter((usage) => usage.allowCodexRuntimeFallback) + // Codex harness auth is already modeled by the selected OpenAI route. + // CLI-runtime aliases are only for distinct backends such as Gemini CLI. + .filter((usage) => usage.allowCodexRuntimeFallback && !usage.routeAuth.usesCodexRuntimeAuth) .map((usage) => { const runtimeProvider = resolveCliRuntimeExecutionProvider({ provider: usage.provider, @@ -478,10 +656,8 @@ export async function modelsStatusCommand( ); const codexProvider = normalizeProviderId(OPENAI_PROVIDER_ID); const codexProviderAlias = aliasMap[codexProvider] ?? codexProvider; - const codexRuntimeAuthUsages = providerUses.filter( - (usage) => - usage.allowCodexRuntimeFallback && - openAIProviderUsesCodexRuntimeByDefault({ provider: usage.provider, config: cfg }), + let codexRuntimeAuthUsages = providerUses.filter( + (usage) => usage.routeAuth.usesCodexRuntimeAuth, ); if (codexRuntimeAuthUsages.length > 0) { syntheticProvidersToProbe.add(codexProvider); @@ -499,7 +675,7 @@ export async function modelsStatusCommand( context: { config: cfg, provider: normalized, - providerConfig: resolveProviderConfigForStatus(cfg, normalized), + providerConfig: resolveMergedModelProviderConfig(cfg, normalized), }, }); if (!resolvedLocal) { @@ -513,15 +689,35 @@ export async function modelsStatusCommand( expiresAt: resolvedLocal.expiresAt, }; syntheticAuthByProvider.set(normalized, syntheticAuth); - if (normalized === "codex" || normalized === codexProviderAlias) { + // The generic Codex token authenticates the local harness, not an + // OpenAI model route. Only provider-owned synthetic credentials may + // become concrete evaluator profiles. + if (normalized !== "codex") { + runtimeSyntheticAuthByProvider.set(normalized, syntheticAuth); + } + if (normalized !== "codex" && normalized === codexProviderAlias) { syntheticAuthByProvider.set(codexProvider, syntheticAuth); } } const runtimeCredentialsByProvider = new Map( - Array.from(syntheticAuthByProvider.entries()) + Array.from(runtimeSyntheticAuthByProvider.entries()) .map(([provider, auth]) => [provider, syntheticAuthCredential(provider, auth)] as const) .filter((entry): entry is readonly [string, AuthProfileCredential] => Boolean(entry[1])), ); + if (runtimeCredentialsByProvider.size > 0) { + const syntheticProfiles = Object.fromEntries( + Array.from(runtimeCredentialsByProvider.entries()).map(([provider, credential]) => [ + `${provider}:runtime-synthetic`, + credential, + ]), + ); + authResolver = createStatusAuthResolver({ + ...store, + profiles: { ...store.profiles, ...syntheticProfiles }, + }); + providerUses = resolveProviderUses(authResolver); + codexRuntimeAuthUsages = providerUses.filter((usage) => usage.routeAuth.usesCodexRuntimeAuth); + } const applied = getShellEnvAppliedKeys(); const shellFallbackEnabled = @@ -563,8 +759,15 @@ export async function modelsStatusCommand( kind: "missing", detail: "missing", }; + const runtimeAuthStore = getRuntimeAuthProfileStoreSnapshot(agentDir); + const healthStore = runtimeAuthStore + ? { + ...store, + profiles: { ...store.profiles, ...runtimeAuthStore.profiles }, + } + : store; const authHealth = buildAuthHealthSummary({ - store, + store: healthStore, cfg, warnAfterMs: DEFAULT_OAUTH_WARN_MS, runtimeCredentialsByProvider, @@ -573,177 +776,51 @@ export async function modelsStatusCommand( const authProfileHealthById = new Map( authHealth.profiles.map((profile) => [profile.profileId, profile]), ); - const hasUsableAuthProfile = ( - profileId: string, - credential: AuthProfileCredential, - ): boolean => { - if (credential.type === "api_key") { - return evaluateStoredCredentialEligibility({ credential }).eligible; - } - const health = authProfileHealthById.get(profileId); - if (health) { - return health.status === "ok" || health.status === "expiring" || health.status === "static"; - } - return evaluateStoredCredentialEligibility({ credential }).eligible; - }; const resolveProviderAuthHealthId = (provider: string): string => resolveProviderIdForAuth(provider, envLookupParams); - const listRuntimeAuthProviderCandidates = ( - provider: string, - options?: { includeLegacyOpenAICodex?: boolean }, - ): string[] => { - const normalizedProvider = normalizeProviderId(provider); - const candidates = [normalizedProvider, resolveProviderAuthHealthId(normalizedProvider)]; - if ( - options?.includeLegacyOpenAICodex === true && - openAIProviderUsesCodexRuntimeByDefault({ - provider: normalizedProvider, - config: cfg, - }) - ) { - candidates.push(OPENAI_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID); - } - return Array.from(new Set(candidates)); - }; - const listProviderProfileCandidates = (provider: string): string[] => { - const orderedProfiles = resolveAuthProfileOrder({ - cfg, - store, - provider, - }); - const providerKey = normalizeProviderId(provider); - const providerAuthKey = resolveProviderAuthHealthId(providerKey); - const explicitOrder = - findNormalizedProviderValue(store.order, providerAuthKey) ?? - findNormalizedProviderValue(store.order, providerKey) ?? - findNormalizedProviderValue(cfg.auth?.order, providerAuthKey) ?? - findNormalizedProviderValue(cfg.auth?.order, providerKey); - const isEligibleOrHealthRescued = (profileId: string): boolean => { - const credential = store.profiles[profileId]; - if (!credential) { - return false; - } - const eligibility = resolveAuthProfileEligibility({ - cfg, - store, - provider, - profileId, - }); - if (eligibility.eligible) { - return true; - } - return ( - eligibility.reasonCode === "missing_credential" && - credential.type !== "api_key" && - hasUsableAuthProfile(profileId, credential) - ); - }; - if (explicitOrder !== undefined) { - return Array.from( - new Set([...orderedProfiles, ...explicitOrder.filter(isEligibleOrHealthRescued)]), - ); - } - const configuredProfiles = Object.entries(cfg.auth?.profiles ?? {}) - .filter(([, profile]) => { - const profileProvider = normalizeProviderId(profile.provider); - return ( - profileProvider === providerKey || - resolveProviderAuthHealthId(profileProvider) === providerAuthKey - ); - }) - .map(([profileId]) => profileId); - if (configuredProfiles.length > 0) { - return Array.from( - new Set([...orderedProfiles, ...configuredProfiles.filter(isEligibleOrHealthRescued)]), - ); - } - const sameProviderProfiles = Object.entries(store.profiles) - .filter(([, credential]) => { - const credentialProvider = normalizeProviderId(credential.provider); - return ( - credentialProvider === providerKey || - resolveProviderAuthHealthId(credentialProvider) === providerAuthKey - ); - }) - .map(([profileId]) => profileId) - .filter(isEligibleOrHealthRescued); - return Array.from(new Set([...orderedProfiles, ...sameProviderProfiles])); - }; const resolveRuntimeAuthRouteEffective = ( provider: string, + evaluation?: ModelAuthAvailabilityEvaluation, ): ProviderAuthOverview["effective"] => { - const candidates = listRuntimeAuthProviderCandidates(provider, { - includeLegacyOpenAICodex: true, - }); - for (const candidate of candidates) { - const direct = providerAuthMap.get(candidate)?.effective; - if ( - direct && - direct.kind !== "missing" && - (direct.kind !== "profiles" || hasUsableProviderAuth(candidate)) - ) { - return direct; - } + if (!evaluation) { + return providerAuthMap.get(provider)?.effective ?? missingProviderAuthEffective; + } + if (evaluation?.availability === false) { + return missingProviderAuthEffective; + } + const candidates = Array.from( + new Set([normalizeProviderId(provider), resolveProviderAuthHealthId(provider)]), + ); + const profileId = evaluation.selectedProfileId; + if (profileId) { + const credentialProvider = store.profiles[profileId]?.provider ?? provider; + const source = providerAuthMap.get( + resolveProviderAuthHealthId(credentialProvider), + )?.effective; + return source && source.kind !== "missing" + ? source + : { kind: "profiles", detail: profileId }; } for (const candidate of candidates) { - const profileId = listProviderProfileCandidates(candidate).find((candidateProfileId) => { - const candidateCredential = store.profiles[candidateProfileId]; - return candidateCredential - ? hasUsableAuthProfile(candidateProfileId, candidateCredential) - : false; - }); - const credential = profileId ? store.profiles[profileId] : undefined; - if (profileId && credential) { - const sourceProvider = resolveProviderAuthHealthId(credential.provider); - const source = providerAuthMap.get(sourceProvider)?.effective; - return source && source.kind !== "missing" - ? source - : { - kind: "profiles", - detail: `${profileId} (${credential.provider})`, - }; + const auth = providerAuthMap.get(candidate); + if (evaluation.evidence === "environment" && auth?.env) { + return { kind: "env", detail: auth.env.value }; + } + if ( + (evaluation.evidence === "provider-config" || evaluation.evidence === "runtime") && + auth?.modelsJson + ) { + return { kind: "models.json", detail: auth.modelsJson.value }; + } + if (evaluation.evidence === "synthetic" && syntheticAuthByProvider.has(candidate)) { + return { + kind: "synthetic", + detail: syntheticAuthByProvider.get(candidate)?.source ?? "plugin-owned", + }; } } const direct = providerAuthMap.get(provider)?.effective; - return direct?.kind === "profiles" - ? missingProviderAuthEffective - : (direct ?? missingProviderAuthEffective); - }; - const hasUsableNonProfileAuth = ( - provider: string, - options?: { includeLegacyOpenAICodex?: boolean }, - ): boolean => { - for (const candidate of listRuntimeAuthProviderCandidates(provider, options)) { - const auth = providerAuthMap.get(candidate); - if ( - auth?.env || - auth?.syntheticAuth || - syntheticAuthByProvider.has(candidate) || - resolveUsableCustomProviderApiKey({ cfg, provider: candidate }) - ) { - return true; - } - } - return false; - }; - const hasUsableDirectProviderAuth = (provider: string): boolean => { - const normalized = normalizeProviderId(provider); - const hasUsableProfile = listProviderProfileCandidates(normalized).some((profileId) => { - const credential = store.profiles[profileId]; - return credential ? hasUsableAuthProfile(profileId, credential) : false; - }); - return hasUsableProfile || hasUsableNonProfileAuth(normalized); - }; - const hasUsableProviderAuth = ( - provider: string, - options?: { includeLegacyOpenAICodex?: boolean }, - ): boolean => { - for (const candidate of listRuntimeAuthProviderCandidates(provider, options)) { - if (hasUsableDirectProviderAuth(candidate)) { - return true; - } - } - return false; + return direct ?? missingProviderAuthEffective; }; const resolveCliRuntimeAuthProvider = (usage: (typeof providerUses)[number]) => cliRuntimeAuthUsages.find( @@ -752,69 +829,115 @@ export async function modelsStatusCommand( candidate.model === usage.model && candidate.allowCodexRuntimeFallback === usage.allowCodexRuntimeFallback, )?.runtime; - const hasUsableAuthForProviderInUse = ( - usage: (typeof providerUses)[number], - options: { allowCodexRuntimeFallback: boolean }, - ): boolean => { + const hasUsableAuthForProviderInUse = (usage: (typeof providerUses)[number]): boolean => { const cliRuntimeAuthProvider = resolveCliRuntimeAuthProvider(usage); if (cliRuntimeAuthProvider) { - return hasUsableDirectProviderAuth(cliRuntimeAuthProvider); + return authResolver.resolveProviderAuthAvailability(cliRuntimeAuthProvider) !== false; } - const { provider } = usage; - if (hasUsableProviderAuth(provider)) { + if (usage.routeAuth.kind === "incompatible") { + // Route contract failures are reported separately from missing auth. return true; } - if (!options.allowCodexRuntimeFallback) { - return false; - } - return ( - openAIProviderUsesCodexRuntimeByDefault({ provider, config: cfg }) && - hasUsableProviderAuth(OPENAI_PROVIDER_ID, { includeLegacyOpenAICodex: true }) - ); + // Unknown evidence is reported as indeterminate, not missing auth. + return usage.routeAuth.evaluation.availability !== false; }; + const codexRuntimeUsagesByProvider = new Map(); + for (const usage of codexRuntimeAuthUsages) { + const usages = codexRuntimeUsagesByProvider.get(usage.provider) ?? []; + usages.push(usage); + codexRuntimeUsagesByProvider.set(usage.provider, usages); + } const runtimeAuthRoutes = Array.from( new Map([ - ...codexRuntimeAuthUsages.map((usage) => { - const effective = resolveRuntimeAuthRouteEffective(codexProvider); + ...Array.from(codexRuntimeUsagesByProvider.entries()).map(([provider, usages]) => { + const representative = + usages.find((usage) => usage.routeAuth.evaluation.availability === true) ?? usages[0]; + const effective = resolveRuntimeAuthRouteEffective( + codexProvider, + representative?.routeAuth.evaluation, + ); + const availabilities = usages.map((usage) => usage.routeAuth.evaluation.availability); return [ - `${usage.provider}:codex:${codexProvider}`, + `${provider}:codex:${codexProvider}`, { - provider: usage.provider, + provider, runtime: "codex", authProvider: codexProvider, - status: hasUsableProviderAuth(codexProvider, { - includeLegacyOpenAICodex: true, - }) + status: availabilities.every((availability) => availability === true) ? "usable" - : "missing", + : availabilities.some((availability) => availability === false) + ? "missing" + : "indeterminate", effective, }, ] as const; }), ...cliRuntimeAuthUsages.map((usage) => { - const effective = resolveRuntimeAuthRouteEffective(usage.runtime); + const evaluation = authResolver.evaluateModelAuth(usage.runtime); + const effective = resolveRuntimeAuthRouteEffective(usage.runtime, evaluation); return [ `${usage.provider}:${usage.runtime}:${usage.runtime}`, { provider: usage.provider, runtime: usage.runtime, authProvider: usage.runtime, - status: hasUsableDirectProviderAuth(usage.runtime) ? "usable" : "missing", + status: + evaluation.availability === true + ? "usable" + : evaluation.availability === false + ? "missing" + : "indeterminate", effective, }, ] as const; }), ]).values(), ).toSorted((a, b) => a.provider.localeCompare(b.provider)); + const modelRouteIssues = providerUses.flatMap((usage) => { + const cliRuntimeAuthProvider = resolveCliRuntimeAuthProvider(usage); + const evaluation = cliRuntimeAuthProvider + ? authResolver.evaluateModelAuth(cliRuntimeAuthProvider) + : usage.routeAuth.evaluation; + if (usage.routeAuth.kind === "incompatible") { + return [ + { + kind: "incompatible" as const, + provider: usage.provider, + model: usage.model, + code: usage.routeAuth.code, + message: usage.routeAuth.message, + }, + ]; + } + if (evaluation.availability === undefined) { + return [ + { + kind: "indeterminate" as const, + provider: usage.provider, + model: usage.model, + ...(evaluation.evidence ? { evidence: evaluation.evidence } : {}), + message: `Auth readiness could not be confirmed for ${usage.provider}/${usage.model}.`, + }, + ]; + } + if (usage.routeAuth.kind !== "route" || evaluation.availability) { + return []; + } + const authRequirement = usage.routeAuth.route.authRequirement; + return [ + { + kind: "missing-auth" as const, + provider: usage.provider, + model: usage.model, + authRequirement, + message: `No usable ${authRequirement} authentication is available for ${usage.provider}/${usage.model}.`, + }, + ]; + }); const missingProvidersInUse = Array.from( new Set( providerUses - .filter( - (usage) => - !hasUsableAuthForProviderInUse(usage, { - allowCodexRuntimeFallback: usage.allowCodexRuntimeFallback, - }), - ) + .filter((usage) => !hasUsableAuthForProviderInUse(usage)) .map((usage) => resolveCliRuntimeAuthProvider(usage) ?? usage.provider), ), ) @@ -952,41 +1075,43 @@ export async function modelsStatusCommand( })(); const checkStatus = (() => { - const providersInUse = new Set(); - for (const usage of providerUses) { + type RequirementHealth = "ok" | "expiring" | "missing" | "indeterminate"; + const resolveRouteAuthHealth = (usage: StatusProviderUse): RequirementHealth => { + if (usage.routeAuth.kind === "incompatible") { + return "missing"; + } const cliRuntimeAuthProvider = resolveCliRuntimeAuthProvider(usage); - if (cliRuntimeAuthProvider) { - providersInUse.add(cliRuntimeAuthProvider); - providersInUse.add(resolveProviderAuthHealthId(cliRuntimeAuthProvider)); - continue; + const evaluation = cliRuntimeAuthProvider + ? authResolver.evaluateModelAuth(cliRuntimeAuthProvider) + : usage.routeAuth.evaluation; + if (evaluation.availability === undefined) { + return "indeterminate"; } - providersInUse.add(usage.provider); - providersInUse.add(resolveProviderAuthHealthId(usage.provider)); - if ( - usage.allowCodexRuntimeFallback && - openAIProviderUsesCodexRuntimeByDefault({ provider: usage.provider, config: cfg }) && - hasUsableProviderAuth(OPENAI_PROVIDER_ID, { includeLegacyOpenAICodex: true }) - ) { - for (const candidate of listRuntimeAuthProviderCandidates(OPENAI_PROVIDER_ID, { - includeLegacyOpenAICodex: true, - })) { - providersInUse.add(candidate); - } + if (!evaluation.availability) { + return "missing"; } - } + const profileId = evaluation.selectedProfileId; + if (!profileId) { + return "ok"; + } + const health = authProfileHealthById.get(profileId); + if (health?.status === "expiring") { + return "expiring"; + } + if (health?.status === "expired" || health?.status === "missing") { + return "missing"; + } + return "ok"; + }; + const routeAuthHealth = new Set(providerUses.map(resolveRouteAuthHealth)); const hasExpiredOrMissing = - authHealth.providers.some( - (provider) => - providersInUse.has(provider.provider) && - ["expired", "missing"].includes(provider.status) && - !hasUsableNonProfileAuth(provider.provider), - ) || missingProvidersInUse.length > 0; - const hasExpiring = authHealth.providers.some( - (provider) => - providersInUse.has(provider.provider) && - provider.status === "expiring" && - !hasUsableNonProfileAuth(provider.provider), - ); + modelRouteIssues.some( + (issue) => issue.kind === "incompatible" || issue.kind === "indeterminate", + ) || + routeAuthHealth.has("missing") || + routeAuthHealth.has("indeterminate") || + missingProvidersInUse.length > 0; + const hasExpiring = routeAuthHealth.has("expiring"); if (hasExpiredOrMissing) { return 1; } @@ -1024,6 +1149,7 @@ export async function modelsStatusCommand( }, providersWithOAuth: providersWithOauth, missingProvidersInUse, + modelRouteIssues, runtimeAuthRoutes, providers: providerAuth, unusableProfiles, @@ -1227,16 +1353,41 @@ export async function modelsStatusCommand( } } + if (modelRouteIssues.length > 0) { + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "Model route issues")); + for (const issue of modelRouteIssues) { + const modelRef = `${issue.provider}/${issue.model}`; + if (issue.kind === "incompatible") { + runtime.log(`- ${theme.heading(modelRef)} [${issue.code}] ${issue.message}`); + continue; + } + if (issue.kind === "indeterminate") { + runtime.log(`- ${theme.heading(modelRef)} [indeterminate] ${issue.message}`); + continue; + } + runtime.log( + `- ${theme.heading(modelRef)} requires ${issue.authRequirement} auth: ${issue.message}`, + ); + } + } + if (missingProvidersInUse.length > 0) { const { buildProviderAuthRecoveryHint } = await import("../../agents/provider-auth-recovery-hint.js"); runtime.log(""); runtime.log(colorize(rich, theme.heading, "Missing auth")); for (const provider of missingProvidersInUse) { + const requiresSubscription = modelRouteIssues.some( + (issue) => + issue.kind === "missing-auth" && + issue.provider === provider && + issue.authRequirement === "subscription", + ); const hint = buildProviderAuthRecoveryHint({ provider, config: cfg, - includeEnvVar: true, + includeEnvVar: !requiresSubscription, }); runtime.log(`- ${theme.heading(provider)} ${hint}`); } diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index f4203e4bebb7..4afe1526547f 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -4,7 +4,12 @@ import { withEnvAsync } from "../../test-utils/env.js"; const mocks = vi.hoisted(() => { type MockAuthProfile = { provider: string; [key: string]: unknown }; - const store = { + type MockAuthStore = { + version: number; + profiles: Record; + order?: Record; + }; + const store: MockAuthStore = { version: 1, profiles: { "anthropic:default": { @@ -35,6 +40,7 @@ const mocks = vi.hoisted(() => { } as Record, order: undefined as Record | undefined, }; + const runtimeStore = { current: undefined as MockAuthStore | undefined }; return { store, @@ -51,6 +57,8 @@ const mocks = vi.hoisted(() => { listAgentIds: vi.fn().mockReturnValue(["main", "jeremiah"]), listAgentEntries: vi.fn().mockReturnValue([{ id: "main" }, { id: "jeremiah" }]), ensureAuthProfileStore: vi.fn().mockReturnValue(store), + getRuntimeAuthProfileStoreSnapshot: vi.fn(() => runtimeStore.current), + runtimeStore, listProfilesForProvider: vi.fn((s: typeof store, provider: string) => { return Object.entries(s.profiles) .filter(([, cred]) => cred.provider === provider) @@ -145,6 +153,9 @@ const mocks = vi.hoisted(() => { loadProviderUsageSummary: vi.fn().mockResolvedValue(undefined), resolveRuntimeSyntheticAuthProviderRefs: vi.fn().mockReturnValue([]), resolveProviderSyntheticAuthWithPlugin: vi.fn().mockReturnValue(undefined), + loadModelCatalog: vi.fn().mockResolvedValue([]), + modelCatalogRouteVariants: undefined as unknown[] | undefined, + openAIModelRouteOverride: undefined as ((params: unknown) => unknown) | undefined, }; }); @@ -174,9 +185,15 @@ vi.mock("../../agents/auth-profiles/persisted.js", () => ({ vi.mock("../../agents/auth-profiles/profiles.js", () => ({ listProfilesForProvider: mocks.listProfilesForProvider, })); -vi.mock("../../agents/auth-profiles/store.js", () => ({ +vi.mock("../../agents/auth-profiles/store.js", async (importOriginal) => ({ + ...(await importOriginal()), ensureAuthProfileStore: mocks.ensureAuthProfileStore, ensureAuthProfileStoreWithoutExternalProfiles: mocks.ensureAuthProfileStore, + getRuntimeAuthProfileStoreSnapshot: mocks.getRuntimeAuthProfileStoreSnapshot, +})); +vi.mock("../../agents/auth-profiles.js", async (importOriginal) => ({ + ...(await importOriginal()), + getRuntimeAuthProfileStoreSnapshot: mocks.getRuntimeAuthProfileStoreSnapshot, })); vi.mock("../../agents/auth-profiles/usage.js", () => ({ resolveProfileUnusableUntilForDisplay: mocks.resolveProfileUnusableUntilForDisplay, @@ -207,12 +224,17 @@ vi.mock("../../agents/auth-health.js", () => ({ ), formatRemainingShort: vi.fn(() => "1h"), })); -vi.mock("../../agents/model-auth.js", () => ({ +vi.mock("../../agents/model-auth.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveEnvApiKey: mocks.resolveEnvApiKey, hasUsableCustomProviderApiKey: mocks.hasUsableCustomProviderApiKey, resolveUsableCustomProviderApiKey: mocks.resolveUsableCustomProviderApiKey, getCustomProviderApiKey: mocks.getCustomProviderApiKey, })); +vi.mock("../../agents/model-auth-env.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveEnvApiKey: mocks.resolveEnvApiKey, +})); vi.mock("../../agents/model-auth-env-vars.js", () => ({ listProviderEnvAuthLookupKeys: mocks.listProviderEnvAuthLookupKeys, resolveProviderEnvAuthLookupMaps: mocks.resolveProviderEnvAuthLookupMaps, @@ -234,7 +256,8 @@ vi.mock("../../infra/shell-env.js", () => ({ getShellEnvAppliedKeys: mocks.getShellEnvAppliedKeys, shouldEnableShellEnvFallback: mocks.shouldEnableShellEnvFallback, })); -vi.mock("../../config/config.js", () => ({ +vi.mock("../../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), createConfigIO: mocks.createConfigIO, })); vi.mock("./load-config.js", () => ({ @@ -251,14 +274,37 @@ vi.mock("../../plugins/synthetic-auth.runtime.js", () => ({ vi.mock("../../plugins/provider-runtime.js", () => ({ resolveProviderSyntheticAuthWithPlugin: mocks.resolveProviderSyntheticAuthWithPlugin, })); +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalogSnapshot: async (...args: unknown[]) => { + const entries = await mocks.loadModelCatalog(...args); + return { entries, routeVariants: mocks.modelCatalogRouteVariants ?? entries }; + }, +})); +vi.mock("../../agents/openai-model-routes.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveOpenAIModelRoutes: (params: Parameters[0]) => + mocks.openAIModelRouteOverride + ? mocks.openAIModelRouteOverride(params) + : actual.resolveOpenAIModelRoutes(params), + createOpenAIModelRoutesResolver: ( + params: Parameters[0], + ) => { + const resolveRoutes = actual.createOpenAIModelRoutesResolver(params); + return (ref: Parameters>[0]) => + mocks.openAIModelRouteOverride + ? mocks.openAIModelRouteOverride({ provider: "openai", ...ref }) + : resolveRoutes(ref); + }, + }; +}); -import { buildAuthHealthSummary } from "../../agents/auth-health.js"; import { modelsStatusCommand } from "./list.status-command.js"; const defaultResolveEnvApiKeyImpl: | ((provider: string) => { apiKey: string; source: string } | null) | undefined = mocks.resolveEnvApiKey.getMockImplementation(); -const buildAuthHealthSummaryMock = vi.mocked(buildAuthHealthSummary); const runtime = { log: vi.fn(), @@ -309,16 +355,6 @@ function requireProvider(providers: unknown, provider: string) { return requireRecord(entry, `provider ${provider}`); } -function requireProfile(profiles: unknown, profileId: string) { - const entry = requireArray(profiles, "auth profiles").find( - (candidate) => requireRecord(candidate, "auth profile").profileId === profileId, - ); - if (!entry) { - throw new Error(`missing profile ${profileId}`); - } - return requireRecord(entry, `profile ${profileId}`); -} - function expectResolveAgentDirCalledFor(agentId: string) { const hasCall = mocks.resolveAgentDir.mock.calls.some((call) => call[1] === agentId); expect(hasCall).toBe(true); @@ -370,6 +406,120 @@ async function withAgentScopeOverrides( } } +async function withOpenAIStatusFixture( + params: { + primary: string; + fallbacks?: string[]; + profiles: typeof mocks.store.profiles; + resolveEnvApiKey?: (provider: string) => { apiKey: string; source: string } | null; + routeOverride?: (params: unknown) => unknown; + authOrder?: string[]; + providerAuth?: "api-key" | "aws-sdk" | "oauth" | "token"; + providerApiKey?: unknown; + providerApi?: "openai-chatgpt-responses"; + providerBaseUrl?: string; + agentRuntime?: string; + catalog?: unknown[]; + routeVariants?: unknown[]; + }, + run: () => Promise, +): Promise { + const originalLoadConfig = mocks.loadConfig.getMockImplementation(); + const originalProfiles = { ...mocks.store.profiles }; + const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; + const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); + const originalCustomKeyImpl = mocks.getCustomProviderApiKey.getMockImplementation(); + const originalUsableCustomKeyImpl = + mocks.resolveUsableCustomProviderApiKey.getMockImplementation(); + const originalRouteOverride = mocks.openAIModelRouteOverride; + const originalCatalogImpl = mocks.loadModelCatalog.getMockImplementation(); + const originalRouteVariants = mocks.modelCatalogRouteVariants; + const configuredModels = Object.fromEntries( + [params.primary, ...(params.fallbacks ?? [])].map((model) => [model, {}]), + ); + mocks.loadConfig.mockReturnValue({ + agents: { + defaults: { + model: { primary: params.primary, fallbacks: params.fallbacks ?? [] }, + models: Object.fromEntries( + Object.keys(configuredModels).map((model) => [ + model, + params.agentRuntime ? { agentRuntime: { id: params.agentRuntime } } : {}, + ]), + ), + }, + }, + ...(params.authOrder ? { auth: { order: { openai: params.authOrder } } } : {}), + models: { + providers: + params.providerAuth || + params.providerApiKey !== undefined || + params.providerApi || + params.providerBaseUrl + ? { + openai: { + ...(params.providerAuth ? { auth: params.providerAuth } : {}), + ...(params.providerApiKey !== undefined ? { apiKey: params.providerApiKey } : {}), + ...(params.providerApi ? { api: params.providerApi } : {}), + ...(params.providerBaseUrl ? { baseUrl: params.providerBaseUrl } : {}), + models: [], + }, + } + : {}, + }, + env: { shellEnv: { enabled: false } }, + }); + mocks.store.profiles = params.profiles; + mocks.store.order = undefined; + mocks.resolveEnvApiKey.mockImplementation(params.resolveEnvApiKey ?? (() => null)); + const providerApiKey = + typeof params.providerApiKey === "string" ? params.providerApiKey.trim() : ""; + if (providerApiKey) { + mocks.getCustomProviderApiKey.mockImplementation((_cfg, provider) => + provider === "openai" ? providerApiKey : undefined, + ); + mocks.resolveUsableCustomProviderApiKey.mockImplementation(({ provider }) => + provider === "openai" ? { apiKey: providerApiKey, source: "models.json" } : null, + ); + } + mocks.openAIModelRouteOverride = params.routeOverride; + mocks.loadModelCatalog.mockResolvedValue(params.catalog ?? []); + mocks.modelCatalogRouteVariants = params.routeVariants; + try { + return await run(); + } finally { + mocks.store.profiles = originalProfiles; + mocks.store.order = originalOrder; + mocks.openAIModelRouteOverride = originalRouteOverride; + mocks.modelCatalogRouteVariants = originalRouteVariants; + if (originalCustomKeyImpl) { + mocks.getCustomProviderApiKey.mockImplementation(originalCustomKeyImpl); + } else { + mocks.getCustomProviderApiKey.mockReturnValue(undefined); + } + if (originalUsableCustomKeyImpl) { + mocks.resolveUsableCustomProviderApiKey.mockImplementation(originalUsableCustomKeyImpl); + } else { + mocks.resolveUsableCustomProviderApiKey.mockReturnValue(null); + } + if (originalCatalogImpl) { + mocks.loadModelCatalog.mockImplementation(originalCatalogImpl); + } else { + mocks.loadModelCatalog.mockResolvedValue([]); + } + if (originalLoadConfig) { + mocks.loadConfig.mockImplementation(originalLoadConfig); + } + if (originalEnvImpl) { + mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); + } else if (defaultResolveEnvApiKeyImpl) { + mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); + } else { + mocks.resolveEnvApiKey.mockImplementation(() => null); + } + } +} + describe("modelsStatusCommand auth overview", () => { it.each([ [{ probeTimeout: "5000ms" }, "--probe-timeout"], @@ -496,875 +646,327 @@ describe("modelsStatusCommand auth overview", () => { ); }); - it("does not report canonical OpenAI agent routes missing when Codex auth is present", async () => { + it("rejects API-key auth for subscription-only Codex Spark", async () => { const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, + const textRuntime = createRuntime(); + await withOpenAIStatusFixture( + { + primary: "openai/gpt-5.3-codex-spark", + profiles: { + "openai:api-key": { + type: "api_key", + provider: "openai", + key: "sk-openai-platform-only", // pragma: allowlist secret + }, }, }, - models: { providers: {} }, - env: { shellEnv: { enabled: true } }, - }); - mocks.store.profiles = { - "openai:default": originalProfiles["openai:default"], - }; - mocks.resolveEnvApiKey.mockImplementation((provider: string) => - provider === "openai" - ? { - apiKey: "oauth-token", - source: "env: OPENAI_OAUTH_TOKEN", - } - : null, + async () => { + await modelsStatusCommand({ json: true, check: true }, localRuntime as never); + await modelsStatusCommand({ check: true }, textRuntime as never); + }, ); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toStrictEqual([]); - expect(localRuntime.exit).not.toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } + const payload = parseFirstJsonLog(localRuntime); + expect(payload.auth.missingProvidersInUse).toEqual(["openai"]); + expect(payload.auth.runtimeAuthRoutes).toEqual([ + { + provider: "openai", + runtime: "codex", + authProvider: "openai", + status: "missing", + effective: { kind: "missing", detail: "missing" }, + }, + ]); + expect(localRuntime.exit).toHaveBeenCalledWith(1); + expect(textRuntime.log.mock.calls.flat().join("\n")).not.toContain("set an API key env var"); }); - it("keeps delegated OAuth marker display separate from runtime route usability", async () => { + it("evaluates mixed primary and fallback OpenAI routes independently", async () => { + const localRuntime = createRuntime(); + await withOpenAIStatusFixture( + { + primary: "openai/gpt-5.6", + fallbacks: ["openai/gpt-5.5"], + profiles: { + "openai:default": { + type: "oauth", + provider: "openai", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }, + }, + async () => { + await modelsStatusCommand({ json: true, check: true }, localRuntime as never); + }, + ); + const payload = parseFirstJsonLog(localRuntime); + expect(payload.auth.missingProvidersInUse).toEqual(["openai"]); + expect(payload.auth.runtimeAuthRoutes).toEqual([ + { + provider: "openai", + runtime: "codex", + authProvider: "openai", + status: "missing", + effective: { + kind: "profiles", + detail: "/tmp/openclaw-agent/auth-profiles.json", + }, + }, + ]); + expect(payload.auth.modelRouteIssues).toEqual([ + { + kind: "missing-auth", + provider: "openai", + model: "gpt-5.6", + authRequirement: "api-key", + message: "No usable api-key authentication is available for openai/gpt-5.6.", + }, + ]); + expect(localRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("reports incompatible model routes separately in JSON and text", async () => { + const jsonRuntime = createRuntime(); + const textRuntime = createRuntime(); + await withOpenAIStatusFixture( + { + primary: "openai/gpt-5.6", + profiles: {}, + routeOverride: () => ({ + kind: "incompatible", + code: "platform-only-model-on-chatgpt", + message: "gpt-5.6 is available only through OpenAI Platform API-key authentication.", + }), + }, + async () => { + await modelsStatusCommand({ json: true, check: true }, jsonRuntime as never); + await modelsStatusCommand({ check: true }, textRuntime as never); + }, + ); + const payload = parseFirstJsonLog(jsonRuntime); + expect(payload.auth.missingProvidersInUse).toEqual([]); + expect(payload.auth.modelRouteIssues).toEqual([ + { + kind: "incompatible", + provider: "openai", + model: "gpt-5.6", + code: "platform-only-model-on-chatgpt", + message: "gpt-5.6 is available only through OpenAI Platform API-key authentication.", + }, + ]); + const text = textRuntime.log.mock.calls.flat().join("\n"); + expect(text).toContain("openai/gpt-5.6"); + expect(text).toContain("platform-only-model-on-chatgpt"); + expect(text).toContain("available only through OpenAI Platform API-key authentication"); + expect(jsonRuntime.exit).toHaveBeenCalledWith(1); + expect(textRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("reports missing static transport observation as indeterminate", async () => { + const localRuntime = createRuntime(); + await withOpenAIStatusFixture( + { + primary: "openai/gpt-5.4-nano", + profiles: { + "openai:subscription": { + type: "oauth", + provider: "openai", + access: "subscription-access", + refresh: "subscription-refresh", + expires: Date.now() + 10 * 60_000, + }, + }, + }, + async () => { + await modelsStatusCommand({ json: true, check: true }, localRuntime as never); + }, + ); + + const payload = parseFirstJsonLog(localRuntime); + expect(payload.auth.missingProvidersInUse).toEqual([]); + expect(payload.auth.modelRouteIssues).toEqual([ + expect.objectContaining({ + kind: "indeterminate", + provider: "openai", + model: "gpt-5.4-nano", + }), + ]); + expect(localRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("uses static catalog transport observation for route readiness", async () => { + const localRuntime = createRuntime(); + await withOpenAIStatusFixture( + { + primary: "openai/gpt-5.4-nano", + profiles: { + "openai:subscription": { + type: "oauth", + provider: "openai", + access: "subscription-access", + refresh: "subscription-refresh", + expires: Date.now() + 10 * 60_000, + }, + }, + catalog: [ + { + id: "gpt-5.4-nano", + name: "GPT 5.4 Nano", + provider: "openai", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + ], + }, + async () => { + await modelsStatusCommand({ json: true, check: true }, localRuntime as never); + }, + ); + + const payload = parseFirstJsonLog(localRuntime); + expect(payload.auth.missingProvidersInUse).toEqual([]); + expect(payload.auth.modelRouteIssues).toEqual([]); + expect(localRuntime.exit).not.toHaveBeenCalledWith(1); + }); + + it.each([ + ["ChatGPT first", false], + ["Platform first", true], + ])("selects ChatGPT nano from grouped physical routes with %s", async (_label, reverse) => { + const localRuntime = createRuntime(); + const platform = { + id: "gpt-5.4-nano", + name: "Platform Nano", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }; + const chatGPT = { + id: "openai/gpt-5.4-nano", + name: "ChatGPT Nano", + provider: "openai", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + const routeVariants = reverse ? [platform, chatGPT] : [chatGPT, platform]; + await withOpenAIStatusFixture( + { + primary: "openai/gpt-5.4-nano", + profiles: { + "openai:subscription": { + type: "oauth", + provider: "openai", + access: "subscription-access", + refresh: "subscription-refresh", + expires: Date.now() + 10 * 60_000, + }, + }, + authOrder: ["openai:subscription"], + catalog: [platform], + routeVariants, + }, + async () => { + await modelsStatusCommand({ json: true, check: true }, localRuntime as never); + }, + ); + + const payload = parseFirstJsonLog(localRuntime); + expect(payload.auth.missingProvidersInUse).toEqual([]); + expect(payload.auth.modelRouteIssues).toEqual([]); + expect(localRuntime.exit).not.toHaveBeenCalledWith(1); + }); + + it("keeps API-key SecretRef profiles usable for a concrete OpenAI route", async () => { + const localRuntime = createRuntime(); + await withOpenAIStatusFixture( + { + primary: "openai/gpt-5.6", + profiles: { + "openai:ref": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }, + }, + async () => + await withEnvAsync({ OPENAI_API_KEY: "resolved-key" }, async () => { + await modelsStatusCommand({ json: true, check: true }, localRuntime as never); + }), + ); + const payload = parseFirstJsonLog(localRuntime); + expect(payload.auth.modelRouteIssues).toEqual([]); + expect(payload.auth.missingProvidersInUse).toEqual([]); + expect(localRuntime.exit).not.toHaveBeenCalledWith(1); + }); + + it("reports unresolved API-key SecretRef profiles as indeterminate", async () => { + const localRuntime = createRuntime(); + await withOpenAIStatusFixture( + { + primary: "openai/gpt-5.6", + profiles: { + "openai:ref": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }, + }, + async () => + await withEnvAsync({ OPENAI_API_KEY: undefined }, async () => { + await modelsStatusCommand({ json: true, check: true }, localRuntime as never); + }), + ); + const payload = parseFirstJsonLog(localRuntime); + expect(payload.auth.missingProvidersInUse).toEqual([]); + expect(payload.auth.modelRouteIssues).toEqual([ + expect.objectContaining({ + kind: "indeterminate", + provider: "openai", + model: "gpt-5.6", + evidence: "profile", + }), + ]); + expect(localRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("preserves configured AWS SDK profiles for non-OpenAI providers", async () => { const localRuntime = createRuntime(); const originalLoadConfig = mocks.loadConfig.getMockImplementation(); const originalProfiles = { ...mocks.store.profiles }; + const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - const originalCustomKeyImpl = mocks.getCustomProviderApiKey.getMockImplementation(); - const originalUsableCustomKeyImpl = - mocks.resolveUsableCustomProviderApiKey.getMockImplementation(); mocks.loadConfig.mockReturnValue({ agents: { defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, + model: { primary: "bedrock/anthropic.claude-sonnet", fallbacks: [] }, + models: { "bedrock/anthropic.claude-sonnet": {} }, }, }, + auth: { + profiles: { + "bedrock:default": { provider: "bedrock", mode: "aws-sdk" }, + }, + order: { bedrock: ["bedrock:default"] }, + }, models: { providers: { - openai: { - apiKey: "oauth:openai", - }, + bedrock: { auth: "aws-sdk", models: [] }, }, }, env: { shellEnv: { enabled: false } }, }); mocks.store.profiles = {}; - mocks.resolveEnvApiKey.mockImplementation(() => null); - mocks.getCustomProviderApiKey.mockImplementation((_cfg: unknown, provider: string) => - provider === "openai" ? "oauth:openai" : undefined, - ); - mocks.resolveUsableCustomProviderApiKey.mockImplementation(() => null); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - const openai = requireProvider(payload.auth.providers, "openai"); - expect(openai.effective).toEqual({ - kind: "models.json", - detail: "marker(oauth:openai)", - }); - expect(payload.auth.runtimeAuthRoutes).toEqual([ - { - provider: "openai", - runtime: "codex", - authProvider: "openai", - status: "missing", - effective: { - kind: "models.json", - detail: "marker(oauth:openai)", - }, - }, - ]); - expect(payload.auth.missingProvidersInUse).toStrictEqual(["openai"]); - expect(localRuntime.exit).toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - if (originalCustomKeyImpl) { - mocks.getCustomProviderApiKey.mockImplementation(originalCustomKeyImpl); - } else { - mocks.getCustomProviderApiKey.mockReturnValue(undefined); - } - if (originalUsableCustomKeyImpl) { - mocks.resolveUsableCustomProviderApiKey.mockImplementation(originalUsableCustomKeyImpl); - } else { - mocks.resolveUsableCustomProviderApiKey.mockReturnValue(null); - } - } - }); - - it("reports unresolved Codex OAuth sidecars as missing for OpenAI Codex runtime routes", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - const originalHealthImpl = buildAuthHealthSummaryMock.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: false } }, - }); - mocks.store.profiles = { - "openai-codex:default": { - type: "oauth", - provider: "openai-codex", - expires: Date.now() + 60_000, - oauthRef: { - source: "openclaw-credentials", - provider: "openai-codex", - id: "0123456789abcdef0123456789abcdef", - }, - }, - }; - mocks.store.order = { - "openai-codex": ["openai-codex:default"], - }; - mocks.resolveEnvApiKey.mockImplementation(() => null); - buildAuthHealthSummaryMock.mockReturnValue({ - now: Date.now(), - warnAfterMs: 86_400_000, - profiles: [ - { - profileId: "openai-codex:default", - provider: "openai-codex", - type: "oauth", - status: "missing", - reasonCode: "unresolved_ref", - source: "store", - label: "openai-codex:default", - }, - ], - providers: [ - { - provider: "openai-codex", - status: "missing", - profiles: [], - }, - ], - }); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toStrictEqual(["openai"]); - expect(payload.auth.runtimeAuthRoutes).toEqual([ - { - provider: "openai", - runtime: "codex", - authProvider: "openai", - status: "missing", - effective: { - kind: "missing", - detail: "missing", - }, - }, - ]); - expect(requireProfile(payload.auth.oauth.profiles, "openai-codex:default").reasonCode).toBe( - "unresolved_ref", - ); - expect(localRuntime.exit).toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - mocks.store.order = originalOrder; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - if (originalHealthImpl) { - buildAuthHealthSummaryMock.mockImplementation(originalHealthImpl); - } - } - }); - - it("reports Gemini CLI OAuth for canonical Google text routed through the CLI runtime", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "google/gemini-3-flash-preview", fallbacks: [] }, - models: { - "google/*": { agentRuntime: { id: "google-gemini-cli" } }, - }, - cliBackends: { "google-gemini-cli": {} }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: true } }, - }); - mocks.store.profiles = { - "google-gemini-cli:user@example.test": { - type: "oauth", - provider: "google-gemini-cli", - access: "gemini-cli-access-token", - refresh: "gemini-cli-refresh-token", - expires: Date.now() + 60_000, - }, - }; - mocks.resolveEnvApiKey.mockImplementation((provider: string) => - provider === "google" - ? { - apiKey: "AIzaSyD-google-env-key-0123456789", - source: "env: GEMINI_API_KEY", - } - : null, - ); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toStrictEqual([]); - expect( - requireRecord( - requireProvider(payload.auth.providers, "google").effective, - "google effective", - ), - ).toEqual(expect.objectContaining({ kind: "env" })); - expect( - requireRecord( - requireProvider(payload.auth.providers, "google-gemini-cli").effective, - "google-gemini-cli effective", - ), - ).toEqual({ - kind: "profiles", - detail: "/tmp/openclaw-agent/auth-profiles.json", - }); - expect(payload.auth.runtimeAuthRoutes).toEqual([ - { - provider: "google", - runtime: "google-gemini-cli", - authProvider: "google-gemini-cli", - status: "usable", - effective: { - kind: "profiles", - detail: "/tmp/openclaw-agent/auth-profiles.json", - }, - }, - ]); - expect(localRuntime.exit).not.toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } - }); - - it("uses Codex synthetic auth for canonical OpenAI text routes", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - const originalSyntheticImpl = - mocks.resolveRuntimeSyntheticAuthProviderRefs.getMockImplementation(); - const originalResolveSyntheticAuthImpl = - mocks.resolveProviderSyntheticAuthWithPlugin.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: false } }, - }); - mocks.store.profiles = {}; - mocks.resolveEnvApiKey.mockImplementation(() => null); - mocks.resolveRuntimeSyntheticAuthProviderRefs.mockReturnValue(["codex"]); - mocks.resolveProviderSyntheticAuthWithPlugin.mockImplementation( - ({ provider }: { provider: string }) => - provider === "codex" - ? { - apiKey: "codex-runtime-token", - source: "codex-app-server", - mode: "token", - expiresAt: Date.now() + 60_000, - } - : undefined, - ); - - try { - const syntheticProbeStart = mocks.resolveProviderSyntheticAuthWithPlugin.mock.calls.length; - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - const syntheticProbeProviders = mocks.resolveProviderSyntheticAuthWithPlugin.mock.calls - .slice(syntheticProbeStart) - .map(([arg]) => (arg as { provider: string }).provider); - expect(payload.auth.missingProvidersInUse).toStrictEqual([]); - expect(payload.auth.runtimeAuthRoutes).toEqual([ - { - provider: "openai", - runtime: "codex", - authProvider: "openai", - status: "usable", - effective: { - kind: "synthetic", - detail: "codex-app-server", - }, - }, - ]); - expect(localRuntime.exit).not.toHaveBeenCalledWith(1); - expect(syntheticProbeProviders).toContain("codex"); - } finally { - mocks.store.profiles = originalProfiles; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - if (originalSyntheticImpl) { - mocks.resolveRuntimeSyntheticAuthProviderRefs.mockImplementation(originalSyntheticImpl); - } else { - mocks.resolveRuntimeSyntheticAuthProviderRefs.mockReturnValue([]); - } - if (originalResolveSyntheticAuthImpl) { - mocks.resolveProviderSyntheticAuthWithPlugin.mockImplementation( - originalResolveSyntheticAuthImpl, - ); - } else { - mocks.resolveProviderSyntheticAuthWithPlugin.mockReturnValue(undefined); - } - } - }); - - it("shows compatible OpenAI API-key profiles for Codex runtime auth routes", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: false } }, - }); - mocks.store.profiles = { - "openai:default": { - type: "api_key", - provider: "openai", - key: "sk-openai-compatible-profile", // pragma: allowlist secret - }, - }; mocks.store.order = undefined; mocks.resolveEnvApiKey.mockImplementation(() => null); - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toStrictEqual([]); - expect(payload.auth.runtimeAuthRoutes).toEqual([ - { - provider: "openai", - runtime: "codex", - authProvider: "openai", - status: "usable", - effective: { - kind: "profiles", - detail: "/tmp/openclaw-agent/auth-profiles.json", - }, - }, - ]); - expect(localRuntime.exit).not.toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - mocks.store.order = originalOrder; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } - }); - - it("uses effective OAuth health for Codex runtime route usability", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: false } }, - }); - mocks.store.profiles = { - "openai:default": { - type: "oauth", - provider: "openai", - }, - }; - mocks.store.order = undefined; - mocks.resolveEnvApiKey.mockImplementation(() => null); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toStrictEqual([]); - expect(payload.auth.runtimeAuthRoutes).toEqual([ - { - provider: "openai", - runtime: "codex", - authProvider: "openai", - status: "usable", - effective: { - kind: "profiles", - detail: "/tmp/openclaw-agent/auth-profiles.json", - }, - }, - ]); - expect(localRuntime.exit).not.toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - mocks.store.order = originalOrder; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } - }); - - it("does not bypass configured auth profiles with unrelated stored profiles", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - const originalHealthImpl = buildAuthHealthSummaryMock.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, - }, - }, - auth: { - profiles: { - "openai:default": { provider: "openai", mode: "oauth" }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: false } }, - }); - mocks.store.profiles = { - "openai:default": { - type: "oauth", - provider: "openai", - access: "expired-access", - refresh: "expired-refresh", - expires: Date.now() - 60_000, - }, - "openai:api-key": { - type: "api_key", - provider: "openai", - key: "sk-openai-unconfigured-profile", // pragma: allowlist secret - }, - }; - mocks.store.order = undefined; - mocks.resolveEnvApiKey.mockImplementation(() => null); - buildAuthHealthSummaryMock.mockReturnValue({ - now: Date.now(), - warnAfterMs: 86_400_000, - profiles: [ - { - profileId: "openai:default", - provider: "openai", - type: "oauth", - status: "expired", - source: "store", - label: "openai:default", - }, - { - profileId: "openai:api-key", - provider: "openai", - type: "api_key", - status: "static", - source: "store", - label: "openai:api-key", - }, - ], - providers: [], - }); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toStrictEqual(["openai"]); - expect(payload.auth.runtimeAuthRoutes).toEqual([ - { - provider: "openai", - runtime: "codex", - authProvider: "openai", - status: "missing", - effective: { - kind: "missing", - detail: "missing", - }, - }, - ]); - expect(localRuntime.exit).toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - mocks.store.order = originalOrder; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - if (originalHealthImpl) { - buildAuthHealthSummaryMock.mockImplementation(originalHealthImpl); - } - } - }); - - it("does not report configured profiles usable when stored credential mode mismatches", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, - }, - }, - auth: { - profiles: { - "openai:default": { provider: "openai", mode: "oauth" }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: false } }, - }); - mocks.store.profiles = { - "openai:default": { - type: "api_key", - provider: "openai", - key: "sk-openai-mode-mismatch", // pragma: allowlist secret - }, - }; - mocks.store.order = undefined; - mocks.resolveEnvApiKey.mockImplementation(() => null); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toStrictEqual(["openai"]); - expect(payload.auth.runtimeAuthRoutes).toEqual([ - { - provider: "openai", - runtime: "codex", - authProvider: "openai", - status: "missing", - effective: { - kind: "missing", - detail: "missing", - }, - }, - ]); - expect(localRuntime.exit).toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - mocks.store.order = originalOrder; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } - }); - - it("does not use stored profiles made ineligible by profile config", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, - }, - }, - auth: { - profiles: { - "openai:default": { provider: "anthropic", mode: "oauth" }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: false } }, - }); - mocks.store.profiles = { - "openai:default": { - type: "oauth", - provider: "openai", - access: "fresh-access", - refresh: "fresh-refresh", - expires: Date.now() + 60_000, - }, - }; - mocks.store.order = undefined; - mocks.resolveEnvApiKey.mockImplementation(() => null); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toStrictEqual(["openai"]); - expect(payload.auth.runtimeAuthRoutes).toEqual([ - { - provider: "openai", - runtime: "codex", - authProvider: "openai", - status: "missing", - effective: { - kind: "missing", - detail: "missing", - }, - }, - ]); - expect(localRuntime.exit).toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - mocks.store.order = originalOrder; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } - }); - - it("does not treat API-key profiles without key material as usable", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: false } }, - }); - mocks.store.profiles = { - "openai:api-key": { - type: "api_key", - provider: "openai", - }, - }; - mocks.store.order = undefined; - mocks.resolveEnvApiKey.mockImplementation(() => null); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toStrictEqual(["openai"]); - expect(payload.auth.runtimeAuthRoutes).toEqual([ - { - provider: "openai", - runtime: "codex", - authProvider: "openai", - status: "missing", - effective: { - kind: "missing", - detail: "missing", - }, - }, - ]); - expect(localRuntime.exit).toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - mocks.store.order = originalOrder; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } - }); - - it("does not fail --check for stale Codex inventory when ordered provider health is usable", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - const originalHealthImpl = buildAuthHealthSummaryMock.getMockImplementation(); - const expiredProfile = { - type: "oauth", - provider: "openai", - access: "expired-access", - refresh: "expired-refresh", - expires: Date.now() - 60_000, - }; - const usableProfile = { - type: "oauth", - provider: "openai", - access: "usable-access", - refresh: "usable-refresh", - expires: Date.now() + 60_000, - }; - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openai/gpt-5.5", fallbacks: [] }, - models: { "openai/gpt-5.5": {} }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: true } }, - }); - mocks.store.profiles = { - "openai:default": expiredProfile, - "openai:named": usableProfile, - }; - mocks.store.order = { - openai: ["openai:named"], - }; - mocks.resolveEnvApiKey.mockImplementation(() => null); - buildAuthHealthSummaryMock.mockReturnValue({ - now: Date.now(), - warnAfterMs: 86_400_000, - profiles: [ - { - profileId: "openai:default", - provider: "openai", - type: "oauth", - status: "expired", - source: "store", - label: "openai:default", - }, - { - profileId: "openai:named", - provider: "openai", - type: "oauth", - status: "ok", - expiresAt: Date.now() + 60_000, - remainingMs: 60_000, - source: "store", - label: "openai:named", - }, - ], - providers: [ - { - provider: "openai", - status: "ok", - expiresAt: Date.now() + 60_000, - remainingMs: 60_000, - profiles: [], - }, - ], - }); - try { await modelsStatusCommand({ json: true, check: true }, localRuntime as never); const payload = parseFirstJsonLog(localRuntime); expect(payload.auth.missingProvidersInUse).toEqual([]); - expect(requireProfile(payload.auth.oauth.profiles, "openai:default").status).toBe("expired"); - expect(requireProfile(payload.auth.oauth.profiles, "openai:named").status).toBe("ok"); - expect(requireProvider(payload.auth.oauth.providers, "openai").status).toBe("ok"); + expect(payload.auth.modelRouteIssues).toEqual([]); expect(localRuntime.exit).not.toHaveBeenCalledWith(1); } finally { mocks.store.profiles = originalProfiles; @@ -1379,432 +981,6 @@ describe("modelsStatusCommand auth overview", () => { } else { mocks.resolveEnvApiKey.mockImplementation(() => null); } - if (originalHealthImpl) { - buildAuthHealthSummaryMock.mockImplementation(originalHealthImpl); - } - } - }); - - it("fails --check when an in-use provider alias has expired canonical auth health", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - const originalHealthImpl = buildAuthHealthSummaryMock.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "codex-cli/gpt-5.5", fallbacks: [] }, - models: { "codex-cli/gpt-5.5": {} }, - cliBackends: { "codex-cli": {} }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: true } }, - }); - mocks.store.profiles = { - "openai:default": { - type: "oauth", - provider: "openai", - access: "expired-access", - refresh: "expired-refresh", - expires: Date.now() - 60_000, - }, - }; - mocks.resolveEnvApiKey.mockImplementation(() => null); - buildAuthHealthSummaryMock.mockReturnValue({ - now: Date.now(), - warnAfterMs: 86_400_000, - profiles: [ - { - profileId: "openai:default", - provider: "openai", - type: "oauth", - status: "expired", - source: "store", - label: "openai:default", - }, - ], - providers: [ - { - provider: "openai", - status: "expired", - expiresAt: Date.now() - 60_000, - remainingMs: -60_000, - profiles: [], - }, - ], - }); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toEqual([]); - expect(localRuntime.exit).toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - if (originalHealthImpl) { - buildAuthHealthSummaryMock.mockImplementation(originalHealthImpl); - } - } - }); - - it("uses resolved configured model aliases when filtering provider health", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - const originalHealthImpl = buildAuthHealthSummaryMock.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "Opus", fallbacks: [] }, - models: { "anthropic/claude-opus-4-6": { alias: "Opus" } }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: true } }, - }); - mocks.store.profiles = { - "anthropic:default": { - type: "oauth", - provider: "anthropic", - access: "expired-access", - refresh: "expired-refresh", - expires: Date.now() - 60_000, - }, - "openai:default": { - type: "api_key", - provider: "openai", - key: "abc123", - }, - }; - mocks.resolveEnvApiKey.mockImplementation((provider: string) => - provider === "openai" - ? { - apiKey: "sk-openai-0123456789abcdefghijklmnopqrstuvwxyz", - source: "shell env: OPENAI_API_KEY", - } - : null, - ); - buildAuthHealthSummaryMock.mockReturnValue({ - now: Date.now(), - warnAfterMs: 86_400_000, - profiles: [ - { - profileId: "anthropic:default", - provider: "anthropic", - type: "oauth", - status: "expired", - source: "store", - label: "anthropic:default", - }, - ], - providers: [ - { - provider: "anthropic", - status: "expired", - expiresAt: Date.now() - 60_000, - remainingMs: -60_000, - profiles: [], - }, - ], - }); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.resolvedDefault).toBe("anthropic/claude-opus-4-6"); - expect(localRuntime.exit).toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - if (originalHealthImpl) { - buildAuthHealthSummaryMock.mockImplementation(originalHealthImpl); - } - } - }); - - it("does not fail --check when profile health is missing but non-profile auth is usable", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; - const originalHealthImpl = buildAuthHealthSummaryMock.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-6", fallbacks: [] }, - models: { "anthropic/claude-opus-4-6": {} }, - }, - }, - auth: { - order: { - anthropic: [], - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: true } }, - }); - mocks.store.profiles = {}; - mocks.store.order = { - anthropic: [], - }; - buildAuthHealthSummaryMock.mockReturnValue({ - now: Date.now(), - warnAfterMs: 86_400_000, - profiles: [ - { - profileId: "anthropic:default", - provider: "anthropic", - type: "oauth", - status: "ok", - source: "store", - label: "anthropic:default", - }, - ], - providers: [ - { - provider: "anthropic", - status: "missing", - profiles: [], - }, - ], - }); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect( - requireRecord(requireProvider(payload.auth.providers, "anthropic").env, "anthropic env") - .source, - ).toBe("env: ANTHROPIC_OAUTH_TOKEN"); - expect(localRuntime.exit).not.toHaveBeenCalledWith(1); - expect(localRuntime.exit).not.toHaveBeenCalledWith(2); - } finally { - mocks.store.profiles = originalProfiles; - mocks.store.order = originalOrder; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalHealthImpl) { - buildAuthHealthSummaryMock.mockImplementation(originalHealthImpl); - } - } - }); - - it("reports missing auth when explicit auth order disables stored profiles", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalOrder = mocks.store.order ? { ...mocks.store.order } : undefined; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-6", fallbacks: [] }, - models: { "anthropic/claude-opus-4-6": {} }, - }, - }, - auth: { - order: { - anthropic: [], - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: true } }, - }); - mocks.store.profiles = { - "anthropic:default": { - type: "oauth", - provider: "anthropic", - access: "usable-access", - refresh: "usable-refresh", - expires: Date.now() + 60_000, - }, - }; - mocks.store.order = undefined; - mocks.resolveEnvApiKey.mockImplementation(() => null); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toEqual(["anthropic"]); - expect(localRuntime.exit).toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - mocks.store.order = originalOrder; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } - }); - - it("does fail --check when the only models.json auth is not resolvable", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - const originalCustomKeyImpl = mocks.getCustomProviderApiKey.getMockImplementation(); - const originalUsableCustomKeyImpl = - mocks.resolveUsableCustomProviderApiKey.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-6", fallbacks: [] }, - models: { "anthropic/claude-opus-4-6": {} }, - }, - }, - models: { - providers: { - anthropic: { - apiKey: "ANTHROPIC_API_KEY", - }, - }, - }, - env: { shellEnv: { enabled: true } }, - }); - mocks.store.profiles = {}; - mocks.resolveEnvApiKey.mockImplementation(() => null); - mocks.getCustomProviderApiKey.mockImplementation((provider: string) => - provider === "anthropic" ? "ANTHROPIC_API_KEY" : undefined, - ); - mocks.resolveUsableCustomProviderApiKey.mockImplementation(() => null); - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toEqual(["anthropic"]); - expect( - mocks.resolveUsableCustomProviderApiKey.mock.calls.some( - ([params]) => - requireRecord(params, "custom provider key params").provider === "anthropic", - ), - ).toBe(true); - expect(localRuntime.exit).toHaveBeenCalledWith(1); - } finally { - mocks.store.profiles = originalProfiles; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - if (originalCustomKeyImpl) { - mocks.getCustomProviderApiKey.mockImplementation(originalCustomKeyImpl); - } else { - mocks.getCustomProviderApiKey.mockReturnValue(undefined); - } - if (originalUsableCustomKeyImpl) { - mocks.resolveUsableCustomProviderApiKey.mockImplementation(originalUsableCustomKeyImpl); - } else { - mocks.resolveUsableCustomProviderApiKey.mockReturnValue(null); - } - } - }); - - it("uses unified OpenAI auth for OpenAI image routes", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - const originalProfiles = { ...mocks.store.profiles }; - const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "anthropic/claude-sonnet-4.6", fallbacks: [] }, - imageModel: { primary: "openai/gpt-image-2", fallbacks: [] }, - models: { "anthropic/claude-sonnet-4.6": {} }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: true } }, - }); - mocks.store.profiles = { - "anthropic:default": originalProfiles["anthropic:default"], - "openai:default": originalProfiles["openai:default"], - }; - mocks.resolveEnvApiKey.mockImplementation((provider: string) => - provider === "openai" - ? { - apiKey: "oauth-token", - source: "env: OPENAI_OAUTH_TOKEN", - } - : null, - ); - - try { - await modelsStatusCommand({ json: true, check: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toEqual([]); - expect(localRuntime.exit).toHaveBeenCalledWith(0); - } finally { - mocks.store.profiles = originalProfiles; - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } - if (originalEnvImpl) { - mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); - } else if (defaultResolveEnvApiKeyImpl) { - mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); - } else { - mocks.resolveEnvApiKey.mockImplementation(() => null); - } - } - }); - - it("does not double-prefix provider-qualified resolved default models", async () => { - const localRuntime = createRuntime(); - const originalLoadConfig = mocks.loadConfig.getMockImplementation(); - mocks.loadConfig.mockReturnValue({ - agents: { - defaults: { - model: { primary: "openrouter/auto", fallbacks: [] }, - models: { "openrouter/auto": {} }, - }, - }, - models: { providers: {} }, - env: { shellEnv: { enabled: true } }, - }); - - try { - await modelsStatusCommand({ json: true }, localRuntime as never); - const payload = parseFirstJsonLog(localRuntime); - - expect(payload.defaultModel).toBe("openrouter/auto"); - expect(payload.resolvedDefault).toBe("openrouter/auto"); - } finally { - if (originalLoadConfig) { - mocks.loadConfig.mockImplementation(originalLoadConfig); - } } }); @@ -1954,6 +1130,71 @@ describe("modelsStatusCommand auth overview", () => { } }); + it("passes the canonical merged provider config to synthetic auth plugins", async () => { + const localRuntime = createRuntime(); + const originalLoadConfig = mocks.loadConfig.getMockImplementation(); + const originalSyntheticRefs = + mocks.resolveRuntimeSyntheticAuthProviderRefs.getMockImplementation(); + const originalResolveSyntheticAuth = + mocks.resolveProviderSyntheticAuthWithPlugin.getMockImplementation(); + mocks.loadConfig.mockReturnValue({ + agents: { + defaults: { + model: { primary: "fixture/demo", fallbacks: [] }, + models: { "fixture/demo": {} }, + }, + }, + models: { + providers: { + fixture: { baseUrl: "https://fixture.example/v1", models: [] }, + " fixture ": { + auth: "api-key", + api: "openai-completions", + apiKey: { source: "env", provider: "default", id: "FIXTURE_API_KEY" }, + }, + }, + }, + env: { shellEnv: { enabled: false } }, + }); + mocks.resolveRuntimeSyntheticAuthProviderRefs.mockReturnValue(["fixture"]); + mocks.resolveProviderSyntheticAuthWithPlugin.mockReturnValue(undefined); + + try { + await modelsStatusCommand({ json: true }, localRuntime as never); + + expect(mocks.resolveProviderSyntheticAuthWithPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "fixture", + context: expect.objectContaining({ + providerConfig: expect.objectContaining({ + auth: "api-key", + api: "openai-completions", + apiKey: { source: "env", provider: "default", id: "FIXTURE_API_KEY" }, + baseUrl: "https://fixture.example/v1", + models: [], + }), + }), + }), + ); + } finally { + if (originalLoadConfig) { + mocks.loadConfig.mockImplementation(originalLoadConfig); + } + if (originalSyntheticRefs) { + mocks.resolveRuntimeSyntheticAuthProviderRefs.mockImplementation(originalSyntheticRefs); + } else { + mocks.resolveRuntimeSyntheticAuthProviderRefs.mockReturnValue([]); + } + if (originalResolveSyntheticAuth) { + mocks.resolveProviderSyntheticAuthWithPlugin.mockImplementation( + originalResolveSyntheticAuth, + ); + } else { + mocks.resolveProviderSyntheticAuthWithPlugin.mockReturnValue(undefined); + } + } + }); + it("does not treat declared but unresolved synthetic auth as usable", async () => { const localRuntime = createRuntime(); const originalLoadConfig = mocks.loadConfig.getMockImplementation(); @@ -1981,7 +1222,10 @@ describe("modelsStatusCommand auth overview", () => { try { await modelsStatusCommand({ json: true, check: true }, localRuntime as never); const payload = parseFirstJsonLog(localRuntime); - expect(payload.auth.missingProvidersInUse).toEqual(["codex"]); + expect(payload.auth.missingProvidersInUse).toEqual([]); + expect(payload.auth.modelRouteIssues).toEqual([ + expect.objectContaining({ kind: "indeterminate", provider: "codex" }), + ]); expect(localRuntime.exit).toHaveBeenCalledWith(1); } finally { mocks.store.profiles = originalProfiles; diff --git a/src/commands/models/list.types.ts b/src/commands/models/list.types.ts index 21c7786cf15f..8c462674b4e7 100644 --- a/src/commands/models/list.types.ts +++ b/src/commands/models/list.types.ts @@ -24,7 +24,7 @@ export type ModelRow = { export type ProviderAuthOverview = { provider: string; effective: { - kind: "profiles" | "env" | "models.json" | "synthetic" | "missing"; + kind: "profiles" | "env" | "models.json" | "synthetic" | "runtime" | "missing"; detail: string; }; profiles: { diff --git a/src/config/codex-plugin-diagnostics.ts b/src/config/codex-plugin-diagnostics.ts index 294a22d15f16..e9df2659ba2c 100644 --- a/src/config/codex-plugin-diagnostics.ts +++ b/src/config/codex-plugin-diagnostics.ts @@ -1,46 +1,40 @@ -import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; // Builds diagnostics for Codex plugin config and provider wiring. +import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs"; +import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { - AUTO_AGENT_RUNTIME_ID, + isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId, } from "../agents/agent-runtime-id.js"; +import { + listAgentIds, + resolveAgentConfig, + resolveAgentEffectiveModelPrimary, + resolveAgentModelFallbacksOverride, + resolveEffectiveModelFallbacks, +} from "../agents/agent-scope.js"; import { resolveModelRuntimePolicy } from "../agents/model-runtime-policy.js"; -import { openAIProviderUsesCodexRuntimeByDefault } from "../agents/openai-routing.js"; -import type { AgentModelEntryConfig } from "./types.agent-defaults.js"; -import type { AgentRuntimePolicyConfig } from "./types.agents-shared.js"; +import { + resolveDefaultModelForAgent, + resolveSubagentConfiguredModelSelection, +} from "../agents/model-selection-config.js"; +import { + buildModelAliasIndex, + resolveModelRefFromString, +} from "../agents/model-selection-shared.js"; +import { resolveOpenAIImplicitAgentRuntime } from "../agents/openai-routing.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { resolveAgentModelFallbackValues } from "./model-input.js"; import type { OpenClawConfig } from "./types.openclaw.js"; const CODEX_PLUGIN_ID = "codex"; const OPENAI_PROVIDER_ID = "openai"; -function normalizeRuntimeId(raw?: string | null): string | undefined { - return normalizeOptionalAgentRuntimeId(raw); -} - -function isCodexRuntimeSelection(raw?: string | null): boolean { - return normalizeRuntimeId(raw) === CODEX_PLUGIN_ID; -} - -function isOpenAiCodexDefaultRuntimeSelection(params: { - cfg: OpenClawConfig; - raw?: string | null; -}): boolean { - const runtime = normalizeRuntimeId(params.raw); - if (runtime === CODEX_PLUGIN_ID) { - return true; - } - if (runtime !== AUTO_AGENT_RUNTIME_ID && runtime !== "default") { - return false; - } - // "auto"/"default" only means Codex for the official OpenAI route. - // Custom OpenAI-compatible base URLs stay on the OpenClaw runtime path. - return openAIProviderUsesCodexRuntimeByDefault({ - provider: OPENAI_PROVIDER_ID, - config: params.cfg, - }); -} +type ModelRoute = { + provider: string; + modelId: string; +}; function codexPluginEntryEnabled(cfg: OpenClawConfig): boolean | undefined { for (const [pluginId, entry] of Object.entries(cfg.plugins?.entries ?? {})) { @@ -51,159 +45,262 @@ function codexPluginEntryEnabled(cfg: OpenClawConfig): boolean | undefined { return undefined; } -function openAiProviderRuntimePolicy(cfg: OpenClawConfig): AgentRuntimePolicyConfig | undefined { - for (const [providerId, providerConfig] of Object.entries(cfg.models?.providers ?? {})) { - if (normalizeProviderId(providerId) === OPENAI_PROVIDER_ID) { - return providerConfig?.agentRuntime?.id?.trim() ? providerConfig.agentRuntime : undefined; - } - } - return undefined; -} - -function listConfiguredAgentIds(cfg: OpenClawConfig): Array { - const ids: Array = [undefined]; - for (const agent of cfg.agents?.list ?? []) { - if (typeof agent.id === "string" && agent.id.trim()) { - ids.push(agent.id); - } - } - return ids; -} - -function openAiProviderModelCanResolveToCodexDefault(params: { +function configuredRuntimeNeedsCodex(params: { cfg: OpenClawConfig; - modelId: string; + env: NodeJS.ProcessEnv; + modelId?: string; + runtimeId?: string; }): boolean { - // Provider model rows are below exact agent model policies in runtime - // precedence, so inspect the resolved policy instead of the raw row. - return listConfiguredAgentIds(params.cfg).some((agentId) => - isOpenAiCodexDefaultRuntimeSelection({ - cfg: params.cfg, - raw: resolveModelRuntimePolicy({ - config: params.cfg, - provider: OPENAI_PROVIDER_ID, - modelId: params.modelId, - agentId, - }).policy?.id, - }), + const runtimeId = normalizeOptionalAgentRuntimeId(params.runtimeId); + if (runtimeId === CODEX_PLUGIN_ID) { + return true; + } + if (!isDefaultAgentRuntimeId(runtimeId)) { + return false; + } + return ( + resolveOpenAIImplicitAgentRuntime({ + provider: OPENAI_PROVIDER_ID, + modelId: params.modelId, + config: params.cfg, + env: params.env, + }) === CODEX_PLUGIN_ID ); } -function openAiHasCodexDefaultRuntimePolicy(cfg: OpenClawConfig): boolean { +/** Resolves effective runtime policy for one canonical provider/model route. */ +export function configuredModelRouteNeedsCodex(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + agentId?: string; + route: ModelRoute; +}): boolean { + if (normalizeProviderId(params.route.provider) !== OPENAI_PROVIDER_ID) { + return false; + } + const runtime = resolveModelRuntimePolicy({ + config: params.cfg, + provider: OPENAI_PROVIDER_ID, + modelId: params.route.modelId, + agentId: params.agentId, + }).policy?.id; + return configuredRuntimeNeedsCodex({ + cfg: params.cfg, + env: params.env, + modelId: params.route.modelId, + runtimeId: runtime, + }); +} + +function resolveEffectiveSelectedModelRefs(params: { cfg: OpenClawConfig; agentId: string }): { + complete: boolean; + values: ReadonlySet; +} { + const { cfg, agentId } = params; + const mainPrimaryRaw = resolveAgentEffectiveModelPrimary(cfg, agentId); + const mainFallbacks = + resolveAgentModelFallbacksOverride(cfg, agentId) ?? + resolveAgentModelFallbackValues(cfg.agents?.defaults?.model); + const subagentPrimaryRaw = + resolveSubagentConfiguredModelSelection({ cfg, agentId }) ?? mainPrimaryRaw; + const subagentFallbacks = + resolveEffectiveModelFallbacks({ + cfg, + agentId, + sessionKey: `agent:${agentId}:subagent:codex-diagnostic`, + hasSessionModelOverride: true, + modelOverrideSource: "auto", + }) ?? []; + const values = new Set(); + for (const raw of [mainPrimaryRaw, ...mainFallbacks, subagentPrimaryRaw, ...subagentFallbacks]) { + const value = raw?.trim(); + if (value) { + values.add(value); + } + } + return { + complete: Boolean(mainPrimaryRaw?.trim() && subagentPrimaryRaw?.trim()), + values, + }; +} + +function configuredRefTargetsAgent(params: { + cfg: OpenClawConfig; + path: string; + agentId: string; +}): boolean { + const match = /^agents\.list\.(\d+)\./.exec(params.path); + if (!match) { + return true; + } + const entry = params.cfg.agents?.list?.[Number(match[1])]; + return Boolean(entry && normalizeAgentId(entry.id) === params.agentId); +} + +function configuredRefIsEffectiveForAgent(params: { + cfg: OpenClawConfig; + path: string; + value: string; + agentId: string; + selectedModelRefs: ReadonlySet; +}): boolean { + if (!configuredRefTargetsAgent(params)) { + return false; + } + // Defaults may be shadowed by per-agent main/subagent selections. Keep only + // refs the runtime's inheritance rules leave reachable for this agent. + if (/^agents\.(?:defaults|list\.\d+)\.(?:model|subagents\.model)(?:\.|$)/.test(params.path)) { + return params.selectedModelRefs.has(params.value); + } + const agent = resolveAgentConfig(params.cfg, params.agentId); + if (params.path.endsWith(".heartbeat.model")) { + const heartbeat = + agent?.heartbeat?.model?.trim() || params.cfg.agents?.defaults?.heartbeat?.model?.trim(); + return heartbeat === params.value; + } + if (params.path.endsWith(".utilityModel")) { + const utilityModel = (agent?.utilityModel ?? params.cfg.agents?.defaults?.utilityModel)?.trim(); + return utilityModel === params.value; + } + return true; +} + +function configuredProviderPoliciesNeedCodex( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, + agentIds: string[], +): boolean { + for (const agentId of agentIds) { + const genericPolicy = resolveModelRuntimePolicy({ + config: cfg, + provider: OPENAI_PROVIDER_ID, + agentId, + }).policy; + if ( + genericPolicy?.id?.trim() && + configuredRuntimeNeedsCodex({ cfg, env, runtimeId: genericPolicy.id }) + ) { + return true; + } + } for (const [providerId, providerConfig] of Object.entries(cfg.models?.providers ?? {})) { if (normalizeProviderId(providerId) !== OPENAI_PROVIDER_ID) { continue; } - if (isCodexRuntimeSelection(providerConfig?.agentRuntime?.id)) { - return true; - } - // A model-scoped explicit "auto"/"default" overrides provider-wide PI/OpenClaw - // policy and falls back to the official OpenAI Codex runtime default. - if ( - providerConfig?.models?.some( - (model) => - model.agentRuntime?.id?.trim() && - openAiProviderModelCanResolveToCodexDefault({ cfg, modelId: model.id }), - ) - ) { - return true; - } - } - if (agentModelsHaveCodexDefaultRuntimePolicy(cfg, cfg.agents?.defaults?.models)) { - return true; - } - return ( - cfg.agents?.list?.some((agent) => - agentModelsHaveCodexDefaultRuntimePolicy(cfg, agent.models), - ) ?? false - ); -} - -function agentModelsHaveCodexDefaultRuntimePolicy( - cfg: OpenClawConfig, - models: Record | undefined, -): boolean { - for (const [modelRef, modelConfig] of Object.entries(models ?? {})) { - const parsed = parseModelCatalogRef(modelRef); - if ( - parsed?.provider === OPENAI_PROVIDER_ID && - isOpenAiCodexDefaultRuntimeSelection({ - cfg, - raw: modelConfig?.agentRuntime?.id, - }) - ) { - return true; + for (const model of providerConfig.models ?? []) { + if (!model.agentRuntime?.id?.trim()) { + continue; + } + const parsed = parseModelCatalogRef(model.id); + const modelId = parsed?.provider === OPENAI_PROVIDER_ID ? parsed.modelId : model.id.trim(); + if ( + modelId && + modelId !== "*" && + agentIds.some((agentId) => + configuredModelRouteNeedsCodex({ + cfg, + env, + agentId, + route: { provider: OPENAI_PROVIDER_ID, modelId }, + }), + ) + ) { + return true; + } } } return false; } -function openAiWildcardRuntimePolicy( - models: Record | undefined, -): AgentRuntimePolicyConfig | undefined { - for (const [modelRef, modelConfig] of Object.entries(models ?? {})) { - const parsed = parseModelCatalogRef(modelRef); - if ( - parsed?.provider === OPENAI_PROVIDER_ID && - parsed.modelId === "*" && - modelConfig?.agentRuntime?.id?.trim() - ) { - return modelConfig.agentRuntime; +function configuredModelRefsNeedCodex(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + agentIds: string[]; +}): { complete: boolean; needsCodex: boolean } { + const refs = collectConfiguredModelRefs(params.cfg); + let complete = true; + for (const agentId of params.agentIds) { + const selected = resolveEffectiveSelectedModelRefs({ cfg: params.cfg, agentId }); + complete &&= selected.complete; + const primary = resolveDefaultModelForAgent({ + cfg: params.cfg, + agentId, + manifestPlugins: [], + }); + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: primary.provider, + manifestPlugins: [], + }); + for (const ref of refs) { + if ( + !configuredRefIsEffectiveForAgent({ + cfg: params.cfg, + path: ref.path, + value: ref.value, + agentId, + selectedModelRefs: selected.values, + }) + ) { + continue; + } + const resolved = resolveModelRefFromString({ + cfg: params.cfg, + raw: ref.value, + defaultProvider: primary.provider, + aliasIndex, + allowManifestNormalization: false, + }); + const route = resolved + ? { provider: resolved.ref.provider, modelId: resolved.ref.model } + : undefined; + if ( + route && + configuredModelRouteNeedsCodex({ cfg: params.cfg, env: params.env, agentId, route }) + ) { + return { complete, needsCodex: true }; + } } } - return undefined; + return { complete, needsCodex: false }; } -function openAiDefaultRouteRuntimePolicy( +function defaultOpenAiRouteNeedsCodex( cfg: OpenClawConfig, -): AgentRuntimePolicyConfig | undefined { - // This mirrors the default-route slice of resolveModelRuntimePolicy: a global - // OpenAI wildcard policy is more specific than the provider-level policy. - return ( - openAiWildcardRuntimePolicy(cfg.agents?.defaults?.models) ?? openAiProviderRuntimePolicy(cfg) - ); -} - -function openAiDefaultRouteKeepsCodexUnavailable(cfg: OpenClawConfig): boolean { - const policy = openAiDefaultRouteRuntimePolicy(cfg); - if (!policy?.id?.trim()) { - // With no explicit runtime policy, the OpenAI route only needs Codex on the - // official OpenAI endpoint. OpenAI-compatible proxies stay on OpenClaw. - return !openAIProviderUsesCodexRuntimeByDefault({ - provider: OPENAI_PROVIDER_ID, + env: NodeJS.ProcessEnv, + agentIds: string[], +): boolean { + return agentIds.some((agentId) => { + const runtimeId = resolveModelRuntimePolicy({ config: cfg, - }); - } - // Any explicit default-route policy that does not resolve to Codex keeps the - // external Codex plugin optional, including custom OpenAI-compatible base URLs. - return !isOpenAiCodexDefaultRuntimeSelection({ cfg, raw: policy.id }); + provider: OPENAI_PROVIDER_ID, + agentId, + }).policy?.id; + return configuredRuntimeNeedsCodex({ cfg, env, runtimeId }); + }); } -/** - * Reports whether the default OpenAI route intentionally avoids the Codex plugin. - * - * Route-specific Codex selections still win; this only answers the missing-plugin - * diagnostic question for OpenAI defaults and OpenAI-compatible proxy configs. - */ -function configExplicitlyKeepsCodexUnavailableForOpenAi(cfg: OpenClawConfig): boolean { - if (openAiHasCodexDefaultRuntimePolicy(cfg)) { - return false; +function configNeedsCodexForOpenAi(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { + const agentIds = listAgentIds(cfg); + const configuredRefs = configuredModelRefsNeedCodex({ cfg, env, agentIds }); + if (configuredRefs.needsCodex) { + return true; } - return openAiDefaultRouteKeepsCodexUnavailable(cfg); + if (configuredProviderPoliciesNeedCodex(cfg, env, agentIds)) { + return true; + } + return configuredRefs.complete ? false : defaultOpenAiRouteNeedsCodex(cfg, env, agentIds); } -/** - * Suppresses missing Codex plugin diagnostics when config makes Codex optional. - * - * Explicitly enabled entries still warn so operator intent is honored even when - * all default routes would otherwise stay on the OpenClaw runtime. - */ -export function shouldSuppressMissingCodexPluginDiagnostics(cfg: OpenClawConfig): boolean { +/** Suppresses missing Codex diagnostics when no effective OpenAI route selects it. */ +export function shouldSuppressMissingCodexPluginDiagnostics( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): boolean { const entryEnabled = codexPluginEntryEnabled(cfg); if (entryEnabled === true) { return false; } - // A disabled entry is an explicit opt-out from the external Codex plugin. - // Route-specific Codex warnings still come from doctor when Codex is selected. - return entryEnabled === false || configExplicitlyKeepsCodexUnavailableForOpenAi(cfg); + // A disabled entry is an explicit opt-out; doctor reports selected-route conflicts. + return entryEnabled === false || !configNeedsCodexForOpenAi(cfg, env); } diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 815ab6577066..60c9a66c763a 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -319,14 +319,17 @@ describe("config plugin validation", () => { }); describe("missing Codex plugin diagnostics", () => { - const validateWithMissingCodexPlugin = (raw: Record) => + const validateWithMissingCodexPlugin = ( + raw: Record, + env: NodeJS.ProcessEnv = suiteEnv(), + ) => validateConfigObjectWithPlugins( { agents: { list: [{ id: "openclaw" }] }, ...raw, }, { - env: suiteEnv(), + env, pluginMetadataSnapshot: { manifestRegistry: { plugins: [], @@ -458,6 +461,402 @@ describe("config plugin validation", () => { expectMissingCodexPluginWarning(res.warnings); }); + it("warns when automatic gpt-5.6 overrides a provider PI runtime policy", () => { + const res = validateWithMissingCodexPlugin({ + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [], + agentRuntime: { id: "pi" }, + }, + }, + }, + agents: { + list: [{ id: "openclaw" }], + defaults: { + models: { + "openai/gpt-5.6": { agentRuntime: { id: "default" } }, + }, + }, + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it("warns when automatic Spark overrides a provider PI runtime policy", () => { + const res = validateWithMissingCodexPlugin({ + models: { + providers: { + openai: { + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [], + agentRuntime: { id: "pi" }, + }, + }, + }, + agents: { + list: [{ id: "openclaw" }], + defaults: { + models: { + "openai/gpt-5.3-codex-spark": { agentRuntime: { id: "default" } }, + }, + }, + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it("warns when the selected gpt-5.6 primary uses the implicit Codex runtime", () => { + const res = validateWithMissingCodexPlugin({ + agents: { + defaults: { + model: { primary: "openai/gpt-5.6", fallbacks: [] }, + }, + list: [{ id: "openclaw" }], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it("warns when the selected Spark primary uses the implicit Codex runtime", () => { + const res = validateWithMissingCodexPlugin({ + agents: { + defaults: { + model: { primary: "openai/gpt-5.3-codex-spark", fallbacks: [] }, + }, + list: [{ id: "openclaw" }], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it("does not inherit default fallbacks after a listed agent selects its own primary", () => { + const res = validateWithMissingCodexPlugin({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.6", + fallbacks: ["openai/gpt-5.3-codex-spark"], + }, + }, + list: [ + { + id: "worker", + model: { primary: "anthropic/claude-sonnet-4-6" }, + }, + ], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectNoMissingCodexPluginWarning(res.warnings); + }); + + it("warns when a listed agent can fall back from gpt-5.6 to Spark", () => { + const res = validateWithMissingCodexPlugin({ + agents: { + defaults: { + model: { primary: "openai/gpt-5.6", fallbacks: [] }, + }, + list: [ + { id: "openclaw" }, + { + id: "worker", + model: { + primary: "openai/gpt-5.6", + fallbacks: ["openai/gpt-5.3-codex-spark"], + }, + }, + ], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it.each([ + { + name: "default subagent", + agents: { + defaults: { + model: { primary: "openai/gpt-5.6", fallbacks: [] }, + subagents: { model: "openai/gpt-5.3-codex-spark" }, + }, + list: [{ id: "openclaw" }], + }, + }, + { + name: "listed-agent subagent", + agents: { + defaults: { + model: { primary: "openai/gpt-5.6", fallbacks: [] }, + subagents: { model: "openai/gpt-5.6" }, + }, + list: [ + { id: "openclaw" }, + { + id: "worker", + subagents: { model: "openai/gpt-5.3-codex-spark" }, + }, + ], + }, + }, + ])("warns when the effective $name model needs Codex", ({ agents }) => { + const res = validateWithMissingCodexPlugin({ + agents, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it("uses a listed-agent subagent model before the default subagent model", () => { + const res = validateWithMissingCodexPlugin({ + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] }, + subagents: { model: "openai/gpt-5.3-codex-spark" }, + }, + list: [ + { + id: "openclaw", + subagents: { model: "anthropic/claude-sonnet-4-6" }, + }, + ], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectNoMissingCodexPluginWarning(res.warnings); + }); + + it("warns when an effective heartbeat route needs Codex", () => { + const res = validateWithMissingCodexPlugin({ + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] }, + heartbeat: { model: "openai/gpt-5.3-codex-spark" }, + }, + list: [{ id: "openclaw" }], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it.each([ + { + name: "compaction-only", + auxiliary: { compaction: { model: "openai/gpt-5.3-codex-spark" } }, + }, + { + name: "utility", + auxiliary: { utilityModel: "openai/gpt-5.3-codex-spark" }, + }, + ])("warns when a canonical $name route needs Codex", ({ auxiliary }) => { + const res = validateWithMissingCodexPlugin({ + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] }, + ...auxiliary, + }, + list: [{ id: "openclaw" }], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it("warns when a channel model override needs Codex", () => { + const res = validateWithMissingCodexPlugin({ + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] }, + }, + list: [{ id: "openclaw" }], + }, + channels: { + modelByChannel: { + telegram: { default: "openai/gpt-5.3-codex-spark" }, + }, + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it.each([ + { + name: "primary", + model: { primary: "spark", fallbacks: [] }, + subagentModel: "direct", + }, + { + name: "fallback", + model: { primary: "direct", fallbacks: ["spark"] }, + subagentModel: "direct", + }, + { + name: "subagent", + model: { primary: "direct", fallbacks: [] }, + subagentModel: "spark", + }, + ])("resolves a Codex $name model alias before diagnostics", ({ model, subagentModel }) => { + const res = validateWithMissingCodexPlugin({ + agents: { + defaults: { + model, + subagents: { model: subagentModel }, + models: { + "openai/gpt-5.6": { alias: "direct" }, + "openai/gpt-5.3-codex-spark": { alias: "spark" }, + }, + }, + list: [{ id: "openclaw" }], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it("does not warn for a fully shadowed default exact Codex policy", () => { + const res = validateWithMissingCodexPlugin({ + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [], + agentRuntime: { id: "pi" }, + }, + }, + }, + agents: { + defaults: { + model: { primary: "openai/gpt-5.6", fallbacks: [] }, + models: { + "openai/gpt-5.6": { agentRuntime: { id: "codex" } }, + }, + }, + list: [ + { + id: "openclaw", + models: { + "openai/gpt-5.6": { agentRuntime: { id: "pi" } }, + }, + }, + ], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectNoMissingCodexPluginWarning(res.warnings); + }); + + it("warns when a default exact Codex policy remains reachable by another agent", () => { + const res = validateWithMissingCodexPlugin({ + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [], + agentRuntime: { id: "pi" }, + }, + }, + }, + agents: { + defaults: { + model: { primary: "openai/gpt-5.6", fallbacks: [] }, + models: { + "openai/gpt-5.6": { agentRuntime: { id: "codex" } }, + }, + }, + list: [ + { + id: "openclaw", + models: { + "openai/gpt-5.6": { agentRuntime: { id: "pi" } }, + }, + }, + { id: "worker" }, + ], + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + expectMissingCodexPluginWarning(res.warnings); + }); + + it.each([ + { + name: "agent wildcard PI over provider Codex", + providerRuntime: "codex", + wildcardRuntime: "pi", + warns: false, + }, + { + name: "agent wildcard Codex over provider PI", + providerRuntime: "pi", + wildcardRuntime: "codex", + warns: true, + }, + ])("uses $name precedence", ({ providerRuntime, wildcardRuntime, warns }) => { + const res = validateWithMissingCodexPlugin({ + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [], + agentRuntime: { id: providerRuntime }, + }, + }, + }, + agents: { + list: [{ id: "openclaw" }], + defaults: { + models: { + "openai/*": { agentRuntime: { id: wildcardRuntime } }, + }, + }, + }, + plugins: { entries: { codex: {} } }, + }); + + expect(res.ok).toBe(true); + if (warns) { + expectMissingCodexPluginWarning(res.warnings); + } else { + expectNoMissingCodexPluginWarning(res.warnings); + } + }); + it("does not warn when a custom OpenAI-compatible base URL uses automatic runtime policy", () => { const res = validateWithMissingCodexPlugin({ models: { @@ -542,6 +941,28 @@ describe("config plugin validation", () => { expectNoMissingCodexPluginWarning(res.warnings); }); + it("uses the validation environment snapshot for implicit OpenAI routing", () => { + const config = { + plugins: { entries: { codex: {} } }, + }; + const customEnv = { + ...suiteEnv(), + OPENAI_BASE_URL: "https://proxy.example.invalid/v1", + }; + const platformEnv = { + ...suiteEnv(), + OPENAI_BASE_URL: "https://api.openai.com/v1", + }; + + const customResult = validateWithMissingCodexPlugin(config, customEnv); + const platformResult = validateWithMissingCodexPlugin(config, platformEnv); + + expect(customResult.ok).toBe(true); + expectNoMissingCodexPluginWarning(customResult.warnings); + expect(platformResult.ok).toBe(true); + expectMissingCodexPluginWarning(platformResult.warnings); + }); + it("does not warn when a normalized custom OpenAI-compatible provider key uses implicit runtime policy", () => { const res = validateWithMissingCodexPlugin({ models: { diff --git a/src/config/model-provider-config.test.ts b/src/config/model-provider-config.test.ts new file mode 100644 index 000000000000..62598641050c --- /dev/null +++ b/src/config/model-provider-config.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + resolveMergedModelProviderModels, + resolveModelProviderRouteOverridePresence, +} from "./model-provider-config.js"; +import type { ModelDefinitionConfig } from "./types.models.js"; + +function model(id: string, fields: Partial = {}): ModelDefinitionConfig { + return { id, ...fields } as ModelDefinitionConfig; +} + +describe("resolveMergedModelProviderModels", () => { + it("keeps first-row fields and fills only omissions from canonical duplicates", () => { + const models = resolveMergedModelProviderModels({ + models: [ + model("openai/gpt-5.5", { + api: "openai-responses", + headers: {}, + }), + model("gpt-5.5", { + api: "openai-completions", + baseUrl: "https://relay.example.test/v1", + headers: { "x-route": "custom" }, + params: { azureApiVersion: "2025-01-01" }, + }), + ], + normalizeModelId: (modelId) => modelId.replace(/^openai\//u, ""), + }); + + expect(models.get("gpt-5.5")).toEqual({ + id: "openai/gpt-5.5", + api: "openai-responses", + baseUrl: "https://relay.example.test/v1", + headers: {}, + params: { azureApiVersion: "2025-01-01" }, + }); + }); + + it("fills headers when the first canonical row omits them", () => { + const models = resolveMergedModelProviderModels({ + models: [ + model("gpt-5.5", { api: "openai-responses" }), + model("openai/gpt-5.5", { headers: { "x-route": "custom" } }), + ], + normalizeModelId: (modelId) => modelId.replace(/^openai\//u, ""), + }); + + expect(models.get("gpt-5.5")?.headers).toEqual({ "x-route": "custom" }); + }); +}); + +describe("resolveModelProviderRouteOverridePresence", () => { + it("treats authored model compatibility as request behavior", () => { + const config = { + models: { + providers: { + openai: { + models: [ + { id: "gpt-5.5", compat: { supportsStore: false } }, + { id: "gpt-5.5-empty", compat: {} }, + ], + }, + }, + }, + } as never; + + expect( + resolveModelProviderRouteOverridePresence({ + provider: "openai", + modelId: "gpt-5.5", + config, + }), + ).toBe("present"); + expect( + resolveModelProviderRouteOverridePresence({ + provider: "openai", + modelId: "gpt-5.5-empty", + config, + }), + ).toBe("none"); + }); + + it("treats a provider request timeout as authored behavior", () => { + expect( + resolveModelProviderRouteOverridePresence({ + provider: "openai", + modelId: "gpt-5.5", + config: { + models: { + providers: { + openai: { baseUrl: "", timeoutSeconds: 90, models: [model("gpt-5.5")] }, + }, + }, + }, + }), + ).toBe("present"); + }); +}); diff --git a/src/config/model-provider-config.ts b/src/config/model-provider-config.ts new file mode 100644 index 000000000000..46fb843b393c --- /dev/null +++ b/src/config/model-provider-config.ts @@ -0,0 +1,137 @@ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import type { ProviderRouteOverridePresence } from "../plugin-sdk/provider-model-types.js"; +import type { ModelDefinitionConfig, ModelProviderConfig } from "./types.models.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +type MergedModelProviderEntry = { + providerKey: string; + providerConfig: ModelProviderConfig; +}; + +/** Indexes configured model rows after caller-owned model-id normalization. */ +export function resolveMergedModelProviderModels(params: { + models: readonly ModelDefinitionConfig[] | undefined; + normalizeModelId: (modelId: string) => string | undefined; +}): ReadonlyMap { + const models = new Map(); + for (const model of params.models ?? []) { + const modelId = params.normalizeModelId(model.id); + if (!modelId) { + continue; + } + const existing = models.get(modelId); + // Earlier rows stay authoritative, including explicit empty objects; + // later duplicates only supply top-level fields the first row omitted. + models.set(modelId, existing ? { ...model, ...existing } : model); + } + return models; +} + +function normalizeModelId(provider: string, modelId: string): string { + const trimmed = modelId.trim(); + const slashIndex = trimmed.indexOf("/"); + return slashIndex > 0 && + normalizeProviderId(trimmed.slice(0, slashIndex)) === normalizeProviderId(provider) + ? trimmed.slice(slashIndex + 1).trim() + : trimmed; +} + +function readRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function hasNonEmptyRecord(value: unknown): boolean { + const record = readRecord(value); + return record !== undefined && Object.keys(record).length > 0; +} + +/** Projects authored request behavior without exposing values or local commands. */ +export function resolveModelProviderRouteOverridePresence(params: { + provider: string; + modelId?: string; + config?: OpenClawConfig; + canonicalizeModelId?: (modelId: string) => string; +}): ProviderRouteOverridePresence { + const providerConfig = resolveMergedModelProviderConfig(params.config, params.provider); + if (!providerConfig) { + return "none"; + } + if ( + readRecord(providerConfig.localService) !== undefined || + hasNonEmptyRecord(providerConfig.headers) || + hasNonEmptyRecord(providerConfig.request) || + hasNonEmptyRecord(providerConfig.params) || + typeof providerConfig.authHeader === "boolean" || + typeof providerConfig.timeoutSeconds === "number" + ) { + return "present"; + } + if (!params.modelId) { + return "none"; + } + const canonicalize = (modelId: string) => { + const normalized = normalizeModelId(params.provider, modelId); + const canonical = params.canonicalizeModelId?.(normalized).trim(); + return canonical || normalized; + }; + const modelId = canonicalize(params.modelId); + const configuredModel = resolveMergedModelProviderModels({ + models: providerConfig.models, + normalizeModelId: canonicalize, + }).get(modelId); + return configuredModel && + (hasNonEmptyRecord(configuredModel.headers) || + hasNonEmptyRecord(configuredModel.params) || + hasNonEmptyRecord(configuredModel.compat)) + ? "present" + : "none"; +} + +/** Resolves the provider entry produced by models-config key normalization. */ +export function resolveMergedModelProviderEntry( + config: OpenClawConfig | undefined, + provider: string, +): MergedModelProviderEntry | undefined { + const requestedProvider = provider.trim(); + const normalizedProvider = normalizeProviderId(requestedProvider); + if (!normalizedProvider) { + return undefined; + } + const providers = Object.entries(config?.models?.providers ?? {}); + // normalizeProviders trims keys but does not lowercase them. Preserve its + // exact-key precedence, then use the existing case-insensitive fallback. + const exactKey = providers.find(([providerId]) => providerId.trim() === requestedProvider)?.[0]; + const fallbackKey = providers.find( + ([providerId]) => normalizeProviderId(providerId) === normalizedProvider, + )?.[0]; + const providerKey = (exactKey ?? fallbackKey)?.trim(); + if (!providerKey) { + return undefined; + } + let matched: ModelProviderConfig | undefined; + for (const [providerId, providerConfig] of providers) { + if (providerId.trim() !== providerKey) { + continue; + } + // Match normalizeProviders: later fields win, while omitted model rows keep + // the earlier catalog instead of erasing it from route/auth decisions. + matched = matched + ? { + ...matched, + ...providerConfig, + models: providerConfig.models ?? matched.models, + } + : providerConfig; + } + return matched ? { providerKey, providerConfig: matched } : undefined; +} + +/** Resolves only the merged provider config when its canonical key is not needed. */ +export function resolveMergedModelProviderConfig( + config: OpenClawConfig | undefined, + provider: string, +): ModelProviderConfig | undefined { + return resolveMergedModelProviderEntry(config, provider)?.providerConfig; +} diff --git a/src/config/validation.ts b/src/config/validation.ts index b7ff7f36b9c7..8857c9ef3251 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1931,7 +1931,7 @@ function validateConfigObjectWithPluginsBase( if ( normalizePluginId(pluginId) === "codex" && pathLocal === "plugins.entries.codex" && - shouldSuppressMissingCodexPluginDiagnostics(config) + shouldSuppressMissingCodexPluginDiagnostics(config, opts.env ?? process.env) ) { return; } diff --git a/src/flows/model-picker.provider-catalog.test.ts b/src/flows/model-picker.provider-catalog.test.ts index a3a3f86dd135..b47143ad8488 100644 --- a/src/flows/model-picker.provider-catalog.test.ts +++ b/src/flows/model-picker.provider-catalog.test.ts @@ -118,6 +118,7 @@ describe("loadPreferredProviderPickerCatalog", () => { return { provider: { baseUrl: "https://integrate.api.nvidia.com/v1", + api: "openai-completions", models: [ textModel("nvidia/nemotron-3-super-120b-a12b", "Nemotron"), textModel("minimaxai/minimax-m2.7", "MiniMax M2.7"), @@ -143,6 +144,14 @@ describe("loadPreferredProviderPickerCatalog", () => { "nvidia/minimaxai/minimax-m2.7", ]); expect(rows.map((entry) => entry.id)).not.toContain("minimaxai/minimax-m2.5"); + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + api: "openai-completions", + baseUrl: "https://integrate.api.nvidia.com/v1", + }), + ]), + ); expect(providersRuntimeMocks.resolvePluginProviders).toHaveBeenCalledWith({ config: {}, env: { NVIDIA_API_KEY: "nvapi-test" }, diff --git a/src/flows/model-picker.provider-catalog.ts b/src/flows/model-picker.provider-catalog.ts index 967175c186ad..45afc518448e 100644 --- a/src/flows/model-picker.provider-catalog.ts +++ b/src/flows/model-picker.provider-catalog.ts @@ -118,10 +118,14 @@ function modelFromProviderCatalog(params: { const contextTokens = positiveNumber(params.model.contextTokens) ?? positiveNumber(params.providerConfig.contextTokens); + const api = params.model.api ?? params.providerConfig.api; + const baseUrl = params.model.baseUrl ?? params.providerConfig.baseUrl; return { id, name: params.model.name || id, provider: params.provider, + ...(api !== undefined ? { api } : {}), + ...(baseUrl !== undefined ? { baseUrl } : {}), ...(contextWindow !== undefined ? { contextWindow } : {}), ...(contextTokens !== undefined ? { contextTokens } : {}), reasoning: params.model.reasoning, diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 77714739874c..fa64f61ced3d 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -3,11 +3,20 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { resolveDefaultAgentDir } from "../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { resolveVisibleModelCatalog } from "../agents/model-catalog-visibility.js"; -import { loadModelCatalog } from "../agents/model-catalog.js"; +import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js"; +import { + resolveLogicalModelCatalogEntryState, + resolveLogicalVisibleModelCatalog, + type ModelCatalogAuthChecker, +} from "../agents/model-catalog-visibility.js"; +import { loadModelCatalogSnapshot } from "../agents/model-catalog.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; +import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js"; import { createModelPickerVisibleProviderPredicate } from "../agents/model-picker-visibility.js"; -import { createProviderAuthChecker } from "../agents/model-provider-auth.js"; +import { + createProviderAuthChecker, + type ProviderModelAuthChecker, +} from "../agents/model-provider-auth.js"; import { formatLiteralProviderPrefixedModelRef } from "../agents/model-ref-shared.js"; import { buildConfiguredModelCatalog, @@ -19,6 +28,7 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../agents/model-selection.js"; +import { openAIModelCatalogRoutePolicy } from "../agents/openai-model-routes.js"; import { loadStaticManifestCatalogRowsForList } from "../commands/models/list.manifest-catalog.js"; import { formatTokenK } from "../commands/models/shared.js"; import { @@ -43,6 +53,12 @@ const MANUAL_VALUE = "__manual__"; const BROWSE_VALUE = "__browse__"; const PROVIDER_FILTER_THRESHOLD = 30; const EMPTY_LITERAL_PREFIX_PROVIDERS = new Set(); +type ModelRouteRuntimeResolver = (params: { + provider: string; + modelId: string; + api?: string | null; + baseUrl?: unknown; +}) => "codex" | "openclaw" | undefined; // Internal router models are valid defaults during auth/setup but not manual API targets. const HIDDEN_ROUTER_MODELS = new Set(["openrouter/auto"]); @@ -127,6 +143,8 @@ function toPickerCatalogEntry( id: row.id, name: row.name, provider: row.provider, + ...(row.api !== undefined ? { api: row.api } : {}), + ...(row.baseUrl !== undefined ? { baseUrl: row.baseUrl } : {}), ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), reasoning: row.reasoning, input: row.input, @@ -144,9 +162,13 @@ function loadPickerModelCatalog( workspaceDir?: string; env?: NodeJS.ProcessEnv; } = {}, -): ReturnType { +): Promise { + const snapshot = (entries: ModelCatalogEntry[]): ModelCatalogSnapshot => ({ + entries, + routeVariants: entries, + }); if (cfg.models?.mode === "replace") { - return Promise.resolve(buildConfiguredModelCatalog({ cfg })); + return Promise.resolve(snapshot(buildConfiguredModelCatalog({ cfg }))); } if (opts.preferredProvider) { if (opts.preferLiveProviderCatalog) { @@ -158,7 +180,7 @@ function loadPickerModelCatalog( ...(opts.env !== undefined ? { env: opts.env } : {}), }).then((providerCatalog) => { if (providerCatalog.length > 0) { - return providerCatalog; + return snapshot(providerCatalog); } if (opts.allowStaticFallbackCatalog !== false) { const manifestRows = loadStaticManifestCatalogRowsForList({ @@ -167,12 +189,12 @@ function loadPickerModelCatalog( ...(opts.env !== undefined ? { env: opts.env } : {}), }); if (manifestRows.length > 0) { - return manifestRows.map(toPickerCatalogEntry); + return snapshot(manifestRows.map(toPickerCatalogEntry)); } } return opts.providerScoped - ? [] - : loadModelCatalog({ + ? snapshot([]) + : loadModelCatalogSnapshot({ config: cfg, }); }); @@ -183,17 +205,76 @@ function loadPickerModelCatalog( ...(opts.env !== undefined ? { env: opts.env } : {}), }); if (manifestRows.length > 0) { - return Promise.resolve(manifestRows.map(toPickerCatalogEntry)); + return Promise.resolve(snapshot(manifestRows.map(toPickerCatalogEntry))); } if (opts.providerScoped) { - return Promise.resolve([]); + return Promise.resolve(snapshot([])); } } - return loadModelCatalog({ + return loadModelCatalogSnapshot({ config: cfg, }); } +async function resolvePickerLogicalCatalog(params: { + cfg: OpenClawConfig; + catalog: ModelCatalogEntry[]; + routeVariants: readonly ModelCatalogEntry[]; + defaultProvider: string; + defaultModel?: string; + agentId?: string; + workspaceDir?: string; + view?: "default" | "configured" | "all"; + hasAuth: ProviderModelAuthChecker; +}): Promise { + const sourceOrder = new Map(); + for (const entry of params.catalog) { + const key = + openAIModelCatalogRoutePolicy.resolveIdentity(entry)?.key ?? modelCatalogEntryKey(entry); + if (!sourceOrder.has(key)) { + sourceOrder.set(key, sourceOrder.size); + } + } + const catalog = await resolveLogicalVisibleModelCatalog({ + cfg: params.cfg, + catalog: params.catalog, + defaultProvider: params.defaultProvider, + ...(params.defaultModel ? { defaultModel: params.defaultModel } : {}), + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(params.view ? { view: params.view } : {}), + routePolicy: openAIModelCatalogRoutePolicy, + routeVariants: params.routeVariants, + evaluateEntry: async (entry, routeVariants) => { + const identity = openAIModelCatalogRoutePolicy.resolveIdentity(entry); + const evaluation = await params.hasAuth.evaluateModelAuth(entry.provider, { + modelId: identity?.id ?? entry.id, + observedRoutes: routeVariants.map((variant) => ({ + api: variant.api, + baseUrl: variant.baseUrl, + })), + }); + return resolveLogicalModelCatalogEntryState({ + entry, + evaluation, + routePolicy: openAIModelCatalogRoutePolicy, + }); + }, + }); + // Picker sources encode product priority: live rows lead static/configured + // supplements. Logical projection must not replace that order with display sorting. + return catalog.toSorted((left, right) => { + const leftKey = + openAIModelCatalogRoutePolicy.resolveIdentity(left)?.key ?? modelCatalogEntryKey(left); + const rightKey = + openAIModelCatalogRoutePolicy.resolveIdentity(right)?.key ?? modelCatalogEntryKey(right); + return ( + (sourceOrder.get(leftKey) ?? Number.MAX_SAFE_INTEGER) - + (sourceOrder.get(rightKey) ?? Number.MAX_SAFE_INTEGER) + ); + }); +} + function normalizeModelKeys(values: string[]): string[] { const seen = new Set(); const next: string[] = []; @@ -250,15 +331,58 @@ function resolveFallbackModelKeys(params: { ); } -function resolveModelRouteHint(provider: string): string | undefined { - const normalized = normalizeProviderId(provider); - if (normalized === "openai") { - return "Codex runtime route"; +function createModelRouteRuntimeResolver(params: { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): ModelRouteRuntimeResolver { + const cache = new Map(); + return (route) => { + const baseUrlKey = + typeof route.baseUrl === "string" + ? route.baseUrl + : route.baseUrl == null + ? "" + : typeof route.baseUrl; + const key = [route.provider, route.modelId, route.api ?? "", baseUrlKey].join("\0"); + if (cache.has(key)) { + return cache.get(key); + } + const policy = resolveAgentHarnessPolicy({ + provider: route.provider, + modelId: route.modelId, + modelApi: route.api, + modelBaseUrl: route.baseUrl, + config: params.config, + env: params.env, + }); + const runtime = + policy.runtime === "codex" ? "codex" : policy.runtime === "openclaw" ? "openclaw" : undefined; + cache.set(key, runtime); + return runtime; + }; +} + +function resolveModelRouteHint(params: { + provider: string; + modelId: string; + api?: string | null; + baseUrl?: unknown; + resolveModelRouteRuntime: ModelRouteRuntimeResolver; +}): string | undefined { + if (normalizeProviderId(params.provider) !== "openai") { + return undefined; } - if (normalized === "openai") { - return "legacy Codex OAuth route"; - } - return undefined; + const runtime = params.resolveModelRouteRuntime({ + provider: params.provider, + modelId: params.modelId, + api: params.api, + baseUrl: params.baseUrl, + }); + return runtime === "codex" + ? "Codex runtime route" + : runtime === "openclaw" + ? "OpenClaw runtime route" + : undefined; } async function resolveLiteralPrefixProviderIds(params: { @@ -306,13 +430,16 @@ async function addModelSelectOption(params: { name?: string; contextWindow?: number; reasoning?: boolean; + api?: string | null; + baseUrl?: unknown; }; options: WizardSelectOption[]; seen: Set; aliasIndex: ReturnType; - hasAuth: (provider: string) => Promise; + hasAuth: ModelCatalogAuthChecker; literalPrefixProviders: Set; isVisibleProvider: (provider: string) => boolean; + resolveModelRouteRuntime: ModelRouteRuntimeResolver; }) { const normalizedRef = normalizeModelRef(params.entry.provider, params.entry.id); const key = modelCatalogEntryKey(params.entry); @@ -337,11 +464,23 @@ async function addModelSelectOption(params: { if (aliases?.length) { hints.push(`alias: ${aliases.join(", ")}`); } - const routeHint = resolveModelRouteHint(normalizedRef.provider); + const routeHint = resolveModelRouteHint({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + api: params.entry.api, + baseUrl: params.entry.baseUrl, + resolveModelRouteRuntime: params.resolveModelRouteRuntime, + }); if (routeHint) { hints.push(routeHint); } - if (!(await params.hasAuth(normalizedRef.provider))) { + if ( + !(await params.hasAuth(normalizedRef.provider, { + modelId: normalizedRef.model, + api: params.entry.api, + baseUrl: params.entry.baseUrl, + })) + ) { return; } const label = formatModelRefLabel({ @@ -374,10 +513,11 @@ async function addModelKeySelectOption(params: { options: WizardSelectOption[]; seen: Set; aliasIndex: ReturnType; - hasAuth: (provider: string) => Promise; + hasAuth: ModelCatalogAuthChecker; literalPrefixProviders?: Set; isVisibleProvider: (provider: string) => boolean; fallbackHint: string; + resolveModelRouteRuntime: ModelRouteRuntimeResolver; }) { const entry = splitModelKey(params.key); if (!entry) { @@ -392,6 +532,7 @@ async function addModelKeySelectOption(params: { hasAuth: params.hasAuth, literalPrefixProviders: params.literalPrefixProviders ?? EMPTY_LITERAL_PREFIX_PROVIDERS, isVisibleProvider: params.isVisibleProvider, + resolveModelRouteRuntime: params.resolveModelRouteRuntime, }); if (params.seen.size > before) { const option = params.options.at(-1); @@ -788,10 +929,10 @@ export async function promptDefaultModel( } const catalogProgress = params.prompter.progress(t("wizard.model.loadingModels")); - let catalog: Awaited>; + let catalogSnapshot: ModelCatalogSnapshot; try { const providerScopedCatalog = browseCatalogOnDemand && preferredProvider; - catalog = await loadPickerModelCatalog(cfg, { + catalogSnapshot = await loadPickerModelCatalog(cfg, { preferredProvider: providerScopedCatalog ? preferredProvider : undefined, preferLiveProviderCatalog: Boolean(providerScopedCatalog), providerScoped: Boolean(providerScopedCatalog), @@ -802,6 +943,7 @@ export async function promptDefaultModel( } finally { catalogProgress.stop(); } + const catalog = catalogSnapshot.entries; if (catalog.length === 0) { return promptManualModel({ prompter: params.prompter, @@ -814,17 +956,26 @@ export async function promptDefaultModel( cfg, defaultProvider: DEFAULT_PROVIDER, }); - const models = ignoreAllowlist - ? catalog - : await resolveVisibleModelCatalog({ - cfg, - catalog, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: resolved.model, - agentDir: pickerAgentDir, - workspaceDir: params.workspaceDir, - env: params.env, - }); + const hasAuth = createProviderAuthChecker({ + cfg, + workspaceDir: params.workspaceDir, + agentDir: pickerAgentDir, + env: params.env, + }); + const resolveModelRouteRuntime = createModelRouteRuntimeResolver({ + config: cfg, + env: params.env, + }); + const models = await resolvePickerLogicalCatalog({ + cfg, + catalog, + routeVariants: catalogSnapshot.routeVariants, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: resolved.model, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(ignoreAllowlist ? { view: "all" as const } : {}), + hasAuth, + }); if (models.length === 0) { return promptManualModel({ prompter: params.prompter, @@ -865,12 +1016,6 @@ export async function promptDefaultModel( const hasPreferredProvider = preferredProvider ? filteredModels.some((entry) => matchesPreferredProvider?.(entry.provider)) : false; - const hasAuth = createProviderAuthChecker({ - cfg, - workspaceDir: params.workspaceDir, - agentDir: pickerAgentDir, - env: params.env, - }); const literalPrefixProviders = await resolveCachedLiteralPrefixProviders(); // Show the literal form (e.g. nvidia/nvidia/...) in the "Keep current" label @@ -913,6 +1058,7 @@ export async function promptDefaultModel( hasAuth, literalPrefixProviders, isVisibleProvider, + resolveModelRouteRuntime, }); } if (configuredKey && !seen.has(configuredKey)) { @@ -1053,6 +1199,10 @@ export async function promptModelAllowlist(params: { agentDir: pickerAgentDir, env: params.env, }); + const resolveModelRouteRuntime = createModelRouteRuntimeResolver({ + config: cfg, + env: params.env, + }); const matchesPreferredProvider = preferredProvider ? createPreferredProviderMatcher({ preferredProvider, @@ -1091,6 +1241,7 @@ export async function promptModelAllowlist(params: { aliasIndex, hasAuth, isVisibleProvider, + resolveModelRouteRuntime, fallbackHint: allowedKeys.length > 0 ? t("wizard.model.allowed") : t("wizard.model.configured"), }); @@ -1123,9 +1274,9 @@ export async function promptModelAllowlist(params: { } const allowlistProgress = params.prompter.progress(t("wizard.model.loadingModels")); - let catalog: Awaited>; + let catalogSnapshot: ModelCatalogSnapshot; try { - catalog = await loadPickerModelCatalog(cfg, { + catalogSnapshot = await loadPickerModelCatalog(cfg, { preferredProvider, preferLiveProviderCatalog: Boolean(preferredProvider), providerScoped: Boolean(preferredProvider && params.providerScopedCatalog), @@ -1137,6 +1288,7 @@ export async function promptModelAllowlist(params: { } finally { allowlistProgress.stop(); } + let catalog = catalogSnapshot.entries; let providerStaticCatalogRows: | ReturnType | undefined; @@ -1187,6 +1339,16 @@ export async function promptModelAllowlist(params: { } catalog = mergedCatalog; } + catalog = await resolvePickerLogicalCatalog({ + cfg, + catalog, + routeVariants: catalogSnapshot.routeVariants, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: resolved.model, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + view: "all", + hasAuth, + }); if (catalog.length === 0 && allowedKeys.length === 0) { const noCatalogInitialKeys = existingKeys.length > 0 ? normalizeModelKeys([...existingKeys, ...fallbackKeys]) : []; @@ -1268,6 +1430,7 @@ export async function promptModelAllowlist(params: { hasAuth, literalPrefixProviders, isVisibleProvider, + resolveModelRouteRuntime, }); } diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index c1260eef70d8..a249bffcf1a8 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -1,6 +1,6 @@ // Local embedded Gateway request context. // Lets local agent paths reuse Gateway server methods without starting a server. -import { loadManifestModelCatalog } from "../agents/model-catalog.js"; +import { loadManifestModelCatalog, loadModelCatalogSnapshot } from "../agents/model-catalog.js"; import type { CliDeps } from "../cli/deps.types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -82,6 +82,8 @@ function createLocalGatewayRequestContext( isTerminalEnabled: () => false, loadGatewayModelCatalog: async () => loadManifestModelCatalog({ config: params.getRuntimeConfig() }), + loadGatewayModelCatalogSnapshot: async ({ readOnly } = {}) => + loadModelCatalogSnapshot({ config: params.getRuntimeConfig(), readOnly }), getHealthCache: () => null, refreshHealthSnapshot: async () => ({}) as Awaited>, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index b964256b8e6c..76fb5c2f714c 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -42,7 +42,7 @@ import { } from "../../agents/agent-scope.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js"; import { modelCatalogBrowseRequiresFullDiscovery } from "../../agents/model-catalog-browse.js"; -import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; +import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { createAgentRunRestartAbortError } from "../../agents/run-termination.js"; import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox/context.js"; @@ -133,6 +133,7 @@ import { isWebchatClient, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { listGatewayAgentsBasic } from "../agent-list.js"; import { abortChatRunById, boundInFlightRunSnapshotForChatHistory, @@ -230,7 +231,8 @@ import { } from "./chat-webchat-media.js"; import { loadOptionalServerMethodModelCatalog, - startOptionalServerMethodModelCatalogLoad, + loadOptionalServerMethodModelCatalogSnapshot, + startOptionalServerMethodModelCatalogSnapshotLoad, } from "./optional-model-catalog.js"; import { hasTrackedActiveSessionRun, @@ -450,7 +452,6 @@ async function buildChatMetadataResult(params: { cfg: OpenClawConfig; context: GatewayRequestContext; agentId: string; - preloadedModelCatalog?: ModelCatalogEntry[]; }): Promise { const [{ buildModelsListResult }, { buildCommandsListResult }] = await Promise.all([ import("./models-list-result.js"), @@ -461,7 +462,6 @@ async function buildChatMetadataResult(params: { context: params.context, agentId: params.agentId, params: { view: "configured" }, - preloadedCatalog: params.preloadedModelCatalog, }), Promise.resolve( buildCommandsListResult({ @@ -479,7 +479,10 @@ async function buildChatStartupMetadataResult(params: { cfg: OpenClawConfig; context: GatewayRequestContext; agentId: string; - modelCatalog: ModelCatalogEntry[] | undefined; + modelCatalog: ModelCatalogSnapshot | undefined; + catalogProjector?: ReturnType< + (typeof import("./models-list-result.js"))["createGatewayAgentModelCatalogProjector"] + >; }): Promise { if (!params.modelCatalog) { return undefined; @@ -494,6 +497,7 @@ async function buildChatStartupMetadataResult(params: { agentId: params.agentId, params: { view: "configured" }, preloadedCatalog: params.modelCatalog, + ...(params.catalogProjector ? { catalogProjector: params.catalogProjector } : {}), }); } catch (err) { params.context.logGateway.debug( @@ -503,6 +507,70 @@ async function buildChatStartupMetadataResult(params: { } } +async function buildChatStartupModelCatalogProjection(params: { + cfg: OpenClawConfig; + snapshot: ModelCatalogSnapshot; + sessionAgentId: string; + sessionEntry: ReturnType["entry"]; + defaultAgentId: string; + includeAgentsList: boolean; +}) { + const { createGatewayAgentModelCatalogProjector } = await import("./models-list-result.js"); + const projectorByKey = new Map< + string, + ReturnType + >(); + const modelCatalogByAgentId = new Map(); + const getProjector = ( + agentId: string, + profiles: { preferredProfileId?: string; lockedProfileId?: string } = {}, + ) => { + const id = normalizeAgentId(agentId); + const key = `${id}\0${profiles.preferredProfileId ?? ""}\0${profiles.lockedProfileId ?? ""}`; + let projector = projectorByKey.get(key); + if (!projector) { + projector = createGatewayAgentModelCatalogProjector({ + cfg: params.cfg, + agentId: id, + snapshot: params.snapshot, + ...(profiles.preferredProfileId ? { preferredProfileId: profiles.preferredProfileId } : {}), + ...(profiles.lockedProfileId ? { lockedProfileId: profiles.lockedProfileId } : {}), + }); + projectorByKey.set(key, projector); + } + return projector; + }; + const agentIds = new Set([params.sessionAgentId, params.defaultAgentId].map(normalizeAgentId)); + if (params.includeAgentsList) { + for (const agent of listGatewayAgentsBasic(params.cfg).agents) { + agentIds.add(agent.id); + } + } + await Promise.all( + [...agentIds].map(async (agentId) => { + modelCatalogByAgentId.set(agentId, await getProjector(agentId).projectCatalog()); + }), + ); + const sessionProfileId = params.sessionEntry?.authProfileOverride?.trim(); + const sessionProfileSource = params.sessionEntry?.authProfileOverrideSource; + // Legacy rows omitted the source; a compaction count is the durable marker + // that the profile was adopted automatically and may fall through. + const legacyUserProfile = + sessionProfileSource === undefined && + params.sessionEntry?.authProfileOverrideCompactionCount === undefined; + const sessionProfiles = sessionProfileId + ? { + preferredProfileId: sessionProfileId, + ...(sessionProfileSource === "user" || legacyUserProfile + ? { lockedProfileId: sessionProfileId } + : {}), + } + : undefined; + const sessionCatalogProjector = getProjector(params.sessionAgentId, sessionProfiles); + const sessionModelCatalog = await sessionCatalogProjector.projectCatalog(); + return { getProjector, modelCatalogByAgentId, sessionCatalogProjector, sessionModelCatalog }; +} + function normalizeUnknownText(value: unknown): string | undefined { return typeof value === "string" ? normalizeOptionalText(value) : undefined; } @@ -3154,17 +3222,21 @@ async function handleChatHistoryRequest({ return; } const startupModelCatalogLoad = - method === "chat.startup" ? startOptionalServerMethodModelCatalogLoad(context) : undefined; + method === "chat.startup" + ? startOptionalServerMethodModelCatalogSnapshotLoad(context) + : undefined; const modelCatalogPromise = measureDiagnosticsTimelineSpan( `gateway.${method}.model_catalog`, () => startupModelCatalogLoad - ? loadOptionalServerMethodModelCatalog(context, method, { + ? loadOptionalServerMethodModelCatalogSnapshot(context, method, { logOnceKey: "chat.startup", startedLoad: startupModelCatalogLoad, timeoutMs: CHAT_STARTUP_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS, }) - : loadOptionalServerMethodModelCatalog(context, method), + : loadOptionalServerMethodModelCatalog(context, method).then((entries) => + entries ? { entries, routeVariants: entries } : undefined, + ), { config: cfg, phase: method, @@ -3231,14 +3303,33 @@ async function handleChatHistoryRequest({ maxHistoryBytes, logDebug: (message) => context.logGateway.debug(message), }); - const modelCatalog = await modelCatalogPromise; + const modelCatalogSnapshot = await modelCatalogPromise; + const modelCatalog = modelCatalogSnapshot?.entries; const defaultAgentId = resolveDefaultAgentId(cfg); + const startupCatalogProjection = + method === "chat.startup" && modelCatalogSnapshot + ? await buildChatStartupModelCatalogProjection({ + cfg, + snapshot: modelCatalogSnapshot, + sessionAgentId, + sessionEntry: entry, + defaultAgentId, + includeAgentsList: includeAgentsList === true, + }) + : undefined; + const sessionModelCatalog = startupCatalogProjection?.sessionModelCatalog ?? modelCatalog; + const defaultModelCatalog = + startupCatalogProjection?.modelCatalogByAgentId.get(normalizeAgentId(defaultAgentId)) ?? + modelCatalog; const startupMetadata = includeMetadata ? await buildChatStartupMetadataResult({ cfg, context, agentId: sessionAgentId, - modelCatalog, + modelCatalog: modelCatalogSnapshot, + ...(startupCatalogProjection + ? { catalogProjector: startupCatalogProjection.sessionCatalogProjector } + : {}), }) : undefined; const sessionInfo = buildGatewaySessionInfo({ @@ -3248,7 +3339,7 @@ async function handleChatHistoryRequest({ key: canonicalKey, entry, agentId: selectedAgent.agentId, - modelCatalog, + modelCatalog: sessionModelCatalog, }); const activeRunAgentId = canonicalKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId; @@ -3262,7 +3353,9 @@ async function handleChatHistoryRequest({ }); sessionInfo.hasActiveRun = activeRunState.active; sessionInfo.activeRunIds = activeRunState.runIds; - const defaults = getSessionDefaults(cfg, modelCatalog, { allowPluginNormalization: false }); + const defaults = getSessionDefaults(cfg, defaultModelCatalog, { + allowPluginNormalization: false, + }); const thinkingLevel = sessionInfo.thinkingLevel ?? sessionInfo.thinkingDefault; const verboseLevel = entry?.verboseLevel ?? cfg.agents?.defaults?.verboseDefault; sessionInfo.verboseLevel = verboseLevel; @@ -3298,7 +3391,17 @@ async function handleChatHistoryRequest({ fastMode: entry?.fastMode, verboseLevel, ...(boundedInFlightRun ? { inFlightRun: boundedInFlightRun } : {}), - ...(includeAgentsList ? { agentsList: listAgentsForGateway(cfg, modelCatalog) } : {}), + ...(includeAgentsList + ? { + agentsList: listAgentsForGateway( + cfg, + modelCatalog, + startupCatalogProjection + ? { modelCatalogByAgentId: startupCatalogProjection.modelCatalogByAgentId } + : undefined, + ), + } + : {}), ...(startupMetadata ? { metadata: startupMetadata } : {}), }; respond(true, payload); diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test.ts b/src/gateway/server-methods/models-list-result.openai-routes.test.ts new file mode 100644 index 000000000000..f25ec1f75b72 --- /dev/null +++ b/src/gateway/server-methods/models-list-result.openai-routes.test.ts @@ -0,0 +1,432 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; +import type { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { withEnvAsync } from "../../test-utils/env.js"; +import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; +import { buildModelsListResult } from "./models-list-result.js"; +import type { GatewayRequestContext } from "./types.js"; + +const WITHOUT_OPENAI_ENV_AUTH = { + CODEX_API_KEY: undefined, + CODEX_HOME: "/__openclaw_models_list_test__/codex", + OPENAI_API_KEY: undefined, + OPENAI_BASE_URL: undefined, + OPENAI_OAUTH_TOKEN: undefined, + CHATGPT_OAUTH_TOKEN: undefined, +} as const; + +function catalogEntry(id: string, api: ModelCatalogEntry["api"]): ModelCatalogEntry { + return { id, name: id, provider: "openai", api }; +} + +async function listModels(params: { + catalog: ModelCatalogEntry[]; + cfg?: OpenClawConfig; + routeResolverFactory?: typeof createOpenAIModelRoutesResolver; + view?: "all" | "configured" | "default"; +}) { + const context = { + getRuntimeConfig: () => params.cfg ?? ({} as OpenClawConfig), + loadGatewayModelCatalog: vi.fn(() => Promise.resolve(params.catalog)), + loadGatewayModelCatalogSnapshot: vi.fn(() => + Promise.resolve({ entries: params.catalog, routeVariants: params.catalog }), + ), + logGateway: { debug: vi.fn() }, + } as unknown as GatewayRequestContext; + return await buildModelsListResult({ + context, + params: { view: params.view ?? "all" }, + ...(params.routeResolverFactory ? { routeResolverFactory: params.routeResolverFactory } : {}), + }); +} + +describe("models.list OpenAI routes", () => { + it("keeps route-aware default browse indeterminate without the provider artifact", async () => { + const resolveRoutes = vi.fn(() => null); + const createResolver = vi.fn(() => resolveRoutes); + await withEnvAsync({ ...WITHOUT_OPENAI_ENV_AUTH, OPENAI_API_KEY: "test-key" }, async () => { + await expect( + listModels({ + view: "default", + catalog: [ + catalogEntry("gpt-5.5", "openai-responses"), + catalogEntry("gpt-5.6", "openai-responses"), + ], + routeResolverFactory: createResolver, + }), + ).resolves.toEqual({ models: [] }); + }); + expect(createResolver).toHaveBeenCalledOnce(); + expect(resolveRoutes).toHaveBeenCalledTimes(2); + }); + it("keeps exhaustive Codex rows visible but unavailable when the route artifact is missing", async () => { + await withEnvAsync(WITHOUT_OPENAI_ENV_AUTH, async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-models-list-openai-null-artifact-oauth-", + agentEnv: "main", + }, + async (state) => { + await state.writeAuthProfiles({ + version: 1, + profiles: { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "chatgpt-access", + refresh: "chatgpt-refresh", + expires: Date.now() + 30 * 60_000, + }, + }, + }); + await expect( + listModels({ + catalog: [catalogEntry("gpt-5.4-codex", "openai-responses")], + routeResolverFactory: () => () => null, + }), + ).resolves.toEqual({ + models: [ + { + id: "gpt-5.4-codex", + name: "gpt-5.4-codex", + provider: "openai", + available: false, + }, + ], + }); + }, + ); + }); + }); + + it("omits route-sensitive metadata while route observation is required", async () => { + const routeResolverFactory = vi.fn(() => () => ({ + kind: "indeterminate" as const, + defaultRuntimeId: "codex", + })); + const row = { + ...catalogEntry("gpt-5.6", "openai-responses"), + baseUrl: "https://api.openai.com/v1", + contextTokens: 800_000, + contextWindow: 1_000_000, + input: ["text", "image"], + params: { apiKey: "private" }, + compat: { supportsStore: false }, + mediaInput: { image: { maxBytes: 42 } }, + reasoning: true, + } as ModelCatalogEntry; + + await expect(listModels({ catalog: [row], routeResolverFactory })).resolves.toEqual({ + models: [{ id: "gpt-5.6", name: "gpt-5.6", provider: "openai", available: false }], + }); + }); + + it("keeps public metadata for a provider-canonical model-level Platform route", async () => { + const cfg = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.4-nano", + name: "GPT-5.4 Nano", + api: "openai-completions", + baseUrl: "https://api.openai.com", + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + const row = { + ...catalogEntry("gpt-5.4-nano", "openai-completions"), + baseUrl: "https://api.openai.com", + contextTokens: 800_000, + contextWindow: 1_000_000, + input: ["text", "image"], + params: { apiKey: "private" }, + compat: { supportsStore: false }, + mediaInput: { image: { maxBytes: 42 } }, + reasoning: true, + } as ModelCatalogEntry; + + await withEnvAsync({ ...WITHOUT_OPENAI_ENV_AUTH, OPENAI_API_KEY: "test-key" }, async () => { + await expect(listModels({ catalog: [row], cfg })).resolves.toEqual({ + models: [ + { + id: "gpt-5.4-nano", + name: "GPT-5.4 Nano", + provider: "openai", + contextWindow: 1_000_000, + reasoning: true, + available: true, + }, + ], + }); + }); + }); + + it("keeps the all view exhaustive while default hides incompatible implicit rows", async () => { + const cfg = { + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [{ id: "gpt-5.6", name: "GPT-5.6" }], + }, + }, + }, + } as unknown as OpenClawConfig; + + const incompatibleRow = { + ...catalogEntry("chat-latest", "openai-chatgpt-responses"), + reasoning: true, + } as ModelCatalogEntry; + + await expect( + listModels({ + cfg, + catalog: [catalogEntry("gpt-5.6", "openai-chatgpt-responses"), incompatibleRow], + }), + ).resolves.toEqual({ + models: [ + { id: "chat-latest", name: "chat-latest", provider: "openai", available: false }, + { id: "gpt-5.6", name: "GPT-5.6", provider: "openai", available: false }, + ], + }); + + await expect( + listModels({ + cfg, + view: "default", + catalog: [catalogEntry("gpt-5.6", "openai-chatgpt-responses"), incompatibleRow], + }), + ).resolves.toEqual({ + models: [{ id: "gpt-5.6", name: "GPT-5.6", provider: "openai", available: false }], + }); + }); + it("uses auth.order to project one logical route and its capabilities", async () => { + await withEnvAsync(WITHOUT_OPENAI_ENV_AUTH, async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-models-list-openai-auth-order-", + agentEnv: "main", + }, + async (state) => { + await state.writeAuthProfiles({ + version: 1, + profiles: { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "chatgpt-access", + refresh: "chatgpt-refresh", + expires: Date.now() + 30 * 60_000, + }, + "openai:key": { + type: "api_key", + provider: "openai", + key: "test-key", + }, + }, + }); + const cfg = { + auth: { order: { openai: ["openai:chatgpt", "openai:key"] } }, + } as unknown as OpenClawConfig; + const row = { + ...catalogEntry("gpt-5.5", "openai-responses"), + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_000_000, + reasoning: true, + } as ModelCatalogEntry; + + await expect(listModels({ catalog: [row], cfg })).resolves.toEqual({ + models: [ + { + id: "gpt-5.5", + name: "gpt-5.5", + provider: "openai", + available: true, + }, + ], + }); + + const chatGPTRow = { + ...catalogEntry("gpt-5.5", "openai-chatgpt-responses"), + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 400_000, + params: { apiKey: "private" }, + compat: { supportsStore: false }, + mediaInput: { image: { maxBytes: 42 } }, + reasoning: true, + } as ModelCatalogEntry; + const subscriptionProjection = { + models: [ + { + id: "gpt-5.5", + name: "gpt-5.5", + provider: "openai", + contextWindow: 400_000, + reasoning: true, + available: true, + }, + ], + }; + await expect(listModels({ catalog: [row, chatGPTRow], cfg })).resolves.toEqual( + subscriptionProjection, + ); + await expect(listModels({ catalog: [chatGPTRow, row], cfg })).resolves.toEqual( + subscriptionProjection, + ); + + await expect( + listModels({ catalog: [row, chatGPTRow], cfg, view: "default" }), + ).resolves.toEqual(subscriptionProjection); + + const apiKeyFirst = { + auth: { order: { openai: ["openai:key", "openai:chatgpt"] } }, + } as unknown as OpenClawConfig; + await expect(listModels({ catalog: [row], cfg: apiKeyFirst })).resolves.toEqual({ + models: [ + { + id: "gpt-5.5", + name: "gpt-5.5", + provider: "openai", + contextWindow: 1_000_000, + reasoning: true, + available: true, + }, + ], + }); + }, + ); + }); + }); + it("keeps configured provider rows visible when unavailable", async () => { + await withEnvAsync(WITHOUT_OPENAI_ENV_AUTH, async () => { + const cfg = { + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [{ id: "gpt-5.6", name: "GPT-5.6" }], + }, + }, + }, + } as unknown as OpenClawConfig; + + await expect( + listModels({ + cfg, + view: "configured", + catalog: [catalogEntry("gpt-5.6", "openai-chatgpt-responses")], + }), + ).resolves.toEqual({ + models: [ + { + id: "gpt-5.6", + name: "GPT-5.6", + provider: "openai", + available: false, + }, + ], + }); + }); + }); + + it("keeps configured fallback rows visible when their route is unavailable", async () => { + await withEnvAsync(WITHOUT_OPENAI_ENV_AUTH, async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-models-list-openai-fallback-", + agentEnv: "main", + }, + async () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "anthropic/claude-test", + fallbacks: ["openai/chat-latest"], + }, + }, + }, + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + const result = await listModels({ + cfg, + view: "configured", + catalog: [catalogEntry("chat-latest", "openai-chatgpt-responses")], + }); + + expect(result.models).toContainEqual({ + id: "chat-latest", + name: "chat-latest", + provider: "openai", + available: false, + }); + }, + ); + }); + }); + + it("resolves configured fallback aliases before retaining unavailable rows", async () => { + await withEnvAsync(WITHOUT_OPENAI_ENV_AUTH, async () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "anthropic/claude-test", + fallbacks: ["fast"], + }, + models: { + "openai/chat-latest": { alias: "fast" }, + }, + }, + }, + models: { + providers: { + openai: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + + await expect( + listModels({ + cfg, + view: "configured", + catalog: [catalogEntry("chat-latest", "openai-chatgpt-responses")], + }), + ).resolves.toEqual({ + models: [ + { + id: "chat-latest", + name: "chat-latest", + provider: "openai", + alias: "fast", + available: false, + }, + ], + }); + }); + }); +}); diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index dbc6dd9c3532..cc62b947c575 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -7,40 +7,53 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../../agents/agent-scope.js"; -import { - loadAuthProfileStoreWithoutExternalProfiles, - resolveAuthProfileOrder, - type AuthProfileCredential, - type AuthProfileStore, -} from "../../agents/auth-profiles.js"; +import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js"; import { DEFAULT_PROVIDER } from "../../agents/defaults.js"; -import { hasRuntimeAvailableProviderAuth } from "../../agents/model-auth.js"; import { - loadModelCatalogForBrowse, + createModelAuthAvailabilityResolver, + type ModelAuthAvailability, + type ModelAuthAvailabilityEvaluation, + type ModelAuthAvailabilityResolver, +} from "../../agents/model-auth-availability.js"; +import { hasSyntheticLocalProviderAuthConfig } from "../../agents/model-auth.js"; +import { + loadModelCatalogSnapshotForBrowse, type ModelCatalogBrowseView, } from "../../agents/model-catalog-browse.js"; import { - isCodexRoutableOpenAIPlatformCatalogEntry, - resolveVisibleModelCatalog, + findModelCatalogRouteDonor, + projectModelCatalogEntryForRoute, + resolveConfiguredModelCatalogOverrides, +} from "../../agents/model-catalog-route.js"; +import { + resolveLogicalModelCatalogEntryState, + resolveLogicalVisibleModelCatalog, } from "../../agents/model-catalog-visibility.js"; +import type { ModelCatalogSnapshot } from "../../agents/model-catalog.types.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; +import { + createModelVisibilityPolicy, + RUNTIME_MODEL_VISIBILITY_NORMALIZATION, +} from "../../agents/model-visibility-policy.js"; +import { + createOpenAIModelRoutesResolver, + openAIModelCatalogRoutePolicy, +} from "../../agents/openai-model-routes.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { isSecretRef } from "../../config/types.secrets.js"; +import { loadPluginRegistrySnapshotWithMetadata } from "../../plugins/plugin-registry.js"; import type { GatewayRequestContext } from "./types.js"; type ModelsListView = ModelCatalogBrowseView; -type ModelsListEntry = ModelCatalogEntry & { available?: boolean }; -type ModelsListAvailability = boolean | undefined; -type ModelsListProviderAuthChecker = ( - provider: string, - modelApi?: string, -) => ModelsListAvailability | Promise; +type ModelsListEntry = Pick< + ModelCatalogEntry, + "alias" | "contextWindow" | "id" | "name" | "provider" | "reasoning" +> & { available?: boolean }; +type ModelsListAvailability = ModelAuthAvailability; +type ModelsListEntryEvaluation = ModelAuthAvailabilityEvaluation; let loggedSlowModelsListCatalog = false; -const OAUTH_REFRESH_MARGIN_MS = 5 * 60 * 1000; -const OPENAI_CODEX_RESPONSES_API = "openai-chatgpt-responses"; // Unknown views are rejected by protocol validation first; this helper keeps the // handler default explicit for older clients that omit the field. @@ -48,173 +61,89 @@ function resolveModelsListView(params: Record): ModelsListView return typeof params.view === "string" ? (params.view as ModelsListView) : "default"; } -// Runtime-only model params are useful inside provider routing, but exposing -// them here would leak provider invocation details into the Control UI API. -function omitRuntimeModelParams(entry: ModelCatalogEntry): ModelCatalogEntry { - const { params: _params, ...rest } = entry as ModelCatalogEntry & { - params?: Record; - }; - return rest; +function resolvePositiveSafeInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; } -function createInFlightProviderAuthChecker( - providerAuthChecker: ModelsListProviderAuthChecker, -): ModelsListProviderAuthChecker { - const pending = new Map>(); - return (provider, modelApi) => { - const key = `${normalizeProviderId(provider)}\0${modelApi ?? ""}`; - const cached = pending.get(key); - if (cached) { - return cached; - } - const next = Promise.resolve(providerAuthChecker(provider, modelApi)); - pending.set(key, next); - return next; +// Project explicitly onto the public protocol shape. Route, base URL, auth, +// runtime, and cost facts stay private to server-side selection. +function buildPublicModelProjection(entry: ModelCatalogEntry): ModelsListEntry { + const contextWindow = resolvePositiveSafeInteger(entry.contextWindow); + return { + id: entry.id, + name: entry.name, + provider: entry.provider, + ...(entry.alias ? { alias: entry.alias } : {}), + ...(contextWindow ? { contextWindow } : {}), + ...(typeof entry.reasoning === "boolean" ? { reasoning: entry.reasoning } : {}), }; } -function hasLiteralSecret(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function hasAvailableEnvSecretRef(value: unknown): boolean { - return isSecretRef(value) && value.source === "env" && hasLiteralSecret(process.env[value.id]); -} - -function hasSecretRef(value: unknown): boolean { - return isSecretRef(value); -} - -function profileModeAllowedForModel( - provider: string, - modelApi: string | undefined, - mode: AuthProfileCredential["type"], -): boolean { - return ( - normalizeProviderId(provider) !== "openai" || - modelApi === undefined || - modelApi === "openai-chatgpt-responses" || - mode === "api_key" - ); -} - -function profileHasReadOnlyAvailableAuth(params: { - credential: AuthProfileCredential; - provider: string; - modelApi?: string; - now: number; -}): ModelsListAvailability { - if (!profileModeAllowedForModel(params.provider, params.modelApi, params.credential.type)) { - return false; - } - if (params.credential.type === "api_key") { - if ( - hasLiteralSecret(params.credential.key) || - hasAvailableEnvSecretRef(params.credential.keyRef) - ) { - return true; - } - return hasSecretRef(params.credential.keyRef) ? undefined : false; - } - if (params.credential.type === "token") { - const hasCurrentToken = - hasLiteralSecret(params.credential.token) || - hasAvailableEnvSecretRef(params.credential.tokenRef); - if (hasCurrentToken) { - return params.credential.expires === undefined || params.credential.expires > params.now; - } - return hasSecretRef(params.credential.tokenRef) ? undefined : false; - } - return ( - hasLiteralSecret(params.credential.access) && - params.credential.expires > params.now + OAUTH_REFRESH_MARGIN_MS - ); -} - -function hasReadOnlyAvailableProfileAuth(params: { - provider: string; - modelApi?: string; +function listEnabledSyntheticAuthProviderRefs(params: { cfg: OpenClawConfig; - store: AuthProfileStore; -}): ModelsListAvailability { - const now = Date.now(); - let sawUnknown = false; - for (const profileId of resolveAuthProfileOrder({ - cfg: params.cfg, - store: params.store, - provider: params.provider, - })) { - const credential = params.store.profiles[profileId]; - if (!credential) { - continue; - } - const available = profileHasReadOnlyAvailableAuth({ - credential, - provider: params.provider, - modelApi: params.modelApi, - now, - }); - if (available === true) { - return true; - } - if (available === undefined) { - sawUnknown = true; - } + workspaceDir: string; +}): readonly string[] { + const result = loadPluginRegistrySnapshotWithMetadata({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: process.env, + }); + if (result.source !== "persisted" && result.source !== "provided") { + return []; } - return sawUnknown ? undefined : false; + return result.snapshot.plugins + .filter((plugin) => plugin.enabled) + .flatMap((plugin) => plugin.syntheticAuthRefs ?? []); } -function createModelsListProviderAuthChecker(params: { +function createModelsListAuthResolver(params: { cfg: OpenClawConfig; agentId: string; + includeOpenAIExternalProfiles: boolean; workspaceDir: string; -}): ModelsListProviderAuthChecker { + routeResolverFactory?: typeof createOpenAIModelRoutesResolver; +}): ModelAuthAvailabilityResolver { const agentDir = resolveAgentDir(params.cfg, params.agentId); - // Auth refreshes can be persisted by another CLI process while the gateway - // keeps an older execution snapshot, so browse availability reads SQLite. - const store = loadAuthProfileStoreWithoutExternalProfiles(agentDir, { + // Browse reads persisted auth because another CLI process may have refreshed + // it after the Gateway execution snapshot was built. + const authStore = loadAuthProfileStoreWithoutExternalProfiles(agentDir, { allowKeychainPrompt: false, }); - return createInFlightProviderAuthChecker( - (provider, modelApi) => - hasRuntimeAvailableProviderAuth({ - provider, - modelApi, - cfg: params.cfg, - workspaceDir: params.workspaceDir, - allowPluginSyntheticAuth: false, - }) || - hasReadOnlyAvailableProfileAuth({ - provider, - modelApi, - cfg: params.cfg, - store, - }), - ); + return createModelAuthAvailabilityResolver({ + cfg: params.cfg, + authStore, + agentDir, + workspaceDir: params.workspaceDir, + env: process.env, + skipSetupProviderFallback: true, + syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(params), + externalCliProviderIds: params.includeOpenAIExternalProfiles ? ["openai"] : [], + routeResolverFactory: params.routeResolverFactory, + }); } -async function resolveModelsListEntryAvailability( - providerAuthChecker: ModelsListProviderAuthChecker, - entry: ModelCatalogEntry, - cfg: OpenClawConfig, - agentId: string, -): Promise { - const primary = await providerAuthChecker(entry.provider, entry.api); - if (primary === true) { - return primary; +function resolveLegacyEntryAvailability(params: { + authResolver: ModelAuthAvailabilityResolver; + entry: ModelCatalogEntry; + primaryAvailability: ModelsListAvailability; + cfg: OpenClawConfig; + agentId: string; +}): ModelsListAvailability { + if (params.primaryAvailability === true) { + return true; } - let available = primary; + let available = params.primaryAvailability; const runtimeProvider = resolveCliRuntimeExecutionProvider({ - provider: entry.provider, - cfg, - agentId, - modelId: entry.id, + provider: params.entry.provider, + cfg: params.cfg, + agentId: params.agentId, + modelId: params.entry.id, }); if ( runtimeProvider && - normalizeProviderId(runtimeProvider) !== normalizeProviderId(entry.provider) + normalizeProviderId(runtimeProvider) !== normalizeProviderId(params.entry.provider) ) { - const runtimeAvailable = await providerAuthChecker(runtimeProvider); + const runtimeAvailable = params.authResolver.resolveProviderAuthAvailability(runtimeProvider); if (runtimeAvailable === true) { return true; } @@ -222,51 +151,186 @@ async function resolveModelsListEntryAvailability( available = undefined; } } - if (!isCodexRoutableOpenAIPlatformCatalogEntry(entry)) { - return available; - } - const codexResponses = await providerAuthChecker(entry.provider, OPENAI_CODEX_RESPONSES_API); - return codexResponses ?? available; + return available; } -async function buildPublicModelsListEntry(params: { - entry: ModelCatalogEntry; +function createModelsListEntryEvaluator(params: { cfg: OpenClawConfig; agentId: string; - providerAuthChecker?: ModelsListProviderAuthChecker; -}): Promise { - const publicEntry = omitRuntimeModelParams(params.entry); - if (!params.providerAuthChecker) { - return publicEntry; - } - const available = await resolveModelsListEntryAvailability( - params.providerAuthChecker, - params.entry, - params.cfg, - params.agentId, + authResolver: ModelAuthAvailabilityResolver; + preferredProfileId?: string; + lockedProfileId?: string; +}): ( + entry: ModelCatalogEntry, + routeVariants?: readonly ModelCatalogEntry[], +) => Promise { + const pending = new Map>(); + return (entry, routeVariants = [entry]) => { + const identity = openAIModelCatalogRoutePolicy.resolveIdentity(entry); + const cacheKey = resolveGatewayModelCatalogRouteKey(entry); + const cached = pending.get(cacheKey); + if (cached) { + return cached; + } + const next = Promise.resolve().then(() => { + const evaluation = params.authResolver.evaluateModelAuth(entry.provider, { + modelId: identity?.id ?? entry.id, + ...(params.preferredProfileId ? { preferredProfileId: params.preferredProfileId } : {}), + ...(params.lockedProfileId ? { lockedProfileId: params.lockedProfileId } : {}), + observedRoutes: routeVariants.map((variant) => ({ + api: variant.api, + baseUrl: variant.baseUrl, + })), + }); + return evaluation.routeResolution === null && normalizeProviderId(entry.provider) !== "openai" + ? { + ...evaluation, + availability: resolveLegacyEntryAvailability({ + authResolver: params.authResolver, + entry, + primaryAvailability: evaluation.availability, + cfg: params.cfg, + agentId: params.agentId, + }), + } + : evaluation; + }); + pending.set(cacheKey, next); + return next; + }; +} + +function resolveGatewayModelCatalogRouteKey(entry: ModelCatalogEntry): string { + return ( + openAIModelCatalogRoutePolicy.resolveIdentity(entry)?.key ?? + `${normalizeProviderId(entry.provider)}/${entry.id}` ); +} + +/** Builds one per-agent, snapshot-scoped route projection for Gateway thinking metadata. */ +export function createGatewayAgentModelCatalogProjector(params: { + cfg: OpenClawConfig; + agentId: string; + snapshot: ModelCatalogSnapshot; + preferredProfileId?: string; + lockedProfileId?: string; + routeResolverFactory?: typeof createOpenAIModelRoutesResolver; +}) { + const defaultModel = resolveAgentEffectiveModelPrimary(params.cfg, params.agentId); + const visibilityPolicy = createModelVisibilityPolicy({ + cfg: params.cfg, + catalog: params.snapshot.entries, + defaultProvider: DEFAULT_PROVIDER, + defaultModel, + agentId: params.agentId, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, + }); + const workspaceDir = + resolveAgentWorkspaceDir(params.cfg, params.agentId) ?? resolveDefaultAgentWorkspaceDir(); + const projectionCatalog = + params.snapshot.routeVariants.length > 0 + ? params.snapshot.routeVariants + : params.snapshot.entries; + const routeVariantsByKey = new Map(); + for (const entry of projectionCatalog) { + const key = resolveGatewayModelCatalogRouteKey(entry); + const variants = routeVariantsByKey.get(key) ?? []; + variants.push(entry); + routeVariantsByKey.set(key, variants); + } + const resolveRouteVariants = (entry: ModelCatalogEntry) => + routeVariantsByKey.get(resolveGatewayModelCatalogRouteKey(entry)) ?? [entry]; + const logicalEntries: ModelCatalogEntry[] = []; + const logicalEntryKeys = new Set(); + for (const entry of params.snapshot.entries) { + const key = resolveGatewayModelCatalogRouteKey(entry); + if (!logicalEntryKeys.has(key)) { + logicalEntryKeys.add(key); + logicalEntries.push(entry); + } + } + const authResolver = createModelsListAuthResolver({ + cfg: params.cfg, + agentId: params.agentId, + includeOpenAIExternalProfiles: + projectionCatalog.some((entry) => normalizeProviderId(entry.provider) === "openai") || + [...visibilityPolicy.configuredKeys].some((key) => key.startsWith("openai/")), + workspaceDir, + routeResolverFactory: params.routeResolverFactory, + }); + const evaluateEntry = createModelsListEntryEvaluator({ + cfg: params.cfg, + agentId: params.agentId, + authResolver, + ...(params.preferredProfileId ? { preferredProfileId: params.preferredProfileId } : {}), + ...(params.lockedProfileId ? { lockedProfileId: params.lockedProfileId } : {}), + }); + let projectedCatalog: Promise | undefined; return { - ...publicEntry, - available: available ?? false, + evaluateEntry, + projectCatalog: () => + (projectedCatalog ??= Promise.all( + logicalEntries.map(async (entry) => { + const routeVariants = resolveRouteVariants(entry); + const evaluation = await evaluateEntry(entry, routeVariants); + const state = resolveLogicalModelCatalogEntryState({ + entry, + evaluation, + routePolicy: openAIModelCatalogRoutePolicy, + }); + const overrides = resolveConfiguredModelCatalogOverrides({ + cfg: params.cfg, + entry, + policy: openAIModelCatalogRoutePolicy, + }); + const projected = projectModelCatalogEntryForRoute({ + entry, + projection: state.routeProjection, + catalog: routeVariants, + ...(overrides ? { overrides } : {}), + }); + if (state.routeProjection.kind !== "selected") { + return projected; + } + const donor = findModelCatalogRouteDonor({ + entry, + route: state.routeProjection.route, + policy: openAIModelCatalogRoutePolicy, + catalog: routeVariants, + }); + if (donor && Object.hasOwn(donor, "compat")) { + projected.compat = donor.compat; + } + if (donor && Object.hasOwn(donor, "params")) { + projected.params = donor.params; + } + return projected; + }), + )), }; } async function buildPublicModelsListEntries(params: { catalog: ModelCatalogEntry[]; cfg: OpenClawConfig; - agentId: string; - workspaceDir: string; + evaluateEntry(entry: ModelCatalogEntry): Promise; }): Promise { - const providerAuthChecker = createModelsListProviderAuthChecker(params); return await Promise.all( - params.catalog.map((entry) => - buildPublicModelsListEntry({ - entry, - cfg: params.cfg, - agentId: params.agentId, - providerAuthChecker, - }), - ), + params.catalog.map(async (entry): Promise => { + const evaluation = await params.evaluateEntry(entry); + const publicEntry = buildPublicModelProjection(entry); + const syntheticLocalAvailable = + evaluation.availability === undefined && + evaluation.routeResolution === null && + normalizeProviderId(entry.provider) !== "openai" && + hasSyntheticLocalProviderAuthConfig({ cfg: params.cfg, provider: entry.provider }); + // Optionality remains for older Gateway compatibility. Current producers + // emit a boolean because existing clients treat omission as selectable. + return { + ...publicEntry, + available: evaluation.availability ?? syntheticLocalAvailable, + }; + }), ); } @@ -274,13 +338,15 @@ export async function buildModelsListResult(params: { context: GatewayRequestContext; agentId?: string; params: Record; - preloadedCatalog?: ModelCatalogEntry[]; + preloadedCatalog?: ModelCatalogSnapshot; + catalogProjector?: ReturnType; + routeResolverFactory?: typeof createOpenAIModelRoutesResolver; }): Promise<{ models: ModelsListEntry[] }> { const cfg = params.context.getRuntimeConfig(); const agentId = params.agentId ?? resolveDefaultAgentId(cfg); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir(); const view = resolveModelsListView(params.params); - const catalog = await loadModelCatalogForBrowse({ + const snapshot = await loadModelCatalogSnapshotForBrowse({ cfg, view, loadCatalog: async (loadParams) => { @@ -288,7 +354,7 @@ export async function buildModelsListResult(params: { if (params.preloadedCatalog && readOnlyLoad) { return params.preloadedCatalog; } - return await params.context.loadGatewayModelCatalog(loadParams); + return await params.context.loadGatewayModelCatalogSnapshot(loadParams); }, onTimeout: (timeoutMs) => { if (loggedSlowModelsListCatalog) { @@ -300,27 +366,64 @@ export async function buildModelsListResult(params: { ); }, }); - if (view === "all") { - return { - models: await buildPublicModelsListEntries({ catalog, cfg, agentId, workspaceDir }), - }; - } - const models = await resolveVisibleModelCatalog({ + const catalog = snapshot.entries; + const routeVariants = snapshot.routeVariants; + const defaultModel = resolveAgentEffectiveModelPrimary(cfg, agentId); + const visibilityPolicy = createModelVisibilityPolicy({ cfg, catalog, defaultProvider: DEFAULT_PROVIDER, - defaultModel: resolveAgentEffectiveModelPrimary(cfg, agentId), + defaultModel, + agentId, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, + }); + const evaluateEntry = + params.catalogProjector?.evaluateEntry ?? + createModelsListEntryEvaluator({ + cfg, + agentId, + authResolver: createModelsListAuthResolver({ + cfg, + agentId, + includeOpenAIExternalProfiles: + catalog.some((entry) => normalizeProviderId(entry.provider) === "openai") || + [...visibilityPolicy.configuredKeys].some((key) => key.startsWith("openai/")), + workspaceDir, + routeResolverFactory: params.routeResolverFactory, + }), + }); + const models = await resolveLogicalVisibleModelCatalog({ + cfg, + catalog, + defaultProvider: DEFAULT_PROVIDER, + defaultModel, agentId, workspaceDir, view, - runtimeAuthDiscovery: false, + policy: visibilityPolicy, + routePolicy: openAIModelCatalogRoutePolicy, + routeVariants, + evaluateEntry: async (entry, variants) => { + const evaluation = await evaluateEntry(entry, variants); + const routeManaged = evaluation.routeResolution !== null; + const syntheticLocal = + !routeManaged && + normalizeProviderId(entry.provider) !== "openai" && + evaluation.availability === undefined && + evaluation.evidence === "synthetic"; + return resolveLogicalModelCatalogEntryState({ + entry, + evaluation, + authBacked: evaluation.availability === true || syntheticLocal, + routePolicy: openAIModelCatalogRoutePolicy, + }); + }, }); return { models: await buildPublicModelsListEntries({ catalog: models, cfg, - agentId, - workspaceDir, + evaluateEntry, }), }; } diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index a0a90c688ff6..2c5d84f3652b 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -6,6 +6,7 @@ import { clearRuntimeAuthProfileStoreSnapshots, replaceRuntimeAuthProfileStoreSnapshots, } from "../../agents/auth-profiles.js"; +import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createDeferred } from "../../test-utils/deferred.js"; import { withEnvAsync } from "../../test-utils/env.js"; @@ -18,7 +19,9 @@ const withoutOpenAIEnvAuth = async (run: () => Promise): Promise => await withEnvAsync( { CODEX_API_KEY: undefined, + CODEX_HOME: "/__openclaw_models_list_test__/codex", OPENAI_API_KEY: undefined, + OPENAI_BASE_URL: undefined, OPENAI_OAUTH_TOKEN: undefined, CHATGPT_OAUTH_TOKEN: undefined, }, @@ -41,10 +44,12 @@ function createDemoOAuthStore(params: { access: string; expires: number }) { } function requestModelsList(params: { - view: "configured" | "all"; + view: "default" | "configured" | "all"; respond?: ReturnType; runtimeConfig?: OpenClawConfig; - loadGatewayModelCatalog: () => Promise>>; + loadGatewayModelCatalog: (params?: { + readOnly?: boolean; + }) => Promise>>; reqId?: string; }) { const respond = params.respond ?? vi.fn(); @@ -62,6 +67,12 @@ function requestModelsList(params: { context: { getRuntimeConfig: () => params.runtimeConfig ?? ({} as OpenClawConfig), loadGatewayModelCatalog: params.loadGatewayModelCatalog, + loadGatewayModelCatalogSnapshot: async ( + loadParams: Parameters[0], + ) => { + const entries = await params.loadGatewayModelCatalog(loadParams); + return { entries, routeVariants: entries }; + }, logGateway: { debug: vi.fn(), }, @@ -124,9 +135,19 @@ describe("models.list", () => { const catalog = createDeferred(); const loadGatewayModelCatalog = vi.fn(() => catalog.promise); const runtimeConfig = { + secrets: { + providers: { + "mounted-json": { + source: "file", + path: "/tmp/openclaw-test-secrets.json", + mode: "json", + }, + }, + }, models: { providers: { vllm: { + baseUrl: "https://vllm.example/v1", apiKey: { source: "file", provider: "mounted-json", @@ -192,7 +213,9 @@ describe("models.list", () => { expect(respond).toHaveBeenCalledWith( true, - { models: [{ id: "gpt-test", name: "GPT Test", provider: "openai", available: false }] }, + { + models: [{ id: "gpt-test", name: "GPT Test", provider: "openai", available: false }], + }, undefined, ); expect(loadGatewayModelCatalog).toHaveBeenCalledWith({ readOnly: false }); @@ -221,136 +244,221 @@ describe("models.list", () => { expect(respond).toHaveBeenCalledWith( true, - { models: [{ id: "qwen-local", name: "Qwen Local", provider: "vllm", available: false }] }, + { + models: [{ id: "qwen-local", name: "Qwen Local", provider: "vllm", available: false }], + }, undefined, ); }); it("loads the full catalog for provider-scoped configured view and filters only providers", async () => { - const catalog = [ - { id: "claude-test", name: "Claude Test", provider: "anthropic" }, - { id: "gpt-5.4-codex", name: "GPT-5.4 Codex", provider: "openai" }, - { id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai" }, - { id: "llama-local", name: "Llama Local", provider: "vllm" }, - { id: "qwen-local", name: "Qwen Local", provider: "vllm" }, - ]; - const cfg = { - agents: { - defaults: { - models: { - "openai/*": {}, - "vllm/*": {}, - }, - }, - }, - models: { - providers: { - openai: { apiKey: "test-key" }, - vllm: { apiKey: "test-key" }, - }, - }, - } as unknown as OpenClawConfig; - - const loadConfiguredCatalog = vi.fn(() => Promise.resolve(catalog)); - const { request: configuredRequest, respond: configuredRespond } = requestModelsList({ - view: "configured", - runtimeConfig: cfg, - loadGatewayModelCatalog: loadConfiguredCatalog, - reqId: "req-models-list-provider-allowlist", - }); - await configuredRequest; - - expect(configuredRespond).toHaveBeenCalledWith( - true, - { - models: [ - { id: "gpt-5.4-codex", name: "GPT-5.4 Codex", provider: "openai", available: true }, - { id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai", available: true }, - { id: "llama-local", name: "Llama Local", provider: "vllm", available: true }, - { id: "qwen-local", name: "Qwen Local", provider: "vllm", available: true }, - ], - }, - undefined, - ); - expect(loadConfiguredCatalog).toHaveBeenCalledWith({ readOnly: false }); - - const { request: allRequest, respond: allRespond } = requestModelsList({ - view: "all", - runtimeConfig: cfg, - loadGatewayModelCatalog: vi.fn(() => Promise.resolve(catalog)), - reqId: "req-models-list-provider-allowlist-all", - }); - await allRequest; - - expect(allRespond).toHaveBeenCalledWith( - true, - { - models: [ - { id: "claude-test", name: "Claude Test", provider: "anthropic", available: false }, - { id: "gpt-5.4-codex", name: "GPT-5.4 Codex", provider: "openai", available: true }, - { id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai", available: true }, - { id: "llama-local", name: "Llama Local", provider: "vllm", available: true }, - { id: "qwen-local", name: "Qwen Local", provider: "vllm", available: true }, - ], - }, - undefined, - ); - }); - - it("marks legacy OpenAI Codex aliases available through ChatGPT OAuth", async () => { - await withOpenClawTestState( - { - layout: "state-only", - prefix: "openclaw-models-list-codex-alias-", - agentEnv: "main", - }, - async (state) => { - await state.writeAuthProfiles({ - version: 1, - profiles: { - "openai:chatgpt": { - type: "oauth", - provider: "openai", - access: "chatgpt-access", - refresh: "chatgpt-refresh", - expires: Date.now() + 30 * 60_000, + await withoutOpenAIEnvAuth(async () => { + const catalog = [ + { id: "claude-test", name: "Claude Test", provider: "anthropic" }, + { id: "gpt-5.4-codex", name: "GPT-5.4 Codex", provider: "openai" }, + { id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai" }, + { id: "llama-local", name: "Llama Local", provider: "vllm" }, + { id: "qwen-local", name: "Qwen Local", provider: "vllm" }, + ]; + const cfg = { + agents: { + defaults: { + models: { + "openai/*": {}, + "vllm/*": {}, }, }, - }); + }, + models: { + providers: { + openai: { + api: "openai-responses", + apiKey: "test-key", + baseUrl: "https://api.openai.com/v1", + }, + vllm: { apiKey: "test-key" }, + }, + }, + } as unknown as OpenClawConfig; - const { request, respond } = requestModelsList({ - view: "all", - loadGatewayModelCatalog: vi.fn(() => - Promise.resolve([ - { - id: "gpt-5.4-codex", - name: "GPT-5.4 Codex", - provider: "openai", - api: "openai-responses", + const loadConfiguredCatalog = vi.fn(() => Promise.resolve(catalog)); + const { request: configuredRequest, respond: configuredRespond } = requestModelsList({ + view: "configured", + runtimeConfig: cfg, + loadGatewayModelCatalog: loadConfiguredCatalog, + reqId: "req-models-list-provider-allowlist", + }); + await configuredRequest; + + expect(configuredRespond).toHaveBeenCalledWith( + true, + { + models: [ + { id: "gpt-5.4", name: "GPT-5.4 Codex", provider: "openai", available: true }, + { id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai", available: true }, + { id: "llama-local", name: "Llama Local", provider: "vllm", available: true }, + { id: "qwen-local", name: "Qwen Local", provider: "vllm", available: true }, + ], + }, + undefined, + ); + expect(loadConfiguredCatalog).toHaveBeenCalledWith({ readOnly: false }); + + const { request: allRequest, respond: allRespond } = requestModelsList({ + view: "all", + runtimeConfig: cfg, + loadGatewayModelCatalog: vi.fn(() => Promise.resolve(catalog)), + reqId: "req-models-list-provider-allowlist-all", + }); + await allRequest; + + expect(allRespond).toHaveBeenCalledWith( + true, + { + models: [ + { + id: "claude-test", + name: "Claude Test", + provider: "anthropic", + available: false, + }, + { id: "gpt-5.4", name: "GPT-5.4 Codex", provider: "openai", available: true }, + { id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai", available: true }, + { id: "llama-local", name: "Llama Local", provider: "vllm", available: true }, + { id: "qwen-local", name: "Qwen Local", provider: "vllm", available: true }, + ], + }, + undefined, + ); + }); + }); + + it("keeps keyless local provider wildcard discoveries visible with unknown availability", async () => { + await withoutOpenAIEnvAuth(async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-models-list-local-wildcard-", + agentEnv: "main", + env: { VLLM_API_KEY: undefined }, + }, + async () => { + const catalog = [ + { + id: "llama-configured", + name: "Llama Configured", + provider: "vllm", + api: "openai-completions", + baseUrl: "http://127.0.0.1:8000/v1", + }, + { + id: "llama-discovered", + name: "Llama Discovered", + provider: "vllm", + api: "openai-completions", + baseUrl: "http://127.0.0.1:8000/v1", + }, + ]; + const cfg = { + agents: { defaults: { models: { "vllm/*": {} } } }, + models: { + providers: { + vllm: { + api: "openai-completions", + baseUrl: "http://127.0.0.1:8000/v1", + models: [{ id: "llama-configured", name: "Llama Configured" }], + }, }, - ]), - ), - reqId: "req-models-list-codex-alias", - }); - await request; - - expect(respond).toHaveBeenCalledWith( - true, - { + }, + } as unknown as OpenClawConfig; + const expected = { models: [ { - id: "gpt-5.4-codex", - name: "GPT-5.4 Codex", - provider: "openai", - api: "openai-responses", + id: "llama-configured", + name: "Llama Configured", + provider: "vllm", + available: true, + }, + { + id: "llama-discovered", + name: "Llama Discovered", + provider: "vllm", available: true, }, ], - }, - undefined, - ); - }, - ); + }; + + for (const view of ["default", "configured"] as const) { + const { request, respond } = requestModelsList({ + view, + runtimeConfig: cfg, + loadGatewayModelCatalog: vi.fn(() => Promise.resolve(catalog)), + reqId: `req-models-list-local-wildcard-${view}`, + }); + await request; + expect(respond).toHaveBeenCalledWith(true, expected, undefined); + } + }, + ); + }); + }); + + it("marks legacy OpenAI Codex aliases available through ChatGPT OAuth", async () => { + await withoutOpenAIEnvAuth(async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-models-list-codex-alias-", + agentEnv: "main", + }, + async (state) => { + await state.writeAuthProfiles({ + version: 1, + profiles: { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "chatgpt-access", + refresh: "chatgpt-refresh", + expires: Date.now() + 30 * 60_000, + }, + }, + }); + + const { request, respond } = requestModelsList({ + view: "all", + loadGatewayModelCatalog: vi.fn(() => + Promise.resolve([ + { + id: "gpt-5.4-codex", + name: "GPT-5.4 Codex", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + ]), + ), + reqId: "req-models-list-codex-alias", + }); + await request; + + expect(respond).toHaveBeenCalledWith( + true, + { + models: [ + { + id: "gpt-5.4", + name: "GPT-5.4 Codex", + provider: "openai", + available: true, + }, + ], + }, + undefined, + ); + }, + ); + }); }); it("marks catalog models available through their configured CLI runtime", async () => { @@ -421,9 +529,18 @@ describe("models.list", () => { }); }); - it("marks file SecretRef provider unavailable when read-only auth cannot prove availability", async () => { + it("keeps file SecretRef provider availability unknown when read-only auth cannot resolve it", async () => { const catalog = [{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }]; const cfg = { + secrets: { + providers: { + "mounted-json": { + source: "file", + path: "/tmp/openclaw-test-secrets.json", + mode: "json", + }, + }, + }, agents: { defaults: { models: { @@ -461,7 +578,7 @@ describe("models.list", () => { ); }); - it("marks managed SecretRef provider unavailable when read-only auth cannot prove availability", async () => { + it("keeps managed SecretRef provider availability unknown without runtime proof", async () => { const catalog = [{ id: "llama-managed", name: "Llama Managed", provider: "vllm" }]; const cfg = { agents: { @@ -499,6 +616,66 @@ describe("models.list", () => { ); }); + it("uses an exact hydrated runtime snapshot as managed SecretRef proof", async () => { + const sourceConfig: OpenClawConfig = { + secrets: { + providers: { + "mounted-json": { + source: "file", + path: "/tmp/openclaw-test-secrets.json", + mode: "json", + }, + }, + }, + models: { + providers: { + vllm: { + baseUrl: "https://vllm.example/v1", + apiKey: { + source: "file", + provider: "mounted-json", + id: "/providers/vllm/apiKey", + }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + ...sourceConfig, + models: { + providers: { + vllm: { + ...sourceConfig.models!.providers!.vllm, + apiKey: "resolved-runtime-key", + }, + }, + }, + }; + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + try { + const { request, respond } = requestModelsList({ + view: "all", + runtimeConfig: sourceConfig, + loadGatewayModelCatalog: vi.fn(() => + Promise.resolve([{ id: "llama-secure", name: "Llama Secure", provider: "vllm" }]), + ), + reqId: "req-models-list-secretref-runtime-proof", + }); + await request; + + expect(respond).toHaveBeenCalledWith( + true, + { + models: [{ id: "llama-secure", name: "Llama Secure", provider: "vllm", available: true }], + }, + undefined, + ); + } finally { + clearRuntimeConfigSnapshot(); + } + }); + it("does not mark catalog rows available from expired OAuth profiles", async () => { await withOpenClawTestState( { @@ -679,6 +856,17 @@ describe("models.list", () => { const { request, respond } = requestModelsList({ view: "all", + runtimeConfig: { + secrets: { + providers: { + "mounted-json": { + source: "file", + path: "/tmp/openclaw-test-secrets.json", + mode: "json", + }, + }, + }, + } as OpenClawConfig, loadGatewayModelCatalog: vi.fn(() => Promise.resolve([{ id: "demo-model", name: "Demo Model", provider: "demo-provider" }]), ), @@ -704,6 +892,89 @@ describe("models.list", () => { ); }); + it("uses an exact hydrated runtime profile SecretRef as read-only proof", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-models-list-hydrated-file-profile-", + agentEnv: "main", + }, + async (state) => { + const tokenRef = { + source: "file" as const, + provider: "mounted-json", + id: "/providers/demo/token", + }; + const persisted = { + version: 1 as const, + profiles: { + "demo-provider:file": { + type: "token" as const, + provider: "demo-provider", + tokenRef, + expires: Date.now() + 10 * 60_000, + }, + }, + }; + await state.writeAuthProfiles(persisted); + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir: state.agentDir(), + store: { + ...persisted, + profiles: { + "demo-provider:file": { + ...persisted.profiles["demo-provider:file"], + token: "resolved-runtime-token", + }, + }, + }, + }, + ]); + try { + const { request, respond } = requestModelsList({ + view: "all", + runtimeConfig: { + secrets: { + providers: { + "mounted-json": { + source: "file", + path: "/tmp/openclaw-test-secrets.json", + mode: "json", + }, + }, + }, + } as OpenClawConfig, + loadGatewayModelCatalog: vi.fn(() => + Promise.resolve([ + { id: "demo-model", name: "Demo Model", provider: "demo-provider" }, + ]), + ), + reqId: "req-models-list-hydrated-file-profile", + }); + await request; + + expect(respond).toHaveBeenCalledWith( + true, + { + models: [ + { + id: "demo-model", + name: "Demo Model", + provider: "demo-provider", + available: true, + }, + ], + }, + undefined, + ); + } finally { + clearRuntimeAuthProfileStoreSnapshots(); + } + }, + ); + }); + it("marks auth profiles available even when provider config uses non-env SecretRef markers", async () => { for (const fixture of [ { @@ -788,6 +1059,81 @@ describe("models.list", () => { } }); + it("projects only public model fields", async () => { + const { request, respond } = requestModelsList({ + view: "all", + loadGatewayModelCatalog: vi.fn(() => + Promise.resolve([ + { + id: "demo-model", + name: "Demo Model", + provider: "demo-provider", + contextWindow: 0, + reasoning: "yes", + api: "openai-responses", + baseUrl: "https://private.example.test/v1", + authRequirement: "api-key", + agentRuntime: { id: "private-runtime" }, + params: { private: true }, + }, + ]), + ), + reqId: "req-models-list-safe-public-projection", + }); + await request; + + expect(respond).toHaveBeenCalledWith( + true, + { + models: [ + { + id: "demo-model", + name: "Demo Model", + provider: "demo-provider", + available: false, + }, + ], + }, + undefined, + ); + }); + + it("does not reinterpret context tokens or expose model input metadata", async () => { + const { request, respond } = requestModelsList({ + view: "all", + loadGatewayModelCatalog: vi.fn(() => + Promise.resolve([ + { + id: "vision-model", + name: "Vision Model", + provider: "demo-provider", + contextWindow: 128_000, + contextTokens: 96_000, + input: ["text", "image", "private-runtime-capability", "image"], + }, + ]), + ), + reqId: "req-models-list-public-capabilities", + }); + await request; + + expect(respond).toHaveBeenCalledWith( + true, + { + models: [ + { + id: "vision-model", + name: "Vision Model", + provider: "demo-provider", + available: false, + contextWindow: 128_000, + }, + ], + }, + undefined, + ); + }); + it("preserves catalog load errors before the timeout fallback wins", async () => { const { request, respond } = requestModelsList({ view: "configured", diff --git a/src/gateway/server-methods/optional-model-catalog.ts b/src/gateway/server-methods/optional-model-catalog.ts index cd64fedee442..9f92d3b359ac 100644 --- a/src/gateway/server-methods/optional-model-catalog.ts +++ b/src/gateway/server-methods/optional-model-catalog.ts @@ -1,6 +1,6 @@ // Optional model-catalog loading gives session/tool methods metadata when fast // while never blocking their primary response path on catalog discovery. -import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; +import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js"; import type { GatewayRequestContext } from "./types.js"; /** @@ -11,13 +11,13 @@ const DEFAULT_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS = 750; const loggedSlowCatalogKeys = new Set(); -export type OptionalServerMethodModelCatalogLoad = { - promise: Promise; +type OptionalServerMethodModelCatalogLoad = { + promise: Promise; }; -type LoadOptionalServerMethodModelCatalogOptions = { +type LoadOptionalServerMethodModelCatalogOptions = { logOnceKey?: string; - startedLoad?: OptionalServerMethodModelCatalogLoad; + startedLoad?: OptionalServerMethodModelCatalogLoad; timeoutMs?: number; }; @@ -25,39 +25,59 @@ function normalizeOptionalModelCatalog(value: unknown): ModelCatalogEntry[] | un return Array.isArray(value) ? value : undefined; } -export function startOptionalServerMethodModelCatalogLoad( - context: GatewayRequestContext, -): OptionalServerMethodModelCatalogLoad { +function normalizeOptionalModelCatalogSnapshot(value: unknown): ModelCatalogSnapshot | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const snapshot = value as Partial; + return Array.isArray(snapshot.entries) && Array.isArray(snapshot.routeVariants) + ? { entries: snapshot.entries, routeVariants: snapshot.routeVariants } + : undefined; +} + +function startOptionalServerMethodModelCatalogValueLoad(params: { + load: () => Promise; + normalize: (value: unknown) => T | undefined; +}): OptionalServerMethodModelCatalogLoad { let catalogPromise: Promise; try { - catalogPromise = context.loadGatewayModelCatalog(); + catalogPromise = params.load(); } catch { catalogPromise = Promise.resolve(undefined); } - const promise = catalogPromise.then( - (value) => { - const catalog = normalizeOptionalModelCatalog(value); - return catalog; - }, - () => { - return undefined; - }, - ); return { - promise, + promise: catalogPromise.then(params.normalize, () => undefined), }; } -/** Loads the gateway model catalog with a short timeout and one-time slow logs. */ -export async function loadOptionalServerMethodModelCatalog( +export function startOptionalServerMethodModelCatalogLoad( + context: GatewayRequestContext, +): OptionalServerMethodModelCatalogLoad { + return startOptionalServerMethodModelCatalogValueLoad({ + load: () => context.loadGatewayModelCatalog(), + normalize: normalizeOptionalModelCatalog, + }); +} + +export function startOptionalServerMethodModelCatalogSnapshotLoad( + context: GatewayRequestContext, +): OptionalServerMethodModelCatalogLoad { + return startOptionalServerMethodModelCatalogValueLoad({ + load: () => context.loadGatewayModelCatalogSnapshot(), + normalize: normalizeOptionalModelCatalogSnapshot, + }); +} + +async function loadOptionalServerMethodModelCatalogValue( context: GatewayRequestContext, surface: string, - options?: LoadOptionalServerMethodModelCatalogOptions, -): Promise { + options: LoadOptionalServerMethodModelCatalogOptions | undefined, + startLoad: () => OptionalServerMethodModelCatalogLoad, +): Promise { let timeout: NodeJS.Timeout | undefined; const timedOut = Symbol("server-method-model-catalog-timeout"); const timeoutMs = options?.timeoutMs ?? DEFAULT_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS; - const catalogLoad = options?.startedLoad ?? startOptionalServerMethodModelCatalogLoad(context); + const catalogLoad = options?.startedLoad ?? startLoad(); const timeoutPromise = new Promise((resolve) => { timeout = setTimeout(() => resolve(timedOut), timeoutMs); timeout.unref?.(); @@ -74,10 +94,32 @@ export async function loadOptionalServerMethodModelCatalog( } return undefined; } - return normalizeOptionalModelCatalog(result); + return result; } finally { if (timeout) { clearTimeout(timeout); } } } + +/** Loads the gateway model catalog with a short timeout and one-time slow logs. */ +export async function loadOptionalServerMethodModelCatalog( + context: GatewayRequestContext, + surface: string, + options?: LoadOptionalServerMethodModelCatalogOptions, +): Promise { + return await loadOptionalServerMethodModelCatalogValue(context, surface, options, () => + startOptionalServerMethodModelCatalogLoad(context), + ); +} + +/** Loads the full gateway model catalog snapshot without blocking the primary response path. */ +export async function loadOptionalServerMethodModelCatalogSnapshot( + context: GatewayRequestContext, + surface: string, + options?: LoadOptionalServerMethodModelCatalogOptions, +): Promise { + return await loadOptionalServerMethodModelCatalogValue(context, surface, options, () => + startOptionalServerMethodModelCatalogSnapshotLoad(context), + ); +} diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index be61a83a0b71..40276f9baa7d 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -3240,6 +3240,13 @@ export const sessionsHandlers: GatewayRequestHandlers = { provider: resolvedModel.provider, model: resolvedModel.model, authProfileId: latestEntry.authProfileOverride, + authProfileIdSource: + latestEntry.authProfileOverrideSource ?? + (latestEntry.authProfileOverride + ? typeof latestEntry.authProfileOverrideCompactionCount === "number" + ? "auto" + : "user" + : undefined), agentHarnessId: latestEntry.modelSelectionLocked === true ? resolvePersistedSessionRuntimeId(latestEntry) diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 81a88ee464e3..2750a9a4a929 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -5,6 +5,7 @@ import type { ErrorShape, RequestFrame, } from "../../../packages/gateway-protocol/src/schema/frames.js"; +import type { ModelCatalogSnapshot } from "../../agents/model-catalog.types.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import type { CliDeps } from "../../cli/deps.types.js"; import type { HealthSummary } from "../../commands/health.types.js"; @@ -95,6 +96,9 @@ export type GatewayRequestContext = { pluginApprovalManager?: ExecApprovalManager; forwardPluginApprovalRequest?: (request: PluginApprovalRequest) => Promise; loadGatewayModelCatalog: (params?: { readOnly?: boolean }) => Promise; + loadGatewayModelCatalogSnapshot: (params?: { + readOnly?: boolean; + }) => Promise; getHealthCache: () => HealthSummary | null; refreshHealthSnapshot: (opts?: { probe?: boolean; diff --git a/src/gateway/server-model-catalog.test.ts b/src/gateway/server-model-catalog.test.ts index a778faf61252..db8ff3461bfe 100644 --- a/src/gateway/server-model-catalog.test.ts +++ b/src/gateway/server-model-catalog.test.ts @@ -11,9 +11,10 @@ import { markGatewayModelCatalogStaleForReload, } from "./server-model-catalog.js"; -type LoadModelCatalogForTest = NonNullable< - NonNullable[0]>["loadModelCatalog"] ->; +type LoadModelCatalogForTest = (params: { + config: OpenClawConfig; + readOnly?: boolean; +}) => Promise; function model(id: string): GatewayModelChoice { return { id, name: id, provider: "openai" } as GatewayModelChoice; @@ -21,6 +22,13 @@ function model(id: string): GatewayModelChoice { const getConfig = () => ({}) as OpenClawConfig; +const toSnapshotLoader = + (loadModelCatalog: LoadModelCatalogForTest) => + async (params: Parameters[0]) => { + const entries = await loadModelCatalog(params); + return { entries, routeVariants: entries }; + }; + function createRefreshingCatalogLoader( firstCatalog: GatewayModelChoice[], secondCatalog: GatewayModelChoice[], @@ -39,7 +47,7 @@ async function expectCatalog( await expect( loadGatewayModelCatalog({ getConfig, - loadModelCatalog, + loadModelCatalogSnapshot: toSnapshotLoader(loadModelCatalog), ...(readOnly ? {} : { readOnly: false }), }), ).resolves.toBe(catalog); @@ -63,8 +71,13 @@ describe("loadGatewayModelCatalog", () => { const catalog = [model("gpt-5.4")]; const loadModelCatalog = vi.fn(async () => catalog); - await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalog })).resolves.toBe(catalog); - await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalog })).resolves.toBe(catalog); + const loadModelCatalogSnapshot = toSnapshotLoader(loadModelCatalog); + await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalogSnapshot })).resolves.toBe( + catalog, + ); + await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalogSnapshot })).resolves.toBe( + catalog, + ); expect(loadModelCatalog).toHaveBeenCalledTimes(1); expect(loadModelCatalog).toHaveBeenCalledWith({ config: getConfig(), readOnly: true }); @@ -77,13 +90,18 @@ describe("loadGatewayModelCatalog", () => { params.readOnly === false ? fullCatalog : readOnlyCatalog, ); - await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalog })).resolves.toBe( + const loadModelCatalogSnapshot = toSnapshotLoader(loadModelCatalog); + await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalogSnapshot })).resolves.toBe( readOnlyCatalog, ); await expect( - loadGatewayModelCatalog({ getConfig, loadModelCatalog, readOnly: false }), + loadGatewayModelCatalog({ + getConfig, + loadModelCatalogSnapshot: toSnapshotLoader(loadModelCatalog), + readOnly: false, + }), ).resolves.toBe(fullCatalog); - await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalog })).resolves.toBe( + await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalogSnapshot })).resolves.toBe( readOnlyCatalog, ); diff --git a/src/gateway/server-model-catalog.ts b/src/gateway/server-model-catalog.ts index 4482fa7a9837..32a4bf1c4c28 100644 --- a/src/gateway/server-model-catalog.ts +++ b/src/gateway/server-model-catalog.ts @@ -1,23 +1,24 @@ // Gateway model catalog cache. // Serves model catalogs with stale-while-refresh behavior for Gateway surfaces. +import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js"; import { getRuntimeConfig } from "../config/io.js"; export type GatewayModelChoice = import("../agents/model-catalog.js").ModelCatalogEntry; type GatewayModelCatalogConfig = ReturnType; -type LoadModelCatalog = (params: { +type LoadModelCatalogSnapshot = (params: { config: GatewayModelCatalogConfig; readOnly?: boolean; -}) => Promise; +}) => Promise; type LoadGatewayModelCatalogParams = { getConfig?: () => GatewayModelCatalogConfig; - loadModelCatalog?: LoadModelCatalog; + loadModelCatalogSnapshot?: LoadModelCatalogSnapshot; readOnly?: boolean; }; type GatewayModelCatalogCache = { - lastSuccessfulCatalog: GatewayModelChoice[] | null; - inFlightRefresh: Promise | null; + lastSuccessfulCatalog: ModelCatalogSnapshot | null; + inFlightRefresh: Promise | null; staleGeneration: number; appliedGeneration: number; }; @@ -55,31 +56,34 @@ function isGatewayModelCatalogStale(cache: GatewayModelCatalogCache): boolean { return cache.appliedGeneration < cache.staleGeneration; } -async function resolveLoadModelCatalog( +async function resolveLoadModelCatalogSnapshot( params?: LoadGatewayModelCatalogParams, -): Promise { - if (params?.loadModelCatalog) { - return params.loadModelCatalog; +): Promise { + if (params?.loadModelCatalogSnapshot) { + return params.loadModelCatalogSnapshot; } - const { loadModelCatalog } = await loadModelCatalogModule(); - return loadModelCatalog; + const { loadModelCatalogSnapshot } = await loadModelCatalogModule(); + return loadModelCatalogSnapshot; } function startGatewayModelCatalogRefresh( params?: LoadGatewayModelCatalogParams, -): Promise { +): Promise { const cache = resolveGatewayModelCatalogCache(params); const config = (params?.getConfig ?? getRuntimeConfig)(); const readOnly = params?.readOnly !== false; const refreshGeneration = cache.staleGeneration; - const refresh = resolveLoadModelCatalog(params) - .then((loadModelCatalog) => loadModelCatalog({ config, readOnly })) - .then((catalog) => { - if ((readOnly || catalog.length > 0) && refreshGeneration === cache.staleGeneration) { - cache.lastSuccessfulCatalog = catalog; + const refresh = resolveLoadModelCatalogSnapshot(params) + .then((loadSnapshot) => loadSnapshot({ config, readOnly })) + .then((snapshot) => { + if ( + (readOnly || snapshot.entries.length > 0) && + refreshGeneration === cache.staleGeneration + ) { + cache.lastSuccessfulCatalog = snapshot; cache.appliedGeneration = cache.staleGeneration; } - return catalog; + return snapshot; }) .finally(() => { if (cache.inFlightRefresh === refresh) { @@ -106,10 +110,10 @@ export async function resetModelCatalogCacheForTest(): Promise { resetModelCatalogCacheForTestLocal(); } -/** Load the Gateway model catalog, returning cached data while stale refreshes run. */ -export async function loadGatewayModelCatalog( +/** Load the Gateway model catalog snapshot, returning cached data while stale refreshes run. */ +export async function loadGatewayModelCatalogSnapshot( params?: LoadGatewayModelCatalogParams, -): Promise { +): Promise { const cache = resolveGatewayModelCatalogCache(params); const isStale = isGatewayModelCatalogStale(cache); if (!isStale && cache.lastSuccessfulCatalog !== null) { @@ -126,3 +130,10 @@ export async function loadGatewayModelCatalog( } return await startGatewayModelCatalogRefresh(params); } + +/** Load the deduplicated Gateway model catalog for entries-only consumers. */ +export async function loadGatewayModelCatalog( + params?: LoadGatewayModelCatalogParams, +): Promise { + return (await loadGatewayModelCatalogSnapshot(params)).entries; +} diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index 630758ac55c8..99a004289f55 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -31,6 +31,7 @@ function makeContextParams( execApprovalManager: undefined, pluginApprovalManager: undefined, loadGatewayModelCatalog: vi.fn(async () => []), + loadGatewayModelCatalogSnapshot: vi.fn(async () => ({ entries: [], routeVariants: [] })), getHealthCache: vi.fn(() => null), refreshHealthSnapshot: vi.fn(async () => ({}) as never), logHealth: { error: vi.fn() }, diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 35d961ae1411..319030bd0477 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -22,6 +22,7 @@ export type GatewayRequestContextParams = { forwardPluginApprovalRequest?: GatewayRequestContext["forwardPluginApprovalRequest"]; pluginApprovalManager: GatewayRequestContext["pluginApprovalManager"]; loadGatewayModelCatalog: GatewayRequestContext["loadGatewayModelCatalog"]; + loadGatewayModelCatalogSnapshot: GatewayRequestContext["loadGatewayModelCatalogSnapshot"]; getHealthCache: GatewayRequestContext["getHealthCache"]; refreshHealthSnapshot: GatewayRequestContext["refreshHealthSnapshot"]; logHealth: GatewayRequestContext["logHealth"]; @@ -107,6 +108,7 @@ export function createGatewayRequestContext( forwardPluginApprovalRequest: params.forwardPluginApprovalRequest, pluginApprovalManager: params.pluginApprovalManager, loadGatewayModelCatalog: params.loadGatewayModelCatalog, + loadGatewayModelCatalogSnapshot: params.loadGatewayModelCatalogSnapshot, getHealthCache: params.getHealthCache, refreshHealthSnapshot: params.refreshHealthSnapshot, logHealth: params.logHealth, diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 6799def04940..ac9f8e81106d 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -15,6 +15,7 @@ import { rotateAgentEventLifecycleGeneration } from "../infra/agent-events.js"; import { runExclusiveSessionLifecycleMutation } from "../sessions/session-lifecycle-admission.js"; import { createDeferred } from "../test-utils/deferred.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { setMaxChatHistoryMessagesBytesForTest } from "./server-constants.js"; import type { GatewayRequestContext, RespondFn } from "./server-methods/shared-types.js"; @@ -615,11 +616,13 @@ describe("gateway server chat", () => { }, }); const catalog = - createDeferred>>(); + createDeferred< + Awaited> + >(); const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; const context = { - loadGatewayModelCatalog: vi - .fn() + loadGatewayModelCatalogSnapshot: vi + .fn() .mockReturnValue(catalog.promise), logGateway: { info: vi.fn(), @@ -649,7 +652,7 @@ describe("gateway server chat", () => { context, }); - expect(context.loadGatewayModelCatalog).toHaveBeenCalledTimes(1); + expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledTimes(1); expect(responses).toHaveLength(1); expect(responses[0]?.ok).toBe(true); const payload = responses[0]?.payload as @@ -668,6 +671,278 @@ describe("gateway server chat", () => { } }); + test("chat.startup projects route thinking metadata per agent and session auth", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-gw-startup-routes-", + agentEnv: "main", + env: { + CHATGPT_OAUTH_TOKEN: undefined, + CODEX_API_KEY: undefined, + CODEX_HOME: "/__openclaw_gateway_startup_routes__/codex", + OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"), + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + OPENAI_API_KEY: undefined, + OPENAI_BASE_URL: undefined, + OPENAI_OAUTH_TOKEN: undefined, + }, + }, + async (state) => { + const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); + try { + testState.sessionStorePath = path.join(sessionDir, "sessions.json"); + const config = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { "openai/gpt-5.5": {} }, + }, + list: [{ id: "main", default: true }, { id: "work" }], + }, + auth: { + order: { openai: ["openai:api", "openai:chatgpt", "openai:expired"] }, + }, + }; + await state.writeConfig(config); + clearConfigCache(); + await writeSessionStore({ + entries: { + "agent:work:main": { + sessionId: "sess-work", + modelProvider: "openai", + model: "gpt-5.5", + authProfileOverride: "openai:chatgpt", + authProfileOverrideSource: "user", + updatedAt: Date.now(), + }, + "agent:work:auto": { + sessionId: "sess-work-auto", + modelProvider: "openai", + model: "gpt-5.5", + authProfileOverride: "openai:expired", + authProfileOverrideSource: "auto", + updatedAt: Date.now(), + }, + "agent:work:auto-preferred": { + sessionId: "sess-work-auto-preferred", + modelProvider: "openai", + model: "gpt-5.5", + authProfileOverride: "openai:chatgpt", + authProfileOverrideSource: "auto", + updatedAt: Date.now(), + }, + "agent:work:legacy-auto": { + sessionId: "sess-work-legacy-auto", + modelProvider: "openai", + model: "gpt-5.5", + authProfileOverride: "openai:expired", + authProfileOverrideCompactionCount: 0, + updatedAt: Date.now(), + }, + }, + }); + await state.writeAuthProfiles({ + version: 1, + profiles: { + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "chatgpt-access", + refresh: "chatgpt-refresh", + expires: Date.now() + 30 * 60_000, + }, + }, + }); + await state.writeAuthProfiles( + { + version: 1, + profiles: { + "openai:api": { + type: "api_key", + provider: "openai", + key: "platform-api-key", + }, + "openai:chatgpt": { + type: "oauth", + provider: "openai", + access: "work-chatgpt-access", + refresh: "work-chatgpt-refresh", + expires: Date.now() + 30 * 60_000, + }, + "openai:expired": { + type: "oauth", + provider: "openai", + access: "expired-work-chatgpt-access", + expires: Date.now() - 60_000, + }, + }, + }, + "work", + ); + const platformRoute = { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + api: "openai-responses" as const, + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_000_000, + reasoning: true, + compat: { supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh"] }, + }; + const subscriptionRoute = { + ...platformRoute, + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + contextWindow: 400_000, + reasoning: false, + compat: { supportedReasoningEfforts: ["low"] }, + params: { apiKey: "private-route-token" }, + }; + const catalogSnapshot = { + entries: [subscriptionRoute], + routeVariants: [subscriptionRoute, platformRoute], + }; + const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; + const context = { + loadGatewayModelCatalogSnapshot: vi + .fn() + .mockResolvedValue(catalogSnapshot), + logGateway: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + chatAbortControllers: new Map(), + chatRunBuffers: new Map(), + getRuntimeConfig: () => config, + } as unknown as GatewayRequestContext; + const { createGatewayAgentModelCatalogProjector } = + await import("./server-methods/models-list-result.js"); + const persistedConfig = getRuntimeConfig(); + expect(persistedConfig.auth?.order?.openai).toEqual([ + "openai:api", + "openai:chatgpt", + "openai:expired", + ]); + const expiredPreferenceEvaluation = await createGatewayAgentModelCatalogProjector({ + cfg: persistedConfig, + agentId: "work", + snapshot: catalogSnapshot, + preferredProfileId: "openai:expired", + }).evaluateEntry(subscriptionRoute, catalogSnapshot.routeVariants); + expect(expiredPreferenceEvaluation).toMatchObject({ + availability: true, + selectedProfileId: "openai:api", + selectedRoute: { authRequirement: "api-key" }, + }); + const { chatHandlers } = await import("./server-methods/chat.js"); + + await chatHandlers["chat.startup"]({ + req: { + type: "req", + id: "startup-dual-route-catalog", + method: "chat.startup", + params: { sessionKey: "agent:work:main" }, + }, + params: { sessionKey: "agent:work:main" }, + client: null, + isWebchatConnect: () => false, + respond: ((ok, payload, error) => { + responses.push({ ok, payload, error }); + }) as RespondFn, + context, + }); + + expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledTimes(1); + expect(responses).toHaveLength(1); + expect(responses[0]?.ok).toBe(true); + const payload = responses[0]?.payload as + | { + metadata?: { models?: unknown[] }; + sessionInfo?: { thinkingLevels?: Array<{ id?: string }> }; + defaults?: { thinkingLevels?: Array<{ id?: string }> }; + agentsList?: { + agents?: Array<{ id?: string; thinkingLevels?: Array<{ id?: string }> }>; + }; + } + | undefined; + expect(payload?.metadata?.models).toEqual([ + { + id: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + contextWindow: 400_000, + reasoning: false, + available: true, + }, + ]); + expect(payload?.sessionInfo?.thinkingLevels?.map((level) => level.id)).toEqual(["off"]); + expect(payload?.defaults?.thinkingLevels?.map((level) => level.id)).toEqual(["off"]); + const mainAgent = payload?.agentsList?.agents?.find((agent) => agent.id === "main"); + const workAgent = payload?.agentsList?.agents?.find((agent) => agent.id === "work"); + expect(mainAgent?.thinkingLevels?.map((level) => level.id)).toEqual(["off"]); + expect(workAgent?.thinkingLevels?.map((level) => level.id)).toContain("high"); + const serialized = JSON.stringify(responses[0]?.payload); + expect(serialized).not.toContain("private-route-token"); + expect(serialized).not.toContain("platform-api-key"); + expect(serialized).not.toContain("chatgpt-access"); + expect(serialized).not.toContain("supportedReasoningEfforts"); + expect(serialized).not.toContain(platformRoute.baseUrl); + expect(serialized).not.toContain(subscriptionRoute.baseUrl); + + for (const [index, [sessionKey, expectedRoute]] of [ + ["agent:work:auto-preferred", "subscription"], + ["agent:work:auto", "platform"], + ["agent:work:legacy-auto", "platform"], + ].entries()) { + responses.length = 0; + await chatHandlers["chat.startup"]({ + req: { + type: "req", + id: `startup-preferred-route-${index}`, + method: "chat.startup", + params: { sessionKey }, + }, + params: { sessionKey }, + client: null, + isWebchatConnect: () => false, + respond: ((ok, responsePayload, error) => { + responses.push({ ok, payload: responsePayload, error }); + }) as RespondFn, + context, + }); + + expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledTimes(index + 2); + expect(responses).toHaveLength(1); + expect(responses[0]?.ok).toBe(true); + const preferredPayload = responses[0]?.payload as + | { + metadata?: { models?: Array<{ contextWindow?: number }> }; + sessionInfo?: { thinkingLevels?: Array<{ id?: string }> }; + } + | undefined; + expect(preferredPayload?.metadata?.models?.[0]?.contextWindow, sessionKey).toBe( + expectedRoute === "subscription" ? 400_000 : 1_000_000, + ); + const thinkingLevels = preferredPayload?.sessionInfo?.thinkingLevels?.map( + (level) => level.id, + ); + if (expectedRoute === "subscription") { + expect(thinkingLevels, sessionKey).toEqual(["off"]); + } else { + expect(thinkingLevels, sessionKey).toContain("high"); + } + } + } finally { + testState.sessionStorePath = undefined; + await removeTempDir(sessionDir); + } + }, + ); + }); + test("chat.startup omits metadata when configured model visibility needs full discovery", async () => { await withGatewayChatHarness(async ({ ws }) => { await writeGatewayConfig({ @@ -751,25 +1026,24 @@ describe("gateway server chat", () => { await writeGatewayConfig(config); const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; const context = { - loadGatewayModelCatalog: vi - .fn() + loadGatewayModelCatalogSnapshot: vi + .fn() .mockImplementation(async () => { await Promise.resolve(); await Promise.resolve(); - return [ + const entries = [ { id: "gpt-main", name: "GPT Main", provider: "openai", - input: ["text"], }, { id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed", provider: "minimax", - input: ["text"], }, ]; + return { entries, routeVariants: entries }; }), logGateway: { info: vi.fn(), @@ -799,7 +1073,7 @@ describe("gateway server chat", () => { context, }); - expect(context.loadGatewayModelCatalog).toHaveBeenCalledTimes(1); + expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledTimes(1); expect(responses).toHaveLength(1); expect(responses[0]?.ok).toBe(true); const payload = responses[0]?.payload as diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index b9cd434c6bf9..a6cf03e73c3e 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -140,6 +140,8 @@ import { createWorkerEnvironmentService } from "./worker-environments/service.js import { createWorkerEnvironmentStore } from "./worker-environments/store.js"; type LoadGatewayModelCatalog = typeof import("./server-model-catalog.js").loadGatewayModelCatalog; +type LoadGatewayModelCatalogSnapshot = + typeof import("./server-model-catalog.js").loadGatewayModelCatalogSnapshot; const loadGatewayModelCatalogModule = createLazyRuntimeModule( () => import("./server-model-catalog.js"), @@ -214,6 +216,10 @@ const loadGatewayModelCatalog: LoadGatewayModelCatalog = async (...args) => { const mod = await loadGatewayModelCatalogModule(); return mod.loadGatewayModelCatalog(...args); }; +const loadGatewayModelCatalogSnapshot: LoadGatewayModelCatalogSnapshot = async (...args) => { + const mod = await loadGatewayModelCatalogModule(); + return mod.loadGatewayModelCatalogSnapshot(...args); +}; const loadGatewayPluginBootstrapModule = createLazyRuntimeModule( () => import("./server-plugin-bootstrap.js"), @@ -1643,6 +1649,7 @@ export async function startGatewayServer( forwardPluginApprovalRequest, pluginApprovalManager, loadGatewayModelCatalog, + loadGatewayModelCatalogSnapshot, getHealthCache, refreshHealthSnapshot: refreshGatewayHealthSnapshotWithRuntime, logHealth, diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index dd6a5b532f57..6592166be605 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -1114,6 +1114,7 @@ test("sessions.compact passes the selected global agent into embedded compaction sessionKey: "global", agentId: "work", authProfileId: "github-copilot:work", + authProfileIdSource: "user", }); await resetConfiguredGlobalAgentSessionStore(globalStores); }); diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 914db6c7f9ec..87282be9157e 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -496,7 +496,7 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(modelPatched.payload?.resolved?.modelProvider).toBe("openai"); expect(modelPatched.payload?.resolved?.model).toBe("gpt-test-a"); expect(modelPatched.payload?.resolved?.agentRuntime).toEqual({ - id: "codex", + id: "openclaw", source: "implicit", }); @@ -514,7 +514,7 @@ test("lists and patches session store via sessions.* RPC", async () => { ); expect(mainAfterModelPatch?.modelProvider).toBe("openai"); expect(mainAfterModelPatch?.model).toBe("gpt-test-a"); - expect(mainAfterModelPatch?.agentRuntime).toEqual({ id: "codex", source: "implicit" }); + expect(mainAfterModelPatch?.agentRuntime).toEqual({ id: "openclaw", source: "implicit" }); const compacted = await directSessionReq<{ ok: true; compacted: boolean }>("sessions.compact", { key: "agent:main:main", diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index ea40738fe299..cfd8e22041e7 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -1218,6 +1218,7 @@ function resolveGatewayAgentModel( export function listAgentsForGateway( cfg: OpenClawConfig, modelCatalog?: ModelCatalogEntry[], + options?: { modelCatalogByAgentId?: ReadonlyMap }, ): { defaultId: string; mainKey: string; @@ -1283,10 +1284,11 @@ export function listAgentsForGateway( agentId: id, sessionKey, }); + const agentModelCatalog = options?.modelCatalogByAgentId?.get(id) ?? modelCatalog; const thinkingLevels = listThinkingLevelOptions( resolvedModel.provider, resolvedModel.model, - modelCatalog, + agentModelCatalog, thinkingRuntime, ); const workspace = resolveAgentWorkspaceDir(cfg, id); @@ -1308,7 +1310,7 @@ export function listAgentsForGateway( provider: resolvedModel.provider, model: resolvedModel.model, agentId: id, - modelCatalog, + modelCatalog: agentModelCatalog, agentRuntime: thinkingRuntime, }), }, diff --git a/src/plugin-sdk/agent-harness-runtime.test.ts b/src/plugin-sdk/agent-harness-runtime.test.ts index b5694b28c1b9..52639c61de44 100644 --- a/src/plugin-sdk/agent-harness-runtime.test.ts +++ b/src/plugin-sdk/agent-harness-runtime.test.ts @@ -1,7 +1,7 @@ /** * Tests agent harness runtime helpers and task dispatch behavior. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; import { attachModelProviderRequestTransport, buildAgentHarnessUserInputAnswers, @@ -9,8 +9,13 @@ import { deliverAgentHarnessUserInputPrompt, formatAgentHarnessUserInputPrompt, getModelProviderRequestTransport, + type AgentHarnessSupportContext, type AgentHarnessTerminalOutcomeClassification, } from "./agent-harness-runtime.js"; +import type { + ProviderModelRouteRuntimePolicy, + ProviderRouteOverridePresence, +} from "./provider-model-types.js"; const { loadResearchAutocapture } = vi.hoisted(() => ({ loadResearchAutocapture: vi.fn(), @@ -167,6 +172,15 @@ describe("agent harness runtime SDK facade", () => { auth: { mode: "header", headerName: "x-api-key", value: "secret" }, }); }); + + it("locks the request-transport support contract", () => { + expectTypeOf< + NonNullable["requestTransportOverrides"] + >().toEqualTypeOf(); + expectTypeOf< + NonNullable["runtimePolicy"] + >().toEqualTypeOf(); + }); }); describe("agent harness user input helpers", () => { diff --git a/src/plugin-sdk/provider-model-types.ts b/src/plugin-sdk/provider-model-types.ts index e7b30b51dec2..675ee32fe3f2 100644 --- a/src/plugin-sdk/provider-model-types.ts +++ b/src/plugin-sdk/provider-model-types.ts @@ -1,6 +1,8 @@ /** * Public SDK type surface for model provider and model definition config. */ +import type { ModelApi } from "../config/types.models.js"; + export type { BedrockDiscoveryConfig, ModelApi, @@ -8,3 +10,62 @@ export type { ModelDefinitionConfig, ModelProviderConfig, } from "../config/types.models.js"; + +export type ProviderModelRouteSource = { + api?: ModelApi | null; + baseUrl?: unknown; +}; + +/** A concrete provider route. Order expresses provider default, never credential precedence. */ +export type ProviderModelRouteAuthRequirement = "api-key" | "subscription"; +export type ProviderRouteOverridePresence = "none" | "present"; +export type ProviderModelRouteRuntimePolicy = { + /** Agent runtime ids that can reproduce this route without losing transport behavior. */ + compatibleIds: readonly string[]; +}; + +export type ProviderModelRouteCandidate = { + api: ModelApi; + baseUrl: string; + authRequirement: ProviderModelRouteAuthRequirement; + /** Secret-free summary of request behavior the selected runtime must reproduce. */ + requestTransportOverrides: ProviderRouteOverridePresence; + /** Provider-owned native-runtime compatibility for this concrete route. */ + runtimePolicy?: ProviderModelRouteRuntimePolicy; +}; + +export type ProviderModelRouteResolution = + | { + kind: "routes"; + routes: readonly [ProviderModelRouteCandidate, ...ProviderModelRouteCandidate[]]; + /** Advisory only; authored agentRuntime policy remains authoritative. */ + defaultRuntimeId?: string; + } + | { + kind: "indeterminate"; + /** Advisory only; preserves the provider's implicit runtime while route facts are absent. */ + defaultRuntimeId?: string; + } + | { + kind: "incompatible"; + code: string; + message: string; + }; + +export type ProviderResolveModelRoutesContext = { + provider: string; + modelId?: string; + /** Effective secret-free request behavior for this provider/model pair. */ + requestTransportOverrides?: ProviderRouteOverridePresence; + configuredModel?: ProviderModelRouteSource; + configuredProvider?: ProviderModelRouteSource; + /** Environment view; the provider owns interpretation of its variables. */ + env?: Readonly>; + /** Physical route facts for one logical model; input order is not preference. */ + observedRoutes?: readonly ProviderModelRouteSource[]; +}; + +export type ProviderNormalizeModelCatalogIdContext = { + provider: string; + modelId: string; +}; diff --git a/src/plugins/provider-model-routes.test.ts b/src/plugins/provider-model-routes.test.ts new file mode 100644 index 000000000000..e5f775514ceb --- /dev/null +++ b/src/plugins/provider-model-routes.test.ts @@ -0,0 +1,479 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ModelApi } from "../config/types.models.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ProviderResolveModelRoutesContext } from "../plugin-sdk/provider-model-types.js"; +import { + createProviderModelRoutesResolver, + resolveProviderModelCatalogId, + resolveProviderModelRoutes, +} from "./provider-model-routes.js"; + +describe("provider model route adapter", () => { + it("does not invent an observed transport from a model id alone", () => { + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + defaultRuntimeId: "codex", + })); + const resolveRoutes = createProviderModelRoutesResolver({ + provider: "openai", + config: {}, + env: {}, + surface: { resolveModelRoutes }, + }); + + expect(resolveRoutes({ modelId: "gpt-5.4-nano" })).toEqual({ + kind: "indeterminate", + defaultRuntimeId: "codex", + }); + expect(resolveModelRoutes).toHaveBeenCalledWith({ + provider: "openai", + modelId: "gpt-5.4-nano", + requestTransportOverrides: "none", + env: {}, + }); + }); + + it("resolves only the requested model/config/env facts", () => { + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + defaultRuntimeId: "codex", + })); + const env = { OPENAI_BASE_URL: "https://env.example.test/v1" }; + const config = { + models: { + providers: { + openai: { + api: "openai-completions", + baseUrl: "https://provider.example.test/v1", + models: [ + { id: "unrelated", api: "openai-chatgpt-responses" }, + { + id: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://model.example.test/v1", + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + resolveProviderModelRoutes({ + provider: "OPENAI", + modelId: "gpt-5.5", + config, + env, + surface: { resolveModelRoutes }, + }), + ).toEqual({ kind: "indeterminate", defaultRuntimeId: "codex" }); + expect(resolveModelRoutes).toHaveBeenCalledWith({ + provider: "openai", + modelId: "gpt-5.5", + requestTransportOverrides: "none", + configuredModel: { + api: "openai-responses", + baseUrl: "https://model.example.test/v1", + }, + configuredProvider: { + api: "openai-completions", + baseUrl: "https://provider.example.test/v1", + }, + env, + }); + expect(resolveModelRoutes.mock.calls[0]?.[0].env).toBe(env); + }); + + it("passes the live environment view instead of cloning it", () => { + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + })); + + resolveProviderModelRoutes({ + provider: "openai", + surface: { resolveModelRoutes }, + }); + + expect(resolveModelRoutes.mock.calls[0]?.[0].env).toBe(process.env); + }); + + it("resolves catalog ids through the direct provider policy surface", () => { + expect( + resolveProviderModelCatalogId({ + provider: "OpenAI", + modelId: "gpt-5.4-codex", + }), + ).toBe("gpt-5.4"); + expect( + resolveProviderModelCatalogId({ + provider: "OpenAI", + modelId: "openai/acme-model", + }), + ).toBe("openai/acme-model"); + expect( + resolveProviderModelCatalogId({ + provider: "fixture", + modelId: "demo-latest", + surface: {}, + }), + ).toBeNull(); + }); + + it("preserves provider-scoped nested ids through route resolution", () => { + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + })); + const config = { + models: { + providers: { + openai: { + models: [ + { + id: "openai/acme-model", + api: "openai-completions", + baseUrl: "https://acme.example.test/v1", + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + resolveProviderModelRoutes({ + provider: "openai", + modelId: "openai/acme-model", + config, + env: {}, + surface: { + normalizeModelCatalogId: ({ modelId }) => modelId, + resolveModelRoutes, + }, + }); + + expect(resolveModelRoutes).toHaveBeenCalledWith({ + provider: "openai", + modelId: "openai/acme-model", + requestTransportOverrides: "none", + configuredModel: { + api: "openai-completions", + baseUrl: "https://acme.example.test/v1", + }, + configuredProvider: { api: undefined, baseUrl: undefined }, + env: {}, + }); + }); + + it("keeps configured model facts ahead of observed route facts", () => { + const config = { + models: { + providers: { + openai: { + baseUrl: "https://provider.example.test/v1", + models: [ + { + id: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://model.example.test/v1", + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + resolveProviderModelRoutes({ + provider: "OPENAI", + modelId: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + config, + env: { OPENAI_BASE_URL: "https://env.example.test/v1" }, + }), + ).toEqual({ + kind: "routes", + defaultRuntimeId: "openclaw", + routes: [ + { + api: "openai-responses", + baseUrl: "https://model.example.test/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw"] }, + }, + ], + }); + }); + + it.each([ + ["canonical config for legacy selection", "gpt-5.4", "gpt-5.4-codex"], + ["legacy config for canonical selection", "gpt-5.4-codex", "gpt-5.4"], + ] as const)("canonicalizes provider-owned aliases: %s", (_label, configuredId, requestedId) => { + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + })); + const normalizeModelCatalogId = vi.fn(({ modelId }: { modelId: string }) => + modelId === "gpt-5.4-codex" ? "gpt-5.4" : modelId, + ); + const config = { + models: { + providers: { + openai: { + models: [ + { + id: configuredId, + api: "openai-responses", + baseUrl: "https://model.example.test/v1", + headers: { "x-route-contract": "required" }, + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + resolveProviderModelRoutes({ + provider: "openai", + modelId: requestedId, + config, + env: {}, + surface: { normalizeModelCatalogId, resolveModelRoutes }, + }); + + expect(resolveModelRoutes).toHaveBeenCalledWith({ + provider: "openai", + modelId: "gpt-5.4", + requestTransportOverrides: "present", + configuredModel: { + api: "openai-responses", + baseUrl: "https://model.example.test/v1", + }, + configuredProvider: { api: undefined, baseUrl: undefined }, + env: {}, + }); + }); + + it("forwards one reversed physical route group in one artifact call", () => { + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + })); + const resolveRoutes = createProviderModelRoutesResolver({ + provider: "openai", + env: {}, + surface: { resolveModelRoutes }, + }); + const observedRoutes = [ + { + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + { api: "openai-responses" as const, baseUrl: "https://api.openai.com/v1" }, + ]; + + resolveRoutes({ modelId: "gpt-future-observed", observedRoutes }); + + expect(resolveModelRoutes).toHaveBeenCalledOnce(); + expect(resolveModelRoutes).toHaveBeenCalledWith({ + provider: "openai", + modelId: "gpt-future-observed", + requestTransportOverrides: "none", + env: {}, + observedRoutes, + }); + }); + + it("locks configured route facts while keeping the environment live", () => { + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + })); + const configuredModel: { + id: string; + api: ModelApi; + baseUrl: string; + } = { + id: "demo", + api: "openai-responses", + baseUrl: "https://model-one.example.test/v1", + }; + const configuredProvider: { + api: ModelApi; + baseUrl: string; + authHeader?: boolean; + models: Array; + } = { + api: "openai-completions", + baseUrl: "https://provider-one.example.test/v1", + models: [configuredModel], + }; + const config = { + models: { providers: { openai: configuredProvider } }, + } as unknown as OpenClawConfig; + const env = { OPENAI_BASE_URL: "https://env-one.example.test/v1" }; + const resolveRoutes = createProviderModelRoutesResolver({ + provider: "openai", + config, + env, + surface: { resolveModelRoutes }, + }); + + configuredModel.api = "openai-completions"; + configuredModel.baseUrl = "https://model-two.example.test/v1"; + configuredProvider.api = "openai-responses"; + configuredProvider.authHeader = false; + configuredProvider.baseUrl = "https://provider-two.example.test/v1"; + env.OPENAI_BASE_URL = "https://env-two.example.test/v1"; + resolveRoutes({ modelId: "demo" }); + + expect(resolveModelRoutes).toHaveBeenCalledWith({ + provider: "openai", + modelId: "demo", + requestTransportOverrides: "none", + configuredModel: { + api: "openai-responses", + baseUrl: "https://model-one.example.test/v1", + }, + configuredProvider: { + api: "openai-completions", + baseUrl: "https://provider-one.example.test/v1", + }, + env: { OPENAI_BASE_URL: "https://env-two.example.test/v1" }, + }); + expect(resolveModelRoutes.mock.calls[0]?.[0].env).toBe(env); + }); + + it("merges duplicate route facts only for the requested canonical model", () => { + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + })); + const config = { + models: { + providers: { + openai: { + baseUrl: "https://provider.example.test/v1", + models: [ + { id: "gpt-5.5" }, + { + id: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://model.example.test/v1", + }, + ], + }, + " openai ": { api: "openai-completions" }, + }, + }, + } as unknown as OpenClawConfig; + + resolveProviderModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + config, + env: {}, + surface: { resolveModelRoutes }, + }); + + expect(resolveModelRoutes).toHaveBeenCalledWith({ + provider: "openai", + modelId: "gpt-5.5", + requestTransportOverrides: "none", + configuredModel: { + api: "openai-responses", + baseUrl: "https://model.example.test/v1", + }, + configuredProvider: { + api: "openai-completions", + baseUrl: "https://provider.example.test/v1", + }, + env: {}, + }); + }); + + it("keeps case-distinct provider keys and unknown model ids separate", () => { + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + })); + const config = { + models: { + providers: { + OpenAI: { + baseUrl: "https://case-fallback.example.test/v1", + models: [{ id: "Foo", api: "openai-responses" }], + }, + openai: { + api: "openai-completions", + models: [{ id: "foo", api: "openai-chatgpt-responses" }], + }, + }, + }, + } as unknown as OpenClawConfig; + + resolveProviderModelRoutes({ + provider: "openai", + modelId: "Foo", + config, + env: {}, + surface: { resolveModelRoutes }, + }); + expect(resolveModelRoutes.mock.calls[0]?.[0]).toMatchObject({ + modelId: "Foo", + configuredProvider: { api: "openai-completions" }, + }); + expect(resolveModelRoutes.mock.calls[0]?.[0]).not.toHaveProperty("configuredModel"); + + resolveProviderModelRoutes({ + provider: "openai", + modelId: "foo", + config, + env: {}, + surface: { resolveModelRoutes }, + }); + expect(resolveModelRoutes.mock.calls[1]?.[0]).toMatchObject({ + modelId: "foo", + configuredModel: { api: "openai-chatgpt-responses" }, + }); + }); + + it.each([ + ["provider headers", { headers: { "x-route": "custom" } }, {}], + ["provider request", { request: { allowPrivateNetwork: true } }, {}], + ["provider local service", { localService: { command: "/custom-provider" } }, {}], + ["provider auth header", { authHeader: false }, {}], + ["provider request timeout", { timeoutSeconds: 90 }, {}], + ["model headers", {}, { headers: { "x-model-route": "custom" } }], + ["model compatibility", {}, { compat: { supportsStore: false } }], + ])("projects %s without exposing its value", (_label, providerPatch, modelPatch) => { + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + })); + const config = { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + ...providerPatch, + models: [{ id: "gpt-5.5", ...modelPatch }], + }, + }, + }, + } as unknown as OpenClawConfig; + + resolveProviderModelRoutes({ + provider: "openai", + modelId: "gpt-5.5", + config, + env: {}, + surface: { resolveModelRoutes }, + }); + + expect(resolveModelRoutes.mock.calls[0]?.[0]).toMatchObject({ + requestTransportOverrides: "present", + }); + }); + + it("returns null when the provider artifact has no route hook", () => { + expect( + resolveProviderModelRoutes({ provider: "fixture", modelId: "demo", surface: {} }), + ).toBeNull(); + }); +}); diff --git a/src/plugins/provider-model-routes.ts b/src/plugins/provider-model-routes.ts new file mode 100644 index 000000000000..dfde2186f6c2 --- /dev/null +++ b/src/plugins/provider-model-routes.ts @@ -0,0 +1,173 @@ +/** Generic adapter for provider-owned model route public artifacts. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { + resolveMergedModelProviderConfig, + resolveMergedModelProviderModels, + resolveModelProviderRouteOverridePresence, +} from "../config/model-provider-config.js"; +import type { ModelApi, ModelDefinitionConfig } from "../config/types.models.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { + ProviderModelRouteResolution, + ProviderModelRouteSource, + ProviderRouteOverridePresence, +} from "../plugin-sdk/provider-model-types.js"; +import { + resolveDirectBundledProviderPolicySurface, + type BundledProviderPolicySurface, +} from "./provider-policy-surface.js"; + +type ProviderModelRouteObservation = { + modelId?: string; + observedRoutes?: readonly ProviderModelRouteSource[]; +}; + +type ProviderModelRoutesResolver = ( + observed?: ProviderModelRouteObservation, +) => ProviderModelRouteResolution | null; + +/** Resolves provider-owned catalog id equivalence without loading its runtime. */ +export function resolveProviderModelCatalogId(params: { + provider: string; + modelId: string; + surface?: BundledProviderPolicySurface | null; +}): string | null { + const provider = normalizeProviderId(params.provider); + const surface = + params.surface === undefined + ? resolveDirectBundledProviderPolicySurface(provider) + : params.surface; + const normalized = surface?.normalizeModelCatalogId?.({ + provider, + modelId: params.modelId, + }); + return typeof normalized === "string" && normalized.trim() ? normalized.trim() : null; +} + +function normalizeModelId( + provider: string, + modelId: string | undefined, + surface?: BundledProviderPolicySurface | null, +): string | undefined { + const trimmed = modelId?.trim(); + if (!trimmed) { + return undefined; + } + const canonical = surface?.normalizeModelCatalogId?.({ provider, modelId: trimmed }); + return typeof canonical === "string" && canonical.trim() ? canonical.trim() : trimmed; +} + +function projectConfiguredModelRoute(model: ModelDefinitionConfig): ProviderModelRouteSource { + return { + ...(Object.hasOwn(model, "api") ? { api: model.api } : {}), + ...(Object.hasOwn(model, "baseUrl") ? { baseUrl: model.baseUrl } : {}), + }; +} + +/** Captures one provider artifact and config view for repeated row resolution. */ +export function createProviderModelRoutesResolver(params: { + provider: string; + config?: OpenClawConfig; + env?: Readonly>; + requestTransportOverrides?: ProviderRouteOverridePresence; + surface?: BundledProviderPolicySurface | null; +}): ProviderModelRoutesResolver { + const provider = normalizeProviderId(params.provider); + if (!provider) { + return () => null; + } + // Runtime selection is a hot path and currently has one canonical OpenAI + // owner. Alias/secondary-owner discovery remains on the cold artifact path. + const surface = + params.surface === undefined + ? resolveDirectBundledProviderPolicySurface(provider) + : params.surface; + const resolveModelRoutes = surface?.resolveModelRoutes; + const providerConfig = resolveMergedModelProviderConfig(params.config, provider); + const configuredProvider = providerConfig + ? { api: providerConfig.api, baseUrl: providerConfig.baseUrl } + : undefined; + const normalizeConfiguredModelId = (modelId: string) => + normalizeModelId(provider, modelId, surface); + const canonicalizeModelId = (modelId: string) => + normalizeConfiguredModelId(modelId) ?? modelId.trim(); + const configuredModels = new Map( + Array.from( + resolveMergedModelProviderModels({ + models: providerConfig?.models, + normalizeModelId: normalizeConfiguredModelId, + }), + ([modelId, model]) => [modelId, projectConfiguredModelRoute(model)] as const, + ), + ); + const providerRouteOverridePresence = + params.requestTransportOverrides === "present" + ? "present" + : resolveModelProviderRouteOverridePresence({ + provider, + config: params.config, + }); + const routeOverridePresenceByModel = new Map( + [...configuredModels.keys()].map( + (modelId) => + [ + modelId, + params.requestTransportOverrides === "present" + ? "present" + : resolveModelProviderRouteOverridePresence({ + provider, + modelId, + config: params.config, + canonicalizeModelId, + }), + ] as const, + ), + ); + const env = params.env ?? process.env; + + return (observed) => { + if (!resolveModelRoutes) { + return null; + } + const modelId = normalizeModelId(provider, observed?.modelId, surface); + const configuredModel = modelId ? configuredModels.get(modelId) : undefined; + const requestTransportOverrides = modelId + ? (routeOverridePresenceByModel.get(modelId) ?? providerRouteOverridePresence) + : providerRouteOverridePresence; + const observedRoutes = observed?.observedRoutes?.filter( + (route) => route.api != null || (route.baseUrl !== undefined && route.baseUrl !== null), + ); + return ( + resolveModelRoutes({ + provider, + ...(modelId ? { modelId } : {}), + requestTransportOverrides, + ...(configuredModel ? { configuredModel } : {}), + ...(configuredProvider ? { configuredProvider } : {}), + env, + ...(observedRoutes && observedRoutes.length > 0 ? { observedRoutes } : {}), + }) ?? null + ); + }; +} + +/** Resolves one model route through its bundled provider public artifact. */ +export function resolveProviderModelRoutes(params: { + provider: string; + modelId?: string; + api?: ModelApi | null; + baseUrl?: unknown; + config?: OpenClawConfig; + env?: Readonly>; + requestTransportOverrides?: ProviderRouteOverridePresence; + surface?: BundledProviderPolicySurface | null; +}): ProviderModelRouteResolution | null { + const resolveRoutes = createProviderModelRoutesResolver(params); + return resolveRoutes({ + modelId: params.modelId, + observedRoutes: + params.api != null || (params.baseUrl !== undefined && params.baseUrl !== null) + ? [{ api: params.api, baseUrl: params.baseUrl }] + : undefined, + }); +} diff --git a/src/plugins/provider-policy-surface.test.ts b/src/plugins/provider-policy-surface.test.ts new file mode 100644 index 000000000000..aa3e8c91ca99 --- /dev/null +++ b/src/plugins/provider-policy-surface.test.ts @@ -0,0 +1,40 @@ +import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("direct provider policy surface", () => { + afterEach(() => { + vi.doUnmock("./bundled-dir.js"); + vi.doUnmock("./manifest-registry.js"); + vi.doUnmock("./public-surface-loader.js"); + vi.resetModules(); + }); + + it("loads the provider-id artifact without evaluating the manifest registry", async () => { + const manifestRegistryModuleFactory = vi.fn(() => { + throw new Error("unexpected manifest registry import"); + }); + const resolveModelRoutes = vi.fn(); + const loadBundledPluginPublicArtifactModuleSync = vi.fn(() => ({ resolveModelRoutes })); + + vi.doMock("./bundled-dir.js", () => ({ + resolveBundledPluginsDir: () => "/tmp/bundled-plugins", + })); + vi.doMock("./manifest-registry.js", manifestRegistryModuleFactory); + vi.doMock("./public-surface-loader.js", () => ({ + loadBundledPluginPublicArtifactModuleSync, + })); + + const { resolveDirectBundledProviderPolicySurface } = await importFreshModule< + typeof import("./provider-policy-surface.js") + >(import.meta.url, "./provider-policy-surface.js?scope=direct-provider-policy"); + + const surface = resolveDirectBundledProviderPolicySurface("openai"); + + expect(surface?.resolveModelRoutes).toBe(resolveModelRoutes); + expect(loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith({ + dirName: "openai", + artifactBasename: "provider-policy-api.js", + }); + expect(manifestRegistryModuleFactory).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/provider-policy-surface.ts b/src/plugins/provider-policy-surface.ts new file mode 100644 index 000000000000..7b981a5e2796 --- /dev/null +++ b/src/plugins/provider-policy-surface.ts @@ -0,0 +1,86 @@ +/** Lightweight direct loader for bundled provider policy public artifacts. */ +import type { ModelProviderConfig } from "../config/types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { + ProviderModelRouteResolution, + ProviderNormalizeModelCatalogIdContext, + ProviderResolveModelRoutesContext, +} from "../plugin-sdk/provider-model-types.js"; +import { resolveBundledPluginsDir } from "./bundled-dir.js"; +import type { + ProviderApplyConfigDefaultsContext, + ProviderNormalizeConfigContext, + ProviderResolveConfigApiKeyContext, +} from "./provider-config-context.types.js"; +import type { + ProviderDefaultThinkingPolicyContext, + ProviderThinkingProfile, +} from "./provider-thinking.types.js"; +import { loadBundledPluginPublicArtifactModuleSync } from "./public-surface-loader.js"; + +const PROVIDER_POLICY_ARTIFACT_CANDIDATES = ["provider-policy-api.js"] as const; +const providerPolicySurfaceByPluginId = new Map(); + +/** Provider policy hooks loaded from bundled plugin public artifacts. */ +export type BundledProviderPolicySurface = { + normalizeConfig?: (ctx: ProviderNormalizeConfigContext) => ModelProviderConfig | null | undefined; + applyConfigDefaults?: ( + ctx: ProviderApplyConfigDefaultsContext, + ) => OpenClawConfig | null | undefined; + resolveConfigApiKey?: (ctx: ProviderResolveConfigApiKeyContext) => string | null | undefined; + resolveThinkingProfile?: ( + ctx: ProviderDefaultThinkingPolicyContext, + ) => ProviderThinkingProfile | null | undefined; + resolveModelRoutes?: ( + ctx: ProviderResolveModelRoutesContext, + ) => ProviderModelRouteResolution | null | undefined; + normalizeModelCatalogId?: ( + ctx: ProviderNormalizeModelCatalogIdContext, + ) => string | null | undefined; +}; + +function hasProviderPolicyHook( + mod: Record, +): mod is Record & BundledProviderPolicySurface { + return ( + typeof mod.normalizeConfig === "function" || + typeof mod.applyConfigDefaults === "function" || + typeof mod.resolveConfigApiKey === "function" || + typeof mod.resolveThinkingProfile === "function" || + typeof mod.resolveModelRoutes === "function" || + typeof mod.normalizeModelCatalogId === "function" + ); +} + +/** Loads policy hooks directly by canonical bundled plugin id. */ +export function resolveDirectBundledProviderPolicySurface( + pluginId: string, +): BundledProviderPolicySurface | null { + const cacheKey = `${resolveBundledPluginsDir() ?? ""}\0${pluginId}`; + const cached = providerPolicySurfaceByPluginId.get(cacheKey); + if (cached !== undefined) { + return cached; + } + for (const artifactBasename of PROVIDER_POLICY_ARTIFACT_CANDIDATES) { + try { + const mod = loadBundledPluginPublicArtifactModuleSync>({ + dirName: pluginId, + artifactBasename, + }); + if (hasProviderPolicyHook(mod)) { + providerPolicySurfaceByPluginId.set(cacheKey, mod); + return mod; + } + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Unable to resolve bundled plugin public surface ") + ) { + continue; + } + throw error; + } + } + providerPolicySurfaceByPluginId.set(cacheKey, null); + return null; +} diff --git a/src/plugins/provider-public-artifacts.test.ts b/src/plugins/provider-public-artifacts.test.ts index 58449a40c99a..b0bd36c76fbc 100644 --- a/src/plugins/provider-public-artifacts.test.ts +++ b/src/plugins/provider-public-artifacts.test.ts @@ -48,6 +48,26 @@ describe("provider public artifacts", () => { ?.resolveThinkingProfile?.({ provider: "openai", modelId: "gpt-5.5" }) ?.levels.map((level) => level.id), ).toContain("xhigh"); + expect(surface?.resolveModelRoutes?.({ provider: "openai", modelId: "gpt-5.5" })).toEqual({ + kind: "routes", + defaultRuntimeId: "codex", + routes: [ + { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authRequirement: "subscription", + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + ], + }); }); it("loads MiniMax thinking policy before runtime registration", () => { @@ -416,4 +436,39 @@ describe("provider public artifacts", () => { artifactBasename: "provider-policy-api.js", }); }); + + it("recognizes resolveModelRoutes as a standalone provider policy surface", async () => { + const resolveModelRoutes = vi.fn(() => ({ + kind: "routes" as const, + routes: [ + { + api: "openai-responses", + baseUrl: "https://fixture.example.test/v1", + authRequirement: "api-key" as const, + requestTransportOverrides: "none" as const, + }, + ] as const, + })); + const loadBundledPluginPublicArtifactModuleSync = vi.fn(() => ({ resolveModelRoutes })); + vi.doMock("./public-surface-loader.js", () => ({ + loadBundledPluginPublicArtifactModuleSync, + })); + + const { resolveBundledProviderPolicySurface: resolvePolicySurface } = await importFreshModule< + typeof import("./provider-public-artifacts.js") + >(import.meta.url, "./provider-public-artifacts.js?scope=model-routes-only"); + + const surface = resolvePolicySurface("openai"); + expect(surface?.resolveModelRoutes?.({ provider: "openai" })).toEqual({ + kind: "routes", + routes: [ + { + api: "openai-responses", + baseUrl: "https://fixture.example.test/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + ], + }); + }); }); diff --git a/src/plugins/provider-public-artifacts.ts b/src/plugins/provider-public-artifacts.ts index 7cccb49952b8..eac13dd010d3 100644 --- a/src/plugins/provider-public-artifacts.ts +++ b/src/plugins/provider-public-artifacts.ts @@ -1,77 +1,11 @@ // Extracts provider public artifacts from plugin metadata. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import type { ModelProviderConfig } from "../config/types.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; -import type { - ProviderApplyConfigDefaultsContext, - ProviderNormalizeConfigContext, - ProviderResolveConfigApiKeyContext, -} from "./provider-config-context.types.js"; -import type { - ProviderDefaultThinkingPolicyContext, - ProviderThinkingProfile, -} from "./provider-thinking.types.js"; -import { loadBundledPluginPublicArtifactModuleSync } from "./public-surface-loader.js"; - -const PROVIDER_POLICY_ARTIFACT_CANDIDATES = ["provider-policy-api.js"] as const; -const providerPolicySurfaceByPluginId = new Map(); - -/** Provider policy hooks loaded from bundled plugin public artifacts. */ -export type BundledProviderPolicySurface = { - normalizeConfig?: (ctx: ProviderNormalizeConfigContext) => ModelProviderConfig | null | undefined; - applyConfigDefaults?: ( - ctx: ProviderApplyConfigDefaultsContext, - ) => OpenClawConfig | null | undefined; - resolveConfigApiKey?: (ctx: ProviderResolveConfigApiKeyContext) => string | null | undefined; - resolveThinkingProfile?: ( - ctx: ProviderDefaultThinkingPolicyContext, - ) => ProviderThinkingProfile | null | undefined; -}; - -function hasProviderPolicyHook( - mod: Record, -): mod is Record & BundledProviderPolicySurface { - return ( - typeof mod.normalizeConfig === "function" || - typeof mod.applyConfigDefaults === "function" || - typeof mod.resolveConfigApiKey === "function" || - typeof mod.resolveThinkingProfile === "function" - ); -} - -function tryLoadBundledProviderPolicySurface( - pluginId: string, -): BundledProviderPolicySurface | null { - const cacheKey = `${resolveBundledPluginsDir() ?? ""}\0${pluginId}`; - const cached = providerPolicySurfaceByPluginId.get(cacheKey); - if (cached !== undefined) { - return cached; - } - for (const artifactBasename of PROVIDER_POLICY_ARTIFACT_CANDIDATES) { - try { - const mod = loadBundledPluginPublicArtifactModuleSync>({ - dirName: pluginId, - artifactBasename, - }); - if (hasProviderPolicyHook(mod)) { - providerPolicySurfaceByPluginId.set(cacheKey, mod); - return mod; - } - } catch (error) { - if ( - error instanceof Error && - error.message.startsWith("Unable to resolve bundled plugin public surface ") - ) { - continue; - } - throw error; - } - } - providerPolicySurfaceByPluginId.set(cacheKey, null); - return null; -} +import { + resolveDirectBundledProviderPolicySurface, + type BundledProviderPolicySurface, +} from "./provider-policy-surface.js"; function resolveBundledProviderPolicyPluginId( providerId: string, @@ -134,7 +68,7 @@ export function resolveBundledProviderPolicySurface( if (!normalizedProviderId) { return null; } - const directSurface = tryLoadBundledProviderPolicySurface(normalizedProviderId); + const directSurface = resolveDirectBundledProviderPolicySurface(normalizedProviderId); if (directSurface) { return directSurface; } @@ -142,5 +76,5 @@ export function resolveBundledProviderPolicySurface( if (!ownerPluginId || ownerPluginId === normalizedProviderId) { return null; } - return tryLoadBundledProviderPolicySurface(ownerPluginId); + return resolveDirectBundledProviderPolicySurface(ownerPluginId); } diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index a20c58afb77b..5eb1cde5804a 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -338,11 +338,20 @@ export function normalizeProviderResolvedModelWithPlugin(params: { model: ProviderRuntimeModel; }; }): ProviderRuntimeModel | undefined { + const context = { + ...params.context, + ...(params.context.config === undefined && params.config !== undefined + ? { config: params.config } + : {}), + ...(params.context.workspaceDir === undefined && params.workspaceDir !== undefined + ? { workspaceDir: params.workspaceDir } + : {}), + }; return ( resolveProviderRuntimePlugin({ ...params, modelId: params.context.modelId, - })?.normalizeResolvedModel?.(params.context) ?? undefined + })?.normalizeResolvedModel?.(context) ?? undefined ); } @@ -354,13 +363,17 @@ export function applyProviderResolvedTransportWithPlugin(params: { env?: NodeJS.ProcessEnv; context: ProviderNormalizeResolvedModelContext; }): ProviderRuntimeModel | undefined { + const config = params.context.config ?? params.config; + const workspaceDir = params.context.workspaceDir ?? params.workspaceDir; const normalized = normalizeProviderTransportWithPlugin({ provider: params.provider, - config: params.config, - workspaceDir: params.workspaceDir, + config, + workspaceDir, env: params.env, modelId: params.context.modelId, context: { + ...(config !== undefined ? { config } : {}), + ...(workspaceDir !== undefined ? { workspaceDir } : {}), provider: params.context.provider, modelId: params.context.modelId, api: params.context.model.api, @@ -410,8 +423,17 @@ export function normalizeProviderTransportWithPlugin(params: { const hasTransportChange = (normalized: { api?: string | null; baseUrl?: string }) => (normalized.api ?? params.context.api) !== params.context.api || (normalized.baseUrl ?? params.context.baseUrl) !== params.context.baseUrl; + const context = { + ...params.context, + ...(params.context.config === undefined && params.config !== undefined + ? { config: params.config } + : {}), + ...(params.context.workspaceDir === undefined && params.workspaceDir !== undefined + ? { workspaceDir: params.workspaceDir } + : {}), + }; const matchedPlugin = resolveProviderHookPlugin(params); - const normalizedMatched = matchedPlugin?.normalizeTransport?.(params.context); + const normalizedMatched = matchedPlugin?.normalizeTransport?.(context); if (normalizedMatched && hasTransportChange(normalizedMatched)) { return normalizedMatched; } @@ -423,7 +445,7 @@ export function normalizeProviderTransportWithPlugin(params: { if (!candidate.normalizeTransport || candidate === matchedPlugin) { continue; } - const normalized = candidate.normalizeTransport(params.context); + const normalized = candidate.normalizeTransport(context); if (normalized && hasTransportChange(normalized)) { return normalized; } diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 09c6009e29b8..e80653f1f1d9 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -2,6 +2,10 @@ import fs from "node:fs/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js"; +import type { + DefaultModelAuthStatus, + DefaultModelCatalogFacts, +} from "../commands/auth-choice.model-check.js"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -29,7 +33,18 @@ const resolveLocalControlUiProbeLinks = vi.hoisted(() => const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {})); const healthCommand = vi.hoisted(() => vi.fn(async () => {})); const resolveDefaultModelAuthStatus = vi.hoisted(() => - vi.fn(() => ({ provider: "anthropic", model: "claude-opus-4-8", hasAuth: true })), + vi.fn<() => DefaultModelAuthStatus>(() => ({ + provider: "anthropic", + model: "claude-opus-4-8", + status: "ready", + hasAuth: true, + })), +); +const resolveDefaultModelCatalogFacts = vi.hoisted(() => + vi.fn<() => DefaultModelCatalogFacts>(() => ({ found: true })), +); +const loadModelCatalog = vi.hoisted(() => + vi.fn<(_params?: unknown) => Promise>(async () => []), ); const buildGatewayInstallPlan = vi.hoisted(() => vi.fn(async (_params?: { warn?: (message: string, title?: string) => void }) => ({ @@ -212,11 +227,19 @@ vi.mock("../tui/tui-launch.js", () => ({ vi.mock("../commands/auth-choice.js", () => ({ applyAuthChoice: vi.fn(), + resolveDefaultModelCatalogFacts, resolveDefaultModelAuthStatus, resolvePreferredProviderForAuthChoice: vi.fn(), warnIfModelConfigLooksOff: vi.fn(), })); +vi.mock("../agents/model-catalog.js", () => ({ + loadModelCatalogSnapshot: async (...args: unknown[]) => { + const entries = await loadModelCatalog(...args); + return { entries, routeVariants: entries }; + }, +})); + vi.mock("./setup.secret-input.js", () => ({ resolveSetupSecretInputString, })); @@ -275,6 +298,35 @@ type AdvancedFinalizeArgs = { installDaemon?: boolean; }; +function createModelAuthFinalizeArgs(params: { + prompter: ReturnType; + nextConfig?: OpenClawConfig; +}) { + return { + flow: "quickstart" as const, + opts: { + acceptRisk: true, + authChoice: "skip" as const, + installDaemon: false, + skipHealth: true, + skipUi: false, + }, + baseConfig: {}, + nextConfig: params.nextConfig ?? {}, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback" as const, + authMode: "token" as const, + gatewayToken: undefined, + tailscaleMode: "off" as const, + tailscaleResetOnExit: false, + }, + prompter: params.prompter, + runtime: createRuntime(), + }; +} + function createLaterPrompter() { return buildWizardPrompter({ select: vi.fn(async () => "later") as never, @@ -346,6 +398,14 @@ function expectNoteTitleNotCalled( expect(calls.filter((call) => call[1] === title)).toEqual([]); } +function expectNoteNotContains( + prompter: ReturnType, + unexpected: string, +): void { + const calls = vi.mocked(prompter.note).mock.calls; + expect(calls.filter((call) => call[0].includes(unexpected))).toEqual([]); +} + async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!; Object.defineProperty(process, "platform", { @@ -416,6 +476,17 @@ describe("finalizeSetupWizard", () => { message: "Windows LAN firewall diagnostics do not apply.", details: [], }); + resolveDefaultModelAuthStatus.mockReset(); + resolveDefaultModelAuthStatus.mockReturnValue({ + provider: "anthropic", + model: "claude-opus-4-8", + status: "ready", + hasAuth: true, + }); + resolveDefaultModelCatalogFacts.mockReset(); + resolveDefaultModelCatalogFacts.mockReturnValue({ found: true }); + loadModelCatalog.mockReset(); + loadModelCatalog.mockResolvedValue([]); }); it("resolves gateway password SecretRef for probe but omits auth from TUI hatch", async () => { @@ -623,44 +694,68 @@ describe("finalizeSetupWizard", () => { ); }); + it("passes physical catalog routes into the bootstrap auth decision", async () => { + vi.spyOn(fs, "access").mockResolvedValueOnce(undefined); + const catalog = [ + { + id: "gpt-5.4-nano", + name: "GPT 5.4 Nano", + provider: "openai", + }, + ]; + const observedRoutes = [ + { + api: "openai-chatgpt-responses" as const, + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + { api: "openai-responses" as const, baseUrl: "https://api.openai.com/v1" }, + ]; + loadModelCatalog.mockResolvedValueOnce(catalog); + resolveDefaultModelCatalogFacts.mockReturnValueOnce({ found: true, observedRoutes }); + const prompter = buildWizardPrompter({ + confirm: vi.fn(async () => false), + }); + const nextConfig = { + agents: { + defaults: { model: "openai/gpt-5.4-nano" }, + list: [{ id: "main", agentDir: "/tmp/custom-agent" }], + }, + } satisfies OpenClawConfig; + + await finalizeSetupWizard(createModelAuthFinalizeArgs({ prompter, nextConfig })); + + expect(loadModelCatalog).toHaveBeenCalledWith({ config: nextConfig, readOnly: true }); + expect(resolveDefaultModelCatalogFacts).toHaveBeenCalledWith(nextConfig, catalog, { + routeVariants: catalog, + }); + expect(resolveDefaultModelAuthStatus).toHaveBeenCalledWith(nextConfig, { + agentDir: "/tmp/custom-agent", + observedRoutes, + }); + }); + it("skips the doomed hatch seed message and warns when model auth is missing", async () => { vi.spyOn(fs, "access").mockResolvedValueOnce(undefined); resolveDefaultModelAuthStatus.mockReturnValueOnce({ provider: "openai", model: "gpt-5.5", + status: "missing", hasAuth: false, }); const prompter = buildWizardPrompter({ confirm: vi.fn(async () => false), }); - await finalizeSetupWizard({ - flow: "quickstart", - opts: { - acceptRisk: true, - authChoice: "skip", - installDaemon: false, - skipHealth: true, - skipUi: false, - }, - baseConfig: {}, - nextConfig: { - agents: { - list: [{ id: "main", agentDir: "/tmp/custom-agent" }], + await finalizeSetupWizard( + createModelAuthFinalizeArgs({ + prompter, + nextConfig: { + agents: { + list: [{ id: "main", agentDir: "/tmp/custom-agent" }], + }, }, - }, - workspaceDir: "/tmp", - settings: { - port: 18789, - bind: "loopback", - authMode: "token", - gatewayToken: undefined, - tailscaleMode: "off", - tailscaleResetOnExit: false, - }, - prompter, - runtime: createRuntime(), - }); + }), + ); expect(launchTuiCli).toHaveBeenCalledWith(expect.objectContaining({ message: undefined }), {}); expect(resolveDefaultModelAuthStatus).toHaveBeenCalledWith( @@ -678,6 +773,48 @@ describe("finalizeSetupWizard", () => { ); }); + it("hatches without a seed and omits setup advice for indeterminate model auth", async () => { + vi.spyOn(fs, "access").mockResolvedValueOnce(undefined); + resolveDefaultModelAuthStatus.mockReturnValueOnce({ + provider: "openai", + model: "gpt-5.5", + status: "indeterminate", + hasAuth: false, + }); + const prompter = buildWizardPrompter({ + confirm: vi.fn(async () => false), + }); + + await finalizeSetupWizard(createModelAuthFinalizeArgs({ prompter })); + + expect(launchTuiCli).toHaveBeenCalledWith(expect.objectContaining({ message: undefined }), {}); + expectNoteTitleNotCalled(prompter, "Model auth missing"); + expectNoteNotContains(prompter, "No credentials are configured"); + expectNoteNotContains(prompter, "openclaw configure --section model"); + }); + + it("hatches without a seed and omits setup advice for an incompatible model route", async () => { + vi.spyOn(fs, "access").mockResolvedValueOnce(undefined); + resolveDefaultModelAuthStatus.mockReturnValueOnce({ + provider: "openai", + model: "gpt-5.6", + status: "incompatible", + hasAuth: false, + code: "auth_mode_unsupported", + message: "gpt-5.6 requires OpenAI Platform API-key authentication.", + }); + const prompter = buildWizardPrompter({ + confirm: vi.fn(async () => false), + }); + + await finalizeSetupWizard(createModelAuthFinalizeArgs({ prompter })); + + expect(launchTuiCli).toHaveBeenCalledWith(expect.objectContaining({ message: undefined }), {}); + expectNoteTitleNotCalled(prompter, "Model auth missing"); + expectNoteNotContains(prompter, "No credentials are configured"); + expectNoteNotContains(prompter, "openclaw configure --section model"); + }); + it("does not resend the bootstrap hatch message on setup reruns", async () => { vi.spyOn(fs, "access").mockResolvedValueOnce(undefined); const prompter = buildWizardPrompter({ diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index f52eba67b87c..3862777b393d 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -608,12 +608,27 @@ export async function finalizeSetupWizard( .then(() => true) .catch(() => false); const agentDir = resolveDefaultAgentDir(nextConfig); - // Without model credentials the seeded first message is guaranteed to fail - // with a provider auth error, so hatch quietly and explain instead. - const { resolveDefaultModelAuthStatus } = await import("../commands/auth-choice.js"); - const modelAuthStatus = resolveDefaultModelAuthStatus(nextConfig, { agentDir }); + // Seed only when the selected route is proven ready. Unknown or incompatible + // route facts must not turn the onboarding greeting into a guaranteed failure. + const [ + { resolveDefaultModelAuthStatus, resolveDefaultModelCatalogFacts }, + { loadModelCatalogSnapshot }, + ] = await Promise.all([ + import("../commands/auth-choice.js"), + import("../agents/model-catalog.js"), + ]); + const modelCatalog = await loadModelCatalogSnapshot({ config: nextConfig, readOnly: true }); + const modelCatalogFacts = resolveDefaultModelCatalogFacts(nextConfig, modelCatalog.entries, { + routeVariants: modelCatalog.routeVariants, + }); + const modelAuthStatus = resolveDefaultModelAuthStatus(nextConfig, { + agentDir, + ...(modelCatalogFacts.observedRoutes + ? { observedRoutes: modelCatalogFacts.observedRoutes } + : {}), + }); const shouldSeedBootstrapHatch = - hasBootstrap && options.hadExistingConfig !== true && modelAuthStatus.hasAuth; + hasBootstrap && options.hadExistingConfig !== true && modelAuthStatus.status === "ready"; await prompter.note( [ @@ -647,7 +662,7 @@ export async function finalizeSetupWizard( t("wizard.finalize.hatchYourAgent"), ); } - if (!modelAuthStatus.hasAuth) { + if (modelAuthStatus.status === "missing") { await prompter.note( [ t("wizard.finalize.noModelAuth", { provider: modelAuthStatus.provider }), diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index 86e5a63febcb..0f95cbb20b77 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -180,7 +180,7 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => { compact: true, }); - expect(compact).toHaveLength(19); + expect(compact).toHaveLength(20); expect(compact.every((shard) => Array.isArray(shard.groups))).toBe(true); expect(compact.some((shard) => shard.requiresDist)).toBe(true); expect( @@ -233,7 +233,7 @@ describe("scripts/lib/ci-node-test-plan.mjs", () => { ); expect( largeJobs.map((shard) => shard.groups.filter((group) => !group.includePatterns).length), - ).toEqual([2, 2, 2]); + ).toEqual([2, 2, 1, 1]); expect( compact.some((shard) => shard.checkName.startsWith("checks-node-compact-large-whole-")), ).toBe(false);