mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(models): support nested policy wildcards (#111350)
This commit is contained in:
committed by
GitHub
parent
29ef6f8637
commit
ccea4ea440
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user