mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:30:47 +00:00
Merge branch 'main' into meow/coven-runtime-bridge-clean
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"]
|
||||
|
||||
57
src/agents/model-suppression.test.ts
Normal file
57
src/agents/model-suppression.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
91
src/plugins/manifest-model-suppression.test.ts
Normal file
91
src/plugins/manifest-model-suppression.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
117
src/plugins/manifest-model-suppression.ts
Normal file
117
src/plugins/manifest-model-suppression.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user