From 947aae5a9920356afc71f485ccb88dd62263b79a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 01:36:50 +0100 Subject: [PATCH] refactor(models): move suppressions to manifests --- CHANGELOG.md | 2 +- docs/plugins/architecture-internals.md | 2 +- docs/plugins/manifest.md | 19 +- docs/plugins/sdk-provider-plugins.md | 2 +- docs/plugins/sdk-subpaths.md | 2 +- extensions/feishu/src/setup-surface.test.ts | 1 + extensions/openai/openai-codex-provider.ts | 9 - extensions/openai/openai-provider.ts | 14 - .../provider-catalog.contract-test-support.ts | 25 +- extensions/qwen/index.test.ts | 38 +-- extensions/qwen/index.ts | 39 --- extensions/qwen/openclaw.plugin.json | 22 ++ src/agents/model-suppression.test.ts | 21 +- src/agents/model-suppression.ts | 15 +- src/agents/models-config.ts | 5 +- .../model.forward-compat.test.ts | 1 - .../model.startup-retry.test.ts | 1 - ...rovider-usage.auth.normalizes-keys.test.ts | 1 - src/model-catalog/manifest-planner.test.ts | 6 + src/model-catalog/manifest-planner.ts | 2 + src/model-catalog/normalize.test.ts | 8 + src/model-catalog/normalize.ts | 15 + src/model-catalog/types.ts | 4 + src/plugin-sdk/provider-catalog-runtime.ts | 1 - .../test-helpers/provider-catalog.ts | 13 +- .../manifest-model-suppression.test.ts | 88 ++++++ src/plugins/manifest-model-suppression.ts | 76 ++++- src/plugins/provider-hook-runtime.ts | 126 +++++++- src/plugins/provider-runtime.test-support.ts | 25 -- src/plugins/provider-runtime.test.ts | 282 +++++++++++++++--- src/plugins/provider-runtime.ts | 73 +---- src/plugins/types.ts | 10 +- 32 files changed, 644 insertions(+), 304 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 172209020b6..0c29ab5f643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index a23aa8d631d..1f351fd6b64 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -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 | diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index d0c41f8125e..6ee2b4ed055 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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`. diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index 1efcdac36ff..9928933e063 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -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 | diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index de7b0767705..d1f530ddfa6 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -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` | diff --git a/extensions/feishu/src/setup-surface.test.ts b/extensions/feishu/src/setup-surface.test.ts index cbe4a6f5bdc..c8f1a15fdd0 100644 --- a/extensions/feishu/src/setup-surface.test.ts +++ b/extensions/feishu/src/setup-surface.test.ts @@ -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 diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index b1277228f5f..e3afd33a1ce 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -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; diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 2dd10e07046..3e4ae8e1a53 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -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, diff --git a/extensions/openai/test-support/provider-catalog.contract-test-support.ts b/extensions/openai/test-support/provider-catalog.contract-test-support.ts index ec0b4c4c728..353c476aa84 100644 --- a/extensions/openai/test-support/provider-catalog.contract-test-support.ts +++ b/extensions/openai/test-support/provider-catalog.contract-test-support.ts @@ -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>[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( diff --git a/extensions/qwen/index.test.ts b/extensions/qwen/index.test.ts index ba40f3b1c03..b18d960b990 100644 --- a/extensions/qwen/index.test.ts +++ b/extensions/qwen/index.test.ts @@ -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(); }); }); diff --git a/extensions/qwen/index.ts b/extensions/qwen/index.ts index b22f3341aeb..c5e8192aef0 100644 --- a/extensions/qwen/index.ts +++ b/extensions/qwen/index.ts @@ -40,29 +40,6 @@ function resolveConfiguredQwenBaseUrl( return undefined; } -function isQwen36PlusUnsupportedForConfig(params: { - config: Parameters[0]; - baseUrl?: string; -}): boolean { - return isQwenCodingPlanBaseUrl(params.baseUrl ?? resolveConfiguredQwenBaseUrl(params.config)); -} - -function hasExactForeignApiOwner(params: { - provider: string; - config: { models?: { providers?: Record } } | 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()); diff --git a/extensions/qwen/openclaw.plugin.json b/extensions/qwen/openclaw.plugin.json index ce974106f44..f7977a271ff 100644 --- a/extensions/qwen/openclaw.plugin.json +++ b/extensions/qwen/openclaw.plugin.json @@ -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"] diff --git a/src/agents/model-suppression.test.ts b/src/agents/model-suppression.test.ts index bc93e41bde2..0ad467392d3 100644 --- a/src/agents/model-suppression.test.ts +++ b/src/agents/model-suppression.test.ts @@ -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(); }); }); diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index a511753a38c..7ab86ef8321 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -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: { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 9f2f2dd5f67..141e4b48a40 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -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) { diff --git a/src/agents/pi-embedded-runner/model.forward-compat.test.ts b/src/agents/pi-embedded-runner/model.forward-compat.test.ts index 94e3fc68a9c..3b35e8b32e7 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.test.ts +++ b/src/agents/pi-embedded-runner/model.forward-compat.test.ts @@ -14,7 +14,6 @@ vi.mock("../../plugins/provider-runtime.js", () => ({ normalizeProviderResolvedModelWithPlugin: () => undefined, normalizeProviderTransportWithPlugin: () => undefined, prepareProviderDynamicModel: async () => undefined, - resolveProviderBuiltInModelSuppression: () => undefined, runProviderDynamicModel: () => undefined, shouldPreferProviderRuntimeResolvedModel: () => false, })); diff --git a/src/agents/pi-embedded-runner/model.startup-retry.test.ts b/src/agents/pi-embedded-runner/model.startup-retry.test.ts index f9a6c51678a..be046b9c33b 100644 --- a/src/agents/pi-embedded-runner/model.startup-retry.test.ts +++ b/src/agents/pi-embedded-runner/model.startup-retry.test.ts @@ -42,7 +42,6 @@ vi.mock("../../plugins/provider-runtime.js", () => ({ normalizeProviderResolvedModelWithPlugin: () => undefined, normalizeProviderTransportWithPlugin: () => undefined, prepareProviderDynamicModel: async () => {}, - resolveProviderBuiltInModelSuppression: () => undefined, runProviderDynamicModel: () => undefined, shouldPreferProviderRuntimeResolvedModel: () => false, })); diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index b3ff24fe27f..e339ad331c4 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -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), diff --git a/src/model-catalog/manifest-planner.test.ts b/src/model-catalog/manifest-planner.test.ts index 3cc258b730f..40bfd5ab858 100644 --- a/src/model-catalog/manifest-planner.test.ts +++ b/src/model-catalog/manifest-planner.test.ts @@ -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"], + }, }, ]); }); diff --git a/src/model-catalog/manifest-planner.ts b/src/model-catalog/manifest-planner.ts index cb28ec61a41..b7493ab44c6 100644 --- a/src/model-catalog/manifest-planner.ts +++ b/src/model-catalog/manifest-planner.ts @@ -46,6 +46,7 @@ export type ManifestModelCatalogSuppressionEntry = { model: string; mergeKey: string; reason?: string; + when?: NonNullable[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 } : {}), }); } } diff --git a/src/model-catalog/normalize.test.ts b/src/model-catalog/normalize.test.ts index 766421fd9df..bc5a1424808 100644 --- a/src/model-catalog/normalize.test.ts +++ b/src/model-catalog/normalize.test.ts @@ -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: { diff --git a/src/model-catalog/normalize.ts b/src/model-catalog/normalize.ts index 300b67a72b3..0c221494d77 100644 --- a/src/model-catalog/normalize.ts +++ b/src/model-catalog/normalize.ts @@ -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; diff --git a/src/model-catalog/types.ts b/src/model-catalog/types.ts index e3a832c06fc..0cf9c4bad32 100644 --- a/src/model-catalog/types.ts +++ b/src/model-catalog/types.ts @@ -63,6 +63,10 @@ export type ModelCatalogSuppression = { provider: string; model: string; reason?: string; + when?: { + baseUrlHosts?: string[]; + providerConfigApiIn?: string[]; + }; }; export type ModelCatalog = { diff --git a/src/plugin-sdk/provider-catalog-runtime.ts b/src/plugin-sdk/provider-catalog-runtime.ts index ff1a339f0b6..914fc7bc1d3 100644 --- a/src/plugin-sdk/provider-catalog-runtime.ts +++ b/src/plugin-sdk/provider-catalog-runtime.ts @@ -3,7 +3,6 @@ export { augmentModelCatalogWithProviderPlugins, resetProviderRuntimeHookCacheForTest, - resolveProviderBuiltInModelSuppression, } from "../plugins/provider-runtime.js"; export { resolveCatalogHookProviderPluginIds, diff --git a/src/plugin-sdk/test-helpers/provider-catalog.ts b/src/plugin-sdk/test-helpers/provider-catalog.ts index 2c23a50e217..71f25e68ccf 100644 --- a/src/plugin-sdk/test-helpers/provider-catalog.ts +++ b/src/plugin-sdk/test-helpers/provider-catalog.ts @@ -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 { - 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, }; } diff --git a/src/plugins/manifest-model-suppression.test.ts b/src/plugins/manifest-model-suppression.test.ts index ff58494c4ab..7d7ecbb40ea 100644 --- a/src/plugins/manifest-model-suppression.test.ts +++ b/src/plugins/manifest-model-suppression.test.ts @@ -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(); + }); }); diff --git a/src/plugins/manifest-model-suppression.ts b/src/plugins/manifest-model-suppression.ts index 61b37a6c38e..a4d1a7772c7 100644 --- a/src/plugins/manifest-model-suppression.ts +++ b/src/plugins/manifest-model-suppression.ts @@ -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(); 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; } diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index b2ec8d7407a..6c2dbc7acf5 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -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 { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function projectPluginEntryForProviderHookCache( + pluginId: string, + entry: unknown, + fullConfigPluginIds: ReadonlySet, +): 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, +): 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(); + 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) { diff --git a/src/plugins/provider-runtime.test-support.ts b/src/plugins/provider-runtime.test-support.ts index 5158b3d1651..d327cbd438a 100644 --- a/src/plugins/provider-runtime.test-support.ts +++ b/src/plugins/provider-runtime.test-support.ts @@ -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; diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 31d6d204c9b..a0e52063ae2 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -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, diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index d7a20d0745f..943fa5c97b5 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -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(); let catalogHookProvidersCache = new WeakMap>(); -let catalogHookProviderIdCacheWithoutConfig = new WeakMap< - NodeJS.ProcessEnv, - Map ->(); -let catalogHookProviderIdCacheByConfig = new WeakMap< - OpenClawConfig, - WeakMap> ->(); +let catalogHookProviderIdCache = new WeakMap>(); function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean { const normalized = normalizeProviderId(providerId); @@ -155,35 +148,16 @@ function resetCatalogHookProvidersCacheForTest(): void { } function clearCatalogHookProviderIdCache(): void { - catalogHookProviderIdCacheWithoutConfig = new WeakMap>(); - catalogHookProviderIdCacheByConfig = new WeakMap< - OpenClawConfig, - WeakMap> - >(); + catalogHookProviderIdCache = new WeakMap>(); } function resolveCatalogHookProviderIdCacheBucket(params: { - config?: OpenClawConfig; env: NodeJS.ProcessEnv; }): Map { - if (!params.config) { - let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env); - if (!bucket) { - bucket = new Map(); - catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket); - } - return bucket; - } - - let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config); - if (!envBuckets) { - envBuckets = new WeakMap>(); - catalogHookProviderIdCacheByConfig.set(params.config, envBuckets); - } - let bucket = envBuckets.get(params.env); + let bucket = catalogHookProviderIdCache.get(params.env); if (!bucket) { bucket = new Map(); - 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(); 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; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index a388254f117..4617e6e364a 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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,