diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 360f4445e282..9c66f7c69a51 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -56,7 +56,7 @@ OpenAI API-key and ChatGPT/Codex subscription credentials remain distinct. See Related model-config surfaces: - `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.modelPolicy.allow` is the optional override allowlist. Use exact refs or trailing prefix wildcards such as `provider/*` and `provider/namespace/*`; 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. @@ -80,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. 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`). +- The Control UI model picker asks the Gateway for its configured model view. An explicit `modelPolicy.allow` filters it, including trailing prefix 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). @@ -112,14 +112,14 @@ If `agents.defaults.modelPolicy.allow` is non-empty, it becomes the allowlist fo ```text 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. +Add "provider/model", "provider/*", or a narrower "provider/namespace/*" prefix to agents.defaults.modelPolicy.allow, or remove/empty the list to allow any model. ``` 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. -To limit providers without listing every model, use `provider/*` wildcard entries: +To limit providers without listing every model, use trailing prefix wildcard entries. A provider-wide `provider/*` matches every model under that provider; a narrower prefix such as `clawrouter/anthropic/*` matches only that namespace: ```json5 { diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 42bd830f9306..fd1029792531 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -436,7 +436,7 @@ Time format in system prompt. Default: `auto` (OS preference). - 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 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. +- `modelPolicy.allow`: explicit override allowlist. Accepts aliases, exact `provider/model` refs, and trailing prefix wildcards such as `openai/*` or `clawrouter/anthropic/*`. 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/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 5d7283e0ae6e..44f677c4e843 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -506,6 +506,19 @@ vi.mock("./model-catalog.js", () => ({ vi.mock("./model-selection.js", () => { const normalizeProviderId = (provider: string) => provider.trim().toLowerCase(); + const isModelKeyAllowedBySet = (allowedKeys: ReadonlySet, key: string) => { + if (allowedKeys.has(key)) { + return true; + } + let separator = key.indexOf("/"); + while (separator > 0) { + if (allowedKeys.has(`${key.slice(0, separator + 1)}*`)) { + return true; + } + separator = key.indexOf("/", separator + 1); + } + return false; + }; const buildAllowedModelSet = ({ cfg, catalog, @@ -582,22 +595,22 @@ vi.mock("./model-selection.js", () => { defaultModel?: string; }) => { const allowed = buildAllowedModelSet(params); - const allowsKey = (key: string) => { - if (allowed.allowAny || allowed.allowedKeys.has(key)) { - return true; - } - const slash = key.indexOf("/"); - return slash > 0 && allowed.allowedKeys.has(`${key.slice(0, slash)}/*`); - }; + const wildcardModelKeys = new Set( + [...allowed.allowedKeys].filter((key) => key.endsWith("/*")), + ); + const allowsKey = (key: string) => + allowed.allowAny || isModelKeyAllowedBySet(allowed.allowedKeys, key); return { ...allowed, exactModelRefs: [], providerWildcards: new Set(), hasConfiguredEntries: !allowed.allowAny, - hasProviderWildcards: [...allowed.allowedKeys].some((key) => key.endsWith("/*")), + hasProviderWildcards: wildcardModelKeys.size > 0, allowsKey, allows: ({ provider, model }: { provider: string; model: string }) => allowsKey(`${provider}/${model}`), + allowsByWildcard: ({ provider, model }: { provider: string; model: string }) => + isModelKeyAllowedBySet(wildcardModelKeys, `${provider}/${model}`), resolveSelection: ({ provider, model }: { provider: string; model: string }) => { const key = `${provider}/${model}`; if (allowsKey(key)) { @@ -637,13 +650,7 @@ vi.mock("./model-selection.js", () => { : [], ); }, - isModelKeyAllowedBySet: (allowedKeys: ReadonlySet, key: string) => { - if (allowedKeys.has(key)) { - return true; - } - const slash = key.indexOf("/"); - return slash > 0 && allowedKeys.has(`${key.slice(0, slash)}/*`); - }, + isModelKeyAllowedBySet, buildModelAliasIndex: ({ cfg, }: { @@ -750,13 +757,21 @@ vi.mock("./model-visibility-policy.js", () => ({ const allowedCatalog = allowAny ? (catalog ?? []) : (catalog ?? []).filter((entry) => allowedKeys.has(`${entry.provider}/${entry.id}`)); - const allowsKey = (key: string) => { - if (allowAny || allowedKeys.has(key)) { + const isModelKeyAllowedBySet = (keys: ReadonlySet, key: string) => { + if (keys.has(key)) { return true; } - const slash = key.indexOf("/"); - return slash > 0 && allowedKeys.has(`${key.slice(0, slash)}/*`); + let separator = key.indexOf("/"); + while (separator > 0) { + if (keys.has(`${key.slice(0, separator + 1)}*`)) { + return true; + } + separator = key.indexOf("/", separator + 1); + } + return false; }; + const wildcardModelKeys = new Set([...allowedKeys].filter((key) => key.endsWith("/*"))); + const allowsKey = (key: string) => allowAny || isModelKeyAllowedBySet(allowedKeys, key); return { allowAny, allowedKeys, @@ -764,10 +779,12 @@ vi.mock("./model-visibility-policy.js", () => ({ exactModelRefs: [], providerWildcards: new Set(), hasConfiguredEntries: !allowAny, - hasProviderWildcards: [...allowedKeys].some((key) => key.endsWith("/*")), + hasProviderWildcards: wildcardModelKeys.size > 0, allowsKey, allows: ({ provider, model }: { provider: string; model: string }) => allowsKey(`${provider}/${model}`), + allowsByWildcard: ({ provider, model }: { provider: string; model: string }) => + isModelKeyAllowedBySet(wildcardModelKeys, `${provider}/${model}`), resolveSelection: ({ provider, model }: { provider: string; model: string }) => { const key = `${provider}/${model}`; if (allowsKey(key)) { diff --git a/src/agents/model-catalog-visibility.ts b/src/agents/model-catalog-visibility.ts index 78785cc52e61..d264053ed182 100644 --- a/src/agents/model-catalog-visibility.ts +++ b/src/agents/model-catalog-visibility.ts @@ -3,7 +3,6 @@ * combines explicit policy, configured models, defaults, and runtime * auth-backed availability. */ -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ModelAuthAvailabilityEvaluation, @@ -301,7 +300,9 @@ export async function resolveLogicalVisibleModelCatalog(params: { const key = resolveLogicalKey(entry, params.routePolicy); const preferredKey = preferredKeys.has(key); const wildcardRoute = - policy.allowAny || policy.providerWildcards.has(normalizeProviderId(entry.provider)); + policy.allowAny || + (policy.hasProviderWildcards && + policy.allowsByWildcard({ provider: entry.provider, model: entry.id })); if (!preferredKey && !wildcardRoute) { continue; } diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 4a014929ee59..6e7f1f826b16 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -11,6 +11,7 @@ import { computeModelPolicyAllowlist, hasExplicitModelPolicyAllow, } from "../config/model-policy-allowlist-migration.js"; +import { parseModelPolicyWildcardRef } from "../config/model-policy-ref.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; @@ -143,7 +144,7 @@ function listModelAliasCandidates(cfg: OpenClawConfig, agentId?: string): ModelA } return modelMaps.flatMap((models) => Object.entries(models ?? {}).flatMap(([keyRaw, entryRaw]) => { - if (parseProviderWildcardModelRef(keyRaw)) { + if (parseModelPolicyWildcardRef(keyRaw)) { return []; } const alias = @@ -663,7 +664,7 @@ function buildModelCatalogMetadata( const aliasByKey = new Map(); const configuredModels = params.cfg.agents?.defaults?.models ?? {}; for (const [rawKey, entryRaw] of Object.entries(configuredModels)) { - if (parseProviderWildcardModelRef(rawKey)) { + if (parseModelPolicyWildcardRef(rawKey)) { continue; } const key = resolveAllowlistModelKey({ @@ -1001,6 +1002,10 @@ export function buildAllowedModelSetWithFallbacks( cfg: params.cfg, agentId: params.agentId, }); + const wildcardModelKeys = resolveConfiguredWildcardModelKeys({ + cfg: params.cfg, + agentId: params.agentId, + }); const policyAliasAgentId = resolvePolicyAliasAgentId(visibility.configPath, params.agentId); const policyAliasIndex = params.aliasIndex ?? @@ -1095,8 +1100,8 @@ export function buildAllowedModelSetWithFallbacks( const allowedKeys = new Set(); const allowedRefs: ModelRef[] = []; const syntheticCatalogEntries = new Map(); - for (const provider of visibility.providerWildcards) { - allowedKeys.add(providerWildcardModelKey(provider)); + for (const wildcardKey of wildcardModelKeys) { + allowedKeys.add(wildcardKey); } const addAllowedCatalogRef = (ref: ModelRef) => { if ( @@ -1108,7 +1113,7 @@ export function buildAllowedModelSetWithFallbacks( allowedRefs.push(ref); } }; - for (const entry of expandModelCatalogProviderWildcards(catalog, visibility.providerWildcards)) { + for (const entry of expandModelCatalogWildcards(catalog, wildcardModelKeys)) { allowedKeys.add(modelKey(entry.provider, entry.id)); addAllowedCatalogRef({ provider: entry.provider, model: entry.id }); } @@ -1137,8 +1142,8 @@ export function buildAllowedModelSetWithFallbacks( if ( defaultKey && - ((visibility.exactModelRefs.length > 0 && visibility.providerWildcards.size === 0) || - (defaultRef && visibility.providerWildcards.has(normalizeProviderId(defaultRef.provider)))) + ((visibility.exactModelRefs.length > 0 && wildcardModelKeys.size === 0) || + isModelKeyAllowedBySet(wildcardModelKeys, defaultKey)) ) { allowedKeys.add(defaultKey); if (defaultRef) { @@ -1156,11 +1161,7 @@ export function buildAllowedModelSetWithFallbacks( ...syntheticCatalogEntries.values(), ]; - if ( - allowedCatalog.length === 0 && - allowedKeys.size === 0 && - visibility.providerWildcards.size === 0 - ) { + if (allowedCatalog.length === 0 && allowedKeys.size === 0 && wildcardModelKeys.size === 0) { if (defaultKey) { catalogKeys.add(defaultKey); } @@ -1486,14 +1487,6 @@ export function normalizeModelSelection(value: unknown): string | undefined { return undefined; } -function parseProviderWildcardModelRef(raw: string): string | null { - const trimmed = raw.trim(); - if (!trimmed.endsWith("/*")) { - return null; - } - return normalizeProviderId(trimmed.slice(0, -2)) || null; -} - const DEFAULT_MODEL_POLICY_ALLOW_CONFIG_PATH = "agents.defaults.modelPolicy.allow"; const AGENT_MODEL_POLICY_ALLOW_CONFIG_PATH = "agents.list[].modelPolicy.allow"; @@ -1564,9 +1557,9 @@ export function parseConfiguredModelVisibilityEntries(params: { if (!trimmed) { continue; } - const wildcardProvider = parseProviderWildcardModelRef(trimmed); - if (wildcardProvider) { - providerWildcards.add(wildcardProvider); + const wildcard = parseModelPolicyWildcardRef(trimmed); + if (wildcard) { + providerWildcards.add(wildcard.provider); continue; } exactModelRefs.push(raw); @@ -1581,27 +1574,42 @@ export function parseConfiguredModelVisibilityEntries(params: { }; } -function providerWildcardModelKey(provider: string): string { - return modelKey(normalizeProviderId(provider), "*"); +function resolveConfiguredWildcardModelKeys(params: { + cfg?: OpenClawConfig; + agentId?: string; +}): Set { + const wildcardModelKeys = new Set(); + for (const raw of resolveConfiguredModelPolicyAllow(params).refs) { + const wildcard = parseModelPolicyWildcardRef(raw); + if (wildcard) { + wildcardModelKeys.add(wildcard.key); + } + } + return wildcardModelKeys; } -/** Expand provider wildcard policy entries against discovered catalog rows. */ -export function expandModelCatalogProviderWildcards( +/** Expand segment-boundary prefix wildcard policy entries against discovered catalog rows. */ +function expandModelCatalogWildcards( catalog: readonly T[], - providerWildcards: ReadonlySet, + wildcardModelKeys: ReadonlySet, ): T[] { - return catalog.filter((entry) => providerWildcards.has(normalizeProviderId(entry.provider))); + return catalog.filter((entry) => + isModelKeyAllowedBySet(wildcardModelKeys, modelKey(entry.provider, entry.id)), + ); } export function isModelKeyAllowedBySet(allowedKeys: ReadonlySet, key: string): boolean { if (allowedKeys.has(key)) { return true; } - const separator = key.indexOf("/"); - if (separator <= 0) { - return false; + let separator = key.indexOf("/"); + while (separator > 0) { + if (allowedKeys.has(`${key.slice(0, separator + 1)}*`)) { + return true; + } + separator = key.indexOf("/", separator + 1); } - return allowedKeys.has(providerWildcardModelKey(key.slice(0, separator))); + return false; } function resolveAllowedModelSelection( @@ -1658,6 +1666,7 @@ export type ModelVisibilityPolicy = { automaticFallbackKeys: ReadonlySet; allowsKey: (key: string) => boolean; allows: (ref: { provider: string; model: string }) => boolean; + allowsByWildcard: (ref: { provider: string; model: string }) => boolean; resolveSelection: (ref: { provider: string; model: string }) => ModelRef | null; visibleCatalog: (params: { catalog: readonly ModelCatalogEntry[]; @@ -1708,6 +1717,10 @@ export function createModelVisibilityPolicyWithFallbacks( cfg: params.cfg, agentId: params.agentId, }); + const wildcardModelKeys = resolveConfiguredWildcardModelKeys({ + cfg: params.cfg, + agentId: params.agentId, + }); const policyAliasAgentId = resolvePolicyAliasAgentId(visibility.configPath, params.agentId); const policyAliasIndex = buildModelAliasIndex({ cfg: params.cfg, @@ -1736,7 +1749,7 @@ export function createModelVisibilityPolicyWithFallbacks( retained: boolean, aliasIndex: ModelAliasIndex, ) => { - if (!raw?.trim() || parseProviderWildcardModelRef(raw)) { + if (!raw?.trim() || parseModelPolicyWildcardRef(raw)) { return; } const resolved = resolveModelRefFromString({ @@ -1799,12 +1812,14 @@ export function createModelVisibilityPolicyWithFallbacks( exactModelRefs: visibility.exactModelRefs, providerWildcards: visibility.providerWildcards, hasConfiguredEntries: visibility.hasEntries, - hasProviderWildcards: visibility.providerWildcards.size > 0, + hasProviderWildcards: wildcardModelKeys.size > 0, allowConfigPath: visibility.configPath, allowRepairConfigPath: visibility.repairConfigPath, automaticFallbackKeys: allowed.automaticFallbackKeys, allowsKey, allows: (ref) => allowsKey(modelKey(ref.provider, ref.model)), + allowsByWildcard: (ref) => + isModelKeyAllowedBySet(wildcardModelKeys, modelKey(ref.provider, ref.model)), resolveSelection: (ref) => resolveAllowedModelSelection({ provider: ref.provider, @@ -1824,17 +1839,15 @@ export function createModelVisibilityPolicyWithFallbacks( if (allowed.allowAny) { return [...defaultVisibleCatalog]; } - if (visibility.providerWildcards.size === 0) { + if (wildcardModelKeys.size === 0) { return [...allowed.allowedCatalog]; } return dedupeModelCatalogEntries([ ...defaultVisibleCatalog.filter((entry) => - visibility.providerWildcards.has(normalizeProviderId(entry.provider)), + isModelKeyAllowedBySet(wildcardModelKeys, modelKey(entry.provider, entry.id)), ), - ...allowed.allowedCatalog.filter( - (entry) => - !visibility.providerWildcards.has(normalizeProviderId(entry.provider)) || - exactConfiguredKeys.has(modelKey(entry.provider, entry.id)), + ...allowed.allowedCatalog.filter((entry) => + exactConfiguredKeys.has(modelKey(entry.provider, entry.id)), ), ]); }, diff --git a/src/agents/model-visibility-policy.test.ts b/src/agents/model-visibility-policy.test.ts index 36099031b02c..ad0158a6f457 100644 --- a/src/agents/model-visibility-policy.test.ts +++ b/src/agents/model-visibility-policy.test.ts @@ -8,6 +8,16 @@ function createPolicy(cfg: OpenClawConfig, agentId?: string) { cfg, catalog: [ { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" }, + { + provider: "clawrouter", + id: "anthropic/claude-haiku-4-5", + name: "Claude Haiku via ClawRouter", + }, + { + provider: "clawrouter", + id: "google/gemini-3.5-flash", + name: "Gemini Flash via ClawRouter", + }, { 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" }, @@ -85,6 +95,11 @@ describe("explicit model visibility policy", () => { 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.allowedCatalog.some( + (entry) => entry.provider === "external" && entry.id === "sensitive", + ), + ).toBe(false); expect(policy.automaticFallbackKeys).toEqual(new Set(["external/sensitive"])); }); @@ -121,6 +136,47 @@ describe("explicit model visibility policy", () => { expect(policy.allows({ provider: "anthropic", model: "claude-sonnet-4-6" })).toBe(false); }); + it("matches nested prefix wildcards on canonical model-key segment boundaries", () => { + const policy = createPolicy({ + agents: { + defaults: { + modelPolicy: { allow: ["clawrouter/anthropic/*", "openai/gpt-5.5"] }, + }, + }, + }); + + expect(policy.allowsKey("clawrouter/anthropic/claude-haiku-4-5")).toBe(true); + expect( + policy.allowsByWildcard({ + provider: "clawrouter", + model: "anthropic/claude-haiku-4-5", + }), + ).toBe(true); + expect(policy.allowsKey("clawrouter/anthropicX/claude-haiku-4-5")).toBe(false); + expect(policy.allowsKey("clawrouter/google/gemini-3.5-flash")).toBe(false); + expect(policy.allowsKey("openai/gpt-5.5")).toBe(true); + expect(policy.allowsByWildcard({ provider: "openai", model: "gpt-5.5" })).toBe(false); + expect(policy.allowsKey("openai/gpt-5.6-sol")).toBe(false); + expect(policy.allowedCatalog.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([ + "clawrouter/anthropic/claude-haiku-4-5", + "openai/gpt-5.5", + ]); + }); + + it("keeps top-level provider wildcard behavior for nested model ids", () => { + const policy = createPolicy({ + agents: { + defaults: { + modelPolicy: { allow: ["clawrouter/*"] }, + }, + }, + }); + + expect(policy.allowsKey("clawrouter/anthropic/claude-haiku-4-5")).toBe(true); + expect(policy.allowsKey("clawrouter/google/gemini-3.5-flash")).toBe(true); + expect(policy.allowsKey("openai/gpt-5.6-sol")).toBe(false); + }); + it("resolves conflicting policy aliases in each agent's model map", () => { const cfg: OpenClawConfig = { agents: { diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index bb4514523902..80e3eefa1087 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -879,6 +879,24 @@ describe("handleModelsCommand", () => { }); }); + it("filters nested provider namespaces with the same prefix policy as enforcement", async () => { + modelCatalogMocks.loadModelCatalog.mockResolvedValue([ + { provider: "clawrouter", id: "anthropic/claude-haiku-4-5", name: "Claude Haiku" }, + { provider: "clawrouter", id: "google/gemini-3.5-flash", name: "Gemini Flash" }, + { provider: "openai", id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + ]); + modelProviderAuthMocks.authenticatedProviders = new Set(["clawrouter", "openai"]); + + const data = await buildModelsProviderData({ + agents: { defaults: { modelPolicy: { allow: ["clawrouter/anthropic/*"] } } }, + } as OpenClawConfig); + + expect(data.providers).toEqual(["clawrouter"]); + expect([...expectDefined(data.byProvider.get("clawrouter"), "clawrouter models")]).toEqual([ + "anthropic/claude-haiku-4-5", + ]); + }); + it("keeps the telegram provider picker browse-only", async () => { modelCatalogMocks.loadModelCatalog.mockResolvedValue([ { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, @@ -1079,30 +1097,14 @@ describe("handleModelsCommand", () => { expect(authCheckerParams?.workspaceDir).toBe("/tmp/spawned-workspace"); }); - it("returns a deprecation message for /models add when no provider is given", async () => { - const result = await handleModelsCommand(buildParams("/models add"), true); - - expect(result).toEqual({ - shouldContinue: false, - reply: { text: MODELS_ADD_DEPRECATED_TEXT }, - }); - }); - - it("returns a deprecation message for /models add ", async () => { - const result = await handleModelsCommand(buildParams("/models add ollama"), true); - - expect(result).toEqual({ - shouldContinue: false, - reply: { text: MODELS_ADD_DEPRECATED_TEXT }, - }); - }); - - it("returns a deprecation message for /models add ", async () => { - const result = await handleModelsCommand(buildParams("/models add openai gpt-5.5"), true); - - expect(result).toEqual({ - shouldContinue: false, - reply: { text: MODELS_ADD_DEPRECATED_TEXT }, - }); - }); + it.each(["/models add", "/models add ollama", "/models add openai gpt-5.5"])( + "returns a deprecation message for %s", + async (command) => { + const result = await handleModelsCommand(buildParams(command), true); + expect(result).toEqual({ + shouldContinue: false, + reply: { text: MODELS_ADD_DEPRECATED_TEXT }, + }); + }, + ); }); diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 6066d0b9ce3d..7d096d887119 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -7,7 +7,6 @@ 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"; @@ -269,9 +268,11 @@ function buildModelPickerCatalog(params: { // 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, + for (const entry of params.allowedModelCatalog.filter((candidate) => + isModelKeyAllowedBySet( + params.allowedModelKeys, + modelKey(candidate.provider, candidate.id ?? ""), + ), )) { push({ provider: entry.provider, diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index bb953cb51472..05e145711f76 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -11,7 +11,6 @@ import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; 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, @@ -217,14 +216,14 @@ export async function createModelSelectionState(params: { 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 defaultModelVisibleByWildcard = visibilityPolicy.allowsByWildcard({ + provider: defaultProvider, + model: defaultModel, + }); const configuredModelCatalog = buildConfiguredModelCatalog({ cfg }); const needsModelCatalog = params.hasModelDirective || - (hasAllowlist && visibility.providerWildcards.size > 0 && !defaultProviderVisibleByWildcard); + (hasAllowlist && visibilityPolicy.hasProviderWildcards && !defaultModelVisibleByWildcard); let allowedModelKeys = new Set(); let allowedModelCatalog: ModelCatalog = configuredModelCatalog; diff --git a/src/commands/agent-command.test-mocks.ts b/src/commands/agent-command.test-mocks.ts index 68549ed8ce2e..e242634fc8fc 100644 --- a/src/commands/agent-command.test-mocks.ts +++ b/src/commands/agent-command.test-mocks.ts @@ -107,8 +107,14 @@ vi.mock("../agents/model-selection.js", () => { if (allowedKeys.has(key)) { return true; } - const slash = key.indexOf("/"); - return slash > 0 && allowedKeys.has(`${key.slice(0, slash)}/*`); + let separator = key.indexOf("/"); + while (separator > 0) { + if (allowedKeys.has(`${key.slice(0, separator + 1)}*`)) { + return true; + } + separator = key.indexOf("/", separator + 1); + } + return false; }; const resolvePrimary = (cfg?: ConfigWithModels): string | undefined => { const primary = cfg?.agents?.defaults?.model; @@ -178,24 +184,28 @@ vi.mock("../agents/model-selection.js", () => { const primary = resolveDefaultRef(cfg); refs.add(modelKey(primary.provider, primary.model)); const allowAny = policyRefs.length === 0; + const wildcardModelKeys = new Set( + policyRefs.filter((key) => key.endsWith("/*")).map((key) => key.trim().toLowerCase()), + ); + const wildcardProviders = new Set( + [...wildcardModelKeys].map((key) => key.slice(0, key.indexOf("/"))), + ); const allowsKey = (key: string) => allowAny || isModelKeyAllowedBySet(refs, key); return { allowAny, allowedKeys: refs, allowedCatalog: catalog, exactModelRefs: policyRefs.filter((key) => !key.endsWith("/*")), - providerWildcards: new Set( - policyRefs - .filter((key) => key.endsWith("/*")) - .map((key) => key.slice(0, -2).trim().toLowerCase()), - ), + providerWildcards: wildcardProviders, hasConfiguredEntries: policyRefs.length > 0, - hasProviderWildcards: policyRefs.some((key) => key.endsWith("/*")), + hasProviderWildcards: wildcardModelKeys.size > 0, allowConfigPath: policy.configPath, allowRepairConfigPath: "agents.defaults.modelPolicy.allow", automaticFallbackKeys: new Set(), allowsKey, allows: ({ provider, model }: ModelRef) => allowsKey(modelKey(provider, model)), + allowsByWildcard: ({ provider, model }: ModelRef) => + isModelKeyAllowedBySet(wildcardModelKeys, modelKey(provider, model)), resolveSelection: ({ provider, model }: ModelRef) => { const key = modelKey(provider, model); if (allowsKey(key)) { diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 5eb9ede05b56..dd94d86771a9 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -51,6 +51,7 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; +import { createModelVisibilityPolicy } from "../../agents/model-visibility-policy.js"; import { OPENAI_PROVIDER_ID } from "../../agents/openai-routing.js"; import { loadPreparedModelCatalogSnapshot } from "../../agents/prepared-model-catalog.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; @@ -65,6 +66,7 @@ import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, } from "../../config/model-input.js"; +import { parseModelPolicyWildcardRef } from "../../config/model-policy-ref.js"; import { resolveMergedModelProviderConfig } from "../../config/model-provider-config.js"; import { parseStrictFiniteNumber, @@ -487,7 +489,9 @@ export async function modelsStatusCommand( } return acc; }, {}); - const allowed = [...resolveConfiguredModelPolicyAllow({ cfg, agentId: workspaceAgentId }).refs]; + const configuredAllowRefs = [ + ...resolveConfiguredModelPolicyAllow({ cfg, agentId: workspaceAgentId }).refs, + ]; const modelsPath = path.join(agentDir, "models.json"); const aliasIndex = buildModelAliasIndex({ @@ -557,7 +561,7 @@ export async function modelsStatusCommand( imageModel, ...imageFallbacks, utilityModelRef ?? "", - ...allowed, + ...configuredAllowRefs, ]) { const ref = resolveStatusModelRef(raw); if (ref?.provider) { @@ -618,6 +622,32 @@ export async function modelsStatusCommand( ...(agentId ? { agentId } : {}), readOnly: true, }); + const visibilityPolicy = createModelVisibilityPolicy({ + cfg, + catalog: catalog.entries, + defaultProvider: resolved.provider, + defaultModel: resolved.model, + agentId: workspaceAgentId, + ...DISPLAY_MODEL_PARSE_OPTIONS, + }); + const allowed = visibilityPolicy.allowAny + ? [] + : [ + ...new Set([ + ...visibilityPolicy.allowedCatalog.map((entry) => modelKey(entry.provider, entry.id)), + ...configuredAllowRefs.flatMap((raw) => { + const wildcard = parseModelPolicyWildcardRef(raw); + if (!wildcard) { + return []; + } + const prefix = wildcard.key.slice(0, -1); + const hasCatalogMatch = catalog.entries.some((entry) => + modelKey(entry.provider, entry.id).startsWith(prefix), + ); + return hasCatalogMatch ? [] : [wildcard.key]; + }), + ]), + ].toSorted(); const routeSourcesByModel = new Map< string, Array<{ api?: (typeof catalog.routeVariants)[number]["api"]; baseUrl?: string }> @@ -1143,7 +1173,7 @@ export async function modelsStatusCommand( // Probe the configured utility model itself; an arbitrary catalog model // from the same provider can sit on a different auth route. utilityModelRef ?? "", - ...allowed, + ...configuredAllowRefs, ].filter(Boolean); const resolvedCandidates = rawCandidates .map( diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 39f087e24e14..221d85281836 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -439,6 +439,7 @@ async function withOpenAIStatusFixture( catalog?: unknown[]; routeVariants?: unknown[]; utilityModel?: string; + modelPolicyAllow?: string[]; }, run: () => Promise, ): Promise { @@ -459,6 +460,9 @@ async function withOpenAIStatusFixture( agents: { defaults: { model: { primary: params.primary, fallbacks: params.fallbacks ?? [] }, + ...(params.modelPolicyAllow + ? { modelPolicy: { allow: params.modelPolicyAllow } } + : undefined), // Route tests target the configured primary/fallback models; keep the // derived utility model out unless a test opts in explicitly. utilityModel: params.utilityModel ?? "", @@ -600,6 +604,54 @@ describe("modelsStatusCommand auth overview", () => { ); }); + it("expands nested wildcard policy entries to the models they actually allow", async () => { + await withOpenAIStatusFixture( + { + primary: "clawrouter/anthropic/claude-haiku-4-5", + profiles: {}, + modelPolicyAllow: ["clawrouter/anthropic/*"], + catalog: [ + { + provider: "clawrouter", + id: "anthropic/claude-haiku-4-5", + name: "Claude Haiku", + }, + { + provider: "clawrouter", + id: "google/gemini-3.5-flash", + name: "Gemini Flash", + }, + { provider: "openai", id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + ], + }, + async () => { + const localRuntime = createRuntime(); + await modelsStatusCommand({ json: true }, localRuntime as never); + + expect(parseFirstJsonLog(localRuntime).allowed).toEqual([ + "clawrouter/anthropic/claude-haiku-4-5", + ]); + }, + ); + }); + + it("preserves a restrictive wildcard when the current catalog has no match", async () => { + await withOpenAIStatusFixture( + { + primary: "openai/gpt-5.6-sol", + profiles: {}, + modelPolicyAllow: ["clawrouter/anthropic/*"], + catalog: [{ provider: "openai", id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }], + }, + async () => { + const localRuntime = createRuntime(); + await modelsStatusCommand({ json: true }, localRuntime as never); + + expect(parseFirstJsonLog(localRuntime).allowed).toEqual(["clawrouter/anthropic/*"]); + }, + ); + }); + it("reports the resolved utility model in JSON output", async () => { const originalLoadConfig = mocks.loadConfig.getMockImplementation(); const baseConfig = { diff --git a/src/config/model-policy-ref.ts b/src/config/model-policy-ref.ts new file mode 100644 index 000000000000..bd24c952731a --- /dev/null +++ b/src/config/model-policy-ref.ts @@ -0,0 +1,74 @@ +import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; + +const MODEL_POLICY_COMPAT_SELECTORS = new Set(["openrouter:auto", "openrouter:free"]); + +function hasControlCharacter(value: string): boolean { + for (const char of value) { + const codePoint = char.codePointAt(0) ?? 0; + if (codePoint <= 0x1f || codePoint === 0x7f) { + return true; + } + } + return false; +} + +function hasValidSegments( + segments: readonly string[], + bounds: { min: number; max?: number }, +): boolean { + return ( + segments.length >= bounds.min && + (bounds.max === undefined || segments.length <= bounds.max) && + segments.every( + (segment) => + segment.length > 0 && + !segment.includes("*") && + !/\s/u.test(segment) && + !hasControlCharacter(segment), + ) + ); +} + +type ModelPolicyWildcardRef = { + key: string; + provider: string; +}; + +/** Parse and canonicalize a segment-boundary model-policy prefix wildcard. */ +export function parseModelPolicyWildcardRef(raw: string): ModelPolicyWildcardRef | null { + const trimmed = raw.trim(); + if (!trimmed.endsWith("/*")) { + return null; + } + const segments = trimmed.split("/"); + if ( + segments.at(-1) !== "*" || + !hasValidSegments(segments.slice(0, -1), { + min: 1, + }) + ) { + return null; + } + const provider = normalizeProviderId(segments[0] ?? ""); + if (!provider) { + return null; + } + return { + key: [provider, ...segments.slice(1)].join("/"), + provider, + }; +} + +/** True for a syntactically valid exact provider/model policy reference. */ +export function isValidExactModelPolicyRef(raw: string): boolean { + const trimmed = raw.trim(); + const parsed = parseModelCatalogRef(trimmed); + return Boolean(parsed && hasValidSegments(trimmed.split("/"), { min: 2 })); +} + +/** True for a supported bare selector whose target is resolved from config. */ +export function isModelPolicyCompatSelector(raw: string): boolean { + return MODEL_POLICY_COMPAT_SELECTORS.has(normalizeLowercaseStringOrEmpty(raw)); +} diff --git a/src/config/schema.help.agents.ts b/src/config/schema.help.agents.ts index 60ad576d4853..69f3e611acce 100644 --- a/src/config/schema.help.agents.ts +++ b/src/config/schema.help.agents.ts @@ -77,7 +77,7 @@ export const AGENT_FIELD_HELP: Record = { "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.', + 'Allowed model override refs for this agent. Accepts aliases, full "provider/model" refs, and trailing prefix wildcards such as "provider/*" or "provider/namespace/*"; 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/validation.ts b/src/config/validation.ts index 8bfd2f603bd5..9298fd0fd902 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -54,6 +54,11 @@ import { } from "./channel-config-metadata.js"; import { shouldSuppressMissingCodexPluginDiagnostics } from "./codex-plugin-diagnostics.js"; import { materializeRuntimeConfig } from "./materialize.js"; +import { + isModelPolicyCompatSelector, + isValidExactModelPolicyRef, + parseModelPolicyWildcardRef, +} from "./model-policy-ref.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { coerceSecretRef } from "./types.secrets.js"; import { @@ -1047,6 +1052,61 @@ function validateGatewayTailscaleAuth(config: OpenClawConfig): ConfigValidationI ]; } +function collectModelPolicyAllowIssues(config: OpenClawConfig): ConfigValidationIssue[] { + const issues: ConfigValidationIssue[] = []; + const defaultModels = config.agents?.defaults?.models; + const collectAliases = (...modelMaps: Array): Set => { + const aliases = new Set(); + for (const models of modelMaps) { + for (const entry of Object.values(models ?? {})) { + const alias = normalizeLowercaseStringOrEmpty(entry?.alias); + if (alias) { + aliases.add(alias); + } + } + } + return aliases; + }; + const validateRefs = ( + refs: readonly string[] | undefined, + configPath: string, + aliases: Set, + ) => { + for (const [index, raw] of (refs ?? []).entries()) { + const trimmed = raw.trim(); + if ( + aliases.has(normalizeLowercaseStringOrEmpty(trimmed)) || + isModelPolicyCompatSelector(trimmed) || + isValidExactModelPolicyRef(trimmed) || + parseModelPolicyWildcardRef(trimmed) + ) { + continue; + } + issues.push({ + path: `${configPath}.${index}`, + message: + `invalid model policy ref: ${sanitizeForLog(JSON.stringify(raw))}. ` + + 'Use a configured alias, an exact "provider/model" ref, or a trailing prefix wildcard such as "provider/*" or "provider/namespace/*".', + }); + } + }; + + const defaultAliases = collectAliases(defaultModels); + validateRefs( + config.agents?.defaults?.modelPolicy?.allow, + "agents.defaults.modelPolicy.allow", + defaultAliases, + ); + for (const [index, agent] of (config.agents?.list ?? []).entries()) { + validateRefs( + agent.modelPolicy?.allow, + `agents.list.${index}.modelPolicy.allow`, + collectAliases(defaultModels, agent.models), + ); + } + return issues; +} + /** * Validates config without applying runtime defaults. * Use this when you need the raw validated config (e.g., for writing back to file). @@ -1111,6 +1171,10 @@ export function validateConfigObjectRaw( if (gatewayTailscaleAuthIssues.length > 0) { return { ok: false, issues: gatewayTailscaleAuthIssues }; } + const modelPolicyAllowIssues = collectModelPolicyAllowIssues(validatedConfig); + if (modelPolicyAllowIssues.length > 0) { + return { ok: false, issues: modelPolicyAllowIssues }; + } return { ok: true, config: validatedConfig, diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index 866d9c39354b..6206c3caf016 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -55,6 +55,47 @@ describe("agent defaults schema", () => { ); }); + it("rejects malformed model policy refs during config validation", () => { + for (const entry of ["", "///", "provider//model", "nogarbageprovider"]) { + const result = validateConfigObject({ + agents: { defaults: { modelPolicy: { allow: [entry] } } }, + }); + + expect(result.ok, entry || "empty entry").toBe(false); + if (result.ok) { + continue; + } + expect(result.issues).toContainEqual( + expect.objectContaining({ path: "agents.defaults.modelPolicy.allow.0" }), + ); + } + }); + + it("accepts exact refs, nested wildcards, configured aliases, and compat selectors", () => { + const result = validateConfigObject({ + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + "openrouter/openai/gpt-oss-120b:free": {}, + }, + modelPolicy: { + allow: [ + "openai/gpt-5.6-sol", + "provider/a/b/c/d/e/f", + "clawrouter/anthropic/*", + "provider/a/b/c/d/*", + "sonnet", + "openrouter:free", + ], + }, + }, + }, + }); + + expect(result.ok).toBe(true); + }); + it("accepts subagent archiveAfterMinutes=0 to disable archiving", () => { expectSchemaSuccess( AgentDefaultsSchema.safeParse({