refactor: route bundled provider catalog hooks through plugins

This commit is contained in:
Peter Steinberger 2026-03-27 17:09:27 +00:00
parent 910cb9f1af
commit 570bfb655f
5 changed files with 91 additions and 151 deletions

View File

@ -15,22 +15,22 @@ const PROVIDER_CATALOG_CONTRACT_TIMEOUT_MS = 300_000;
type ResolvePluginProviders = typeof import("../providers.runtime.js").resolvePluginProviders;
type ResolveOwningPluginIdsForProvider =
typeof import("../providers.js").resolveOwningPluginIdsForProvider;
type ResolveNonBundledProviderPluginIds =
typeof import("../providers.js").resolveNonBundledProviderPluginIds;
type ResolveCatalogHookProviderPluginIds =
typeof import("../providers.js").resolveCatalogHookProviderPluginIds;
const resolvePluginProvidersMock = vi.hoisted(() => vi.fn<ResolvePluginProviders>(() => []));
const resolveOwningPluginIdsForProviderMock = vi.hoisted(() =>
vi.fn<ResolveOwningPluginIdsForProvider>(() => undefined),
);
const resolveNonBundledProviderPluginIdsMock = vi.hoisted(() =>
vi.fn<ResolveNonBundledProviderPluginIds>((_) => [] as string[]),
const resolveCatalogHookProviderPluginIdsMock = vi.hoisted(() =>
vi.fn<ResolveCatalogHookProviderPluginIds>((_) => [] as string[]),
);
vi.mock("../providers.js", () => ({
resolveOwningPluginIdsForProvider: (params: unknown) =>
resolveOwningPluginIdsForProviderMock(params as never),
resolveNonBundledProviderPluginIds: (params: unknown) =>
resolveNonBundledProviderPluginIdsMock(params as never),
resolveCatalogHookProviderPluginIds: (params: unknown) =>
resolveCatalogHookProviderPluginIdsMock(params as never),
}));
vi.mock("../providers.runtime.js", () => ({
@ -83,8 +83,8 @@ describe("provider catalog contract", { timeout: PROVIDER_CATALOG_CONTRACT_TIMEO
}
});
resolveNonBundledProviderPluginIdsMock.mockReset();
resolveNonBundledProviderPluginIdsMock.mockReturnValue([]);
resolveCatalogHookProviderPluginIdsMock.mockReset();
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]);
});
it("keeps codex-only missing-auth hints wired through the provider runtime", () => {

View File

@ -1,121 +0,0 @@
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 openAiGpt54MiniTemplate = findCatalogTemplate({
entries: context.entries,
providerId: OPENAI_PROVIDER_ID,
templateIds: ["gpt-5-mini"],
});
const openAiGpt54NanoTemplate = findCatalogTemplate({
entries: context.entries,
providerId: OPENAI_PROVIDER_ID,
templateIds: ["gpt-5-nano", "gpt-5-mini"],
});
const openAiCodexGpt54Template = findCatalogTemplate({
entries: context.entries,
providerId: OPENAI_CODEX_PROVIDER_ID,
templateIds: ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"],
});
const openAiCodexSparkTemplate = findCatalogTemplate({
entries: context.entries,
providerId: OPENAI_CODEX_PROVIDER_ID,
templateIds: ["gpt-5.4", "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,
openAiGpt54MiniTemplate
? {
...openAiGpt54MiniTemplate,
id: "gpt-5.4-mini",
name: "gpt-5.4-mini",
}
: undefined,
openAiGpt54NanoTemplate
? {
...openAiGpt54NanoTemplate,
id: "gpt-5.4-nano",
name: "gpt-5.4-nano",
}
: 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

@ -7,13 +7,13 @@ import {
import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js";
type ResolvePluginProviders = typeof import("./providers.runtime.js").resolvePluginProviders;
type ResolveNonBundledProviderPluginIds =
typeof import("./providers.js").resolveNonBundledProviderPluginIds;
type ResolveCatalogHookProviderPluginIds =
typeof import("./providers.js").resolveCatalogHookProviderPluginIds;
type ResolveOwningPluginIdsForProvider =
typeof import("./providers.js").resolveOwningPluginIdsForProvider;
const resolvePluginProvidersMock = vi.fn<ResolvePluginProviders>((_) => [] as ProviderPlugin[]);
const resolveNonBundledProviderPluginIdsMock = vi.fn<ResolveNonBundledProviderPluginIds>(
const resolveCatalogHookProviderPluginIdsMock = vi.fn<ResolveCatalogHookProviderPluginIds>(
(_) => [] as string[],
);
const resolveOwningPluginIdsForProviderMock = vi.fn<ResolveOwningPluginIdsForProvider>(
@ -64,8 +64,8 @@ describe("provider-runtime", () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("./providers.js", () => ({
resolveNonBundledProviderPluginIds: (params: unknown) =>
resolveNonBundledProviderPluginIdsMock(params as never),
resolveCatalogHookProviderPluginIds: (params: unknown) =>
resolveCatalogHookProviderPluginIdsMock(params as never),
resolveOwningPluginIdsForProvider: (params: unknown) =>
resolveOwningPluginIdsForProviderMock(params as never),
}));
@ -103,8 +103,8 @@ describe("provider-runtime", () => {
resetProviderRuntimeHookCacheForTest();
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue([]);
resolveNonBundledProviderPluginIdsMock.mockReset();
resolveNonBundledProviderPluginIdsMock.mockReturnValue([]);
resolveCatalogHookProviderPluginIdsMock.mockReset();
resolveCatalogHookProviderPluginIdsMock.mockReturnValue([]);
resolveOwningPluginIdsForProviderMock.mockReset();
resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined);
});
@ -150,6 +150,7 @@ describe("provider-runtime", () => {
});
it("dispatches runtime hooks for the matched provider", async () => {
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]);
resolveOwningPluginIdsForProviderMock.mockImplementation((params) => {
if (params.provider === "demo") {
return ["demo"];
@ -248,6 +249,8 @@ describe("provider-runtime", () => {
augmentModelCatalog: () => [
{ provider: "openai", id: "gpt-5.4", name: "gpt-5.4" },
{ provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" },
{ provider: "openai", id: "gpt-5.4-mini", name: "gpt-5.4-mini" },
{ provider: "openai", id: "gpt-5.4-nano", name: "gpt-5.4-nano" },
{ provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" },
{
provider: "openai-codex",
@ -540,7 +543,40 @@ describe("provider-runtime", () => {
expect(fetchUsageSnapshot).toHaveBeenCalledTimes(1);
});
it("resolves bundled catalog hooks without loading provider plugins", async () => {
it("resolves bundled catalog hooks through provider plugins", async () => {
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]);
resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => {
const onlyPluginIds = params?.onlyPluginIds;
if (!onlyPluginIds || !onlyPluginIds.includes("openai")) {
return [];
}
return [
{
id: "openai",
label: "OpenAI",
auth: [],
suppressBuiltInModel: ({ provider, modelId }) =>
provider === "openai" && modelId === "gpt-5.3-codex-spark"
? { suppress: true, errorMessage: "openai-codex/gpt-5.3-codex-spark" }
: provider === "azure-openai-responses" && modelId === "gpt-5.3-codex-spark"
? { suppress: true, errorMessage: "openai-codex/gpt-5.3-codex-spark" }
: undefined,
augmentModelCatalog: () => [
{ provider: "openai", id: "gpt-5.4", name: "gpt-5.4" },
{ provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" },
{ provider: "openai", id: "gpt-5.4-mini", name: "gpt-5.4-mini" },
{ provider: "openai", id: "gpt-5.4-nano", name: "gpt-5.4-nano" },
{ 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(
resolveProviderBuiltInModelSuppression({
env: process.env,
@ -581,6 +617,12 @@ describe("provider-runtime", () => {
},
]);
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["openai"],
activate: false,
cache: false,
}),
);
});
});

View File

@ -2,11 +2,7 @@ import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-prof
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/config.js";
import {
augmentBundledProviderCatalog,
resolveBundledProviderBuiltInModelSuppression,
} from "./provider-catalog-metadata.js";
import {
resolveNonBundledProviderPluginIds,
resolveCatalogHookProviderPluginIds,
resolveOwningPluginIdsForProvider,
} from "./providers.js";
import { resolvePluginProviders } from "./providers.runtime.js";
@ -145,7 +141,7 @@ function resolveProviderPluginsForCatalogHooks(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin[] {
const onlyPluginIds = resolveNonBundledProviderPluginIds({
const onlyPluginIds = resolveCatalogHookProviderPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
@ -416,10 +412,6 @@ export function resolveProviderBuiltInModelSuppression(params: {
env?: NodeJS.ProcessEnv;
context: ProviderBuiltInModelSuppressionContext;
}) {
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) {
@ -435,9 +427,7 @@ export async function augmentModelCatalogWithProviderPlugins(params: {
env?: NodeJS.ProcessEnv;
context: ProviderAugmentModelCatalogContext;
}) {
const supplemental = [
...augmentBundledProviderCatalog(params.context),
] as ProviderAugmentModelCatalogContext["entries"];
const supplemental = [] as ProviderAugmentModelCatalogContext["entries"];
for (const plugin of resolveProviderPluginsForCatalogHooks(params)) {
const next = await plugin.augmentModelCatalog?.(params.context);
if (!next || next.length === 0) {

View File

@ -91,3 +91,32 @@ export function resolveNonBundledProviderPluginIds(params: {
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function resolveCatalogHookProviderPluginIds(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);
const enabledProviderPluginIds = registry.plugins
.filter(
(plugin) =>
plugin.providers.length > 0 &&
resolveEffectiveEnableState({
id: plugin.id,
origin: plugin.origin,
config: normalizedConfig,
rootConfig: params.config,
}).enabled,
)
.map((plugin) => plugin.id);
const bundledCompatPluginIds = resolveBundledProviderCompatPluginIds(params);
return [...new Set([...enabledProviderPluginIds, ...bundledCompatPluginIds])].toSorted(
(left, right) => left.localeCompare(right),
);
}