refactor(models): move suppressions to manifests

This commit is contained in:
Peter Steinberger
2026-04-28 01:36:50 +01:00
parent c0fdf9923b
commit 947aae5a99
32 changed files with 644 additions and 304 deletions

View File

@@ -11,7 +11,7 @@ Docs: https://docs.openclaw.ai
- Plugin SDK: expose shared channel route normalization, parser-driven target resolution, raw-target compact keys, parsed-target types, and route comparison helpers through `openclaw/plugin-sdk/channel-route`, switch native approval origin matching onto that route contract with optional delivery and match-only target normalization, and retire the internal channel-route shim behind dated compatibility aliases for legacy key/comparable-target helpers. Thanks @vincentkoc.
- Docs/Codex: document how Codex Computer Use, direct `cua-driver mcp`, and OpenClaw.app's PeekabooBridge fit together so desktop-control setup choices are clearer. Thanks @pash-openai and @trycua.
- Matrix/streaming: stream tool-progress updates into live Matrix preview edits by default when preview streaming is active, with `streaming.preview.toolProgress: false` to keep answer previews while hiding interim tool lines. Thanks @gumadeiras.
- 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.
- Plugins/models: wire manifest `modelCatalog.aliases` and `modelCatalog.suppressions` into model-catalog planning and built-in model suppression, with stale Spark and Qwen Coding Plan suppressions now declared in plugin manifests instead of runtime fallback hooks. 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: reuse lookup-table plugin manifests when loading startup plugins so Gateway boot avoids rebuilding plugin discovery and manifest metadata. Thanks @shakkernerd.

View File

@@ -248,7 +248,7 @@ The "When to use" column is the quick decision guide.
| 28 | `classifyFailoverReason` | Provider-owned failover reason classification | Provider can map raw API/transport errors to rate-limit/overload/etc |
| 29 | `isCacheTtlEligible` | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating |
| 30 | `buildMissingAuthMessage` | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint |
| 31 | `suppressBuiltInModel` | Stale upstream model suppression plus optional user-facing error hint | Provider needs to hide stale upstream rows or replace them with a vendor hint |
| 31 | `suppressBuiltInModel` | Deprecated. Runtime hook is no longer called; use manifest `modelCatalog.suppressions` | Historical hook for hiding stale upstream rows; keep new suppression data in the plugin manifest |
| 32 | `augmentModelCatalog` | Synthetic/final catalog rows appended after discovery | Provider needs synthetic forward-compat rows in `models list` and pickers |
| 33 | `resolveThinkingProfile` | Model-specific `/think` level set, display labels, and default | Provider exposes a custom thinking ladder or binary label for selected models |
| 34 | `isBinaryThinking` | On/off reasoning toggle compatibility hook | Provider exposes only binary thinking on/off |

View File

@@ -735,11 +735,10 @@ 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.
`suppressions` replaces the old provider runtime `suppressBuiltInModel` hook.
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 are no longer called during model resolution.
Provider fields:
@@ -772,6 +771,16 @@ Model fields:
| `replacedBy` | `string` | Replacement provider-local model id for deprecated rows. |
| `tags` | `string[]` | Stable tags used by pickers and filters. |
Suppression fields:
| Field | Type | What it means |
| -------------------------- | ---------- | --------------------------------------------------------------------------------------------------------- |
| `provider` | `string` | Provider id for the upstream row to suppress. Must be owned by this plugin or declared as an owned alias. |
| `model` | `string` | Provider-local model id to suppress. |
| `reason` | `string` | Optional message shown when the suppressed row is requested directly. |
| `when.baseUrlHosts` | `string[]` | Optional list of effective provider base URL hosts required before the suppression applies. |
| `when.providerConfigApiIn` | `string[]` | Optional list of exact provider-config `api` values required before the suppression applies. |
Do not put runtime-only data in `modelCatalog`. If a provider needs account
state, an API request, or local process discovery to know the complete model
set, declare that provider as `refreshable` or `runtime` in `discovery`.

View File

@@ -452,7 +452,7 @@ API key auth, and dynamic model resolution.
| 27 | `classifyFailoverReason` | Provider-owned rate-limit/overload classification |
| 28 | `isCacheTtlEligible` | Prompt cache TTL gating |
| 29 | `buildMissingAuthMessage` | Custom missing-auth hint |
| 30 | `suppressBuiltInModel` | Hide stale upstream rows |
| 30 | `suppressBuiltInModel` | Deprecated. Runtime hook is no longer called; use manifest `modelCatalog.suppressions` |
| 31 | `augmentModelCatalog` | Synthetic forward-compat rows |
| 32 | `resolveThinkingProfile` | Model-specific `/think` option set |
| 33 | `isBinaryThinking` | Binary thinking on/off compatibility |

View File

@@ -110,7 +110,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/provider-env-vars` | Provider auth env-var lookup helpers |
| `plugin-sdk/provider-auth` | `createProviderApiKeyAuthMethod`, `ensureApiKeyFromOptionEnvOrPrompt`, `upsertAuthProfile`, `upsertApiKeyProfile`, `writeOAuthCredentials` |
| `plugin-sdk/provider-model-shared` | `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and model-id normalization helpers such as `normalizeNativeXaiModelId` |
| `plugin-sdk/provider-catalog-runtime` | Provider catalog runtime hook and plugin-provider registry seams for contract tests |
| `plugin-sdk/provider-catalog-runtime` | Provider catalog augmentation runtime hook and plugin-provider registry seams for contract tests |
| `plugin-sdk/provider-catalog-shared` | `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` |
| `plugin-sdk/provider-http` | Generic provider HTTP/endpoint capability helpers, provider HTTP errors, and audio transcription multipart form helpers |
| `plugin-sdk/provider-web-fetch-contract` | Narrow web-fetch config/selection contract helpers such as `enablePluginInConfig` and `WebFetchProviderPlugin` |

View File

