diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 881c9615075..5445bb9fa12 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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` | 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 diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index 24692684390..7a879fb6d65 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -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. 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`. diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index d7afcbbb3ce..21af2607d39 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.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"] diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 17ad0e97879..457e58cea6b 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -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"] diff --git a/src/config/plugin-auto-enable.model-support.test.ts b/src/config/plugin-auto-enable.model-support.test.ts new file mode 100644 index 00000000000..81a40df7a97 --- /dev/null +++ b/src/config/plugin-auto-enable.model-support.test.ts @@ -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([]); + }); +}); diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 00e54a92bdd..8f1284a9898 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -48,6 +48,7 @@ function makeRegistry( id: string; channels: string[]; autoEnableWhenConfiguredProviders?: string[]; + modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] }; contracts?: { webFetchProviders?: string[] }; channelConfigs?: Record; 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: [], diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index ba97deda9a3..85ff0a614b6 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -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 | 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); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index ec4e5e6cdf1..3b66dc64bd6 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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; 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, diff --git a/src/plugins/manifest.json5-tolerance.test.ts b/src/plugins/manifest.json5-tolerance.test.ts index 815026c13ca..8de052c10cb 100644 --- a/src/plugins/manifest.json5-tolerance.test.ts +++ b/src/plugins/manifest.json5-tolerance.test.ts @@ -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"); diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 7dd7e5967f8..e711fbb6fa5 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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; @@ -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, diff --git a/src/plugins/providers.runtime.ts b/src/plugins/providers.runtime.ts index 4b4786edc1a..90bec1a08ad 100644 --- a/src/plugins/providers.runtime.ts +++ b/src/plugins/providers.runtime.ts @@ -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, diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index cf9c2251b97..efc9cad501c 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -15,18 +15,21 @@ const loadPluginManifestRegistryMock = vi.fn(); const applyPluginAutoEnableMock = vi.fn(); 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) => 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 }, + }, + }), + }), + }), + ); + }); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 88671fcb598..27075093d03 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -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[] { + 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;