Plugins: avoid booting bundled providers for catalog hooks

This commit is contained in:
Gustavo Madeira Santana
2026-03-16 12:56:09 +00:00
parent 8ad8069854
commit 55253e2a9d
9 changed files with 289 additions and 11 deletions

View File

@@ -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<typeof entry> => entry !== undefined);
}

View File

@@ -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<ResolvePluginProviders>((_) => [] as ProviderPlugin[]);
const resolveNonBundledProviderPluginIdsMock = vi.fn<ResolveNonBundledProviderPluginIds>(
(_) => [] as string[],
);
const resolveOwningPluginIdsForProviderMock = vi.fn<ResolveOwningPluginIdsForProvider>(
(_) => 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();
});
});

View File

@@ -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<string, ProviderPlugin[]>
>();
let cachedHookProvidersByConfig = new WeakMap<
OpenClawConfig,
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);
if (!bucket) {
bucket = new Map<string, ProviderPlugin[]>();
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<string, ProviderPlugin[]>
>();
cachedHookProvidersByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>
>();
}
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;

View File

@@ -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;