From 55253e2a9d12bdce0b14c1b9aa0e9e400799c128 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 12:56:09 +0000 Subject: [PATCH] Plugins: avoid booting bundled providers for catalog hooks --- extensions/openai/openai-codex-provider.ts | 2 +- extensions/openai/openai-provider.ts | 2 +- extensions/xai/index.ts | 2 +- src/agents/model-catalog.test-harness.ts | 3 + src/agents/model-suppression.ts | 2 +- src/plugins/provider-catalog-metadata.ts | 97 ++++++++++++++++++ src/plugins/provider-runtime.test.ts | 51 ++++++++++ src/plugins/provider-runtime.ts | 111 +++++++++++++++++++-- src/plugins/providers.ts | 30 +++++- 9 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 src/plugins/provider-catalog-metadata.ts diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index c0ae2c12210..999c37c6204 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -10,8 +10,8 @@ import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js" import type { OAuthCredential } from "../../src/agents/auth-profiles/types.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { normalizeProviderId } from "../../src/agents/model-selection.js"; import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js"; +import { normalizeProviderId } from "../../src/agents/provider-id.js"; import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js"; import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 9155fb3cd30..9c93ec1bd27 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -3,7 +3,7 @@ import { type ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; import { normalizeModelCompat } from "../../src/agents/model-compat.js"; -import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import { normalizeProviderId } from "../../src/agents/provider-id.js"; import { applyOpenAIConfig, OPENAI_DEFAULT_MODEL, diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 98731023653..c9f3bcdf4de 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,4 +1,4 @@ -import { normalizeProviderId } from "../../src/agents/model-selection.js"; +import { normalizeProviderId } from "../../src/agents/provider-id.js"; import { createPluginBackedWebSearchProvider, getScopedCredentialValue, diff --git a/src/agents/model-catalog.test-harness.ts b/src/agents/model-catalog.test-harness.ts index 0c4633d6748..4343cfc40e6 100644 --- a/src/agents/model-catalog.test-harness.ts +++ b/src/agents/model-catalog.test-harness.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, vi } from "vitest"; +import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js"; import { __setModelCatalogImportForTest, resetModelCatalogCacheForTest } from "./model-catalog.js"; export type PiSdkModule = typeof import("./pi-model-discovery.js"); @@ -14,11 +15,13 @@ vi.mock("./agent-paths.js", () => ({ export function installModelCatalogTestHooks() { beforeEach(() => { resetModelCatalogCacheForTest(); + resetProviderRuntimeHookCacheForTest(); }); afterEach(() => { __setModelCatalogImportForTest(); resetModelCatalogCacheForTest(); + resetProviderRuntimeHookCacheForTest(); vi.restoreAllMocks(); }); } diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index ac1dcccdb74..48927e6d5f3 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -1,5 +1,5 @@ import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js"; -import { normalizeProviderId } from "./model-selection.js"; +import { normalizeProviderId } from "./provider-id.js"; function resolveBuiltInModelSuppression(params: { provider?: string | null; id?: string | null }) { const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? ""); diff --git a/src/plugins/provider-catalog-metadata.ts b/src/plugins/provider-catalog-metadata.ts new file mode 100644 index 00000000000..123fef24289 --- /dev/null +++ b/src/plugins/provider-catalog-metadata.ts @@ -0,0 +1,97 @@ +import { normalizeProviderId } from "../agents/provider-id.js"; +import type { + ProviderAugmentModelCatalogContext, + ProviderBuiltInModelSuppressionContext, +} from "./types.js"; + +const OPENAI_PROVIDER_ID = "openai"; +const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; +const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; +const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); + +function findCatalogTemplate(params: { + entries: ReadonlyArray<{ provider: string; id: string }>; + providerId: string; + templateIds: readonly string[]; +}) { + return params.templateIds + .map((templateId) => + params.entries.find( + (entry) => + entry.provider.toLowerCase() === params.providerId.toLowerCase() && + entry.id.toLowerCase() === templateId.toLowerCase(), + ), + ) + .find((entry) => entry !== undefined); +} + +export function resolveBundledProviderBuiltInModelSuppression( + context: ProviderBuiltInModelSuppressionContext, +) { + if ( + !SUPPRESSED_SPARK_PROVIDERS.has(normalizeProviderId(context.provider)) || + context.modelId.toLowerCase() !== OPENAI_DIRECT_SPARK_MODEL_ID + ) { + return undefined; + } + return { + suppress: true, + errorMessage: `Unknown model: ${context.provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is only supported via openai-codex OAuth. Use openai-codex/${OPENAI_DIRECT_SPARK_MODEL_ID}.`, + }; +} + +export function augmentBundledProviderCatalog( + context: ProviderAugmentModelCatalogContext, +): ProviderAugmentModelCatalogContext["entries"] { + const openAiGpt54Template = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_PROVIDER_ID, + templateIds: ["gpt-5.2"], + }); + const openAiGpt54ProTemplate = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_PROVIDER_ID, + templateIds: ["gpt-5.2-pro", "gpt-5.2"], + }); + const openAiCodexGpt54Template = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_CODEX_PROVIDER_ID, + templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + }); + const openAiCodexSparkTemplate = findCatalogTemplate({ + entries: context.entries, + providerId: OPENAI_CODEX_PROVIDER_ID, + templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + }); + + return [ + openAiGpt54Template + ? { + ...openAiGpt54Template, + id: "gpt-5.4", + name: "gpt-5.4", + } + : undefined, + openAiGpt54ProTemplate + ? { + ...openAiGpt54ProTemplate, + id: "gpt-5.4-pro", + name: "gpt-5.4-pro", + } + : undefined, + openAiCodexGpt54Template + ? { + ...openAiCodexGpt54Template, + id: "gpt-5.4", + name: "gpt-5.4", + } + : undefined, + openAiCodexSparkTemplate + ? { + ...openAiCodexSparkTemplate, + id: OPENAI_DIRECT_SPARK_MODEL_ID, + name: OPENAI_DIRECT_SPARK_MODEL_ID, + } + : undefined, + ].filter((entry): entry is NonNullable => entry !== undefined); +} diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index a41cb52727f..07ee1794562 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -2,16 +2,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js"; type ResolvePluginProviders = typeof import("./providers.js").resolvePluginProviders; +type ResolveNonBundledProviderPluginIds = + typeof import("./providers.js").resolveNonBundledProviderPluginIds; type ResolveOwningPluginIdsForProvider = typeof import("./providers.js").resolveOwningPluginIdsForProvider; const resolvePluginProvidersMock = vi.fn((_) => [] as ProviderPlugin[]); +const resolveNonBundledProviderPluginIdsMock = vi.fn( + (_) => [] as string[], +); const resolveOwningPluginIdsForProviderMock = vi.fn( (_) => undefined as string[] | undefined, ); vi.mock("./providers.js", () => ({ resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), + resolveNonBundledProviderPluginIds: (params: unknown) => + resolveNonBundledProviderPluginIdsMock(params as never), resolveOwningPluginIdsForProvider: (params: unknown) => resolveOwningPluginIdsForProviderMock(params as never), })); @@ -34,6 +41,7 @@ import { normalizeProviderResolvedModelWithPlugin, prepareProviderDynamicModel, prepareProviderRuntimeAuth, + resetProviderRuntimeHookCacheForTest, refreshProviderOAuthCredentialWithPlugin, resolveProviderRuntimePlugin, runProviderDynamicModel, @@ -55,8 +63,11 @@ const MODEL: ProviderRuntimeModel = { describe("provider-runtime", () => { beforeEach(() => { + resetProviderRuntimeHookCacheForTest(); resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue([]); + resolveNonBundledProviderPluginIdsMock.mockReset(); + resolveNonBundledProviderPluginIdsMock.mockReturnValue([]); resolveOwningPluginIdsForProviderMock.mockReset(); resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined); }); @@ -454,4 +465,44 @@ describe("provider-runtime", () => { expect(resolveUsageAuth).toHaveBeenCalledTimes(1); expect(fetchUsageSnapshot).toHaveBeenCalledTimes(1); }); + + it("resolves bundled catalog hooks without loading provider plugins", async () => { + 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, + context: { + env: process.env, + entries: [ + { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, + { provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, + { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ], + }, + }), + ).resolves.toEqual([ + { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, + { provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, + { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, + { + provider: "openai-codex", + id: "gpt-5.3-codex-spark", + name: "gpt-5.3-codex-spark", + }, + ]); + + expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 3d1bd77f6d9..61a2a0c5792 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1,7 +1,15 @@ import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js"; -import { normalizeProviderId } from "../agents/model-selection.js"; +import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveOwningPluginIdsForProvider, resolvePluginProviders } from "./providers.js"; +import { + augmentBundledProviderCatalog, + resolveBundledProviderBuiltInModelSuppression, +} from "./provider-catalog-metadata.js"; +import { + resolveNonBundledProviderPluginIds, + resolveOwningPluginIdsForProvider, + resolvePluginProviders, +} from "./providers.js"; import type { ProviderAuthDoctorHintContext, ProviderAugmentModelCatalogContext, @@ -33,19 +41,104 @@ function matchesProviderId(provider: ProviderPlugin, providerId: string): boolea return (provider.aliases ?? []).some((alias) => normalizeProviderId(alias) === normalized); } +let cachedHookProvidersWithoutConfig = new WeakMap< + NodeJS.ProcessEnv, + Map +>(); +let cachedHookProvidersByConfig = new WeakMap< + OpenClawConfig, + WeakMap> +>(); + +function resolveHookProviderCacheBucket(params: { + config?: OpenClawConfig; + env: NodeJS.ProcessEnv; +}) { + if (!params.config) { + let bucket = cachedHookProvidersWithoutConfig.get(params.env); + if (!bucket) { + bucket = new Map(); + cachedHookProvidersWithoutConfig.set(params.env, bucket); + } + return bucket; + } + + let envBuckets = cachedHookProvidersByConfig.get(params.config); + if (!envBuckets) { + envBuckets = new WeakMap>(); + cachedHookProvidersByConfig.set(params.config, envBuckets); + } + let bucket = envBuckets.get(params.env); + if (!bucket) { + bucket = new Map(); + envBuckets.set(params.env, bucket); + } + return bucket; +} + +function buildHookProviderCacheKey(params: { workspaceDir?: string; onlyPluginIds?: string[] }) { + return `${params.workspaceDir ?? ""}::${JSON.stringify(params.onlyPluginIds ?? [])}`; +} + +export function resetProviderRuntimeHookCacheForTest(): void { + cachedHookProvidersWithoutConfig = new WeakMap< + NodeJS.ProcessEnv, + Map + >(); + cachedHookProvidersByConfig = new WeakMap< + OpenClawConfig, + WeakMap> + >(); +} + function resolveProviderPluginsForHooks(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; onlyPluginIds?: string[]; }): ProviderPlugin[] { - return resolvePluginProviders({ + const env = params.env ?? process.env; + const cacheBucket = resolveHookProviderCacheBucket({ + config: params.config, + env, + }); + const cacheKey = buildHookProviderCacheKey({ + workspaceDir: params.workspaceDir, + onlyPluginIds: params.onlyPluginIds, + }); + const cached = cacheBucket.get(cacheKey); + if (cached) { + return cached; + } + const resolved = resolvePluginProviders({ ...params, + env, activate: false, cache: false, bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }); + cacheBucket.set(cacheKey, resolved); + return resolved; +} + +function resolveProviderPluginsForCatalogHooks(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderPlugin[] { + const onlyPluginIds = resolveNonBundledProviderPluginIds({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + if (onlyPluginIds.length === 0) { + return []; + } + return resolveProviderPluginsForHooks({ + ...params, + onlyPluginIds, + }); } export function resolveProviderRuntimePlugin(params: { @@ -265,7 +358,11 @@ export function resolveProviderBuiltInModelSuppression(params: { env?: NodeJS.ProcessEnv; context: ProviderBuiltInModelSuppressionContext; }) { - for (const plugin of resolveProviderPluginsForHooks(params)) { + const bundledResult = resolveBundledProviderBuiltInModelSuppression(params.context); + if (bundledResult?.suppress) { + return bundledResult; + } + for (const plugin of resolveProviderPluginsForCatalogHooks(params)) { const result = plugin.suppressBuiltInModel?.(params.context); if (result?.suppress) { return result; @@ -280,8 +377,10 @@ export async function augmentModelCatalogWithProviderPlugins(params: { env?: NodeJS.ProcessEnv; context: ProviderAugmentModelCatalogContext; }) { - const supplemental = [] as ProviderAugmentModelCatalogContext["entries"]; - for (const plugin of resolveProviderPluginsForHooks(params)) { + const supplemental = [ + ...augmentBundledProviderCatalog(params.context), + ] as ProviderAugmentModelCatalogContext["entries"]; + for (const plugin of resolveProviderPluginsForCatalogHooks(params)) { const next = await plugin.augmentModelCatalog?.(params.context); if (!next || next.length === 0) { continue; diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 90a2acedcad..35ef2703553 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,6 +1,7 @@ -import { normalizeProviderId } from "../agents/model-selection.js"; +import { normalizeProviderId } from "../agents/provider-id.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { withBundledPluginAllowlistCompat } from "./bundled-compat.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; import { createPluginLoaderLogger } from "./logger.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; @@ -106,6 +107,33 @@ export function resolveOwningPluginIdsForProvider(params: { return pluginIds.length > 0 ? pluginIds : undefined; } +export function resolveNonBundledProviderPluginIds(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] { + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const normalizedConfig = normalizePluginsConfig(params.config?.plugins); + return registry.plugins + .filter( + (plugin) => + plugin.origin !== "bundled" && + plugin.providers.length > 0 && + resolveEffectiveEnableState({ + id: plugin.id, + origin: plugin.origin, + config: normalizedConfig, + rootConfig: params.config, + }).enabled, + ) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} + export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string;