Merge branch 'main' into meow/coven-runtime-bridge-clean

This commit is contained in:
Val Alexander
2026-04-27 11:23:24 -05:00
committed by GitHub
20 changed files with 717 additions and 40 deletions

View File

@@ -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.

View File

@@ -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<string, "static" \| "refreshable" \| "runtime">` | 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 |

View File

@@ -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"]

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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", () => ({

View File

@@ -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([]);
});
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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.",
},
]);
});
});

View File

@@ -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<ModelCatalog, "providers" | "discovery">;
providers?: readonly string[];
modelCatalog?: Pick<ModelCatalog, "providers" | "aliases" | "suppressions" | "discovery">;
};
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<string> {
return new Set((plugin.providers ?? []).map(normalizeModelCatalogProviderId).filter(Boolean));
}
function buildModelCatalogProviderAliasTargets(
plugin: ManifestModelCatalogPlugin,
): ReadonlyMap<string, readonly string[]> {
const ownedProviders = buildOwnedProviderSet(plugin);
const aliasesByTargetProvider = new Map<string, string[]>();
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<string> {
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),
),
};
}

View File

@@ -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);
});
});

View File

@@ -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<string, readonly ManifestModelCatalogSuppressionEntry[]>;
let cacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>();
let cacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>
>();
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<NodeJS.ProcessEnv, ManifestSuppressionCache>();
cacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>
>();
}
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,
}),
};
}

View File

@@ -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"]);

View File

@@ -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);
}

View File

@@ -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":

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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",