feat(plugins): merge openai vendor seams into one plugin

This commit is contained in:
Peter Steinberger
2026-03-15 17:50:16 -07:00
parent bc5054ce68
commit b54e37c71f
26 changed files with 833 additions and 548 deletions

View File

@@ -6,6 +6,7 @@ import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types
import { coerceSecretRef } from "../config/types.secrets.js";
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { buildProviderMissingAuthMessageWithPlugin } from "../plugins/provider-runtime.js";
import {
normalizeOptionalSecretInput,
normalizeSecretInput,
@@ -358,13 +359,19 @@ export async function resolveApiKeyForProvider(params: {
return resolveAwsSdkAuthInfo();
}
if (provider === "openai") {
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
if (hasCodex) {
throw new Error(
'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.4 (OAuth) or set OPENAI_API_KEY to use openai/gpt-5.4.',
);
}
const pluginMissingAuthMessage = buildProviderMissingAuthMessageWithPlugin({
provider,
config: cfg,
context: {
config: cfg,
agentDir: params.agentDir,
env: process.env,
provider,
listProfileIds: (providerId) => listProfilesForProvider(store, providerId),
},
});
if (pluginMissingAuthMessage) {
throw new Error(pluginMissingAuthMessage);
}
const authStorePath = resolveAuthStorePathForDisplay(params.agentDir);

View File

@@ -1,5 +1,6 @@
import { type OpenClawConfig, loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import { shouldSuppressBuiltInModel } from "./model-suppression.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
@@ -33,70 +34,8 @@ let hasLoggedModelCatalogError = false;
const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js");
let importPiSdk = defaultImportPiSdk;
const CODEX_PROVIDER = "openai-codex";
const OPENAI_PROVIDER = "openai";
const OPENAI_GPT54_MODEL_ID = "gpt-5.4";
const OPENAI_GPT54_PRO_MODEL_ID = "gpt-5.4-pro";
const OPENAI_CODEX_GPT53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_GPT53_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const OPENAI_CODEX_GPT54_MODEL_ID = "gpt-5.4";
const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["kilocode"]);
type SyntheticCatalogFallback = {
provider: string;
id: string;
templateIds: readonly string[];
};
const SYNTHETIC_CATALOG_FALLBACKS: readonly SyntheticCatalogFallback[] = [
{
provider: OPENAI_PROVIDER,
id: OPENAI_GPT54_MODEL_ID,
templateIds: ["gpt-5.2"],
},
{
provider: OPENAI_PROVIDER,
id: OPENAI_GPT54_PRO_MODEL_ID,
templateIds: ["gpt-5.2-pro", "gpt-5.2"],
},
{
provider: CODEX_PROVIDER,
id: OPENAI_CODEX_GPT54_MODEL_ID,
templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"],
},
{
provider: CODEX_PROVIDER,
id: OPENAI_CODEX_GPT53_SPARK_MODEL_ID,
templateIds: [OPENAI_CODEX_GPT53_MODEL_ID],
},
] as const;
function applySyntheticCatalogFallbacks(models: ModelCatalogEntry[]): void {
const findCatalogEntry = (provider: string, id: string) =>
models.find(
(entry) =>
entry.provider.toLowerCase() === provider.toLowerCase() &&
entry.id.toLowerCase() === id.toLowerCase(),
);
for (const fallback of SYNTHETIC_CATALOG_FALLBACKS) {
if (findCatalogEntry(fallback.provider, fallback.id)) {
continue;
}
const template = fallback.templateIds
.map((templateId) => findCatalogEntry(fallback.provider, templateId))
.find((entry) => entry !== undefined);
if (!template) {
continue;
}
models.push({
...template,
id: fallback.id,
name: fallback.id,
});
}
}
function normalizeConfiguredModelInput(input: unknown): ModelInputType[] | undefined {
if (!Array.isArray(input)) {
return undefined;
@@ -256,7 +195,31 @@ export async function loadModelCatalog(params?: {
models.push({ id, name, provider, contextWindow, reasoning, input });
}
mergeConfiguredOptInProviderModels({ config: cfg, models });
applySyntheticCatalogFallbacks(models);
const supplemental = await augmentModelCatalogWithProviderPlugins({
config: cfg,
env: process.env,
context: {
config: cfg,
agentDir,
env: process.env,
entries: [...models],
},
});
if (supplemental.length > 0) {
const seen = new Set(
models.map(
(entry) => `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`,
),
);
for (const entry of supplemental) {
const key = `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`;
if (seen.has(key)) {
continue;
}
models.push(entry);
seen.add(key);
}
}
if (models.length === 0) {
// If we found nothing, don't cache this result so we can try again.

View File

@@ -4,83 +4,18 @@ import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
import { normalizeModelCompat } from "./model-compat.js";
import { normalizeProviderId } from "./model-selection.js";
const OPENAI_GPT_54_MODEL_ID = "gpt-5.4";
const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro";
const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000;
const OPENAI_GPT_54_MAX_TOKENS = 128_000;
const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const;
const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const;
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6";
const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6";
const ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet-4.5"] as const;
const ZAI_GLM5_MODEL_ID = "glm-5";
const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const;
// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not yet in pi-ai's built-in
// google-gemini-cli catalog. Clone the gemini-3-pro/flash-preview template so users
// don't get "Unknown model" errors when Google releases a new minor version.
// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not present in some pi-ai
// Google catalogs yet. Clone the nearest gemini-3 template so users don't get
// "Unknown model" errors when Google ships new minor-version models before pi-ai
// updates its built-in registry.
const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro";
const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash";
const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const;
const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const;
function resolveOpenAIGpt54ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
if (normalizedProvider !== "openai") {
return undefined;
}
const trimmedModelId = modelId.trim();
const lower = trimmedModelId.toLowerCase();
let templateIds: readonly string[];
if (lower === OPENAI_GPT_54_MODEL_ID) {
templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS;
} else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) {
templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS;
} else {
return undefined;
}
return (
cloneFirstTemplateModel({
normalizedProvider,
trimmedModelId,
templateIds: [...templateIds],
modelRegistry,
patch: {
api: "openai-responses",
provider: normalizedProvider,
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text", "image"],
contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS,
maxTokens: OPENAI_GPT_54_MAX_TOKENS,
},
}) ??
normalizeModelCompat({
id: trimmedModelId,
name: trimmedModelId,
api: "openai-responses",
provider: normalizedProvider,
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS,
maxTokens: OPENAI_GPT_54_MAX_TOKENS,
} as Model<Api>)
);
}
function cloneFirstTemplateModel(params: {
normalizedProvider: string;
trimmedModelId: string;
@@ -104,88 +39,6 @@ function cloneFirstTemplateModel(params: {
return undefined;
}
function resolveAnthropic46ForwardCompatModel(params: {
provider: string;
modelId: string;
modelRegistry: ModelRegistry;
dashModelId: string;
dotModelId: string;
dashTemplateId: string;
dotTemplateId: string;
fallbackTemplateIds: readonly string[];
}): Model<Api> | undefined {
const { provider, modelId, modelRegistry, dashModelId, dotModelId } = params;
const normalizedProvider = normalizeProviderId(provider);
if (normalizedProvider !== "anthropic") {
return undefined;
}
const trimmedModelId = modelId.trim();
const lower = trimmedModelId.toLowerCase();
const is46Model =
lower === dashModelId ||
lower === dotModelId ||
lower.startsWith(`${dashModelId}-`) ||
lower.startsWith(`${dotModelId}-`);
if (!is46Model) {
return undefined;
}
const templateIds: string[] = [];
if (lower.startsWith(dashModelId)) {
templateIds.push(lower.replace(dashModelId, params.dashTemplateId));
}
if (lower.startsWith(dotModelId)) {
templateIds.push(lower.replace(dotModelId, params.dotTemplateId));
}
templateIds.push(...params.fallbackTemplateIds);
return cloneFirstTemplateModel({
normalizedProvider,
trimmedModelId,
templateIds,
modelRegistry,
});
}
function resolveAnthropicOpus46ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
return resolveAnthropic46ForwardCompatModel({
provider,
modelId,
modelRegistry,
dashModelId: ANTHROPIC_OPUS_46_MODEL_ID,
dotModelId: ANTHROPIC_OPUS_46_DOT_MODEL_ID,
dashTemplateId: "claude-opus-4-5",
dotTemplateId: "claude-opus-4.5",
fallbackTemplateIds: ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS,
});
}
function resolveAnthropicSonnet46ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
return resolveAnthropic46ForwardCompatModel({
provider,
modelId,
modelRegistry,
dashModelId: ANTHROPIC_SONNET_46_MODEL_ID,
dotModelId: ANTHROPIC_SONNET_46_DOT_MODEL_ID,
dashTemplateId: "claude-sonnet-4-5",
dotTemplateId: "claude-sonnet-4.5",
fallbackTemplateIds: ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS,
});
}
// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not present in some pi-ai
// Google catalogs yet. Clone the nearest gemini-3 template so users don't get
// "Unknown model" errors when Google ships new minor-version models before pi-ai
// updates its built-in registry.
function resolveGoogle31ForwardCompatModel(
provider: string,
modelId: string,
@@ -264,9 +117,6 @@ export function resolveForwardCompatModel(
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
return (
resolveOpenAIGpt54ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveAnthropicSonnet46ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveGoogle31ForwardCompatModel(provider, modelId, modelRegistry)
);

View File

@@ -1,27 +1,32 @@
import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js";
import { normalizeProviderId } from "./model-selection.js";
const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]);
function resolveBuiltInModelSuppression(params: { provider?: string | null; id?: string | null }) {
const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "");
const modelId = params.id?.trim().toLowerCase() ?? "";
if (!provider || !modelId) {
return undefined;
}
return resolveProviderBuiltInModelSuppression({
env: process.env,
context: {
env: process.env,
provider,
modelId,
},
});
}
export function shouldSuppressBuiltInModel(params: {
provider?: string | null;
id?: string | null;
}) {
const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "");
const id = params.id?.trim().toLowerCase() ?? "";
// pi-ai still ships non-Codex Spark rows, but OpenClaw treats Spark as
// Codex-only until upstream availability is proven on direct API paths.
return SUPPRESSED_SPARK_PROVIDERS.has(provider) && id === OPENAI_DIRECT_SPARK_MODEL_ID;
return resolveBuiltInModelSuppression(params)?.suppress ?? false;
}
export function buildSuppressedBuiltInModelError(params: {
provider?: string | null;
id?: string | null;
}): string | undefined {
if (!shouldSuppressBuiltInModel(params)) {
return undefined;
}
const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "") || "openai";
return `Unknown model: ${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}.`;
return resolveBuiltInModelSuppression(params)?.errorMessage;
}

View File

@@ -34,7 +34,7 @@ type InlineProviderConfig = {
headers?: unknown;
};
const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["anthropic", "google-gemini-cli", "openai", "zai"]);
const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["google-gemini-cli", "zai"]);
function sanitizeModelHeaders(
headers: unknown,

View File

@@ -4,6 +4,10 @@ export type {
ProviderDiscoveryContext,
ProviderCatalogContext,
ProviderCatalogResult,
ProviderAugmentModelCatalogContext,
ProviderBuiltInModelSuppressionContext,
ProviderBuiltInModelSuppressionResult,
ProviderBuildMissingAuthMessageContext,
ProviderCacheTtlEligibilityContext,
ProviderFetchUsageSnapshotContext,
ProviderPreparedRuntimeAuth,

View File

@@ -109,6 +109,10 @@ export type {
PluginLogger,
ProviderAuthContext,
ProviderAuthResult,
ProviderAugmentModelCatalogContext,
ProviderBuiltInModelSuppressionContext,
ProviderBuiltInModelSuppressionResult,
ProviderBuildMissingAuthMessageContext,
ProviderCacheTtlEligibilityContext,
ProviderFetchUsageSnapshotContext,
ProviderPreparedRuntimeAuth,

View File

@@ -77,6 +77,22 @@ describe("normalizePluginsConfig", () => {
});
expect(result.entries["voice-call"]?.hooks).toBeUndefined();
});
it("normalizes legacy plugin ids to their merged bundled plugin id", () => {
const result = normalizePluginsConfig({
allow: ["openai-codex"],
deny: ["openai-codex"],
entries: {
"openai-codex": {
enabled: true,
},
},
});
expect(result.allow).toEqual(["openai"]);
expect(result.deny).toEqual(["openai"]);
expect(result.entries.openai?.enabled).toBe(true);
});
});
describe("resolveEffectiveEnableState", () => {

View File

@@ -40,7 +40,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"nvidia",
"ollama",
"openai",
"openai-codex",
"opencode",
"opencode-go",
"openrouter",
@@ -59,11 +58,22 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"zai",
]);
const PLUGIN_ID_ALIASES: Readonly<Record<string, string>> = {
"openai-codex": "openai",
};
function normalizePluginId(id: string): string {
const trimmed = id.trim();
return PLUGIN_ID_ALIASES[trimmed] ?? trimmed;
}
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
return value
.map((entry) => (typeof entry === "string" ? normalizePluginId(entry) : ""))
.filter(Boolean);
};
const normalizeSlotValue = (value: unknown): string | null | undefined => {
@@ -86,11 +96,12 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr
}
const normalized: NormalizedPluginsConfig["entries"] = {};
for (const [key, value] of Object.entries(entries)) {
if (!key.trim()) {
const normalizedKey = normalizePluginId(key);
if (!normalizedKey) {
continue;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
normalized[key] = {};
normalized[normalizedKey] = {};
continue;
}
const entry = value as Record<string, unknown>;
@@ -108,10 +119,12 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr
allowPromptInjection: hooks.allowPromptInjection,
}
: undefined;
normalized[key] = {
enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
hooks: normalizedHooks,
config: "config" in entry ? entry.config : undefined,
normalized[normalizedKey] = {
...normalized[normalizedKey],
enabled:
typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled,
hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks,
config: "config" in entry ? entry.config : normalized[normalizedKey]?.config,
};
}
return normalized;

View File

@@ -8,8 +8,11 @@ vi.mock("./providers.js", () => ({
}));
import {
augmentModelCatalogWithProviderPlugins,
buildProviderMissingAuthMessageWithPlugin,
prepareProviderExtraParams,
resolveProviderCacheTtlEligibility,
resolveProviderBuiltInModelSuppression,
resolveProviderUsageSnapshotWithPlugin,
resolveProviderCapabilitiesWithPlugin,
resolveProviderUsageAuthWithPlugin,
@@ -57,6 +60,7 @@ describe("provider-runtime", () => {
expect.objectContaining({
provider: "Open Router",
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
}),
);
});
@@ -77,31 +81,59 @@ describe("provider-runtime", () => {
displayName: "Demo",
windows: [{ label: "Day", usedPercent: 25 }],
}));
resolvePluginProvidersMock.mockReturnValue([
{
id: "demo",
label: "Demo",
auth: [],
resolveDynamicModel: () => MODEL,
prepareDynamicModel,
capabilities: {
providerFamily: "openai",
resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => {
if (params?.onlyPluginIds?.includes("openai")) {
return [
{
id: "openai",
label: "OpenAI",
auth: [],
buildMissingAuthMessage: () =>
'No API key found for provider "openai". Use openai-codex/gpt-5.4.',
suppressBuiltInModel: ({ provider, modelId }) =>
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-codex", id: "gpt-5.4", name: "gpt-5.4" },
{
provider: "openai-codex",
id: "gpt-5.3-codex-spark",
name: "gpt-5.3-codex-spark",
},
],
},
];
}
return [
{
id: "demo",
label: "Demo",
auth: [],
resolveDynamicModel: () => MODEL,
prepareDynamicModel,
capabilities: {
providerFamily: "openai",
},
prepareExtraParams: ({ extraParams }) => ({
...extraParams,
transport: "auto",
}),
wrapStreamFn: ({ streamFn }) => streamFn,
normalizeResolvedModel: ({ model }) => ({
...model,
api: "openai-codex-responses",
}),
prepareRuntimeAuth,
resolveUsageAuth,
fetchUsageSnapshot,
isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"),
},
prepareExtraParams: ({ extraParams }) => ({
...extraParams,
transport: "auto",
}),
wrapStreamFn: ({ streamFn }) => streamFn,
normalizeResolvedModel: ({ model }) => ({
...model,
api: "openai-codex-responses",
}),
prepareRuntimeAuth,
resolveUsageAuth,
fetchUsageSnapshot,
isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"),
},
]);
];
});
expect(
runProviderDynamicModel({
@@ -234,6 +266,60 @@ describe("provider-runtime", () => {
}),
).toBe(true);
expect(
buildProviderMissingAuthMessageWithPlugin({
provider: "openai",
env: process.env,
context: {
env: process.env,
provider: "openai",
listProfileIds: (providerId) => (providerId === "openai-codex" ? ["p1"] : []),
},
}),
).toContain("openai-codex/gpt-5.4");
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("openai-codex/gpt-5.3-codex-spark"),
});
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).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["openai"],
}),
);
expect(prepareDynamicModel).toHaveBeenCalledTimes(1);
expect(prepareRuntimeAuth).toHaveBeenCalledTimes(1);
expect(resolveUsageAuth).toHaveBeenCalledTimes(1);

