mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
Preserve provider wildcard allowlist intent
This commit is contained in:
committed by
Peter Steinberger
parent
f0ed2b5229
commit
2e495b07f3
@@ -396,6 +396,13 @@ 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)}/*`);
|
||||
},
|
||||
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 }),
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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<string>, 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<string>();
|
||||
const allowedRefs: ModelRef[] = [];
|
||||
const syntheticCatalogEntries = new Map<string, ModelCatalogEntry>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}'.`,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user