diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index fee88c4fc60..81f5883fe95 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveManifestBuiltInModelSuppression } from "../plugins/manifest-model-suppression.js"; import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { normalizeProviderId } from "./provider-id.js"; @@ -14,6 +15,15 @@ function resolveBuiltInModelSuppression(params: { if (!provider || !modelId) { return undefined; } + const manifestResult = resolveManifestBuiltInModelSuppression({ + provider, + id: modelId, + ...(params.config ? { config: params.config } : {}), + env: process.env, + }); + if (manifestResult?.suppress) { + return manifestResult; + } return resolveProviderBuiltInModelSuppression({ ...(params.config ? { config: params.config } : {}), env: process.env, diff --git a/src/plugins/manifest-model-suppression.test.ts b/src/plugins/manifest-model-suppression.test.ts new file mode 100644 index 00000000000..ff58494c4ab --- /dev/null +++ b/src/plugins/manifest-model-suppression.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadPluginManifestRegistryForPluginRegistry: vi.fn(), +})); + +vi.mock("./plugin-registry.js", () => ({ + loadPluginManifestRegistryForPluginRegistry: mocks.loadPluginManifestRegistryForPluginRegistry, +})); + +import { + clearManifestModelSuppressionCacheForTest, + resolveManifestBuiltInModelSuppression, +} from "./manifest-model-suppression.js"; + +describe("manifest model suppression", () => { + beforeEach(() => { + clearManifestModelSuppressionCacheForTest(); + mocks.loadPluginManifestRegistryForPluginRegistry.mockReset(); + mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "openai", + providers: ["openai"], + modelCatalog: { + aliases: { + "azure-openai-responses": { + provider: "openai", + }, + }, + suppressions: [ + { + provider: "azure-openai-responses", + model: "gpt-5.3-codex-spark", + reason: "Use openai/gpt-5.5.", + }, + { + provider: "openrouter", + model: "foreign-row", + }, + ], + }, + }, + ], + }); + }); + + it("resolves manifest suppressions for declared provider aliases", () => { + expect( + resolveManifestBuiltInModelSuppression({ + provider: "azure-openai-responses", + id: "GPT-5.3-Codex-Spark", + env: process.env, + }), + ).toEqual({ + suppress: true, + errorMessage: + "Unknown model: azure-openai-responses/gpt-5.3-codex-spark. Use openai/gpt-5.5.", + }); + }); + + it("ignores suppressions for providers the plugin does not own", () => { + expect( + resolveManifestBuiltInModelSuppression({ + provider: "openrouter", + id: "foreign-row", + env: process.env, + }), + ).toBeUndefined(); + }); + + it("caches planned manifest suppressions per config and environment", () => { + const config = { plugins: { entries: { openai: { enabled: true } } } }; + + resolveManifestBuiltInModelSuppression({ + provider: "azure-openai-responses", + id: "gpt-5.3-codex-spark", + config, + env: process.env, + }); + resolveManifestBuiltInModelSuppression({ + provider: "azure-openai-responses", + id: "gpt-5.3-codex-spark", + config, + env: process.env, + }); + + expect(mocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/manifest-model-suppression.ts b/src/plugins/manifest-model-suppression.ts new file mode 100644 index 00000000000..61b37a6c38e --- /dev/null +++ b/src/plugins/manifest-model-suppression.ts @@ -0,0 +1,117 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + buildModelCatalogMergeKey, + planManifestModelCatalogSuppressions, + type ManifestModelCatalogSuppressionEntry, +} from "../model-catalog/index.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; + +type ManifestSuppressionCache = Map; + +let cacheWithoutConfig = new WeakMap(); +let cacheByConfig = new WeakMap< + OpenClawConfig, + WeakMap +>(); + +function resolveSuppressionCache(params: { + config?: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): ManifestSuppressionCache { + if (!params.config) { + let cache = cacheWithoutConfig.get(params.env); + if (!cache) { + cache = new Map(); + cacheWithoutConfig.set(params.env, cache); + } + return cache; + } + let envCaches = cacheByConfig.get(params.config); + if (!envCaches) { + envCaches = new WeakMap(); + cacheByConfig.set(params.config, envCaches); + } + let cache = envCaches.get(params.env); + if (!cache) { + cache = new Map(); + envCaches.set(params.env, cache); + } + return cache; +} + +function cacheKey(params: { workspaceDir?: string }): string { + return params.workspaceDir ?? ""; +} + +function listManifestModelCatalogSuppressions(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): readonly ManifestModelCatalogSuppressionEntry[] { + const cache = resolveSuppressionCache({ + config: params.config, + env: params.env, + }); + const key = cacheKey(params); + const cached = cache.get(key); + if (cached) { + return cached; + } + const registry = loadPluginManifestRegistryForPluginRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const planned = planManifestModelCatalogSuppressions({ registry }); + cache.set(key, planned.suppressions); + return planned.suppressions; +} + +function buildManifestSuppressionError(params: { + provider: string; + modelId: string; + reason?: string; +}): string { + const ref = `${params.provider}/${params.modelId}`; + return params.reason ? `Unknown model: ${ref}. ${params.reason}` : `Unknown model: ${ref}.`; +} + +export function clearManifestModelSuppressionCacheForTest(): void { + cacheWithoutConfig = new WeakMap(); + cacheByConfig = new WeakMap< + OpenClawConfig, + WeakMap + >(); +} + +export function resolveManifestBuiltInModelSuppression(params: { + provider?: string | null; + id?: string | null; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}) { + const provider = normalizeLowercaseStringOrEmpty(params.provider); + const modelId = normalizeLowercaseStringOrEmpty(params.id); + if (!provider || !modelId) { + return undefined; + } + const mergeKey = buildModelCatalogMergeKey(provider, modelId); + const suppression = listManifestModelCatalogSuppressions({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env ?? process.env, + }).find((entry) => entry.mergeKey === mergeKey); + if (!suppression) { + return undefined; + } + return { + suppress: true, + errorMessage: buildManifestSuppressionError({ + provider, + modelId, + reason: suppression.reason, + }), + }; +}