View File

@@ -2,6 +2,9 @@ import { normalizeProviderId } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolvePluginProviders } from "./providers.js";
import type {
ProviderAugmentModelCatalogContext,
ProviderBuildMissingAuthMessageContext,
ProviderBuiltInModelSuppressionContext,
ProviderCacheTtlEligibilityContext,
ProviderFetchUsageSnapshotContext,
ProviderPrepareExtraParamsContext,
@@ -25,16 +28,41 @@ function matchesProviderId(provider: ProviderPlugin, providerId: string): boolea
return (provider.aliases ?? []).some((alias) => normalizeProviderId(alias) === normalized);
}
function resolveProviderPluginsForHooks(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
}): ProviderPlugin[] {
return resolvePluginProviders({
...params,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
}
const GLOBAL_PROVIDER_HOOK_PLUGIN_IDS = ["openai"] as const;
function resolveGlobalProviderHookPlugins(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin[] {
return resolveProviderPluginsForHooks({
...params,
onlyPluginIds: [...GLOBAL_PROVIDER_HOOK_PLUGIN_IDS],
});
}
export function resolveProviderRuntimePlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin | undefined {
return resolvePluginProviders({
...params,
bundledProviderAllowlistCompat: true,
}).find((plugin) => matchesProviderId(plugin, params.provider));
return resolveProviderPluginsForHooks(params).find((plugin) =>
matchesProviderId(plugin, params.provider),
);
}
export function runProviderDynamicModel(params: {
@@ -144,3 +172,48 @@ export function resolveProviderCacheTtlEligibility(params: {
}) {
return resolveProviderRuntimePlugin(params)?.isCacheTtlEligible?.(params.context);
}
export function buildProviderMissingAuthMessageWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderBuildMissingAuthMessageContext;
}) {
const plugin = resolveGlobalProviderHookPlugins(params).find((providerPlugin) =>
matchesProviderId(providerPlugin, params.provider),
);
return plugin?.buildMissingAuthMessage?.(params.context) ?? undefined;
}
export function resolveProviderBuiltInModelSuppression(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderBuiltInModelSuppressionContext;
}) {
for (const plugin of resolveGlobalProviderHookPlugins(params)) {
const result = plugin.suppressBuiltInModel?.(params.context);
if (result?.suppress) {
return result;
}
}
return undefined;
}
export async function augmentModelCatalogWithProviderPlugins(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderAugmentModelCatalogContext;
}) {
const supplemental = [] as ProviderAugmentModelCatalogContext["entries"];
for (const plugin of resolveGlobalProviderHookPlugins(params)) {
const next = await plugin.augmentModelCatalog?.(params.context);
if (!next || next.length === 0) {
continue;
}
supplemental.push(...next);
}
return supplemental;
}

View File

@@ -52,4 +52,22 @@ describe("resolvePluginProviders", () => {
}),
);
});
it("can enable bundled provider plugins under Vitest when no explicit plugin config exists", () => {
resolvePluginProviders({
env: { VITEST: "1" } as NodeJS.ProcessEnv,
bundledProviderVitestCompat: true,
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
enabled: true,
allow: expect.arrayContaining(["openai", "moonshot", "zai"]),
}),
}),
}),
);
});
});

View File

@@ -22,7 +22,6 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [
"nvidia",
"ollama",
"openai",
"openai-codex",
"opencode",
"opencode-go",
"openrouter",
@@ -39,6 +38,32 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [
"zai",
] as const;
function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean {
const plugins = config?.plugins;
if (!plugins) {
return false;
}
if (typeof plugins.enabled === "boolean") {
return true;
}
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
return true;
}
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
return true;
}
if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) {
return true;
}
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
return true;
}
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
return true;
}
return false;
}
function withBundledProviderAllowlistCompat(
config: PluginLoadOptions["config"],
): PluginLoadOptions["config"] {
@@ -71,20 +96,52 @@ function withBundledProviderAllowlistCompat(
};
}
function withBundledProviderVitestCompat(params: {
config: PluginLoadOptions["config"];
env?: PluginLoadOptions["env"];
}): PluginLoadOptions["config"] {
const env = params.env ?? process.env;
if (!env.VITEST || hasExplicitPluginConfig(params.config)) {
return params.config;
}
return {
...params.config,
plugins: {
...params.config?.plugins,
enabled: true,
allow: [...BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS],
slots: {
...params.config?.plugins?.slots,
memory: "none",
},
},
};
}
export function resolvePluginProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
/** Use an explicit env when plugin roots should resolve independently from process.env. */
env?: PluginLoadOptions["env"];
bundledProviderAllowlistCompat?: boolean;
bundledProviderVitestCompat?: boolean;
onlyPluginIds?: string[];
}): ProviderPlugin[] {
const config = params.bundledProviderAllowlistCompat
const maybeAllowlistCompat = params.bundledProviderAllowlistCompat
? withBundledProviderAllowlistCompat(params.config)
: params.config;
const config = params.bundledProviderVitestCompat
? withBundledProviderVitestCompat({
config: maybeAllowlistCompat,
env: params.env,
})
: maybeAllowlistCompat;
const registry = loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,
env: params.env,
onlyPluginIds: params.onlyPluginIds,
logger: createPluginLoaderLogger(log),
});

View File

@@ -10,6 +10,7 @@ import type {
AuthProfileCredential,
OAuthCredential,
} from "../agents/auth-profiles/types.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import type { ProviderCapabilities } from "../agents/provider-capabilities.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
@@ -390,6 +391,59 @@ export type ProviderCacheTtlEligibilityContext = {
modelId: string;
};
/**
* Provider-owned missing-auth message override.
*
* Runs only after OpenClaw exhausts normal env/profile/config auth resolution
* for the requested provider. Return a custom message to replace the generic
* "No API key found" error.
*/
export type ProviderBuildMissingAuthMessageContext = {
config?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
provider: string;
listProfileIds: (providerId: string) => string[];
};
/**
* Built-in model suppression hook.
*
* Use this when a provider/plugin needs to hide stale upstream catalog rows or
* replace them with a vendor-specific hint. This hook is consulted by model
* resolution, model listing, and catalog loading.
*/
export type ProviderBuiltInModelSuppressionContext = {
config?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
provider: string;
modelId: string;
};
export type ProviderBuiltInModelSuppressionResult = {
suppress: boolean;
errorMessage?: string;
};
/**
* Final catalog augmentation hook.
*
* Runs after OpenClaw loads the discovered model catalog and merges configured
* opt-in providers. Use this for forward-compat rows or vendor-owned synthetic
* entries that should appear in `models list` and model pickers even when the
* upstream registry has not caught up yet.
*/
export type ProviderAugmentModelCatalogContext = {
config?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
entries: ModelCatalogEntry[];
};
/**
* @deprecated Use ProviderCatalogOrder.
*/
@@ -560,6 +614,40 @@ export type ProviderPlugin = {
* only a subset of upstream models.
*/
isCacheTtlEligible?: (ctx: ProviderCacheTtlEligibilityContext) => boolean | undefined;
/**
* Provider-owned missing-auth message override.
*
* Return a custom message when the provider wants a more specific recovery
* hint than OpenClaw's generic auth-store guidance.
*/
buildMissingAuthMessage?: (
ctx: ProviderBuildMissingAuthMessageContext,
) => string | null | undefined;
/**
* Provider-owned built-in model suppression.
*
* Return `{ suppress: true }` to hide a stale upstream row. Include
* `errorMessage` when OpenClaw should surface a provider-specific hint for
* direct model resolution failures.
*/
suppressBuiltInModel?: (
ctx: ProviderBuiltInModelSuppressionContext,
) => ProviderBuiltInModelSuppressionResult | null | undefined;
/**
* Provider-owned final catalog augmentation.
*
* Return extra rows to append to the final catalog after discovery/config
* merging. OpenClaw deduplicates by `provider/id`, so plugins only need to
* describe the desired supplemental rows.
*/
augmentModelCatalog?: (
ctx: ProviderAugmentModelCatalogContext,
) =>
| Array<ModelCatalogEntry>
| ReadonlyArray<ModelCatalogEntry>
| Promise<Array<ModelCatalogEntry> | ReadonlyArray<ModelCatalogEntry> | null | undefined>
| null
| undefined;
wizard?: ProviderPluginWizard;
formatApiKey?: (cred: AuthProfileCredential) => string;
refreshOAuth?: (cred: OAuthCredential) => Promise<OAuthCredential>;