From 09a64f7ab00fc87794df66117f045a539f8cd365 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 21:42:05 +0100 Subject: [PATCH] feat(models): make per-agent allowlists explicit (#110888) --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/channels/discord.md | 2 +- docs/cli/configure.md | 4 +- docs/cli/models.md | 1 + docs/concepts/model-providers.md | 4 +- docs/concepts/models.md | 31 +- docs/gateway/cli-backends.md | 2 +- docs/gateway/config-agents.md | 5 +- docs/gateway/configuration.md | 4 +- docs/gateway/protocol.md | 6 +- docs/help/faq-models.md | 13 +- docs/providers/clawrouter.md | 6 +- docs/providers/minimax.md | 3 +- docs/providers/synthetic.md | 2 +- docs/tools/slash-commands.md | 5 +- docs/web/control-ui.md | 2 +- scripts/plugin-sdk-surface-report.mjs | 6 +- src/agents/agent-scope-config.test.ts | 30 ++ src/agents/agent-scope-config.ts | 3 + src/agents/command/model-selection.ts | 19 +- src/agents/model-allowlist-entry.ts | 4 +- src/agents/model-fallback.test.ts | 1 + src/agents/model-selection-resolve.test.ts | 93 ++++++ src/agents/model-selection-resolve.ts | 18 +- src/agents/model-selection-shared.ts | 300 +++++++++++++---- src/agents/model-selection.test.ts | 63 +++- src/agents/model-selection.ts | 2 + src/agents/model-visibility-policy.test.ts | 312 ++++++++++++++++++ src/agents/model-visibility-policy.ts | 3 +- src/auto-reply/reply/commands-models.ts | 1 + .../reply/directive-handling.defaults.ts | 1 + .../reply/directive-handling.fast-lane.ts | 2 + .../reply/directive-handling.impl.ts | 4 + .../directive-handling.model-selection.ts | 5 + .../reply/directive-handling.model.test.ts | 150 ++++++++- .../reply/directive-handling.model.ts | 68 +++- .../reply/directive-handling.params.ts | 2 + .../reply/directive-handling.persist.ts | 1 + .../reply/get-reply-directives-apply.test.ts | 26 +- .../reply/get-reply-directives-apply.ts | 11 +- .../reply/model-selection-directive.test.ts | 80 +++++ .../reply/model-selection-directive.ts | 13 +- src/auto-reply/reply/model-selection.test.ts | 6 +- src/auto-reply/reply/model-selection.ts | 51 +-- .../reply/session-reset-model.test.ts | 76 +++++ src/auto-reply/reply/session-reset-model.ts | 20 +- src/commands/agent-command.test-mocks.ts | 56 ++-- src/commands/agent.test.ts | 38 ++- ...re.gateway-auth.prompt-auth-config.test.ts | 27 +- src/commands/configure.gateway-auth.ts | 2 +- .../doctor-legacy-config.migrations.test.ts | 4 + .../shared/codex-route-config-repair.ts | 39 ++- .../doctor/shared/codex-route-config-scan.ts | 26 ++ .../shared/legacy-config-migrate.test.ts | 49 ++- ...y-config-migrations.runtime.models.test.ts | 198 ++++++++++- ...legacy-config-migrations.runtime.models.ts | 147 ++++++++- src/commands/model-picker.test.ts | 58 +++- src/commands/models.set.e2e.test.ts | 30 +- src/commands/models/aliases.test.ts | 38 ++- src/commands/models/list.status-command.ts | 9 +- .../config.meta-timestamp-coercion.test.ts | 15 +- src/config/io.meta.test.ts | 294 +++++++++++++++++ src/config/io.meta.ts | 87 ++++- src/config/io.write-config.test.ts | 1 + src/config/io.write-safety.ts | 8 +- src/config/io.write.ts | 6 +- .../model-policy-allowlist-migration.ts | 50 +++ src/config/schema.help.agents.ts | 4 + src/config/schema.help.core.ts | 3 + src/config/schema.help.models.ts | 6 +- src/config/schema.labels.ts | 6 + src/config/types.agent-defaults.ts | 7 + src/config/types.agents.ts | 3 + src/config/types.openclaw.ts | 5 + src/config/zod-schema.agent-defaults.test.ts | 17 + src/config/zod-schema.agent-defaults.ts | 2 + src/config/zod-schema.agent-runtime.ts | 7 + src/config/zod-schema.root-shape.ts | 5 + .../isolated-agent.model-formatting.test.ts | 65 +++- src/cron/isolated-agent/model-selection.ts | 27 +- src/cron/isolated-agent/run-config.ts | 2 + .../run.model-policy-config-preserved.test.ts | 68 ++++ .../isolated-agent/run.skill-filter.test.ts | 3 +- src/cron/isolated-agent/run.ts | 4 +- src/flows/doctor-core-checks.ts | 5 +- src/flows/doctor-health-contributions.ts | 2 +- src/flows/model-picker.ts | 72 +++- src/gateway/config-reload-plan.ts | 5 + src/gateway/config-reload.test.ts | 4 + src/gateway/server-startup-post-attach.ts | 2 +- .../server.models-voicewake-misc.test.ts | 8 +- src/gateway/server/hooks.agent-trust.test.ts | 2 +- src/plugin-sdk/provider-onboard.ts | 2 +- .../provider-auth-choice-helpers.test.ts | 12 +- src/plugins/provider-auth-choice-helpers.ts | 62 ++-- 95 files changed, 2734 insertions(+), 323 deletions(-) create mode 100644 src/agents/agent-scope-config.test.ts create mode 100644 src/agents/model-visibility-policy.test.ts create mode 100644 src/auto-reply/reply/model-selection-directive.test.ts create mode 100644 src/config/model-policy-allowlist-migration.ts create mode 100644 src/cron/isolated-agent/run.model-policy-config-preserved.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 3c145a95fbce..b059f822bef3 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -94,8 +94,8 @@ d340686cf814326b5a554a32cc5ca324a9e1254a933c4d9d8d1ada5ac7109a10 module/command a821f9cc4e6f9339399d99e73f58c3b00baf139aa9d85c22d73c89d8be5702d2 module/config-contracts 20f3f8042de53e4eee61b64de9102c8c202b9299e6a29235647a4729f70145f2 module/config-mutation 316949815affe623ac63951a5db580527f02663576dffa084f02768f612c0c1c module/config-runtime -20a92012a24deb819aad3866c6e2b3cf8fee3ca83e0135464b2188bae01278e5 module/config-schema -bfe6eec12f45bc2fda6df28699681b22dfd819a2261aabe8e0c789a20a017058 module/config-types +e0c0e99a4d797ba583a7d99e258530230ea61c66875bd6cf45843eb9211f4ff0 module/config-schema +2efdb1e995106ed5843461b7706100838db80453afa7e27c2c14f9c10a5a1e62 module/config-types 42d15153981cfe3adc1d5f91621434c56f07a9bd48c15ce34742f72dd040c142 module/context-visibility-runtime 03636897fb99cb73e4d8620c8a0e0d72b4d52fc32bf94f525af2aa88c489c6c2 module/conversation-binding-runtime c1ea9510dfda047609a99d5d2cd1f1560f5d469a36e6b695766213d695c25b0f module/conversation-runtime diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 4edb21afab29..45f969bd6a4e 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -346,7 +346,7 @@ To restrict who can click a button, set `allowedUsers` on that button (Discord u Component callbacks expire after 30 minutes by default. Set `channels.discord.agentComponents.ttlMs` to change the callback registry lifetime for the default account, or `channels.discord.accounts..agentComponents.ttlMs` per account. The value is milliseconds, must be a positive integer, and is capped at `86400000` (24 hours). Longer TTLs suit review/approval workflows that need buttons to stay usable, but they extend the window in which an old Discord message can still trigger an action. Prefer the shortest TTL that fits, and keep the default when stale callbacks would be surprising. -The `/model` and `/models` slash commands open an interactive model picker with provider, model, and compatible runtime dropdowns plus a Submit step. `/models add` is deprecated and returns a deprecation message instead of registering models from chat. The picker reply is ephemeral and only usable by the invoking user. Discord select menus are limited to 25 options, so add `provider/*` entries to `agents.defaults.models` when you want the picker to show dynamically discovered models only for selected providers such as `openai` or `vllm`. +The `/model` and `/models` slash commands open an interactive model picker with provider, model, and compatible runtime dropdowns plus a Submit step. `/models add` is deprecated and returns a deprecation message instead of registering models from chat. The picker reply is ephemeral and only usable by the invoking user. Discord select menus are limited to 25 options, so add `provider/*` entries to `agents.defaults.modelPolicy.allow` when you want the picker to show dynamically discovered models only for selected providers such as `openai` or `vllm`. File attachments: diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 1de21cdb8f74..518cc20c1c81 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -37,12 +37,12 @@ Selecting `gateway`, `daemon`, or `health` (or running the full wizard with no ` ## Model section -**Model** includes a multi-select for the `agents.defaults.models` allowlist (what shows up in `/model` and the model picker). Provider-scoped setup choices merge their selected models into the existing allowlist instead of replacing unrelated providers already in the config. +**Model** includes a multi-select for the explicit `agents.defaults.modelPolicy.allow` list (what shows up in `/model` and the model picker). Provider-scoped setup choices merge their selected models into the existing list instead of replacing unrelated providers already in the config. Per-model aliases and parameters remain under `agents.defaults.models`; those entries do not restrict model overrides by themselves. Re-running provider auth from configure preserves an existing `agents.defaults.model.primary`, even when the provider's auth step returns a config patch with its own recommended default model. Adding or reauthing a provider makes its models available without taking over your current primary model. Use `openclaw models auth login --provider --set-default` or `openclaw models set ` to intentionally change the default model. -When configure starts from a provider auth choice, the default-model and allowlist pickers prefer that provider automatically. For paired providers such as Volcengine and BytePlus, the same preference also matches their coding-plan variants (`volcengine-plan/*`, `byteplus-plan/*`). If the preferred-provider filter would produce an empty list, configure falls back to the unfiltered catalog instead of showing a blank picker. +When configure starts from a provider auth choice, the default-model and model-policy pickers prefer that provider automatically. For paired providers such as Volcengine and BytePlus, the same preference also matches their coding-plan variants (`volcengine-plan/*`, `byteplus-plan/*`). If the preferred-provider filter would produce an empty list, configure falls back to the unfiltered catalog instead of showing a blank picker. ## Web section diff --git a/docs/cli/models.md b/docs/cli/models.md index 6cd860a237db..960e025d5771 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -120,6 +120,7 @@ openclaw models aliases remove ``` Aliases are stored per model entry as `agents.defaults.models..alias`. `add` resolves `` to a canonical provider/model key first, so aliasing an alias repoints it rather than chaining. +Adding an alias does not change `agents.defaults.modelPolicy.allow` or restrict model overrides. ## Fallbacks diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 8a29239c3720..297e500456f4 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -14,7 +14,7 @@ Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram) - Model refs use `provider/model` (example: `opencode/claude-opus-4-6`). - - `agents.defaults.models` acts as an allowlist when set. + - `agents.defaults.models` stores aliases and per-model settings; `agents.defaults.modelPolicy.allow` is the optional explicit override allowlist. - CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set `. - `models.providers.*.contextWindow` / `contextTokens` / `maxTokens` set provider-level defaults; `models.providers.*.models[].contextWindow` / `contextTokens` / `maxTokens` override them per model. - Fallback rules, cooldown probes, and session-override persistence: [Model failover](/concepts/model-failover). @@ -359,7 +359,7 @@ Many of the bundled provider plugins below already publish a default catalog. Us Gateway model capability checks also read explicit `models.providers..models[]` metadata. If a custom or proxy model accepts images, set `input: ["text", "image"]` on that model so WebChat and node-origin attachment paths pass images as native model inputs instead of text-only media refs. -`agents.defaults.models["provider/model"]` only controls model visibility, aliases, and per-model metadata for agents. It does not register a new runtime model by itself. For custom provider models, also add `models.providers..models[]` with at least the matching `id`. +`agents.defaults.models["provider/model"]` controls aliases and per-model metadata for agents. It neither restricts overrides nor registers a new runtime model by itself. For custom provider models, also add `models.providers..models[]` with at least the matching `id`; use `agents.defaults.modelPolicy.allow` separately when you want an override restriction. ### Moonshot AI (Kimi) diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 36cdc76fa313..360f4445e282 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -55,7 +55,8 @@ OpenAI API-key and ChatGPT/Codex subscription credentials remain distinct. See Related model-config surfaces: -- `agents.defaults.models` is the allowlist/catalog of models OpenClaw can use, plus aliases. Use `provider/*` entries to allow every discovered model from a provider without listing each one. +- `agents.defaults.models` stores aliases and per-model settings. Adding an entry does not restrict model overrides. +- `agents.defaults.modelPolicy.allow` is the optional override allowlist. Use exact refs or `provider/*` entries; omit it or set `[]` to allow any model. Per-agent `agents.list[].modelPolicy.allow` replaces the default policy for that agent. - `agents.defaults.utilityModel` is an optional lower-cost model for short internal tasks such as generated dashboard session titles, supported channel thread/topic titles, and progress narration. Per-agent `agents.list[].utilityModel` overrides it. When unset, OpenClaw uses the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`), otherwise the agent's primary model; set it to an empty string to disable utility routing. Utility tasks are separate model calls and may send bounded task content to the selected model provider. - `agents.defaults.imageModel` is used only when the primary model cannot accept images. - `agents.defaults.pdfModel` is used by the `pdf` tool. If unset, the tool falls back to `imageModel`, then the resolved session/default model. @@ -79,7 +80,7 @@ Other selection rules: - Changing `agents.defaults.model.primary` does not rewrite existing session pins. If status reports `This session is pinned to X; config primary Y will apply to new/unpinned sessions.`, run `/model default` to clear the pin. - CLI default-model and allowlist pickers respect `models.mode: "replace"` by listing only `models.providers.*.models` instead of the full built-in catalog. -- The Control UI model picker asks the Gateway for its configured model view: `agents.defaults.models` when set (including `provider/*` wildcard entries), otherwise `models.providers.*.models` plus providers with usable auth. The full built-in catalog is reserved for explicit browse views (`models.list` with `view: "all"`, or `openclaw models list --all`). +- The Control UI model picker asks the Gateway for its configured model view. An explicit `modelPolicy.allow` filters it, including `provider/*` wildcard entries; otherwise it shows configured models plus providers with usable auth. The full built-in catalog is reserved for explicit browse views (`models.list` with `view: "all"`, or `openclaw models list --all`). - Provider inventory UIs use `models.list` with `view: "provider-config"` to show source-authored `models.providers.*.models` rows without applying picker allowlists. Full mechanics: [Model failover](/concepts/model-failover). @@ -107,14 +108,14 @@ Reauthentication preserves an existing explicit primary model, including ## "Model is not allowed" (and why replies stop) -If `agents.defaults.models` is set, it becomes the allowlist for `/model` and session overrides. Selecting a model outside that allowlist returns, before any normal reply is generated: +If `agents.defaults.modelPolicy.allow` is non-empty, it becomes the allowlist for `/model`, session overrides, and `--model`. Selecting a model outside that allowlist returns before any normal reply is generated. A per-agent `agents.list[].modelPolicy.allow` replaces the default policy for that agent. ```text -Model "provider/model" is not allowed. Use /models to list providers, or /models to list models. -Add it with: openclaw config set agents.defaults.models '{"provider/model":{}}' --strict-json --merge +Model override "provider/model" is not allowed by agents.defaults.modelPolicy.allow. +Add "provider/model" or "provider/*" to agents.defaults.modelPolicy.allow, or remove/empty the list to allow any model. ``` -Fix it by adding the model to `agents.defaults.models`, clearing the allowlist entirely (remove the key), or picking a model from `/model list`. If the rejected command included a runtime override such as `/model openai/gpt-5.5 --runtime codex`, fix the allowlist first, then retry the same `/model ... --runtime ...` command. +Fix it by adding the model or a provider wildcard to the named `modelPolicy.allow` key, removing/emptying that list, or picking a model from `/model list`. If the rejected command included a runtime override such as `/model openai/gpt-5.5 --runtime codex`, fix the allowlist first, then retry the same command. For local/GGUF models, the allowlist needs the full provider-prefixed ref, for example `ollama/gemma4:26b` or `lmstudio/Gemma4-26b-a4-it-gguf` — check `openclaw models list --provider ` for the exact string. Bare filenames or display names are not enough once the allowlist is active. @@ -124,9 +125,8 @@ To limit providers without listing every model, use `provider/*` wildcard entrie { agents: { defaults: { - models: { - "openai/*": {}, - "vllm/*": {}, + modelPolicy: { + allow: ["openai/*", "vllm/*"], }, }, }, @@ -135,13 +135,16 @@ To limit providers without listing every model, use `provider/*` wildcard entrie `/model`, `/models`, and model pickers then show the discovered catalog for those providers only, and new models can appear without editing the allowlist. Mix exact `provider/model` entries with `provider/*` entries to pull in one specific model from another provider. -Example allowlist with aliases: +Example allowlist with aliases and per-model settings: ```json5 { agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" }, + modelPolicy: { + allow: ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"], + }, models: { "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, "anthropic/claude-opus-4-6": { alias: "Opus" }, @@ -151,14 +154,14 @@ Example allowlist with aliases: } ``` - -Use `--merge` for additive changes: + +Set the complete list directly: ```bash -openclaw config set agents.defaults.models '{"openai/gpt-5.4":{}}' --strict-json --merge +openclaw config set agents.defaults.modelPolicy.allow '["openai/gpt-5.4","anthropic/*"]' --strict-json ``` -`openclaw config set` refuses plain-object assignments to `agents.defaults.models`, `models.providers`, or `models.providers..models` when they would drop existing entries; use `--replace` only when the new value should become the complete target value. Interactive provider setup and `openclaw configure --section model` already merge provider-scoped selections into the allowlist, so adding a provider does not drop unrelated entries; configure preserves an existing `agents.defaults.model.primary`. Explicit commands like `openclaw models auth login --provider --set-default` and `openclaw models set ` still replace the primary. +`openclaw models set`, provider setup, and `openclaw models aliases add` can add entries under `agents.defaults.models`, but they never change `modelPolicy.allow`. This keeps model metadata and aliases independent from override policy. ## `/model` in chat diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 0e394eac4fea..f202cb1fc9f4 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -69,7 +69,7 @@ Add the CLI backend to your fallback list so it only runs when primary models fa } ``` -If you use `agents.defaults.models` as an allowlist, include your CLI backend models there too. When the primary provider fails (auth, rate limits, timeouts), OpenClaw tries the CLI backend next. +Configured fallbacks remain eligible when the primary provider fails (auth, rate limits, timeouts), even when they are not in `agents.defaults.modelPolicy.allow`. Add a CLI backend model to that policy only when users should also be able to select it directly through `/model`, a session override, or `--model`. `agents.defaults.models` only owns per-model aliases, parameters, and metadata. ## Configuration diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index a1f5990930cd..86a4cd4b3e02 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -432,10 +432,11 @@ Time format in system prompt. Default: `auto` (OS preference). - `reasoningDefault`: default reasoning visibility for agents. Values: `"off"`, `"on"`, `"stream"`. Per-agent `agents.list[].reasoningDefault` overrides this default. Configured reasoning defaults are only applied for owners, authorized senders, or operator-admin gateway contexts when no per-message or session reasoning override is set. - `elevatedDefault`: default elevated-output level for agents. Values: `"off"`, `"on"`, `"ask"`, `"full"`. Default: `"on"`. - `model.primary`: format `provider/model` (e.g. `openai/gpt-5.6-sol` for Codex OAuth access). If you omit the provider, OpenClaw tries an alias first, then a unique configured-provider match for that exact model id, and only then falls back to the configured default provider (deprecated compatibility behavior, so prefer explicit `provider/model`). 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. -- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`, `responsesServerCompaction`, `responsesCompactThreshold`, OpenRouter `provider` routing, `chat_template_kwargs`, `extra_body`/`extraBody`). +- `models`: configured aliases and per-model settings. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`, `responsesServerCompaction`, `responsesCompactThreshold`, OpenRouter `provider` routing, `chat_template_kwargs`, `extra_body`/`extraBody`). Adding entries does not restrict model overrides. - Use `provider/*` entries such as `"openai/*": {}` or `"vllm/*": {}` to show all discovered models for selected providers without manually listing every model id. - Add `agentRuntime` to a `provider/*` entry when every dynamically discovered model for that provider should use the same runtime. Exact `provider/model` runtime policy still wins over the wildcard. - - Safe edits: use `openclaw config set agents.defaults.models '' --strict-json --merge` to add entries. `config set` refuses replacements that would remove existing allowlist entries unless you pass `--replace`. + - Safe metadata edits: use `openclaw config set agents.defaults.models '' --strict-json --merge` to add entries. `config set` refuses replacements that would remove existing entries unless you pass `--replace`. +- `modelPolicy.allow`: explicit override allowlist. Accepts aliases, exact `provider/model` refs, and provider wildcards such as `openai/*`. Omit it or use `[]` to allow any model. `agents.list[].modelPolicy.allow` replaces the default policy for that agent; an explicit empty list opts that agent into allow-any. - Provider-scoped configure/onboarding flows merge selected provider models into this map and preserve unrelated providers already configured. - For direct OpenAI Responses models, server-side compaction is enabled automatically. Use `params.responsesServerCompaction: false` to stop injecting `context_management`, or `params.responsesCompactThreshold` to override the threshold. See [OpenAI server-side compaction](/providers/openai#advanced-configuration). - `params`: global default provider parameters applied to all models. Set at `agents.defaults.params` (e.g. `{ cacheRetention: "long" }`). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f73e3932c906..37cc4ce0efbc 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -151,8 +151,8 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`. } ``` - - `agents.defaults.models` defines the model catalog and acts as the allowlist for `/model`; `provider/*` entries filter `/model`, `/models`, and model pickers to selected providers while still using dynamic model discovery. - - Use `openclaw config set agents.defaults.models '' --strict-json --merge` to add allowlist entries without removing existing models. Plain replacements that would remove entries are rejected unless you pass `--replace`. + - `agents.defaults.models` stores aliases and per-model settings; adding an entry never restricts `/model` or `--model` overrides. + - `agents.defaults.modelPolicy.allow` is the explicit allowlist for overrides and model pickers. It accepts exact refs and `provider/*` wildcards; omit it or use `[]` to allow any model. - Model refs use `provider/model` format (e.g. `anthropic/claude-opus-4-6`). - `agents.defaults.imageMaxDimensionPx` controls transcript/tool image downscaling (default `1200`); lower values usually reduce vision-token usage on screenshot-heavy runs. - See [Models CLI](/concepts/models) for switching models in chat and [Model Failover](/concepts/model-failover) for auth rotation and fallback behavior. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index e5b8ff60d039..70017f7b95c3 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -864,11 +864,11 @@ context. `models.list` accepts an optional `view` parameter (`src/agents/model-catalog-visibility.ts`): -- Omitted or `"default"`: if `agents.defaults.models` is configured, the +- Omitted or `"default"`: if `agents.defaults.modelPolicy.allow` is configured, the response is the allowed catalog, including dynamically discovered models for `provider/*` entries. Otherwise the response is the full gateway catalog. -- `"configured"`: picker-sized behavior. If `agents.defaults.models` is +- `"configured"`: picker-sized behavior. If `agents.defaults.modelPolicy.allow` is configured, it still wins, including provider-scoped discovery for `provider/*` entries. Without an allowlist, the response uses explicit `models.providers..models` entries, falling back to the full @@ -877,7 +877,7 @@ context. independent of picker allowlists. Rows include public model capabilities and route-aware availability, but omit provider endpoints, auth material, and runtime request configuration. -- `"all"`: full gateway catalog, bypassing `agents.defaults.models`. Use for +- `"all"`: full gateway catalog, bypassing `agents.defaults.modelPolicy.allow`. Use for diagnostics/discovery UIs, not normal model pickers. ## Exec approvals diff --git a/docs/help/faq-models.md b/docs/help/faq-models.md index 35dd74a8b57a..ff1a8a584813 100644 --- a/docs/help/faq-models.md +++ b/docs/help/faq-models.md @@ -180,18 +180,17 @@ troubleshooting, see the main [FAQ](/help/faq). - If `agents.defaults.models` is set, it becomes the **allowlist** for - `/model` and session overrides. Picking a model outside that list returns + If `agents.defaults.modelPolicy.allow` is non-empty, it becomes the + **allowlist** for `/model`, session overrides, and `--model`. Picking a model outside that list returns this instead of a normal reply: ```text - Model "provider/model" is not allowed. Use /models to list providers, or /models to list models. - Add it with: openclaw config set agents.defaults.models '{"provider/model":{}}' --strict-json --merge + Model override "provider/model" is not allowed by agents.defaults.modelPolicy.allow. ``` - Fix: add the exact model to `agents.defaults.models`, add a provider - wildcard such as `"provider/*": {}` for dynamic catalogs, remove the - allowlist, or pick a model from `/model list`. If the command also + Fix: add the exact model or a provider wildcard such as `"provider/*"` to + the named `modelPolicy.allow` list, remove/empty that list, or pick a model + from `/model list`. If the command also included `--runtime codex`, update the allowlist first, then retry the same `/model provider/model --runtime codex` command. diff --git a/docs/providers/clawrouter.md b/docs/providers/clawrouter.md index 5ab495c44e79..a6861300be24 100644 --- a/docs/providers/clawrouter.md +++ b/docs/providers/clawrouter.md @@ -54,8 +54,8 @@ you only need an issued ClawRouter credential. Use the returned model refs exactly as shown. They retain the upstream namespace, such as `clawrouter/openai/gpt-5.5`, `clawrouter/anthropic/claude-sonnet-4-6`, or - `clawrouter/google/gemini-3.5-flash`. If `agents.defaults.models` is an - allowlist in your configuration, add each selected ClawRouter ref to it. + `clawrouter/google/gemini-3.5-flash`. If `agents.defaults.modelPolicy.allow` + is configured, add each selected ClawRouter ref to it. @@ -252,7 +252,7 @@ the same ClawRouter policy can change the remaining percentage. | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | No ClawRouter models | Confirm the plugin is enabled and allowed by `plugins.allow`, then check that the credential is active and grants at least one ready provider. | | A configured ClawRouter model is missing | Inspect its `/v1/catalog` capability and route support. Unsupported transport contracts are intentionally filtered. | -| `Unknown model: clawrouter/...` | Add the exact catalog ref to `agents.defaults.models` when that configuration map is being used as an allowlist. | +| Model override rejected by policy | Add the exact catalog ref or `clawrouter/*` to `agents.defaults.modelPolicy.allow`. | | `401` or `403` from catalog or usage | Reissue or re-scope the ClawRouter credential; OpenClaw does not fall back to upstream provider keys. | | Model call fails after discovery | Check the provider connection and upstream health in ClawRouter, then retry after its readiness state recovers. | | Usage has totals but no percentage | The policy is unmetered; add a monthly budget in ClawRouter to expose a percentage window. | diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 6608a5171a72..7423b3636765 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -332,7 +332,8 @@ See [MiniMax Search](/tools/minimax-search) for full web search configuration an | `models.providers.minimax.api` | Prefer `anthropic-messages`; `openai-completions` is optional for OpenAI-compatible payloads | | `models.providers.minimax.apiKey` | MiniMax API key (`MINIMAX_API_KEY`) | | `models.providers.minimax.models` | Define `id`, `name`, `reasoning`, `contextWindow`, `maxTokens`, `cost` | - | `agents.defaults.models` | Alias models you want in the allowlist | + | `agents.defaults.models` | Per-model aliases, parameters, and metadata | + | `agents.defaults.modelPolicy.allow` | Optional explicit model allowlist | | `models.mode` | Keep `merge` if you want to add MiniMax alongside built-ins | diff --git a/docs/providers/synthetic.md b/docs/providers/synthetic.md index ff6fbd8102a6..29b1d7649e6f 100644 --- a/docs/providers/synthetic.md +++ b/docs/providers/synthetic.md @@ -101,7 +101,7 @@ account. - If you enable a model allowlist (`agents.defaults.models`), add every + If you enable a model allowlist (`agents.defaults.modelPolicy.allow`), add every Synthetic model you plan to use. Models not in the allowlist are hidden from the agent. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 3ee6ccd2eb67..f1e0a68ce4c1 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -371,8 +371,9 @@ use the Control UI Tools panel or config surfaces. ``` On Discord, `/model` and `/models` open an interactive picker with provider and -model dropdowns. The picker respects `agents.defaults.models`, including -`provider/*` entries. +model dropdowns. The picker respects `agents.defaults.modelPolicy.allow`, +including `provider/*` entries. Without an explicit allowlist, model entries and +aliases do not restrict selection. ## `/config`: on-disk config writes diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 1378a7acbfad..06eaca92c10d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -407,7 +407,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das - On narrow screens, split view keeps the layout but renders only the active pane, including its header with the close control. - If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model. - Typing `/new` creates and switches to the same fresh dashboard session as New Chat, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; then it resets the main session in place. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session. - - The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker, including `provider/*` entries that keep provider-scoped catalogs dynamic. Otherwise the picker shows explicit `models.providers.*.models` entries plus providers with usable auth. The full catalog stays available through the debug `models.list` RPC with `view: "all"`. + - The chat model picker requests the Gateway's configured model view. If `agents.defaults.modelPolicy.allow` is non-empty, that policy drives the picker, including `provider/*` entries that keep provider-scoped catalogs dynamic. Otherwise the picker shows configured entries plus providers with usable auth; aliases and settings under `agents.defaults.models` do not restrict it. The full catalog stays available through the debug `models.list` RPC with `view: "all"`. - When fresh Gateway session usage reports include current context tokens, the chat composer toolbar shows a small context usage ring with the used percentage. Open the ring for the current context window, latest-run token counts and estimated total cost, provider/model identity, and the latest provider response's input/output/cache cost breakdown when reported. The ring switches to warning styling at high context pressure and, at recommended compaction levels, shows a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again. diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 46f0e2aa092a..4724a6c458cf 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -273,7 +273,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: ingress-effect-once factory. // +3: bounded raw transcript cursor request, result, and reader. // +3: bounded visible transcript cursor request, result, and reader. - 8164, + // +1: explicit AgentModelPolicyConfig shared with provider setup surfaces. + 8165, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -335,7 +336,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +24: narrowed drain seam compat mirrors in the channel-message // deprecation-window barrels (#108656). // Harvest: retired dual-field plan payload builder -1; lower-only drift -8. - 3005, + // +1: AgentModelPolicyConfig mirrored by deprecated config-types. + 3006, env, ), publicWildcardReexports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/agent-scope-config.test.ts b/src/agents/agent-scope-config.test.ts new file mode 100644 index 000000000000..2351669caf30 --- /dev/null +++ b/src/agents/agent-scope-config.test.ts @@ -0,0 +1,30 @@ +// Agent scope tests cover which per-agent fields may flatten into runtime defaults. +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveAgentConfig } from "./agent-scope-config.js"; + +describe("resolveAgentConfig model policy", () => { + it("keeps an empty per-agent policy inherited instead of flattening it", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { modelPolicy: { allow: ["openai/gpt-5.5"] } }, + list: [{ id: "main", modelPolicy: {} }], + }, + }; + + expect(resolveAgentConfig(cfg, "main")?.modelPolicy).toBeUndefined(); + }); + + it("returns an explicit per-agent allowlist override", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { modelPolicy: { allow: ["openai/gpt-5.5"] } }, + list: [{ id: "main", modelPolicy: { allow: ["openai/gpt-5.6-sol"] } }], + }, + }; + + expect(resolveAgentConfig(cfg, "main")?.modelPolicy).toEqual({ + allow: ["openai/gpt-5.6-sol"], + }); + }); +}); diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index e5276b2f8ec1..fcb9670b9051 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -1,6 +1,7 @@ /** Resolves configured agent ids, directories, workspaces, and merged agent defaults. */ import path from "node:path"; import { readStringValue } from "@openclaw/normalization-core/string-coerce"; +import { hasExplicitModelPolicyAllow } from "../config/model-policy-allowlist-migration.js"; import { resolveStateDir } from "../config/paths.js"; import type { AgentContextLimitsConfig, @@ -21,6 +22,7 @@ export type ResolvedAgentConfig = { agentDir?: string; model?: AgentEntry["model"]; models?: AgentEntry["models"]; + modelPolicy?: AgentEntry["modelPolicy"]; utilityModel?: AgentEntry["utilityModel"]; thinkingDefault?: AgentEntry["thinkingDefault"]; verboseDefault?: AgentDefaultsConfig["verboseDefault"]; @@ -131,6 +133,7 @@ export function resolveAgentConfig( ? entry.model : undefined, ...(entry.models ? { models: entry.models } : {}), + ...(hasExplicitModelPolicyAllow(entry.modelPolicy) ? { modelPolicy: entry.modelPolicy } : {}), utilityModel: readStringValue(entry.utilityModel), thinkingDefault: entry.thinkingDefault, verboseDefault: entry.verboseDefault ?? agentDefaults?.verboseDefault, diff --git a/src/agents/command/model-selection.ts b/src/agents/command/model-selection.ts index b8775de1e06e..8df40b44f744 100644 --- a/src/agents/command/model-selection.ts +++ b/src/agents/command/model-selection.ts @@ -100,9 +100,6 @@ export async function resolveEmbeddedModelSelection(params: { let provider = defaultProvider; let model = defaultModel; let sessionEntry = params.sessionEntry; - const hasAllowlist = - params.cfg.agents?.defaults?.models && - Object.keys(params.cfg.agents.defaults.models).length > 0; const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride); let storedModelOverrideSource = hasStoredOverride ? sessionEntry?.modelOverrideSource : undefined; let hasStoredAutoFallbackProvenance = @@ -132,11 +129,17 @@ export async function resolveEmbeddedModelSelection(params: { catalog: [], defaultProvider, defaultModel, + agentId: params.sessionAgentId, allowManifestNormalization: true, allowPluginNormalization: params.pluginsEnabled, ...params.modelManifestContext, }); - if (hasAllowlist) { + const hasAllowlist = !visibilityPolicy.allowAny; + const agentModels = resolveAgentConfig(params.cfg, params.sessionAgentId)?.models; + const hasConfiguredModels = + Object.keys(params.cfg.agents?.defaults?.models ?? {}).length > 0 || + Object.keys(agentModels ?? {}).length > 0; + if (hasAllowlist || hasConfiguredModels) { modelCatalog = params.pluginsEnabled ? loadManifestModelCatalog({ config: params.cfg, workspaceDir: params.workspaceDir }) : []; @@ -325,8 +328,11 @@ export async function resolveEmbeddedModelSelection(params: { throw new Error("Invalid model override."); } if (!visibilityPolicy.allowsKey(modelKey(explicitRef.provider, explicitRef.model))) { + const rejectedKey = `${sanitizeForLog(explicitRef.provider)}/${sanitizeForLog(explicitRef.model)}`; + const policyPath = visibilityPolicy.allowConfigPath ?? "modelPolicy.allow"; + const repairPath = visibilityPolicy.allowRepairConfigPath; throw new Error( - `Model override "${sanitizeForLog(explicitRef.provider)}/${sanitizeForLog(explicitRef.model)}" is not allowed for agent "${params.sessionAgentId}".`, + `Model override "${rejectedKey}" is not allowed for agent "${params.sessionAgentId}" by ${policyPath}. Add "${rejectedKey}" or "${sanitizeForLog(explicitRef.provider)}/*" to ${repairPath}, or remove/empty the list to allow any model.`, ); } provider = explicitRef.provider; @@ -334,8 +340,9 @@ export async function resolveEmbeddedModelSelection(params: { } const allowedInitialSelection = visibilityPolicy.resolveSelection({ provider, model }); if (!allowedInitialSelection) { + const policyPath = visibilityPolicy.allowConfigPath ?? "modelPolicy.allow"; throw new Error( - `Configured default model "${modelKey(provider, model)}" is not allowed by agents.defaults.models, and no allowed model is available.`, + `Configured default model "${modelKey(provider, model)}" is not allowed by ${policyPath}, and no allowed model is available.`, ); } provider = allowedInitialSelection.provider; diff --git a/src/agents/model-allowlist-entry.ts b/src/agents/model-allowlist-entry.ts index 747ab784091c..d83e2379cfcb 100644 --- a/src/agents/model-allowlist-entry.ts +++ b/src/agents/model-allowlist-entry.ts @@ -1,12 +1,12 @@ /** - * Updates static model allowlist entries in agent defaults. Setup uses this + * Updates static per-model config entries in agent defaults. Setup uses this * helper to keep both raw and canonical provider/model keys present. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { DEFAULT_PROVIDER } from "./defaults.js"; import { resolveStaticAllowlistModelKey } from "./model-ref-shared.js"; -/** Ensures a static model allowlist entry exists in agent defaults. */ +/** Ensures a static per-model config entry exists in agent defaults. */ export function ensureStaticModelAllowlistEntry(params: { cfg: OpenClawConfig; modelRef: string; diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 7c275a8134c9..df9920172951 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -2353,6 +2353,7 @@ describe("runWithModelFallback", () => { primary: "openai/gpt-4.1-mini", fallbacks: ["anthropic/claude-haiku-3-5", "openrouter/deepseek-chat"], }, + modelPolicy: { allow: ["openai/gpt-4.1-mini"] }, }, }, }); diff --git a/src/agents/model-selection-resolve.test.ts b/src/agents/model-selection-resolve.test.ts index d483a2448f06..d15cc1158a56 100644 --- a/src/agents/model-selection-resolve.test.ts +++ b/src/agents/model-selection-resolve.test.ts @@ -4,6 +4,99 @@ import type { OpenClawConfig } from "../config/types.js"; import { resolveAllowedModelRef, resolveConfiguredModelRef } from "./model-selection-resolve.js"; describe("model-selection-resolve OpenRouter compat aliases", () => { + it("keeps inherited policy aliases bound to default metadata for per-agent selection", () => { + const cfg = { + meta: { migrations: { modelPolicyAllowlist: true } }, + agents: { + defaults: { + models: { + "openai/gpt-5.5": { alias: "approved" }, + }, + modelPolicy: { allow: ["approved"] }, + }, + list: [ + { + id: "worker", + models: { + "anthropic/claude-sonnet-4-6": { alias: "approved" }, + }, + }, + ], + }, + } as OpenClawConfig; + const catalog = [ + { provider: "openai", id: "gpt-5.5", name: "GPT 5.5" }, + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]; + + expect( + resolveAllowedModelRef({ + cfg, + catalog, + raw: "approved", + defaultProvider: "openai", + agentId: "worker", + }), + ).toEqual({ error: "model not allowed: anthropic/claude-sonnet-4-6" }); + expect( + resolveAllowedModelRef({ + cfg, + catalog, + raw: "openai/gpt-5.5", + defaultProvider: "openai", + agentId: "worker", + }), + ).toEqual({ + key: "openai/gpt-5.5", + ref: { provider: "openai", model: "gpt-5.5" }, + }); + }); + + it("binds explicit per-agent policy aliases to per-agent metadata", () => { + const cfg = { + meta: { migrations: { modelPolicyAllowlist: true } }, + agents: { + defaults: { + models: { "openai/gpt-5.5": { alias: "approved" } }, + modelPolicy: { allow: ["approved"] }, + }, + list: [ + { + id: "worker", + models: { "anthropic/claude-sonnet-4-6": { alias: "approved" } }, + modelPolicy: { allow: ["approved"] }, + }, + ], + }, + } as OpenClawConfig; + const catalog = [ + { provider: "openai", id: "gpt-5.5", name: "GPT 5.5" }, + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]; + + expect( + resolveAllowedModelRef({ + cfg, + catalog, + raw: "approved", + defaultProvider: "openai", + agentId: "worker", + }), + ).toEqual({ + key: "anthropic/claude-sonnet-4-6", + ref: { provider: "anthropic", model: "claude-sonnet-4-6" }, + }); + expect( + resolveAllowedModelRef({ + cfg, + catalog, + raw: "openai/gpt-5.5", + defaultProvider: "openai", + agentId: "worker", + }), + ).toEqual({ error: "model not allowed: openai/gpt-5.5" }); + }); + it("preserves exact configured proxy provider ids for cron-style aliases", () => { // Proxy providers can intentionally own short ids like "cron"; keep the // configured provider scope instead of treating the id as a global alias. diff --git a/src/agents/model-selection-resolve.ts b/src/agents/model-selection-resolve.ts index a5b5b8edac57..1738c91a61aa 100644 --- a/src/agents/model-selection-resolve.ts +++ b/src/agents/model-selection-resolve.ts @@ -6,6 +6,7 @@ */ import { resolveAgentModelFallbackValues } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveAgentModelFallbacksOverride } from "./agent-scope.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; import type { ModelManifestNormalizationContext, ModelRef } from "./model-selection-normalize.js"; import { @@ -24,7 +25,13 @@ export { resolveModelRefFromString, } from "./model-selection-shared.js"; -function resolveDefaultFallbackModels(cfg: OpenClawConfig): string[] { +function resolveDefaultFallbackModels(cfg: OpenClawConfig, agentId?: string): string[] { + if (agentId) { + const override = resolveAgentModelFallbacksOverride(cfg, agentId); + if (override !== undefined) { + return override; + } + } return resolveAgentModelFallbackValues(cfg.agents?.defaults?.model); } @@ -36,16 +43,18 @@ export function getModelRefStatus( ref: ModelRef; defaultProvider: string; defaultModel?: string; + agentId?: string; } & ModelManifestNormalizationContext, ): ModelRefStatus { - const { cfg, catalog, ref, defaultProvider, defaultModel, manifestPlugins } = params; + const { cfg, catalog, ref, defaultProvider, defaultModel, agentId, manifestPlugins } = params; return getModelRefStatusWithFallbackModels({ cfg, catalog, ref, defaultProvider, defaultModel, - fallbackModels: resolveDefaultFallbackModels(cfg), + agentId, + fallbackModels: resolveDefaultFallbackModels(cfg, agentId), manifestPlugins, }); } @@ -58,6 +67,7 @@ export function resolveAllowedModelRef( raw: string; defaultProvider: string; defaultModel?: string; + agentId?: string; } & ModelManifestNormalizationContext, ): | { ref: ModelRef; key: string } @@ -67,6 +77,7 @@ export function resolveAllowedModelRef( const aliasIndex = buildModelAliasIndex({ cfg: params.cfg, defaultProvider: params.defaultProvider, + agentId: params.agentId, manifestPlugins: params.manifestPlugins, }); return resolveAllowedModelRefFromAliasIndex({ @@ -82,6 +93,7 @@ export function resolveAllowedModelRef( ref, defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, + agentId: params.agentId, manifestPlugins: params.manifestPlugins, }), }); diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 56e648b3a7a4..4a014929ee59 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -7,11 +7,16 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { sanitizeForLog, stripAnsi } from "../../packages/terminal-core/src/ansi.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { + computeModelPolicyAllowlist, + hasExplicitModelPolicyAllow, +} from "../config/model-policy-allowlist-migration.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "../plugins/runtime-state.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { resolveConfiguredProviderFallback } from "./configured-provider-fallback.js"; import { DEFAULT_PROVIDER } from "./defaults.js"; import { findModelCatalogEntry } from "./model-catalog-lookup.js"; @@ -127,15 +132,25 @@ function createModelManifestPluginContext(params: { }; } -function listModelAliasCandidates(cfg: OpenClawConfig): ModelAliasCandidate[] { - return Object.entries(cfg.agents?.defaults?.models ?? {}).flatMap(([keyRaw, entryRaw]) => { - if (parseProviderWildcardModelRef(keyRaw)) { - return []; - } - const alias = - normalizeOptionalString((entryRaw as { alias?: string } | undefined)?.alias) ?? ""; - return alias ? [{ keyRaw, alias }] : []; - }); +function listModelAliasCandidates(cfg: OpenClawConfig, agentId?: string): ModelAliasCandidate[] { + const modelMaps = [cfg.agents?.defaults?.models]; + if (agentId) { + const normalizedAgentId = normalizeAgentId(agentId); + const agentModels = cfg.agents?.list?.find( + (entry) => normalizeAgentId(entry.id) === normalizedAgentId, + )?.models; + modelMaps.push(agentModels); + } + return modelMaps.flatMap((models) => + Object.entries(models ?? {}).flatMap(([keyRaw, entryRaw]) => { + if (parseProviderWildcardModelRef(keyRaw)) { + return []; + } + const alias = + normalizeOptionalString((entryRaw as { alias?: string } | undefined)?.alias) ?? ""; + return alias ? [{ keyRaw, alias }] : []; + }), + ); } function findModelAliasCandidate( @@ -497,18 +512,20 @@ export function resolveAllowlistModelKey( cfg?: OpenClawConfig; raw: string; defaultProvider: string; + aliasIndex?: ModelAliasIndex; allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext, ): string | null { - const parsed = parseModelRefWithCompatAlias({ + const parsed = resolveModelRefFromString({ cfg: params.cfg, raw: params.raw, defaultProvider: params.defaultProvider, + aliasIndex: params.aliasIndex, allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, - }); + })?.ref; if (!parsed) { return null; } @@ -520,21 +537,34 @@ export function buildConfiguredAllowlistKeys( params: { cfg: OpenClawConfig | undefined; defaultProvider: string; + agentId?: string; allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext, ): Set | null { - const visibility = parseConfiguredModelVisibilityEntries({ cfg: params.cfg }); + const visibility = parseConfiguredModelVisibilityEntries({ + cfg: params.cfg, + agentId: params.agentId, + }); if (visibility.exactModelRefs.length === 0) { return null; } + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg ?? {}, + defaultProvider: params.defaultProvider, + agentId: resolvePolicyAliasAgentId(visibility.configPath, params.agentId), + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + manifestPlugins: params.manifestPlugins, + }); const keys = new Set(); for (const raw of visibility.exactModelRefs) { const key = resolveAllowlistModelKey({ cfg: params.cfg, raw, defaultProvider: params.defaultProvider, + aliasIndex, allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, @@ -549,6 +579,7 @@ export function buildConfiguredAllowlistKeys( type BuildModelAliasIndexParams = { cfg: OpenClawConfig; defaultProvider: string; + agentId?: string; allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext; @@ -561,7 +592,7 @@ function buildModelAliasIndexWithManifestContext( const byAlias = new Map(); const byProviderAlias = new Map(); const byKey = new Map(); - const aliasCandidates = listModelAliasCandidates(params.cfg); + const aliasCandidates = listModelAliasCandidates(params.cfg, params.agentId); if (aliasCandidates.length === 0) { return { byAlias, byProviderAlias, byKey }; } @@ -601,6 +632,7 @@ export function buildModelAliasIndex(params: BuildModelAliasIndexParams): ModelA return buildModelAliasIndexWithManifestContext({ cfg: params.cfg, defaultProvider: params.defaultProvider, + agentId: params.agentId, allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, manifestPluginContext: createModelManifestPluginContext(params), @@ -930,7 +962,7 @@ export function resolveConfiguredModelRef( return { provider: params.defaultProvider, model: params.defaultModel }; } -/** Build allowed model keys/catalog entries after provider wildcards and fallbacks. */ +/** Build explicit override authorization plus configured automatic fallback keys. */ export function buildAllowedModelSetWithFallbacks( params: { cfg: OpenClawConfig; @@ -938,6 +970,8 @@ export function buildAllowedModelSetWithFallbacks( defaultProvider: string; defaultModel?: string; fallbackModels: readonly string[]; + agentId?: string; + aliasIndex?: ModelAliasIndex; allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext, @@ -945,6 +979,7 @@ export function buildAllowedModelSetWithFallbacks( allowAny: boolean; allowedCatalog: ModelCatalogEntry[]; allowedKeys: Set; + automaticFallbackKeys: Set; configuredCatalog: ModelCatalogEntry[]; } { const metadata = buildModelCatalogMetadata({ @@ -962,7 +997,32 @@ export function buildAllowedModelSetWithFallbacks( primary: params.catalog, secondary: configuredCatalog, }).map((entry) => applyModelCatalogMetadata({ entry, metadata })); - const visibility = parseConfiguredModelVisibilityEntries({ cfg: params.cfg }); + const visibility = parseConfiguredModelVisibilityEntries({ + cfg: params.cfg, + agentId: params.agentId, + }); + const policyAliasAgentId = resolvePolicyAliasAgentId(visibility.configPath, params.agentId); + const policyAliasIndex = + params.aliasIndex ?? + buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + agentId: policyAliasAgentId, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + manifestPlugins: params.manifestPlugins, + }); + const selectionAliasIndex = + params.agentId && policyAliasAgentId !== params.agentId + ? buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + agentId: params.agentId, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + manifestPlugins: params.manifestPlugins, + }) + : policyAliasIndex; const allowAny = !visibility.hasEntries; const defaultModelNormalization = allowAny ? { @@ -986,6 +1046,34 @@ export function buildAllowedModelSetWithFallbacks( }) : null; const defaultKey = defaultRef ? modelKey(defaultRef.provider, defaultRef.model) : undefined; + const resolveSelectionModelRef = (raw: string, aliasIndex: ModelAliasIndex) => { + const trimmed = raw.trim(); + const defaultProvider = !trimmed.includes("/") + ? resolveBareModelDefaultProvider({ + cfg: params.cfg, + catalog, + model: trimmed, + defaultProvider: params.defaultProvider, + manifestPlugins: params.manifestPlugins, + }) + : params.defaultProvider; + return resolveModelRefFromString({ + cfg: params.cfg, + raw, + defaultProvider, + aliasIndex, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + manifestPlugins: params.manifestPlugins, + })?.ref; + }; + const automaticFallbackKeys = new Set(); + for (const fallback of params.fallbackModels) { + const parsed = resolveSelectionModelRef(fallback, selectionAliasIndex); + if (parsed) { + automaticFallbackKeys.add(modelKey(parsed.provider, parsed.model)); + } + } const catalogKeys = new Set(); for (const entry of catalog) { catalogKeys.add(modelKey(entry.provider, entry.id)); @@ -999,6 +1087,7 @@ export function buildAllowedModelSetWithFallbacks( allowAny: true, allowedCatalog: catalog, allowedKeys: catalogKeys, + automaticFallbackKeys, configuredCatalog, }; } @@ -1019,32 +1108,12 @@ export function buildAllowedModelSetWithFallbacks( allowedRefs.push(ref); } }; - for (const entry of catalog) { - if (!visibility.providerWildcards.has(normalizeProviderId(entry.provider))) { - continue; - } + for (const entry of expandModelCatalogProviderWildcards(catalog, visibility.providerWildcards)) { allowedKeys.add(modelKey(entry.provider, entry.id)); addAllowedCatalogRef({ provider: entry.provider, model: entry.id }); } - const addAllowedModelRef = (raw: string) => { - const trimmed = raw.trim(); - const defaultProvider = !trimmed.includes("/") - ? resolveBareModelDefaultProvider({ - cfg: params.cfg, - catalog, - model: trimmed, - defaultProvider: params.defaultProvider, - manifestPlugins: params.manifestPlugins, - }) - : params.defaultProvider; - const parsed = parseModelRefWithCompatAlias({ - cfg: params.cfg, - raw, - defaultProvider, - allowManifestNormalization: params.allowManifestNormalization, - allowPluginNormalization: params.allowPluginNormalization, - manifestPlugins: params.manifestPlugins, - }); + const addAllowedModelRef = (raw: string, aliasIndex: ModelAliasIndex) => { + const parsed = resolveSelectionModelRef(raw, aliasIndex); if (!parsed) { return; } @@ -1063,13 +1132,7 @@ export function buildAllowedModelSetWithFallbacks( }; for (const raw of visibility.exactModelRefs) { - addAllowedModelRef(raw); - } - - if (visibility.exactModelRefs.length > 0) { - for (const fallback of params.fallbackModels) { - addAllowedModelRef(fallback); - } + addAllowedModelRef(raw, policyAliasIndex); } if ( @@ -1105,11 +1168,18 @@ export function buildAllowedModelSetWithFallbacks( allowAny: true, allowedCatalog: catalog, allowedKeys: catalogKeys, + automaticFallbackKeys, configuredCatalog, }; } - return { allowAny: false, allowedCatalog, allowedKeys, configuredCatalog }; + return { + allowAny: false, + allowedCatalog, + allowedKeys, + automaticFallbackKeys, + configuredCatalog, + }; } /** Status of a candidate model against catalog and configured allowlist state. */ @@ -1156,6 +1226,7 @@ export function getModelRefStatusWithFallbackModels( defaultProvider: string; defaultModel?: string; fallbackModels: readonly string[]; + agentId?: string; } & ModelManifestNormalizationContext, ): ModelRefStatus { const allowed = buildAllowedModelSetWithFallbacks({ @@ -1164,6 +1235,7 @@ export function getModelRefStatusWithFallbackModels( defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, fallbackModels: params.fallbackModels, + agentId: params.agentId, manifestPlugins: params.manifestPlugins, }); return getModelRefStatusFromAllowedSet({ @@ -1422,16 +1494,72 @@ function parseProviderWildcardModelRef(raw: string): string | null { return normalizeProviderId(trimmed.slice(0, -2)) || null; } -export function parseConfiguredModelVisibilityEntries(params: { cfg?: OpenClawConfig }): { +const DEFAULT_MODEL_POLICY_ALLOW_CONFIG_PATH = "agents.defaults.modelPolicy.allow"; +const AGENT_MODEL_POLICY_ALLOW_CONFIG_PATH = "agents.list[].modelPolicy.allow"; + +function resolvePolicyAliasAgentId( + configPath: string | null, + agentId: string | undefined, +): string | undefined { + return configPath === AGENT_MODEL_POLICY_ALLOW_CONFIG_PATH ? agentId : undefined; +} + +export function resolveConfiguredModelPolicyAllow(params: { + cfg?: OpenClawConfig; + agentId?: string; +}): { refs: readonly string[]; configPath: string | null; repairConfigPath: string } { + const defaults = params.cfg?.agents?.defaults; + if (params.agentId) { + const normalizedAgentId = normalizeAgentId(params.agentId); + const agent = params.cfg?.agents?.list?.find( + (entry) => normalizeAgentId(entry.id) === normalizedAgentId, + ); + const agentPolicy = agent?.modelPolicy; + if (hasExplicitModelPolicyAllow(agentPolicy)) { + return { + refs: agentPolicy?.allow ?? [], + configPath: AGENT_MODEL_POLICY_ALLOW_CONFIG_PATH, + repairConfigPath: AGENT_MODEL_POLICY_ALLOW_CONFIG_PATH, + }; + } + } + const defaultPolicy = defaults?.modelPolicy; + if (hasExplicitModelPolicyAllow(defaultPolicy)) { + return { + refs: defaultPolicy?.allow ?? [], + configPath: DEFAULT_MODEL_POLICY_ALLOW_CONFIG_PATH, + repairConfigPath: DEFAULT_MODEL_POLICY_ALLOW_CONFIG_PATH, + }; + } + const legacyDefaultRefs = computeModelPolicyAllowlist({ + root: params.cfg, + defaults, + }); + if (legacyDefaultRefs) { + return { + refs: legacyDefaultRefs, + configPath: "agents.defaults.models", + repairConfigPath: DEFAULT_MODEL_POLICY_ALLOW_CONFIG_PATH, + }; + } + return { refs: [], configPath: null, repairConfigPath: DEFAULT_MODEL_POLICY_ALLOW_CONFIG_PATH }; +} + +export function parseConfiguredModelVisibilityEntries(params: { + cfg?: OpenClawConfig; + agentId?: string; +}): { exactModelRefs: string[]; providerWildcards: Set; hasEntries: boolean; + configPath: string | null; + repairConfigPath: string; } { - const rawModels = Object.keys(params.cfg?.agents?.defaults?.models ?? {}); + const configured = resolveConfiguredModelPolicyAllow(params); const exactModelRefs: string[] = []; const providerWildcards = new Set(); - for (const raw of rawModels) { + for (const raw of configured.refs) { const trimmed = raw.trim(); if (!trimmed) { continue; @@ -1447,7 +1575,9 @@ export function parseConfiguredModelVisibilityEntries(params: { cfg?: OpenClawCo return { exactModelRefs, providerWildcards, - hasEntries: rawModels.length > 0, + hasEntries: configured.refs.length > 0, + configPath: configured.configPath, + repairConfigPath: configured.repairConfigPath, }; } @@ -1455,6 +1585,14 @@ function providerWildcardModelKey(provider: string): string { return modelKey(normalizeProviderId(provider), "*"); } +/** Expand provider wildcard policy entries against discovered catalog rows. */ +export function expandModelCatalogProviderWildcards( + catalog: readonly T[], + providerWildcards: ReadonlySet, +): T[] { + return catalog.filter((entry) => providerWildcards.has(normalizeProviderId(entry.provider))); +} + export function isModelKeyAllowedBySet(allowedKeys: ReadonlySet, key: string): boolean { if (allowedKeys.has(key)) { return true; @@ -1508,12 +1646,16 @@ export type ModelVisibilityPolicy = { allowAny: boolean; allowedCatalog: ModelCatalogEntry[]; allowedKeys: Set; + policyAliasIndex: ModelAliasIndex; configuredKeys: ReadonlySet; retainedKeys: ReadonlySet; exactModelRefs: readonly string[]; providerWildcards: ReadonlySet; hasConfiguredEntries: boolean; hasProviderWildcards: boolean; + allowConfigPath?: string | null; + allowRepairConfigPath: string; + automaticFallbackKeys: ReadonlySet; allowsKey: (key: string) => boolean; allows: (ref: { provider: string; model: string }) => boolean; resolveSelection: (ref: { provider: string; model: string }) => ModelRef | null; @@ -1557,22 +1699,43 @@ export function createModelVisibilityPolicyWithFallbacks( defaultModel?: string; fallbackModels: readonly string[]; additionalConfiguredModelRefs?: readonly string[]; + agentId?: string; allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext, ): ModelVisibilityPolicy { - const visibility = parseConfiguredModelVisibilityEntries({ cfg: params.cfg }); - const allowed = buildAllowedModelSetWithFallbacks(params); - const aliasIndex = buildModelAliasIndex({ + const visibility = parseConfiguredModelVisibilityEntries({ + cfg: params.cfg, + agentId: params.agentId, + }); + const policyAliasAgentId = resolvePolicyAliasAgentId(visibility.configPath, params.agentId); + const policyAliasIndex = buildModelAliasIndex({ cfg: params.cfg, defaultProvider: params.defaultProvider, + agentId: policyAliasAgentId, allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, }); + const selectionAliasIndex = + params.agentId && policyAliasAgentId !== params.agentId + ? buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + agentId: params.agentId, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + manifestPlugins: params.manifestPlugins, + }) + : policyAliasIndex; + const allowed = buildAllowedModelSetWithFallbacks({ ...params, aliasIndex: policyAliasIndex }); const configuredKeys = new Set(allowed.configuredCatalog.map(modelCatalogLogicalKey)); const retainedKeys = new Set(); - const addConfiguredRef = (raw: string | undefined, retained: boolean) => { + const addConfiguredRef = ( + raw: string | undefined, + retained: boolean, + aliasIndex: ModelAliasIndex, + ) => { if (!raw?.trim() || parseProviderWildcardModelRef(raw)) { return; } @@ -1597,42 +1760,49 @@ export function createModelVisibilityPolicyWithFallbacks( retainedKeys.add(key); } }; - for (const raw of [ - ...visibility.exactModelRefs, - ...(params.additionalConfiguredModelRefs ?? []), - ]) { - addConfiguredRef(raw, false); + for (const raw of visibility.exactModelRefs) { + addConfiguredRef(raw, false, policyAliasIndex); } - addConfiguredRef(params.defaultModel, true); + for (const raw of params.additionalConfiguredModelRefs ?? []) { + addConfiguredRef(raw, false, selectionAliasIndex); + } + addConfiguredRef(params.defaultModel, true, selectionAliasIndex); for (const fallback of params.fallbackModels) { - addConfiguredRef(fallback, true); + // Configured fallbacks remain available for automatic failover and catalog + // retention, but are not user-selectable overrides unless policy also allows them. + addConfiguredRef(fallback, true, selectionAliasIndex); } const allowsKey = (key: string): boolean => allowed.allowAny || isModelKeyAllowedBySet(allowed.allowedKeys, key); const exactConfiguredKeys = new Set(); for (const raw of visibility.exactModelRefs) { - const key = resolveAllowlistModelKey({ + const resolved = resolveModelRefFromString({ cfg: params.cfg, raw, defaultProvider: params.defaultProvider, + aliasIndex: policyAliasIndex, allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, - }); - if (key) { - exactConfiguredKeys.add(key); + })?.ref; + if (resolved) { + exactConfiguredKeys.add(modelKey(resolved.provider, resolved.model)); } } const policy: ModelVisibilityPolicy = { allowAny: allowed.allowAny, allowedCatalog: allowed.allowedCatalog, allowedKeys: allowed.allowedKeys, + policyAliasIndex, configuredKeys, retainedKeys, exactModelRefs: visibility.exactModelRefs, providerWildcards: visibility.providerWildcards, hasConfiguredEntries: visibility.hasEntries, hasProviderWildcards: visibility.providerWildcards.size > 0, + allowConfigPath: visibility.configPath, + allowRepairConfigPath: visibility.repairConfigPath, + automaticFallbackKeys: allowed.automaticFallbackKeys, allowsKey, allows: (ref) => allowsKey(modelKey(ref.provider, ref.model)), resolveSelection: (ref) => diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 5c4b13b82c36..e3d01d848c14 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -7,6 +7,7 @@ import { resolveAgentHarnessPolicy } from "./harness/policy.js"; import { isModelKeyAllowedBySet } from "./model-selection-shared.js"; import { buildAllowedModelSet, + buildConfiguredAllowlistKeys, buildConfiguredModelCatalog, inferUniqueProviderFromConfiguredModels, parseModelRef, @@ -148,6 +149,7 @@ const EXPLICIT_ALLOWLIST_CONFIG = { models: { "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, }, + modelPolicy: { allow: ["anthropic/claude-sonnet-4-6"] }, }, }, } as OpenClawConfig; @@ -261,6 +263,7 @@ function createAgentFallbackConfig(params: { models: { "openai/gpt-4o": {}, }, + modelPolicy: { allow: ["openai/gpt-4o"] }, model: { primary: params.primary ?? "openai/gpt-4o", fallbacks: params.fallbacks ?? [], @@ -1044,6 +1047,45 @@ describe("model-selection", () => { }); }); + describe("buildConfiguredAllowlistKeys", () => { + it("resolves per-agent policy aliases to the enforcement key", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + }, + list: [ + { + id: "research", + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + modelPolicy: { allow: ["sonnet"] }, + }, + ], + }, + } as OpenClawConfig; + + const keys = buildConfiguredAllowlistKeys({ + cfg, + defaultProvider: "openai", + agentId: "research", + }); + const policy = createModelVisibilityPolicy({ + cfg, + catalog: [], + defaultProvider: "openai", + defaultModel: "gpt-5.5", + agentId: "research", + }); + + expect(keys).toEqual(new Set(["anthropic/claude-sonnet-4-6"])); + expect(keys?.has("openai/sonnet")).toBe(false); + expect(policy.allowsKey("anthropic/claude-sonnet-4-6")).toBe(true); + expect(policy.allowsKey("openai/sonnet")).toBe(false); + }); + }); + describe("buildAllowedModelSet", () => { it("keeps explicitly allowlisted models even when missing from bundled catalog", () => { const result = buildAllowedModelSet({ @@ -1072,6 +1114,7 @@ describe("model-selection", () => { models: { "openai/gpt-test-z": { alias: "GPT Test Z Alias" }, }, + modelPolicy: { allow: ["openai/gpt-test-z"] }, }, }, models: { @@ -1206,6 +1249,7 @@ describe("model-selection", () => { "openai/*": {}, "vllm/*": {}, }, + modelPolicy: { allow: ["openai/*", "vllm/*"] }, }, }, } as unknown as OpenClawConfig; @@ -1243,6 +1287,7 @@ describe("model-selection", () => { models: { "openai/*": {}, }, + modelPolicy: { allow: ["openai/*"] }, }, }, } as unknown as OpenClawConfig; @@ -1268,6 +1313,7 @@ describe("model-selection", () => { "openai/*": {}, "anthropic/claude-sonnet-4-6": {}, }, + modelPolicy: { allow: ["openai/*", "anthropic/claude-sonnet-4-6"] }, }, }, } as unknown as OpenClawConfig; @@ -1308,6 +1354,7 @@ describe("model-selection", () => { "vllm/*": {}, "vllm/manual": {}, }, + modelPolicy: { allow: ["vllm/*", "vllm/manual"] }, }, }, } as unknown as OpenClawConfig; @@ -1338,6 +1385,7 @@ describe("model-selection", () => { "openai/*": {}, "google/gemini-test": {}, }, + modelPolicy: { allow: ["openai/*", "google/gemini-test"] }, }, }, } as unknown as OpenClawConfig; @@ -1369,6 +1417,7 @@ describe("model-selection", () => { "anthropic/claude-sonnet-4-6": {}, "openai/*": {}, }, + modelPolicy: { allow: ["anthropic/claude-sonnet-4-6", "openai/*"] }, }, }, } as unknown as OpenClawConfig; @@ -1401,6 +1450,7 @@ describe("model-selection", () => { models: { "modelscope/Qwen/Qwen3.5-35B-A3B": {}, }, + modelPolicy: { allow: ["modelscope/Qwen/Qwen3.5-35B-A3B"] }, }, }, } as unknown as OpenClawConfig; @@ -1433,6 +1483,7 @@ describe("model-selection", () => { models: { "nvidia/moonshotai/kimi-k2.5": { alias: "Kimi K2.5 (NVIDIA)" }, }, + modelPolicy: { allow: ["nvidia/moonshotai/kimi-k2.5"] }, }, }, models: { @@ -1477,7 +1528,7 @@ describe("model-selection", () => { ]); }); - it("includes fallback models in allowed set", () => { + it("keeps fallback models separate from explicit override authorization", () => { const cfg = createAgentFallbackConfig({ fallbacks: ["anthropic/claude-sonnet-4-6", "google/gemini-3-pro"], }); @@ -1490,8 +1541,11 @@ describe("model-selection", () => { }); expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true); - expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true); - expect(result.allowedKeys.has("google/gemini-3.1-pro-preview")).toBe(true); + expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(false); + expect(result.allowedKeys.has("google/gemini-3.1-pro-preview")).toBe(false); + expect(result.automaticFallbackKeys).toEqual( + new Set(["anthropic/claude-sonnet-4-6", "google/gemini-3.1-pro-preview"]), + ); expect(result.allowAny).toBe(false); }); @@ -1524,8 +1578,9 @@ describe("model-selection", () => { }); expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true); - expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true); + expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(false); expect(result.allowedKeys.has("google/gemini-3.1-pro-preview")).toBe(false); + expect(result.automaticFallbackKeys).toEqual(new Set(["anthropic/claude-sonnet-4-6"])); expect(result.allowAny).toBe(false); }); }); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 699b9000265f..347c633dd9c9 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -379,12 +379,14 @@ export function buildAllowedModelSet( allowAny: boolean; allowedCatalog: ModelCatalogEntry[]; allowedKeys: Set; + automaticFallbackKeys: Set; } { return buildAllowedModelSetWithFallbacks({ cfg: params.cfg, catalog: params.catalog, defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, + agentId: params.agentId, fallbackModels: resolveAllowedFallbacks({ cfg: params.cfg, agentId: params.agentId, diff --git a/src/agents/model-visibility-policy.test.ts b/src/agents/model-visibility-policy.test.ts new file mode 100644 index 000000000000..36099031b02c --- /dev/null +++ b/src/agents/model-visibility-policy.test.ts @@ -0,0 +1,312 @@ +// Explicit model policy tests keep catalog metadata separate from override restrictions. +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.js"; +import { createModelVisibilityPolicy } from "./model-visibility-policy.js"; + +function createPolicy(cfg: OpenClawConfig, agentId?: string) { + return createModelVisibilityPolicy({ + cfg, + catalog: [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" }, + { provider: "external", id: "sensitive", name: "Sensitive external model" }, + { provider: "openai", id: "gpt-5.5", name: "GPT 5.5" }, + { provider: "openai", id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }, + ], + defaultProvider: "openai", + defaultModel: "gpt-5.5", + agentId, + }); +} + +describe("explicit model visibility policy", () => { + it("keeps overrides open when model entries only configure aliases or params", () => { + const policy = createPolicy({ + meta: { migrations: { modelPolicyAllowlist: true } }, + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + }); + + expect(policy.allowAny).toBe(true); + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(true); + }); + + it("closes overrides only for an explicit allow list", () => { + const policy = createPolicy({ + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + modelPolicy: { allow: ["anthropic/claude-sonnet-4-6"] }, + }, + }, + }); + + expect(policy.allowAny).toBe(false); + expect(policy.allowConfigPath).toBe("agents.defaults.modelPolicy.allow"); + expect(policy.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(true); + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(false); + }); + + it("does not let model metadata widen an explicit policy", () => { + const policy = createPolicy({ + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + modelPolicy: { allow: ["openai/gpt-5.5"] }, + }, + }, + }); + + expect(policy.allows({ provider: "openai", model: "gpt-5.5" })).toBe(true); + expect(policy.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(false); + }); + + it("keeps configured fallbacks failover-only while retaining the configured primary", () => { + const policy = createPolicy({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5", + fallbacks: ["external/sensitive"], + }, + modelPolicy: { allow: ["openai/safe"] }, + }, + }, + }); + + expect(policy.allows({ provider: "openai", model: "gpt-5.5" })).toBe(true); + expect(policy.allows({ provider: "openai", model: "safe" })).toBe(true); + expect(policy.allows({ provider: "external", model: "sensitive" })).toBe(false); + expect(policy.automaticFallbackKeys).toEqual(new Set(["external/sensitive"])); + }); + + it("allows a configured fallback when the explicit policy also allows it", () => { + const policy = createPolicy({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.5", + fallbacks: ["external/sensitive"], + }, + modelPolicy: { allow: ["openai/safe", "external/sensitive"] }, + }, + }, + }); + + expect(policy.allows({ provider: "external", model: "sensitive" })).toBe(true); + }); + + it("honors provider wildcards", () => { + const policy = createPolicy({ + agents: { + defaults: { + modelPolicy: { allow: ["openai/*"] }, + }, + }, + }); + + expect(policy.allows({ provider: "openai", model: "future-model" })).toBe(true); + expect(policy.allowedCatalog.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([ + "openai/gpt-5.5", + "openai/gpt-5.6-sol", + ]); + expect(policy.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(false); + }); + + it("resolves conflicting policy aliases in each agent's model map", () => { + const cfg: OpenClawConfig = { + agents: { + list: [ + { + id: "research", + models: { "anthropic/claude-sonnet-4-6": { alias: "sonnet" } }, + modelPolicy: { allow: ["sonnet"] }, + }, + { + id: "writer", + models: { "openai/gpt-5.6-sol": { alias: "sonnet" } }, + modelPolicy: { allow: ["sonnet"] }, + }, + ], + }, + }; + + const research = createPolicy(cfg, "research"); + expect(research.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(true); + expect(research.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(false); + + const writer = createPolicy(cfg, "writer"); + expect(writer.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(true); + expect(writer.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(false); + }); + + it("resolves an inherited default policy alias in the default scope", () => { + const policy = createPolicy( + { + meta: { migrations: { modelPolicyAllowlist: true } }, + agents: { + defaults: { + models: { "anthropic/claude-sonnet-4-6": { alias: "approved" } }, + modelPolicy: { allow: ["approved"] }, + }, + list: [ + { + id: "research", + models: { "openai/gpt-5.6-sol": { alias: "approved" } }, + }, + ], + }, + }, + "research", + ); + + expect(policy.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(true); + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(false); + }); + + it("resolves an explicit per-agent policy alias in the agent scope", () => { + const policy = createPolicy( + { + meta: { migrations: { modelPolicyAllowlist: true } }, + agents: { + defaults: { + models: { "anthropic/claude-sonnet-4-6": { alias: "approved" } }, + modelPolicy: { allow: ["approved"] }, + }, + list: [ + { + id: "research", + models: { "openai/gpt-5.6-sol": { alias: "approved" } }, + modelPolicy: { allow: ["approved"] }, + }, + ], + }, + }, + "research", + ); + + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(true); + expect(policy.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(false); + }); + + it("supports per-agent replacement and explicit allow-any", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + modelPolicy: { allow: ["openai/*"] }, + }, + list: [ + { + id: "research", + models: { "anthropic/claude-sonnet-4-6": { alias: "sonnet" } }, + modelPolicy: { allow: ["anthropic/*"] }, + }, + { id: "open", modelPolicy: { allow: [] } }, + ], + }, + }; + + const research = createPolicy(cfg, "research"); + expect(research.allowConfigPath).toBe("agents.list[].modelPolicy.allow"); + expect(research.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(true); + expect(research.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(false); + + const open = createPolicy(cfg, "open"); + expect(open.allowAny).toBe(true); + expect(open.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(true); + }); + + it("does not let unmarked per-agent metadata override an explicit default policy", () => { + const policy = createPolicy( + { + agents: { + defaults: { modelPolicy: { allow: ["openai/*"] } }, + list: [ + { + id: "research", + models: { "external/sensitive": { alias: "sensitive" } }, + }, + ], + }, + }, + "research", + ); + + expect(policy.allowAny).toBe(false); + expect(policy.allowConfigPath).toBe("agents.defaults.modelPolicy.allow"); + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(true); + expect(policy.allows({ provider: "external", model: "sensitive" })).toBe(false); + }); + + it("preserves an unmarked legacy default restriction before doctor runs", () => { + const policy = createPolicy({ + agents: { + defaults: { + models: { "openai/gpt-5.5": {} }, + }, + }, + }); + + expect(policy.allowAny).toBe(false); + expect(policy.allowConfigPath).toBe("agents.defaults.models"); + expect(policy.allowRepairConfigPath).toBe("agents.defaults.modelPolicy.allow"); + expect(policy.allows({ provider: "openai", model: "gpt-5.5" })).toBe(true); + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(false); + }); + + it("inherits the unmarked legacy default restriction despite per-agent metadata", () => { + const policy = createPolicy( + { + agents: { + defaults: { models: { "openai/*": {} } }, + list: [ + { + id: "research", + models: { "anthropic/claude-sonnet-4-6": { alias: "sonnet" } }, + }, + ], + }, + }, + "research", + ); + + expect(policy.allowAny).toBe(false); + expect(policy.allowConfigPath).toBe("agents.defaults.models"); + expect(policy.allowRepairConfigPath).toBe("agents.defaults.modelPolicy.allow"); + expect(policy.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(false); + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(true); + }); + + it("keeps an effectively allow-any unmarked legacy map unrestricted", () => { + const policy = createPolicy({ + agents: { defaults: { models: { " ": {} } } }, + }); + + expect(policy.allowAny).toBe(true); + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(true); + }); + + it("does not resurrect legacy maps after an empty marked policy", () => { + const policy = createPolicy({ + meta: { migrations: { modelPolicyAllowlist: true } }, + agents: { + defaults: { + models: { "openai/gpt-5.5": {} }, + modelPolicy: { allow: [] }, + }, + }, + }); + + expect(policy.allowAny).toBe(true); + expect(policy.allowConfigPath).toBe("agents.defaults.modelPolicy.allow"); + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(true); + }); +}); diff --git a/src/agents/model-visibility-policy.ts b/src/agents/model-visibility-policy.ts index f3d34f891379..f3ed9ab62827 100644 --- a/src/agents/model-visibility-policy.ts +++ b/src/agents/model-visibility-policy.ts @@ -1,5 +1,5 @@ /** - * Builds model visibility policies with configured fallbacks included. + * Builds model visibility policies while retaining configured automatic fallbacks. */ import { resolveAgentModelFallbackValues } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -42,6 +42,7 @@ export function createModelVisibilityPolicy( catalog: params.catalog, defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, + agentId: params.agentId, fallbackModels: resolveAllowedFallbacks({ cfg: params.cfg, agentId: params.agentId, diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 5fcefab10cbe..168bfe8fe669 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -237,6 +237,7 @@ export async function buildModelsProviderData( const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: resolvedDefault.provider, + agentId, }); const restrictToProviderWildcards = options.view !== "all" && visibilityPolicy.hasProviderWildcards; diff --git a/src/auto-reply/reply/directive-handling.defaults.ts b/src/auto-reply/reply/directive-handling.defaults.ts index f7639db9d39a..48ca0d9d524c 100644 --- a/src/auto-reply/reply/directive-handling.defaults.ts +++ b/src/auto-reply/reply/directive-handling.defaults.ts @@ -24,6 +24,7 @@ export function resolveDefaultModel(params: { cfg: OpenClawConfig; agentId?: str const aliasIndex = buildModelAliasIndex({ cfg: params.cfg, defaultProvider, + agentId: params.agentId, allowPluginNormalization: false, }); return { defaultProvider, defaultModel, aliasIndex }; diff --git a/src/auto-reply/reply/directive-handling.fast-lane.ts b/src/auto-reply/reply/directive-handling.fast-lane.ts index 81d34db095ac..34c5f448cd01 100644 --- a/src/auto-reply/reply/directive-handling.fast-lane.ts +++ b/src/auto-reply/reply/directive-handling.fast-lane.ts @@ -31,6 +31,7 @@ export async function applyInlineDirectivesFastLane( defaultProvider, defaultModel, aliasIndex, + policyAliasIndex, allowedModelKeys, allowedModelCatalog, resetModelOverride, @@ -83,6 +84,7 @@ export async function applyInlineDirectivesFastLane( defaultProvider, defaultModel, aliasIndex, + policyAliasIndex, allowedModelKeys, allowedModelCatalog, thinkingCatalog: await modelState.resolveThinkingCatalog(), diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 68039dfe35b4..a8c9516f6ade 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -70,6 +70,7 @@ export async function handleDirectiveOnly( defaultProvider, defaultModel, aliasIndex, + policyAliasIndex, allowedModelKeys, allowedModelCatalog, resetModelOverride, @@ -129,6 +130,8 @@ export async function handleDirectiveOnly( defaultProvider, defaultModel, aliasIndex, + policyAliasIndex, + allowedModelKeys, allowedModelCatalog, resetModelOverride, workspaceDir: params.workspaceDir, @@ -149,6 +152,7 @@ export async function handleDirectiveOnly( allowedModelKeys, allowedModelCatalog, provider, + agentId: activeAgentId, }); if (modelResolution.errorText) { return { text: modelResolution.errorText }; diff --git a/src/auto-reply/reply/directive-handling.model-selection.ts b/src/auto-reply/reply/directive-handling.model-selection.ts index e972921c9146..72171a163161 100644 --- a/src/auto-reply/reply/directive-handling.model-selection.ts +++ b/src/auto-reply/reply/directive-handling.model-selection.ts @@ -56,6 +56,7 @@ export function resolveModelSelectionFromDirective(params: { allowedModelKeys: Set; allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>; provider: string; + agentId?: string; }): { modelSelection?: ModelDirectiveSelection; profileOverride?: string; @@ -92,6 +93,8 @@ export function resolveModelSelectionFromDirective(params: { defaultModel: params.defaultModel, aliasIndex: params.aliasIndex, allowedModelKeys: params.allowedModelKeys, + cfg: params.cfg, + agentId: params.agentId, rawRuntime: params.directives.rawModelRuntime, }) : null; @@ -147,6 +150,8 @@ export function resolveModelSelectionFromDirective(params: { defaultModel: params.defaultModel, aliasIndex: params.aliasIndex, allowedModelKeys: params.allowedModelKeys, + cfg: params.cfg, + agentId: params.agentId, rawRuntime: params.directives.rawModelRuntime, }); diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index d0e6dff9ae46..2d19c33bf9ce 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -305,6 +305,8 @@ import type { ElevatedLevel } from "../thinking.js"; let handleDirectiveOnly: typeof import("./directive-handling.impl.js").handleDirectiveOnly; let cliBackendsTesting: typeof import("../../agents/cli-backends.test-support.js").testing; let maybeHandleModelDirectiveInfo: typeof import("./directive-handling.model.js").maybeHandleModelDirectiveInfo; +let createModelVisibilityPolicy: typeof import("../../agents/model-visibility-policy.js").createModelVisibilityPolicy; +let buildModelAliasIndex: typeof import("../../agents/model-selection.js").buildModelAliasIndex; let resolveModelSelectionFromDirective: typeof import("./directive-handling.model-selection.js").resolveModelSelectionFromDirective; let parseInlineDirectives: typeof import("./directive-handling.parse.js").parseInlineDirectives; let persistInlineDirectives: typeof import("./directive-handling.persist.js").persistInlineDirectives; @@ -313,6 +315,8 @@ beforeAll(async () => { ({ testing: cliBackendsTesting } = await import("../../agents/cli-backends.test-support.js")); ({ handleDirectiveOnly } = await import("./directive-handling.impl.js")); ({ maybeHandleModelDirectiveInfo } = await import("./directive-handling.model.js")); + ({ createModelVisibilityPolicy } = await import("../../agents/model-visibility-policy.js")); + ({ buildModelAliasIndex } = await import("../../agents/model-selection.js")); ({ resolveModelSelectionFromDirective } = await import("./directive-handling.model-selection.js")); ({ parseInlineDirectives } = await import("./directive-handling.parse.js")); @@ -523,10 +527,13 @@ function resolveModelSelectionForCommand(params: { command: string; allowedModelKeys: Set; allowedModelCatalog: Array<{ provider: string; id: string }>; + cfg?: OpenClawConfig; + agentId?: string; }) { return resolveModelSelectionFromDirective({ directives: parseInlineDirectives(params.command), - cfg: { commands: { text: true } } as unknown as OpenClawConfig, + cfg: params.cfg ?? ({ commands: { text: true } } as unknown as OpenClawConfig), + agentId: params.agentId, agentDir: TEST_AGENT_DIR, defaultProvider: "anthropic", defaultModel: "claude-opus-4-6", @@ -628,6 +635,7 @@ async function resolveModelInfoReply( defaultProvider: "anthropic", defaultModel: "claude-opus-4-6", aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(), allowedModelCatalog: [], resetModelOverride: false, ...overrides, @@ -769,6 +777,126 @@ describe("/model chat UX", () => { expect(reply?.text).not.toContain("missing (missing)"); }); + it("expands provider wildcard models without retaining a rejected default", async () => { + const reply = await resolveModelInfoReply({ + directives: parseInlineDirectives("/model status"), + provider: "anthropic", + model: "claude-sonnet-4-6", + defaultProvider: "openai", + defaultModel: "gpt-5.5", + cfg: { + commands: { text: true }, + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + modelPolicy: { allow: ["anthropic/*"] }, + }, + }, + } as unknown as OpenClawConfig, + allowedModelKeys: new Set(["anthropic/*"]), + allowedModelCatalog: [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { provider: "anthropic", id: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }, + ], + }); + + expect(reply?.text).toContain("anthropic/claude-sonnet-4-6"); + expect(reply?.text).toContain("anthropic/claude-opus-4-6"); + expect(reply?.text).not.toContain(" • openai/gpt-5.5"); + }); + + it("resolves config-dependent policy refs identically in enforcement and picker", async () => { + const cfg = { + commands: { text: true }, + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "openrouter/meta-llama/llama-3.3-70b-instruct:free": {}, + }, + modelPolicy: { allow: ["openrouter:free"] }, + }, + }, + } as unknown as OpenClawConfig; + const policy = createModelVisibilityPolicy({ + cfg, + catalog: [], + defaultProvider: "anthropic", + defaultModel: "claude-sonnet-4-6", + allowManifestNormalization: true, + allowPluginNormalization: true, + }); + + const reply = await resolveModelInfoReply({ + directives: parseInlineDirectives("/model status"), + cfg, + allowedModelKeys: policy.allowedKeys, + allowedModelCatalog: policy.allowedCatalog, + }); + + expect(policy.allowsKey("openrouter/meta-llama/llama-3.3-70b-instruct:free")).toBe(true); + expect(reply?.text).toContain("openrouter/meta-llama/llama-3.3-70b-instruct:free"); + expect(reply?.text).not.toContain("anthropic/openrouter:free"); + }); + + it("resolves inherited policy aliases with the default-scoped index in the picker", async () => { + const cfg = { + commands: { text: true }, + meta: { migrations: { modelPolicyAllowlist: true } }, + agents: { + defaults: { + model: { primary: "provider-a/model-a" }, + models: { + "provider-a/model-a": { alias: "approved" }, + }, + modelPolicy: { allow: ["approved"] }, + }, + list: [ + { + id: "main", + models: { + "provider-b/model-b": { alias: "approved" }, + }, + }, + ], + }, + } as unknown as OpenClawConfig; + const policy = createModelVisibilityPolicy({ + cfg, + catalog: [], + defaultProvider: "provider-a", + defaultModel: "model-a", + agentId: "main", + }); + const agentAliasIndex = buildModelAliasIndex({ + cfg, + defaultProvider: "provider-a", + agentId: "main", + }); + + const reply = await resolveModelInfoReply({ + directives: parseInlineDirectives("/model status"), + cfg, + activeAgentId: "main", + defaultProvider: "provider-a", + defaultModel: "model-a", + aliasIndex: agentAliasIndex, + policyAliasIndex: policy.policyAliasIndex, + allowedModelKeys: policy.allowedKeys, + allowedModelCatalog: policy.allowedCatalog, + }); + + expect(agentAliasIndex.byAlias.get("approved")?.ref).toEqual({ + provider: "provider-b", + model: "model-b", + }); + expect(policy.allows({ provider: "provider-a", model: "model-a" })).toBe(true); + expect(policy.allows({ provider: "provider-b", model: "model-b" })).toBe(false); + expect(reply?.text).toContain("provider-a/model-a"); + expect(reply?.text).not.toContain("provider-b/model-b"); + }); + it("hides missing-auth direct provider rows covered by OpenRouter nested model ids", async () => { const reply = await resolveModelInfoReply({ directives: parseInlineDirectives("/model status"), @@ -1164,12 +1292,30 @@ describe("/model chat UX", () => { expect(resolved.modelSelection).toBeUndefined(); expect(resolved.errorText).toContain('Model "openai/gpt-5.5" is not allowed.'); expect(resolved.errorText).toContain( - `openclaw config set agents.defaults.models '{"openai/gpt-5.5":{}}' --strict-json --merge`, + 'Add "openai/gpt-5.5" or its provider wildcard to agents.defaults.modelPolicy.allow.', ); expect(resolved.errorText).toContain("Then retry: /model openai/gpt-5.5 --runtime codex"); expect(resolved.errorText).toContain("openclaw plugins enable codex"); }); + it("names the active per-agent allowlist in repair guidance", () => { + const resolved = resolveModelSelectionForCommand({ + command: "/model openai/gpt-5.5", + allowedModelKeys: new Set(["anthropic/claude-opus-4-6"]), + allowedModelCatalog: [], + cfg: { + agents: { + list: [{ id: "ops", modelPolicy: { allow: ["anthropic/*"] } }], + }, + }, + agentId: "ops", + }); + + expect(resolved.errorText).toContain( + 'Add "openai/gpt-5.5" or its provider wildcard to agents.list[].modelPolicy.allow.', + ); + }); + it("treats explicit default /model selection as resettable default", () => { const resolved = resolveModelSelectionForCommand({ command: "/model anthropic/claude-opus-4-6", diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index ed24b6358395..6066d0b9ce3d 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -6,6 +6,11 @@ import { import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles.js"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; +import { + expandModelCatalogProviderWildcards, + isModelKeyAllowedBySet, + parseConfiguredModelVisibilityEntries, +} from "../../agents/model-selection-shared.js"; import { type ModelAliasIndex, buildConfiguredModelCatalog, @@ -14,6 +19,7 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; +import { RUNTIME_MODEL_VISIBILITY_NORMALIZATION } from "../../agents/model-visibility-policy.js"; import { buildAgentRuntimeAuthPlan } from "../../agents/runtime-plan/auth.js"; import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; @@ -161,13 +167,17 @@ function buildModelPickerCatalog(params: { cfg: OpenClawConfig; defaultProvider: string; defaultModel: string; + agentId: string; aliasIndex: ModelAliasIndex; + policyAliasIndex: ModelAliasIndex; + allowedModelKeys: ReadonlySet; allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>; }): ModelPickerCatalogEntry[] { const resolvedDefault = resolveConfiguredModelRef({ cfg: params.cfg, defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }); const buildConfiguredCatalog = (): ModelPickerCatalogEntry[] => { @@ -239,8 +249,11 @@ function buildModelPickerCatalog(params: { }); }; - const hasAllowlist = Object.keys(params.cfg.agents?.defaults?.models ?? {}).length > 0; - if (!hasAllowlist) { + const visibility = parseConfiguredModelVisibilityEntries({ + cfg: params.cfg, + agentId: params.agentId, + }); + if (!visibility.hasEntries) { for (const entry of params.allowedModelCatalog) { push({ provider: entry.provider, @@ -254,9 +267,12 @@ function buildModelPickerCatalog(params: { return out; } - // Prefer catalog entries (when available), but always merge in config-only - // allowlist entries. This keeps custom providers/models visible in /model. - for (const entry of params.allowedModelCatalog) { + // Expand wildcard policy entries through the same discovered-catalog path as + // the main model selection policy. + for (const entry of expandModelCatalogProviderWildcards( + params.allowedModelCatalog, + visibility.providerWildcards, + )) { push({ provider: entry.provider, id: entry.id ?? "", @@ -264,25 +280,42 @@ function buildModelPickerCatalog(params: { }); } - // Merge any configured allowlist keys that the catalog doesn't know about. - for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) { + // Merge exact policy refs that the catalog doesn't know about. + for (const raw of visibility.exactModelRefs) { const resolved = resolveModelRefFromString({ + cfg: params.cfg, raw, defaultProvider: params.defaultProvider, - aliasIndex: params.aliasIndex, + aliasIndex: params.policyAliasIndex, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }); if (!resolved) { continue; } - push({ - provider: resolved.ref.provider, - id: resolved.ref.model, - name: resolved.ref.model, - }); + const catalogEntry = params.allowedModelCatalog.find( + (entry) => + modelKey(entry.provider, entry.id ?? "") === + modelKey(resolved.ref.provider, resolved.ref.model), + ); + push( + catalogEntry + ? { provider: catalogEntry.provider, id: catalogEntry.id ?? "", name: catalogEntry.name } + : { + provider: resolved.ref.provider, + id: resolved.ref.model, + name: resolved.ref.model, + }, + ); } - // Ensure the configured default is always present (even when no allowlist). - if (resolvedDefault.model) { + // A restricted picker must not reintroduce a default rejected by the active policy. + if ( + resolvedDefault.model && + isModelKeyAllowedBySet( + params.allowedModelKeys, + modelKey(resolvedDefault.provider, resolvedDefault.model), + ) + ) { push({ provider: resolvedDefault.provider, id: resolvedDefault.model, @@ -343,6 +376,8 @@ export async function maybeHandleModelDirectiveInfo(params: { defaultProvider: string; defaultModel: string; aliasIndex: ModelAliasIndex; + policyAliasIndex?: ModelAliasIndex; + allowedModelKeys: ReadonlySet; allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>; resetModelOverride: boolean; workspaceDir?: string; @@ -371,7 +406,10 @@ export async function maybeHandleModelDirectiveInfo(params: { cfg: params.cfg, defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, + agentId: params.activeAgentId, aliasIndex: params.aliasIndex, + policyAliasIndex: params.policyAliasIndex ?? params.aliasIndex, + allowedModelKeys: params.allowedModelKeys, allowedModelCatalog: params.allowedModelCatalog, }); diff --git a/src/auto-reply/reply/directive-handling.params.ts b/src/auto-reply/reply/directive-handling.params.ts index 6786bf9ae84a..c5bd06e37778 100644 --- a/src/auto-reply/reply/directive-handling.params.ts +++ b/src/auto-reply/reply/directive-handling.params.ts @@ -23,6 +23,7 @@ type HandleDirectiveOnlyCoreParams = { defaultProvider: string; defaultModel: string; aliasIndex: ModelAliasIndex; + policyAliasIndex?: ModelAliasIndex; allowedModelKeys: Set; allowedModelCatalog: Awaited< ReturnType @@ -69,6 +70,7 @@ export type ApplyInlineDirectivesFastLaneParams = HandleDirectiveOnlyCoreParams allowedModelCatalog: Awaited< ReturnType >; + policyAliasIndex?: ModelAliasIndex; resetModelOverride: boolean; }; }; diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index ed3209e19fbf..6fb4cb2ee38c 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -152,6 +152,7 @@ export async function persistInlineDirectives(params: { allowedModelKeys, allowedModelCatalog: params.modelCatalog ?? [], provider, + agentId: activeAgentId, }) : undefined; const modelRuntimeResolution = modelResolution?.modelSelection diff --git a/src/auto-reply/reply/get-reply-directives-apply.test.ts b/src/auto-reply/reply/get-reply-directives-apply.test.ts index 964c8454d9d5..a1393ddbc3f8 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.test.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.test.ts @@ -35,23 +35,43 @@ describe("applyInlineDirectiveOverrides", () => { { rejectedRef: "ollama/Gemma4-26b-a4-it-gguf", reason: "disallowed" as const, + modelPolicyConfigPath: undefined, + modelPolicyRepairConfigPath: undefined, expected: - "Model override ollama/Gemma4-26b-a4-it-gguf is not allowed for this agent; reverted to openai/gpt-5.5. Add ollama/Gemma4-26b-a4-it-gguf to agents.defaults.models or pick an allowed model with /model list.", + "Model override ollama/Gemma4-26b-a4-it-gguf is not allowed for this agent by modelPolicy.allow; reverted to openai/gpt-5.5. Add ollama/Gemma4-26b-a4-it-gguf to modelPolicy.allow or pick an allowed model with /model list.", }, { rejectedRef: undefined, reason: "disallowed" as const, + modelPolicyConfigPath: undefined, + modelPolicyRepairConfigPath: undefined, expected: "Model override not allowed for this agent; reverted to openai/gpt-5.5.", }, { rejectedRef: "openai/gpt-4o", reason: "stale" as const, + modelPolicyConfigPath: undefined, + modelPolicyRepairConfigPath: undefined, expected: "Stored model override openai/gpt-4o is stale for this session; reverted to openai/gpt-5.5. Pick a model again with /model if you still want to override the default.", }, + { + rejectedRef: "external/sensitive", + reason: "disallowed" as const, + modelPolicyConfigPath: "agents.defaults.models", + modelPolicyRepairConfigPath: "agents.defaults.modelPolicy.allow", + expected: + "Model override external/sensitive is not allowed for this agent by agents.defaults.models; reverted to openai/gpt-5.5. Add external/sensitive to agents.defaults.modelPolicy.allow or pick an allowed model with /model list.", + }, ])( "emits the $reason reset event before rejecting a locked mixed directive", - async ({ rejectedRef, reason, expected }) => { + async ({ + rejectedRef, + reason, + modelPolicyConfigPath, + modelPolicyRepairConfigPath, + expected, + }) => { const directives = parseInlineDirectives("hello /model openai/gpt-5.4 --runtime openclaw"); const typing = { onReplyStart: async () => {}, @@ -81,6 +101,8 @@ describe("applyInlineDirectiveOverrides", () => { resetModelOverride: true, resetModelOverrideRef: rejectedRef, resetModelOverrideReason: reason, + modelPolicyConfigPath, + modelPolicyRepairConfigPath, }); const result = await applyInlineDirectiveOverrides({ diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index fe3fe1804877..3a284bc9a5b4 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -73,6 +73,8 @@ function formatModelOverrideResetEvent(params: { rejectedRef?: string; initialModelLabel: string; reason?: "disallowed" | "stale" | "temporarily-unavailable"; + modelPolicyConfigPath?: string; + modelPolicyRepairConfigPath?: string; }): string { if (params.reason === "temporarily-unavailable") { // Non-destructive: the pin is preserved and comes back once the catalog reloads. @@ -88,7 +90,9 @@ function formatModelOverrideResetEvent(params: { return `Stored model override is stale for this session; reverted to ${params.initialModelLabel}.`; } if (params.rejectedRef) { - return `Model override ${params.rejectedRef} is not allowed for this agent; reverted to ${params.initialModelLabel}. Add ${params.rejectedRef} to agents.defaults.models or pick an allowed model with /model list.`; + const policyPath = params.modelPolicyConfigPath ?? "modelPolicy.allow"; + const repairPath = params.modelPolicyRepairConfigPath ?? "modelPolicy.allow"; + return `Model override ${params.rejectedRef} is not allowed for this agent by ${policyPath}; reverted to ${params.initialModelLabel}. Add ${params.rejectedRef} to ${repairPath} or pick an allowed model with /model list.`; } return `Model override not allowed for this agent; reverted to ${params.initialModelLabel}.`; } @@ -182,6 +186,7 @@ export async function applyInlineDirectiveOverrides(params: { const directiveModelState = { allowedModelKeys: modelState.allowedModelKeys, allowedModelCatalog: modelState.allowedModelCatalog, + policyAliasIndex: modelState.policyAliasIndex, resetModelOverride: modelState.resetModelOverride, }; const createDirectiveHandlingBase = () => ({ @@ -215,6 +220,8 @@ export async function applyInlineDirectiveOverrides(params: { rejectedRef: modelState.resetModelOverrideRef, initialModelLabel, reason: modelState.resetModelOverrideReason, + modelPolicyConfigPath: modelState.modelPolicyConfigPath, + modelPolicyRepairConfigPath: modelState.modelPolicyRepairConfigPath, }), { sessionKey, @@ -245,6 +252,7 @@ export async function applyInlineDirectiveOverrides(params: { allowedModelKeys: modelState.allowedModelKeys, allowedModelCatalog: modelState.allowedModelCatalog, provider, + agentId, }); if (lockedModelResolution.modelSelection) { typing.cleanup(); @@ -329,6 +337,7 @@ export async function applyInlineDirectiveOverrides(params: { allowedModelKeys: modelState.allowedModelKeys, allowedModelCatalog: modelState.allowedModelCatalog, provider, + agentId, }); if (modelResolution.errorText) { typing.cleanup(); diff --git a/src/auto-reply/reply/model-selection-directive.test.ts b/src/auto-reply/reply/model-selection-directive.test.ts new file mode 100644 index 000000000000..61de901007de --- /dev/null +++ b/src/auto-reply/reply/model-selection-directive.test.ts @@ -0,0 +1,80 @@ +// Covers direct model directive authorization and upgrade-era repair guidance. +import { describe, expect, it } from "vitest"; +import { buildModelAliasIndex } from "../../agents/model-selection.js"; +import { createModelVisibilityPolicy } from "../../agents/model-visibility-policy.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveModelDirectiveSelection } from "./model-selection-directive.js"; + +function resolveDirective(params: { cfg: OpenClawConfig; raw: string; agentId?: string }) { + const defaultProvider = "openai"; + const defaultModel = "safe"; + const policy = createModelVisibilityPolicy({ + cfg: params.cfg, + catalog: [], + defaultProvider, + defaultModel, + agentId: params.agentId, + }); + return { + policy, + result: resolveModelDirectiveSelection({ + raw: params.raw, + defaultProvider, + defaultModel, + aliasIndex: buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider, + agentId: params.agentId, + }), + allowedModelKeys: policy.allowedKeys, + cfg: params.cfg, + agentId: params.agentId, + }), + }; +} + +describe("resolveModelDirectiveSelection", () => { + it("rejects a configured fallback that the explicit policy does not allow", () => { + const { policy, result } = resolveDirective({ + cfg: { + agents: { + defaults: { + model: { primary: "openai/safe", fallbacks: ["external/sensitive"] }, + modelPolicy: { allow: ["openai/safe"] }, + }, + }, + }, + raw: "external/sensitive", + }); + + expect(policy.automaticFallbackKeys).toEqual(new Set(["external/sensitive"])); + expect(policy.allowedKeys.has("external/sensitive")).toBe(false); + expect(result.selection).toBeUndefined(); + expect(result.error).toContain('Model "external/sensitive" is not allowed.'); + }); + + it.each([ + { + name: "defaults", + cfg: { + agents: { defaults: { models: { "openai/safe": {} } } }, + } as OpenClawConfig, + agentId: undefined, + repairPath: "agents.defaults.modelPolicy.allow", + legacyPath: "agents.defaults.models", + }, + // Only agents.defaults.models is a legacy allowlist; per-agent models maps are + // metadata-only, so there is no per-agent legacy-repair case to cover here. + ])("points unmarked legacy $name repair at modelPolicy.allow", (testCase) => { + const { result } = resolveDirective({ + cfg: testCase.cfg, + raw: "external/sensitive", + agentId: testCase.agentId, + }); + + expect(result.error).toContain( + `Add "external/sensitive" or its provider wildcard to ${testCase.repairPath}.`, + ); + expect(result.error).not.toContain(`to ${testCase.legacyPath}.`); + }); +}); diff --git a/src/auto-reply/reply/model-selection-directive.ts b/src/auto-reply/reply/model-selection-directive.ts index 6bcd89d39159..a0662cf35be2 100644 --- a/src/auto-reply/reply/model-selection-directive.ts +++ b/src/auto-reply/reply/model-selection-directive.ts @@ -6,8 +6,10 @@ import { modelKey } from "../../agents/model-ref-shared.js"; import { isModelKeyAllowedBySet, type ModelAliasIndex, + resolveConfiguredModelPolicyAllow, resolveModelRefFromString, } from "../../agents/model-selection-shared.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; export { modelKey }; export type { ModelAliasIndex }; @@ -19,12 +21,9 @@ export type ModelDirectiveSelection = { alias?: string; }; -function formatAddModelCommand(modelRef: string): string { - return `openclaw config set agents.defaults.models '${JSON.stringify({ [modelRef]: {} })}' --strict-json --merge`; -} - function formatNotAllowedError(params: { modelRef: string; + policyPath: string; rawRuntime?: string | undefined; }): string { const rawRuntime = params.rawRuntime?.trim(); @@ -33,7 +32,7 @@ function formatNotAllowedError(params: { : `/model ${params.modelRef}`; const lines = [ `Model "${params.modelRef}" is not allowed. Use /models to list providers, or /models to list models.`, - `Add it with: ${formatAddModelCommand(params.modelRef)}`, + `Add "${params.modelRef}" or its provider wildcard to ${params.policyPath}.`, `Then retry: ${retryCommand}`, ]; if (rawRuntime && normalizeProviderId(rawRuntime) === "codex") { @@ -232,6 +231,8 @@ export function resolveModelDirectiveSelection(params: { defaultModel: string; aliasIndex: ModelAliasIndex; allowedModelKeys: Set; + cfg?: OpenClawConfig; + agentId?: string; rawRuntime?: string | undefined; }): { selection?: ModelDirectiveSelection; error?: string } { const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } = params; @@ -404,6 +405,8 @@ export function resolveModelDirectiveSelection(params: { return { error: formatNotAllowedError({ modelRef: `${resolved.ref.provider}/${resolved.ref.model}`, + policyPath: resolveConfiguredModelPolicyAllow({ cfg: params.cfg, agentId: params.agentId }) + .repairConfigPath, rawRuntime: params.rawRuntime, }), }; diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 945c9b578686..6da354d6ee24 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -1138,10 +1138,14 @@ describe("createModelSelectionState respects session model override", () => { const cfg = { agents: { defaults: { - model: { primary: "openai/gpt-4o" }, + model: { + primary: "openai/gpt-4o", + fallbacks: ["openai/gpt-4o-mini"], + }, models: { "openai/gpt-4o": {}, }, + modelPolicy: { allow: ["openai/gpt-4o"] }, }, }, } as OpenClawConfig; diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 4d543539644e..c736a6a5218b 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -11,6 +11,7 @@ import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import { parseConfiguredModelVisibilityEntries } from "../../agents/model-selection-shared.js"; import { + type ModelAliasIndex, buildConfiguredModelCatalog, legacyModelKey, modelKey, @@ -64,9 +65,12 @@ type ModelSelectionState = { model: string; allowedModelKeys: Set; allowedModelCatalog: ModelCatalog; + policyAliasIndex: ModelAliasIndex; resetModelOverride: boolean; resetModelOverrideRef?: string; resetModelOverrideReason?: "disallowed" | "stale" | "temporarily-unavailable"; + modelPolicyConfigPath?: string; + modelPolicyRepairConfigPath?: string; resolveThinkingCatalog: () => Promise; resolveDefaultThinkingLevel: (selection?: ThinkingDefaultSelection) => Promise; hasConfiguredThinkingDefault?: boolean; @@ -95,9 +99,12 @@ export function createFastTestModelSelectionState(params: { model: params.model, allowedModelKeys: new Set(), allowedModelCatalog: [], + policyAliasIndex: { byAlias: new Map(), byKey: new Map() }, resetModelOverride: false, resetModelOverrideRef: undefined, resetModelOverrideReason: undefined, + modelPolicyConfigPath: undefined, + modelPolicyRepairConfigPath: undefined, resolveThinkingCatalog: async () => [], resolveDefaultThinkingLevel: async () => params.agentCfg?.thinkingDefault as ThinkLevel, hasConfiguredThinkingDefault: params.agentCfg?.thinkingDefault !== undefined, @@ -188,29 +195,31 @@ export async function createModelSelectionState(params: { const primaryProvider = params.primaryProvider ?? defaultProvider; const primaryModel = params.primaryModel ?? defaultModel; const hasOneTurnModelOverride = params.hasOneTurnModelOverride === true; + const agentEntry = params.agentId ? resolveAgentConfig(cfg, params.agentId) : undefined; - const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0; - const visibility = parseConfiguredModelVisibilityEntries({ cfg }); + let visibilityPolicy: ModelVisibilityPolicy = createModelVisibilityPolicy({ + cfg, + catalog: [], + defaultProvider, + defaultModel, + agentId: params.agentId, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, + }); + const hasAllowlist = !visibilityPolicy.allowAny; + const hasConfiguredModels = + Object.keys(agentCfg?.models ?? {}).length > 0 || + Object.keys(agentEntry?.models ?? {}).length > 0; + const visibility = parseConfiguredModelVisibilityEntries({ cfg, agentId: params.agentId }); const defaultProviderVisibleByWildcard = visibility.providerWildcards.has( normalizeProviderId(defaultProvider), ); const configuredModelCatalog = buildConfiguredModelCatalog({ cfg }); const needsModelCatalog = params.hasModelDirective || - Boolean( - hasAllowlist && visibility.providerWildcards.size > 0 && !defaultProviderVisibleByWildcard, - ); + (hasAllowlist && visibility.providerWildcards.size > 0 && !defaultProviderVisibleByWildcard); let allowedModelKeys = new Set(); let allowedModelCatalog: ModelCatalog = configuredModelCatalog; - let visibilityPolicy: ModelVisibilityPolicy = createModelVisibilityPolicy({ - cfg, - catalog: configuredModelCatalog, - defaultProvider, - defaultModel, - agentId: params.agentId, - ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, - }); let modelCatalog: ModelCatalog | null = null; // Whether the loaded catalog is a complete/live snapshot. A degraded catalog // (discovery threw, static/empty fallback) must not destroy a pinned override. @@ -218,7 +227,6 @@ export async function createModelSelectionState(params: { let resetModelOverride = false; let resetModelOverrideRef: string | undefined; let resetModelOverrideReason: "disallowed" | "stale" | "temporarily-unavailable" | undefined; - const agentEntry = params.agentId ? resolveAgentConfig(cfg, params.agentId) : undefined; const normalizedDirectStoredOverride = normalizeStoredOverrideModel({ providerOverride: sessionEntry?.providerOverride, modelOverride: sessionEntry?.modelOverride, @@ -299,7 +307,7 @@ export async function createModelSelectionState(params: { "allowlist-built", `allowed=${allowedModelCatalog.length} keys=${allowedModelKeys.size}`, ); - } else if (hasAllowlist) { + } else if (hasAllowlist || hasConfiguredModels) { visibilityPolicy = createModelVisibilityPolicy({ cfg, catalog: configuredModelCatalog, @@ -431,8 +439,9 @@ export async function createModelSelectionState(params: { model, }); if (!allowedInitialSelection) { + const policyPath = visibilityPolicy.allowConfigPath ?? "modelPolicy.allow"; throw new Error( - `Configured default model "${modelKey(provider, model)}" is not allowed by agents.defaults.models, and no allowed model is available.`, + `Configured default model "${modelKey(provider, model)}" is not allowed by ${policyPath}, and no allowed model is available.`, ); } provider = allowedInitialSelection.provider; @@ -622,9 +631,10 @@ export async function createModelSelectionState(params: { }); if (!modelCatalog && selectedReasoningEntry?.reasoning === undefined) { const manifestCatalog = await loadManifestCatalog(); - const manifestReasoningCatalog = hasAllowlist - ? buildThinkingCatalog(manifestCatalog) - : manifestCatalog; + const manifestReasoningCatalog = + hasAllowlist || hasConfiguredModels + ? buildThinkingCatalog(manifestCatalog) + : manifestCatalog; const manifestSelectedEntry = findSelectedCatalogEntry({ catalog: manifestReasoningCatalog, provider, @@ -671,9 +681,12 @@ export async function createModelSelectionState(params: { model, allowedModelKeys, allowedModelCatalog, + policyAliasIndex: visibilityPolicy.policyAliasIndex, resetModelOverride, resetModelOverrideRef, resetModelOverrideReason, + modelPolicyConfigPath: visibilityPolicy.allowConfigPath ?? undefined, + modelPolicyRepairConfigPath: visibilityPolicy.allowRepairConfigPath, resolveThinkingCatalog, resolveDefaultThinkingLevel, hasConfiguredThinkingDefault, diff --git a/src/auto-reply/reply/session-reset-model.test.ts b/src/auto-reply/reply/session-reset-model.test.ts index f9e142cbf267..5e63026c936e 100644 --- a/src/auto-reply/reply/session-reset-model.test.ts +++ b/src/auto-reply/reply/session-reset-model.test.ts @@ -67,6 +67,82 @@ describe("applyResetModelOverride", () => { expect(sessionCtx.BodyStripped).toBe("summarize"); }); + it.each([ + { name: "empty catalog", catalog: [] }, + { + name: "unrelated catalog", + catalog: [{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }], + }, + ] satisfies Array<{ name: string; catalog: ModelCatalogEntry[] }>)( + "honors a configured primary missing from the $name", + async ({ catalog }) => { + const fixture = createResetFixture({ + providerOverride: "openai", + modelOverride: "gpt-4o-mini", + }); + fixture.cfg.agents = { + defaults: { model: { primary: "custom/private-model" } }, + }; + fixture.sessionCtx.BodyStripped = "custom/private-model summarize"; + + const result = await applyResetModelOverride({ + cfg: fixture.cfg, + resetTriggered: true, + bodyStripped: fixture.sessionCtx.BodyStripped, + sessionCtx: fixture.sessionCtx, + ctx: fixture.ctx, + sessionEntry: fixture.sessionEntry, + sessionStore: fixture.sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "custom", + defaultModel: "private-model", + aliasIndex: fixture.aliasIndex, + modelCatalog: catalog, + }); + + expect(result.selection).toMatchObject({ + provider: "custom", + model: "private-model", + isDefault: true, + }); + expect(result.cleanedBody).toBe("summarize"); + expect(fixture.sessionCtx.BodyStripped).toBe("summarize"); + expect(fixture.sessionEntry.providerOverride).toBeUndefined(); + expect(fixture.sessionEntry.modelOverride).toBeUndefined(); + }, + ); + + it("does not let the configured primary bypass an explicit model policy", async () => { + const fixture = createResetFixture(); + fixture.cfg.agents = { + defaults: { + model: { primary: "custom/private-model" }, + modelPolicy: { allow: ["openai/*"] }, + }, + }; + fixture.sessionCtx.BodyStripped = "custom/private-model summarize"; + + const result = await applyResetModelOverride({ + cfg: fixture.cfg, + resetTriggered: true, + bodyStripped: fixture.sessionCtx.BodyStripped, + sessionCtx: fixture.sessionCtx, + ctx: fixture.ctx, + sessionEntry: fixture.sessionEntry, + sessionStore: fixture.sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "custom", + defaultModel: "private-model", + aliasIndex: fixture.aliasIndex, + modelCatalog, + }); + + expect(result).toEqual({}); + expect(fixture.sessionCtx.BodyStripped).toBe("custom/private-model summarize"); + expect(fixture.sessionEntry.providerOverride).toBeUndefined(); + expect(fixture.sessionEntry.modelOverride).toBeUndefined(); + }); + it("clears auth profile overrides when reset applies a model", async () => { const { sessionEntry } = await applyResetFixture({ resetTriggered: true, diff --git a/src/auto-reply/reply/session-reset-model.ts b/src/auto-reply/reply/session-reset-model.ts index 5ddb213eee77..dbc67e5063c3 100644 --- a/src/auto-reply/reply/session-reset-model.ts +++ b/src/auto-reply/reply/session-reset-model.ts @@ -67,21 +67,14 @@ async function buildResetAllowedModelKeys(params: { defaultProvider: string; defaultModel?: string; fallbackModels: readonly string[]; + agentId?: string; }): Promise> { - const rawAllowlist = Object.keys(params.cfg.agents?.defaults?.models ?? {}); - if (rawAllowlist.length > 0 || params.cfg.models?.providers) { - return buildAllowedModelSetWithFallbacks(params).allowedKeys; - } - - const allowedKeys = new Set(); - for (const entry of params.catalog) { - allowedKeys.add(modelKey(entry.provider, entry.id)); - } + const allowed = buildAllowedModelSetWithFallbacks(params); const defaultModel = params.defaultModel?.trim(); - if (defaultModel) { - allowedKeys.add(modelKey(normalizeProviderId(params.defaultProvider), defaultModel)); + if (allowed.allowAny && defaultModel) { + allowed.allowedKeys.add(modelKey(normalizeProviderId(params.defaultProvider), defaultModel)); } - return allowedKeys; + return allowed.allowedKeys; } function buildSelectionFromExplicit(params: { @@ -205,6 +198,7 @@ export async function applyResetModelOverride(params: { cfg: params.cfg, agentId: params.agentId, }), + agentId: params.agentId, }); if (allowedModelKeys.size === 0) { return {}; @@ -226,6 +220,8 @@ export async function applyResetModelOverride(params: { defaultModel: params.defaultModel, aliasIndex: params.aliasIndex, allowedModelKeys, + cfg: params.cfg, + agentId: params.agentId, }); let selection: ModelDirectiveSelection | undefined; diff --git a/src/commands/agent-command.test-mocks.ts b/src/commands/agent-command.test-mocks.ts index 738d0a6afc93..21b43621338f 100644 --- a/src/commands/agent-command.test-mocks.ts +++ b/src/commands/agent-command.test-mocks.ts @@ -65,9 +65,11 @@ vi.mock("../agents/model-catalog.js", () => ({ vi.mock("../agents/model-selection.js", () => { type ConfigWithModels = { + meta?: { migrations?: { modelPolicyAllowlist?: boolean } }; agents?: { defaults?: { model?: string | { primary?: string; fallbacks?: string[] }; + modelPolicy?: { allow?: string[] }; models?: Record; thinkingDefault?: string; }; @@ -120,12 +122,31 @@ vi.mock("../agents/model-selection.js", () => { const models = cfg?.agents?.defaults?.models ?? {}; return models[`${ref.provider}/${ref.model}`] ?? models[modelKey(ref.provider, ref.model)]; }; + const resolvePolicyRefs = (cfg?: ConfigWithModels) => { + const defaults = cfg?.agents?.defaults; + const hasExplicitPolicy = Boolean( + defaults?.modelPolicy && Object.hasOwn(defaults.modelPolicy, "allow"), + ); + if (hasExplicitPolicy) { + return { + refs: defaults?.modelPolicy?.allow ?? [], + configPath: "agents.defaults.modelPolicy.allow", + }; + } + if (cfg?.meta?.migrations?.modelPolicyAllowlist !== true) { + const refs = Object.keys(defaults?.models ?? {}); + if (refs.length > 0) { + return { refs, configPath: "agents.defaults.models" }; + } + } + return { refs: [], configPath: null }; + }; return { buildAllowedModelSet: vi.fn(({ cfg }: { cfg?: ConfigWithModels; catalog?: CatalogEntry[] }) => { const refs = new Set(); - const modelConfig = cfg?.agents?.defaults?.models ?? {}; - for (const raw of Object.keys(modelConfig)) { + const policyRefs = resolvePolicyRefs(cfg).refs; + for (const raw of policyRefs) { const parsed = parseModelRefImpl(raw, "openai"); if (parsed) { refs.add(modelKey(parsed.provider, parsed.model)); @@ -133,27 +154,19 @@ vi.mock("../agents/model-selection.js", () => { } const primary = resolveDefaultRef(cfg); refs.add(modelKey(primary.provider, primary.model)); - const fallbackRefs = - typeof cfg?.agents?.defaults?.model === "object" - ? (cfg.agents.defaults.model.fallbacks ?? []) - : []; - for (const fallback of fallbackRefs) { - const parsed = parseModelRefImpl(fallback, primary.provider); - if (parsed) { - refs.add(modelKey(parsed.provider, parsed.model)); - } - } return { allowedKeys: refs, allowedCatalog: [], - allowAny: Object.keys(modelConfig).length === 0, + allowAny: policyRefs.length === 0, + automaticFallbackKeys: new Set(), }; }), createModelVisibilityPolicy: vi.fn( ({ cfg, catalog = [] }: { cfg?: ConfigWithModels; catalog?: CatalogEntry[] }) => { const refs = new Set(); - const modelConfig = cfg?.agents?.defaults?.models ?? {}; - for (const raw of Object.keys(modelConfig)) { + const policy = resolvePolicyRefs(cfg); + const policyRefs = policy.refs; + for (const raw of policyRefs) { const parsed = parseModelRefImpl(raw, "openai"); if (parsed) { refs.add(modelKey(parsed.provider, parsed.model)); @@ -161,20 +174,23 @@ vi.mock("../agents/model-selection.js", () => { } const primary = resolveDefaultRef(cfg); refs.add(modelKey(primary.provider, primary.model)); - const allowAny = Object.keys(modelConfig).length === 0; + const allowAny = policyRefs.length === 0; const allowsKey = (key: string) => allowAny || isModelKeyAllowedBySet(refs, key); return { allowAny, allowedKeys: refs, allowedCatalog: catalog, - exactModelRefs: Object.keys(modelConfig).filter((key) => !key.endsWith("/*")), + exactModelRefs: policyRefs.filter((key) => !key.endsWith("/*")), providerWildcards: new Set( - Object.keys(modelConfig) + policyRefs .filter((key) => key.endsWith("/*")) .map((key) => key.slice(0, -2).trim().toLowerCase()), ), - hasConfiguredEntries: Object.keys(modelConfig).length > 0, - hasProviderWildcards: Object.keys(modelConfig).some((key) => key.endsWith("/*")), + hasConfiguredEntries: policyRefs.length > 0, + hasProviderWildcards: policyRefs.some((key) => key.endsWith("/*")), + allowConfigPath: policy.configPath, + allowRepairConfigPath: "agents.defaults.modelPolicy.allow", + automaticFallbackKeys: new Set(), allowsKey, allows: ({ provider, model }: ModelRef) => allowsKey(modelKey(provider, model)), resolveSelection: ({ provider, model }: ModelRef) => { diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index e8e9d521b310..946d8896fa5a 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -271,6 +271,7 @@ function mockConfig( agentsList?: NonNullable["list"]>, ) { const cfg = { + meta: { migrations: { modelPolicyAllowlist: true } }, agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" }, @@ -1502,10 +1503,14 @@ describe("agentCommand", () => { }); mockConfig(home, clearStore, { - model: { primary: "openai/gpt-4.1-mini" }, + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-opus-4-6"], + }, models: { "openai/gpt-4.1-mini": {}, }, + modelPolicy: { allow: ["openai/gpt-4.1-mini"] }, }); mockModelCatalogOnce([ @@ -1559,6 +1564,7 @@ describe("agentCommand", () => { models: { "openai/gpt-4.1-mini": {}, }, + modelPolicy: { allow: ["openai/gpt-4.1-mini"] }, }); mockModelCatalogOnce([ { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, @@ -1719,9 +1725,14 @@ describe("agentCommand", () => { model: "claude-haiku-4-5\u001b[32m", })); mockConfig(home, store, { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-4-5"], + }, models: { "openai/gpt-4.1-mini": {}, }, + modelPolicy: { allow: ["openai/gpt-4.1-mini"] }, }); try { await expect( @@ -1734,11 +1745,34 @@ describe("agentCommand", () => { runtime, ), ).rejects.toThrow( - 'Model override "anthropic/claude-haiku-4-5" is not allowed for agent "main".', + 'Model override "anthropic/claude-haiku-4-5" is not allowed for agent "main" by agents.defaults.modelPolicy.allow. Add "anthropic/claude-haiku-4-5" or "anthropic/*" to agents.defaults.modelPolicy.allow, or remove/empty the list to allow any model.', ); } finally { parseModelRefSpy.mockRestore(); } + + const legacyCfg = mockConfig(home, store, { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["external/sensitive"], + }, + models: { + "openai/gpt-4.1-mini": {}, + }, + }); + delete (legacyCfg as { meta?: unknown }).meta; + await expect( + agentCommand( + { + message: "use the configured fallback directly", + sessionKey: "agent:main:subagent:legacy-fallback-override", + model: "external/sensitive", + }, + runtime, + ), + ).rejects.toThrow( + 'Model override "external/sensitive" is not allowed for agent "main" by agents.defaults.models. Add "external/sensitive" or "external/*" to agents.defaults.modelPolicy.allow, or remove/empty the list to allow any model.', + ); }); }); diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index 9a9569157556..2a2801a5bed6 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -27,7 +27,7 @@ const mocks = vi.hoisted(() => ({ const scopeKeys = opts.scopeKeys ? normalizeTestModelKeys(opts.scopeKeys) : []; const scopeKeySet = scopeKeys.length > 0 ? new Set(scopeKeys) : null; if (normalized.length === 0) { - if (!defaults?.models) { + if (!defaults?.models && !defaults?.modelPolicy?.allow) { return cfg; } if (scopeKeySet) { @@ -36,18 +36,23 @@ const mocks = vi.hoisted(() => ({ delete nextModels[key]; } const { models: _ignored, ...restDefaults } = defaults; + const allow = Object.keys(nextModels); return { ...cfg, agents: { ...cfg.agents, defaults: - Object.keys(nextModels).length > 0 - ? { ...defaults, models: nextModels } - : restDefaults, + allow.length > 0 + ? { + ...defaults, + models: nextModels, + modelPolicy: { ...defaults.modelPolicy, allow }, + } + : (({ modelPolicy: _modelPolicy, ...rest }) => rest)(restDefaults), }, }; } - const { models: _ignored, ...restDefaults } = defaults; + const { models: _ignored, modelPolicy: _modelPolicy, ...restDefaults } = defaults; return { ...cfg, agents: { ...cfg.agents, defaults: restDefaults } }; } const existingModels = defaults?.models ?? {}; @@ -64,7 +69,11 @@ const mocks = vi.hoisted(() => ({ ...cfg, agents: { ...cfg.agents, - defaults: { ...defaults, models: nextModels }, + defaults: { + ...defaults, + models: nextModels, + modelPolicy: { ...defaults?.modelPolicy, allow: Object.keys(nextModels) }, + }, }, }; }, @@ -307,6 +316,7 @@ describe("promptAuthConfig", () => { expect(Object.keys(result.agents?.defaults?.models ?? {})).toEqual([ "kilocode/kilo-auto/balanced", ]); + expect(result.agents?.defaults?.modelPolicy?.allow).toEqual(["kilocode/kilo-auto/balanced"]); }); it("does not mutate provider model catalogs when allowlist is set", async () => { @@ -393,6 +403,10 @@ describe("promptAuthConfig", () => { "openai/gpt-5.5": { alias: "GPT" }, "anthropic/claude-sonnet-4-6": {}, }); + expect(result.agents?.defaults?.modelPolicy?.allow).toEqual([ + "openai/gpt-5.5", + "anthropic/claude-sonnet-4-6", + ]); }); it("resolves fallback aliases before scoped allowlist pruning", async () => { @@ -714,6 +728,7 @@ describe("promptAuthConfig", () => { expect(promptModelAllowlistOptions()?.preferredProvider).toBe("openai"); expect(result.agents?.defaults?.model).toEqual({ primary: "openai/gpt-5.5" }); expect(Object.keys(result.agents?.defaults?.models ?? {})).toEqual(["openai/gpt-5.5"]); + expect(result.agents?.defaults?.modelPolicy?.allow).toEqual(["openai/gpt-5.5"]); }); it("returns to auth selection when plugin install onboarding asks for a retry", async () => { diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index ab9022e0a5c8..a5bd57b607d1 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -198,7 +198,7 @@ export function buildGatewayAuthConfig(params: { return base; } -/** Prompt for model provider credentials and default model allowlist settings. */ +/** Prompt for model provider credentials and explicit default model policy settings. */ export async function promptAuthConfig( cfg: OpenClawConfig, runtime: RuntimeEnv, diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index eb024ca2619c..862cba75f26a 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -1153,6 +1153,10 @@ describe("normalizeCompatibilityConfigValues", () => { "openai/gpt-5.6-sol": { alias: "codex", agentRuntime: { id: "codex" } }, "openai/gpt-5.4-mini": { agentRuntime: { id: "codex" } }, }); + expect(repaired.cfg.agents?.defaults?.modelPolicy?.allow).toEqual([ + "openai/gpt-5.6-sol", + "openai/gpt-5.4-mini", + ]); expect(repaired.cfg.models?.providers).not.toHaveProperty("codex"); expect(repaired.cfg.models?.providers?.openai?.models?.[0]).toMatchObject({ id: "gpt-5.6-sol", diff --git a/src/commands/doctor/shared/codex-route-config-repair.ts b/src/commands/doctor/shared/codex-route-config-repair.ts index 3269ed6b1cb0..ce614888c148 100644 --- a/src/commands/doctor/shared/codex-route-config-repair.ts +++ b/src/commands/doctor/shared/codex-route-config-repair.ts @@ -16,7 +16,11 @@ import { resolveRuntime, type LegacyCodexModelIdentity, } from "./codex-route-model-ref.js"; -import { rewriteModelConfigSlot, rewriteModelsMap } from "./codex-route-model-slots.js"; +import { + recordCodexModelHit, + rewriteModelConfigSlot, + rewriteModelsMap, +} from "./codex-route-model-slots.js"; import { clearConfigLegacyAgentRuntimePolicies, ensureCodexRuntimePolicy, @@ -32,6 +36,31 @@ import type { const AGENT_MEDIA_MODEL_CONFIG_KEYS = ["imageGenerationModel", "videoGenerationModel"] as const; +function rewriteModelPolicyAllowRefs(params: { + hits: CodexRouteHit[]; + agent: MutableRecord; + path: string; + blockedModelIdentities?: ReadonlySet; +}): void { + const modelPolicy = asMutableRecord(params.agent.modelPolicy); + if (!Array.isArray(modelPolicy?.allow)) { + return; + } + modelPolicy.allow = modelPolicy.allow.map((entry, index) => { + if (typeof entry !== "string") { + return entry; + } + return ( + recordCodexModelHit({ + hits: params.hits, + path: `${params.path}.modelPolicy.allow.${index}`, + model: entry.trim(), + blockedModelIdentities: params.blockedModelIdentities, + }) ?? entry + ); + }); +} + function rewriteAgentModelRefs(params: { cfg: OpenClawConfig; preRepairCfg: OpenClawConfig; @@ -144,6 +173,14 @@ function rewriteAgentModelRefs(params: { blockedModelIdentities: params.blockedModelIdentities, }); } + const modelPolicyStart = params.hits.length; + rewriteModelPolicyAllowRefs({ + hits: params.hits, + agent: params.agent, + path: params.path, + blockedModelIdentities: params.blockedModelIdentities, + }); + preserveCodexRuntimePolicyForNewHits(modelPolicyStart); if (params.rewriteModelsMap) { const start = params.hits.length; rewriteModelsMap({ diff --git a/src/commands/doctor/shared/codex-route-config-scan.ts b/src/commands/doctor/shared/codex-route-config-scan.ts index 13a8a40e9046..37959fe85329 100644 --- a/src/commands/doctor/shared/codex-route-config-scan.ts +++ b/src/commands/doctor/shared/codex-route-config-scan.ts @@ -54,6 +54,26 @@ function collectModelsMapRefs(params: { } } +function collectModelPolicyAllowRefs(params: { + hits: CodexRouteHit[]; + path: string; + modelPolicy: unknown; + blockedModelIdentities?: ReadonlySet; +}): void { + const allow = asMutableRecord(params.modelPolicy)?.allow; + if (!Array.isArray(allow)) { + return; + } + for (const [index, modelRef] of allow.entries()) { + collectStringModelSlot({ + hits: params.hits, + path: `${params.path}.allow.${index}`, + value: modelRef, + blockedModelIdentities: params.blockedModelIdentities, + }); + } +} + function collectAgentModelRefs(params: { hits: CodexRouteHit[]; agent: unknown; @@ -113,6 +133,12 @@ function collectAgentModelRefs(params: { models: agent.models, blockedModelIdentities: params.blockedModelIdentities, }); + collectModelPolicyAllowRefs({ + hits: params.hits, + path: `${params.path}.modelPolicy`, + modelPolicy: agent.modelPolicy, + blockedModelIdentities: params.blockedModelIdentities, + }); } export function collectConfigModelRefs( diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index a194b14e6726..8283d015298c 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -2020,6 +2020,7 @@ describe("legacy migrate sandbox scope aliases", () => { "Removed agents.defaults.agentRuntime; runtime is now provider/model scoped.", "Moved agents.list.0.agentRuntime.id claude-cli to matching anthropic model runtime policy.", "Removed agents.list.0.agentRuntime; runtime is now provider/model scoped.", + "Copied the legacy default model map to agents.defaults.modelPolicy.allow.", ]); expect(res.config?.agents?.defaults).toEqual({ model: { @@ -2035,6 +2036,9 @@ describe("legacy migrate sandbox scope aliases", () => { agentRuntime: { id: "claude-cli" }, }, }, + modelPolicy: { + allow: ["anthropic/claude-opus-4-7", "anthropic/claude-sonnet-4-6"], + }, }); expect(res.config?.agents?.list?.[0]).toEqual({ id: "paige", @@ -2062,12 +2066,14 @@ describe("legacy migrate sandbox scope aliases", () => { expect(res.changes).toStrictEqual([ "Removed agents.defaults.agentRuntime; runtime is now provider/model scoped.", + "Copied the legacy default model map to agents.defaults.modelPolicy.allow.", ]); expect(res.config?.agents?.defaults).toEqual({ model: "anthropic/claude-opus-4-7", models: { "anthropic/claude-opus-4-7": { agentRuntime: { id: "openclaw" } }, }, + modelPolicy: { allow: ["anthropic/claude-opus-4-7"] }, }); }); @@ -3392,6 +3398,7 @@ describe("legacy model compat migrate", () => { }); expect(res.config?.models?.providers?.vllm?.models?.[0]?.reasoning).toBe(true); expect(res.changes).toStrictEqual([ + "Copied the legacy default model map to agents.defaults.modelPolicy.allow.", 'Moved agents.defaults.models."vllm/Qwen/Qwen3-8B".params.qwenThinkingFormat to models.providers.vllm.models[0].compat.thinkingFormat ("qwen-chat-template").', ]); }); @@ -3423,6 +3430,7 @@ describe("legacy model compat migrate", () => { }, ]); expect(res.changes).toStrictEqual([ + "Copied the legacy default model map to agents.defaults.modelPolicy.allow.", 'Moved agents.defaults.models."VLLM/Qwen/Qwen3-8B".params.qwenThinkingFormat to models.providers.vllm.models[0].compat.thinkingFormat ("qwen-chat-template").', ]); }); @@ -3454,6 +3462,7 @@ describe("legacy model compat migrate", () => { }, ]); expect(res.changes).toStrictEqual([ + "Copied the legacy default model map to agents.defaults.modelPolicy.allow.", 'Moved agents.defaults.models."vllm/Qwen/Qwen3-8B".params.qwen_thinking_format to models.providers.vllm.models[0].compat.thinkingFormat ("qwen").', ]); }); @@ -3493,6 +3502,7 @@ describe("legacy model compat migrate", () => { }); expect(res.config?.models?.providers?.vllm?.models?.[0]?.reasoning).toBe(true); expect(res.changes).toStrictEqual([ + "Copied the legacy default model map to agents.defaults.modelPolicy.allow.", 'Removed agents.defaults.models."vllm/Qwen/Qwen3-8B".params.qwenThinkingFormat; models.providers.vllm.models[0].compat.thinkingFormat is already "qwen-chat-template".', ]); }); @@ -3528,6 +3538,7 @@ describe("legacy model compat migrate", () => { }, ]); expect(res.changes).toStrictEqual([ + "Copied the legacy default model map to agents.defaults.modelPolicy.allow.", 'Moved agents.defaults.models."vllm/Qwen/Qwen3-8B".params.qwenThinkingFormat to models.providers.vllm.models[0].compat.thinkingFormat ("qwen-chat-template").', ]); }); @@ -3914,8 +3925,23 @@ describe("legacy model compat migrate", () => { }, }); - expect(res.config).toBeNull(); - expect(res.changes).toStrictEqual([]); + expect(res.config).toMatchObject({ + agents: { + defaults: { + modelPolicy: { allow: ["vllm/Qwen/Qwen3-8B"] }, + models: { + "vllm/Qwen/Qwen3-8B": { + params: { qwenThinkingFormat: "chat-template" }, + }, + }, + }, + }, + meta: { migrations: { modelPolicyAllowlist: true } }, + models: { providers: { vllm: { models: "malformed" } } }, + }); + expect(res.changes).toStrictEqual([ + "Copied the legacy default model map to agents.defaults.modelPolicy.allow.", + ]); }); it("leaves malformed vLLM provider ancestors untouched during legacy Qwen migration", () => { @@ -3938,8 +3964,23 @@ describe("legacy model compat migrate", () => { }, }); - expect(res.config).toBeNull(); - expect(res.changes).toStrictEqual([]); + expect(res.config).toMatchObject({ + agents: { + defaults: { + modelPolicy: { allow: ["vllm/Qwen/Qwen3-8B"] }, + models: { + "vllm/Qwen/Qwen3-8B": { + params: { qwenThinkingFormat: "chat-template" }, + }, + }, + }, + }, + meta: { migrations: { modelPolicyAllowlist: true } }, + models: { providers: { vllm: "malformed" } }, + }); + expect(res.changes).toStrictEqual([ + "Copied the legacy default model map to agents.defaults.modelPolicy.allow.", + ]); }); it("reports legacy vLLM Qwen thinking params before doctor fix", () => { diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.test.ts index d60dbea4d2d5..bad05ac8216f 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.test.ts @@ -2,7 +2,203 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, it, expect } from "vitest"; -import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS } from "./legacy-config-migrations.runtime.models.js"; +import { createModelVisibilityPolicy } from "../../../agents/model-visibility-policy.js"; +import type { OpenClawConfig } from "../../../config/types.js"; +import { legacyCodexProviderIdentityKey } from "./codex-route-model-ref.js"; +import { + collectBlockedLegacyOpenAICodexProviderPlan, + LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS, +} from "./legacy-config-migrations.runtime.models.js"; + +describe("explicit model allow policy migration", () => { + const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS.find( + (entry) => entry.id === "agents.defaults.models->agents.defaults.modelPolicy.allow", + ); + + it("preserves a legacy restriction after an unrelated new-version write", () => { + const raw = { + meta: { lastTouchedVersion: "2026.7.2" }, + agents: { + defaults: { + models: { + "openai/*": {}, + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + }; + const changes: string[] = []; + + expect(migration?.legacyRules?.[0]?.match?.(raw.agents.defaults.models, raw)).toBe(true); + migration?.apply(raw, changes); + + expect(raw.agents.defaults).toMatchObject({ + modelPolicy: { + allow: ["openai/*", "anthropic/claude-sonnet-4-6"], + }, + }); + expect(raw).toMatchObject({ + meta: { migrations: { modelPolicyAllowlist: true } }, + }); + expect(changes).toHaveLength(1); + expect(migration?.legacyRules?.[0]?.match?.(raw.agents.defaults.models, raw)).toBe(false); + + const migratedDefaults = raw.agents.defaults as typeof raw.agents.defaults & { + modelPolicy: { allow: string[] }; + }; + migratedDefaults.modelPolicy.allow = ["google/*"]; + const secondChanges: string[] = []; + migration?.apply(raw, secondChanges); + expect(migratedDefaults.modelPolicy.allow).toEqual(["google/*"]); + expect(secondChanges).toEqual([]); + }); + + it("leaves an explicit allow list untouched", () => { + const raw = { + agents: { + defaults: { + models: { "openai/gpt-5.5": {} }, + modelPolicy: { allow: ["anthropic/*"] }, + }, + }, + }; + const changes: string[] = []; + + migration?.apply(raw, changes); + + expect(raw.agents.defaults.modelPolicy.allow).toEqual(["anthropic/*"]); + expect(changes).toEqual([]); + }); + + it("migrates only the default restriction and keeps per-agent metadata policy-free", () => { + const raw = { + agents: { + defaults: { models: { "openai/*": {} } }, + list: [ + { + id: "worker", + models: { "anthropic/claude-sonnet-4-6": { alias: "sonnet" } }, + }, + ], + }, + }; + const changes: string[] = []; + const createPolicy = (cfg: OpenClawConfig) => + createModelVisibilityPolicy({ + cfg, + catalog: [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" }, + { provider: "openai", id: "gpt-5.5", name: "GPT 5.5" }, + ], + defaultProvider: "openai", + defaultModel: "gpt-5.5", + agentId: "worker", + }); + const before = createPolicy(raw); + + migration?.apply(raw, changes); + + expect(raw.agents.defaults).toMatchObject({ modelPolicy: { allow: ["openai/*"] } }); + expect(raw.agents.list[0]).not.toHaveProperty("modelPolicy"); + expect(raw).toMatchObject({ + meta: { migrations: { modelPolicyAllowlist: true } }, + }); + expect(changes).toHaveLength(1); + const after = createPolicy(raw); + expect(after.exactModelRefs).toEqual(before.exactModelRefs); + expect([...after.providerWildcards]).toEqual([...before.providerWildcards]); + expect(after.allowAny).toBe(before.allowAny); + expect(after.allows({ provider: "openai", model: "gpt-5.5" })).toBe( + before.allows({ provider: "openai", model: "gpt-5.5" }), + ); + }); + + it("ignores a per-agent model map when no legacy default restriction exists", () => { + const raw = { + agents: { + list: [ + { + id: "worker", + models: { "anthropic/claude-sonnet-4-6": { alias: "sonnet" } }, + }, + ], + }, + }; + const changes: string[] = []; + + expect(migration?.legacyRules).toHaveLength(1); + migration?.apply(raw, changes); + + expect(raw.agents.list[0]).not.toHaveProperty("modelPolicy"); + expect(raw).not.toHaveProperty("meta"); + expect(changes).toEqual([]); + }); + + it("marks a blank-only legacy map migrated without stamping an allow list", () => { + const raw = { agents: { defaults: { models: { " ": {} } } } }; + const changes: string[] = []; + + migration?.apply(raw, changes); + + expect(raw.agents.defaults).not.toHaveProperty("modelPolicy"); + expect(raw).toMatchObject({ + meta: { migrations: { modelPolicyAllowlist: true } }, + }); + expect(changes).toHaveLength(1); + + const secondChanges: string[] = []; + migration?.apply(raw, secondChanges); + expect(secondChanges).toEqual([]); + }); +}); + +describe("legacy Codex policy wildcard migration", () => { + const providerMigration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS.find( + (entry) => entry.id === "models.providers.codex-routes->models.providers.openai", + ); + + it.each([ + { + name: "default policy", + agents: { defaults: { modelPolicy: { allow: ["codex/*"] } } }, + expectedPath: "agents.defaults.modelPolicy.allow.0", + }, + { + name: "per-agent policy", + agents: { + defaults: {}, + list: [{ id: "worker", modelPolicy: { allow: ["codex/*"] } }], + }, + expectedPath: "agents.list.0.modelPolicy.allow.0", + }, + ])("retains the legacy provider for a $name", ({ agents, expectedPath }) => { + const raw = { + agents, + models: { + providers: { + codex: { + api: "openai-chatgpt-responses", + models: [{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }], + }, + }, + }, + } as Record; + const changes: string[] = []; + + providerMigration?.apply(raw, changes); + + const providers = (raw.models as { providers: Record }).providers; + expect(providers).toHaveProperty("codex"); + expect(providers).not.toHaveProperty("openai"); + expect(changes).toEqual([]); + const blocked = collectBlockedLegacyOpenAICodexProviderPlan(raw); + expect(blocked.blockedModelIdentities).toContain( + expectDefined(legacyCodexProviderIdentityKey("codex"), "Codex identity test invariant"), + ); + expect(blocked.warning).toContain(expectedPath); + expect(blocked.warning).toContain("authorize unrelated OpenAI models"); + }); +}); describe("stale contextWindow migration", () => { const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS.find( diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts index 033548a1f8ce..466530a4a813 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts @@ -10,6 +10,11 @@ import { type LegacyConfigMigrationSpec, type LegacyConfigRule, } from "../../../config/legacy.shared.js"; +import { + computeModelPolicyAllowlist, + hasModelPolicyAllowlistMigrationMarker, + MODEL_POLICY_ALLOWLIST_MIGRATION_MARKER, +} from "../../../config/model-policy-allowlist-migration.js"; import { isModelThinkingFormat, type ModelDefinitionConfig } from "../../../config/types.models.js"; import { isBlockedObjectKey } from "../../../infra/prototype-keys.js"; import { @@ -828,7 +833,6 @@ const MODEL_REF_ARRAY_KEYS = new Set([ "imageModelFallbacks", ]); const MODEL_REF_MAP_KEYS = new Set(["models"]); - function pathKey(path: string): string { return path.slice(path.lastIndexOf(".") + 1); } @@ -837,6 +841,10 @@ function isChannelModelOverridePath(path: string): boolean { return path.includes(".modelByChannel."); } +function isModelPolicyAllowPath(path: string): boolean { + return path.endsWith(".modelPolicy.allow"); +} + function scanKnownModelRefs(value: unknown, key?: string, path = ""): boolean { if (typeof value === "string") { return Boolean( @@ -847,7 +855,9 @@ function scanKnownModelRefs(value: unknown, key?: string, path = ""): boolean { } if (Array.isArray(value)) { return value.some((entry, index) => - typeof entry === "string" && key && MODEL_REF_ARRAY_KEYS.has(key) + typeof entry === "string" && + key && + (MODEL_REF_ARRAY_KEYS.has(key) || isModelPolicyAllowPath(path)) ? Boolean(upgradeRetiredModelRef(entry)) : scanKnownModelRefs(entry, undefined, `${path}.${index}`), ); @@ -864,6 +874,48 @@ function scanKnownModelRefs(value: unknown, key?: string, path = ""): boolean { ); } +function collectLegacyDefaultModelAllowRefs(raw: Record): string[] | null { + // Marker seeding at the config write boundary ships atomically with metadata-only + // model maps. Therefore an unmarked map is legacy even if a general write version advanced. + const defaults = getRecord(getRecord(raw.agents)?.defaults); + return computeModelPolicyAllowlist({ + root: raw, + defaults, + }); +} + +function migrateExplicitDefaultModelAllowPolicy( + raw: Record, + changes: string[], +): void { + if (hasModelPolicyAllowlistMigrationMarker(raw)) { + return; + } + const defaults = getRecord(getRecord(raw.agents)?.defaults); + const defaultModelPolicy = getRecord(defaults?.modelPolicy); + const defaultNeedsEvaluation = + Boolean(getRecord(defaults?.models)) && + !(defaultModelPolicy && Object.hasOwn(defaultModelPolicy, "allow")); + if (!defaultNeedsEvaluation) { + return; + } + const defaultAllow = collectLegacyDefaultModelAllowRefs(raw); + if (defaultAllow) { + const mutableDefaults = ensureRecord(ensureRecord(raw, "agents"), "defaults"); + const mutableModelPolicy = ensureRecord(mutableDefaults, "modelPolicy"); + // The policy builder still retains configured defaults/fallbacks, so copying the + // original keys reproduces the legacy effective set, including wildcard expansion. + mutableModelPolicy.allow = defaultAllow; + } + const migrations = ensureRecord(ensureRecord(raw, "meta"), "migrations"); + migrations[MODEL_POLICY_ALLOWLIST_MIGRATION_MARKER] = true; + changes.push( + defaultAllow + ? "Copied the legacy default model map to agents.defaults.modelPolicy.allow." + : "Recorded the legacy default model map as unrestricted without creating modelPolicy.allow.", + ); +} + function rewriteModelRefString(value: string, path: string, changes: string[]): string { const upgraded = upgradeRetiredModelRef(value); if (!upgraded) { @@ -1014,7 +1066,10 @@ function rewriteKnownModelRefs( if (Array.isArray(value)) { let changed = false; const next = value.map((entry, index) => { - if (typeof entry === "string" && MODEL_REF_ARRAY_KEYS.has(key)) { + if ( + typeof entry === "string" && + (MODEL_REF_ARRAY_KEYS.has(key) || isModelPolicyAllowPath(path)) + ) { const rewritten = rewriteModelRefString(entry, `${path}.${index}`, changes); changed ||= rewritten !== entry; return rewritten; @@ -1192,17 +1247,58 @@ function getMergeableLegacyOpenAIModels(params: { }); } -function hasAutoFixableLegacyOpenAICodexProvider(providersValue: unknown): boolean { +function collectLegacyModelPolicyWildcardPaths(raw: unknown): Map { + const pathsByProvider = new Map(); + const agents = getRecord(getRecord(raw)?.agents); + const scopes: Array<{ value: unknown; path: string }> = [ + { value: getRecord(agents?.defaults)?.modelPolicy, path: "agents.defaults.modelPolicy" }, + ]; + const list = Array.isArray(agents?.list) ? agents.list : []; + for (const [index, agent] of list.entries()) { + scopes.push({ + value: getRecord(agent)?.modelPolicy, + path: `agents.list.${index}.modelPolicy`, + }); + } + for (const scope of scopes) { + const allow = getRecord(scope.value)?.allow; + if (!Array.isArray(allow)) { + continue; + } + for (const [index, entry] of allow.entries()) { + if (typeof entry !== "string" || !entry.trim().endsWith("/*")) { + continue; + } + const provider = normalizeProviderId(entry.trim().slice(0, -2)); + if (!isLegacyCodexProviderId(provider)) { + continue; + } + const paths = pathsByProvider.get(provider) ?? []; + paths.push(`${scope.path}.allow.${index}`); + pathsByProvider.set(provider, paths); + } + } + return pathsByProvider; +} + +function hasAutoFixableLegacyOpenAICodexProvider( + providersValue: unknown, + root?: Record, +): boolean { const providers = getRecord(providersValue); if (!providers) { return false; } + const wildcardPaths = collectLegacyModelPolicyWildcardPaths(root); const canonicalEntry = getCanonicalOpenAIProviderEntry(providers); for (const [providerId, providerValue] of Object.entries(providers)) { const provider = getRecord(providerValue); if (!provider || !isLegacyCodexProviderId(providerId)) { continue; } + if (wildcardPaths.has(normalizeProviderId(providerId))) { + continue; + } const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, []); if (normalized.changed || !canonicalEntry) { return true; @@ -1246,12 +1342,21 @@ export function collectBlockedLegacyOpenAICodexProviderPlan( const models = getRecord(getRecord(raw)?.models); const providers = getRecord(models?.providers); const canonicalEntry = providers ? getCanonicalOpenAIProviderEntry(providers) : undefined; - if (!providers || !canonicalEntry) { - return { blockedModelIdentities: [] }; - } - const blockedModelIdentities = new Set(); const warningLines: string[] = []; + for (const [providerId, paths] of collectLegacyModelPolicyWildcardPaths(raw)) { + const identity = legacyCodexProviderIdentityKey(providerId); + if (identity) { + blockedModelIdentities.add(identity); + } + warningLines.push( + `- ${paths.join(", ")} cannot migrate automatically because ${providerId}/* would become openai/* and authorize unrelated OpenAI models.`, + ); + } + if (!providers || !canonicalEntry) { + return buildBlockedLegacyOpenAICodexProviderPlan(blockedModelIdentities, warningLines); + } + for (const [providerId, providerValue] of Object.entries(providers)) { const provider = getRecord(providerValue); if (!provider || !isLegacyCodexProviderId(providerId)) { @@ -1300,6 +1405,13 @@ export function collectBlockedLegacyOpenAICodexProviderPlan( // reconciled (the live codex provider is gone, and a hidden resolver/auth // shim is forbidden by policy). Only hand-authored models.providers.codex // definitions can reach this state; the warning names the exact repair. + return buildBlockedLegacyOpenAICodexProviderPlan(blockedModelIdentities, warningLines); +} + +function buildBlockedLegacyOpenAICodexProviderPlan( + blockedModelIdentities: ReadonlySet, + warningLines: string[], +): BlockedLegacyOpenAICodexProviderPlan { return { blockedModelIdentities: [...blockedModelIdentities], ...(warningLines.length > 0 @@ -1478,11 +1590,15 @@ function migrateLegacyOpenAICodexProvider(raw: Record, changes: } let providersChanged = false; + const wildcardPaths = collectLegacyModelPolicyWildcardPaths(raw); for (const [providerId, providerValue] of Object.entries({ ...providers })) { const provider = getRecord(providers[providerId]) ?? getRecord(providerValue); if (!provider) { continue; } + if (isLegacyCodexProviderId(providerId) && wildcardPaths.has(normalizeProviderId(providerId))) { + continue; + } const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, changes); if (!isLegacyCodexProviderId(providerId)) { @@ -1601,7 +1717,7 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS: LegacyConfigMigrationSpec[ path: ["models", "providers"], message: 'models.providers.codex and models.providers.openai-codex are legacy; run "openclaw doctor --fix" to move them to models.providers.openai.', - match: (value) => hasAutoFixableLegacyOpenAICodexProvider(value), + match: (value, root) => hasAutoFixableLegacyOpenAICodexProvider(value, root), }, { path: ["models", "providers"], @@ -1644,6 +1760,19 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS: LegacyConfigMigrationSpec[ } }, }), + defineLegacyConfigMigration({ + id: "agents.defaults.models->agents.defaults.modelPolicy.allow", + describe: "Make the legacy model override restriction explicit", + legacyRules: [ + { + path: ["agents", "defaults", "models"], + message: + 'agents.defaults.models no longer restricts model overrides; run "openclaw doctor --fix" to preserve the previous restriction in agents.defaults.modelPolicy.allow.', + match: (_value, root) => collectLegacyDefaultModelAllowRefs(root) !== null, + }, + ], + apply: migrateExplicitDefaultModelAllowPolicy, + }), defineLegacyConfigMigration({ id: "agents.defaults.models.vllm.params.qwenThinkingFormat->models.providers.vllm.models.compat.thinkingFormat", describe: "Move legacy vLLM Qwen thinking params to model compat metadata", diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 90bc8f8e4bf3..5a979349ae41 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { testing as cliBackendsTesting } from "../agents/cli-backends.test-support.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import type { OpenClawConfig } from "../config/config.js"; +import { stampConfigWriteMetadata } from "../config/io.meta.js"; import type { WizardMultiSelectParams, WizardPrompter } from "../wizard/prompts.js"; import { applyModelAllowlist, @@ -1866,8 +1867,13 @@ describe("promptModelAllowlist", () => { }); expect(next.agents?.defaults?.models).toEqual({ [activeModel]: { alias: "llama" }, + [staleModel]: { alias: "elephant" }, "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, }); + expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([ + "anthropic/claude-sonnet-4-6", + activeModel, + ]); }); it("shows configured preferred provider models when the catalog has no entries", async () => { @@ -2544,7 +2550,9 @@ describe("applyModelAllowlist", () => { const next = applyModelAllowlist(config, ["openai/gpt-5.5"]); expect(next.agents?.defaults?.models).toEqual({ "openai/gpt-5.5": { alias: "gpt" }, + "anthropic/claude-opus-4-6": { alias: "opus" }, }); + expect(next.agents?.defaults?.modelPolicy?.allow).toEqual(["openai/gpt-5.5"]); }); it("normalizes retired Google Gemini refs before writing selected models", () => { @@ -2568,6 +2576,11 @@ describe("applyModelAllowlist", () => { "google-gemini-cli/gemini-3.1-pro-preview": {}, "openrouter/google/gemini-3.1-pro-preview": {}, }); + expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([ + "google/gemini-3.1-pro-preview", + "google-gemini-cli/gemini-3.1-pro-preview", + "openrouter/google/gemini-3.1-pro-preview", + ]); }); it("keeps non-Google provider Gemini-looking refs unchanged while writing selected models", () => { @@ -2578,6 +2591,10 @@ describe("applyModelAllowlist", () => { "litellm/gemini-3-flash": {}, "litellm/gemini-3.1-pro": {}, }); + expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([ + "litellm/gemini-3-flash", + "litellm/gemini-3.1-pro", + ]); }); it("preserves entries outside scoped allowlist updates", () => { @@ -2589,6 +2606,7 @@ describe("applyModelAllowlist", () => { "anthropic/claude-opus-4-6": { alias: "opus" }, "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, }, + modelPolicy: { allow: ["openai/*", "anthropic/*", "sonnet"] }, }, }, } as OpenClawConfig; @@ -2598,11 +2616,40 @@ describe("applyModelAllowlist", () => { }); expect(next.agents?.defaults?.models).toEqual({ "openai/gpt-5.5": { alias: "gpt" }, + "anthropic/claude-opus-4-6": { alias: "opus" }, "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, }); + expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([ + "openai/*", + "anthropic/claude-sonnet-4-6", + ]); }); - it("clears the allowlist when no models remain", () => { + it("seeds provider-scoped configure edits from the effective legacy allowlist", () => { + const config = { + agents: { + defaults: { + models: { + "openai/gpt-5.5": { alias: "gpt" }, + "anthropic/claude-opus-4-6": { alias: "opus" }, + }, + }, + }, + } as OpenClawConfig; + + const applied = applyModelAllowlist(config, ["openai/gpt-5.6-sol"], { + scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.6-sol"], + }); + const next = stampConfigWriteMetadata(applied, undefined, undefined, config); + + expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([ + "anthropic/claude-opus-4-6", + "openai/gpt-5.6-sol", + ]); + expect(next.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("clears an effective legacy restriction and preserves model metadata", () => { const config = { agents: { defaults: { @@ -2613,8 +2660,13 @@ describe("applyModelAllowlist", () => { }, } as OpenClawConfig; - const next = applyModelAllowlist(config, []); - expect(next.agents?.defaults?.models).toBeUndefined(); + const applied = applyModelAllowlist(config, []); + const next = stampConfigWriteMetadata(applied, undefined, undefined, config); + expect(next.agents?.defaults?.models).toEqual({ + "openai/gpt-5.5": { alias: "gpt" }, + }); + expect(next.agents?.defaults?.modelPolicy?.allow).toEqual([]); + expect(next.meta?.migrations?.modelPolicyAllowlist).toBe(true); }); }); diff --git a/src/commands/models.set.e2e.test.ts b/src/commands/models.set.e2e.test.ts index 57eebe2a1203..524c75d1d097 100644 --- a/src/commands/models.set.e2e.test.ts +++ b/src/commands/models.set.e2e.test.ts @@ -1,5 +1,8 @@ // Models set e2e tests cover persisted model selection updates through command handlers. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createModelVisibilityPolicy } from "../agents/model-visibility-policy.js"; +import { stampConfigWriteMetadata } from "../config/io.meta.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; const mocks = vi.hoisted(() => ({ currentConfig: {} as Record, @@ -39,11 +42,11 @@ function makeRuntime() { return { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; } -function getWrittenConfig() { +function getWrittenConfig(): OpenClawConfig { if (!mocks.writtenConfig) { throw new Error("expected config write"); } - return mocks.writtenConfig; + return mocks.writtenConfig as OpenClawConfig; } function expectWrittenPrimaryModel(model: string) { @@ -71,6 +74,29 @@ describe("models set + fallbacks", () => { expectWrittenPrimaryModel("zai/glm-4.7"); }); + it("does not make an unlisted model override invalid on a fresh config", async () => { + mockConfigSnapshot({}); + + await modelsSetCommand("clawrouter/google/gemini-3.5-flash", makeRuntime()); + + const written = getWrittenConfig(); + const persisted = stampConfigWriteMetadata( + written, + "2026-07-18T00:00:00.000Z", + "test", + mocks.currentConfig, + ); + const policy = createModelVisibilityPolicy({ + cfg: persisted, + catalog: [], + defaultProvider: "clawrouter", + defaultModel: "google/gemini-3.5-flash", + }); + expect(written.agents?.defaults?.modelPolicy).toBeUndefined(); + expect(persisted.meta?.migrations?.modelPolicyAllowlist).toBe(true); + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(true); + }); + it("normalizes z-ai provider in models fallbacks add", async () => { mockConfigSnapshot({ agents: { defaults: { model: { fallbacks: [] } } } }); const runtime = makeRuntime(); diff --git a/src/commands/models/aliases.test.ts b/src/commands/models/aliases.test.ts index c9d50ea7f9b6..91a8e1790918 100644 --- a/src/commands/models/aliases.test.ts +++ b/src/commands/models/aliases.test.ts @@ -1,8 +1,14 @@ import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createModelVisibilityPolicy } from "../../agents/model-visibility-policy.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { stampConfigWriteMetadata } from "../../config/io.meta.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { modelsAliasesListCommand, modelsAliasesRemoveCommand } from "./aliases.js"; +import { + modelsAliasesAddCommand, + modelsAliasesListCommand, + modelsAliasesRemoveCommand, +} from "./aliases.js"; const mocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), @@ -176,6 +182,36 @@ describe("modelsAliasesRemoveCommand", () => { }); }); +describe("modelsAliasesAddCommand", () => { + beforeEach(() => { + mocks.readConfigFileSnapshot.mockReset(); + mocks.replaceConfigFile.mockReset(); + mocks.loadModelsConfig.mockReset(); + }); + + it("does not make an unlisted model override invalid on a fresh config", async () => { + const cfg = {} as OpenClawConfig; + mocks.loadModelsConfig.mockResolvedValue(cfg); + mocks.readConfigFileSnapshot.mockResolvedValue(snapshot(cfg)); + mocks.replaceConfigFile.mockResolvedValue(undefined); + + await modelsAliasesAddCommand("zippy", "clawrouter/deepseek/deepseek-v4-flash", makeRuntime()); + + const [replaceParams] = mocks.replaceConfigFile.mock.calls[0] ?? []; + const written = replaceParams?.nextConfig as OpenClawConfig; + const persisted = stampConfigWriteMetadata(written, "2026-07-18T00:00:00.000Z", "test", cfg); + const policy = createModelVisibilityPolicy({ + cfg: persisted, + catalog: [], + defaultProvider: "clawrouter", + defaultModel: "deepseek/deepseek-v4-flash", + }); + expect(written.agents?.defaults?.modelPolicy).toBeUndefined(); + expect(persisted.meta?.migrations?.modelPolicyAllowlist).toBe(true); + expect(policy.allows({ provider: "openai", model: "gpt-5.6-sol" })).toBe(true); + }); +}); + describe("modelsAliasesListCommand <-> modelsAliasesRemoveCommand agreement", () => { beforeEach(() => { mocks.readConfigFileSnapshot.mockReset(); diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index f554bf68ec38..3c924dd8b195 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -40,7 +40,10 @@ import { import { resolveEnvApiKey } from "../../agents/model-auth.js"; import { loadModelCatalogSnapshot } from "../../agents/model-catalog.js"; import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; -import { modelCatalogLogicalKey } from "../../agents/model-selection-shared.js"; +import { + modelCatalogLogicalKey, + resolveConfiguredModelPolicyAllow, +} from "../../agents/model-selection-shared.js"; import { buildModelAliasIndex, isCliProvider, @@ -484,7 +487,7 @@ export async function modelsStatusCommand( } return acc; }, {}); - const allowed = Object.keys(cfg.agents?.defaults?.models ?? {}); + const allowed = [...resolveConfiguredModelPolicyAllow({ cfg, agentId: workspaceAgentId }).refs]; const modelsPath = path.join(agentDir, "models.json"); const aliasIndex = buildModelAliasIndex({ @@ -1415,7 +1418,7 @@ export async function modelsStatusCommand( )}`, ); runtime.log( - `${label(`Configured models (${allowed.length || 0})`)}${colorize(rich, theme.muted, ":")} ${colorize( + `${label(`Allowed models (${allowed.length || 0})`)}${colorize(rich, theme.muted, ":")} ${colorize( rich, allowed.length ? theme.info : theme.muted, allowed.length ? allowed.join(", ") : "all", diff --git a/src/config/config.meta-timestamp-coercion.test.ts b/src/config/config.meta-timestamp-coercion.test.ts index 1c184acd868e..9f18f1299a4f 100644 --- a/src/config/config.meta-timestamp-coercion.test.ts +++ b/src/config/config.meta-timestamp-coercion.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { validateConfigObject } from "./validation.js"; -describe("meta.lastTouchedAt numeric timestamp coercion", () => { +describe("config metadata", () => { it("accepts a numeric Unix timestamp and coerces it to an ISO string", () => { const numericTimestamp = 1770394758161; const res = validateConfigObject({ @@ -59,4 +59,17 @@ describe("meta.lastTouchedAt numeric timestamp coercion", () => { }); expect(res.ok).toBe(true); }); + + it("accepts the model-policy migration completion marker", () => { + const res = validateConfigObject({ + meta: { + migrations: { modelPolicyAllowlist: true }, + }, + }); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.meta?.migrations?.modelPolicyAllowlist).toBe(true); + } + }); }); diff --git a/src/config/io.meta.test.ts b/src/config/io.meta.test.ts index a2d865ba9a61..074f5dece513 100644 --- a/src/config/io.meta.test.ts +++ b/src/config/io.meta.test.ts @@ -1,6 +1,7 @@ // Verifies config IO metadata for persisted and generated settings. import { describe, expect, it } from "vitest"; import { AUTO_MANAGED_CONFIG_META_PATHS, stampConfigWriteMetadata } from "./io.meta.js"; +import { computeModelPolicyAllowlist } from "./model-policy-allowlist-migration.js"; describe("config write metadata stamping", () => { it("stamps every declared auto-managed meta path", () => { @@ -16,4 +17,297 @@ describe("config write metadata stamping", () => { expect(typeof stamped.meta?.[field]).toBe("string"); } }); + + it("preserves a legacy model restriction before an unrelated write updates version metadata", () => { + const previous = { + meta: { lastTouchedVersion: "2026.7.1" }, + agents: { + defaults: { + models: { + "openai/*": {}, + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + }; + + const stamped = stampConfigWriteMetadata( + previous, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + previous, + ); + + expect(stamped.agents?.defaults?.modelPolicy?.allow).toEqual([ + "openai/*", + "anthropic/claude-sonnet-4-6", + ]); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("honors an explicit empty default model policy added on the first write", () => { + const previous = { + agents: { + defaults: { + models: { "openai/gpt-5.5": {} }, + }, + }, + }; + const next = { + agents: { + defaults: { + ...previous.agents.defaults, + modelPolicy: {}, + }, + }, + }; + + const stamped = stampConfigWriteMetadata( + next, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + previous, + ); + + expect(stamped.agents?.defaults?.modelPolicy).toEqual({}); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("marks a model-map-free write so later model metadata stays unrestricted", () => { + const stamped = stampConfigWriteMetadata({}, "2026-07-18T00:00:00.000Z", "2026.7.2", {}); + const edited = { + ...stamped, + agents: { + defaults: { + models: { "custom/private-model": { alias: "private" } }, + }, + }, + }; + + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + expect( + computeModelPolicyAllowlist({ + root: edited, + defaults: edited.agents.defaults, + }), + ).toBeNull(); + }); + + it("does not widen a legacy restriction from model metadata added by the first write", () => { + const previous = { + agents: { defaults: { models: { "openai/gpt-5.5": {} } } }, + }; + const stamped = stampConfigWriteMetadata( + { + agents: { + defaults: { + models: { + "openai/gpt-5.5": {}, + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + }, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + previous, + ); + + expect(stamped.agents?.defaults?.modelPolicy?.allow).toEqual(["openai/gpt-5.5"]); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("does not drop a legacy restriction when the first write removes model metadata", () => { + const stamped = stampConfigWriteMetadata({}, "2026-07-18T00:00:00.000Z", "2026.7.2", { + agents: { defaults: { models: { "openai/gpt-5.5": {} } } }, + }); + + expect(stamped.agents?.defaults?.modelPolicy?.allow).toEqual(["openai/gpt-5.5"]); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("does not materialize per-agent model metadata as policy before stamping", () => { + const previous = { + agents: { + list: [{ id: "worker", models: { "anthropic/claude-sonnet-4-6": {} } }], + }, + }; + + const stamped = stampConfigWriteMetadata( + previous, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + previous, + ); + + expect(stamped.agents?.list?.[0]?.modelPolicy).toBeUndefined(); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("keeps per-agent model metadata policy-free when the candidate already has the marker", () => { + const previous = { + agents: { + list: [{ id: "worker", models: { "anthropic/claude-sonnet-4-6": {} } }], + }, + }; + + const stamped = stampConfigWriteMetadata( + { ...previous, meta: { migrations: { modelPolicyAllowlist: true as const } } }, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + previous, + ); + + expect(stamped.agents?.list?.[0]?.modelPolicy).toBeUndefined(); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("materializes only the default restriction when per-agent metadata is present", () => { + const previous = { + agents: { + defaults: { models: { "openai/gpt-5.5": {} } }, + list: [{ id: "worker", models: { "anthropic/claude-sonnet-4-6": {} } }], + }, + }; + + const stamped = stampConfigWriteMetadata( + previous, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + previous, + ); + + expect(stamped.agents?.defaults?.modelPolicy?.allow).toEqual(["openai/gpt-5.5"]); + expect(stamped.agents?.list?.[0]?.modelPolicy).toBeUndefined(); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("does not widen an explicit per-agent policy with newly added metadata", () => { + const previous = { + agents: { + list: [ + { + id: "worker", + models: { "anthropic/claude-sonnet-4-6": {} }, + modelPolicy: { allow: ["anthropic/claude-sonnet-4-6"] }, + }, + ], + }, + }; + const next = { + agents: { + list: [ + { + id: "worker", + models: { + "anthropic/claude-sonnet-4-6": {}, + "openai/gpt-5.5": {}, + }, + modelPolicy: { allow: ["anthropic/claude-sonnet-4-6"] }, + }, + ], + }, + }; + + const stamped = stampConfigWriteMetadata( + next, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + previous, + ); + + expect(stamped.agents?.list?.[0]?.modelPolicy?.allow).toEqual(["anthropic/claude-sonnet-4-6"]); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("marks a replacement agent's new model map as metadata-only", () => { + const previous = { + agents: { + list: [{ id: "legacy", models: { "anthropic/claude-sonnet-4-6": {} } }], + }, + }; + const next = { + agents: { + list: [{ id: "replacement", models: { "openai/gpt-5.5": {} } }], + }, + }; + + const stamped = stampConfigWriteMetadata( + next, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + previous, + ); + + expect(stamped.agents?.list?.[0]?.modelPolicy).toBeUndefined(); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("does not trust a candidate marker over an unmarked legacy snapshot", () => { + const stamped = stampConfigWriteMetadata( + { + meta: { migrations: { modelPolicyAllowlist: true } }, + agents: { defaults: { models: { "openai/gpt-5.5": {} } } }, + }, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + { + agents: { defaults: { models: { "anthropic/claude-sonnet-4-6": {} } } }, + }, + ); + + expect(stamped.agents?.defaults?.modelPolicy?.allow).toEqual(["anthropic/claude-sonnet-4-6"]); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("marks a newly created metadata model map without restricting it", () => { + const stamped = stampConfigWriteMetadata( + { agents: { defaults: { models: { "openai/gpt-5.5": {} } } } }, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + null, + ); + + expect(stamped.agents?.defaults?.modelPolicy).toBeUndefined(); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("does not restore a removed allow list after the migration marker exists", () => { + const stamped = stampConfigWriteMetadata( + { agents: { defaults: { models: { "openai/gpt-5.5": {} } } } }, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + { + meta: { migrations: { modelPolicyAllowlist: true } }, + agents: { + defaults: { + models: { "openai/gpt-5.5": {} }, + modelPolicy: { allow: ["openai/gpt-5.5"] }, + }, + }, + }, + ); + + expect(stamped.agents?.defaults?.modelPolicy).toBeUndefined(); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); + + it("does not restore a removed explicit allow list from a pre-marker config", () => { + const stamped = stampConfigWriteMetadata( + { agents: { defaults: { models: { "openai/gpt-5.5": {} } } } }, + "2026-07-18T00:00:00.000Z", + "2026.7.2", + { + agents: { + defaults: { + models: { "openai/gpt-5.5": {} }, + modelPolicy: { allow: ["openai/gpt-5.5"] }, + }, + }, + }, + ); + + expect(stamped.agents?.defaults?.modelPolicy).toBeUndefined(); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); + }); }); diff --git a/src/config/io.meta.ts b/src/config/io.meta.ts index 5d12ed92ff2a..5f74742f1304 100644 --- a/src/config/io.meta.ts +++ b/src/config/io.meta.ts @@ -1,5 +1,11 @@ +import { isRecord } from "../utils.js"; // Maintains config metadata fields written alongside user config. import { VERSION } from "../version.js"; +import { + computeModelPolicyAllowlist, + hasModelPolicyAllowlistMigrationMarker, + isExplicitModelPolicy, +} from "./model-policy-allowlist-migration.js"; import type { OpenClawConfig } from "./types.openclaw.js"; /** Metadata keys automatically stamped on config writes. */ @@ -13,15 +19,92 @@ export const AUTO_MANAGED_CONFIG_META_PATHS = [ ["meta", AUTO_MANAGED_CONFIG_META_FIELDS.lastTouchedAt], ] as const; +function defaultModelScope(value: unknown): Record | null { + if (!isRecord(value) || !isRecord(value.agents) || !isRecord(value.agents.defaults)) { + return null; + } + return value.agents.defaults; +} + +function collectLegacyDefaultModelAllow(value: unknown): string[] | null { + const defaults = defaultModelScope(value); + if (!defaults) { + return null; + } + return computeModelPolicyAllowlist({ + root: value, + defaults, + }); +} + +function withDefaultModelAllow(cfg: OpenClawConfig, allow: string[]): OpenClawConfig { + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + modelPolicy: { + ...cfg.agents?.defaults?.modelPolicy, + allow, + }, + }, + }, + }; +} + +function withModelPolicyAllowlistMigrationMarker( + cfg: OpenClawConfig, + params: { + defaultAllow?: string[]; + } = {}, +): OpenClawConfig { + const withDefault = params.defaultAllow ? withDefaultModelAllow(cfg, params.defaultAllow) : cfg; + return { + ...withDefault, + meta: { + ...withDefault.meta, + migrations: { + ...withDefault.meta?.migrations, + modelPolicyAllowlist: true, + }, + }, + }; +} + +function stampModelPolicyAllowlistMigrationForWrite( + cfg: OpenClawConfig, + previousConfig: unknown, +): OpenClawConfig { + const previousDefaultAllow = collectLegacyDefaultModelAllow(previousConfig); + const defaultAllow = isExplicitModelPolicy(cfg.agents?.defaults?.modelPolicy) + ? undefined + : (previousDefaultAllow ?? undefined); + if (defaultAllow) { + return withModelPolicyAllowlistMigrationMarker(cfg, { defaultAllow }); + } + if (hasModelPolicyAllowlistMigrationMarker(cfg)) { + return cfg; + } + // The pre-write snapshot distinguishes a legacy restriction from a model map + // created under the new metadata-only semantics before the general version stamp changes. + return withModelPolicyAllowlistMigrationMarker(cfg); +} + export function stampConfigWriteMetadata( cfg: OpenClawConfig, now: string = new Date().toISOString(), version: string = VERSION, + previousConfig?: unknown, ): OpenClawConfig { + const migrationStamped = + previousConfig === undefined + ? cfg + : stampModelPolicyAllowlistMigrationForWrite(cfg, previousConfig); return { - ...cfg, + ...migrationStamped, meta: { - ...cfg.meta, + ...migrationStamped.meta, [AUTO_MANAGED_CONFIG_META_FIELDS.lastTouchedVersion]: version, [AUTO_MANAGED_CONFIG_META_FIELDS.lastTouchedAt]: now, }, diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 17367a98cc50..26023fa8caec 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -2492,6 +2492,7 @@ describe("config io write", () => { meta: { lastTouchedAt: persisted.meta?.lastTouchedAt, lastTouchedVersion: persisted.meta?.lastTouchedVersion, + migrations: { modelPolicyAllowlist: true }, }, }); expect(typeof persisted.meta?.lastTouchedAt).toBe("string"); diff --git a/src/config/io.write-safety.ts b/src/config/io.write-safety.ts index 38dcd44ed3ed..cc02cc7273e4 100644 --- a/src/config/io.write-safety.ts +++ b/src/config/io.write-safety.ts @@ -188,8 +188,12 @@ export function formatConfigArtifactTimestamp(ts: string): string { return ts.replaceAll(":", "-").replaceAll(".", "-"); } -export function stampConfigVersion(cfg: OpenClawConfig, version?: string): OpenClawConfig { - return stampConfigWriteMetadata(cfg, new Date().toISOString(), version); +export function stampConfigVersion( + cfg: OpenClawConfig, + version?: string, + previousConfig?: unknown, +): OpenClawConfig { + return stampConfigWriteMetadata(cfg, new Date().toISOString(), version, previousConfig); } export function resolveConfigSizeBaselineBytes(params: { diff --git a/src/config/io.write.ts b/src/config/io.write.ts index 73fc58a345a0..5ec3b4e5fec9 100644 --- a/src/config/io.write.ts +++ b/src/config/io.write.ts @@ -201,7 +201,11 @@ export async function writeConfigFileFromContext( deps.homedir(), ) as OpenClawConfig; const outputConfig = applyUnsetPathsForWrite(tildeRestoredOutputConfig, unsetPaths); - const stampedOutputConfig = stampConfigVersion(outputConfig, options.lastTouchedVersionOverride); + const stampedOutputConfig = stampConfigVersion( + outputConfig, + options.lastTouchedVersionOverride, + snapshot.exists ? snapshot.parsed : null, + ); const json = JSON.stringify(stampedOutputConfig, null, 2).trimEnd().concat("\n"); const nextHash = hashConfigRaw(json); const previousHash = resolveConfigSnapshotHash(snapshot); diff --git a/src/config/model-policy-allowlist-migration.ts b/src/config/model-policy-allowlist-migration.ts new file mode 100644 index 000000000000..404447c88291 --- /dev/null +++ b/src/config/model-policy-allowlist-migration.ts @@ -0,0 +1,50 @@ +// Shared legacy model allowlist detection for runtime, doctor, and config writes. +import { isRecord } from "../utils.js"; + +export const MODEL_POLICY_ALLOWLIST_MIGRATION_MARKER = "modelPolicyAllowlist"; + +export function hasModelPolicyAllowlistMigrationMarker(value: unknown): boolean { + if (!isRecord(value) || !isRecord(value.meta) || !isRecord(value.meta.migrations)) { + return false; + } + return value.meta.migrations[MODEL_POLICY_ALLOWLIST_MIGRATION_MARKER] === true; +} + +/** Any policy object opts into the explicit model-policy semantics. */ +export function isExplicitModelPolicy(value: unknown): value is Record { + return isRecord(value); +} + +/** A per-agent policy replaces inherited defaults only when it owns `allow`. */ +export function hasExplicitModelPolicyAllow(value: unknown): boolean { + return isExplicitModelPolicy(value) && Object.hasOwn(value, "allow"); +} + +export function computeModelPolicyAllowlist(params: { + root: unknown; + defaults: unknown; +}): string[] | null { + // Unmarked persisted configs are indistinguishable from shipped legacy configs. + // Preserve their restrictions until doctor or a config write stamps the marker. + if (hasModelPolicyAllowlistMigrationMarker(params.root)) { + return null; + } + return collectLegacyDefaultModelAllowRefs(params.defaults); +} + +function collectLegacyDefaultModelAllowRefs(defaults: unknown): string[] | null { + if (!isRecord(defaults)) { + return null; + } + // An explicit modelPolicy object (even `{}`, which means allow-any) opts into the + // new semantics, so a sibling models map stays metadata-only and is never read as + // a legacy allowlist. + if (isExplicitModelPolicy(defaults.modelPolicy)) { + return null; + } + if (!isRecord(defaults.models)) { + return null; + } + const refs = Object.keys(defaults.models).filter((key) => key.trim().length > 0); + return refs.length > 0 ? refs : null; +} diff --git a/src/config/schema.help.agents.ts b/src/config/schema.help.agents.ts index 1fb1c8b3fba0..67fe1ed0e705 100644 --- a/src/config/schema.help.agents.ts +++ b/src/config/schema.help.agents.ts @@ -76,6 +76,10 @@ export const AGENT_FIELD_HELP: Record = { "agents.list.*.utilityModel": "Optional per-agent utility model override for short internal tasks. Overrides agents.defaults.utilityModel.", "agents.list.*.models": "Per-agent model catalog overrides keyed by full provider/model IDs.", + "agents.list.*.modelPolicy": + "Per-agent model override policy. An explicit allow list replaces the default policy for this agent.", + "agents.list.*.modelPolicy.allow": + 'Allowed model override refs for this agent. Accepts aliases, full "provider/model" refs, and provider wildcards; empty permits any model.', "agents.list.*.models.*.agentRuntime": "Optional per-model runtime policy for this agent. Use this for agent-specific model exceptions instead of setting a whole-agent runtime.", "agents.list.*.models.*.agentRuntime.id": diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index 31c39cd4f1f6..8207b5d659ed 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -11,6 +11,9 @@ export const CORE_FIELD_HELP: Record = { meta: "Metadata fields automatically maintained by OpenClaw to record write/version history for this config file. Keep these values system-managed and avoid manual edits unless debugging migration history.", "meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.", "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", + "meta.migrations": "System-managed completion markers for one-time config migrations.", + "meta.migrations.modelPolicyAllowlist": + "Records that doctor preserved or evaluated legacy default and per-agent model-map override restrictions.", marketplaces: "Marketplace feed and local package source profile settings. Feeds provide package selection and governance metadata, while sources define the local source names that install candidates may reference.", "marketplaces.feeds": diff --git a/src/config/schema.help.models.ts b/src/config/schema.help.models.ts index d3a72fb70c31..41e4c2922da9 100644 --- a/src/config/schema.help.models.ts +++ b/src/config/schema.help.models.ts @@ -215,7 +215,11 @@ export const MODEL_FIELD_HELP: Record = { 'Include absolute timestamps in message envelopes, direct agent prompt prefixes, and embedded model-input prefixes ("on" or "off").', "agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").', "agents.defaults.models": - "Configured model catalog and allowlist (keys are full provider/model IDs or literal provider/* entries for dynamic provider catalogs).", + "Configured model catalog and per-model settings. Entries provide aliases, params, and runtime metadata; they do not restrict model overrides.", + "agents.defaults.modelPolicy": + "Explicit policy for model overrides. Omit it or leave allow empty to permit any model.", + "agents.defaults.modelPolicy.allow": + 'Allowed model override refs. Accepts aliases, full "provider/model" refs, and provider wildcards such as "openai/*". Empty permits any model.', "agents.defaults.models.*.agentRuntime": "Optional per-model runtime policy for the default agent. Use this for model-specific runtime exceptions instead of setting a whole-agent runtime.", "agents.defaults.models.*.agentRuntime.id": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 3c54500c0e2c..6c4009b5016f 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -9,6 +9,8 @@ export const FIELD_LABELS: Record = { meta: "Metadata", "meta.lastTouchedVersion": "Config Last Touched Version", "meta.lastTouchedAt": "Config Last Touched At", + "meta.migrations": "Config Migrations", + "meta.migrations.modelPolicyAllowlist": "Model Policy Allowlist Migration", marketplaces: "Marketplaces", "marketplaces.feeds": "Marketplace Feeds", "marketplaces.feeds.*.url": "Marketplace Feed URL", @@ -124,6 +126,8 @@ export const FIELD_LABELS: Record = { "agents.list[].contextLimits.toolResultMaxChars": "Agent Tool Result Max Chars", "agents.list[].contextLimits.postCompactionMaxChars": "Agent Post-compaction Max Chars", "agents.list.*.models": "Agent Model Overrides", + "agents.list.*.modelPolicy": "Agent Model Policy", + "agents.list.*.modelPolicy.allow": "Allowed Agent Models", "agents.list.*.models.*.agentRuntime": "Agent Model Runtime", "agents.list.*.models.*.agentRuntime.id": "Agent Model Runtime ID", "agents.list.*.agentRuntime": "Legacy Agent Runtime", @@ -691,6 +695,8 @@ export const FIELD_LABELS: Record = { "auth.cooldowns.overloadedBackoffMs": "Overloaded Backoff (ms)", "auth.cooldowns.rateLimitedProfileRotations": "Rate-Limited Profile Rotations", "agents.defaults.models": "Models", + "agents.defaults.modelPolicy": "Model Policy", + "agents.defaults.modelPolicy.allow": "Allowed Models", "agents.defaults.models.*.agentRuntime": "Default Agent Model Runtime", "agents.defaults.models.*.agentRuntime.id": "Default Agent Model Runtime ID", "agents.defaults.model.primary": "Primary Model", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 1e6f1858ac86..587e2e6e5040 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -46,6 +46,11 @@ export type AgentModelEntryConfig = { streaming?: boolean; }; +export type AgentModelPolicyConfig = { + /** Model refs allowed for session/run overrides. Empty or omitted allows any model. */ + allow?: string[]; +}; + export type AgentModelListConfig = { /** Primary provider/model ref. */ primary?: string; @@ -258,6 +263,8 @@ export type AgentDefaultsConfig = { pdfMaxPages?: number; /** Model catalog with optional aliases (full provider/model keys). */ models?: Record; + /** Explicit model override policy. Empty or omitted allow permits any model. */ + modelPolicy?: AgentModelPolicyConfig; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ workspace?: string; /** Optional default allowlist of skills for agents that do not set agents.list[].skills. */ diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 2332eead8052..7d1ee38fdc5e 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -5,6 +5,7 @@ import type { AgentContextLimitsConfig, AgentDefaultsConfig, AgentModelEntryConfig, + AgentModelPolicyConfig, EmbeddedAgentExecutionContract, SubagentDelegationMode, } from "./types.agent-defaults.js"; @@ -96,6 +97,8 @@ export type AgentConfig = { agentRuntime?: AgentModelEntryConfig["agentRuntime"]; /** Per-model metadata overrides for this agent. */ models?: Record; + /** Per-agent model override policy. Replaces the default policy when allow is present. */ + modelPolicy?: AgentModelPolicyConfig; /** @deprecated Legacy per-agent compaction config is kept for raw doctor migration/repair. */ compaction?: AgentDefaultsConfig["compaction"]; /** Optional per-agent default thinking level (overrides agents.defaults.thinkingDefault). */ diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 54e00fe0868c..93cfce40dc68 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -105,6 +105,11 @@ export type OpenClawConfig = { lastTouchedVersion?: string; /** ISO timestamp when this config was last written. */ lastTouchedAt?: string; + /** One-time doctor migrations already applied to this config. */ + migrations?: { + /** Legacy default/per-agent model-map restrictions were preserved or confirmed unrestricted. */ + modelPolicyAllowlist?: true; + }; }; /** Authentication provider/profile configuration. */ auth?: AuthConfig; diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index f23c97e3ed27..4bd7c9da64e7 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -38,6 +38,23 @@ describe("agent defaults schema", () => { expect(agent.utilityModel).toBe("google/gemini-3.1-flash-lite-preview"); }); + it("accepts explicit model policy on defaults and agent entries", () => { + const defaults = AgentDefaultsSchema.parse({ + modelPolicy: { allow: ["openai/*", "anthropic/claude-sonnet-4-6"] }, + }); + const agent = AgentEntrySchema.parse({ + id: "ops", + modelPolicy: { allow: [] }, + }); + + expect(defaults?.modelPolicy?.allow).toEqual(["openai/*", "anthropic/claude-sonnet-4-6"]); + expect(agent.modelPolicy?.allow).toEqual([]); + expectSchemaFailurePath( + AgentDefaultsSchema.safeParse({ modelPolicy: { allow: "openai/*" } }), + "modelPolicy.allow", + ); + }); + it("accepts subagent archiveAfterMinutes=0 to disable archiving", () => { expectSchemaSuccess( AgentDefaultsSchema.safeParse({ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 3038db89ef93..99970888ef89 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -6,6 +6,7 @@ import { AgentSandboxSchema, AgentContextLimitsSchema, AgentModelRuntimeEntrySchema, + AgentModelPolicySchema, AgentModelSchema, AgentToolModelSchema, MemorySearchSchema, @@ -65,6 +66,7 @@ export const AgentDefaultsSchema = z pdfMaxBytesMb: z.number().positive().optional(), pdfMaxPages: z.number().int().positive().optional(), models: z.record(z.string(), AgentModelRuntimeEntrySchema).optional(), + modelPolicy: AgentModelPolicySchema.optional(), workspace: z.string().optional(), skills: z.array(z.string()).optional(), silentReply: SilentReplyPolicyConfigSchema.optional(), diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 413f91950163..b0f59420e574 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -1031,6 +1031,12 @@ export const AgentModelRuntimeEntrySchema = z }) .strict(); +export const AgentModelPolicySchema = z + .object({ + allow: z.array(z.string()).optional(), + }) + .strict(); + export const AgentEntrySchema = z .object({ id: z.string(), @@ -1042,6 +1048,7 @@ export const AgentEntrySchema = z model: AgentModelSchema.optional(), utilityModel: z.string().optional(), models: z.record(z.string(), AgentModelRuntimeEntrySchema).optional(), + modelPolicy: AgentModelPolicySchema.optional(), thinkingDefault: z .enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max", "ultra"]) .optional(), diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index ed22a6f9403d..bdbc058a637a 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -59,6 +59,11 @@ export const OpenClawSchemaShape = { .pipe(z.string()), ]) .optional(), + migrations: z + .strictObject({ + modelPolicyAllowlist: z.literal(true).optional(), + }) + .optional(), }) .optional(), env: z diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index 9930aba7cb7d..d766ec6b017d 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -73,6 +73,7 @@ type AgentTurnPayload = { type SelectModelOptions = { cfg?: Record; + cfgWithAgentDefaults?: Record; agentConfigOverride?: Pick; payload?: AgentTurnPayload; sessionEntry?: { @@ -137,7 +138,7 @@ async function selectModel(options: SelectModelOptions = {}) { const cfg = options.cfg ?? {}; return resolveCronModelSelection({ cfg: cfg as never, - cfgWithAgentDefaults: cfg as never, + cfgWithAgentDefaults: (options.cfgWithAgentDefaults ?? cfg) as never, agentConfigOverride: options.agentConfigOverride, sessionEntry: options.sessionEntry ?? {}, payload: options.payload ?? defaultPayload(), @@ -247,10 +248,70 @@ describe("cron model formatting and precedence edge cases", () => { ).resolves.toEqual({ ok: false, error: - "cron payload.model 'anthropic/claude-sonnet-4-6' rejected by agents.defaults.models allowlist: anthropic/claude-sonnet-4-6 is not in [(none configured)]", + "cron payload.model 'anthropic/claude-sonnet-4-6' rejected by agents.defaults.modelPolicy.allow: anthropic/claude-sonnet-4-6 is not in [(none configured)]", }); }); + it("reports the active per-agent allowlist path and refs", async () => { + resolveAllowedModelRefMock.mockReturnValueOnce({ + error: "model not allowed: openai/gpt-5.5", + }); + + await expect( + selectModel({ + agentId: "ops", + cfg: { + agents: { + list: [{ id: "ops", modelPolicy: { allow: ["anthropic/*"] } }], + }, + }, + payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "openai/gpt-5.5" }, + }), + ).resolves.toEqual({ + ok: false, + error: + "cron payload.model 'openai/gpt-5.5' rejected by agents.list[].modelPolicy.allow: openai/gpt-5.5 is not in [anthropic/*]", + }); + }); + + it("authorizes cron payload aliases against the original agent policy scope", async () => { + const cfg = { + agents: { + defaults: { + models: { "openai/gpt-5.5": { alias: "approved" } }, + modelPolicy: { allow: ["approved"] }, + }, + list: [ + { + id: "worker", + models: { "anthropic/claude-sonnet-4-6": { alias: "approved" } }, + }, + ], + }, + }; + const cfgWithAgentDefaults = { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents.defaults, + models: cfg.agents.list[0]?.models, + }, + }, + }; + + await selectModel({ + cfg, + cfgWithAgentDefaults, + agentId: "worker", + payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "approved" }, + }); + + expect(resolveAllowedModelRefMock).toHaveBeenCalledWith( + expect.objectContaining({ cfg, agentId: "worker", raw: "approved" }), + ); + }); + it("normalizes provider casing", async () => { await expectSelectedModel( { diff --git a/src/cron/isolated-agent/model-selection.ts b/src/cron/isolated-agent/model-selection.ts index 7a2987755c25..0a9acef21b5c 100644 --- a/src/cron/isolated-agent/model-selection.ts +++ b/src/cron/isolated-agent/model-selection.ts @@ -1,3 +1,4 @@ +import { resolveConfiguredModelPolicyAllow } from "../../agents/model-selection-shared.js"; /** Resolves provider/model precedence for isolated cron runs. */ import type { AgentConfig } from "../../config/types.agents.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -45,23 +46,26 @@ type ResolveCronModelSelectionResult = error: string; }; -function formatAllowedModelRefs(params: { cfg: OpenClawConfig }): string { - const configured = params.cfg.agents?.defaults?.models; - if (configured && typeof configured === "object" && Object.keys(configured).length > 0) { - return Object.keys(configured).toSorted().join(", "); +function formatAllowedModelRefs(params: { cfg: OpenClawConfig; agentId?: string }): string { + const configured = resolveConfiguredModelPolicyAllow(params).refs; + if (configured && configured.length > 0) { + return configured.toSorted().join(", "); } return "(none configured)"; } function formatCronPayloadModelRejection(params: { cfg: OpenClawConfig; + agentId?: string; modelOverride: string; error: string; }): string { const { modelOverride, error } = params; if (error.startsWith("model not allowed:")) { const modelRef = error.slice("model not allowed:".length).trim(); - return `cron payload.model '${modelOverride}' rejected by agents.defaults.models allowlist: ${modelRef} is not in [${formatAllowedModelRefs({ cfg: params.cfg })}]`; + const policy = resolveConfiguredModelPolicyAllow(params); + const policyPath = policy.configPath ?? "agents.defaults.modelPolicy.allow"; + return `cron payload.model '${modelOverride}' rejected by ${policyPath}: ${modelRef} is not in [${formatAllowedModelRefs(params)}]`; } return `cron payload.model '${modelOverride}' rejected: ${error}`; } @@ -99,11 +103,12 @@ export async function resolveCronModelSelection( // Subagent/agent model config is advisory here: invalid refs fall back to // defaults so an agent config typo does not prevent unrelated cron runs. const resolvedSubagent = resolveAllowedModelRef({ - cfg: params.cfgWithAgentDefaults, + cfg: params.cfg, catalog: await loadCatalogOnce(), raw: subagentModelRaw, defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model, + agentId: params.agentId, }); if (!("error" in resolvedSubagent)) { provider = resolvedSubagent.ref.provider; @@ -128,6 +133,7 @@ export async function resolveCronModelSelection( ref: hooksGmailModelRef, defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model, + agentId: params.agentId, }); if (status.allowed) { provider = hooksGmailModelRef.provider; @@ -143,17 +149,19 @@ export async function resolveCronModelSelection( // Payload model overrides are explicit cron config, so reject disallowed // refs instead of silently falling back to defaults. const resolvedOverride = resolveAllowedModelRef({ - cfg: params.cfgWithAgentDefaults, + cfg: params.cfg, catalog: await loadCatalogOnce(), raw: modelOverride, defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model, + agentId: params.agentId, }); if ("error" in resolvedOverride) { return { ok: false, error: formatCronPayloadModelRejection({ - cfg: params.cfgWithAgentDefaults, + cfg: params.cfg, + agentId: params.agentId, modelOverride, error: resolvedOverride.error, }), @@ -172,11 +180,12 @@ export async function resolveCronModelSelection( const sessionProviderOverride = params.sessionEntry.providerOverride?.trim() || resolvedDefault.provider; const resolvedSessionOverride = resolveAllowedModelRef({ - cfg: params.cfgWithAgentDefaults, + cfg: params.cfg, catalog: await loadCatalogOnce(), raw: `${sessionProviderOverride}/${sessionModelOverride}`, defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model, + agentId: params.agentId, }); if (!("error" in resolvedSessionOverride)) { provider = resolvedSessionOverride.ref.provider; diff --git a/src/cron/isolated-agent/run-config.ts b/src/cron/isolated-agent/run-config.ts index 181bc41e5af5..f1c19cfc2c13 100644 --- a/src/cron/isolated-agent/run-config.ts +++ b/src/cron/isolated-agent/run-config.ts @@ -63,6 +63,8 @@ export function buildCronAgentDefaultsConfig(params: { // Keep nested configs owned by agent-aware resolvers out of this flattened snapshot. // Copying a partial sandbox or memorySearch object into defaults destroys its global // fields before the resolver can merge the selected agent's override. + // Model authorization likewise uses the unflattened config plus agent id; this + // snapshot only carries the effective runtime metadata and explicit policy. return mergeCronAgentModelOverride({ defaults: Object.assign({}, params.defaults, definedOverrides), overrideModel, diff --git a/src/cron/isolated-agent/run.model-policy-config-preserved.test.ts b/src/cron/isolated-agent/run.model-policy-config-preserved.test.ts new file mode 100644 index 000000000000..a940713151a2 --- /dev/null +++ b/src/cron/isolated-agent/run.model-policy-config-preserved.test.ts @@ -0,0 +1,68 @@ +// Cron policy tests cover per-agent defaults flattening before model resolution. +import { describe, expect, it } from "vitest"; +import { resolveAgentConfig } from "../../agents/agent-scope.js"; +import { resolveAllowedModelRef } from "../../agents/model-selection-resolve.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { buildCronAgentDefaultsConfig } from "./run-config.js"; + +function buildCronConfig(cfg: OpenClawConfig, agentId: string): OpenClawConfig { + const defaults = buildCronAgentDefaultsConfig({ + defaults: cfg.agents?.defaults, + agentConfigOverride: resolveAgentConfig(cfg, agentId), + }); + return { + ...cfg, + agents: { ...cfg.agents, defaults }, + }; +} + +function resolveCronPayloadModel(cfg: OpenClawConfig, raw: string) { + return resolveAllowedModelRef({ + cfg, + catalog: [ + { provider: "openai", id: "gpt-5.5", name: "GPT 5.5" }, + { provider: "openai", id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }, + ], + raw, + defaultProvider: "openai", + defaultModel: "baseline", + manifestPlugins: [], + }); +} + +describe("buildCronAgentDefaultsConfig model policy preservation", () => { + it("keeps the inherited default restriction when the per-agent policy is empty", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { modelPolicy: { allow: ["openai/gpt-5.5"] } }, + list: [{ id: "worker", modelPolicy: {} }], + }, + }; + + const cronCfg = buildCronConfig(cfg, "worker"); + + expect(cronCfg.agents?.defaults?.modelPolicy).toEqual({ allow: ["openai/gpt-5.5"] }); + expect(resolveCronPayloadModel(cronCfg, "openai/gpt-5.6-sol")).toEqual({ + error: "model not allowed: openai/gpt-5.6-sol", + }); + }); + + it("applies an explicit per-agent allowlist to cron model resolution", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { modelPolicy: { allow: ["openai/gpt-5.5"] } }, + list: [{ id: "worker", modelPolicy: { allow: ["openai/gpt-5.6-sol"] } }], + }, + }; + + const cronCfg = buildCronConfig(cfg, "worker"); + + expect(cronCfg.agents?.defaults?.modelPolicy).toEqual({ allow: ["openai/gpt-5.6-sol"] }); + expect(resolveCronPayloadModel(cronCfg, "openai/gpt-5.5")).toEqual({ + error: "model not allowed: openai/gpt-5.5", + }); + expect(resolveCronPayloadModel(cronCfg, "openai/gpt-5.6-sol")).toMatchObject({ + ref: { provider: "openai", model: "gpt-5.6-sol" }, + }); + }); +}); diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts index 12aa106ac60c..350cb0e55f32 100644 --- a/src/cron/isolated-agent/run.skill-filter.test.ts +++ b/src/cron/isolated-agent/run.skill-filter.test.ts @@ -255,6 +255,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { defaults: { model: { primary: "openai/gpt-5.4", fallbacks: defaultFallbacks }, models: { "openai/gpt-5.4": {} }, + modelPolicy: { allow: ["openai/gpt-5.4"] }, }, }, }, @@ -270,7 +271,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { expect(result.status).toBe("error"); expect(result.error).toBe( - "cron payload.model 'anthropic/claude-sonnet-4-6' rejected by agents.defaults.models allowlist: anthropic/claude-sonnet-4-6 is not in [openai/gpt-5.4]", + "cron payload.model 'anthropic/claude-sonnet-4-6' rejected by agents.defaults.modelPolicy.allow: anthropic/claude-sonnet-4-6 is not in [openai/gpt-5.4]", ); expect(logWarnMock).not.toHaveBeenCalled(); expect(runWithModelFallbackMock).not.toHaveBeenCalled(); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 6728abca75ff..3fe63e915405 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -769,7 +769,9 @@ async function prepareCronRunContext(params: { } const resolvedModelSelection = await resolveCronModelSelection({ - cfg: input.cfg, + // Authorization needs the unflattened active config so inherited policy + // aliases cannot be rebound by the selected agent's metadata aliases. + cfg: runtimeCfg, cfgWithAgentDefaults, agentConfigOverride, sessionEntry: cronSession.sessionEntry, diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index ad4c84bd89d5..05597bdf0e43 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -412,9 +412,10 @@ const hooksModelCheck: HealthCheck = { findings.push({ checkId: "core/doctor/hooks-model", severity: "warning", - message: `hooks.gmail.model "${status.key}" is not in agents.defaults.models allowlist.`, + message: `hooks.gmail.model "${status.key}" is not allowed by agents.defaults.modelPolicy.allow.`, path: "hooks.gmail.model", - fixHint: "Add the model to agents.defaults.models or remove hooks.gmail.model.", + fixHint: + "Add the model or its provider wildcard to agents.defaults.modelPolicy.allow, or remove hooks.gmail.model.", }); } if (!status.inCatalog) { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 5234fffb795d..398cefcfa186 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -815,7 +815,7 @@ async function runHooksModelHealth(ctx: DoctorHealthFlowContext): Promise const warnings: string[] = []; if (!status.allowed) { warnings.push( - `- hooks.gmail.model "${status.key}" not in agents.defaults.models allowlist (will use primary instead)`, + `- hooks.gmail.model "${status.key}" not allowed by agents.defaults.modelPolicy.allow (will use primary instead)`, ); } if (!status.inCatalog) { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index a3aeb214297a..387d9d195fe9 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -37,6 +37,7 @@ import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, } from "../config/model-input.js"; +import { computeModelPolicyAllowlist } from "../config/model-policy-allowlist-migration.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveOwningPluginIdsForProviderRef } from "../plugins/providers.js"; import type { ProviderPlugin } from "../plugins/types.js"; @@ -1496,26 +1497,65 @@ export function applyModelAllowlist( const normalized = normalizeModelKeys(models); const scopeKeys = opts.scopeKeys ? normalizeModelKeys(opts.scopeKeys) : []; const scopeKeySet = scopeKeys.length > 0 ? new Set(scopeKeys) : null; + const existingModels = normalizeAgentModelMapForConfig(defaults?.models ?? {}); + const legacyAllow = computeModelPolicyAllowlist({ + root: cfg, + defaults, + }); + const existingAllow = normalizeModelKeys(defaults?.modelPolicy?.allow ?? legacyAllow ?? []); + const scopeProviders = new Set( + scopeKeys.map((key) => normalizeProviderId(key.slice(0, key.indexOf("/")))), + ); + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER }); + const isPolicyRefInScope = (raw: string): boolean => { + const trimmed = raw.trim(); + if (trimmed.endsWith("/*")) { + return scopeProviders.has(normalizeProviderId(trimmed.slice(0, -2))); + } + const resolved = resolveModelRefFromString({ + cfg, + raw: trimmed, + defaultProvider: DEFAULT_PROVIDER, + aliasIndex, + }); + return Boolean( + resolved && scopeKeySet?.has(modelKey(resolved.ref.provider, resolved.ref.model)), + ); + }; if (normalized.length === 0) { - if (!defaults?.models) { + // No agent defaults means no policy/legacy map to edit; nothing to clear. + if (!defaults || (!defaults.modelPolicy && !legacyAllow)) { return cfg; } if (scopeKeySet) { - const nextModels = { ...defaults.models }; - for (const key of scopeKeySet) { - delete nextModels[key]; - } - const { models: _ignored, ...restDefaults } = defaults; + const nextAllow = existingAllow.filter((key) => !isPolicyRefInScope(key)); + const { modelPolicy: _modelPolicy, ...restDefaults } = defaults; return { ...cfg, agents: { ...cfg.agents, - defaults: - Object.keys(nextModels).length > 0 ? { ...defaults, models: nextModels } : restDefaults, + defaults: { + ...restDefaults, + ...(nextAllow.length > 0 || legacyAllow + ? { modelPolicy: { ...defaults?.modelPolicy, allow: nextAllow } } + : {}), + }, }, }; } - const { models: _ignored, ...restDefaults } = defaults; + if (legacyAllow) { + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + modelPolicy: { ...defaults?.modelPolicy, allow: [] }, + }, + }, + }; + } + const { modelPolicy: _modelPolicy, ...restDefaults } = defaults; return { ...cfg, agents: { @@ -1525,15 +1565,17 @@ export function applyModelAllowlist( }; } - const existingModels = normalizeAgentModelMapForConfig(defaults?.models ?? {}); if (scopeKeySet) { const nextModels = { ...existingModels }; - for (const key of scopeKeySet) { - delete nextModels[key]; - } for (const key of normalized) { nextModels[key] = existingModels[key] ?? {}; } + const nextAllow = existingAllow.filter((key) => !isPolicyRefInScope(key)); + for (const key of normalized) { + if (!nextAllow.includes(key)) { + nextAllow.push(key); + } + } return { ...cfg, agents: { @@ -1541,12 +1583,13 @@ export function applyModelAllowlist( defaults: { ...defaults, models: nextModels, + modelPolicy: { ...defaults?.modelPolicy, allow: nextAllow }, }, }, }; } - const nextModels: Record = {}; + const nextModels: Record = { ...existingModels }; for (const key of normalized) { nextModels[key] = existingModels[key] ?? {}; } @@ -1558,6 +1601,7 @@ export function applyModelAllowlist( defaults: { ...defaults, models: nextModels, + modelPolicy: { ...defaults?.modelPolicy, allow: normalized }, }, }, }; diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index 1df9ed27d206..71354a0961a6 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -104,6 +104,11 @@ const BASE_RELOAD_RULES: ReloadRule[] = [ kind: "hot", actions: ["restart-heartbeat"], }, + { + prefix: "agents.defaults.modelPolicy", + kind: "hot", + actions: ["restart-heartbeat"], + }, { prefix: "agents.defaults.model", kind: "hot", diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index cae8cb4c9d20..f9c75da78bb1 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -322,6 +322,10 @@ describe("buildGatewayReloadPlan", () => { path: "agents.defaults.models", expected: { restartHeartbeat: true }, }, + { + path: "agents.defaults.modelPolicy.allow", + expected: { restartHeartbeat: true }, + }, { path: "agents.list", expected: { restartHeartbeat: true }, diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index f7a465d4c07a..7b979b8b616d 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -972,7 +972,7 @@ export async function startGatewaySidecars(params: { }); if (!status.allowed) { params.logHooks.warn( - `hooks.gmail.model "${status.key}" not in agents.defaults.models allowlist (will use primary instead)`, + `hooks.gmail.model "${status.key}" not allowed by agents.defaults.modelPolicy.allow (will use primary instead)`, ); } if (!status.inCatalog) { diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index 4d5a8bfeff40..0d7625f809d4 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -188,6 +188,7 @@ const configuredProviderModelConfig = (params: ConfiguredProviderModelFixture) = models: { [`${params.provider}/${params.modelId}`]: { alias: params.alias }, }, + modelPolicy: { allow: [`${params.provider}/${params.modelId}`] }, }, }, models: { @@ -292,6 +293,7 @@ describe("gateway server models + voicewake", () => { defaults: { model: { primary: options.primary }, models: options.models, + modelPolicy: { allow: Object.keys(options.models) }, }, }, }, @@ -635,7 +637,7 @@ describe("gateway server models + voicewake", () => { ); }); - test("models.list configured view still prefers agents.defaults.models allowlist", async () => { + test("models.list configured view prefers the explicit model policy", async () => { await withModelsConfig( { agents: { @@ -644,6 +646,7 @@ describe("gateway server models + voicewake", () => { models: { "openai/gpt-test-z": {}, }, + modelPolicy: { allow: ["openai/gpt-test-z"] }, }, }, models: { @@ -669,7 +672,7 @@ describe("gateway server models + voicewake", () => { ); }); - test("models.list all view bypasses agents.defaults.models allowlist", async () => { + test("models.list all view bypasses the explicit model policy", async () => { await withModelsConfig( { agents: { @@ -678,6 +681,7 @@ describe("gateway server models + voicewake", () => { models: { "openai/gpt-test-z": {}, }, + modelPolicy: { allow: ["openai/gpt-test-z"] }, }, }, }, diff --git a/src/gateway/server/hooks.agent-trust.test.ts b/src/gateway/server/hooks.agent-trust.test.ts index f1b51952b57e..a49164ce00ef 100644 --- a/src/gateway/server/hooks.agent-trust.test.ts +++ b/src/gateway/server/hooks.agent-trust.test.ts @@ -404,7 +404,7 @@ describe("dispatchAgentHook trust handling", () => { it("prefers cron diagnostics for returned hook errors", async () => { const diagnosticSummary = - "cron payload.model 'anthropic/claude-sonnet-4-6' rejected by agents.defaults.models allowlist: anthropic/claude-sonnet-4-6"; + "cron payload.model 'anthropic/claude-sonnet-4-6' rejected by agents.defaults.modelPolicy.allow: anthropic/claude-sonnet-4-6"; runCronIsolatedAgentTurnMock.mockResolvedValueOnce({ status: "error", summary: "generic failure", diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index 0c8dd79f10cf..e4a48e86391b 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -656,7 +656,7 @@ export function createModelCatalogPresetAppliers(params }); } -/** Ensure static model allowlists include a provider model ref after onboarding. */ +/** Ensure static per-model config includes a provider model ref after onboarding. */ export function ensureModelAllowlistEntry(params: { cfg: OpenClawConfig; modelRef: string; diff --git a/src/plugins/provider-auth-choice-helpers.test.ts b/src/plugins/provider-auth-choice-helpers.test.ts index 847f83bf8d25..65042ab4e52c 100644 --- a/src/plugins/provider-auth-choice-helpers.test.ts +++ b/src/plugins/provider-auth-choice-helpers.test.ts @@ -31,7 +31,7 @@ describe("applyProviderAuthConfigPatch", () => { expect(next.agents?.defaults?.model).toEqual(base.agents.defaults.model); }); - it("keeps configured primary and fallback refs in a newly introduced allowlist", () => { + it("does not turn primary and fallback refs into per-model config entries", () => { const next = applyProviderAuthConfigPatch( { agents: { @@ -48,12 +48,10 @@ describe("applyProviderAuthConfigPatch", () => { expect(next.agents?.defaults?.models).toEqual({ "openai/gpt-5.6-sol": {}, - "openai/gpt-5.5": {}, - "anthropic/claude-opus-4-6": {}, }); }); - it("replaces the allowlist only when replaceDefaultModels is set", () => { + it("replaces the per-model config only when replaceDefaultModels is set", () => { const patch = { agents: { defaults: { @@ -198,7 +196,6 @@ describe("applyProviderAuthConfigPatch", () => { alias: "gemini", params: { thinking: "high", maxTokens: 12_000 }, }, - "openai/gpt-5.5": {}, }); }); @@ -352,7 +349,6 @@ describe("applyDefaultModel", () => { }); expect(next.agents?.defaults?.models).toEqual({ "openrouter/auto": {}, - "anthropic/claude-opus-4-6": {}, }); }); @@ -392,12 +388,10 @@ describe("applyDefaultModel", () => { }); expect(next.agents?.defaults?.models).toEqual({ "openrouter/auto": {}, - "anthropic/claude-opus-4-6": {}, - "openai/gpt-5.4": {}, }); }); - it("adds the model to the allowlist", () => { + it("adds the model to per-model config", () => { const config = { agents: { defaults: { models: { "anthropic/claude-sonnet-4-6": {} } } }, } as OpenClawConfig; diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index 5b866548a05a..64c20d6d267b 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -137,6 +137,18 @@ function normalizeAgentModelMapForWrite(value: unknown): unknown { return normalizeAgentModelMapForConfig(value); } +function normalizeAgentModelPolicyForWrite(value: unknown): unknown { + if (!isPlainRecord(value) || !Array.isArray(value.allow)) { + return value; + } + return { + ...value, + allow: value.allow.map((ref) => + typeof ref === "string" ? normalizeAgentModelRefForConfig(ref) : ref, + ), + }; +} + function normalizeProviderCatalogModelIdForWrite(provider: string, modelId: string): string { const trimmed = modelId.trim(); if (!trimmed) { @@ -232,6 +244,13 @@ function normalizeAgentListForWrite(value: unknown): unknown { mutated = true; } } + if (Object.hasOwn(agent, "modelPolicy")) { + const normalizedModelPolicy = normalizeAgentModelPolicyForWrite(agent.modelPolicy); + if (normalizedModelPolicy !== agent.modelPolicy) { + nextAgent = { ...nextAgent, modelPolicy: normalizedModelPolicy }; + mutated = true; + } + } return nextAgent; }); @@ -259,6 +278,11 @@ function normalizeConfigModelRefsForWrite( defaults.models, ) as typeof defaults.models; } + if (defaults.modelPolicy !== undefined) { + nextDefaults.modelPolicy = normalizeAgentModelPolicyForWrite( + defaults.modelPolicy, + ) as typeof defaults.modelPolicy; + } } const nextAgentsList = normalizeAgentListForWrite(agentsList); @@ -276,38 +300,6 @@ function normalizeConfigModelRefsForWrite( }; } -/** Keep a restrictive model allowlist consistent with the configured primary and fallbacks. */ -function ensureConfiguredDefaultModelsAllowed(cfg: OpenClawConfig): OpenClawConfig { - const defaults = cfg.agents?.defaults; - if (!defaults?.models) { - return cfg; - } - const model = defaults.model; - const refs = [ - typeof model === "string" ? model : model?.primary, - ...(typeof model === "object" ? (model.fallbacks ?? []) : []), - ].filter((ref): ref is string => typeof ref === "string" && ref.trim().length > 0); - const models = normalizeAgentModelMapForConfig(defaults.models); - let changed = false; - for (const ref of refs) { - const normalizedRef = normalizeAgentModelRefForConfig(ref); - if (!models[normalizedRef]) { - models[normalizedRef] = {}; - changed = true; - } - } - if (!changed) { - return cfg; - } - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { ...defaults, models }, - }, - }; -} - export function applyProviderAuthConfigPatch( cfg: OpenClawConfig, patch: unknown, @@ -323,7 +315,7 @@ export function applyProviderAuthConfigPatch( providerConfigNormalizer, ); if (!options?.replaceDefaultModels || !isPlainRecord(patch)) { - return ensureConfiguredDefaultModelsAllowed(merged); + return merged; } const patchModels = (patch.agents as { defaults?: { models?: unknown } } | undefined)?.defaults @@ -401,7 +393,7 @@ export function applyDefaultModel( normalizeAgentModelRefForConfig(fallback), ) : undefined; - return ensureConfiguredDefaultModelsAllowed({ + return { ...cfg, agents: { ...cfg.agents, @@ -417,5 +409,5 @@ export function applyDefaultModel( }, }, }, - }); + }; }