diff --git a/CHANGELOG.md b/CHANGELOG.md index 716424694dd..e3601082b85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Plugins/models: wire manifest `modelCatalog.aliases` and `modelCatalog.suppressions` into model-catalog planning and built-in model suppression, with OpenAI stale Spark suppression now declared in the plugin manifest before runtime fallback. Thanks @shakkernerd. - Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay. - Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh. - Gateway/startup: pass the plugin metadata snapshot from config validation into plugin bootstrap so startup reuses one manifest product instead of rebuilding plugin metadata. Thanks @shakkernerd. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index a560cd171bc..3990eab6f18 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -730,6 +730,17 @@ Top-level fields: | `suppressions` | `object[]` | Model rows from another source that this plugin suppresses for a provider-specific reason. | | `discovery` | `Record` | Whether the provider catalog can be read from manifest metadata, refreshed into cache, or requires runtime. | +`aliases` participates in provider ownership lookup for model-catalog planning. +Alias targets must be top-level providers owned by the same plugin. When a +provider-filtered list uses an alias, OpenClaw can read the owning manifest and +apply alias API/base URL overrides without loading provider runtime. + +`suppressions` is the preferred static replacement for provider runtime +`suppressBuiltInModel` hooks. Suppression entries are honored only when the +provider is owned by the plugin or declared as a `modelCatalog.aliases` key that +targets an owned provider. Runtime suppression hooks still run as deprecated +compatibility fallback for plugins that have not migrated. + Provider fields: | Field | Type | What it means | diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index b0e54a9305d..cc21a80adf9 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -41,6 +41,31 @@ } } }, + "modelCatalog": { + "aliases": { + "azure-openai-responses": { + "provider": "openai", + "api": "azure-openai-responses" + } + }, + "suppressions": [ + { + "provider": "openai", + "model": "gpt-5.3-codex-spark", + "reason": "gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5." + }, + { + "provider": "azure-openai-responses", + "model": "gpt-5.3-codex-spark", + "reason": "gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5." + }, + { + "provider": "openai-codex", + "model": "gpt-5.3-codex-spark", + "reason": "gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5." + } + ] + }, "cliBackends": ["codex-cli"], "providerAuthEnvVars": { "openai": ["OPENAI_API_KEY"] diff --git a/src/agents/model-suppression.test.ts b/src/agents/model-suppression.test.ts new file mode 100644 index 00000000000..bc93e41bde2 --- /dev/null +++ b/src/agents/model-suppression.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveManifestBuiltInModelSuppression: vi.fn(), + resolveProviderBuiltInModelSuppression: vi.fn(), +})); + +vi.mock("../plugins/manifest-model-suppression.js", () => ({ + resolveManifestBuiltInModelSuppression: mocks.resolveManifestBuiltInModelSuppression, +})); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderBuiltInModelSuppression: mocks.resolveProviderBuiltInModelSuppression, +})); + +import { shouldSuppressBuiltInModel } from "./model-suppression.js"; + +describe("model suppression", () => { + beforeEach(() => { + mocks.resolveManifestBuiltInModelSuppression.mockReset(); + mocks.resolveProviderBuiltInModelSuppression.mockReset(); + }); + + it("uses manifest suppression before runtime hooks", () => { + mocks.resolveManifestBuiltInModelSuppression.mockReturnValueOnce({ + suppress: true, + errorMessage: "manifest suppression", + }); + + expect( + shouldSuppressBuiltInModel({ + provider: "openai", + id: "gpt-5.3-codex-spark", + config: {}, + }), + ).toBe(true); + + expect(mocks.resolveProviderBuiltInModelSuppression).not.toHaveBeenCalled(); + }); + + it("falls back to runtime hooks when no manifest suppression matches", () => { + mocks.resolveProviderBuiltInModelSuppression.mockReturnValueOnce({ + suppress: true, + errorMessage: "runtime suppression", + }); + + expect( + shouldSuppressBuiltInModel({ + provider: "openai", + id: "gpt-5.3-codex-spark", + config: {}, + }), + ).toBe(true); + + expect(mocks.resolveProviderBuiltInModelSuppression).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index fee88c4fc60..a511753a38c 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -1,14 +1,37 @@ 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"; +function resolveBuiltInModelSuppressionFromManifest(params: { + provider?: string | null; + id?: string | null; + config?: OpenClawConfig; +}) { + const provider = normalizeProviderId(params.provider ?? ""); + const modelId = normalizeLowercaseStringOrEmpty(params.id); + if (!provider || !modelId) { + return undefined; + } + return resolveManifestBuiltInModelSuppression({ + provider, + id: modelId, + ...(params.config ? { config: params.config } : {}), + env: process.env, + }); +} + function resolveBuiltInModelSuppression(params: { provider?: string | null; id?: string | null; baseUrl?: string | null; config?: OpenClawConfig; }) { + const manifestResult = resolveBuiltInModelSuppressionFromManifest(params); + if (manifestResult?.suppress) { + return manifestResult; + } const provider = normalizeProviderId(params.provider ?? ""); const modelId = normalizeLowercaseStringOrEmpty(params.id); if (!provider || !modelId) { @@ -27,6 +50,14 @@ function resolveBuiltInModelSuppression(params: { }); } +export function shouldSuppressBuiltInModelFromManifest(params: { + provider?: string | null; + id?: string | null; + config?: OpenClawConfig; +}) { + return resolveBuiltInModelSuppressionFromManifest(params)?.suppress ?? false; +} + export function shouldSuppressBuiltInModel(params: { provider?: string | null; id?: string | null; diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 1c49749be10..ccc75cc5a83 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -110,16 +110,19 @@ let listRowsModule: typeof import("./list.rows.js"); let listRegistryModule: typeof import("./list.registry.js"); function installModelsListCommandForwardCompatMocks() { + const suppressOpenAiSpark = ({ + provider, + id, + }: { + provider?: string | null; + id?: string | null; + }) => + (provider === "openai" || provider === "azure-openai-responses") && + id === "gpt-5.3-codex-spark"; + vi.doMock("../../agents/model-suppression.js", () => ({ - shouldSuppressBuiltInModel: ({ - provider, - id, - }: { - provider?: string | null; - id?: string | null; - }) => - (provider === "openai" || provider === "azure-openai-responses") && - id === "gpt-5.3-codex-spark", + shouldSuppressBuiltInModel: suppressOpenAiSpark, + shouldSuppressBuiltInModelFromManifest: suppressOpenAiSpark, })); vi.doMock("./load-config.js", () => ({ diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index 663596dc9f6..afc2bc43a0a 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ shouldSuppressBuiltInModel: vi.fn(() => { throw new Error("runtime model suppression should be skipped"); }), + shouldSuppressBuiltInModelFromManifest: vi.fn(() => false), loadProviderCatalogModelsForList: vi.fn().mockResolvedValue([ { id: "gpt-5.5", @@ -21,6 +22,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("../../agents/model-suppression.js", () => ({ shouldSuppressBuiltInModel: mocks.shouldSuppressBuiltInModel, + shouldSuppressBuiltInModelFromManifest: mocks.shouldSuppressBuiltInModelFromManifest, })); vi.mock("./list.provider-catalog.js", () => ({ @@ -76,6 +78,14 @@ describe("appendProviderCatalogRows", () => { }); expect(mocks.shouldSuppressBuiltInModel).not.toHaveBeenCalled(); + expect(mocks.shouldSuppressBuiltInModelFromManifest).toHaveBeenCalledWith({ + provider: "codex", + id: "gpt-5.5", + config: { + agents: { defaults: { model: { primary: "codex/gpt-5.5" } } }, + models: { providers: {} }, + }, + }); expect(rows).toMatchObject([ { key: "codex/gpt-5.5", @@ -84,4 +94,47 @@ describe("appendProviderCatalogRows", () => { }, ]); }); + + it("applies manifest suppression when runtime model-suppression hooks are skipped", async () => { + mocks.loadProviderCatalogModelsForList.mockResolvedValueOnce([ + { + id: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + input: ["text", "image"], + }, + ]); + mocks.shouldSuppressBuiltInModelFromManifest.mockReturnValueOnce(true); + const rows: ModelRow[] = []; + + await appendProviderCatalogRows({ + rows, + seenKeys: new Set(), + context: { + cfg: { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + models: { providers: {} }, + }, + agentDir: "/tmp/openclaw-agent", + authStore: { version: 1, profiles: {}, order: {} }, + configuredByKey: new Map(), + discoveredKeys: new Set(), + filter: { provider: "openai", local: false }, + skipRuntimeModelSuppression: true, + }, + }); + + expect(mocks.shouldSuppressBuiltInModel).not.toHaveBeenCalled(); + expect(mocks.shouldSuppressBuiltInModelFromManifest).toHaveBeenCalledWith({ + provider: "openai", + id: "gpt-5.3-codex-spark", + config: { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + models: { providers: {} }, + }, + }); + expect(rows).toEqual([]); + }); }); diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index df6b9bd5dc5..f8ea35ab2e4 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -7,7 +7,10 @@ import { resolveAwsSdkEnvVarName, resolveEnvApiKey, } from "../../agents/model-auth.js"; -import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; +import { + shouldSuppressBuiltInModel, + shouldSuppressBuiltInModelFromManifest, +} from "../../agents/model-suppression.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -137,7 +140,11 @@ function shouldSuppressListModel(params: { context: RowBuilderContext; }): boolean { if (params.context.skipRuntimeModelSuppression) { - return false; + return shouldSuppressBuiltInModelFromManifest({ + provider: params.model.provider, + id: params.model.id, + config: params.context.cfg, + }); } return shouldSuppressBuiltInModel({ provider: params.model.provider, diff --git a/src/model-catalog/index.ts b/src/model-catalog/index.ts index 720e3fc7d6e..6f93133d5a0 100644 --- a/src/model-catalog/index.ts +++ b/src/model-catalog/index.ts @@ -16,7 +16,10 @@ export { loadOpenClawProviderIndex, normalizeOpenClawProviderIndex, } from "./provider-index/index.js"; -export { planManifestModelCatalogRows } from "./manifest-planner.js"; +export { + planManifestModelCatalogRows, + planManifestModelCatalogSuppressions, +} from "./manifest-planner.js"; export { planProviderIndexModelCatalogRows } from "./provider-index-planner.js"; export type { ProviderIndexModelCatalogPlan, @@ -28,6 +31,8 @@ export type { ManifestModelCatalogPlanEntry, ManifestModelCatalogPlugin, ManifestModelCatalogRegistry, + ManifestModelCatalogSuppressionEntry, + ManifestModelCatalogSuppressionPlan, } from "./manifest-planner.js"; export type { ModelCatalog, diff --git a/src/model-catalog/manifest-planner.test.ts b/src/model-catalog/manifest-planner.test.ts index 906c3b77d94..3cc258b730f 100644 --- a/src/model-catalog/manifest-planner.test.ts +++ b/src/model-catalog/manifest-planner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { planManifestModelCatalogRows } from "./index.js"; +import { planManifestModelCatalogRows, planManifestModelCatalogSuppressions } from "./index.js"; describe("manifest model catalog planner", () => { it("builds manifest rows from plugin-owned catalog providers", () => { @@ -92,6 +92,57 @@ describe("manifest model catalog planner", () => { expect(plan.conflicts).toEqual([]); }); + it("plans alias-filtered rows from owned provider catalogs", () => { + const plan = planManifestModelCatalogRows({ + providerFilter: "azure-openai-responses", + registry: { + plugins: [ + { + id: "openai", + providers: ["openai"], + modelCatalog: { + aliases: { + "azure-openai-responses": { + provider: "openai", + api: "azure-openai-responses", + baseUrl: "https://example.openai.azure.com/openai/v1", + }, + }, + discovery: { + openai: "static", + }, + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [{ id: "gpt-5.4", name: "GPT-5.4" }], + }, + }, + }, + }, + ], + }, + }); + + expect(plan.entries).toEqual([ + expect.objectContaining({ + pluginId: "openai", + provider: "azure-openai-responses", + discovery: "static", + }), + ]); + expect(plan.rows).toEqual([ + expect.objectContaining({ + provider: "azure-openai-responses", + id: "gpt-5.4", + ref: "azure-openai-responses/gpt-5.4", + mergeKey: "azure-openai-responses::gpt-5.4", + api: "azure-openai-responses", + baseUrl: "https://example.openai.azure.com/openai/v1", + }), + ]); + }); + it("reports duplicate provider/model keys and excludes conflicted rows", () => { const plan = planManifestModelCatalogRows({ registry: { @@ -141,3 +192,58 @@ describe("manifest model catalog planner", () => { }); }); }); + +describe("manifest model catalog suppression planner", () => { + it("plans suppressions for owned providers and declared provider aliases", () => { + const plan = planManifestModelCatalogSuppressions({ + registry: { + plugins: [ + { + id: "openai", + providers: ["openai", "openai-codex"], + modelCatalog: { + aliases: { + "azure-openai-responses": { + provider: "openai", + }, + }, + suppressions: [ + { + provider: "openai", + model: "gpt-5.3-codex-spark", + reason: "Use openai/gpt-5.5.", + }, + { + provider: "azure-openai-responses", + model: "GPT-5.3-Codex-Spark", + reason: "Use openai/gpt-5.5.", + }, + { + provider: "openrouter", + model: "foreign-row", + }, + ], + }, + }, + ], + }, + }); + + expect(plan.suppressions).toEqual([ + { + pluginId: "openai", + provider: "azure-openai-responses", + model: "gpt-5.3-codex-spark", + mergeKey: "azure-openai-responses::gpt-5.3-codex-spark", + reason: "Use openai/gpt-5.5.", + }, + { + pluginId: "openai", + provider: "openai", + model: "gpt-5.3-codex-spark", + mergeKey: "openai::gpt-5.3-codex-spark", + reason: "Use openai/gpt-5.5.", + }, + ]); + }); +}); diff --git a/src/model-catalog/manifest-planner.ts b/src/model-catalog/manifest-planner.ts index f14b6d9b61d..cb28ec61a41 100644 --- a/src/model-catalog/manifest-planner.ts +++ b/src/model-catalog/manifest-planner.ts @@ -1,10 +1,17 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { normalizeModelCatalogProviderRows } from "./normalize.js"; -import { normalizeModelCatalogProviderId } from "./refs.js"; -import type { ModelCatalog, ModelCatalogDiscovery, NormalizedModelCatalogRow } from "./types.js"; +import { buildModelCatalogMergeKey, normalizeModelCatalogProviderId } from "./refs.js"; +import type { + ModelCatalog, + ModelCatalogAlias, + ModelCatalogDiscovery, + NormalizedModelCatalogRow, +} from "./types.js"; export type ManifestModelCatalogPlugin = { id: string; - modelCatalog?: Pick; + providers?: readonly string[]; + modelCatalog?: Pick; }; export type ManifestModelCatalogRegistry = { @@ -33,6 +40,18 @@ export type ManifestModelCatalogPlan = { conflicts: readonly ManifestModelCatalogConflict[]; }; +export type ManifestModelCatalogSuppressionEntry = { + pluginId: string; + provider: string; + model: string; + mergeKey: string; + reason?: string; +}; + +export type ManifestModelCatalogSuppressionPlan = { + suppressions: readonly ManifestModelCatalogSuppressionEntry[]; +}; + export function planManifestModelCatalogRows(params: { registry: ManifestModelCatalogRegistry; providerFilter?: string; @@ -94,29 +113,141 @@ function planManifestModelCatalogPluginEntries(params: { return []; } + const aliasesByTargetProvider = buildModelCatalogProviderAliasTargets(params.plugin); + return Object.entries(providers).flatMap(([provider, providerCatalog]) => { const normalizedProvider = normalizeModelCatalogProviderId(provider); - if ( - !normalizedProvider || - (params.providerFilter && normalizedProvider !== params.providerFilter) - ) { + if (!normalizedProvider) { return []; } - const rows = normalizeModelCatalogProviderRows({ - provider: normalizedProvider, - providerCatalog, - source: "manifest", + const providerAliases = aliasesByTargetProvider.get(normalizedProvider) ?? []; + const plannedProviders = params.providerFilter + ? providerAliases.includes(params.providerFilter) || + normalizedProvider === params.providerFilter + ? [params.providerFilter] + : [] + : [normalizedProvider]; + if (plannedProviders.length === 0) { + return []; + } + return plannedProviders.flatMap((plannedProvider) => { + const rows = normalizeModelCatalogProviderRows({ + provider: plannedProvider, + providerCatalog, + source: "manifest", + }); + if (rows.length === 0) { + return []; + } + return [ + { + pluginId: params.plugin.id, + provider: plannedProvider, + discovery: params.plugin.modelCatalog?.discovery?.[normalizedProvider], + rows: applyModelCatalogAliasOverrides({ + rows, + alias: params.plugin.modelCatalog?.aliases?.[plannedProvider], + }), + }, + ]; }); - if (rows.length === 0) { - return []; - } - return [ - { - pluginId: params.plugin.id, - provider: normalizedProvider, - discovery: params.plugin.modelCatalog?.discovery?.[normalizedProvider], - rows, - }, - ]; }); } + +function buildOwnedProviderSet(plugin: ManifestModelCatalogPlugin): ReadonlySet { + return new Set((plugin.providers ?? []).map(normalizeModelCatalogProviderId).filter(Boolean)); +} + +function buildModelCatalogProviderAliasTargets( + plugin: ManifestModelCatalogPlugin, +): ReadonlyMap { + const ownedProviders = buildOwnedProviderSet(plugin); + const aliasesByTargetProvider = new Map(); + for (const [rawAlias, alias] of Object.entries(plugin.modelCatalog?.aliases ?? {})) { + const aliasProvider = normalizeModelCatalogProviderId(rawAlias); + const targetProvider = normalizeModelCatalogProviderId(alias.provider); + if (!aliasProvider || !targetProvider || !ownedProviders.has(targetProvider)) { + continue; + } + const aliases = aliasesByTargetProvider.get(targetProvider) ?? []; + aliases.push(aliasProvider); + aliasesByTargetProvider.set(targetProvider, aliases); + } + return aliasesByTargetProvider; +} + +function buildModelCatalogProviderRefs(plugin: ManifestModelCatalogPlugin): ReadonlySet { + const ownedProviders = buildOwnedProviderSet(plugin); + const refs = new Set(ownedProviders); + for (const [rawAlias, alias] of Object.entries(plugin.modelCatalog?.aliases ?? {})) { + const aliasProvider = normalizeModelCatalogProviderId(rawAlias); + const targetProvider = normalizeModelCatalogProviderId(alias.provider); + if (aliasProvider && targetProvider && ownedProviders.has(targetProvider)) { + refs.add(aliasProvider); + } + } + return refs; +} + +function applyModelCatalogAliasOverrides(params: { + rows: readonly NormalizedModelCatalogRow[]; + alias?: ModelCatalogAlias; +}): readonly NormalizedModelCatalogRow[] { + const alias = params.alias; + if (!alias) { + return params.rows; + } + return params.rows.map((row) => ({ + ...row, + ...(alias.api ? { api: alias.api } : {}), + ...(alias.baseUrl ? { baseUrl: alias.baseUrl } : {}), + })); +} + +export function planManifestModelCatalogSuppressions(params: { + registry: ManifestModelCatalogRegistry; + providerFilter?: string; + modelFilter?: string; +}): ManifestModelCatalogSuppressionPlan { + const providerFilter = params.providerFilter + ? normalizeModelCatalogProviderId(params.providerFilter) + : undefined; + const modelFilter = params.modelFilter + ? normalizeLowercaseStringOrEmpty(params.modelFilter) + : undefined; + const suppressions: ManifestModelCatalogSuppressionEntry[] = []; + for (const plugin of params.registry.plugins) { + const providerRefs = buildModelCatalogProviderRefs(plugin); + for (const suppression of plugin.modelCatalog?.suppressions ?? []) { + const provider = normalizeModelCatalogProviderId(suppression.provider); + const model = normalizeLowercaseStringOrEmpty(suppression.model); + if (!provider || !model) { + continue; + } + if (providerFilter && provider !== providerFilter) { + continue; + } + if (modelFilter && model !== modelFilter) { + continue; + } + if (!providerRefs.has(provider)) { + continue; + } + suppressions.push({ + pluginId: plugin.id, + provider, + model, + mergeKey: buildModelCatalogMergeKey(provider, model), + ...(suppression.reason ? { reason: suppression.reason } : {}), + }); + } + } + return { + suppressions: suppressions.toSorted( + (left, right) => + left.provider.localeCompare(right.provider) || + left.model.localeCompare(right.model) || + left.pluginId.localeCompare(right.pluginId), + ), + }; +} 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, + }), + }; +} diff --git a/src/plugins/plugin-lookup-table.test.ts b/src/plugins/plugin-lookup-table.test.ts index 4b50c54897c..af225416323 100644 --- a/src/plugins/plugin-lookup-table.test.ts +++ b/src/plugins/plugin-lookup-table.test.ts @@ -125,6 +125,11 @@ describe("loadPluginLookUpTable", () => { origin: "bundled", providers: ["openai", "openai-codex"], modelCatalog: { + aliases: { + "azure-openai-responses": { + provider: "openai", + }, + }, providers: { openai: { models: [{ id: "gpt-test" }], @@ -180,6 +185,7 @@ describe("loadPluginLookUpTable", () => { expect(table.owners.channelConfigs.get("telegram")).toEqual(["telegram"]); expect(table.owners.providers.get("openai")).toEqual(["openai"]); expect(table.owners.modelCatalogProviders.get("openai")).toEqual(["openai"]); + expect(table.owners.modelCatalogProviders.get("azure-openai-responses")).toEqual(["openai"]); expect(table.owners.cliBackends.get("codex-cli")).toEqual(["openai"]); expect(table.owners.setupProviders.get("openai")).toEqual(["openai"]); expect(table.owners.commandAliases.get("telegram-send")).toEqual(["telegram"]); diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index b6087a205f5..7733ff1f651 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -120,6 +120,9 @@ export function buildPluginMetadataOwnerMaps( for (const providerId of Object.keys(plugin.modelCatalog?.providers ?? {})) { appendOwner(modelCatalogProviders, providerId, plugin.id); } + for (const providerId of Object.keys(plugin.modelCatalog?.aliases ?? {})) { + appendOwner(modelCatalogProviders, providerId, plugin.id); + } for (const cliBackendId of plugin.cliBackends) { appendOwner(cliBackends, cliBackendId, plugin.id); } diff --git a/src/plugins/plugin-registry-contributions.ts b/src/plugins/plugin-registry-contributions.ts index 01a524b1c82..f0aa16c95fb 100644 --- a/src/plugins/plugin-registry-contributions.ts +++ b/src/plugins/plugin-registry-contributions.ts @@ -173,7 +173,10 @@ function listManifestContributionIds( case "cliBackends": return [...plugin.cliBackends, ...(plugin.setup?.cliBackends ?? [])]; case "modelCatalogProviders": - return collectObjectKeys(plugin.modelCatalog?.providers); + return [ + ...collectObjectKeys(plugin.modelCatalog?.providers), + ...collectObjectKeys(plugin.modelCatalog?.aliases), + ]; case "commandAliases": return plugin.commandAliases?.map((alias) => alias.name) ?? []; case "contracts": diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 30ea9842a1a..603627f6a9e 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -80,6 +80,11 @@ function createCandidate(rootDir: string): PluginCandidate { }, }, modelCatalog: { + aliases: { + "demo-alias": { + provider: "demo", + }, + }, providers: { demo: { models: [{ id: "demo-model" }], @@ -157,7 +162,18 @@ describe("plugin registry facade", () => { }); expect(isPluginEnabled({ index, pluginId: "demo" })).toBe(true); expect(listPluginContributionIds({ index, contribution: "providers" })).toEqual(["demo"]); + expect(listPluginContributionIds({ index, contribution: "modelCatalogProviders" })).toEqual([ + "demo", + "demo-alias", + ]); expect(resolveProviderOwners({ index, providerId: "demo" })).toEqual(["demo"]); + expect( + resolvePluginContributionOwners({ + index, + contribution: "modelCatalogProviders", + matches: "demo-alias", + }), + ).toEqual(["demo"]); expect(resolveChannelOwners({ index, channelId: "demo-chat" })).toEqual(["demo"]); expect(resolveCliBackendOwners({ index, cliBackendId: "demo-cli" })).toEqual(["demo"]); expect( diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 9174ff240c9..d7a20d0745f 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1102,6 +1102,9 @@ export function resolveProviderBuiltInModelSuppression(params: { env?: NodeJS.ProcessEnv; context: ProviderBuiltInModelSuppressionContext; }) { + // Deprecated compatibility fallback. Static suppression rules should live in + // manifest modelCatalog.suppressions so list/model resolution can answer + // without loading provider runtime. for (const plugin of resolveProviderPluginsForCatalogHooks(params)) { const result = plugin.suppressBuiltInModel?.(params.context); if (result?.suppress) { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 26597215af2..d45d432ceb1 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -966,11 +966,11 @@ export type ProviderBuildUnknownModelHintContext = { }; /** - * Built-in model suppression hook. + * Built-in model suppression hook context. * - * Use this when a provider/plugin needs to hide stale upstream catalog rows or - * replace them with a vendor-specific hint. This hook is consulted by model - * resolution, model listing, and catalog loading. + * @deprecated Use manifest `modelCatalog.suppressions` for static suppression + * rules. Runtime suppression hooks remain as compatibility fallback for + * plugins that cannot express a rule declaratively yet. */ export type ProviderBuiltInModelSuppressionContext = { config?: OpenClawConfig; @@ -1480,6 +1480,10 @@ export type ProviderPlugin = { * Return `{ suppress: true }` to hide a stale upstream row. Include * `errorMessage` when OpenClaw should surface a provider-specific hint for * direct model resolution failures. + * + * @deprecated Use manifest `modelCatalog.suppressions` for static suppression + * rules. Runtime suppression hooks remain as compatibility fallback for + * plugins that cannot express a rule declaratively yet. */ suppressBuiltInModel?: ( ctx: ProviderBuiltInModelSuppressionContext, diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index 975f85404ee..ecd4951b34f 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -76,6 +76,7 @@ export const forcedUnitFastTestFiles = [ "src/flows/channel-setup.test.ts", "src/context-engine/context-engine.test.ts", "src/canvas-host/server.state-dir.test.ts", + "src/docs/install-cloud-secrets.test.ts", "src/docker-image-digests.test.ts", "src/dockerfile.test.ts", "src/entry.test.ts", @@ -95,6 +96,7 @@ export const forcedUnitFastTestFiles = [ "src/pairing/allow-from-store-read.test.ts", "src/pairing/pairing-store.test.ts", "src/plugin-sdk/memory-host-events.test.ts", + "src/proxy-capture/runtime.test.ts", "src/proxy-capture/store.sqlite.test.ts", "src/security/audit-exec-surface.test.ts", "src/security/audit-extra.async.test.ts", @@ -108,8 +110,10 @@ export const forcedUnitFastTestFiles = [ "src/realtime-transcription/websocket-session.test.ts", "src/routing/resolve-route.test.ts", "src/trajectory/export.test.ts", + "src/trajectory/runtime.test.ts", "src/tts/provider-registry.test.ts", "src/tts/status-config.test.ts", + "src/tts/tts-config.test.ts", "src/terminal/table.test.ts", "src/test-helpers/state-dir-env.test.ts", "src/utils.test.ts",