@@ -67,6 +67,7 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st
const feishuConfigure = createPluginSetupWizardConfigure(feishuPlugin);
const feishuGetStatus = createPluginSetupWizardStatus(feishuPlugin);
describe("feishu setup wizard", () => {
it("does not throw when config appId/appSecret are SecretRef objects", async () => {
const text = vi

View File

@@ -507,15 +507,6 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
],
}),
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS),
suppressBuiltInModel: ({ provider, modelId }) =>
normalizeProviderId(provider) === PROVIDER_ID &&
normalizeLowercaseStringOrEmpty(modelId) === "gpt-5.3-codex-spark"
? {
suppress: true,
errorMessage:
"gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5.",
}
: undefined,
preferRuntimeResolvedModel: (ctx) => {
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {
return false;

View File

@@ -77,8 +77,6 @@ const OPENAI_MODERN_MODEL_IDS = [
OPENAI_GPT_54_NANO_MODEL_ID,
"gpt-5.2",
] as const;
const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]);
function shouldUseOpenAIResponsesTransport(params: {
provider: string;
api?: string | null;
@@ -260,18 +258,6 @@ export function buildOpenAIProvider(): ProviderPlugin {
}
return 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.5, or set OPENAI_API_KEY for direct OpenAI API access.';
},
suppressBuiltInModel: (ctx) => {
if (
!SUPPRESSED_SPARK_PROVIDERS.has(normalizeProviderId(ctx.provider)) ||
normalizeLowercaseStringOrEmpty(ctx.modelId) !== OPENAI_DIRECT_SPARK_MODEL_ID
) {
return undefined;
}
return {
suppress: true,
errorMessage: `Unknown model: ${ctx.provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5.`,
};
},
augmentModelCatalog: (ctx) => {
const openAiGpt55ProTemplate = findCatalogTemplate({
entries: ctx.entries,

View File

@@ -5,7 +5,6 @@ import {
import {
expectAugmentedCodexCatalog,
expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55,
expectCodexBuiltInSuppression,
expectCodexMissingAuthHint,
importProviderRuntimeCatalogModule,
loadBundledPluginPublicSurface,
@@ -49,17 +48,6 @@ vi.mock("openclaw/plugin-sdk/provider-catalog-runtime", async () => {
}
return supplemental;
},
resolveProviderBuiltInModelSuppression: (params: {
context: Parameters<NonNullable<ProviderPlugin["suppressBuiltInModel"]>>[0];
}) => {
for (const provider of resolveCatalogHookProviders(params)) {
const result = provider.suppressBuiltInModel?.(params.context);
if (result?.suppress) {
return result;
}
}
return undefined;
},
resolveOwningPluginIdsForProvider: (params: unknown) =>
resolveOwningPluginIdsForProviderMock(params as never),
resolveCatalogHookProviderPluginIds: (params: unknown) =>
@@ -86,15 +74,11 @@ export function describeOpenAIProviderCatalogContract() {
})
).providers;
const openaiProvider = requireRegisteredProvider(openaiProviders, "openai", "provider");
const {
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
} = await importProviderRuntimeCatalogModule();
const { augmentModelCatalogWithProviderPlugins, resetProviderRuntimeHookCacheForTest } =
await importProviderRuntimeCatalogModule();
return {
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
openaiProviders,
openaiProvider,
};
@@ -141,11 +125,6 @@ export function describeOpenAIProviderCatalogContract() {
);
});
it("keeps built-in model suppression wired through the provider runtime", async () => {
const { resolveProviderBuiltInModelSuppression } = await contractDepsPromise;
expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression);
});
it("keeps bundled model augmentation wired through the provider runtime", async () => {
const { augmentModelCatalogWithProviderPlugins } = await contractDepsPromise;
await expectAugmentedCodexCatalog(

View File

@@ -1,4 +1,3 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it } from "vitest";
import qwenPlugin from "./index.js";
@@ -8,42 +7,9 @@ async function registerQwenProvider() {
}
describe("qwen provider plugin", () => {
it("does not suppress exact custom modelstudio providers owned by another api", async () => {
const provider = await registerQwenProvider();
const config = {
models: {
providers: {
modelstudio: {
api: "openai-completions",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
models: [{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus" }],
},
},
},
} as unknown as OpenClawConfig;
expect(
provider.suppressBuiltInModel?.({
config,
env: {},
provider: "modelstudio",
modelId: "qwen3.6-plus",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
}),
).toBeUndefined();
});
it("still suppresses legacy modelstudio refs on Qwen Coding Plan endpoints", async () => {
it("does not expose runtime model suppression hooks", async () => {
const provider = await registerQwenProvider();
expect(
provider.suppressBuiltInModel?.({
config: {},
env: {},
provider: "modelstudio",
modelId: "qwen3.6-plus",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
})?.suppress,
).toBe(true);
expect(provider.suppressBuiltInModel).toBeUndefined();
});
});

View File

@@ -40,29 +40,6 @@ function resolveConfiguredQwenBaseUrl(
return undefined;
}
function isQwen36PlusUnsupportedForConfig(params: {
config: Parameters<typeof resolveConfiguredQwenBaseUrl>[0];
baseUrl?: string;
}): boolean {
return isQwenCodingPlanBaseUrl(params.baseUrl ?? resolveConfiguredQwenBaseUrl(params.config));
}
function hasExactForeignApiOwner(params: {
provider: string;
config: { models?: { providers?: Record<string, { api?: string } | undefined> } } | undefined;
}): boolean {
const providers = params.config?.models?.providers;
if (!providers) {
return false;
}
const provider = normalizeProviderId(params.provider);
const exact = Object.entries(providers).find(
([providerId]) => normalizeProviderId(providerId) === provider,
)?.[1];
const api = normalizeProviderId(exact?.api ?? "");
return !!api && api !== PROVIDER_ID && api !== LEGACY_PROVIDER_ID;
}
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "Qwen Provider",
@@ -192,22 +169,6 @@ export default defineSingleProviderPluginEntry({
? { ...providerConfig, models }
: undefined;
},
suppressBuiltInModel: (ctx) => {
const provider = normalizeProviderId(ctx.provider);
if (
(provider !== PROVIDER_ID && provider !== LEGACY_PROVIDER_ID) ||
hasExactForeignApiOwner({ provider: ctx.provider, config: ctx.config }) ||
ctx.modelId !== QWEN_36_PLUS_MODEL_ID ||
!isQwen36PlusUnsupportedForConfig({ config: ctx.config, baseUrl: ctx.baseUrl })
) {
return undefined;
}
return {
suppress: true,
errorMessage:
"Unknown model: qwen/qwen3.6-plus. qwen3.6-plus is not supported on the Qwen Coding Plan endpoint; use a Standard pay-as-you-go Qwen endpoint or choose qwen/qwen3.5-plus.",
};
},
},
register(api) {
api.registerMediaUnderstandingProvider(buildQwenMediaUnderstandingProvider());

View File

@@ -29,6 +29,28 @@
}
}
},
"modelCatalog": {
"suppressions": [
{
"provider": "qwen",
"model": "qwen3.6-plus",
"reason": "qwen3.6-plus is not supported on the Qwen Coding Plan endpoint; use a Standard pay-as-you-go Qwen endpoint or choose qwen/qwen3.5-plus.",
"when": {
"baseUrlHosts": ["coding.dashscope.aliyuncs.com", "coding-intl.dashscope.aliyuncs.com"],
"providerConfigApiIn": ["qwen", "modelstudio"]
}
},
{
"provider": "modelstudio",
"model": "qwen3.6-plus",
"reason": "qwen3.6-plus is not supported on the Qwen Coding Plan endpoint; use a Standard pay-as-you-go Qwen endpoint or choose qwen/qwen3.5-plus.",
"when": {
"baseUrlHosts": ["coding.dashscope.aliyuncs.com", "coding-intl.dashscope.aliyuncs.com"],
"providerConfigApiIn": ["qwen", "modelstudio"]
}
}
]
},
"contracts": {
"mediaUnderstandingProviders": ["qwen"],
"videoGenerationProviders": ["qwen"]

View File

@@ -2,26 +2,20 @@ 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", () => {
it("uses manifest suppression", () => {
mocks.resolveManifestBuiltInModelSuppression.mockReturnValueOnce({
suppress: true,
errorMessage: "manifest suppression",
@@ -35,23 +29,18 @@ describe("model suppression", () => {
}),
).toBe(true);
expect(mocks.resolveProviderBuiltInModelSuppression).not.toHaveBeenCalled();
expect(mocks.resolveManifestBuiltInModelSuppression).toHaveBeenCalledOnce();
});
it("falls back to runtime hooks when no manifest suppression matches", () => {
mocks.resolveProviderBuiltInModelSuppression.mockReturnValueOnce({
suppress: true,
errorMessage: "runtime suppression",
});
it("does not run deprecated runtime suppression hooks", () => {
expect(
shouldSuppressBuiltInModel({
provider: "openai",
id: "gpt-5.3-codex-spark",
config: {},
}),
).toBe(true);
).toBe(false);
expect(mocks.resolveProviderBuiltInModelSuppression).toHaveBeenCalledOnce();
expect(mocks.resolveManifestBuiltInModelSuppression).toHaveBeenCalledOnce();
});
});

View File

@@ -1,12 +1,12 @@
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;
baseUrl?: string | null;
config?: OpenClawConfig;
}) {
const provider = normalizeProviderId(params.provider ?? "");
@@ -18,6 +18,7 @@ function resolveBuiltInModelSuppressionFromManifest(params: {
provider,
id: modelId,
...(params.config ? { config: params.config } : {}),
...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
env: process.env,
});
}
@@ -37,17 +38,7 @@ function resolveBuiltInModelSuppression(params: {
if (!provider || !modelId) {
return undefined;
}
return resolveProviderBuiltInModelSuppression({
...(params.config ? { config: params.config } : {}),
env: process.env,
context: {
...(params.config ? { config: params.config } : {}),
env: process.env,
provider,
modelId,
...(params.baseUrl ? { baseUrl: params.baseUrl } : {}),
},
});
return undefined;
}
export function shouldSuppressBuiltInModelFromManifest(params: {

View File

@@ -255,7 +255,10 @@ export async function ensureOpenClawModelsJson(
const refreshedCacheKey = modelsJsonReadyCacheKey(targetPath, refreshedFingerprint);
if (refreshedCacheKey !== cacheKey) {
MODELS_JSON_STATE.readyCache.delete(cacheKey);
MODELS_JSON_STATE.readyCache.set(refreshedCacheKey, Promise.resolve(settled));
MODELS_JSON_STATE.readyCache.set(
refreshedCacheKey,
Promise.resolve({ fingerprint: refreshedFingerprint, result: settled.result }),
);
}
return settled.result;
} catch (error) {

View File

@@ -14,7 +14,6 @@ vi.mock("../../plugins/provider-runtime.js", () => ({
normalizeProviderResolvedModelWithPlugin: () => undefined,
normalizeProviderTransportWithPlugin: () => undefined,
prepareProviderDynamicModel: async () => undefined,
resolveProviderBuiltInModelSuppression: () => undefined,
runProviderDynamicModel: () => undefined,
shouldPreferProviderRuntimeResolvedModel: () => false,
}));

View File

@@ -42,7 +42,6 @@ vi.mock("../../plugins/provider-runtime.js", () => ({
normalizeProviderResolvedModelWithPlugin: () => undefined,
normalizeProviderTransportWithPlugin: () => undefined,
prepareProviderDynamicModel: async () => {},
resolveProviderBuiltInModelSuppression: () => undefined,
runProviderDynamicModel: () => undefined,
shouldPreferProviderRuntimeResolvedModel: () => false,
}));

View File

@@ -147,7 +147,6 @@ const providerRuntimeMocks = vi.hoisted(() => ({
refreshProviderOAuthCredentialWithPlugin: vi.fn(async () => undefined),
resetProviderRuntimeHookCacheForTest: vi.fn(() => {}),
resolveProviderBinaryThinking: vi.fn(() => undefined),
resolveProviderBuiltInModelSuppression: vi.fn(() => undefined),
resolveProviderCacheTtlEligibility: vi.fn(() => undefined),
resolveProviderCapabilitiesWithPlugin: vi.fn(() => undefined),
resolveProviderDefaultThinkingLevel: vi.fn(() => undefined),

View File

@@ -212,6 +212,9 @@ describe("manifest model catalog suppression planner", () => {
provider: "openai",
model: "gpt-5.3-codex-spark",
reason: "Use openai/gpt-5.5.",
when: {
baseUrlHosts: ["api.openai.com"],
},
},
{
provider: "azure-openai-responses",
@@ -243,6 +246,9 @@ describe("manifest model catalog suppression planner", () => {
model: "gpt-5.3-codex-spark",
mergeKey: "openai::gpt-5.3-codex-spark",
reason: "Use openai/gpt-5.5.",
when: {
baseUrlHosts: ["api.openai.com"],
},
},
]);
});

View File

@@ -46,6 +46,7 @@ export type ManifestModelCatalogSuppressionEntry = {
model: string;
mergeKey: string;
reason?: string;
when?: NonNullable<ModelCatalog["suppressions"]>[number]["when"];
};
export type ManifestModelCatalogSuppressionPlan = {
@@ -239,6 +240,7 @@ export function planManifestModelCatalogSuppressions(params: {
model,
mergeKey: buildModelCatalogMergeKey(provider, model),
...(suppression.reason ? { reason: suppression.reason } : {}),
...(suppression.when ? { when: suppression.when } : {}),
});
}
}

View File

@@ -84,6 +84,10 @@ describe("model catalog normalization", () => {
provider: "Azure-OpenAI-Responses",
model: "gpt-5.3-codex-spark",
reason: "not available",
when: {
baseUrlHosts: ["CODING-INTL.DASHSCOPE.ALIYUNCS.COM"],
providerConfigApiIn: ["Qwen", "ModelStudio"],
},
},
],
discovery: {
@@ -154,6 +158,10 @@ describe("model catalog normalization", () => {
provider: "azure-openai-responses",
model: "gpt-5.3-codex-spark",
reason: "not available",
when: {
baseUrlHosts: ["coding-intl.dashscope.aliyuncs.com"],
providerConfigApiIn: ["qwen", "modelstudio"],
},
},
],
discovery: {

View File

@@ -370,10 +370,25 @@ function normalizeModelCatalogSuppressions(value: unknown): ModelCatalogSuppress
continue;
}
const reason = normalizeOptionalString(entry.reason) ?? "";
const rawWhen = isRecord(entry.when) ? entry.when : undefined;
const baseUrlHosts = normalizeTrimmedStringList(rawWhen?.baseUrlHosts).map((host) =>
host.toLowerCase(),
);
const providerConfigApiIn = normalizeTrimmedStringList(rawWhen?.providerConfigApiIn).map(
(api) => api.toLowerCase(),
);
const when =
baseUrlHosts.length > 0 || providerConfigApiIn.length > 0
? {
...(baseUrlHosts.length > 0 ? { baseUrlHosts } : {}),
...(providerConfigApiIn.length > 0 ? { providerConfigApiIn } : {}),
}
: undefined;
suppressions.push({
provider,
model,
...(reason ? { reason } : {}),
...(when ? { when } : {}),
});
}
return suppressions.length > 0 ? suppressions : undefined;

View File

@@ -63,6 +63,10 @@ export type ModelCatalogSuppression = {
provider: string;
model: string;
reason?: string;
when?: {
baseUrlHosts?: string[];
providerConfigApiIn?: string[];
};
};
export type ModelCatalog = {

View File

@@ -3,7 +3,6 @@
export {
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
} from "../plugins/provider-runtime.js";
export {
resolveCatalogHookProviderPluginIds,

View File

@@ -1,7 +1,6 @@
export {
expectAugmentedCodexCatalog,
expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55,
expectCodexBuiltInSuppression,
expectCodexMissingAuthHint,
} from "../testing.js";
export type { ProviderPlugin } from "../provider-model-shared.js";
@@ -12,20 +11,14 @@ export {
type ProviderRuntimeCatalogModule = Pick<
typeof import("openclaw/plugin-sdk/provider-catalog-runtime"),
| "augmentModelCatalogWithProviderPlugins"
| "resetProviderRuntimeHookCacheForTest"
| "resolveProviderBuiltInModelSuppression"
"augmentModelCatalogWithProviderPlugins" | "resetProviderRuntimeHookCacheForTest"
>;
export async function importProviderRuntimeCatalogModule(): Promise<ProviderRuntimeCatalogModule> {
const {
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
} = await import("openclaw/plugin-sdk/provider-catalog-runtime");
const { augmentModelCatalogWithProviderPlugins, resetProviderRuntimeHookCacheForTest } =
await import("openclaw/plugin-sdk/provider-catalog-runtime");
return {
augmentModelCatalogWithProviderPlugins,
resetProviderRuntimeHookCacheForTest,
resolveProviderBuiltInModelSuppression,
};
}

View File

@@ -88,4 +88,92 @@ describe("manifest model suppression", () => {
expect(mocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes(1);
});
it("matches conditional suppressions by base URL host", () => {
mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
diagnostics: [],
plugins: [
{
id: "qwen",
providers: ["qwen", "modelstudio"],
modelCatalog: {
suppressions: [
{
provider: "qwen",
model: "qwen3.6-plus",
reason: "Use qwen/qwen3.5-plus.",
when: {
baseUrlHosts: [
"coding.dashscope.aliyuncs.com",
"coding-intl.dashscope.aliyuncs.com",
],
providerConfigApiIn: ["qwen", "modelstudio"],
},
},
],
},
},
],
});
expect(
resolveManifestBuiltInModelSuppression({
provider: "qwen",
id: "qwen3.6-plus",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
env: process.env,
})?.suppress,
).toBe(true);
expect(
resolveManifestBuiltInModelSuppression({
provider: "qwen",
id: "qwen3.6-plus",
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
env: process.env,
}),
).toBeUndefined();
});
it("does not apply conditional suppressions to custom providers with a foreign api owner", () => {
mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
diagnostics: [],
plugins: [
{
id: "qwen",
providers: ["modelstudio"],
modelCatalog: {
suppressions: [
{
provider: "modelstudio",
model: "qwen3.6-plus",
when: {
baseUrlHosts: ["coding-intl.dashscope.aliyuncs.com"],
providerConfigApiIn: ["qwen", "modelstudio"],
},
},
],
},
},
],
});
expect(
resolveManifestBuiltInModelSuppression({
provider: "modelstudio",
id: "qwen3.6-plus",
config: {
models: {
providers: {
modelstudio: {
api: "openai-completions",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
models: [],
},
},
},
},
env: process.env,
}),
).toBeUndefined();
});
});

View File

@@ -77,6 +77,70 @@ function buildManifestSuppressionError(params: {
return params.reason ? `Unknown model: ${ref}. ${params.reason}` : `Unknown model: ${ref}.`;
}
function normalizeBaseUrlHost(baseUrl: string | null | undefined): string {
if (!baseUrl?.trim()) {
return "";
}
try {
return new URL(baseUrl).hostname.toLowerCase();
} catch {
return "";
}
}
function resolveConfiguredProviderValue(params: {
provider: string;
config?: OpenClawConfig;
}): { api?: string; baseUrl?: string } | undefined {
const providers = params.config?.models?.providers;
if (!providers) {
return undefined;
}
for (const [providerId, entry] of Object.entries(providers)) {
if (normalizeLowercaseStringOrEmpty(providerId) !== params.provider) {
continue;
}
return {
api: normalizeLowercaseStringOrEmpty(entry?.api),
baseUrl: typeof entry?.baseUrl === "string" ? entry.baseUrl : undefined,
};
}
return undefined;
}
function manifestSuppressionMatchesConditions(params: {
suppression: ManifestModelCatalogSuppressionEntry;
provider: string;
baseUrl?: string | null;
config?: OpenClawConfig;
}): boolean {
const when = params.suppression.when;
if (!when) {
return true;
}
const configuredProvider = resolveConfiguredProviderValue({
provider: params.provider,
config: params.config,
});
if (when.providerConfigApiIn?.length && configuredProvider?.api) {
const allowedApis = new Set(when.providerConfigApiIn.map(normalizeLowercaseStringOrEmpty));
if (!allowedApis.has(configuredProvider.api)) {
return false;
}
}
if (when.baseUrlHosts?.length) {
const baseUrlHost = normalizeBaseUrlHost(params.baseUrl ?? configuredProvider?.baseUrl);
if (!baseUrlHost) {
return false;
}
const allowedHosts = new Set(when.baseUrlHosts.map(normalizeLowercaseStringOrEmpty));
if (!allowedHosts.has(baseUrlHost)) {
return false;
}
}
return true;
}
export function clearManifestModelSuppressionCacheForTest(): void {
cacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>();
cacheByConfig = new WeakMap<
@@ -91,6 +155,7 @@ export function resolveManifestBuiltInModelSuppression(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
baseUrl?: string | null;
}) {
const provider = normalizeLowercaseStringOrEmpty(params.provider);
const modelId = normalizeLowercaseStringOrEmpty(params.id);
@@ -102,7 +167,16 @@ export function resolveManifestBuiltInModelSuppression(params: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env ?? process.env,
}).find((entry) => entry.mergeKey === mergeKey);
}).find(
(entry) =>
entry.mergeKey === mergeKey &&
manifestSuppressionMatchesConditions({
suppression: entry,
provider,
baseUrl: params.baseUrl,
config: params.config,
}),
);
if (!suppression) {
return undefined;
}

View File

@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js";
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
import { resolveOwningPluginIdsForProvider } from "./providers.js";
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";
import { resolvePluginCacheInputs } from "./roots.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
@@ -45,12 +46,100 @@ function resolveHookProviderCacheBucket(env: NodeJS.ProcessEnv) {
return bucket;
}
function resolveHookProviderConfigCacheShape(config: OpenClawConfig | undefined): unknown {
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function projectPluginEntryForProviderHookCache(
pluginId: string,
entry: unknown,
fullConfigPluginIds: ReadonlySet<string>,
): unknown {
if (!isRecord(entry) || fullConfigPluginIds.has(pluginId)) {
return entry;
}
const {
config: _config,
hooks: _hooks,
subagent: _subagent,
apiKey: _apiKey,
env: _env,
...rest
} = entry;
return rest;
}
function projectPluginsConfigForProviderHookCache(
plugins: OpenClawConfig["plugins"],
fullConfigPluginIds: ReadonlySet<string>,
): unknown {
if (!isRecord(plugins)) {
return plugins ?? null;
}
const entries = isRecord(plugins.entries)
? Object.fromEntries(
Object.entries(plugins.entries)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([pluginId, entry]) => [
pluginId,
projectPluginEntryForProviderHookCache(pluginId, entry, fullConfigPluginIds),
]),
)
: plugins.entries;
return {
...plugins,
entries,
};
}
function resolveProviderOwnerConfigPluginIds(params: {
providerRefs?: readonly string[];
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string[] {
if (!params.providerRefs?.length) {
return [];
}
const pluginIds = new Set<string>();
for (const provider of params.providerRefs) {
for (const pluginId of resolveOwningPluginIdsForProvider({
provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}) ?? []) {
pluginIds.add(pluginId);
}
const apiOwnerHint = resolveProviderConfigApiOwnerHint({
provider,
config: params.config,
});
if (!apiOwnerHint) {
continue;
}
for (const pluginId of resolveOwningPluginIdsForProvider({
provider: apiOwnerHint,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}) ?? []) {
pluginIds.add(pluginId);
}
}
return [...pluginIds].toSorted((left, right) => left.localeCompare(right));
}
export function resolveProviderHookConfigCacheShape(
config: OpenClawConfig | undefined,
fullConfigPluginIds: readonly string[] | undefined,
): unknown {
if (!config) {
return null;
}
const fullConfigPluginIdSet = new Set(fullConfigPluginIds ?? []);
return {
plugins: config.plugins,
plugins: projectPluginsConfigForProviderHookCache(config.plugins, fullConfigPluginIdSet),
};
}
@@ -60,13 +149,24 @@ function buildHookProviderCacheKey(params: {
onlyPluginIds?: string[];
providerRefs?: string[];
env?: NodeJS.ProcessEnv;
fullConfigPluginIds?: string[];
applyAutoEnable?: boolean;
bundledProviderAllowlistCompat?: boolean;
bundledProviderVitestCompat?: boolean;
installBundledRuntimeDeps?: boolean;
}) {
const { roots } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
env: params.env,
});
const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds);
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(resolveHookProviderConfigCacheShape(params.config))}::${serializePluginIdScope(onlyPluginIds)}::${JSON.stringify(params.providerRefs ?? [])}`;
const loadPolicy = {
applyAutoEnable: params.applyAutoEnable ?? true,
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true,
bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true,
installBundledRuntimeDeps: params.installBundledRuntimeDeps ?? false,
};
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(resolveProviderHookConfigCacheShape(params.config, params.fullConfigPluginIds))}::${serializePluginIdScope(onlyPluginIds)}::${JSON.stringify(params.providerRefs ?? [])}::${JSON.stringify(loadPolicy)}`;
}
export function clearProviderRuntimeHookCache(): void {
@@ -95,12 +195,30 @@ export function resolveProviderPluginsForHooks(params: {
const env = params.env ?? process.env;
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
const cacheBucket = resolveHookProviderCacheBucket(env);
const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds);
const explicitPluginIds = onlyPluginIds ?? [];
const fullConfigPluginIds = [
...new Set([
...explicitPluginIds,
...resolveProviderOwnerConfigPluginIds({
providerRefs: params.providerRefs,
config: params.config,
workspaceDir,
env,
}),
]),
].toSorted((left, right) => left.localeCompare(right));
const cacheKey = buildHookProviderCacheKey({
config: params.config,
workspaceDir,
onlyPluginIds: params.onlyPluginIds,
onlyPluginIds,
providerRefs: params.providerRefs,
env,
fullConfigPluginIds,
applyAutoEnable: params.applyAutoEnable,
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat,
bundledProviderVitestCompat: params.bundledProviderVitestCompat,
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
});
const cached = cacheBucket.get(cacheKey);
if (cached) {

View File

@@ -50,31 +50,6 @@ export function expectCodexMissingAuthHint(
).toContain(expectedModel);
}
export function expectCodexBuiltInSuppression(
resolveProviderBuiltInModelSuppression: (params: {
env: NodeJS.ProcessEnv;
context: {
env: NodeJS.ProcessEnv;
provider: string;
modelId: string;
};
}) => unknown,
) {
expect(
resolveProviderBuiltInModelSuppression({
env: process.env,
context: {
env: process.env,
provider: "azure-openai-responses",
modelId: "gpt-5.3-codex-spark",
},
}),
).toMatchObject({
suppress: true,
errorMessage: expect.stringContaining("gpt-5.3-codex-spark"),
});
}
export async function expectAugmentedCodexCatalog(
augmentModelCatalogWithProviderPlugins: (params: {
env: NodeJS.ProcessEnv;

View File

@@ -4,7 +4,6 @@ import type { ModelProviderConfig, OpenClawConfig } from "../config/types.js";
import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js";
import {
expectAugmentedCodexCatalog,
expectCodexBuiltInSuppression,
expectCodexMissingAuthHint,
expectedAugmentedOpenaiCodexCatalogEntries,
} from "./provider-runtime.test-support.js";
@@ -70,7 +69,6 @@ let resolveProviderFollowupFallbackRoute: typeof import("./provider-runtime.js")
let resolveProviderStreamFn: typeof import("./provider-runtime.js").resolveProviderStreamFn;
let resolveProviderCacheTtlEligibility: typeof import("./provider-runtime.js").resolveProviderCacheTtlEligibility;
let resolveProviderBinaryThinking: typeof import("./provider-runtime.js").resolveProviderBinaryThinking;
let resolveProviderBuiltInModelSuppression: typeof import("./provider-runtime.js").resolveProviderBuiltInModelSuppression;
let createProviderEmbeddingProvider: typeof import("./provider-runtime.js").createProviderEmbeddingProvider;
let resolveProviderDefaultThinkingLevel: typeof import("./provider-runtime.js").resolveProviderDefaultThinkingLevel;
let resolveProviderModernModelRef: typeof import("./provider-runtime.js").resolveProviderModernModelRef;
@@ -144,15 +142,6 @@ function createOpenAiCatalogProviderPlugin(
id: "openai",
label: "OpenAI",
auth: [],
suppressBuiltInModel: ({ provider, modelId }) =>
(provider === "openai" || provider === "azure-openai-responses") &&
modelId === "gpt-5.3-codex-spark"
? {
suppress: true,
errorMessage:
"gpt-5.3-codex-spark is no longer exposed by the OpenAI or Codex catalogs. Use openai/gpt-5.5.",
}
: undefined,
augmentModelCatalog: () => [
{ provider: "openai", id: "gpt-5.4", name: "gpt-5.4" },
{ provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" },
@@ -303,7 +292,6 @@ describe("provider-runtime", () => {
resolveProviderStreamFn,
resolveProviderCacheTtlEligibility,
resolveProviderBinaryThinking,
resolveProviderBuiltInModelSuppression,
createProviderEmbeddingProvider,
resolveProviderDefaultThinkingLevel,
resolveProviderModernModelRef,
@@ -405,6 +393,201 @@ describe("provider-runtime", () => {
);
});
it("separates provider hook cache keys by load policy", () => {
const base = {
workspaceDir: "/tmp/workspace",
env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv,
providerRefs: ["demo"],
};
expect(
providerRuntimeTesting.buildHookProviderCacheKey({
...base,
applyAutoEnable: false,
bundledProviderAllowlistCompat: false,
bundledProviderVitestCompat: false,
installBundledRuntimeDeps: false,
}),
).not.toBe(providerRuntimeTesting.buildHookProviderCacheKey(base));
});
it("ignores unrelated plugin config values in provider hook cache keys", () => {
const base = {
workspaceDir: "/tmp/workspace",
env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv,
onlyPluginIds: ["demo"],
};
const firstConfig = {
plugins: {
entries: {
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
"active-memory": { enabled: true },
},
},
} as OpenClawConfig;
const secondConfig = {
plugins: {
entries: {
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
"active-memory": { enabled: true, config: { qmd: { searchMode: "fast" } } },
},
},
} as OpenClawConfig;
expect(
providerRuntimeTesting.buildHookProviderCacheKey({
...base,
config: firstConfig,
fullConfigPluginIds: ["demo"],
}),
).toBe(
providerRuntimeTesting.buildHookProviderCacheKey({
...base,
config: secondConfig,
fullConfigPluginIds: ["demo"],
}),
);
});
it("keeps scoped provider plugin config in provider hook cache keys", () => {
const base = {
workspaceDir: "/tmp/workspace",
env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv,
onlyPluginIds: ["demo"],
fullConfigPluginIds: ["demo"],
};
expect(
providerRuntimeTesting.buildHookProviderCacheKey({
...base,
config: {
plugins: {
entries: {
demo: { enabled: true, config: { endpoint: "https://one.example" } },
},
},
} as OpenClawConfig,
}),
).not.toBe(
providerRuntimeTesting.buildHookProviderCacheKey({
...base,
config: {
plugins: {
entries: {
demo: { enabled: true, config: { endpoint: "https://two.example" } },
},
},
} as OpenClawConfig,
}),
);
});
it("keeps provider-ref owner plugin config in provider hook cache keys", () => {
const provider: ProviderPlugin = {
id: DEMO_PROVIDER_ID,
label: "Demo",
auth: [],
};
resolveOwningPluginIdsForProviderMock.mockReturnValue(["demo"]);
resolvePluginProvidersMock.mockReturnValue([provider]);
const firstConfig = {
plugins: {
entries: {
demo: { enabled: true, config: { endpoint: "https://one.example" } },
},
},
} as OpenClawConfig;
const secondConfig = {
plugins: {
entries: {
demo: { enabled: true, config: { endpoint: "https://two.example" } },
},
},
} as OpenClawConfig;
expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID, config: firstConfig })).toBe(
provider,
);
expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID, config: secondConfig })).toBe(
provider,
);
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2);
});
it("reuses provider-ref hook loads when unrelated plugin config changes", () => {
const provider: ProviderPlugin = {
id: DEMO_PROVIDER_ID,
label: "Demo",
auth: [],
};
resolveOwningPluginIdsForProviderMock.mockReturnValue(["demo"]);
resolvePluginProvidersMock.mockReturnValue([provider]);
const firstConfig = {
plugins: {
entries: {
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
"active-memory": { enabled: true },
},
},
} as OpenClawConfig;
const secondConfig = {
plugins: {
entries: {
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
"active-memory": { enabled: true, config: { qmd: { searchMode: "fast" } } },
},
},
} as OpenClawConfig;
expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID, config: firstConfig })).toBe(
provider,
);
expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID, config: secondConfig })).toBe(
provider,
);
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1);
});
it("does not reuse auto-enabled runtime providers for synthetic auth fallback", () => {
const runtimeProvider: ProviderPlugin = {
id: DEMO_PROVIDER_ID,
label: "Demo",
auth: [],
resolveSyntheticAuth: () => ({
apiKey: "default-runtime-token",
source: "default runtime",
mode: "api-key" as const,
}),
};
resolvePluginProvidersMock.mockImplementation((params) =>
params.applyAutoEnable === false &&
params.bundledProviderAllowlistCompat === false &&
params.bundledProviderVitestCompat === false &&
params.installBundledRuntimeDeps === false
? []
: [runtimeProvider],
);
expect(resolveProviderRuntimePlugin({ provider: DEMO_PROVIDER_ID })).toBe(runtimeProvider);
expect(
resolveProviderSyntheticAuthWithPlugin({
provider: DEMO_PROVIDER_ID,
context: {
provider: DEMO_PROVIDER_ID,
providerConfig: {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
}),
).toBeUndefined();
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(3);
});
it("skips provider runtime loading when no plugin declares external auth hooks", () => {
expect(
resolveExternalAuthProfilesWithPlugins({
@@ -503,14 +686,14 @@ describe("provider-runtime", () => {
expect(providerRuntimeWarnMock).not.toHaveBeenCalled();
});
it("reuses catalog hook provider loads when only non-plugin config changes", () => {
it("reuses catalog hook provider loads when only non-plugin config changes", async () => {
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["demo"]);
resolvePluginProvidersMock.mockReturnValue([
{
id: "demo",
label: "Demo",
auth: [],
suppressBuiltInModel: () => ({ suppress: true, errorMessage: "suppressed" }),
augmentModelCatalog: () => [{ provider: "demo", id: "demo-model", name: "Demo Model" }],
},
]);
const baseConfig = {
@@ -530,23 +713,64 @@ describe("provider-runtime", () => {
} as OpenClawConfig;
expect(
resolveProviderBuiltInModelSuppression({
await augmentModelCatalogWithProviderPlugins({
config: firstConfig,
env: process.env,
context: { config: firstConfig, env: process.env, provider: "openai", modelId: "demo" },
})?.suppress,
).toBe(true);
context: { config: firstConfig, env: process.env, entries: [] },
}),
).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]);
expect(
resolveProviderBuiltInModelSuppression({
await augmentModelCatalogWithProviderPlugins({
config: secondConfig,
env: process.env,
context: { config: secondConfig, env: process.env, provider: "openai", modelId: "demo" },
})?.suppress,
).toBe(true);
context: { config: secondConfig, env: process.env, entries: [] },
}),
).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]);
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1);
});
it("reuses catalog hook provider loads when unrelated plugin config changes", async () => {
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["demo"]);
resolvePluginProvidersMock.mockReturnValue([
{
id: "demo",
label: "Demo",
auth: [],
augmentModelCatalog: () => [{ provider: "demo", id: "demo-model", name: "Demo Model" }],
},
]);
const firstConfig = {
plugins: {
entries: {
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
"active-memory": { enabled: true },
},
},
} as OpenClawConfig;
const secondConfig = {
plugins: {
entries: {
demo: { enabled: true, config: { endpoint: "https://demo.example" } },
"active-memory": { enabled: true, config: { qmd: { searchMode: "fast" } } },
},
},
} as OpenClawConfig;
for (const config of [firstConfig, secondConfig]) {
expect(
await augmentModelCatalogWithProviderPlugins({
config,
env: process.env,
context: { config, env: process.env, entries: [] },
}),
).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]);
}
expect(resolveCatalogHookProviderPluginIdsMock).toHaveBeenCalledTimes(1);
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1);
});
it("returns provider-prepared runtime auth for the matched provider", async () => {
const prepareRuntimeAuth = vi.fn(async () => ({
apiKey: "runtime-token",
@@ -1653,7 +1877,6 @@ describe("provider-runtime", () => {
]);
expectCodexMissingAuthHint(buildProviderMissingAuthMessageWithPlugin);
expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression);
await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins);
expectCalledOnce(
@@ -1839,19 +2062,6 @@ describe("provider-runtime", () => {
return [createOpenAiCatalogProviderPlugin()];
});
expect(
resolveProviderBuiltInModelSuppression({
env: process.env,
context: {
env: process.env,
provider: "openai",
modelId: "gpt-5.3-codex-spark",
},
}),
).toMatchObject({
suppress: true,
});
await expect(
augmentModelCatalogWithProviderPlugins({
env: process.env,

View File

@@ -17,6 +17,7 @@ import {
__testing as providerHookRuntimeTesting,
clearProviderRuntimeHookCache as clearProviderHookRuntimeCache,
prepareProviderExtraParams,
resolveProviderHookConfigCacheShape,
resolveProviderAuthProfileId,
resolveProviderExtraParamsForTransport,
resolveProviderFollowupFallbackRoute,
@@ -43,7 +44,6 @@ import type {
ProviderExternalAuthProfile,
ProviderBuildMissingAuthMessageContext,
ProviderBuildUnknownModelHintContext,
ProviderBuiltInModelSuppressionContext,
ProviderCacheTtlEligibilityContext,
ProviderCreateEmbeddingProviderContext,
ProviderDeferSyntheticProfileAuthContext,
@@ -87,14 +87,7 @@ import type {
const log = createSubsystemLogger("plugins/provider-runtime");
const warnedExternalAuthFallbackPluginIds = new Set<string>();
let catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
let catalogHookProviderIdCacheWithoutConfig = new WeakMap<
NodeJS.ProcessEnv,
Map<string, string[]>
>();
let catalogHookProviderIdCacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
>();
let catalogHookProviderIdCache = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
@@ -155,35 +148,16 @@ function resetCatalogHookProvidersCacheForTest(): void {
}
function clearCatalogHookProviderIdCache(): void {
catalogHookProviderIdCacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
catalogHookProviderIdCacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
>();
catalogHookProviderIdCache = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
}
function resolveCatalogHookProviderIdCacheBucket(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Map<string, string[]> {
if (!params.config) {
let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env);
if (!bucket) {
bucket = new Map<string, string[]>();
catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket);
}
return bucket;
}
let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config);
if (!envBuckets) {
envBuckets = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
catalogHookProviderIdCacheByConfig.set(params.config, envBuckets);
}
let bucket = envBuckets.get(params.env);
let bucket = catalogHookProviderIdCache.get(params.env);
if (!bucket) {
bucket = new Map<string, string[]>();
envBuckets.set(params.env, bucket);
catalogHookProviderIdCache.set(params.env, bucket);
}
return bucket;
}
@@ -197,7 +171,7 @@ function buildCatalogHookProviderIdCacheKey(params: {
workspaceDir: params.workspaceDir,
env: params.env,
});
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}`;
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(resolveProviderHookConfigCacheShape(params.config, undefined))}`;
}
function resolveCachedCatalogHookProviderPluginIds(params: {
@@ -207,7 +181,6 @@ function resolveCachedCatalogHookProviderPluginIds(params: {
}): string[] {
const env = params.env ?? process.env;
const bucket = resolveCatalogHookProviderIdCacheBucket({
config: params.config,
env,
});
const key = buildCatalogHookProviderIdCacheKey({
@@ -266,19 +239,19 @@ function resolveProviderPluginsForCatalogHooks(params: {
envCache = new Map<string, ProviderPlugin[]>();
catalogHookProvidersCache.set(env, envCache);
}
const cacheKey = JSON.stringify({
workspaceDir: workspaceDir ?? "",
plugins: params.config?.plugins ?? null,
});
const cached = envCache.get(cacheKey);
if (cached) {
return cached;
}
const onlyPluginIds = resolveCachedCatalogHookProviderPluginIds({
config: params.config,
workspaceDir,
env,
});
const cacheKey = JSON.stringify({
workspaceDir: workspaceDir ?? "",
plugins: resolveProviderHookConfigCacheShape(params.config, onlyPluginIds),
});
const cached = envCache.get(cacheKey);
if (cached) {
return cached;
}
if (onlyPluginIds.length === 0) {
envCache.set(cacheKey, []);
return [];
@@ -1096,24 +1069,6 @@ export function shouldDeferProviderSyntheticProfileAuthWithPlugin(params: {
return undefined;
}
export function resolveProviderBuiltInModelSuppression(params: {
config?: OpenClawConfig;
workspaceDir?: string;
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) {
return result;
}
}
return undefined;
}
export async function augmentModelCatalogWithProviderPlugins(params: {
config?: OpenClawConfig;
workspaceDir?: string;

View File

@@ -1005,9 +1005,8 @@ export type ProviderBuildUnknownModelHintContext = {
/**
* Built-in model suppression hook context.
*
* @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.
* @deprecated Use manifest `modelCatalog.suppressions`. Runtime suppression
* hooks are no longer called by model resolution.
*/
export type ProviderBuiltInModelSuppressionContext = {
config?: OpenClawConfig;
@@ -1518,9 +1517,8 @@ export type ProviderPlugin = {
* `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.
* @deprecated Use manifest `modelCatalog.suppressions`. Runtime suppression
* hooks are no longer called by model resolution.
*/
suppressBuiltInModel?: (
ctx: ProviderBuiltInModelSuppressionContext,