diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 82624222ef4e..e4a5792a9fa9 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -396,6 +396,13 @@ 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)}/*`); + }, modelKey: (p: string, m: string) => `${p}/${m}`, normalizeModelRef: (p: string, m: string) => ({ provider: p, model: m }), parseModelRef: (m: string, p: string) => ({ provider: p, model: m }), diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 86db51622aa1..5b008ec1318c 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -62,6 +62,7 @@ import { runWithModelFallback } from "./model-fallback.js"; import { buildAllowedModelSet, buildConfiguredModelCatalog, + isModelKeyAllowedBySet, modelKey, normalizeModelRef, parseModelRef, @@ -778,7 +779,7 @@ async function agentCommandInternal( if (overrideModel) { const normalizedOverride = normalizeModelRef(overrideProvider, overrideModel); const key = modelKey(normalizedOverride.provider, normalizedOverride.model); - if (!allowAnyModel && !allowedModelKeys.has(key)) { + if (!allowAnyModel && !isModelKeyAllowedBySet(allowedModelKeys, key)) { const { updated } = applyModelOverrideToSessionEntry({ entry, selection: { provider: defaultProvider, model: defaultModel, isDefault: true }, @@ -801,7 +802,7 @@ async function agentCommandInternal( const candidateProvider = storedProviderOverride || defaultProvider; const normalizedStored = normalizeModelRef(candidateProvider, storedModelOverride); const key = modelKey(normalizedStored.provider, normalizedStored.model); - if (allowAnyModel || allowedModelKeys.has(key)) { + if (allowAnyModel || isModelKeyAllowedBySet(allowedModelKeys, key)) { provider = normalizedStored.provider; model = normalizedStored.model; } @@ -819,7 +820,7 @@ async function agentCommandInternal( throw new Error("Invalid model override."); } const explicitKey = modelKey(explicitRef.provider, explicitRef.model); - if (!allowAnyModel && !allowedModelKeys.has(explicitKey)) { + if (!allowAnyModel && !isModelKeyAllowedBySet(allowedModelKeys, explicitKey)) { throw new Error( `Model override "${sanitizeForLog(explicitRef.provider)}/${sanitizeForLog(explicitRef.model)}" is not allowed for agent "${sessionAgentId}".`, ); @@ -1125,7 +1126,7 @@ async function agentCommandInternal( } const switchRef = normalizeModelRef(err.provider, err.model); const switchKey = modelKey(switchRef.provider, switchRef.model); - if (!allowAnyModel && !allowedModelKeys.has(switchKey)) { + if (!allowAnyModel && !isModelKeyAllowedBySet(allowedModelKeys, switchKey)) { log.info( `Live session model switch in subagent run ${runId}: ` + `rejected ${sanitizeForLog(err.provider)}/${sanitizeForLog(err.model)} (not in allowlist)`, diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index c1d296e7f92b..e57cce0e4343 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -107,6 +107,21 @@ export function parseConfiguredModelVisibilityEntries(params: { cfg?: OpenClawCo }; } +export function providerWildcardModelKey(provider: string): string { + return modelKey(normalizeProviderId(provider), "*"); +} + +export function isModelKeyAllowedBySet(allowedKeys: ReadonlySet, key: string): boolean { + if (allowedKeys.has(key)) { + return true; + } + const separator = key.indexOf("/"); + if (separator <= 0) { + return false; + } + return allowedKeys.has(providerWildcardModelKey(key.slice(0, separator))); +} + export function inferUniqueProviderFromConfiguredModels(params: { cfg: OpenClawConfig; model: string; @@ -689,6 +704,9 @@ export function buildAllowedModelSetWithFallbacks(params: { const allowedKeys = new Set(); const allowedRefs: ModelRef[] = []; const syntheticCatalogEntries = new Map(); + for (const provider of visibility.providerWildcards) { + allowedKeys.add(providerWildcardModelKey(provider)); + } const addAllowedCatalogRef = (ref: ModelRef) => { if ( !allowedRefs.some( @@ -816,7 +834,7 @@ function getModelRefStatusFromAllowedSet(params: { }), ), allowAny: params.allowed.allowAny, - allowed: params.allowed.allowAny || params.allowed.allowedKeys.has(key), + allowed: params.allowed.allowAny || isModelKeyAllowedBySet(params.allowed.allowedKeys, key), }; } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 4b673bad4e57..fca6ca3e9c0a 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -7,12 +7,14 @@ import { buildAllowedModelSet, buildConfiguredModelCatalog, inferUniqueProviderFromConfiguredModels, + isModelKeyAllowedBySet, parseModelRef, buildModelAliasIndex, normalizeModelSelection, normalizeProviderId, normalizeProviderIdForAuth, modelKey, + providerWildcardModelKey, resolvePersistedOverrideModelRef, resolvePersistedModelRef, resolvePersistedSelectedModelRef, @@ -977,6 +979,31 @@ describe("model-selection", () => { expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(false); }); + it("preserves provider wildcard intent when catalog rows are unavailable", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + models: { + "openai-codex/*": {}, + }, + }, + }, + } as unknown as OpenClawConfig; + + const result = buildAllowedModelSet({ + cfg, + catalog: [], + defaultProvider: "anthropic", + defaultModel: "claude-sonnet-4-6", + }); + + expect(result.allowAny).toBe(false); + expect(result.allowedCatalog).toEqual([]); + expect(result.allowedKeys.has(providerWildcardModelKey("openai-codex"))).toBe(true); + expect(isModelKeyAllowedBySet(result.allowedKeys, "openai-codex/gpt-added-later")).toBe(true); + expect(isModelKeyAllowedBySet(result.allowedKeys, "anthropic/claude-sonnet-4-6")).toBe(false); + }); + it("unions exact model entries with provider wildcard entries", () => { const cfg: OpenClawConfig = { agents: { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index ca9ac45bb408..72aa223bdb6a 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -37,8 +37,10 @@ import { getModelRefStatusWithFallbackModels, inferUniqueProviderFromCatalog, inferUniqueProviderFromConfiguredModels, + isModelKeyAllowedBySet, normalizeModelSelection, parseConfiguredModelVisibilityEntries, + providerWildcardModelKey, resolveBareModelDefaultProvider, resolveAllowedModelRefFromAliasIndex, resolveAllowlistModelKey as resolveAllowlistModelKeyFromShared, @@ -70,6 +72,7 @@ export { findNormalizedProviderValue, inferUniqueProviderFromConfiguredModels, inferUniqueProviderFromCatalog, + isModelKeyAllowedBySet, legacyModelKey, modelKey, normalizeModelRef, @@ -78,6 +81,7 @@ export { normalizeProviderIdForAuth, parseConfiguredModelVisibilityEntries, parseModelRef, + providerWildcardModelKey, resolveBareModelDefaultProvider, resolveConfiguredModelRef, resolveHooksGmailModel, diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index a4081c0eb81c..7031ac52dae3 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -31,6 +31,7 @@ import { buildAllowedModelSet, buildConfiguredModelCatalog, buildModelAliasIndex, + isModelKeyAllowedBySet, modelKey, resolveDefaultModelForAgent, resolveModelRefFromString, @@ -305,7 +306,7 @@ async function resolveModelOverride(params: { throw new Error(`Unrecognized model "${raw}".`); } const key = modelKey(resolved.ref.provider, resolved.ref.model); - if (allowed.allowedKeys.size > 0 && !allowed.allowedKeys.has(key)) { + if (allowed.allowedKeys.size > 0 && !isModelKeyAllowedBySet(allowed.allowedKeys, key)) { throw new Error(`Model "${key}" is not allowed.`); } const isDefault = diff --git a/src/auto-reply/reply/directive-handling.model-selection.ts b/src/auto-reply/reply/directive-handling.model-selection.ts index 153e96ce6c92..7fe96dcbe880 100644 --- a/src/auto-reply/reply/directive-handling.model-selection.ts +++ b/src/auto-reply/reply/directive-handling.model-selection.ts @@ -1,5 +1,6 @@ import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; import { + isModelKeyAllowedBySet, type ModelAliasIndex, modelKey, resolveModelRefFromString, @@ -122,7 +123,10 @@ export function resolveModelSelectionFromDirective(params: { }); if (explicit) { const explicitKey = modelKey(explicit.ref.provider, explicit.ref.model); - if (params.allowedModelKeys.size === 0 || params.allowedModelKeys.has(explicitKey)) { + if ( + params.allowedModelKeys.size === 0 || + isModelKeyAllowedBySet(params.allowedModelKeys, explicitKey) + ) { modelSelection = { provider: explicit.ref.provider, model: explicit.ref.model, diff --git a/src/auto-reply/reply/model-selection-directive.ts b/src/auto-reply/reply/model-selection-directive.ts index 7200c1577b6b..aff5fc45ca1f 100644 --- a/src/auto-reply/reply/model-selection-directive.ts +++ b/src/auto-reply/reply/model-selection-directive.ts @@ -1,4 +1,5 @@ import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js"; +import { isModelKeyAllowedBySet } from "../../agents/model-selection.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; @@ -300,6 +301,9 @@ export function resolveModelDirectiveSelection(params: { } const provider = normalizeProviderId(key.slice(0, slash)); const model = key.slice(slash + 1); + if (model === "*") { + continue; + } if (providerFilter && provider !== providerFilter) { continue; } @@ -320,7 +324,7 @@ export function resolveModelDirectiveSelection(params: { } for (const match of aliasMatches) { const key = modelKey(match.provider, match.model); - if (!allowedModelKeys.has(key)) { + if (!isModelKeyAllowedBySet(allowedModelKeys, key)) { continue; } if (!candidates.some((c) => c.provider === match.provider && c.model === match.model)) { @@ -395,7 +399,7 @@ export function resolveModelDirectiveSelection(params: { } const resolvedKey = modelKey(resolved.ref.provider, resolved.ref.model); - if (allowedModelKeys.size === 0 || allowedModelKeys.has(resolvedKey)) { + if (allowedModelKeys.size === 0 || isModelKeyAllowedBySet(allowedModelKeys, resolvedKey)) { return { selection: { provider: resolved.ref.provider, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 8e744f834ce4..331eb01d2536 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -659,6 +659,45 @@ describe("createModelSelectionState respects session model override", () => { expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined(); }); + it("keeps wildcard-provider overrides when configured catalog rows are unavailable", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + "openai-codex/*": {}, + }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:telegram:direct:1"; + const sessionEntry = makeEntry({ + providerOverride: "openai-codex", + modelOverride: "gpt-added-after-startup", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "anthropic", + defaultModel: "claude-sonnet-4-6", + provider: "anthropic", + model: "claude-sonnet-4-6", + hasModelDirective: false, + }); + + expect(state.provider).toBe("openai-codex"); + expect(state.model).toBe("gpt-added-after-startup"); + expect(state.resetModelOverride).toBe(false); + expect(sessionStore[sessionKey]?.providerOverride).toBe("openai-codex"); + expect(sessionStore[sessionKey]?.modelOverride).toBe("gpt-added-after-startup"); + }); + it("keeps allowed legacy combined session overrides after normalization", async () => { const cfg = { agents: { diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index b44b43210f53..cafc7903abbd 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -7,6 +7,7 @@ import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import { buildConfiguredModelCatalog, buildAllowedModelSet, + isModelKeyAllowedBySet, modelKey, normalizeModelRef, normalizeProviderId, @@ -180,7 +181,7 @@ export async function createModelSelectionState(params: { directStoredOverride.model, ); const key = modelKey(normalizedOverride.provider, normalizedOverride.model); - if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { + if (allowedModelKeys.size > 0 && !isModelKeyAllowedBySet(allowedModelKeys, key)) { const { updated } = applyModelOverrideToSessionEntry({ entry: sessionEntry, selection: { provider: defaultProvider, model: defaultModel, isDefault: true }, @@ -220,7 +221,7 @@ export async function createModelSelectionState(params: { storedOverride.model, ); const key = modelKey(normalizedStoredOverride.provider, normalizedStoredOverride.model); - if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { + if (allowedModelKeys.size === 0 || isModelKeyAllowedBySet(allowedModelKeys, key)) { provider = normalizedStoredOverride.provider; model = normalizedStoredOverride.model; } diff --git a/src/auto-reply/reply/session-reset-model.ts b/src/auto-reply/reply/session-reset-model.ts index 8fc57e915544..8b031fdf3276 100644 --- a/src/auto-reply/reply/session-reset-model.ts +++ b/src/auto-reply/reply/session-reset-model.ts @@ -1,4 +1,5 @@ import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; +import { isModelKeyAllowedBySet } from "../../agents/model-selection.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import { resolveAgentModelFallbackValues } from "../../config/model-input.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -89,7 +90,7 @@ function buildSelectionFromExplicit(params: { return undefined; } const key = modelKey(resolved.ref.provider, resolved.ref.model); - if (params.allowedModelKeys.size > 0 && !params.allowedModelKeys.has(key)) { + if (params.allowedModelKeys.size > 0 && !isModelKeyAllowedBySet(params.allowedModelKeys, key)) { return undefined; } const isDefault = diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index cfdc29dca617..db199bba75ca 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -3,6 +3,7 @@ import type { IncomingMessage } from "node:http"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { buildAllowedModelSet, + isModelKeyAllowedBySet, modelKey, parseModelRef, resolveDefaultModelForAgent, @@ -103,7 +104,7 @@ export async function resolveOpenAiCompatModelOverride(params: { agentId: params.agentId, }); const normalized = modelKey(parsed.provider, parsed.model); - if (!allowed.allowAny && !allowed.allowedKeys.has(normalized)) { + if (!allowed.allowAny && !isModelKeyAllowedBySet(allowed.allowedKeys, normalized)) { return { errorMessage: `Model '${normalized}' is not allowed for agent '${params.agentId}'.`, };