fix(models): support nested policy wildcards (#111350)

This commit is contained in:
Peter Steinberger
2026-07-19 06:47:42 -07:00
committed by GitHub
parent 29ef6f8637
commit ccea4ea440
16 changed files with 476 additions and 116 deletions
+4 -4
View File
@@ -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 <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
{
+1 -1
View File
@@ -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 '<json>' --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" }`).
@@ -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<string>, 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<string>(),
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<string>, 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<string>, 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<string>(),
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)) {
+3 -2
View File
@@ -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;
}
+54 -41
View File
@@ -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<string, string>();
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<string>();
const allowedRefs: ModelRef[] = [];
const syntheticCatalogEntries = new Map<string, ModelCatalogEntry>();
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<string> {
const wildcardModelKeys = new Set<string>();
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<T extends { provider: string }>(
/** Expand segment-boundary prefix wildcard policy entries against discovered catalog rows. */
function expandModelCatalogWildcards<T extends { provider: string; id: string }>(
catalog: readonly T[],
providerWildcards: ReadonlySet<string>,
wildcardModelKeys: ReadonlySet<string>,
): 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<string>, 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<string>;
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)),
),
]);
},
@@ -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: {
+28 -26
View File
@@ -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 <provider>", 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 <provider> <modelId>", 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 },
});
},
);
});
@@ -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,
+5 -6
View File
@@ -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<string>();
let allowedModelCatalog: ModelCatalog = configuredModelCatalog;
+18 -8
View File
@@ -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<string>(),
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)) {
+33 -3
View File
@@ -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(
+52
View File
@@ -439,6 +439,7 @@ async function withOpenAIStatusFixture<T>(
catalog?: unknown[];
routeVariants?: unknown[];
utilityModel?: string;
modelPolicyAllow?: string[];
},
run: () => Promise<T>,
): Promise<T> {
@@ -459,6 +460,9 @@ async function withOpenAIStatusFixture<T>(
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 = {
+74
View File
@@ -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));
}
+1 -1
View File
@@ -77,7 +77,7 @@ export const AGENT_FIELD_HELP: Record<string, string> = {
"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":
+64
View File
@@ -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<typeof defaultModels | undefined>): Set<string> => {
const aliases = new Set<string>();
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<string>,
) => {
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,
@@ -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({