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
No known key found for this signature in database
13 changed files with 599 additions and 11 deletions

View File

@ -46,6 +46,8 @@ Use it for:
- config validation
- auth and onboarding metadata that should be available without booting plugin
runtime
- shorthand model-family ownership metadata that should auto-activate the
plugin before runtime loads
- static capability ownership snapshots used for bundled compat wiring and
contract coverage
- config UI hints
@ -80,6 +82,9 @@ Those belong in your plugin code and `package.json`.
"description": "OpenRouter provider plugin",
"version": "1.0.0",
"providers": ["openrouter"],
"modelSupport": {
"modelPrefixes": ["router-"]
},
"cliBackends": ["openrouter-cli"],
"providerAuthEnvVars": {
"openrouter": ["OPENROUTER_API_KEY"]
@ -128,6 +133,7 @@ Those belong in your plugin code and `package.json`.
| `kind` | No | `"memory"` \| `"context-engine"` | Declares an exclusive plugin kind used by `plugins.slots.*`. |
| `channels` | No | `string[]` | Channel ids owned by this plugin. Used for discovery and config validation. |
| `providers` | No | `string[]` | Provider ids owned by this plugin. |
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. |
@ -218,6 +224,36 @@ Each list is optional:
| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. |
| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. |
## modelSupport reference
Use `modelSupport` when OpenClaw should infer your provider plugin from
shorthand model ids like `gpt-5.4` or `claude-sonnet-4.6` before plugin runtime
loads.
```json
{
"modelSupport": {
"modelPrefixes": ["gpt-", "o1", "o3", "o4"],
"modelPatterns": ["^computer-use-preview"]
}
}
```
OpenClaw applies this precedence:
- explicit `provider/model` refs use the owning `providers` manifest metadata
- `modelPatterns` beat `modelPrefixes`
- if one non-bundled plugin and one bundled plugin both match, the non-bundled
plugin wins
- remaining ambiguity is ignored until the user or config specifies a provider
Fields:
| Field | Type | What it means |
| --------------- | ---------- | ------------------------------------------------------------------------------- |
| `modelPrefixes` | `string[]` | Prefixes matched with `startsWith` against shorthand model ids. |
| `modelPatterns` | `string[]` | Regex sources matched against shorthand model ids after profile suffix removal. |
Legacy top-level `speechProviders`, `mediaUnderstandingProviders`, and
`imageGenerationProviders` are deprecated. Use `openclaw doctor --fix` to move
them under `contracts`; normal manifest loading no longer treats them as

View File

@ -52,6 +52,9 @@ API key auth, and dynamic model resolution.
"name": "Acme AI",
"description": "Acme AI model provider",
"providers": ["acme-ai"],
"modelSupport": {
"modelPrefixes": ["acme-"]
},
"providerAuthEnvVars": {
"acme-ai": ["ACME_AI_API_KEY"]
},
@ -77,7 +80,9 @@ API key auth, and dynamic model resolution.
</CodeGroup>
The manifest declares `providerAuthEnvVars` so OpenClaw can detect
credentials without loading your plugin runtime. If you publish the
credentials without loading your plugin runtime. `modelSupport` is optional
and lets OpenClaw auto-load your provider plugin from shorthand model ids
like `acme-large` before runtime hooks exist. If you publish the
provider on ClawHub, those `openclaw.compat` and `openclaw.build` fields
are required in `package.json`.

View File

@ -2,6 +2,9 @@
"id": "anthropic",
"enabledByDefault": true,
"providers": ["anthropic"],
"modelSupport": {
"modelPrefixes": ["claude-"]
},
"cliBackends": ["claude-cli"],
"providerAuthEnvVars": {
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]

View File

@ -2,6 +2,9 @@
"id": "openai",
"enabledByDefault": true,
"providers": ["openai", "openai-codex"],
"modelSupport": {
"modelPrefixes": ["gpt-", "o1", "o3", "o4"]
},
"cliBackends": ["codex-cli"],
"providerAuthEnvVars": {
"openai": ["OPENAI_API_KEY"]

View File

@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
function makeRegistry(
plugins: Array<{
id: string;
modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] };
}>,
): PluginManifestRegistry {
return {
plugins: plugins.map((plugin) => ({
id: plugin.id,
channels: [],
providers: [],
modelSupport: plugin.modelSupport,
cliBackends: [],
skills: [],
hooks: [],
origin: "config" as const,
rootDir: `/fake/${plugin.id}`,
source: `/fake/${plugin.id}/index.js`,
manifestPath: `/fake/${plugin.id}/openclaw.plugin.json`,
})),
diagnostics: [],
};
}
describe("applyPluginAutoEnable modelSupport", () => {
it("auto-enables provider plugins from shorthand modelSupport ownership", () => {
const result = applyPluginAutoEnable({
config: {
agents: {
defaults: {
model: "gpt-5.4",
},
},
},
env: {},
manifestRegistry: makeRegistry([
{
id: "openai",
modelSupport: {
modelPrefixes: ["gpt-", "o1", "o3", "o4"],
},
},
]),
});
expect(result.config.plugins?.entries?.openai?.enabled).toBe(true);
expect(result.changes).toContain("gpt-5.4 model configured, enabled automatically.");
});
it("skips ambiguous shorthand model ownership during auto-enable", () => {
const result = applyPluginAutoEnable({
config: {
agents: {
defaults: {
model: "gpt-5.4",
},
},
},
env: {},
manifestRegistry: makeRegistry([
{
id: "openai",
modelSupport: {
modelPrefixes: ["gpt-"],
},
},
{
id: "proxy-openai",
modelSupport: {
modelPrefixes: ["gpt-"],
},
},
]),
});
expect(result.config.plugins?.entries?.openai).toBeUndefined();
expect(result.config.plugins?.entries?.["proxy-openai"]).toBeUndefined();
expect(result.changes).toEqual([]);
});
});

View File

@ -48,6 +48,7 @@ function makeRegistry(
id: string;
channels: string[];
autoEnableWhenConfiguredProviders?: string[];
modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] };
contracts?: { webFetchProviders?: string[] };
channelConfigs?: Record<string, { schema: Record<string, unknown>; preferOver?: string[] }>;
}>,
@ -57,6 +58,7 @@ function makeRegistry(
id: p.id,
channels: p.channels,
autoEnableWhenConfiguredProviders: p.autoEnableWhenConfiguredProviders,
modelSupport: p.modelSupport,
contracts: p.contracts,
channelConfigs: p.channelConfigs,
providers: [],

View File

@ -15,6 +15,7 @@ import {
loadPluginManifestRegistry,
type PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import { resolveOwningPluginIdsForModelRef } from "../plugins/providers.js";
import { isRecord, resolveConfigDir, resolveUserPath } from "../utils.js";
import { isChannelConfigured } from "./channel-configured.js";
import type { OpenClawConfig } from "./config.js";
@ -37,6 +38,11 @@ export type PluginAutoEnableCandidate =
kind: "provider-auth-configured";
providerId: string;
}
| {
pluginId: string;
kind: "provider-model-configured";
modelRef: string;
}
| {
pluginId: string;
kind: "web-fetch-provider-selected";
@ -382,6 +388,15 @@ function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean {
}
function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) {
return true;
}
if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) {
return true;
}
if (collectModelRefs(cfg).length > 0) {
return true;
}
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
if (!configuredChannels || typeof configuredChannels !== "object") {
return false;
@ -506,6 +521,8 @@ export function resolvePluginAutoEnableCandidateReason(
break;
case "provider-auth-configured":
return `${candidate.providerId} auth configured`;
case "provider-model-configured":
return `${candidate.modelRef} model configured`;
case "web-fetch-provider-selected":
return `${candidate.providerId} web fetch provider selected`;
case "plugin-web-search-configured":
@ -550,6 +567,22 @@ function resolveConfiguredPlugins(
});
}
}
for (const modelRef of collectModelRefs(cfg)) {
const owningPluginIds = resolveOwningPluginIdsForModelRef({
model: modelRef,
config: cfg,
env,
manifestRegistry: registry,
});
if (owningPluginIds?.length !== 1) {
continue;
}
changes.push({
pluginId: owningPluginIds[0],
kind: "provider-model-configured",
modelRef,
});
}
const webFetchProvider =
typeof cfg.tools?.web?.fetch?.provider === "string" ? cfg.tools.web.fetch.provider : undefined;
const webFetchPluginId = resolvePluginIdForConfiguredWebFetchProvider(webFetchProvider);

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;