Compare commits

...

6 Commits

Author SHA1 Message Date
Peter Steinberger
23d9fe4f33 test(ci): update support boundary expectations 2026-04-28 02:23:38 +01:00
Peter Steinberger
2221f2a3f1 fix(memory): reject empty lancedb embedding config 2026-04-28 02:23:38 +01:00
Peter Steinberger
351b3f35e8 test(models): drop suppression helper exports 2026-04-28 02:23:38 +01:00
Peter Steinberger
a686a4fcfc refactor(models): move suppressions to manifests 2026-04-28 02:23:37 +01:00
Peter Steinberger
c7f9156c10 perf(agents): keep model resolution caches warm 2026-04-28 02:23:37 +01:00
Jochen Roessner
43ff409317 perf: cache model resolution to avoid repeated plugin-provider loads
On ARM64 devices (e.g. Raspberry Pi 4), resolvePluginProviders takes ~20s
on first call. Three bugs cause this cost to be paid repeatedly:

1. ensureOpenClawModelsJson readyCache fingerprint includes models.json
   mtime. After a write, the stored fingerprint (pre-write mtime) never
   matches again, forcing every caller to re-run planOpenClawModelsJson.

2. readyCache has one entry per file path. Agents with different configs
   (e.g. main agent vs active-memory subagent) overwrite each other's
   entry, so neither benefits from caching.

3. resolveExplicitModelWithRegistry calls shouldSuppressBuiltInModel →
   resolveProviderPluginsForCatalogHooks on every agent run. The internal
   cache key includes the full config, so callers with slightly different
   configs each pay the full provider-load cost.

Fixes:
- Remove modelsFileMtimeMs from fingerprint (bug 1)
- Add noopCache to MODELS_JSON_STATE keyed by (path, mtime) — a noop
  result is config-agnostic, so any caller can reuse it (bug 2)
- Cache resolveExplicitModelWithRegistry by (provider, modelId, agentDir),
  stable for the lifetime of a gateway session (bug 3)

Measured on Raspberry Pi 4 (ARM64):
  active-memory subagent preprocessing: 66-75s → ~3s (warm)
  active-memory total elapsed:           ~96s  → ~14s (warm)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 02:23:37 +01:00
41 changed files with 823 additions and 348 deletions

View File

@@ -10,7 +10,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/runtime: reuse the current plugin metadata snapshot for provider discovery so repeated model-provider discovery avoids rebuilding plugin manifest metadata. Thanks @shakkernerd.
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
- Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys.
- macOS app: update Peekaboo, ElevenLabsKit, and MLX TTS helper dependencies, make canvas file watching and config/exec-approval state writes reliable under concurrent app/test activity, and keep the app plus helper builds warning-free. Thanks @Blaizzy.
- iOS app: refresh SwiftPM/XcodeGen source hygiene, make app, extension, watch, and curated shared Swift files pass the prebuild SwiftFormat and SwiftLint checks, move relay registration off deprecated StoreKit receipt APIs, and keep simulator builds and logic tests warning-free. Thanks @ngutman.
- Agents/models: keep `models.json` readiness and provider-hook caches warm across repeated agent and subagent model resolution while preserving external `models.json` invalidation, reducing repeated provider-plugin loads on slower ARM64 hosts. Fixes #73075. Thanks @jochen.
- Docs/tools: clarify that `tools.profile: "messaging"` is intentionally narrow and that `tools.profile: "full"` is the unrestricted baseline for broader command/control access. Carries forward #39954. Thanks @posigit.
- Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev.
- Agents/sessions: keep `sessions_history` recall redaction enabled even when general log redaction is disabled, and clarify that safety-boundary UI/tool/diagnostic payloads still redact independently of `logging.redactSensitive`. Carries forward #72319. Thanks @volcano303 and @BunsDev.

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

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

