feat: add Fireworks provider and simplify plugin setup loading

This commit is contained in:
Peter Steinberger
2026-04-05 07:42:15 +01:00
parent f842f518cd
commit d655a8bc76
62 changed files with 1096 additions and 880 deletions

View File

@@ -47,6 +47,38 @@ export type BundledPluginCompatibleActivationInputs = PluginActivationInputs & {
compatPluginIds: string[];
};
export function withActivatedPluginIds(params: {
config?: OpenClawConfig;
pluginIds: readonly string[];
}): OpenClawConfig | undefined {
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 applyPluginCompatibilityOverrides(params: {
config?: OpenClawConfig;
compat?: PluginActivationCompatConfig;

View File

@@ -1,49 +0,0 @@
// Auto-generated by scripts/generate-bundled-provider-auth-env-vars.mjs. Do not edit directly.
export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
brave: ["BRAVE_API_KEY"],
byteplus: ["BYTEPLUS_API_KEY"],
chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"],
"cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"],
deepgram: ["DEEPGRAM_API_KEY"],
deepseek: ["DEEPSEEK_API_KEY"],
exa: ["EXA_API_KEY"],
fal: ["FAL_KEY"],
firecrawl: ["FIRECRAWL_API_KEY"],
"github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
groq: ["GROQ_API_KEY"],
huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"],
kilocode: ["KILOCODE_API_KEY"],
kimi: ["KIMI_API_KEY", "KIMICODE_API_KEY"],
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"],
litellm: ["LITELLM_API_KEY"],
"microsoft-foundry": ["AZURE_OPENAI_API_KEY"],
minimax: ["MINIMAX_API_KEY"],
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
mistral: ["MISTRAL_API_KEY"],
moonshot: ["MOONSHOT_API_KEY"],
nvidia: ["NVIDIA_API_KEY"],
ollama: ["OLLAMA_API_KEY"],
openai: ["OPENAI_API_KEY"],
opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
"opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
openrouter: ["OPENROUTER_API_KEY"],
perplexity: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
qianfan: ["QIANFAN_API_KEY"],
qwen: ["QWEN_API_KEY", "MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"],
sglang: ["SGLANG_API_KEY"],
stepfun: ["STEPFUN_API_KEY"],
"stepfun-plan": ["STEPFUN_API_KEY"],
synthetic: ["SYNTHETIC_API_KEY"],
tavily: ["TAVILY_API_KEY"],
together: ["TOGETHER_API_KEY"],
venice: ["VENICE_API_KEY"],
"vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
vllm: ["VLLM_API_KEY"],
volcengine: ["VOLCANO_ENGINE_API_KEY"],
xai: ["XAI_API_KEY"],
xiaomi: ["XIAOMI_API_KEY"],
zai: ["ZAI_API_KEY", "Z_AI_API_KEY"],
} as const satisfies Record<string, readonly string[]>;

View File

@@ -1,112 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
collectBundledProviderAuthEnvVars,
writeBundledProviderAuthEnvVarModule,
} from "../../scripts/generate-bundled-provider-auth-env-vars.mjs";
import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.js";
import {
createGeneratedPluginTempRoot,
installGeneratedPluginTempRootCleanup,
pluginTestRepoRoot as repoRoot,
writeJson,
} from "./generated-plugin-test-helpers.js";
installGeneratedPluginTempRootCleanup();
function expectGeneratedAuthEnvVarModuleState(params: {
tempRoot: string;
expectedChanged: boolean;
expectedWrote: boolean;
}) {
const result = writeBundledProviderAuthEnvVarModule({
repoRoot: params.tempRoot,
outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts",
check: true,
});
expect(result.changed).toBe(params.expectedChanged);
expect(result.wrote).toBe(params.expectedWrote);
}
function expectGeneratedAuthEnvVarCheckMode(tempRoot: string) {
expectGeneratedAuthEnvVarModuleState({
tempRoot,
expectedChanged: false,
expectedWrote: false,
});
}
function expectBundledProviderEnvVars(expected: Record<string, readonly string[]>) {
expect(
Object.fromEntries(
Object.keys(expected).map((providerId) => [
providerId,
BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES[
providerId as keyof typeof BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES
],
]),
),
).toEqual(expected);
}
function expectMissingBundledProviderEnvVars(providerIds: readonly string[]) {
providerIds.forEach((providerId) => {
expect(providerId in BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toBe(false);
});
}
describe("bundled provider auth env vars", () => {
it("matches the generated manifest snapshot", () => {
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toEqual(
collectBundledProviderAuthEnvVars({ repoRoot }),
);
});
it("reads bundled provider auth env vars from plugin manifests", () => {
expectBundledProviderEnvVars({
brave: ["BRAVE_API_KEY"],
deepgram: ["DEEPGRAM_API_KEY"],
firecrawl: ["FIRECRAWL_API_KEY"],
"github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
groq: ["GROQ_API_KEY"],
perplexity: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
tavily: ["TAVILY_API_KEY"],
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
openai: ["OPENAI_API_KEY"],
fal: ["FAL_KEY"],
});
expectMissingBundledProviderEnvVars(["openai-codex"]);
});
it("supports check mode for stale generated artifacts", () => {
const tempRoot = createGeneratedPluginTempRoot("openclaw-provider-auth-env-vars-");
writeJson(path.join(tempRoot, "extensions", "alpha", "openclaw.plugin.json"), {
id: "alpha",
providerAuthEnvVars: {
alpha: ["ALPHA_TOKEN"],
},
});
const initial = writeBundledProviderAuthEnvVarModule({
repoRoot: tempRoot,
outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts",
});
expect(initial.wrote).toBe(true);
expectGeneratedAuthEnvVarCheckMode(tempRoot);
fs.writeFileSync(
path.join(tempRoot, "src/plugins/bundled-provider-auth-env-vars.generated.ts"),
"// stale\n",
"utf8",
);
expectGeneratedAuthEnvVarModuleState({
tempRoot,
expectedChanged: true,
expectedWrote: false,
});
});
});

View File

@@ -1,3 +0,0 @@
// Generated from extension manifests so core secrets/auth code does not need
// static imports into extension source trees.
export { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.generated.js";

View File

@@ -1,7 +1 @@
import { BUNDLED_WEB_FETCH_PLUGIN_IDS as BUNDLED_WEB_FETCH_PLUGIN_IDS_FROM_METADATA } from "./bundled-capability-metadata.js";
export const BUNDLED_WEB_FETCH_PLUGIN_IDS = BUNDLED_WEB_FETCH_PLUGIN_IDS_FROM_METADATA;
export function listBundledWebFetchPluginIds(): string[] {
return [...BUNDLED_WEB_FETCH_PLUGIN_IDS];
}
export { resolveBundledWebFetchPluginIds as listBundledWebFetchPluginIds } from "./bundled-web-fetch.js";

View File

@@ -1,18 +1 @@
import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "./bundled-capability-metadata.js";
const bundledWebFetchProviderPluginIds = Object.fromEntries(
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.flatMap((entry) =>
entry.webFetchProviderIds.map((providerId) => [providerId, entry.pluginId] as const),
).toSorted(([left], [right]) => left.localeCompare(right)),
) as Readonly<Record<string, string>>;
export function resolveBundledWebFetchPluginId(providerId: string | undefined): string | undefined {
if (!providerId) {
return undefined;
}
const normalizedProviderId = providerId.trim().toLowerCase();
if (!(normalizedProviderId in bundledWebFetchProviderPluginIds)) {
return undefined;
}
return bundledWebFetchProviderPluginIds[normalizedProviderId];
}
export { resolveBundledWebFetchPluginId } from "./bundled-web-fetch.js";

View File

@@ -1,25 +1,50 @@
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import { BUNDLED_WEB_FETCH_PLUGIN_IDS } from "./bundled-web-fetch-ids.js";
import { resolveBundledWebFetchPluginId as resolveBundledWebFetchPluginIdFromMap } from "./bundled-web-fetch-provider-ids.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginWebFetchProviderEntry } from "./types.js";
type BundledWebFetchProviderEntry = PluginWebFetchProviderEntry & { pluginId: string };
let bundledWebFetchProvidersCache: BundledWebFetchProviderEntry[] | null = null;
const bundledWebFetchProvidersCache = new Map<string, BundledWebFetchProviderEntry[]>();
function loadBundledWebFetchProviders(): BundledWebFetchProviderEntry[] {
if (!bundledWebFetchProvidersCache) {
bundledWebFetchProvidersCache = loadBundledCapabilityRuntimeRegistry({
pluginIds: BUNDLED_WEB_FETCH_PLUGIN_IDS,
pluginSdkResolution: "dist",
}).webFetchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
function resolveBundledWebFetchManifestPlugins(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}) {
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}).plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.webFetchProviders?.length ?? 0) > 0,
);
}
function loadBundledWebFetchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): BundledWebFetchProviderEntry[] {
const pluginIds = resolveBundledWebFetchPluginIds(params ?? {});
const cacheKey = pluginIds.join("\u0000");
const cached = bundledWebFetchProvidersCache.get(cacheKey);
if (cached) {
return cached;
}
return bundledWebFetchProvidersCache;
const providers =
pluginIds.length === 0
? []
: loadBundledCapabilityRuntimeRegistry({
pluginIds,
pluginSdkResolution: "dist",
}).webFetchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
bundledWebFetchProvidersCache.set(cacheKey, providers);
return providers;
}
export function resolveBundledWebFetchPluginIds(params: {
@@ -27,23 +52,41 @@ export function resolveBundledWebFetchPluginIds(params: {
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
const bundledWebFetchPluginIdSet = new Set<string>(BUNDLED_WEB_FETCH_PLUGIN_IDS);
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter(
(plugin) => plugin.origin === "bundled" && bundledWebFetchPluginIdSet.has(plugin.id),
)
return resolveBundledWebFetchManifestPlugins(params)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function listBundledWebFetchProviders(): PluginWebFetchProviderEntry[] {
return loadBundledWebFetchProviders();
export function listBundledWebFetchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): PluginWebFetchProviderEntry[] {
return loadBundledWebFetchProviders(params);
}
export function resolveBundledWebFetchPluginId(providerId: string | undefined): string | undefined {
return resolveBundledWebFetchPluginIdFromMap(providerId);
export function resolveBundledWebFetchPluginId(
providerId: string | undefined,
params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
},
): string | undefined {
if (!providerId) {
return undefined;
}
const normalizedProviderId = providerId.trim().toLowerCase();
if (!normalizedProviderId) {
return undefined;
}
return resolveBundledWebFetchManifestPlugins({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
}).find((plugin) =>
plugin.contracts?.webFetchProviders?.some(
(candidate) => candidate.trim().toLowerCase() === normalizedProviderId,
),
)?.id;
}

View File

@@ -1,7 +1,7 @@
import { BUNDLED_WEB_SEARCH_PLUGIN_IDS as BUNDLED_WEB_SEARCH_PLUGIN_IDS_FROM_METADATA } from "./bundled-capability-metadata.js";
import { listBundledWebSearchPluginIds as listBundledWebSearchPluginIdsImpl } from "./bundled-web-search.js";
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = BUNDLED_WEB_SEARCH_PLUGIN_IDS_FROM_METADATA;
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = listBundledWebSearchPluginIdsImpl();
export function listBundledWebSearchPluginIds(): string[] {
return [...BUNDLED_WEB_SEARCH_PLUGIN_IDS];
return listBundledWebSearchPluginIdsImpl();
}

View File

@@ -1,14 +1 @@
import { BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS } from "./bundled-capability-metadata.js";
export function resolveBundledWebSearchPluginId(
providerId: string | undefined,
): string | undefined {
if (!providerId) {
return undefined;
}
const normalizedProviderId = providerId.trim().toLowerCase();
if (!(normalizedProviderId in BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS)) {
return undefined;
}
return BUNDLED_WEB_SEARCH_PROVIDER_PLUGIN_IDS[normalizedProviderId];
}
export { resolveBundledWebSearchPluginId } from "./bundled-web-search.js";

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-web-search-ids.js";
import { hasBundledWebSearchCredential } from "./bundled-web-search-registry.js";
import {
listBundledWebSearchPluginIds,
@@ -58,8 +57,9 @@ describe("bundled web search helpers", () => {
vi.clearAllMocks();
vi.mocked(loadPluginManifestRegistry).mockReturnValue({
plugins: [
{ id: "xai", origin: "bundled" },
{ id: "google", origin: "bundled" },
{ id: "xai", origin: "bundled", contracts: { webSearchProviders: ["grok"] } },
{ id: "google", origin: "bundled", contracts: { webSearchProviders: ["gemini"] } },
{ id: "minimax", origin: "bundled", contracts: { webSearchProviders: ["minimax"] } },
{ id: "noise", origin: "bundled" },
{ id: "external-google", origin: "workspace" },
] as never[],
@@ -92,7 +92,7 @@ describe("bundled web search helpers", () => {
} as never);
});
it("filters bundled manifest entries down to known bundled web search plugins", () => {
it("returns bundled manifest-derived web search plugins from the registry", () => {
expect(
resolveBundledWebSearchPluginIds({
config: {
@@ -103,7 +103,7 @@ describe("bundled web search helpers", () => {
workspaceDir: "/tmp/workspace",
env: { OPENCLAW_HOME: "/tmp/openclaw-home" },
}),
).toEqual(["google", "xai"]);
).toEqual(["google", "minimax", "xai"]);
expect(loadPluginManifestRegistry).toHaveBeenCalledWith({
config: {
plugins: {
@@ -117,8 +117,8 @@ describe("bundled web search helpers", () => {
it("returns a copy of the bundled plugin id fast-path list", () => {
const listed = listBundledWebSearchPluginIds();
expect(listed).toEqual([...BUNDLED_WEB_SEARCH_PLUGIN_IDS]);
expect(listed).not.toBe(BUNDLED_WEB_SEARCH_PLUGIN_IDS);
expect(listed).toEqual(["google", "minimax", "xai"]);
expect(listed).not.toBe(listBundledWebSearchPluginIds());
});
it("maps bundled provider ids back to their owning plugins", () => {
@@ -140,7 +140,7 @@ describe("bundled web search helpers", () => {
]);
expect(loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledTimes(1);
expect(loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({
pluginIds: BUNDLED_WEB_SEARCH_PLUGIN_IDS,
pluginIds: ["google", "minimax", "xai"],
pluginSdkResolution: "dist",
});
});

View File

@@ -1,25 +1,50 @@
import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-capability-metadata.js";
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import { resolveBundledWebSearchPluginId as resolveBundledWebSearchPluginIdFromMap } from "./bundled-web-search-provider-ids.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginWebSearchProviderEntry } from "./types.js";
type BundledWebSearchProviderEntry = PluginWebSearchProviderEntry & { pluginId: string };
let bundledWebSearchProvidersCache: BundledWebSearchProviderEntry[] | null = null;
const bundledWebSearchProvidersCache = new Map<string, BundledWebSearchProviderEntry[]>();
function loadBundledWebSearchProviders(): BundledWebSearchProviderEntry[] {
if (!bundledWebSearchProvidersCache) {
bundledWebSearchProvidersCache = loadBundledCapabilityRuntimeRegistry({
pluginIds: BUNDLED_WEB_SEARCH_PLUGIN_IDS,
pluginSdkResolution: "dist",
}).webSearchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
function resolveBundledWebSearchManifestPlugins(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}) {
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}).plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0,
);
}
function loadBundledWebSearchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): BundledWebSearchProviderEntry[] {
const pluginIds = resolveBundledWebSearchPluginIds(params ?? {});
const cacheKey = pluginIds.join("\u0000");
const cached = bundledWebSearchProvidersCache.get(cacheKey);
if (cached) {
return cached;
}
return bundledWebSearchProvidersCache;
const providers =
pluginIds.length === 0
? []
: loadBundledCapabilityRuntimeRegistry({
pluginIds,
pluginSdkResolution: "dist",
}).webSearchProviders.map((entry) => ({
pluginId: entry.pluginId,
...entry.provider,
}));
bundledWebSearchProvidersCache.set(cacheKey, providers);
return providers;
}
export function resolveBundledWebSearchPluginIds(params: {
@@ -27,29 +52,49 @@ export function resolveBundledWebSearchPluginIds(params: {
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
const bundledWebSearchPluginIdSet = new Set<string>(BUNDLED_WEB_SEARCH_PLUGIN_IDS);
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter(
(plugin) => plugin.origin === "bundled" && bundledWebSearchPluginIdSet.has(plugin.id),
)
return resolveBundledWebSearchManifestPlugins(params)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function listBundledWebSearchPluginIds(): string[] {
return [...BUNDLED_WEB_SEARCH_PLUGIN_IDS];
export function listBundledWebSearchPluginIds(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): string[] {
return resolveBundledWebSearchPluginIds(params ?? {});
}
export function listBundledWebSearchProviders(): PluginWebSearchProviderEntry[] {
return loadBundledWebSearchProviders();
export function listBundledWebSearchProviders(params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
}): PluginWebSearchProviderEntry[] {
return loadBundledWebSearchProviders(params);
}
export function resolveBundledWebSearchPluginId(
providerId: string | undefined,
params?: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
},
): string | undefined {
return resolveBundledWebSearchPluginIdFromMap(providerId);
if (!providerId) {
return undefined;
}
const normalizedProviderId = providerId.trim().toLowerCase();
if (!normalizedProviderId) {
return undefined;
}
return resolveBundledWebSearchManifestPlugins({
config: params?.config,
workspaceDir: params?.workspaceDir,
env: params?.env,
}).find((plugin) =>
plugin.contracts?.webSearchProviders?.some(
(candidate) => candidate.trim().toLowerCase() === normalizedProviderId,
),
)?.id;
}

View File

@@ -25,8 +25,7 @@ export async function resolvePreferredProviderForAuthChoice(params: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
const pluginResolved = resolveProviderPluginChoice({
providers,

View File

@@ -174,8 +174,7 @@ export async function applyAuthChoiceLoadedPluginProvider(
config: params.config,
workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
const resolved = resolveProviderPluginChoice({
providers,
@@ -256,8 +255,7 @@ export async function applyAuthChoicePluginProvider(
config: nextConfig,
workspaceDir,
env: params.env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
const provider = resolveProviderMatch(providers, options.providerId);
if (!provider) {

View File

@@ -79,64 +79,6 @@ function createWizardRuntimeParams(params?: {
};
}
function expectWizardResolutionCount(params: {
provider: ProviderPlugin;
config?: object;
env?: NodeJS.ProcessEnv;
expectedCount: number;
}) {
setResolvedProviders(params.provider);
resolveProviderWizardOptions(
createWizardRuntimeParams({
config: params.config,
env: params.env,
}),
);
resolveProviderWizardOptions(
createWizardRuntimeParams({
config: params.config,
env: params.env,
}),
);
expectProviderResolutionCall({
config: params.config,
env: params.env,
count: params.expectedCount,
});
}
function expectWizardCacheInvalidationCount(params: {
provider: ProviderPlugin;
config: { [key: string]: unknown };
env: NodeJS.ProcessEnv;
mutate: () => void;
expectedCount?: number;
}) {
setResolvedProviders(params.provider);
resolveProviderWizardOptions(
createWizardRuntimeParams({
config: params.config,
env: params.env,
}),
);
params.mutate();
resolveProviderWizardOptions(
createWizardRuntimeParams({
config: params.config,
env: params.env,
}),
);
expectProviderResolutionCall({
config: params.config,
env: params.env,
count: params.expectedCount ?? 2,
});
}
function expectProviderResolutionCall(params?: {
config?: object;
env?: NodeJS.ProcessEnv;
@@ -146,8 +88,7 @@ function expectProviderResolutionCall(params?: {
expect(resolvePluginProviders).toHaveBeenCalledTimes(params?.count ?? 1);
expect(resolvePluginProviders).toHaveBeenCalledWith({
...createWizardRuntimeParams(params),
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
mode: "setup",
});
}
@@ -351,7 +292,7 @@ describe("provider wizard boundaries", () => {
]);
});
it("reuses provider resolution across wizard consumers for the same config and env", () => {
it("resolves providers in setup mode across wizard consumers", () => {
const provider = createSglangWizardProvider({ includeModelPicker: true });
const config = {};
const env = createHomeEnv();
@@ -361,82 +302,9 @@ describe("provider wizard boundaries", () => {
expect(resolveProviderWizardOptions(runtimeParams)).toHaveLength(1);
expect(resolveProviderModelPickerEntries(runtimeParams)).toHaveLength(1);
expectProviderResolutionCall({ config, env });
});
it("invalidates the wizard cache when config or env contents change in place", () => {
const config = createSglangConfig();
const env = createHomeEnv("-a");
expectWizardCacheInvalidationCount({
provider: createSglangWizardProvider(),
config,
env,
mutate: () => {
config.plugins.allow = ["vllm"];
env.OPENCLAW_HOME = "/tmp/openclaw-home-b";
},
});
});
it.each([
{
name: "skips provider-wizard memoization when plugin cache opt-outs are set",
env: createHomeEnv("", {
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
}),
},
{
name: "skips provider-wizard memoization when discovery cache ttl is zero",
env: createHomeEnv("", {
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0",
}),
},
] as const)("$name", ({ env }) => {
expectWizardResolutionCount({
provider: createSglangWizardProvider(),
config: createSglangConfig(),
env,
expectedCount: 2,
});
});
it("expires provider-wizard memoization after the shortest plugin cache ttl", () => {
vi.useFakeTimers();
const provider = createSglangWizardProvider();
const config = {};
const env = createHomeEnv("", {
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5",
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "20",
});
setResolvedProviders(provider);
const runtimeParams = createWizardRuntimeParams({ config, env });
resolveProviderWizardOptions(runtimeParams);
vi.advanceTimersByTime(4);
resolveProviderWizardOptions(runtimeParams);
vi.advanceTimersByTime(2);
resolveProviderWizardOptions(runtimeParams);
expectProviderResolutionCall({ config, env, count: 2 });
});
it("invalidates provider-wizard snapshots when cache-control env values change in place", () => {
const config = {};
const env = createHomeEnv("", {
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "1000",
});
expectWizardCacheInvalidationCount({
provider: createSglangWizardProvider(),
config,
env,
mutate: () => {
env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS = "5";
},
});
});
it("routes model-selected hooks only to the matching provider", async () => {
const matchingHook = vi.fn(async () => {});
const otherHook = vi.fn(async () => {});

View File

@@ -2,11 +2,6 @@ import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
buildPluginSnapshotCacheEnvKey,
resolvePluginSnapshotCacheTtlMs,
shouldUsePluginSnapshotCache,
} from "./cache-controls.js";
import { resolvePluginProviders } from "./providers.runtime.js";
import type {
ProviderAuthMethod,
@@ -16,26 +11,6 @@ import type {
} from "./types.js";
export const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:";
type ProviderWizardCacheEntry = {
expiresAt: number;
providers: ProviderPlugin[];
};
const providerWizardCache = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderWizardCacheEntry>>
>();
function buildProviderWizardCacheKey(params: {
config: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): string {
return JSON.stringify({
workspaceDir: params.workspaceDir ?? "",
config: params.config,
env: buildPluginSnapshotCacheEnvKey(params.env),
});
}
export type ProviderWizardOption = {
value: string;
@@ -135,53 +110,12 @@ function resolveProviderWizardProviders(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin[] {
if (!params.config) {
return resolvePluginProviders(params);
}
const env = params.env ?? process.env;
if (!shouldUsePluginSnapshotCache(env)) {
return resolvePluginProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
}
const cacheKey = buildProviderWizardCacheKey({
return resolvePluginProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env,
env: params.env,
mode: "setup",
});
const configCache = providerWizardCache.get(params.config);
const envCache = configCache?.get(env);
const cached = envCache?.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.providers;
}
const providers = resolvePluginProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
const ttlMs = resolvePluginSnapshotCacheTtlMs(env);
let nextConfigCache = configCache;
if (!nextConfigCache) {
nextConfigCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderWizardCacheEntry>>();
providerWizardCache.set(params.config, nextConfigCache);
}
let nextEnvCache = nextConfigCache.get(env);
if (!nextEnvCache) {
nextEnvCache = new Map<string, ProviderWizardCacheEntry>();
nextConfigCache.set(env, nextEnvCache);
}
nextEnvCache.set(cacheKey, {
expiresAt: Date.now() + ttlMs,
providers,
});
return providers;
}
export function resolveProviderWizardOptions(params: {

View File

@@ -1,8 +1,14 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import { withActivatedPluginIds } from "./activation-context.js";
import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js";
import { resolveRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js";
import {
loadOpenClawPlugins,
resolveRuntimePluginRegistry,
type PluginLoadOptions,
} from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import {
resolveDiscoveredProviderPluginIds,
resolveEnabledProviderPluginIds,
resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForModelRefs,
@@ -12,38 +18,6 @@ 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;
@@ -56,6 +30,7 @@ export function resolvePluginProviders(params: {
activate?: boolean;
cache?: boolean;
pluginSdkResolution?: PluginLoadOptions["pluginSdkResolution"];
mode?: "runtime" | "setup";
}): ProviderPlugin[] {
const env = params.env ?? process.env;
const modelOwnedPluginIds = params.modelRefs?.length
@@ -70,10 +45,40 @@ export function resolvePluginProviders(params: {
params.onlyPluginIds || modelOwnedPluginIds.length > 0
? [...new Set([...(params.onlyPluginIds ?? []), ...modelOwnedPluginIds])]
: undefined;
const runtimeConfig = withRuntimeActivatedPluginIds({
const runtimeConfig = withActivatedPluginIds({
config: params.config,
pluginIds: modelOwnedPluginIds,
});
if (params.mode === "setup") {
const providerPluginIds = resolveDiscoveredProviderPluginIds({
config: runtimeConfig,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: requestedPluginIds,
});
if (providerPluginIds.length === 0) {
return [];
}
const registry = loadOpenClawPlugins({
config: withActivatedPluginIds({
config: runtimeConfig,
pluginIds: providerPluginIds,
}),
activationSourceConfig: runtimeConfig,
autoEnabledReasons: {},
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: providerPluginIds,
pluginSdkResolution: params.pluginSdkResolution,
cache: params.cache ?? false,
activate: params.activate ?? false,
logger: createPluginLoaderLogger(log),
});
return registry.providers.map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
}));
}
const activation = resolveBundledPluginCompatibleActivationInputs({
rawConfig: runtimeConfig,
env,

View File

@@ -6,11 +6,13 @@ import { createEmptyPluginRegistry } from "./registry-empty.js";
import type { ProviderPlugin } from "./types.js";
type ResolveRuntimePluginRegistry = typeof import("./loader.js").resolveRuntimePluginRegistry;
type LoadOpenClawPlugins = typeof import("./loader.js").loadOpenClawPlugins;
type LoadPluginManifestRegistry =
typeof import("./manifest-registry.js").loadPluginManifestRegistry;
type ApplyPluginAutoEnable = typeof import("../config/plugin-auto-enable.js").applyPluginAutoEnable;
const resolveRuntimePluginRegistryMock = vi.fn<ResolveRuntimePluginRegistry>();
const loadOpenClawPluginsMock = vi.fn<LoadOpenClawPlugins>();
const loadPluginManifestRegistryMock = vi.fn<LoadPluginManifestRegistry>();
const applyPluginAutoEnableMock = vi.fn<ApplyPluginAutoEnable>();
@@ -131,6 +133,20 @@ function expectLastRuntimeRegistryLoad(params?: {
);
}
function expectLastSetupRegistryLoad(params?: {
env?: NodeJS.ProcessEnv;
onlyPluginIds?: readonly string[];
}) {
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
cache: false,
activate: false,
...(params?.env ? { env: params.env } : {}),
...(params?.onlyPluginIds ? { onlyPluginIds: params.onlyPluginIds } : {}),
}),
);
}
function getLastResolvedPluginConfig() {
return getLastRuntimeRegistryCall().config as
| {
@@ -142,6 +158,19 @@ function getLastResolvedPluginConfig() {
| undefined;
}
function getLastSetupLoadedPluginConfig() {
const call = loadOpenClawPluginsMock.mock.calls.at(-1)?.[0];
expect(call).toBeDefined();
return (call?.config ?? undefined) as
| {
plugins?: {
allow?: string[];
entries?: Record<string, { enabled?: boolean }>;
};
}
| undefined;
}
function createBundledProviderCompatOptions(params?: { onlyPluginIds?: readonly string[] }) {
return {
config: {
@@ -222,6 +251,8 @@ describe("resolvePluginProviders", () => {
beforeAll(async () => {
vi.resetModules();
vi.doMock("./loader.js", () => ({
loadOpenClawPlugins: (...args: Parameters<LoadOpenClawPlugins>) =>
loadOpenClawPluginsMock(...args),
resolveRuntimePluginRegistry: (...args: Parameters<ResolveRuntimePluginRegistry>) =>
resolveRuntimePluginRegistryMock(...args),
}));
@@ -243,6 +274,7 @@ describe("resolvePluginProviders", () => {
beforeEach(() => {
resolveRuntimePluginRegistryMock.mockReset();
loadOpenClawPluginsMock.mockReset();
const provider: ProviderPlugin = {
id: "demo-provider",
label: "Demo Provider",
@@ -251,6 +283,7 @@ describe("resolvePluginProviders", () => {
const registry = createEmptyPluginRegistry();
registry.providers.push({ pluginId: "google", provider, source: "bundled" });
resolveRuntimePluginRegistryMock.mockReturnValue(registry);
loadOpenClawPluginsMock.mockReturnValue(registry);
loadPluginManifestRegistryMock.mockReset();
applyPluginAutoEnableMock.mockReset();
applyPluginAutoEnableMock.mockImplementation(
@@ -452,6 +485,43 @@ describe("resolvePluginProviders", () => {
});
});
it("loads all discovered provider plugins in setup mode", () => {
resolvePluginProviders({
config: {
plugins: {
allow: ["openrouter"],
entries: {
google: { enabled: false },
},
},
},
mode: "setup",
});
expectLastSetupRegistryLoad({
onlyPluginIds: ["google", "kilocode", "moonshot", "workspace-provider"],
});
expect(getLastSetupLoadedPluginConfig()).toEqual(
expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining([
"openrouter",
"google",
"kilocode",
"moonshot",
"workspace-provider",
]),
entries: expect.objectContaining({
google: { enabled: true },
kilocode: { enabled: true },
moonshot: { enabled: true },
"workspace-provider": { enabled: true },
}),
}),
}),
);
});
it("loads provider plugins from the auto-enabled config snapshot", () => {
const { rawConfig, autoEnabledConfig } = createAutoEnabledProviderConfig();
applyPluginAutoEnableMock.mockReturnValue({

View File

@@ -69,8 +69,29 @@ export function resolveEnabledProviderPluginIds(params: {
.toSorted((left, right) => left.localeCompare(right));
}
export function resolveDiscoveredProviderPluginIds(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
onlyPluginIds?: readonly string[];
}): string[] {
const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null;
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter(
(plugin) =>
plugin.providers.length > 0 && (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)),
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export const __testing = {
resolveEnabledProviderPluginIds,
resolveDiscoveredProviderPluginIds,
resolveBundledProviderCompatPluginIds,
withBundledProviderVitestCompat,
} as const;

View File

@@ -345,6 +345,32 @@ describe("resolvePluginWebSearchProviders", () => {
expectLoaderCallCount(1);
});
it("loads manifest-declared web-search providers in setup mode", () => {
const providers = resolvePluginWebSearchProviders({
config: {
plugins: {
allow: ["perplexity"],
},
},
mode: "setup",
});
expect(toRuntimeProviderKeys(providers)).toEqual(["brave:brave"]);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["brave"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["perplexity", "brave"],
entries: {
brave: { enabled: true },
},
}),
}),
}),
);
});
it("loads plugin web-search providers from the auto-enabled config snapshot", () => {
const rawConfig = createBraveAllowConfig();
const autoEnabledConfig = {

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isRecord } from "../utils.js";
import { withActivatedPluginIds } from "./activation-context.js";
import {
buildPluginSnapshotCacheEnvKey,
resolvePluginSnapshotCacheTtlMs,
@@ -155,8 +156,36 @@ export function resolvePluginWebSearchProviders(params: {
onlyPluginIds?: readonly string[];
activate?: boolean;
cache?: boolean;
mode?: "runtime" | "setup";
}): PluginWebSearchProviderEntry[] {
const env = params.env ?? process.env;
if (params.mode === "setup") {
const pluginIds =
resolveWebSearchCandidatePluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: params.onlyPluginIds,
}) ?? [];
if (pluginIds.length === 0) {
return [];
}
const registry = loadOpenClawPlugins({
config: withActivatedPluginIds({
config: params.config,
pluginIds,
}),
activationSourceConfig: params.config,
autoEnabledReasons: {},
workspaceDir: params.workspaceDir,
env,
onlyPluginIds: pluginIds,
cache: params.cache ?? false,
activate: params.activate ?? false,
logger: createPluginLoaderLogger(log),
});
return mapRegistryWebSearchProviders({ registry, onlyPluginIds: pluginIds });
}
const cacheOwnerConfig = params.config;
const shouldMemoizeSnapshot =
params.activate !== true && params.cache !== true && shouldUsePluginSnapshotCache(env);