mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(openai): align auth availability with effective routes (#104685)
* feat(openai): add provider-owned route facts * fix(openai): harden provider route facts * test(codex): update rebased auth fixtures * chore: leave release notes to release workflow * fix(openai): align route auth with current contracts * test(openai): align route and shard expectations * test(openai): satisfy route fixture contracts * fix(openai): preserve direct profile forwarding * test(models): complete route auth mocks * test(codex): type compaction factory mock * fix(openai): preserve provider-native model ids * test(agents): align route auth fixtures * style(agents): format route integrations * test(plugin-sdk): pin current surface counts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -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 <id>` 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 <id>` 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.
|
||||
|
||||
@@ -27,15 +27,17 @@ Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram)
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="OpenAI provider/runtime split">
|
||||
OpenAI-family routes are prefix-specific:
|
||||
OpenAI model refs and agent runtimes are separate:
|
||||
|
||||
- `openai/<model>` uses the native Codex app-server harness for agent turns by default. This is the usual ChatGPT/Codex subscription setup.
|
||||
- `openai/<model>` 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/<model>`.
|
||||
- `openai/<model>` 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/<model>` 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/<model>` 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/<model>"].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/<model>"].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
|
||||
|
||||
+15
-1
@@ -23,7 +23,21 @@ sidebarTitle: "Models CLI"
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<provider>.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.<provider>.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.<provider>.models[]`), then `agents.list[]` / `agents.defaults.models["provider/*"]`, then provider-wide policy at `models.providers.<provider>.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/<model>` 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.
|
||||
|
||||
|
||||
@@ -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:<channel-id>`.
|
||||
- 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.
|
||||
</Accordion>
|
||||
<Accordion title="2e. Codex OAuth provider overrides">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="2f. Codex route repair">
|
||||
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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+75
-42
@@ -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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## OpenClaw feature coverage
|
||||
@@ -124,7 +145,7 @@ were not set by explicit runtime config.
|
||||
| Chat / Responses | `openai/<model>` model provider | Yes |
|
||||
| Codex subscription models | `openai/<model>` with OpenAI OAuth | Yes |
|
||||
| Legacy Codex model refs | old Codex model refs, `codex-cli/<model>` | Repaired by doctor to `openai/<model>` |
|
||||
| Codex app-server harness | `openai/<model>` 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 |
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
### 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.
|
||||
</Step>
|
||||
<Step title="Verify Codex auth is available">
|
||||
@@ -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 |
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Transport (WebSocket vs SSE)">
|
||||
OpenClaw uses WebSocket-first with SSE fallback (`"auto"`) for `openai/*`.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<CodexAppServerClientFactory>(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:
|
||||
| {
|
||||
|
||||
@@ -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<StartCodexAttemptThreadResult> {
|
||||
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<void>) | 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);
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
|
||||
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<typeof snapshot>,
|
||||
},
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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<CodexAppServerStartOptions> {
|
||||
@@ -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<CodexAppServerPreparedAuth, { kind: "api-key" }>
|
||||
| (Extract<CodexAppServerPreparedAuth, { kind: "profile" }> & {
|
||||
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<CodexAppServerPreparedAuthProfileSnapshot | undefined> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string | undefined> = [];
|
||||
const seenAgentDirs: Array<string | undefined> = [];
|
||||
const seenClientOptions: Array<NonNullable<Parameters<CodexTestAppServerClientFactory>[4]>> = [];
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const notificationHandlers = new Set<(notification: unknown) => Promise<void> | 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> | 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> | 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CodexAppServerAuthProfileLookup } from "./session-binding.js";
|
||||
|
||||
type ClientRuntimeContext = Omit<CodexAppServerAuthProfileLookup, "agentDir"> & {
|
||||
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,
|
||||
|
||||
@@ -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<CodexAppServerClientFactory>(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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, { token?: string }>;
|
||||
};
|
||||
}) => {
|
||||
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);
|
||||
|
||||
@@ -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<typeof resolveCodexAppServerAuthProfileIdForAgent>[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<void>();
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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<typeof import("./auth-bridge.js")>()),
|
||||
refreshCodexAppServerAuthTokens: (...args: unknown[]) =>
|
||||
refreshCodexAppServerAuthTokensMock(...args),
|
||||
}));
|
||||
@@ -364,6 +365,12 @@ function nativeCommandItem(
|
||||
}
|
||||
|
||||
function sideParams(overrides: Partial<Parameters<typeof runCodexAppServerSideQuestion>[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<Parameters<typeof runCodexAppServerSideQu
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session-1.jsonl",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
authProfileId: "openai:work",
|
||||
authProfileIdSource: "user",
|
||||
authProfileId,
|
||||
authProfileIdSource,
|
||||
preparedRuntimeAuth: {
|
||||
plan: {
|
||||
providerForAuth: "openai",
|
||||
authProfileProviderForAuth: "openai",
|
||||
forwardedAuthProfileId: authProfileId,
|
||||
forwardedAuthProfileSource: authProfileId ? authProfileIdSource : undefined,
|
||||
forwardedAuthProfileCandidateIds: authProfileId ? [authProfileId] : undefined,
|
||||
selectedAuthMode: authProfileId ? "token" : undefined,
|
||||
modelRoute: {
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.5",
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
authRequirement: "subscription",
|
||||
requestTransportOverrides: "none",
|
||||
},
|
||||
},
|
||||
authProfileStore: {
|
||||
version: 1,
|
||||
profiles: authProfileId
|
||||
? {
|
||||
[authProfileId]: {
|
||||
type: "token",
|
||||
provider: "openai",
|
||||
token: "test-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
},
|
||||
authStorage: {} as never,
|
||||
modelRegistry: {} as never,
|
||||
},
|
||||
...overrides,
|
||||
} satisfies Parameters<typeof runCodexAppServerSideQuestion>[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<typeof runCodexAppServerSideQuestion>[0]["preparedRuntimeAuth"];
|
||||
}
|
||||
|
||||
async function runSideQuestionWithManagedWebSearchCall(
|
||||
params: Parameters<typeof runCodexAppServerSideQuestion>[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<string, unknown> | 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 () => {
|
||||
|
||||
@@ -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<string, unknown> }) => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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<string, string | undefined> = 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 {
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string>(OPENAI_DUAL_ROUTE_MODEL_IDS);
|
||||
const openAIPlatformOnlyRouteModelIds = new Set<string>(OPENAI_PLATFORM_ONLY_ROUTE_MODEL_IDS);
|
||||
const openAISubscriptionOnlyRouteModelIds = new Set<string>(
|
||||
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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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?.({
|
||||
|
||||
@@ -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<string, ModelProviderConfig | undefined> } };
|
||||
}): 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<string, ModelProviderConfig | undefined> } };
|
||||
}):
|
||||
| { 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<string, ModelProviderConfig | undefined> } };
|
||||
}): { 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<string, ModelProviderConfig | undefined> } };
|
||||
},
|
||||
): 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,
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ProviderResolveModelRoutesContext, "env">,
|
||||
): 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<ProviderModelRouteResolution, { kind: "incompatible" }> {
|
||||
return { kind: "incompatible", code: "ambiguous-openai-route-group", message };
|
||||
}
|
||||
|
||||
function resolveAuthoredObservedFallback(observedRoutes: readonly ProviderModelRouteSource[]):
|
||||
| { kind: "observed"; route?: ProviderModelRouteSource }
|
||||
| {
|
||||
kind: "incompatible";
|
||||
resolution: Extract<ProviderModelRouteResolution, { kind: "incompatible" }>;
|
||||
} {
|
||||
const platformApis = new Set<ModelApi>();
|
||||
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<string, ProviderModelRouteCandidate>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,11 @@ type DiscoverModelsOptions = {
|
||||
};
|
||||
|
||||
/** Applies plugin model normalization and transport hooks to discovered agent models. */
|
||||
export function normalizeDiscoveredAgentModel<T>(value: T, agentDir: string): T {
|
||||
export function normalizeDiscoveredAgentModel<T>(
|
||||
value: T,
|
||||
agentDir: string,
|
||||
options?: Pick<DiscoverModelsOptions, "config" | "workspaceDir">,
|
||||
): T {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
@@ -51,10 +55,15 @@ export function normalizeDiscoveredAgentModel<T>(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<T>(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<string, Model | undefined>();
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: <T>(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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+839
-209
File diff suppressed because it is too large
Load Diff
+449
-91
@@ -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<typeof prepareAgentRuntimeAuth>;
|
||||
|
||||
type BtwRuntimeModelMaterialization = {
|
||||
cfg: OpenClawConfig;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
agentDir: string;
|
||||
workspaceDir?: string;
|
||||
authStorage: ReturnType<typeof discoverAuthStorage>;
|
||||
modelRegistry: ReturnType<typeof discoverModels>;
|
||||
};
|
||||
|
||||
async function materializeBtwRuntimeModel(
|
||||
params: BtwRuntimeModelMaterialization & {
|
||||
plan: AgentRuntimeAuthPlan;
|
||||
model: Model;
|
||||
forceResolve?: boolean;
|
||||
},
|
||||
): Promise<Model> {
|
||||
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<typeof discoverAuthStorage>;
|
||||
modelRegistry: ReturnType<typeof discoverModels>;
|
||||
}> {
|
||||
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<string, AgentHarness>();
|
||||
const prepareHarness = async (provider: string, modelId: string): Promise<AgentHarness> => {
|
||||
const prepareHarness = async (
|
||||
provider: string,
|
||||
modelId: string,
|
||||
modelProvider?: AgentHarnessPreparedModelProvider,
|
||||
): Promise<AgentHarness> => {
|
||||
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<ReturnType<typeof resolveRuntimeModel>>;
|
||||
resolvedAttempt: Awaited<ReturnType<typeof resolveBtwPreparedRuntimeAuth>>;
|
||||
};
|
||||
let preparedOpenClawFallback:
|
||||
| Extract<BtwHarnessSideQuestionDispatch, { kind: "openclaw" }>
|
||||
| undefined;
|
||||
const runHarnessSideQuestion = async (
|
||||
selectedHarness: AgentHarness,
|
||||
runtime: Awaited<ReturnType<typeof resolveRuntimeModel>>,
|
||||
): Promise<ReplyPayload | undefined> => {
|
||||
if (!selectedHarness.runSideQuestion) {
|
||||
throw new Error(
|
||||
`Selected agent harness "${selectedHarness.id}" does not support /btw side questions.`,
|
||||
);
|
||||
}
|
||||
routeFinalized = false,
|
||||
): Promise<BtwHarnessSideQuestionDispatch> => {
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown>) => ({
|
||||
result: { ok: true, compacted: false, reason: "no-op" },
|
||||
provider: params.provider,
|
||||
|
||||
@@ -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<string, never>;
|
||||
@@ -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<MockMemorySearchManager>
|
||||
> = 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<typeof import("../harness/selection.js")>("../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<typeof import("../../tasks/detached-task-runtime.js")>(
|
||||
"../../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", () => ({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<void>
|
||||
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<EmbeddedAgentCompactResult> {
|
||||
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<typeof selectAgentHarness>) =>
|
||||
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<ProviderRuntimeModel>({
|
||||
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,
|
||||
|
||||
@@ -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<ReturnType<typeof getApiKeyForModel>> | 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<typeof selectAgentHarness>) =>
|
||||
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<typeof materializePreparedRuntimeModel<ProviderRuntimeModel>>[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<ProviderRuntimeModel> =>
|
||||
(await materializePreparedRuntimeModel<ProviderRuntimeModel>({
|
||||
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<ReturnType<typeof resolveRuntimeAuthAttempt>>;
|
||||
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" }
|
||||
: {}),
|
||||
})
|
||||
|
||||
@@ -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<ExecToolDefaults, "host" | "security" | "ask" | "node">;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> | 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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ProviderRuntimeModel | undefined> | 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 } : {}),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
};
|
||||
modelRegistry: Record<string, never>;
|
||||
};
|
||||
|
||||
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<AgentRuntimePlan, "auth"> & {
|
||||
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<MockResolveModelResult> => ({
|
||||
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<string, { api?: string; baseUrl?: string }> };
|
||||
}
|
||||
)?.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<ResolvedProviderAuth>
|
||||
>(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<typeof import("../auth-profiles/order.js")>(
|
||||
"../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,
|
||||
}));
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 () => {
|
||||
|
||||
@@ -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<AgentHarness["runAttempt"]>(async () =>
|
||||
makeAttemptResult({
|
||||
timedOut: true,
|
||||
lastAssistant: {
|
||||
usage: { input: 150_000 },
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
const nativeCompact = vi.fn<NonNullable<AgentHarness["compact"]>>(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");
|
||||
});
|
||||
|
||||
@@ -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<boolean> => {
|
||||
const advancePluginHarnessAuthAttempt = async (): Promise<boolean> => {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<T>() {
|
||||
// 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["usageStats"]>,
|
||||
): 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();
|
||||
|
||||
|
||||
@@ -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<string | undefined>;
|
||||
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<ReturnType<NonNullable<typeof params.prepareModelForAuthProfile>>>
|
||||
| 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<void> => {
|
||||
const apiKeyInfo = await resolveApiKeyForCandidate(candidate);
|
||||
const applyApiKeyInfo = async (candidate?: string, attemptIndex?: number): Promise<void> => {
|
||||
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,
|
||||
|
||||
@@ -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<Parameters<typeof materializePreparedRuntimeModel<Model>>[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<Model>({
|
||||
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<Model, HarnessCompactionResolvedAuth>({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): 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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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 =
|
||||
|
||||
@@ -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<AgentHarness["runAttempt"]>(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<typeof import("../model-auth.js")>()),
|
||||
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<typeof maybeCompactAgentHarnessSession>[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> = {},
|
||||
): 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<NonNullable<AgentHarness["compact"]>>(
|
||||
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<AgentHarness["supports"]>[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<AgentHarness["supports"]>[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<AgentHarness["supports"]>[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<AgentHarness["supports"]>[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<AgentHarness["supports"]>[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<AgentHarness["supports"]>[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<AgentHarness["supports"]>[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<NonNullable<AgentHarness["compact"]>>(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",
|
||||
}),
|
||||
);
|
||||
|
||||
+176
-53
@@ -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<AgentHarnessSelectionParams, "modelProvider"> & {
|
||||
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)) {
|
||||
|
||||
+191
-18
@@ -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<AgentHarnessSupportContext["modelProvider"]>,
|
||||
"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);
|
||||
}
|
||||
|
||||
@@ -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<import("openclaw/plugin-sdk/llm").Api>;
|
||||
/** One atomic route/profile/store snapshot prepared before native dispatch. */
|
||||
preparedRuntimeAuth: AgentHarnessSideQuestionPreparedRuntimeAuth;
|
||||
question: string;
|
||||
sessionEntry: import("../../config/sessions.js").SessionEntry;
|
||||
sessionStore?: Record<string, import("../../config/sessions.js").SessionEntry>;
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,9 +41,13 @@ async function runProviderDynamicModelDefault(
|
||||
return runProviderDynamicModel(params);
|
||||
}
|
||||
|
||||
async function normalizeDynamicModelDefault(model: Model, agentDir: string): Promise<Model> {
|
||||
async function normalizeDynamicModelDefault(
|
||||
model: Model,
|
||||
agentDir: string,
|
||||
options: { config?: OpenClawConfig; workspaceDir?: string },
|
||||
): Promise<Model> {
|
||||
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<string>();
|
||||
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.
|
||||
|
||||
@@ -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<string, unknown> = {},
|
||||
order?: AuthProfileStore["order"],
|
||||
): AuthProfileStore {
|
||||
return {
|
||||
version: 1,
|
||||
profiles: profiles as AuthProfileStore["profiles"],
|
||||
...(order ? { order } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function evaluate(params: {
|
||||
cfg?: OpenClawConfig | Record<string, unknown>;
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, string>>;
|
||||
candidateMap?: Readonly<Record<string, readonly string[]>>;
|
||||
authEvidenceMap?: Readonly<Record<string, readonly ProviderAuthEvidence[]>>;
|
||||
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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
+108
-44
@@ -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<boolean> {
|
||||
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<ResolvedProviderAuth> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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<ModelCatalogEntry[]>(() => {}));
|
||||
const loadCatalog = vi.fn(() => new Promise<ModelCatalogSnapshot>(() => {}));
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<T>(params: {
|
||||
cfg: OpenClawConfig;
|
||||
view?: ModelCatalogBrowseView;
|
||||
loadCatalog: (params: { readOnly: boolean }) => Promise<ModelCatalogEntry[]>;
|
||||
loadCatalog: (params: { readOnly: boolean }) => Promise<T>;
|
||||
empty: T;
|
||||
timeoutMs?: number;
|
||||
onTimeout?: (timeoutMs: number) => void;
|
||||
}): Promise<ModelCatalogEntry[]> {
|
||||
}): Promise<T> {
|
||||
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<typeof timedOut>((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<ModelCatalogSnapshot>;
|
||||
timeoutMs?: number;
|
||||
onTimeout?: (timeoutMs: number) => void;
|
||||
}): Promise<ModelCatalogSnapshot> {
|
||||
return loadCatalogForBrowse({ ...params, empty: { entries: [], routeVariants: [] } });
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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<ModelCatalogEntry, "provider" | "id">,
|
||||
): ModelCatalogLogicalIdentity | null;
|
||||
matchesRoute: ModelCatalogRouteMatcher;
|
||||
};
|
||||
|
||||
export type ModelCatalogRouteProjection =
|
||||
| { kind: "unmanaged" }
|
||||
| { kind: "unresolved"; policy: ModelCatalogRoutePolicy }
|
||||
| {
|
||||
kind: "selected";
|
||||
route: ProviderModelRouteCandidate;
|
||||
policy: ModelCatalogRoutePolicy;
|
||||
};
|
||||
|
||||
export type ModelCatalogLogicalOverrides = Partial<
|
||||
Pick<ModelCatalogEntry, "name" | "contextWindow" | "contextTokens" | "reasoning" | "input">
|
||||
>;
|
||||
|
||||
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<ModelCatalogEntry, "provider" | "id">;
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,12 @@ type AgentModelCatalogDatabase = Pick<OpenClawStateKyselyDatabase, "agent_model_
|
||||
type CachedAgentModelCatalogPayload = {
|
||||
version: typeof AGENT_MODEL_CATALOG_CACHE_VERSION;
|
||||
entries: readonly unknown[];
|
||||
routeVariants?: readonly unknown[];
|
||||
};
|
||||
|
||||
export type CachedAgentModelCatalogSnapshot = {
|
||||
entries: unknown[];
|
||||
routeVariants: unknown[];
|
||||
};
|
||||
|
||||
type AgentModelCatalogCacheKeyInput = {
|
||||
@@ -39,6 +45,7 @@ type WriteCachedAgentModelCatalogParams = {
|
||||
agentDir: string;
|
||||
catalogKey: string;
|
||||
entries: readonly unknown[];
|
||||
routeVariants?: readonly unknown[];
|
||||
nowMs?: number;
|
||||
};
|
||||
|
||||
@@ -93,17 +100,17 @@ export function buildAgentModelCatalogCacheKey(input: AgentModelCatalogCacheKeyI
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
function parseCachedAgentModelCatalog(rawJson: string): unknown[] | undefined {
|
||||
function parseCachedAgentModelCatalog(rawJson: string): CachedAgentModelCatalogPayload | undefined {
|
||||
const parsed = JSON.parse(rawJson) as CachedAgentModelCatalogPayload;
|
||||
if (parsed?.version !== AGENT_MODEL_CATALOG_CACHE_VERSION || !Array.isArray(parsed.entries)) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.entries;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function readCachedAgentModelCatalog(
|
||||
function readCachedAgentModelCatalogPayload(
|
||||
params: ReadCachedAgentModelCatalogParams,
|
||||
): unknown[] | undefined {
|
||||
): CachedAgentModelCatalogPayload | undefined {
|
||||
try {
|
||||
const database = openOpenClawStateDatabase();
|
||||
const db = getNodeSqliteKysely<AgentModelCatalogDatabase>(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<AgentModelCatalogDatabase>(database.db);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<boolean>;
|
||||
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<boolean>): value is Promise<boolean> {
|
||||
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<boolean> {
|
||||
const result =
|
||||
modelApi === undefined
|
||||
? providerAuthChecker(provider)
|
||||
: providerAuthChecker(provider, modelApi);
|
||||
return isPromiseLike(result) ? await result : result;
|
||||
ref?: ModelAuthAvailabilityRef,
|
||||
) => boolean | Promise<boolean>;
|
||||
type ModelCatalogEntryAuthChecker = (entry: ModelCatalogEntry) => boolean | Promise<boolean>;
|
||||
|
||||
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<boolean> {
|
||||
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<ModelCatalogEntry, "provider" | "id">,
|
||||
routePolicy: ModelCatalogRoutePolicy,
|
||||
): string {
|
||||
return routePolicy.resolveIdentity(entry)?.key ?? modelCatalogLogicalKey(entry);
|
||||
}
|
||||
|
||||
function dedupeLogicalModelCatalogEntries(
|
||||
entries: readonly ModelCatalogEntry[],
|
||||
routePolicy: ModelCatalogRoutePolicy,
|
||||
) {
|
||||
const seen = new Set<string>();
|
||||
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<ModelCatalogEntry[]> {
|
||||
providerAuthChecker?: ModelCatalogAuthChecker;
|
||||
entryAuthChecker?: ModelCatalogEntryAuthChecker;
|
||||
};
|
||||
|
||||
async function resolveVisibleModelCatalogWithPolicy(
|
||||
params: ResolveVisibleModelCatalogParams,
|
||||
policy: ModelVisibilityPolicy,
|
||||
): Promise<ModelCatalogEntry[]> {
|
||||
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<LogicalModelCatalogEntryState>;
|
||||
}): Promise<ModelCatalogEntry[]> {
|
||||
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<string, ModelCatalogEntry[]>();
|
||||
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<string, Promise<LogicalModelCatalogEntryState>>();
|
||||
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<ModelCatalogEntry>();
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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<typeof vi.fn<(...args: unknown[])
|
||||
let readFileMock: ReturnType<typeof vi.fn<(pathname: string) => Promise<string>>>;
|
||||
let buildAgentModelCatalogCacheKeyMock: ReturnType<typeof vi.fn>;
|
||||
let buildModelsJsonSourceFingerprintMock: ReturnType<typeof vi.fn>;
|
||||
let readCachedAgentModelCatalogMock: ReturnType<typeof vi.fn>;
|
||||
let readCachedAgentModelCatalogMock: ReturnType<
|
||||
typeof vi.fn<(params: { agentDir: string; catalogKey: string }) => unknown[] | undefined>
|
||||
>;
|
||||
let readCachedAgentModelCatalogSnapshotMock: ReturnType<typeof vi.fn>;
|
||||
let writeCachedAgentModelCatalogMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
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",
|
||||
|
||||
+213
-78
@@ -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<ModelCatalogEntry[]> | null = null;
|
||||
let loadedModelCatalogSnapshot: ModelCatalogEntry[] | undefined;
|
||||
export type LoadModelCatalogParams = {
|
||||
config?: OpenClawConfig;
|
||||
useCache?: boolean;
|
||||
cacheOnly?: boolean;
|
||||
readOnly?: boolean;
|
||||
metadataSnapshot?: PluginMetadataSnapshot;
|
||||
};
|
||||
|
||||
let modelCatalogPromise: Promise<ModelCatalogSnapshot> | 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<OpenClawConfig, ManifestModelCatalogCacheEntry>();
|
||||
|
||||
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<string, number>;
|
||||
};
|
||||
|
||||
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<string, unknown>,
|
||||
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<ModelCatalogEntry[]> {
|
||||
}): Promise<ModelCatalogSnapshot> {
|
||||
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<string, unknown>[]) {
|
||||
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<ModelCatalogEntry[]> {
|
||||
/** Loads logical entries together with browse-only physical route provenance. */
|
||||
export async function loadModelCatalogSnapshot(
|
||||
params?: LoadModelCatalogParams,
|
||||
): Promise<ModelCatalogSnapshot> {
|
||||
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<ModelCatalogEntry[]> {
|
||||
return (await loadModelCatalogSnapshot(params)).entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports image input based on its catalog entry.
|
||||
*/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user