fix: restore full gate after web-search rebase

This commit is contained in:
Peter Steinberger 2026-03-18 05:26:19 +00:00
parent 861fcb1575
commit e9b19ca1d1
16 changed files with 791 additions and 406 deletions

View File

@ -16,8 +16,8 @@ import {
resolveSearchTimeoutSeconds,
resolveSiteName,
resolveProviderWebSearchPluginConfig,
setTopLevelCredentialValue,
setProviderWebSearchPluginConfigValue,
type OpenClawConfig,
type SearchConfigRecord,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
@ -92,7 +92,6 @@ const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
type BraveConfig = {
apiKey?: unknown;
mode?: string;
};
@ -115,41 +114,18 @@ type BraveLlmContextResponse = {
sources?: { url?: string; hostname?: string; date?: string }[];
};
function resolveBraveConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): BraveConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "brave");
if (pluginConfig) {
return pluginConfig as BraveConfig;
}
const scoped = (searchConfig as Record<string, unknown> | undefined)?.brave;
return scoped && typeof scoped === "object" && !Array.isArray(scoped)
? ({
...(scoped as BraveConfig),
apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey,
} as BraveConfig)
: ({ apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey } as BraveConfig);
function resolveBraveConfig(searchConfig?: SearchConfigRecord): BraveConfig {
const brave = searchConfig?.brave;
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
}
function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" {
return brave?.mode === "llm-context" ? "llm-context" : "web";
}
function resolveBraveApiKey(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): string | undefined {
const braveConfig = resolveBraveConfig(config, searchConfig);
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
return (
readConfiguredSecretString(
braveConfig.apiKey,
"plugins.entries.brave.config.webSearch.apiKey",
) ??
readConfiguredSecretString(
(searchConfig as Record<string, unknown> | undefined)?.apiKey,
"tools.web.search.apiKey",
) ??
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readProviderEnvValue(["BRAVE_API_KEY"])
);
}
@ -410,10 +386,9 @@ function missingBraveKeyPayload() {
}
function createBraveToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
const braveConfig = resolveBraveConfig(config, searchConfig);
const braveConfig = resolveBraveConfig(searchConfig);
const braveMode = resolveBraveMode(braveConfig);
return {
@ -423,7 +398,7 @@ function createBraveToolDefinition(
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: createBraveSchema(),
execute: async (args) => {
const apiKey = resolveBraveApiKey(config, searchConfig);
const apiKey = resolveBraveApiKey(searchConfig);
if (!apiKey) {
return missingBraveKeyPayload();
}
@ -624,16 +599,20 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
setCredentialValue: (searchConfigTarget, value) => {
searchConfigTarget.apiKey = value;
},
setCredentialValue: setTopLevelCredentialValue,
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
},
createTool: (ctx) =>
createBraveToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
createTool: (ctx) => {
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "brave");
const searchConfig = {
...(ctx.searchConfig as SearchConfigRecord | undefined),
...(pluginConfig as SearchConfigRecord | undefined),
};
return createBraveToolDefinition(searchConfig);
},
};
}

View File

@ -14,7 +14,6 @@ import {
resolveSearchTimeoutSeconds,
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
type OpenClawConfig,
type SearchConfigRecord,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
@ -54,15 +53,8 @@ type GeminiGroundingResponse = {
};
};
function resolveGeminiConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): GeminiConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "google");
if (pluginConfig) {
return pluginConfig as GeminiConfig;
}
const gemini = (searchConfig as Record<string, unknown> | undefined)?.gemini;
function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig {
const gemini = searchConfig?.gemini;
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
? (gemini as GeminiConfig)
: {};
@ -70,7 +62,7 @@ function resolveGeminiConfig(
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
return (
readConfiguredSecretString(gemini?.apiKey, "plugins.entries.google.config.webSearch.apiKey") ??
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
readProviderEnvValue(["GEMINI_API_KEY"])
);
}
@ -177,7 +169,6 @@ function createGeminiSchema() {
}
function createGeminiToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
return {
@ -204,13 +195,13 @@ function createGeminiToolDefinition(
}
}
const geminiConfig = resolveGeminiConfig(config, searchConfig);
const geminiConfig = resolveGeminiConfig(searchConfig);
const apiKey = resolveGeminiApiKey(geminiConfig);
if (!apiKey) {
return {
error: "missing_gemini_api_key",
message:
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure plugins.entries.google.config.webSearch.apiKey.",
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@ -290,8 +281,19 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "google", "apiKey", value);
},
createTool: (ctx) =>
createGeminiToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
createTool: (ctx) => {
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "google");
const searchConfig = {
...(ctx.searchConfig as SearchConfigRecord | undefined),
gemini: {
...((ctx.searchConfig as SearchConfigRecord | undefined)?.gemini as
| Record<string, unknown>
| undefined),
...(pluginConfig as Record<string, unknown> | undefined),
},
} as SearchConfigRecord;
return createGeminiToolDefinition(searchConfig);
},
};
}

View File

@ -13,7 +13,6 @@ import {
resolveSearchTimeoutSeconds,
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
type OpenClawConfig,
type SearchConfigRecord,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
@ -63,18 +62,14 @@ type KimiSearchResponse = {
}>;
};
function resolveKimiConfig(config?: OpenClawConfig, searchConfig?: SearchConfigRecord): KimiConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "moonshot");
if (pluginConfig) {
return pluginConfig as KimiConfig;
}
const kimi = (searchConfig as Record<string, unknown> | undefined)?.kimi;
function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig {
const kimi = searchConfig?.kimi;
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
}
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
return (
readConfiguredSecretString(kimi?.apiKey, "plugins.entries.moonshot.config.webSearch.apiKey") ??
readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ??
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
);
}
@ -243,7 +238,6 @@ function createKimiSchema() {
}
function createKimiToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
return {
@ -270,13 +264,13 @@ function createKimiToolDefinition(
}
}
const kimiConfig = resolveKimiConfig(config, searchConfig);
const kimiConfig = resolveKimiConfig(searchConfig);
const apiKey = resolveKimiApiKey(kimiConfig);
if (!apiKey) {
return {
error: "missing_kimi_api_key",
message:
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure plugins.entries.moonshot.config.webSearch.apiKey.",
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@ -359,8 +353,19 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "moonshot", "apiKey", value);
},
createTool: (ctx) =>
createKimiToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
createTool: (ctx) => {
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot");
const searchConfig = {
...(ctx.searchConfig as SearchConfigRecord | undefined),
kimi: {
...((ctx.searchConfig as SearchConfigRecord | undefined)?.kimi as
| Record<string, unknown>
| undefined),
...(pluginConfig as Record<string, unknown> | undefined),
},
} as SearchConfigRecord;
return createKimiToolDefinition(searchConfig);
},
};
}

View File

@ -3,6 +3,8 @@ import {
readNumberParam,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/provider-web-search";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
MAX_SEARCH_COUNT,
@ -19,7 +21,6 @@ import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
throwWebSearchApiError,
type OpenClawConfig,
type SearchConfigRecord,
type WebSearchCredentialResolutionSource,
type WebSearchProviderPlugin,
@ -70,15 +71,8 @@ type PerplexitySearchApiResponse = {
}>;
};
function resolvePerplexityConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): PerplexityConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "perplexity");
if (pluginConfig) {
return pluginConfig as PerplexityConfig;
}
const perplexity = (searchConfig as Record<string, unknown> | undefined)?.perplexity;
function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig {
const perplexity = searchConfig?.perplexity;
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as PerplexityConfig)
: {};
@ -104,7 +98,7 @@ function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
} {
const fromConfig = readConfiguredSecretString(
perplexity?.apiKey,
"plugins.entries.perplexity.config.webSearch.apiKey",
"tools.web.search.perplexity.apiKey",
);
if (fromConfig) {
return { apiKey: fromConfig, source: "config" };
@ -319,16 +313,16 @@ async function runPerplexitySearch(params: {
}
function resolveRuntimeTransport(params: {
config?: OpenClawConfig;
searchConfig?: Record<string, unknown>;
resolvedKey?: string;
keySource: WebSearchCredentialResolutionSource;
fallbackEnvVar?: string;
}): PerplexityTransport | undefined {
const scoped = resolvePerplexityConfig(
params.config,
params.searchConfig as SearchConfigRecord | undefined,
);
const perplexity = params.searchConfig?.perplexity;
const scoped =
perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as { baseUrl?: string; model?: string })
: undefined;
const configuredBaseUrl = typeof scoped?.baseUrl === "string" ? scoped.baseUrl.trim() : "";
const configuredModel = typeof scoped?.model === "string" ? scoped.model.trim() : "";
const baseUrl = (() => {
@ -410,11 +404,10 @@ function createPerplexitySchema(transport?: PerplexityTransport) {
}
function createPerplexityToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
runtimeTransport?: PerplexityTransport,
): WebSearchProviderToolDefinition {
const perplexityConfig = resolvePerplexityConfig(config, searchConfig);
const perplexityConfig = resolvePerplexityConfig(searchConfig);
const schemaTransport =
runtimeTransport ??
(perplexityConfig.baseUrl || perplexityConfig.model ? "chat_completions" : undefined);
@ -431,7 +424,7 @@ function createPerplexityToolDefinition(
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure plugins.entries.perplexity.config.webSearch.apiKey.",
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@ -686,19 +679,38 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
},
resolveRuntimeMetadata: (ctx) => ({
perplexityTransport: resolveRuntimeTransport({
config: ctx.config,
searchConfig: ctx.searchConfig,
searchConfig: {
...(ctx.searchConfig as SearchConfigRecord | undefined),
perplexity: {
...((ctx.searchConfig as SearchConfigRecord | undefined)?.perplexity as
| Record<string, unknown>
| undefined),
...(resolveProviderWebSearchPluginConfig(ctx.config, "perplexity") as
| Record<string, unknown>
| undefined),
},
},
resolvedKey: ctx.resolvedCredential?.value,
keySource: ctx.resolvedCredential?.source ?? "missing",
fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar,
}),
}),
createTool: (ctx) =>
createPerplexityToolDefinition(
ctx.config,
ctx.searchConfig as SearchConfigRecord | undefined,
createTool: (ctx) => {
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "perplexity");
const searchConfig = {
...(ctx.searchConfig as SearchConfigRecord | undefined),
perplexity: {
...((ctx.searchConfig as SearchConfigRecord | undefined)?.perplexity as
| Record<string, unknown>
| undefined),
...(pluginConfig as Record<string, unknown> | undefined),
},
} as SearchConfigRecord;
return createPerplexityToolDefinition(
searchConfig,
ctx.runtimeMetadata?.perplexityTransport as PerplexityTransport | undefined,
),
);
},
};
}

View File

@ -13,7 +13,6 @@ import {
resolveSearchTimeoutSeconds,
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
type OpenClawConfig,
type SearchConfigRecord,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
@ -62,18 +61,14 @@ type GrokSearchResponse = {
}>;
};
function resolveGrokConfig(config?: OpenClawConfig, searchConfig?: SearchConfigRecord): GrokConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "xai");
if (pluginConfig) {
return pluginConfig as GrokConfig;
}
const grok = (searchConfig as Record<string, unknown> | undefined)?.grok;
function resolveGrokConfig(searchConfig?: SearchConfigRecord): GrokConfig {
const grok = searchConfig?.grok;
return grok && typeof grok === "object" && !Array.isArray(grok) ? (grok as GrokConfig) : {};
}
function resolveGrokApiKey(grok?: GrokConfig): string | undefined {
return (
readConfiguredSecretString(grok?.apiKey, "plugins.entries.xai.config.webSearch.apiKey") ??
readConfiguredSecretString(grok?.apiKey, "tools.web.search.grok.apiKey") ??
readProviderEnvValue(["XAI_API_KEY"])
);
}
@ -185,7 +180,6 @@ function createGrokSchema() {
}
function createGrokToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
return {
@ -212,13 +206,13 @@ function createGrokToolDefinition(
}
}
const grokConfig = resolveGrokConfig(config, searchConfig);
const grokConfig = resolveGrokConfig(searchConfig);
const apiKey = resolveGrokApiKey(grokConfig);
if (!apiKey) {
return {
error: "missing_xai_api_key",
message:
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.",
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@ -302,8 +296,19 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin {
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "xai", "apiKey", value);
},
createTool: (ctx) =>
createGrokToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
createTool: (ctx) => {
const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "xai");
const searchConfig = {
...(ctx.searchConfig as SearchConfigRecord | undefined),
grok: {
...((ctx.searchConfig as SearchConfigRecord | undefined)?.grok as
| Record<string, unknown>
| undefined),
...(pluginConfig as Record<string, unknown> | undefined),
},
} as SearchConfigRecord;
return createGrokToolDefinition(searchConfig);
},
};
}

View File

@ -12,6 +12,8 @@ function isSourceFile(filePath: string): boolean {
function isProductionExtensionFile(filePath: string): boolean {
return !(
filePath.endsWith("/runtime-api.ts") ||
filePath.endsWith("\\runtime-api.ts") ||
filePath.includes(".test.") ||
filePath.includes(".spec.") ||
filePath.includes(".fixture.") ||

View File

@ -1,36 +1,123 @@
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
import { logVerbose } from "../../globals.js";
import type { PluginWebSearchProviderEntry } from "../../plugins/types.js";
import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
import {
__testing as runtimeTesting,
resolveWebSearchDefinition,
} from "../../web-search/runtime.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult } from "./common.js";
import { SEARCH_CACHE } from "./web-search-provider-common.js";
import {
resolveSearchConfig,
resolveSearchEnabled,
type WebSearchConfig,
} from "./web-search-provider-config.js";
function readProviderEnvValue(envVars: string[]): string | undefined {
for (const envVar of envVars) {
const value = normalizeSecretInput(process.env[envVar]);
if (value) {
return value;
}
}
return undefined;
}
function hasProviderCredential(
provider: PluginWebSearchProviderEntry,
search: WebSearchConfig | undefined,
): boolean {
const rawValue = provider.getCredentialValue(search as Record<string, unknown> | undefined);
const fromConfig = normalizeSecretInput(
normalizeResolvedSecretInputString({
value: rawValue,
path: provider.credentialPath,
}),
);
return Boolean(fromConfig || readProviderEnvValue(provider.envVars));
}
function resolveSearchProvider(search?: WebSearchConfig): string {
const providers = resolvePluginWebSearchProviders({
bundledAllowlistCompat: true,
});
const raw =
search && "provider" in search && typeof search.provider === "string"
? search.provider.trim().toLowerCase()
: "";
if (raw) {
const explicit = providers.find((provider) => provider.id === raw);
if (explicit) {
return explicit.id;
}
}
if (!raw) {
for (const provider of providers) {
if (!hasProviderCredential(provider, search)) {
continue;
}
logVerbose(
`web_search: no provider configured, auto-detected "${provider.id}" from available API keys`,
);
return provider.id;
}
}
return providers[0]?.id ?? "";
}
export function createWebSearchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): AnyAgentTool | null {
const resolved = resolveWebSearchDefinition({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebSearch: options?.runtimeWebSearch,
});
if (!resolved) {
const search = resolveSearchConfig(options?.config);
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) {
return null;
}
const providers = resolvePluginWebSearchProviders({
config: options?.config,
bundledAllowlistCompat: true,
});
if (providers.length === 0) {
return null;
}
const providerId =
options?.runtimeWebSearch?.selectedProvider ??
options?.runtimeWebSearch?.providerConfigured ??
resolveSearchProvider(search);
const provider =
providers.find((entry) => entry.id === providerId) ??
providers.find((entry) => entry.id === resolveSearchProvider(search)) ??
providers[0];
if (!provider) {
return null;
}
const definition = provider.createTool({
config: options?.config,
searchConfig: search as Record<string, unknown> | undefined,
runtimeMetadata: options?.runtimeWebSearch,
});
if (!definition) {
return null;
}
return {
label: "Web Search",
name: "web_search",
description: resolved.definition.description,
parameters: resolved.definition.parameters,
execute: async (_toolCallId, args) => jsonResult(await resolved.definition.execute(args)),
description: definition.description,
parameters: definition.parameters,
execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)),
};
}
export const __testing = {
SEARCH_CACHE,
...runtimeTesting,
resolveSearchProvider,
};

View File

@ -12,7 +12,10 @@ import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { SecretInputMode } from "./onboard-types.js";
export type SearchProvider = string;
export type SearchProvider = NonNullable<
NonNullable<NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"]>["provider"]
>;
type SearchConfig = NonNullable<NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"]>;
type SearchProviderEntry = {
value: SearchProvider;
@ -29,7 +32,7 @@ export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] =
resolvePluginWebSearchProviders({
bundledAllowlistCompat: true,
}).map((provider) => ({
value: provider.id,
value: provider.id as SearchProvider,
label: provider.label,
hint: provider.hint,
envKeys: provider.envVars,
@ -44,14 +47,12 @@ export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
}
function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown {
const search = config.tools?.web?.search;
const entry = resolvePluginWebSearchProviders({
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
return (
entry?.getConfiguredCredentialValue?.(config) ??
entry?.getCredentialValue(config.tools?.web?.search as Record<string, unknown> | undefined)
);
return entry?.getCredentialValue(search as Record<string, unknown> | undefined);
}
/** Returns the plaintext key string, or undefined for SecretRefs/missing. */
@ -101,24 +102,17 @@ export function applySearchKey(
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
const nextBase = {
const search: SearchConfig = { ...config.tools?.web?.search, provider, enabled: true };
if (providerEntry) {
providerEntry.setCredentialValue(search as Record<string, unknown>, key);
}
const nextBase: OpenClawConfig = {
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search: { ...config.tools?.web?.search, provider, enabled: true },
},
web: { ...config.tools?.web, search },
},
};
if (providerEntry?.setConfiguredCredentialValue) {
providerEntry.setConfiguredCredentialValue(nextBase, key);
} else {
const search = nextBase.tools?.web?.search as Record<string, unknown> | undefined;
if (providerEntry && search) {
providerEntry.setCredentialValue(search, key);
}
}
return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
}
@ -127,17 +121,18 @@ function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): Op
config,
bundledAllowlistCompat: true,
}).find((candidate) => candidate.id === provider);
const nextBase = {
const search: SearchConfig = {
...config.tools?.web?.search,
provider,
enabled: true,
};
const nextBase: OpenClawConfig = {
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search: {
...config.tools?.web?.search,
provider,
enabled: true,
},
search,
},
},
};
@ -198,7 +193,7 @@ export async function setupSearch(
return SEARCH_PROVIDER_OPTIONS[0].value;
})();
type PickerValue = string;
type PickerValue = SearchProvider | "__skip__";
const choice = await prompter.select<PickerValue>({
message: "Search provider",
options: [
@ -278,16 +273,17 @@ export async function setupSearch(
"Web search",
);
const search: SearchConfig = {
...config.tools?.web?.search,
provider: choice,
};
return {
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search: {
...config.tools?.web?.search,
provider: choice,
},
search,
},
},
};

View File

@ -1,10 +1,23 @@
export type { SignalAccountConfig } from "../config/types.js";
export type { ChannelPlugin } from "./channel-plugin-common.js";
export {
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
buildChannelConfigSchema,
deleteAccountFromConfigSection,
getChatChannelMeta,
setAccountEnabledInConfigSection,
} from "./channel-plugin-common.js";
export { SignalConfigSchema } from "../config/zod-schema.providers-core.js";
export {
looksLikeSignalTargetId,
normalizeSignalMessagingTarget,
} from "../channels/plugins/normalize/signal.js";
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
export { normalizeE164 } from "../utils.js";
export {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
collectStatusIssuesFromLastError,
createDefaultChannelRuntimeState,
} from "./status-helpers.js";

View File

@ -52,12 +52,9 @@ export {
listSignalAccountIds,
resolveDefaultSignalAccountId,
} from "../../extensions/signal/api.js";
export { resolveSignalReactionLevel } from "../../extensions/signal/src/reaction-level.js";
export { signalMessageActions } from "../../extensions/signal/src/message-actions.js";
export { monitorSignalProvider } from "../../extensions/signal/src/monitor.js";
export { probeSignal } from "../../extensions/signal/src/probe.js";
export {
removeReactionSignal,
sendReactionSignal,
} from "../../extensions/signal/src/send-reactions.js";
export { sendMessageSignal } from "../../extensions/signal/src/send.js";
export { monitorSignalProvider } from "../../extensions/signal/runtime-api.js";
export { probeSignal } from "../../extensions/signal/runtime-api.js";
export { resolveSignalReactionLevel } from "../../extensions/signal/runtime-api.js";
export { removeReactionSignal, sendReactionSignal } from "../../extensions/signal/runtime-api.js";
export { sendMessageSignal } from "../../extensions/signal/runtime-api.js";
export { signalMessageActions } from "../../extensions/signal/runtime-api.js";

View File

@ -8,7 +8,6 @@ import {
setupAuthTestEnv,
} from "../../../test/helpers/auth-wizard.js";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js";
import { applyAuthChoiceLoadedPluginProvider } from "../../plugins/provider-auth-choice.js";
import { buildProviderPluginMethodChoice } from "../provider-wizard.js";
import { requireProviderContractProvider, uniqueProviderContractProviders } from "./registry.js";
import { registerProviders, requireProvider } from "./testkit.js";
@ -28,23 +27,6 @@ const runProviderModelSelectedHookMock = vi.hoisted(() =>
vi.fn<RunProviderModelSelectedHook>(async () => {}),
);
vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({
loginQwenPortalOAuth: loginQwenPortalOAuthMock,
}));
vi.mock("../../providers/github-copilot-auth.js", () => ({
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
}));
vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({
resolvePluginProviders: resolvePluginProvidersMock,
resolveProviderPluginChoice: resolveProviderPluginChoiceMock,
runProviderModelSelectedHook: runProviderModelSelectedHookMock,
}));
const { resolvePreferredProviderForAuthChoice } =
await import("../../plugins/provider-auth-choice-preference.js");
type StoredAuthProfile = {
type?: string;
provider?: string;
@ -54,7 +36,9 @@ type StoredAuthProfile = {
token?: string;
};
const qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default;
let applyAuthChoiceLoadedPluginProvider: typeof import("../../plugins/provider-auth-choice.js").applyAuthChoiceLoadedPluginProvider;
let resolvePreferredProviderForAuthChoice: typeof import("../../plugins/provider-auth-choice-preference.js").resolvePreferredProviderForAuthChoice;
let qwenPortalPlugin: (typeof import("../../../extensions/qwen-portal-auth/index.js"))["default"];
describe("provider auth-choice contract", () => {
const lifecycle = createAuthTestLifecycle([
@ -73,7 +57,24 @@ describe("provider auth-choice contract", () => {
lifecycle.setStateDir(env.stateDir);
}
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("../../../extensions/qwen-portal-auth/oauth.js", () => ({
loginQwenPortalOAuth: loginQwenPortalOAuthMock,
}));
vi.doMock("../../providers/github-copilot-auth.js", () => ({
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
}));
vi.doMock("../../plugins/provider-auth-choice.runtime.js", () => ({
resolvePluginProviders: resolvePluginProvidersMock,
resolveProviderPluginChoice: resolveProviderPluginChoiceMock,
runProviderModelSelectedHook: runProviderModelSelectedHookMock,
}));
({ applyAuthChoiceLoadedPluginProvider } =
await import("../../plugins/provider-auth-choice.js"));
({ resolvePreferredProviderForAuthChoice } =
await import("../../plugins/provider-auth-choice-preference.js"));
({ default: qwenPortalPlugin } = await import("../../../extensions/qwen-portal-auth/index.js"));
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders);
resolveProviderPluginChoiceMock.mockReset();
@ -95,6 +96,7 @@ describe("provider auth-choice contract", () => {
});
afterEach(async () => {
vi.restoreAllMocks();
loginQwenPortalOAuthMock.mockReset();
githubCopilotLoginCommandMock.mockReset();
resolvePluginProvidersMock.mockReset();

View File

@ -1,8 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots,
} from "../../agents/auth-profiles/store.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import { createNonExitingRuntime } from "../../runtime.js";
import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js";
import type {
@ -14,34 +12,51 @@ import type {
import type { OpenClawPluginApi, ProviderPlugin } from "../types.js";
type LoginOpenAICodexOAuth =
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"];
(typeof import("openclaw/plugin-sdk/provider-auth"))["loginOpenAICodexOAuth"];
type LoginQwenPortalOAuth =
(typeof import("../../../extensions/qwen-portal-auth/oauth.js"))["loginQwenPortalOAuth"];
type GithubCopilotLoginCommand =
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"];
(typeof import("openclaw/plugin-sdk/provider-auth"))["githubCopilotLoginCommand"];
type CreateVpsAwareHandlers =
(typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"];
type EnsureAuthProfileStore =
typeof import("openclaw/plugin-sdk/agent-runtime").ensureAuthProfileStore;
type ListProfilesForProvider =
typeof import("openclaw/plugin-sdk/agent-runtime").listProfilesForProvider;
const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn<LoginOpenAICodexOAuth>());
const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn<LoginQwenPortalOAuth>());
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn<GithubCopilotLoginCommand>());
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn<EnsureAuthProfileStore>());
const listProfilesForProviderMock = vi.hoisted(() => vi.fn<ListProfilesForProvider>());
vi.mock("openclaw/plugin-sdk/provider-auth-login", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth-login")>();
vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth")>();
return {
...actual,
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
};
});
vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-runtime")>();
return {
...actual,
ensureAuthProfileStore: ensureAuthProfileStoreMock,
listProfilesForProvider: listProfilesForProviderMock,
};
});
vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({
loginQwenPortalOAuth: loginQwenPortalOAuthMock,
}));
const openAIPlugin = (await import("../../../extensions/openai/index.js")).default;
const qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default;
const githubCopilotPlugin = (await import("../../../extensions/github-copilot/index.js")).default;
import githubCopilotPlugin from "../../../extensions/github-copilot/index.js";
import openAIPlugin from "../../../extensions/openai/index.js";
import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js";
function registerProviders(...plugins: Array<{ register(api: OpenClawPluginApi): void }>) {
const captured = createCapturedPluginRegistration();
@ -96,10 +111,26 @@ function buildAuthContext() {
}
describe("provider auth contract", () => {
let authStore: AuthProfileStore;
beforeEach(() => {
authStore = { version: 1, profiles: {} };
ensureAuthProfileStoreMock.mockReset();
ensureAuthProfileStoreMock.mockImplementation(() => authStore);
listProfilesForProviderMock.mockReset();
listProfilesForProviderMock.mockImplementation((store, providerId) =>
Object.entries(store.profiles)
.filter(([, credential]) => credential?.provider === providerId)
.map(([profileId]) => profileId),
);
});
afterEach(() => {
loginOpenAICodexOAuthMock.mockReset();
loginQwenPortalOAuthMock.mockReset();
githubCopilotLoginCommandMock.mockReset();
ensureAuthProfileStoreMock.mockReset();
listProfilesForProviderMock.mockReset();
clearRuntimeAuthProfileStoreSnapshots();
});
@ -197,20 +228,11 @@ describe("provider auth contract", () => {
it("keeps GitHub Copilot device auth results provider-owned", async () => {
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
replaceRuntimeAuthProfileStoreSnapshots([
{
store: {
version: 1,
profiles: {
"github-copilot:github": {
type: "token",
provider: "github-copilot",
token: "github-device-token",
},
},
},
},
]);
authStore.profiles["github-copilot:github"] = {
type: "token" as const,
provider: "github-copilot",
token: "github-device-token",
};
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");

View File

@ -1,11 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots,
} from "../../agents/auth-profiles/store.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QWEN_OAUTH_MARKER } from "../../agents/model-auth-markers.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import { runProviderCatalog } from "../provider-discovery.js";
import { registerProviders, requireProvider } from "./testkit.js";
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
@ -13,66 +8,18 @@ const buildOllamaProviderMock = vi.hoisted(() => vi.fn());
const buildVllmProviderMock = vi.hoisted(() => vi.fn());
const buildSglangProviderMock = vi.hoisted(() => vi.fn());
vi.mock("../../../extensions/github-copilot/token.js", async () => {
const actual = await vi.importActual<object>("../../../extensions/github-copilot/token.js");
return {
...actual,
resolveCopilotApiToken: resolveCopilotApiTokenMock,
};
});
vi.mock("openclaw/plugin-sdk/provider-setup", async () => {
const actual = await vi.importActual<object>("openclaw/plugin-sdk/provider-setup");
return {
...actual,
buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args),
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/self-hosted-provider-setup", async () => {
const actual = await vi.importActual<object>("openclaw/plugin-sdk/self-hosted-provider-setup");
return {
...actual,
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/ollama-setup", async () => {
const actual = await vi.importActual<object>("openclaw/plugin-sdk/ollama-setup");
return {
...actual,
buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args),
};
});
const qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default;
const githubCopilotPlugin = (await import("../../../extensions/github-copilot/index.js")).default;
const ollamaPlugin = (await import("../../../extensions/ollama/index.js")).default;
const vllmPlugin = (await import("../../../extensions/vllm/index.js")).default;
const sglangPlugin = (await import("../../../extensions/sglang/index.js")).default;
const minimaxPlugin = (await import("../../../extensions/minimax/index.js")).default;
const modelStudioPlugin = (await import("../../../extensions/modelstudio/index.js")).default;
const cloudflareAiGatewayPlugin = (
await import("../../../extensions/cloudflare-ai-gateway/index.js")
).default;
const qwenPortalProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
const githubCopilotProvider = requireProvider(
registerProviders(githubCopilotPlugin),
"github-copilot",
);
const ollamaProvider = requireProvider(registerProviders(ollamaPlugin), "ollama");
const vllmProvider = requireProvider(registerProviders(vllmPlugin), "vllm");
const sglangProvider = requireProvider(registerProviders(sglangPlugin), "sglang");
const minimaxProvider = requireProvider(registerProviders(minimaxPlugin), "minimax");
const minimaxPortalProvider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal");
const modelStudioProvider = requireProvider(registerProviders(modelStudioPlugin), "modelstudio");
const cloudflareAiGatewayProvider = requireProvider(
registerProviders(cloudflareAiGatewayPlugin),
"cloudflare-ai-gateway",
);
let runProviderCatalog: typeof import("../provider-discovery.js").runProviderCatalog;
let qwenPortalProvider: Awaited<ReturnType<typeof requireProvider>>;
let githubCopilotProvider: Awaited<ReturnType<typeof requireProvider>>;
let ollamaProvider: Awaited<ReturnType<typeof requireProvider>>;
let vllmProvider: Awaited<ReturnType<typeof requireProvider>>;
let sglangProvider: Awaited<ReturnType<typeof requireProvider>>;
let minimaxProvider: Awaited<ReturnType<typeof requireProvider>>;
let minimaxPortalProvider: Awaited<ReturnType<typeof requireProvider>>;
let modelStudioProvider: Awaited<ReturnType<typeof requireProvider>>;
let cloudflareAiGatewayProvider: Awaited<ReturnType<typeof requireProvider>>;
let clearRuntimeAuthProfileStoreSnapshots: typeof import("../../agents/auth-profiles/store.js").clearRuntimeAuthProfileStoreSnapshots;
let replaceRuntimeAuthProfileStoreSnapshots: typeof import("../../agents/auth-profiles/store.js").replaceRuntimeAuthProfileStoreSnapshots;
function createModelConfig(id: string, name = id): ModelDefinitionConfig {
return {
@ -159,7 +106,83 @@ function runCatalog(params: {
}
describe("provider discovery contract", () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("../../../extensions/github-copilot/token.js", async () => {
const actual = await vi.importActual<object>("../../../extensions/github-copilot/token.js");
return {
...actual,
resolveCopilotApiToken: resolveCopilotApiTokenMock,
};
});
vi.doMock("openclaw/plugin-sdk/provider-setup", async () => {
const actual = await vi.importActual<object>("openclaw/plugin-sdk/provider-setup");
return {
...actual,
buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args),
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
};
});
vi.doMock("openclaw/plugin-sdk/self-hosted-provider-setup", async () => {
const actual = await vi.importActual<object>(
"openclaw/plugin-sdk/self-hosted-provider-setup",
);
return {
...actual,
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
};
});
vi.doMock("openclaw/plugin-sdk/ollama-setup", async () => {
const actual = await vi.importActual<object>("openclaw/plugin-sdk/ollama-setup");
return {
...actual,
buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args),
};
});
({ clearRuntimeAuthProfileStoreSnapshots, replaceRuntimeAuthProfileStoreSnapshots } =
await import("../../agents/auth-profiles/store.js"));
({ runProviderCatalog } = await import("../provider-discovery.js"));
const [
{ default: qwenPortalPlugin },
{ default: githubCopilotPlugin },
{ default: ollamaPlugin },
{ default: vllmPlugin },
{ default: sglangPlugin },
{ default: minimaxPlugin },
{ default: modelStudioPlugin },
{ default: cloudflareAiGatewayPlugin },
] = await Promise.all([
import("../../../extensions/qwen-portal-auth/index.js"),
import("../../../extensions/github-copilot/index.js"),
import("../../../extensions/ollama/index.js"),
import("../../../extensions/vllm/index.js"),
import("../../../extensions/sglang/index.js"),
import("../../../extensions/minimax/index.js"),
import("../../../extensions/modelstudio/index.js"),
import("../../../extensions/cloudflare-ai-gateway/index.js"),
]);
qwenPortalProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
githubCopilotProvider = requireProvider(
registerProviders(githubCopilotPlugin),
"github-copilot",
);
ollamaProvider = requireProvider(registerProviders(ollamaPlugin), "ollama");
vllmProvider = requireProvider(registerProviders(vllmPlugin), "vllm");
sglangProvider = requireProvider(registerProviders(sglangPlugin), "sglang");
minimaxProvider = requireProvider(registerProviders(minimaxPlugin), "minimax");
minimaxPortalProvider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal");
modelStudioProvider = requireProvider(registerProviders(modelStudioPlugin), "modelstudio");
cloudflareAiGatewayProvider = requireProvider(
registerProviders(cloudflareAiGatewayPlugin),
"cloudflare-ai-gateway",
);
});
afterEach(() => {
vi.restoreAllMocks();
resolveCopilotApiTokenMock.mockReset();
buildOllamaProviderMock.mockReset();
buildVllmProviderMock.mockReset();

View File

@ -1,8 +1,43 @@
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { withBundledPluginEnablementCompat } from "../bundled-compat.js";
import { resolveBundledWebSearchPluginIds } from "../bundled-web-search.js";
import { loadOpenClawPlugins } from "../loader.js";
import { createPluginLoaderLogger } from "../logger.js";
import amazonBedrockPlugin from "../../../extensions/amazon-bedrock/index.js";
import anthropicPlugin from "../../../extensions/anthropic/index.js";
import bravePlugin from "../../../extensions/brave/index.js";
import byteplusPlugin from "../../../extensions/byteplus/index.js";
import chutesPlugin from "../../../extensions/chutes/index.js";
import cloudflareAiGatewayPlugin from "../../../extensions/cloudflare-ai-gateway/index.js";
import copilotProxyPlugin from "../../../extensions/copilot-proxy/index.js";
import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js";
import falPlugin from "../../../extensions/fal/index.js";
import firecrawlPlugin from "../../../extensions/firecrawl/index.js";
import githubCopilotPlugin from "../../../extensions/github-copilot/index.js";
import googlePlugin from "../../../extensions/google/index.js";
import huggingFacePlugin from "../../../extensions/huggingface/index.js";
import kilocodePlugin from "../../../extensions/kilocode/index.js";
import kimiCodingPlugin from "../../../extensions/kimi-coding/index.js";
import microsoftPlugin from "../../../extensions/microsoft/index.js";
import minimaxPlugin from "../../../extensions/minimax/index.js";
import mistralPlugin from "../../../extensions/mistral/index.js";
import modelStudioPlugin from "../../../extensions/modelstudio/index.js";
import moonshotPlugin from "../../../extensions/moonshot/index.js";
import nvidiaPlugin from "../../../extensions/nvidia/index.js";
import ollamaPlugin from "../../../extensions/ollama/index.js";
import openAIPlugin from "../../../extensions/openai/index.js";
import opencodeGoPlugin from "../../../extensions/opencode-go/index.js";
import opencodePlugin from "../../../extensions/opencode/index.js";
import openrouterPlugin from "../../../extensions/openrouter/index.js";
import perplexityPlugin from "../../../extensions/perplexity/index.js";
import qianfanPlugin from "../../../extensions/qianfan/index.js";
import qwenPortalAuthPlugin from "../../../extensions/qwen-portal-auth/index.js";
import sglangPlugin from "../../../extensions/sglang/index.js";
import syntheticPlugin from "../../../extensions/synthetic/index.js";
import togetherPlugin from "../../../extensions/together/index.js";
import venicePlugin from "../../../extensions/venice/index.js";
import vercelAiGatewayPlugin from "../../../extensions/vercel-ai-gateway/index.js";
import vllmPlugin from "../../../extensions/vllm/index.js";
import volcenginePlugin from "../../../extensions/volcengine/index.js";
import xaiPlugin from "../../../extensions/xai/index.js";
import xiaomiPlugin from "../../../extensions/xiaomi/index.js";
import zaiPlugin from "../../../extensions/zai/index.js";
import { createCapturedPluginRegistration } from "../captured-registration.js";
import { resolvePluginProviders } from "../providers.js";
import type {
ImageGenerationProviderPlugin,
@ -12,6 +47,11 @@ import type {
WebSearchProviderPlugin,
} from "../types.js";
type RegistrablePlugin = {
id: string;
register: (api: ReturnType<typeof createCapturedPluginRegistration>["api"]) => void;
};
type CapabilityContractEntry<T> = {
pluginId: string;
provider: T;
@ -38,30 +78,57 @@ type PluginRegistrationContractEntry = {
toolNames: string[];
};
const log = createSubsystemLogger("plugins");
const bundledWebSearchPlugins: Array<RegistrablePlugin & { credentialValue: unknown }> = [
{ ...bravePlugin, credentialValue: "BSA-test" },
{ ...firecrawlPlugin, credentialValue: "fc-test" },
{ ...googlePlugin, credentialValue: "AIza-test" },
{ ...moonshotPlugin, credentialValue: "sk-test" },
{ ...perplexityPlugin, credentialValue: "pplx-test" },
{ ...xaiPlugin, credentialValue: "xai-test" },
];
const BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES: Readonly<Record<string, unknown>> = {
brave: "BSA-test",
firecrawl: "fc-test",
google: "AIza-test",
moonshot: "sk-test",
perplexity: "pplx-test",
xai: "xai-test",
};
const bundledSpeechPlugins: RegistrablePlugin[] = [elevenLabsPlugin, microsoftPlugin, openAIPlugin];
const BUNDLED_SPEECH_PLUGIN_IDS = ["elevenlabs", "microsoft", "openai"] as const;
const BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS = [
"anthropic",
"google",
"minimax",
"mistral",
"moonshot",
"openai",
"zai",
] as const;
const BUNDLED_IMAGE_GENERATION_PLUGIN_IDS = ["fal", "google", "openai"] as const;
const bundledMediaUnderstandingPlugins: RegistrablePlugin[] = [
anthropicPlugin,
googlePlugin,
minimaxPlugin,
mistralPlugin,
moonshotPlugin,
openAIPlugin,
zaiPlugin,
];
export const providerContractRegistry: ProviderContractEntry[] = [];
const bundledImageGenerationPlugins: RegistrablePlugin[] = [googlePlugin, openAIPlugin];
function captureRegistrations(plugin: RegistrablePlugin) {
const captured = createCapturedPluginRegistration();
plugin.register(captured.api);
return captured;
}
function buildCapabilityContractRegistry<T>(params: {
plugins: RegistrablePlugin[];
select: (captured: ReturnType<typeof createCapturedPluginRegistration>) => T[];
}): CapabilityContractEntry<T>[] {
return params.plugins.flatMap((plugin) => {
const captured = captureRegistrations(plugin);
return params.select(captured).map((provider) => ({
pluginId: plugin.id,
provider,
}));
});
}
function dedupePlugins<T extends RegistrablePlugin>(
plugins: ReadonlyArray<T | undefined | null>,
): T[] {
return [
...new Map(
plugins.filter((plugin): plugin is T => Boolean(plugin)).map((plugin) => [plugin.id, plugin]),
).values(),
];
}
export let providerContractLoadError: Error | undefined;
@ -87,78 +154,111 @@ function loadBundledProviderRegistry(): ProviderContractEntry[] {
}
}
const loadedBundledProviderRegistry: ProviderContractEntry[] = loadBundledProviderRegistry();
providerContractRegistry.splice(
0,
providerContractRegistry.length,
...loadedBundledProviderRegistry,
);
export const uniqueProviderContractProviders: ProviderPlugin[] = [
...new Map(providerContractRegistry.map((entry) => [entry.provider.id, entry.provider])).values(),
];
export const providerContractPluginIds = [
...new Set(providerContractRegistry.map((entry) => entry.pluginId)),
].toSorted((left, right) => left.localeCompare(right));
export const providerContractCompatPluginIds = providerContractPluginIds.map((pluginId) =>
pluginId === "kimi-coding" ? "kimi" : pluginId,
);
const bundledCapabilityContractPluginIds = [
...new Set([
...providerContractCompatPluginIds,
...resolveBundledWebSearchPluginIds({}),
...BUNDLED_SPEECH_PLUGIN_IDS,
...BUNDLED_MEDIA_UNDERSTANDING_PLUGIN_IDS,
...BUNDLED_IMAGE_GENERATION_PLUGIN_IDS,
]),
].toSorted((left, right) => left.localeCompare(right));
export let capabilityContractLoadError: Error | undefined;
function loadBundledCapabilityRegistry() {
try {
capabilityContractLoadError = undefined;
return loadOpenClawPlugins({
config: withBundledPluginEnablementCompat({
config: {
plugins: {
enabled: true,
allow: bundledCapabilityContractPluginIds,
slots: {
memory: "none",
},
},
},
pluginIds: bundledCapabilityContractPluginIds,
}),
cache: false,
activate: false,
logger: createPluginLoaderLogger(log),
});
} catch (error) {
capabilityContractLoadError = error instanceof Error ? error : new Error(String(error));
return loadOpenClawPlugins({
config: {
plugins: {
enabled: false,
},
},
cache: false,
activate: false,
logger: createPluginLoaderLogger(log),
});
}
function createLazyArrayView<T>(load: () => T[]): T[] {
return new Proxy([] as T[], {
get(_target, prop) {
const actual = load();
const value = Reflect.get(actual, prop, actual);
return typeof value === "function" ? value.bind(actual) : value;
},
has(_target, prop) {
return Reflect.has(load(), prop);
},
ownKeys() {
return Reflect.ownKeys(load());
},
getOwnPropertyDescriptor(_target, prop) {
const actual = load();
const descriptor = Reflect.getOwnPropertyDescriptor(actual, prop);
if (descriptor) {
return descriptor;
}
if (Reflect.has(actual, prop)) {
return {
configurable: true,
enumerable: true,
writable: false,
value: Reflect.get(actual, prop, actual),
};
}
return undefined;
},
});
}
const loadedBundledCapabilityRegistry = loadBundledCapabilityRegistry();
let providerContractRegistryCache: ProviderContractEntry[] | null = null;
let webSearchProviderContractRegistryCache: WebSearchProviderContractEntry[] | null = null;
let speechProviderContractRegistryCache: SpeechProviderContractEntry[] | null = null;
let mediaUnderstandingProviderContractRegistryCache:
| MediaUnderstandingProviderContractEntry[]
| null = null;
let imageGenerationProviderContractRegistryCache: ImageGenerationProviderContractEntry[] | null =
null;
let pluginRegistrationContractRegistryCache: PluginRegistrationContractEntry[] | null = null;
let providerRegistrationEntriesLoaded = false;
function loadProviderContractRegistry(): ProviderContractEntry[] {
if (!providerContractRegistryCache) {
providerContractRegistryCache = buildCapabilityContractRegistry({
plugins: bundledProviderPlugins,
select: (captured) => captured.providers,
}).map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
}
if (!providerRegistrationEntriesLoaded) {
const registrationEntries = loadPluginRegistrationContractRegistry();
if (!providerRegistrationEntriesLoaded) {
mergeProviderContractRegistrations(registrationEntries, providerContractRegistryCache);
providerRegistrationEntriesLoaded = true;
}
}
return providerContractRegistryCache;
}
function loadUniqueProviderContractProviders(): ProviderPlugin[] {
return [
...new Map(
loadProviderContractRegistry().map((entry) => [entry.provider.id, entry.provider]),
).values(),
];
}
function loadProviderContractPluginIds(): string[] {
return [...new Set(loadProviderContractRegistry().map((entry) => entry.pluginId))].toSorted(
(left, right) => left.localeCompare(right),
);
}
function loadProviderContractCompatPluginIds(): string[] {
return loadProviderContractPluginIds().map((pluginId) =>
pluginId === "kimi-coding" ? "kimi" : pluginId,
);
}
export const providerContractRegistry: ProviderContractEntry[] = createLazyArrayView(
loadProviderContractRegistry,
);
export const uniqueProviderContractProviders: ProviderPlugin[] = createLazyArrayView(
loadUniqueProviderContractProviders,
);
export const providerContractPluginIds: string[] = createLazyArrayView(
loadProviderContractPluginIds,
);
export const providerContractCompatPluginIds: string[] = createLazyArrayView(
loadProviderContractCompatPluginIds,
);
export function requireProviderContractProvider(providerId: string): ProviderPlugin {
const provider = uniqueProviderContractProviders.find((entry) => entry.id === providerId);
if (!provider) {
if (!providerContractLoadError) {
loadBundledProviderRegistry();
}
if (providerContractLoadError) {
throw new Error(
`provider contract entry missing for ${providerId}; bundled provider registry failed to load: ${providerContractLoadError.message}`,
@ -195,51 +295,190 @@ export function resolveProviderContractProvidersForPluginIds(
];
}
export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] =
loadedBundledCapabilityRegistry.webSearchProviders
.filter((entry) => entry.pluginId in BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES)
.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
credentialValue: BUNDLED_WEB_SEARCH_CREDENTIAL_VALUES[entry.pluginId],
}));
function loadWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] {
if (!webSearchProviderContractRegistryCache) {
webSearchProviderContractRegistryCache = bundledWebSearchPlugins.flatMap((plugin) => {
const captured = captureRegistrations(plugin);
return captured.webSearchProviders.map((provider) => ({
pluginId: plugin.id,
provider,
credentialValue: plugin.credentialValue,
}));
});
}
return webSearchProviderContractRegistryCache;
}
export const speechProviderContractRegistry: SpeechProviderContractEntry[] =
loadedBundledCapabilityRegistry.speechProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
function loadSpeechProviderContractRegistry(): SpeechProviderContractEntry[] {
if (!speechProviderContractRegistryCache) {
speechProviderContractRegistryCache = buildCapabilityContractRegistry({
plugins: bundledSpeechPlugins,
select: (captured) => captured.speechProviders,
});
}
return speechProviderContractRegistryCache;
}
function loadMediaUnderstandingProviderContractRegistry(): MediaUnderstandingProviderContractEntry[] {
if (!mediaUnderstandingProviderContractRegistryCache) {
mediaUnderstandingProviderContractRegistryCache = buildCapabilityContractRegistry({
plugins: bundledMediaUnderstandingPlugins,
select: (captured) => captured.mediaUnderstandingProviders,
});
}
return mediaUnderstandingProviderContractRegistryCache;
}
function loadImageGenerationProviderContractRegistry(): ImageGenerationProviderContractEntry[] {
if (!imageGenerationProviderContractRegistryCache) {
imageGenerationProviderContractRegistryCache = buildCapabilityContractRegistry({
plugins: bundledImageGenerationPlugins,
select: (captured) => captured.imageGenerationProviders,
});
}
return imageGenerationProviderContractRegistryCache;
}
export const webSearchProviderContractRegistry: WebSearchProviderContractEntry[] =
createLazyArrayView(loadWebSearchProviderContractRegistry);
export const speechProviderContractRegistry: SpeechProviderContractEntry[] = createLazyArrayView(
loadSpeechProviderContractRegistry,
);
export const mediaUnderstandingProviderContractRegistry: MediaUnderstandingProviderContractEntry[] =
loadedBundledCapabilityRegistry.mediaUnderstandingProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
createLazyArrayView(loadMediaUnderstandingProviderContractRegistry);
export const imageGenerationProviderContractRegistry: ImageGenerationProviderContractEntry[] =
loadedBundledCapabilityRegistry.imageGenerationProviders.map((entry) => ({
pluginId: entry.pluginId,
provider: entry.provider,
}));
createLazyArrayView(loadImageGenerationProviderContractRegistry);
const bundledProviderPlugins = dedupePlugins([
amazonBedrockPlugin,
anthropicPlugin,
byteplusPlugin,
chutesPlugin,
cloudflareAiGatewayPlugin,
copilotProxyPlugin,
githubCopilotPlugin,
falPlugin,
googlePlugin,
huggingFacePlugin,
kilocodePlugin,
kimiCodingPlugin,
minimaxPlugin,
mistralPlugin,
modelStudioPlugin,
moonshotPlugin,
nvidiaPlugin,
ollamaPlugin,
openAIPlugin,
opencodePlugin,
opencodeGoPlugin,
openrouterPlugin,
qianfanPlugin,
qwenPortalAuthPlugin,
sglangPlugin,
syntheticPlugin,
togetherPlugin,
venicePlugin,
vercelAiGatewayPlugin,
vllmPlugin,
volcenginePlugin,
xaiPlugin,
xiaomiPlugin,
zaiPlugin,
]);
const bundledPluginRegistrationList = dedupePlugins([
...bundledSpeechPlugins,
...bundledMediaUnderstandingPlugins,
...bundledImageGenerationPlugins,
...bundledWebSearchPlugins,
]);
function mergeIds(existing: string[], next: string[]): string[] {
return next.length > 0 ? next : existing;
}
function upsertPluginRegistrationContractEntry(
entries: PluginRegistrationContractEntry[],
next: PluginRegistrationContractEntry,
): void {
const existing = entries.find((entry) => entry.pluginId === next.pluginId);
if (!existing) {
entries.push(next);
return;
}
existing.providerIds = mergeIds(existing.providerIds, next.providerIds);
existing.speechProviderIds = mergeIds(existing.speechProviderIds, next.speechProviderIds);
existing.mediaUnderstandingProviderIds = mergeIds(
existing.mediaUnderstandingProviderIds,
next.mediaUnderstandingProviderIds,
);
existing.imageGenerationProviderIds = mergeIds(
existing.imageGenerationProviderIds,
next.imageGenerationProviderIds,
);
existing.webSearchProviderIds = mergeIds(
existing.webSearchProviderIds,
next.webSearchProviderIds,
);
existing.toolNames = mergeIds(existing.toolNames, next.toolNames);
}
function mergeProviderContractRegistrations(
registrationEntries: PluginRegistrationContractEntry[],
providerEntries: ProviderContractEntry[],
): void {
const byPluginId = new Map<string, string[]>();
for (const entry of providerEntries) {
const providerIds = byPluginId.get(entry.pluginId) ?? [];
providerIds.push(entry.provider.id);
byPluginId.set(entry.pluginId, providerIds);
}
for (const [pluginId, providerIds] of byPluginId) {
upsertPluginRegistrationContractEntry(registrationEntries, {
pluginId,
providerIds: providerIds.toSorted((left, right) => left.localeCompare(right)),
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
toolNames: [],
});
}
}
function loadPluginRegistrationContractRegistry(): PluginRegistrationContractEntry[] {
if (!pluginRegistrationContractRegistryCache) {
const entries: PluginRegistrationContractEntry[] = [];
for (const plugin of bundledPluginRegistrationList) {
const captured = captureRegistrations(plugin);
upsertPluginRegistrationContractEntry(entries, {
pluginId: plugin.id,
providerIds: captured.providers.map((provider) => provider.id),
speechProviderIds: captured.speechProviders.map((provider) => provider.id),
mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map(
(provider) => provider.id,
),
imageGenerationProviderIds: captured.imageGenerationProviders.map(
(provider) => provider.id,
),
webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id),
toolNames: captured.tools.map((tool) => tool.name),
});
}
pluginRegistrationContractRegistryCache = entries;
}
if (providerContractRegistryCache && !providerRegistrationEntriesLoaded) {
mergeProviderContractRegistrations(
pluginRegistrationContractRegistryCache,
providerContractRegistryCache,
);
providerRegistrationEntriesLoaded = true;
}
return pluginRegistrationContractRegistryCache;
}
export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] =
loadedBundledCapabilityRegistry.plugins
.filter(
(plugin) =>
plugin.origin === "bundled" &&
(plugin.providerIds.length > 0 ||
plugin.speechProviderIds.length > 0 ||
plugin.mediaUnderstandingProviderIds.length > 0 ||
plugin.imageGenerationProviderIds.length > 0 ||
plugin.webSearchProviderIds.length > 0 ||
plugin.toolNames.length > 0),
)
.map((plugin) => ({
pluginId: plugin.id,
providerIds: plugin.providerIds,
speechProviderIds: plugin.speechProviderIds,
mediaUnderstandingProviderIds: plugin.mediaUnderstandingProviderIds,
imageGenerationProviderIds: plugin.imageGenerationProviderIds,
webSearchProviderIds: plugin.webSearchProviderIds,
toolNames: plugin.toolNames,
}));
createLazyArrayView(loadPluginRegistrationContractRegistry);

View File

@ -20,6 +20,7 @@ describe("web search runtime", () => {
envVars: ["CUSTOM_SEARCH_API_KEY"],
placeholder: "custom-...",
signupUrl: "https://example.com/signup",
credentialPath: "tools.web.search.custom.apiKey",
autoDetectOrder: 1,
credentialPath: "tools.web.search.custom.apiKey",
getCredentialValue: () => "configured",

View File

@ -611,8 +611,8 @@
"file": "src/plugins/runtime/runtime-whatsapp.ts",
"line": 85,
"kind": "dynamic-import",
"specifier": "../../../extensions/whatsapp/action-runtime.runtime.js",
"resolvedPath": "extensions/whatsapp/action-runtime.runtime.js",
"specifier": "../../../extensions/whatsapp/action-runtime-api.js",
"resolvedPath": "extensions/whatsapp/action-runtime-api.js",
"reason": "dynamically imports extension-owned file from src/plugins"
}
]