@@ -61,7 +61,7 @@ describe("memory-lancedb config", () => {
expect(parsed.embedding.provider).toBe("openai");
});
it("rejects empty embedding placeholders in the manifest schema", () => {
it("rejects empty embedding config in the manifest schema and runtime parser", () => {
const manifestResult = validateJsonSchemaValue({
schema: manifest.configSchema,
cacheKey: "memory-lancedb.manifest.empty-embedding",
@@ -71,6 +71,17 @@ describe("memory-lancedb config", () => {
});
expect(manifestResult.ok).toBe(false);
if (!manifestResult.ok) {
expect(manifestResult.errors.map((error) => error.text)).toContain(
"embedding: must NOT have fewer than 1 properties",
);
}
expect(() => {
memoryConfigSchema.parse({
embedding: {},
});
}).toThrow("embedding config must include at least one setting");
});
it("rejects empty embedding providers", () => {

View File

@@ -58,6 +58,7 @@ const EMBEDDING_DIMENSIONS: Record<string, number> = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
};
const EMBEDDING_CONFIG_KEYS = ["provider", "apiKey", "model", "baseUrl", "dimensions"] as const;
function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], label: string) {
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
@@ -118,11 +119,10 @@ export const memoryConfigSchema = {
if (!embedding || typeof embedding !== "object" || Array.isArray(embedding)) {
throw new Error("embedding config required");
}
assertAllowedKeys(
embedding,
["provider", "apiKey", "model", "baseUrl", "dimensions"],
"embedding config",
);
assertAllowedKeys(embedding, [...EMBEDDING_CONFIG_KEYS], "embedding config");
if (Object.keys(embedding).length === 0) {
throw new Error("embedding config must include at least one setting");
}
const model = resolveEmbeddingModel(embedding);
const provider = typeof embedding.provider === "string" ? embedding.provider.trim() : "openai";

View File

@@ -73,6 +73,7 @@
"type": "object",
"minProperties": 1,
"additionalProperties": false,
"minProperties": 1,
"properties": {
"apiKey": {
"type": "string"

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

@@ -298,6 +298,14 @@ const SOURCE_TEST_TARGETS = new Map([
["src/memory-host-sdk/host/embeddings.test.ts"],
],
["src/memory-host-sdk/host/embeddings.ts", ["src/memory-host-sdk/host/embeddings.test.ts"]],
[
"src/plugin-sdk/test-helpers/directory-ids.ts",
[
"extensions/discord/src/directory-contract.test.ts",
"extensions/slack/src/directory-contract.test.ts",
"extensions/telegram/src/directory-contract.test.ts",
],
],
[
"src/auto-reply/reply/dispatch-from-config.ts",
["src/auto-reply/reply/dispatch-from-config.test.ts"],

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

@@ -71,6 +71,10 @@ async function buildModelsJsonFingerprint(params: {
});
}
function modelsJsonReadyCacheKey(targetPath: string, fingerprint: string): string {
return `${targetPath}\0${fingerprint}`;
}
async function readExistingModelsFile(pathname: string): Promise<{
raw: string;
parsed: unknown;
@@ -188,13 +192,12 @@ export async function ensureOpenClawModelsJson(
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
});
const cached = MODELS_JSON_STATE.readyCache.get(targetPath);
const cacheKey = modelsJsonReadyCacheKey(targetPath, fingerprint);
const cached = MODELS_JSON_STATE.readyCache.get(cacheKey);
if (cached) {
const settled = await cached;
if (settled.fingerprint === fingerprint) {
await ensureModelsFileModeForModelsJson(targetPath);
return settled.result;
}
await ensureModelsFileModeForModelsJson(targetPath);
return settled.result;
}
const pending = withModelsJsonWriteLock(targetPath, async () => {
@@ -233,13 +236,34 @@ export async function ensureOpenClawModelsJson(
await ensureModelsFileModeForModelsJson(targetPath);
return { fingerprint, result: { agentDir, wrote: true } };
});
MODELS_JSON_STATE.readyCache.set(targetPath, pending);
MODELS_JSON_STATE.readyCache.set(cacheKey, pending);
try {
const settled = await pending;
const refreshedFingerprint = await buildModelsJsonFingerprint({
config: cfg,
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
...(workspaceDir ? { workspaceDir } : {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
...(options.providerDiscoveryProviderIds
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
: {}),
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
});
const refreshedCacheKey = modelsJsonReadyCacheKey(targetPath, refreshedFingerprint);
if (refreshedCacheKey !== cacheKey) {
MODELS_JSON_STATE.readyCache.delete(cacheKey);
MODELS_JSON_STATE.readyCache.set(
refreshedCacheKey,
Promise.resolve({ fingerprint: refreshedFingerprint, result: settled.result }),
);
}
return settled.result;
} catch (error) {
if (MODELS_JSON_STATE.readyCache.get(targetPath) === pending) {
MODELS_JSON_STATE.readyCache.delete(targetPath);
if (MODELS_JSON_STATE.readyCache.get(cacheKey) === pending) {
MODELS_JSON_STATE.readyCache.delete(cacheKey);
}
throw error;
}

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import {
CUSTOM_PROXY_MODELS_CONFIG,
installModelsConfigTestHooks,
@@ -136,6 +137,44 @@ describe("models-config write serialization", () => {
});
});
it("keeps the ready cache warm after models.json is written", async () => {
await withModelsTempHome(async () => {
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(1);
});
});
it("invalidates the ready cache when models.json changes externally", async () => {
await withModelsTempHome(async () => {
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
await fs.writeFile(modelPath, `${JSON.stringify({ external: true })}\n`, "utf8");
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(2);
});
});
it("keeps distinct config fingerprints cached without evicting each other", async () => {
await withModelsTempHome(async () => {
planOpenClawModelsJsonMock.mockImplementation(async () => ({ action: "noop" }));
const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG);
const second = structuredClone(CUSTOM_PROXY_MODELS_CONFIG);
first.agents = { defaults: { model: "openai/gpt-5.4" } };
second.agents = { defaults: { model: "anthropic/claude-sonnet-4-5" } };
await ensureOpenClawModelsJson(first);
await ensureOpenClawModelsJson(second);
await ensureOpenClawModelsJson(first);
expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(2);
});
});
it("serializes concurrent models.json writes to avoid overlap", async () => {
await withModelsTempHome(async () => {
const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG);

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

@@ -517,6 +517,32 @@ describe("plugins cli install", () => {
expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(true);
});
it("enables config-gated bundled installs when provider-backed config is explicit", async () => {
const cfg = {
plugins: {
entries: {
"memory-lancedb": {
config: {
embedding: {
provider: "openai",
model: "text-embedding-3-small",
},
},
},
},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("memory-lancedb");
loadConfig.mockReturnValue(cfg);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
await runPluginsCommand(["plugins", "install", "memory-lancedb"]);
expect(enablePluginInConfig).toHaveBeenCalled();
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(false);
});
it("passes force through as overwrite mode for ClawHub installs", async () => {
const cfg = {
plugins: {

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

@@ -6,7 +6,6 @@ export {
export {
expectAugmentedCodexCatalog,
expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55,
expectCodexBuiltInSuppression,
expectCodexMissingAuthHint,
importProviderRuntimeCatalogModule,
loadBundledPluginPublicSurface,

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

@@ -49,7 +49,6 @@ export { resolveBundledExplicitProviderContractsFromPublicArtifacts } from "../p
export {
expectAugmentedCodexCatalog,
expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55,
expectCodexBuiltInSuppression,
expectCodexMissingAuthHint,
} from "../plugins/provider-runtime.test-support.js";
export {

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";
@@ -34,65 +35,142 @@ function matchesProviderLiteralId(provider: ProviderPlugin, providerId: string):
return !!normalized && normalizeLowercaseStringOrEmpty(provider.id) === normalized;
}
let cachedHookProvidersWithoutConfig = new WeakMap<
NodeJS.ProcessEnv,
Map<string, ProviderPlugin[]>
>();
let cachedHookProvidersByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>
>();
let cachedHookProviders = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
function resolveHookProviderCacheBucket(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}) {
if (!params.config) {
let bucket = cachedHookProvidersWithoutConfig.get(params.env);
if (!bucket) {
bucket = new Map<string, ProviderPlugin[]>();
cachedHookProvidersWithoutConfig.set(params.env, bucket);
}
return bucket;
}
let envBuckets = cachedHookProvidersByConfig.get(params.config);
if (!envBuckets) {
envBuckets = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
cachedHookProvidersByConfig.set(params.config, envBuckets);
}
let bucket = envBuckets.get(params.env);
function resolveHookProviderCacheBucket(env: NodeJS.ProcessEnv) {
let bucket = cachedHookProviders.get(env);
if (!bucket) {
bucket = new Map<string, ProviderPlugin[]>();
envBuckets.set(params.env, bucket);
cachedHookProviders.set(env, bucket);
}
return bucket;
}
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: projectPluginsConfigForProviderHookCache(config.plugins, fullConfigPluginIdSet),
};
}
function buildHookProviderCacheKey(params: {
config?: OpenClawConfig;
workspaceDir?: string;
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(params.config ?? null)}::${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 {
cachedHookProvidersWithoutConfig = new WeakMap<
NodeJS.ProcessEnv,
Map<string, ProviderPlugin[]>
>();
cachedHookProvidersByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>
>();
cachedHookProviders = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
}
export function resetProviderRuntimeHookCacheForTest(): void {
@@ -116,16 +194,31 @@ export function resolveProviderPluginsForHooks(params: {
}): ProviderPlugin[] {
const env = params.env ?? process.env;
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
const cacheBucket = resolveHookProviderCacheBucket({
config: params.config,
env,
});
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

@@ -1,10 +1,9 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelProviderConfig } from "../config/types.js";
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,6 +686,91 @@ describe("provider-runtime", () => {
expect(providerRuntimeWarnMock).not.toHaveBeenCalled();
});
it("reuses catalog hook provider loads when only non-plugin config changes", async () => {
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["demo"]);
resolvePluginProvidersMock.mockReturnValue([
{
id: "demo",
label: "Demo",
auth: [],
augmentModelCatalog: () => [{ provider: "demo", id: "demo-model", name: "Demo Model" }],
},
]);
const baseConfig = {
plugins: {
entries: {
demo: { enabled: true },
},
},
} as OpenClawConfig;
const firstConfig = {
...baseConfig,
agents: { defaults: { model: "openai/gpt-5.4" } },
} as OpenClawConfig;
const secondConfig = {
...baseConfig,
agents: { defaults: { model: "anthropic/claude-sonnet-4-5" } },
} as OpenClawConfig;
expect(
await augmentModelCatalogWithProviderPlugins({
config: firstConfig,
env: process.env,
context: { config: firstConfig, env: process.env, entries: [] },
}),
).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]);
expect(
await augmentModelCatalogWithProviderPlugins({
config: secondConfig,
env: process.env,
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",
@@ -1609,7 +1877,6 @@ describe("provider-runtime", () => {
]);
expectCodexMissingAuthHint(buildProviderMissingAuthMessageWithPlugin);
expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression);
await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins);
expectCalledOnce(
@@ -1795,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,

View File

@@ -251,6 +251,17 @@ describe("scripts/test-projects changed-target routing", () => {
expect(plan.targets).not.toContain("extensions/discord/src/directory-contract.test.ts");
});
it("routes channel SDK helper edits through the tests that import them", () => {
expect(resolveChangedTestTargetPlan(["src/plugin-sdk/test-helpers/directory-ids.ts"])).toEqual({
mode: "targets",
targets: [
"extensions/discord/src/directory-contract.test.ts",
"extensions/slack/src/directory-contract.test.ts",
"extensions/telegram/src/directory-contract.test.ts",
],
});
});
it("routes channel contract helper edits through contract shards", () => {
const plan = resolveChangedTestTargetPlan([
"src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts",