feat(plugins): auto-load provider plugins from model support

This commit is contained in:
Peter Steinberger
2026-04-04 04:52:14 +01:00
parent 5b144655f2
commit fff7e610df
13 changed files with 599 additions and 11 deletions

View File

@@ -15,6 +15,7 @@ import {
type PluginManifest,
type PluginManifestChannelConfig,
type PluginManifestContracts,
type PluginManifestModelSupport,
} from "./manifest.js";
import { checkMinHostVersion } from "./min-host-version.js";
import { isPathInside, safeRealpathSync } from "./path-safety.js";
@@ -56,6 +57,7 @@ export type PluginManifestRecord = {
kind?: PluginKind | PluginKind[];
channels: string[];
providers: string[];
modelSupport?: PluginManifestModelSupport;
cliBackends: string[];
providerAuthEnvVars?: Record<string, string[]>;
providerAuthChoices?: PluginManifest["providerAuthChoices"];
@@ -216,6 +218,7 @@ function buildRecord(params: {
kind: params.manifest.kind,
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
modelSupport: params.manifest.modelSupport,
cliBackends: params.manifest.cliBackends ?? [],
providerAuthEnvVars: params.manifest.providerAuthEnvVars,
providerAuthChoices: params.manifest.providerAuthChoices,

View File

@@ -81,6 +81,27 @@ describe("loadPluginManifest JSON5 tolerance", () => {
}
});
it("normalizes modelSupport metadata from the manifest", () => {
const dir = makeTempDir();
const json5Content = `{
id: "provider-plugin",
modelSupport: {
modelPrefixes: ["gpt-", "", "claude-"],
modelPatterns: ["^o[0-9].*", ""],
},
configSchema: { type: "object" }
}`;
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), json5Content, "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.modelSupport).toEqual({
modelPrefixes: ["gpt-", "claude-"],
modelPatterns: ["^o[0-9].*"],
});
}
});
it("still rejects completely invalid syntax", () => {
const dir = makeTempDir();
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), "not json at all {{{}}", "utf-8");

View File

@@ -17,6 +17,19 @@ export type PluginManifestChannelConfig = {
preferOver?: string[];
};
export type PluginManifestModelSupport = {
/**
* Cheap manifest-owned model-id prefixes for transparent provider activation
* from shorthand model refs such as `gpt-5.4` or `claude-sonnet-4.6`.
*/
modelPrefixes?: string[];
/**
* Regex sources matched against the raw model id after profile suffixes are
* stripped. Use this when simple prefixes are not expressive enough.
*/
modelPatterns?: string[];
};
export type PluginManifest = {
id: string;
configSchema: Record<string, unknown>;
@@ -28,6 +41,11 @@ export type PluginManifest = {
kind?: PluginKind | PluginKind[];
channels?: string[];
providers?: string[];
/**
* Cheap model-family ownership metadata used before plugin runtime loads.
* Use this for shorthand model refs that omit an explicit provider prefix.
*/
modelSupport?: PluginManifestModelSupport;
/** Cheap startup activation lookup for plugin-owned CLI inference backends. */
cliBackends?: string[];
/** Cheap provider-auth env lookup without booting plugin runtime. */
@@ -148,6 +166,21 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u
return Object.keys(contracts).length > 0 ? contracts : undefined;
}
function normalizeManifestModelSupport(value: unknown): PluginManifestModelSupport | undefined {
if (!isRecord(value)) {
return undefined;
}
const modelPrefixes = normalizeStringList(value.modelPrefixes);
const modelPatterns = normalizeStringList(value.modelPatterns);
const modelSupport = {
...(modelPrefixes.length > 0 ? { modelPrefixes } : {}),
...(modelPatterns.length > 0 ? { modelPatterns } : {}),
} satisfies PluginManifestModelSupport;
return Object.keys(modelSupport).length > 0 ? modelSupport : undefined;
}
function normalizeProviderAuthChoices(
value: unknown,
): PluginManifestProviderAuthChoice[] | undefined {
@@ -313,6 +346,7 @@ export function loadPluginManifest(
const version = typeof raw.version === "string" ? raw.version.trim() : undefined;
const channels = normalizeStringList(raw.channels);
const providers = normalizeStringList(raw.providers);
const modelSupport = normalizeManifestModelSupport(raw.modelSupport);
const cliBackends = normalizeStringList(raw.cliBackends);
const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars);
const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices);
@@ -338,6 +372,7 @@ export function loadPluginManifest(
kind,
channels,
providers,
modelSupport,
cliBackends,
providerAuthEnvVars,
providerAuthChoices,

View File

@@ -5,12 +5,45 @@ import { createPluginLoaderLogger } from "./logger.js";
import {
resolveEnabledProviderPluginIds,
resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForModelRefs,
withBundledProviderVitestCompat,
} from "./providers.js";
import type { ProviderPlugin } from "./types.js";
const log = createSubsystemLogger("plugins");
function withRuntimeActivatedPluginIds(params: {
config?: PluginLoadOptions["config"];
pluginIds: readonly string[];
}): PluginLoadOptions["config"] {
if (params.pluginIds.length === 0) {
return params.config;
}
const allow = new Set(params.config?.plugins?.allow ?? []);
const entries = {
...params.config?.plugins?.entries,
};
for (const pluginId of params.pluginIds) {
const normalized = pluginId.trim();
if (!normalized) {
continue;
}
allow.add(normalized);
entries[normalized] = {
...entries[normalized],
enabled: true,
};
}
return {
...params.config,
plugins: {
...params.config?.plugins,
...(allow.size > 0 ? { allow: [...allow] } : {}),
entries,
},
};
}
export function resolvePluginProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
@@ -19,16 +52,33 @@ export function resolvePluginProviders(params: {
bundledProviderAllowlistCompat?: boolean;
bundledProviderVitestCompat?: boolean;
onlyPluginIds?: string[];
modelRefs?: readonly string[];
activate?: boolean;
cache?: boolean;
pluginSdkResolution?: PluginLoadOptions["pluginSdkResolution"];
}): ProviderPlugin[] {
const env = params.env ?? process.env;
const modelOwnedPluginIds = params.modelRefs?.length
? resolveOwningPluginIdsForModelRefs({
models: params.modelRefs,
config: params.config,
workspaceDir: params.workspaceDir,
env,
})
: [];
const requestedPluginIds =
params.onlyPluginIds || modelOwnedPluginIds.length > 0
? [...new Set([...(params.onlyPluginIds ?? []), ...modelOwnedPluginIds])]
: undefined;
const runtimeConfig = withRuntimeActivatedPluginIds({
config: params.config,
pluginIds: modelOwnedPluginIds,
});
const activation = resolveBundledPluginCompatibleActivationInputs({
rawConfig: params.config,
rawConfig: runtimeConfig,
env,
workspaceDir: params.workspaceDir,
onlyPluginIds: params.onlyPluginIds,
onlyPluginIds: requestedPluginIds,
applyAutoEnable: true,
compatMode: {
allowlist: params.bundledProviderAllowlistCompat,
@@ -48,7 +98,7 @@ export function resolvePluginProviders(params: {
config,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: params.onlyPluginIds,
onlyPluginIds: requestedPluginIds,
});
const registry = resolveRuntimePluginRegistry({
config,

View File

@@ -15,18 +15,21 @@ const loadPluginManifestRegistryMock = vi.fn<LoadPluginManifestRegistry>();
const applyPluginAutoEnableMock = vi.fn<ApplyPluginAutoEnable>();
let resolveOwningPluginIdsForProvider: typeof import("./providers.js").resolveOwningPluginIdsForProvider;
let resolveOwningPluginIdsForModelRef: typeof import("./providers.js").resolveOwningPluginIdsForModelRef;
let resolvePluginProviders: typeof import("./providers.runtime.js").resolvePluginProviders;
function createManifestProviderPlugin(params: {
id: string;
providerIds: string[];
origin?: "bundled" | "workspace";
modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] };
}): PluginManifestRecord {
return {
id: params.id,
channels: [],
cliBackends: [],
providers: params.providerIds,
modelSupport: params.modelSupport,
skills: [],
hooks: [],
origin: params.origin ?? "bundled",
@@ -52,6 +55,47 @@ function setOwningProviderManifestPlugins() {
createManifestProviderPlugin({
id: "openai",
providerIds: ["openai", "openai-codex"],
modelSupport: {
modelPrefixes: ["gpt-", "o1", "o3", "o4"],
},
}),
createManifestProviderPlugin({
id: "anthropic",
providerIds: ["anthropic"],
modelSupport: {
modelPrefixes: ["claude-"],
},
}),
]);
}
function setOwningProviderManifestPluginsWithWorkspace() {
setManifestPlugins([
createManifestProviderPlugin({
id: "minimax",
providerIds: ["minimax", "minimax-portal"],
}),
createManifestProviderPlugin({
id: "openai",
providerIds: ["openai", "openai-codex"],
modelSupport: {
modelPrefixes: ["gpt-", "o1", "o3", "o4"],
},
}),
createManifestProviderPlugin({
id: "anthropic",
providerIds: ["anthropic"],
modelSupport: {
modelPrefixes: ["claude-"],
},
}),
createManifestProviderPlugin({
id: "workspace-provider",
providerIds: ["workspace-provider"],
origin: "workspace",
modelSupport: {
modelPrefixes: ["workspace-model-"],
},
}),
]);
}
@@ -158,6 +202,10 @@ function expectOwningPluginIds(provider: string, expectedPluginIds?: readonly st
expect(resolveOwningPluginIdsForProvider({ provider })).toEqual(expectedPluginIds);
}
function expectModelOwningPluginIds(model: string, expectedPluginIds?: readonly string[]) {
expect(resolveOwningPluginIdsForModelRef({ model })).toEqual(expectedPluginIds);
}
function expectProviderRuntimeRegistryLoad(params?: { config?: unknown; env?: NodeJS.ProcessEnv }) {
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -182,7 +230,8 @@ describe("resolvePluginProviders", () => {
loadPluginManifestRegistry: (...args: Parameters<LoadPluginManifestRegistry>) =>
loadPluginManifestRegistryMock(...args),
}));
({ resolveOwningPluginIdsForProvider } = await import("./providers.js"));
({ resolveOwningPluginIdsForProvider, resolveOwningPluginIdsForModelRef } =
await import("./providers.js"));
({ resolvePluginProviders } = await import("./providers.runtime.js"));
});
@@ -215,6 +264,9 @@ describe("resolvePluginProviders", () => {
id: "workspace-provider",
providerIds: ["workspace-provider"],
origin: "workspace",
modelSupport: {
modelPrefixes: ["workspace-model-"],
},
}),
]);
});
@@ -433,4 +485,112 @@ describe("resolvePluginProviders", () => {
expectOwningPluginIds(provider, expectedPluginIds);
},
);
it.each([
{
model: "gpt-5.4",
expectedPluginIds: ["openai"],
},
{
model: "claude-sonnet-4-6",
expectedPluginIds: ["anthropic"],
},
{
model: "openai/gpt-5.4",
expectedPluginIds: ["openai"],
},
{
model: "workspace-model-fast",
expectedPluginIds: ["workspace-provider"],
},
{
model: "unknown-model",
expectedPluginIds: undefined,
},
] as const)(
"maps $model to owning plugin ids via modelSupport",
({ model, expectedPluginIds }) => {
setOwningProviderManifestPluginsWithWorkspace();
expectModelOwningPluginIds(model, expectedPluginIds);
},
);
it("refuses ambiguous bundled shorthand model ownership", () => {
setManifestPlugins([
createManifestProviderPlugin({
id: "openai",
providerIds: ["openai"],
modelSupport: { modelPrefixes: ["gpt-"] },
}),
createManifestProviderPlugin({
id: "proxy-openai",
providerIds: ["proxy-openai"],
modelSupport: { modelPrefixes: ["gpt-"] },
}),
]);
expectModelOwningPluginIds("gpt-5.4", undefined);
});
it("prefers non-bundled shorthand model ownership over bundled matches", () => {
setManifestPlugins([
createManifestProviderPlugin({
id: "openai",
providerIds: ["openai"],
modelSupport: { modelPrefixes: ["gpt-"] },
}),
createManifestProviderPlugin({
id: "workspace-openai",
providerIds: ["workspace-openai"],
origin: "workspace",
modelSupport: { modelPrefixes: ["gpt-"] },
}),
]);
expectModelOwningPluginIds("gpt-5.4", ["workspace-openai"]);
});
it("auto-loads a model-owned provider plugin from shorthand model refs", () => {
setManifestPlugins([
createManifestProviderPlugin({
id: "openai",
providerIds: ["openai", "openai-codex"],
modelSupport: {
modelPrefixes: ["gpt-", "o1", "o3", "o4"],
},
}),
]);
const provider: ProviderPlugin = {
id: "openai",
label: "OpenAI",
auth: [],
};
const registry = createEmptyPluginRegistry();
registry.providers.push({ pluginId: "openai", provider, source: "bundled" });
resolveRuntimePluginRegistryMock.mockReturnValue(registry);
const providers = resolvePluginProviders({
config: {},
modelRefs: ["gpt-5.4"],
bundledProviderAllowlistCompat: true,
});
expectResolvedProviders(providers, [
{ id: "openai", label: "OpenAI", auth: [], pluginId: "openai" },
]);
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["openai"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["openai"],
entries: {
openai: { enabled: true },
},
}),
}),
}),
);
});
});

View File

@@ -2,7 +2,11 @@ import { normalizeProviderId } from "../agents/provider-id.js";
import { withBundledPluginVitestCompat } from "./bundled-compat.js";
import { normalizePluginsConfig, resolveEffectivePluginActivationState } from "./config-state.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import {
loadPluginManifestRegistry,
type PluginManifestRecord,
type PluginManifestRegistry,
} from "./manifest-registry.js";
export function withBundledProviderVitestCompat(params: {
config: PluginLoadOptions["config"];
@@ -70,22 +74,112 @@ export const __testing = {
withBundledProviderVitestCompat,
} as const;
type ModelSupportMatchKind = "pattern" | "prefix";
function resolveManifestRegistry(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
manifestRegistry?: PluginManifestRegistry;
}): PluginManifestRegistry {
return (
params.manifestRegistry ??
loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
);
}
function stripModelProfileSuffix(value: string): string {
const trimmed = value.trim();
const at = trimmed.indexOf("@");
return at <= 0 ? trimmed : trimmed.slice(0, at).trim();
}
function splitExplicitModelRef(rawModel: string): { provider?: string; modelId: string } | null {
const trimmed = rawModel.trim();
if (!trimmed) {
return null;
}
const slash = trimmed.indexOf("/");
if (slash === -1) {
const modelId = stripModelProfileSuffix(trimmed);
return modelId ? { modelId } : null;
}
const provider = normalizeProviderId(trimmed.slice(0, slash));
const modelId = stripModelProfileSuffix(trimmed.slice(slash + 1));
if (!provider || !modelId) {
return null;
}
return { provider, modelId };
}
function resolveModelSupportMatchKind(
plugin: PluginManifestRecord,
modelId: string,
): ModelSupportMatchKind | undefined {
const patterns = plugin.modelSupport?.modelPatterns ?? [];
for (const patternSource of patterns) {
try {
if (new RegExp(patternSource, "u").test(modelId)) {
return "pattern";
}
} catch {
continue;
}
}
const prefixes = plugin.modelSupport?.modelPrefixes ?? [];
for (const prefix of prefixes) {
if (modelId.startsWith(prefix)) {
return "prefix";
}
}
return undefined;
}
function dedupeSortedPluginIds(values: Iterable<string>): string[] {
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
}
function resolvePreferredManifestPluginIds(
registry: PluginManifestRegistry,
matchedPluginIds: readonly string[],
): string[] | undefined {
if (matchedPluginIds.length === 0) {
return undefined;
}
const uniquePluginIds = dedupeSortedPluginIds(matchedPluginIds);
if (uniquePluginIds.length <= 1) {
return uniquePluginIds;
}
const nonBundledPluginIds = uniquePluginIds.filter((pluginId) => {
const plugin = registry.plugins.find((entry) => entry.id === pluginId);
return plugin?.origin !== "bundled";
});
if (nonBundledPluginIds.length === 1) {
return nonBundledPluginIds;
}
if (nonBundledPluginIds.length > 1) {
return undefined;
}
return undefined;
}
export function resolveOwningPluginIdsForProvider(params: {
provider: string;
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
manifestRegistry?: PluginManifestRegistry;
}): string[] | undefined {
const normalizedProvider = normalizeProviderId(params.provider);
if (!normalizedProvider) {
return undefined;
}
const registry = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const registry = resolveManifestRegistry(params);
const pluginIds = registry.plugins
.filter((plugin) =>
plugin.providers.some((providerId) => normalizeProviderId(providerId) === normalizedProvider),
@@ -95,6 +189,65 @@ export function resolveOwningPluginIdsForProvider(params: {
return pluginIds.length > 0 ? pluginIds : undefined;
}
export function resolveOwningPluginIdsForModelRef(params: {
model: string;
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
manifestRegistry?: PluginManifestRegistry;
}): string[] | undefined {
const parsed = splitExplicitModelRef(params.model);
if (!parsed) {
return undefined;
}
if (parsed.provider) {
return resolveOwningPluginIdsForProvider({
provider: parsed.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
manifestRegistry: params.manifestRegistry,
});
}
const registry = resolveManifestRegistry(params);
const matchedByPattern = registry.plugins
.filter((plugin) => resolveModelSupportMatchKind(plugin, parsed.modelId) === "pattern")
.map((plugin) => plugin.id);
const preferredPatternPluginIds = resolvePreferredManifestPluginIds(registry, matchedByPattern);
if (preferredPatternPluginIds) {
return preferredPatternPluginIds;
}
const matchedByPrefix = registry.plugins
.filter((plugin) => resolveModelSupportMatchKind(plugin, parsed.modelId) === "prefix")
.map((plugin) => plugin.id);
return resolvePreferredManifestPluginIds(registry, matchedByPrefix);
}
export function resolveOwningPluginIdsForModelRefs(params: {
models: readonly string[];
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
manifestRegistry?: PluginManifestRegistry;
}): string[] {
const registry = resolveManifestRegistry(params);
return dedupeSortedPluginIds(
params.models.flatMap(
(model) =>
resolveOwningPluginIdsForModelRef({
model,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
manifestRegistry: registry,
}) ?? [],
),
);
}
export function resolveNonBundledProviderPluginIds(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;