mirror of https://github.com/openclaw/openclaw.git
feat(plugins): auto-load provider plugins from model support
This commit is contained in:
parent
5b144655f2
commit
fff7e610df
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
"id": "anthropic",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["anthropic"],
|
||||
"modelSupport": {
|
||||
"modelPrefixes": ["claude-"]
|
||||
},
|
||||
"cliBackends": ["claude-cli"],
|
||||
"providerAuthEnvVars": {
|
||||
"anthropic": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue