mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 14:30:57 +00:00
feat(web-search): add plugin-backed search providers
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { buildMoonshotProvider } from "../../src/agents/models-config.providers.static.js";
|
||||
import {
|
||||
createMoonshotThinkingWrapper,
|
||||
resolveMoonshotThinkingType,
|
||||
} from "../../src/agents/pi-embedded-runner/moonshot-stream-wrappers.js";
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getScopedCredentialValue,
|
||||
setScopedCredentialValue,
|
||||
} from "../../src/agents/tools/web-search-plugin-factory.js";
|
||||
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
|
||||
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
const PROVIDER_ID = "moonshot";
|
||||
|
||||
@@ -46,6 +52,21 @@ const moonshotPlugin = {
|
||||
return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType);
|
||||
},
|
||||
});
|
||||
api.registerWebSearchProvider(
|
||||
createPluginBackedWebSearchProvider({
|
||||
id: "kimi",
|
||||
label: "Kimi (Moonshot)",
|
||||
hint: "Moonshot web search",
|
||||
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
|
||||
placeholder: "sk-...",
|
||||
signupUrl: "https://platform.moonshot.cn/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 40,
|
||||
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"),
|
||||
setCredentialValue: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "kimi", value),
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
32
extensions/web-search-brave/index.ts
Normal file
32
extensions/web-search-brave/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getTopLevelCredentialValue,
|
||||
setTopLevelCredentialValue,
|
||||
} from "../../src/agents/tools/web-search-plugin-factory.js";
|
||||
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
|
||||
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
const braveSearchPlugin = {
|
||||
id: "web-search-brave",
|
||||
name: "Web Search Brave Provider",
|
||||
description: "Bundled Brave provider for the web_search tool",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerWebSearchProvider(
|
||||
createPluginBackedWebSearchProvider({
|
||||
id: "brave",
|
||||
label: "Brave Search",
|
||||
hint: "Structured results · country/language/time filters",
|
||||
envVars: ["BRAVE_API_KEY"],
|
||||
placeholder: "BSA...",
|
||||
signupUrl: "https://brave.com/search/api/",
|
||||
docsUrl: "https://docs.openclaw.ai/brave-search",
|
||||
autoDetectOrder: 10,
|
||||
getCredentialValue: getTopLevelCredentialValue,
|
||||
setCredentialValue: setTopLevelCredentialValue,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default braveSearchPlugin;
|
||||
8
extensions/web-search-brave/openclaw.plugin.json
Normal file
8
extensions/web-search-brave/openclaw.plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "web-search-brave",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/web-search-brave/package.json
Normal file
12
extensions/web-search-brave/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/web-search-brave",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw Brave web search provider plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
33
extensions/web-search-gemini/index.ts
Normal file
33
extensions/web-search-gemini/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getScopedCredentialValue,
|
||||
setScopedCredentialValue,
|
||||
} from "../../src/agents/tools/web-search-plugin-factory.js";
|
||||
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
|
||||
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
const geminiSearchPlugin = {
|
||||
id: "web-search-gemini",
|
||||
name: "Web Search Gemini Provider",
|
||||
description: "Bundled Gemini provider for the web_search tool",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerWebSearchProvider(
|
||||
createPluginBackedWebSearchProvider({
|
||||
id: "gemini",
|
||||
label: "Gemini (Google Search)",
|
||||
hint: "Google Search grounding · AI-synthesized",
|
||||
envVars: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 20,
|
||||
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"),
|
||||
setCredentialValue: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "gemini", value),
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default geminiSearchPlugin;
|
||||
8
extensions/web-search-gemini/openclaw.plugin.json
Normal file
8
extensions/web-search-gemini/openclaw.plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "web-search-gemini",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/web-search-gemini/package.json
Normal file
12
extensions/web-search-gemini/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/web-search-gemini",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw Gemini web search provider plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
33
extensions/web-search-grok/index.ts
Normal file
33
extensions/web-search-grok/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getScopedCredentialValue,
|
||||
setScopedCredentialValue,
|
||||
} from "../../src/agents/tools/web-search-plugin-factory.js";
|
||||
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
|
||||
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
const grokSearchPlugin = {
|
||||
id: "web-search-grok",
|
||||
name: "Web Search Grok Provider",
|
||||
description: "Bundled Grok provider for the web_search tool",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerWebSearchProvider(
|
||||
createPluginBackedWebSearchProvider({
|
||||
id: "grok",
|
||||
label: "Grok (xAI)",
|
||||
hint: "xAI web-grounded responses",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
placeholder: "xai-...",
|
||||
signupUrl: "https://console.x.ai/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 30,
|
||||
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"),
|
||||
setCredentialValue: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "grok", value),
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default grokSearchPlugin;
|
||||
8
extensions/web-search-grok/openclaw.plugin.json
Normal file
8
extensions/web-search-grok/openclaw.plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "web-search-grok",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/web-search-grok/package.json
Normal file
12
extensions/web-search-grok/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/web-search-grok",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw Grok web search provider plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
33
extensions/web-search-perplexity/index.ts
Normal file
33
extensions/web-search-perplexity/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getScopedCredentialValue,
|
||||
setScopedCredentialValue,
|
||||
} from "../../src/agents/tools/web-search-plugin-factory.js";
|
||||
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
|
||||
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
const perplexitySearchPlugin = {
|
||||
id: "web-search-perplexity",
|
||||
name: "Web Search Perplexity Provider",
|
||||
description: "Bundled Perplexity provider for the web_search tool",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerWebSearchProvider(
|
||||
createPluginBackedWebSearchProvider({
|
||||
id: "perplexity",
|
||||
label: "Perplexity Search",
|
||||
hint: "Structured results · domain/country/language/time filters",
|
||||
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
|
||||
placeholder: "pplx-...",
|
||||
signupUrl: "https://www.perplexity.ai/settings/api",
|
||||
docsUrl: "https://docs.openclaw.ai/perplexity",
|
||||
autoDetectOrder: 50,
|
||||
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"),
|
||||
setCredentialValue: (searchConfigTarget, value) =>
|
||||
setScopedCredentialValue(searchConfigTarget, "perplexity", value),
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default perplexitySearchPlugin;
|
||||
8
extensions/web-search-perplexity/openclaw.plugin.json
Normal file
8
extensions/web-search-perplexity/openclaw.plugin.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "web-search-perplexity",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/web-search-perplexity/package.json
Normal file
12
extensions/web-search-perplexity/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/web-search-perplexity",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw Perplexity web search provider plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
2235
src/agents/tools/web-search-core.ts
Normal file
2235
src/agents/tools/web-search-core.ts
Normal file
File diff suppressed because it is too large
Load Diff
85
src/agents/tools/web-search-plugin-factory.ts
Normal file
85
src/agents/tools/web-search-plugin-factory.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { WebSearchProviderPlugin } from "../../plugins/types.js";
|
||||
import { createWebSearchTool as createLegacyWebSearchTool } from "./web-search-core.js";
|
||||
|
||||
function cloneWithDescriptors<T extends object>(value: T | undefined): T {
|
||||
const next = Object.create(Object.getPrototypeOf(value ?? {})) as T;
|
||||
if (value) {
|
||||
Object.defineProperties(next, Object.getOwnPropertyDescriptors(value));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function withForcedProvider(config: OpenClawConfig | undefined, provider: string): OpenClawConfig {
|
||||
const next = cloneWithDescriptors(config ?? {});
|
||||
const tools = cloneWithDescriptors(next.tools ?? {});
|
||||
const web = cloneWithDescriptors(tools.web ?? {});
|
||||
const search = cloneWithDescriptors(web.search ?? {});
|
||||
|
||||
search.provider = provider;
|
||||
web.search = search;
|
||||
tools.web = web;
|
||||
next.tools = tools;
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function createPluginBackedWebSearchProvider(
|
||||
provider: Omit<WebSearchProviderPlugin, "createTool">,
|
||||
): WebSearchProviderPlugin {
|
||||
return {
|
||||
...provider,
|
||||
createTool: (ctx) => {
|
||||
const tool = createLegacyWebSearchTool({
|
||||
config: withForcedProvider(ctx.config, provider.id),
|
||||
runtimeWebSearch: ctx.runtimeMetadata,
|
||||
});
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
description: tool.description,
|
||||
parameters: tool.parameters as Record<string, unknown>,
|
||||
execute: async (args) => {
|
||||
const result = await tool.execute(`web-search:${provider.id}`, args);
|
||||
return (result.details ?? {}) as Record<string, unknown>;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getTopLevelCredentialValue(searchConfig?: Record<string, unknown>): unknown {
|
||||
return searchConfig?.apiKey;
|
||||
}
|
||||
|
||||
export function setTopLevelCredentialValue(
|
||||
searchConfigTarget: Record<string, unknown>,
|
||||
value: unknown,
|
||||
): void {
|
||||
searchConfigTarget.apiKey = value;
|
||||
}
|
||||
|
||||
export function getScopedCredentialValue(
|
||||
searchConfig: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): unknown {
|
||||
const scoped = searchConfig?.[key];
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
return undefined;
|
||||
}
|
||||
return (scoped as Record<string, unknown>).apiKey;
|
||||
}
|
||||
|
||||
export function setScopedCredentialValue(
|
||||
searchConfigTarget: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const scoped = searchConfigTarget[key];
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
searchConfigTarget[key] = { apiKey: value };
|
||||
return;
|
||||
}
|
||||
(scoped as Record<string, unknown>).apiKey = value;
|
||||
}
|
||||
@@ -1,48 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
const { withStrictWebToolsEndpointMock } = vi.hoisted(() => ({
|
||||
withStrictWebToolsEndpointMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/net/fetch-guard.js", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
vi.mock("./web-guarded-fetch.js", () => ({
|
||||
withStrictWebToolsEndpoint: withStrictWebToolsEndpointMock,
|
||||
}));
|
||||
|
||||
import { __testing } from "./web-search.js";
|
||||
|
||||
describe("web_search redirect resolution hardening", () => {
|
||||
const { resolveRedirectUrl } = __testing;
|
||||
async function resolveRedirectUrl() {
|
||||
const module = await import("./web-search-citation-redirect.js");
|
||||
return module.resolveCitationRedirectUrl;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.resetModules();
|
||||
withStrictWebToolsEndpointMock.mockReset();
|
||||
});
|
||||
|
||||
it("resolves redirects via SSRF-guarded HEAD requests", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(null, { status: 200 }),
|
||||
finalUrl: "https://example.com/final",
|
||||
release,
|
||||
const resolve = await resolveRedirectUrl();
|
||||
withStrictWebToolsEndpointMock.mockImplementation(async (_params, run) => {
|
||||
return await run({
|
||||
response: new Response(null, { status: 200 }),
|
||||
finalUrl: "https://example.com/final",
|
||||
});
|
||||
});
|
||||
|
||||
const resolved = await resolveRedirectUrl("https://example.com/start");
|
||||
const resolved = await resolve("https://example.com/start");
|
||||
expect(resolved).toBe("https://example.com/final");
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
||||
expect(withStrictWebToolsEndpointMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/start",
|
||||
timeoutMs: 5000,
|
||||
init: { method: "HEAD" },
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.proxy).toBeUndefined();
|
||||
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy).toBeUndefined();
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the original URL when guarded resolution fails", async () => {
|
||||
fetchWithSsrFGuardMock.mockRejectedValue(new Error("blocked"));
|
||||
await expect(resolveRedirectUrl("https://example.com/start")).resolves.toBe(
|
||||
"https://example.com/start",
|
||||
);
|
||||
const resolve = await resolveRedirectUrl();
|
||||
withStrictWebToolsEndpointMock.mockRejectedValue(new Error("blocked"));
|
||||
await expect(resolve("https://example.com/start")).resolves.toBe("https://example.com/start");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -244,18 +244,66 @@ describe("setupSearch", () => {
|
||||
});
|
||||
|
||||
it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => {
|
||||
const originalPerplexity = process.env.PERPLEXITY_API_KEY;
|
||||
const originalOpenRouter = process.env.OPENROUTER_API_KEY;
|
||||
delete process.env.PERPLEXITY_API_KEY;
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
secretInputMode: "ref", // pragma: allowlist secret
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "PERPLEXITY_API_KEY", // pragma: allowlist secret
|
||||
});
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
try {
|
||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
secretInputMode: "ref", // pragma: allowlist secret
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "PERPLEXITY_API_KEY", // pragma: allowlist secret
|
||||
});
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (originalPerplexity === undefined) {
|
||||
delete process.env.PERPLEXITY_API_KEY;
|
||||
} else {
|
||||
process.env.PERPLEXITY_API_KEY = originalPerplexity;
|
||||
}
|
||||
if (originalOpenRouter === undefined) {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
} else {
|
||||
process.env.OPENROUTER_API_KEY = originalOpenRouter;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers detected OPENROUTER_API_KEY SecretRef for perplexity ref mode", async () => {
|
||||
const originalPerplexity = process.env.PERPLEXITY_API_KEY;
|
||||
const originalOpenRouter = process.env.OPENROUTER_API_KEY;
|
||||
delete process.env.PERPLEXITY_API_KEY;
|
||||
process.env.OPENROUTER_API_KEY = "sk-or-test";
|
||||
const cfg: OpenClawConfig = {};
|
||||
try {
|
||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
secretInputMode: "ref", // pragma: allowlist secret
|
||||
});
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENROUTER_API_KEY", // pragma: allowlist secret
|
||||
});
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (originalPerplexity === undefined) {
|
||||
delete process.env.PERPLEXITY_API_KEY;
|
||||
} else {
|
||||
process.env.PERPLEXITY_API_KEY = originalPerplexity;
|
||||
}
|
||||
if (originalOpenRouter === undefined) {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
} else {
|
||||
process.env.OPENROUTER_API_KEY = originalOpenRouter;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("stores env-backed SecretRef when secretInputMode=ref for brave", async () => {
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "../config/types.secrets.js";
|
||||
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import type { SecretInputMode } from "./onboard-types.js";
|
||||
|
||||
export type SearchProvider = "brave" | "gemini" | "grok" | "kimi" | "perplexity";
|
||||
export type SearchProvider = string;
|
||||
|
||||
type SearchProviderEntry = {
|
||||
value: SearchProvider;
|
||||
@@ -21,48 +22,17 @@ type SearchProviderEntry = {
|
||||
signupUrl: string;
|
||||
};
|
||||
|
||||
export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [
|
||||
{
|
||||
value: "brave",
|
||||
label: "Brave Search",
|
||||
hint: "Structured results · country/language/time filters",
|
||||
envKeys: ["BRAVE_API_KEY"],
|
||||
placeholder: "BSA...",
|
||||
signupUrl: "https://brave.com/search/api/",
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
label: "Gemini (Google Search)",
|
||||
hint: "Google Search grounding · AI-synthesized",
|
||||
envKeys: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
},
|
||||
{
|
||||
value: "grok",
|
||||
label: "Grok (xAI)",
|
||||
hint: "xAI web-grounded responses",
|
||||
envKeys: ["XAI_API_KEY"],
|
||||
placeholder: "xai-...",
|
||||
signupUrl: "https://console.x.ai/",
|
||||
},
|
||||
{
|
||||
value: "kimi",
|
||||
label: "Kimi (Moonshot)",
|
||||
hint: "Moonshot web search",
|
||||
envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
|
||||
placeholder: "sk-...",
|
||||
signupUrl: "https://platform.moonshot.cn/",
|
||||
},
|
||||
{
|
||||
value: "perplexity",
|
||||
label: "Perplexity Search",
|
||||
hint: "Structured results · domain/country/language/time filters",
|
||||
envKeys: ["PERPLEXITY_API_KEY"],
|
||||
placeholder: "pplx-...",
|
||||
signupUrl: "https://www.perplexity.ai/settings/api",
|
||||
},
|
||||
] as const;
|
||||
export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] =
|
||||
resolvePluginWebSearchProviders({
|
||||
bundledAllowlistCompat: true,
|
||||
}).map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.label,
|
||||
hint: provider.hint,
|
||||
envKeys: provider.envVars,
|
||||
placeholder: provider.placeholder,
|
||||
signupUrl: provider.signupUrl,
|
||||
}));
|
||||
|
||||
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
|
||||
return entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
|
||||
@@ -70,18 +40,11 @@ export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
|
||||
|
||||
function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown {
|
||||
const search = config.tools?.web?.search;
|
||||
switch (provider) {
|
||||
case "brave":
|
||||
return search?.apiKey;
|
||||
case "gemini":
|
||||
return search?.gemini?.apiKey;
|
||||
case "grok":
|
||||
return search?.grok?.apiKey;
|
||||
case "kimi":
|
||||
return search?.kimi?.apiKey;
|
||||
case "perplexity":
|
||||
return search?.perplexity?.apiKey;
|
||||
}
|
||||
const entry = resolvePluginWebSearchProviders({
|
||||
config,
|
||||
bundledAllowlistCompat: true,
|
||||
}).find((candidate) => candidate.id === provider);
|
||||
return entry?.getCredentialValue(search as Record<string, unknown> | undefined);
|
||||
}
|
||||
|
||||
/** Returns the plaintext key string, or undefined for SecretRefs/missing. */
|
||||
@@ -128,22 +91,12 @@ export function applySearchKey(
|
||||
key: SecretInput,
|
||||
): OpenClawConfig {
|
||||
const search = { ...config.tools?.web?.search, provider, enabled: true };
|
||||
switch (provider) {
|
||||
case "brave":
|
||||
search.apiKey = key;
|
||||
break;
|
||||
case "gemini":
|
||||
search.gemini = { ...search.gemini, apiKey: key };
|
||||
break;
|
||||
case "grok":
|
||||
search.grok = { ...search.grok, apiKey: key };
|
||||
break;
|
||||
case "kimi":
|
||||
search.kimi = { ...search.kimi, apiKey: key };
|
||||
break;
|
||||
case "perplexity":
|
||||
search.perplexity = { ...search.perplexity, apiKey: key };
|
||||
break;
|
||||
const entry = resolvePluginWebSearchProviders({
|
||||
config,
|
||||
bundledAllowlistCompat: true,
|
||||
}).find((candidate) => candidate.id === provider);
|
||||
if (entry) {
|
||||
entry.setCredentialValue(search as Record<string, unknown>, key);
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
@@ -225,7 +178,7 @@ export async function setupSearch(
|
||||
return SEARCH_PROVIDER_OPTIONS[0].value;
|
||||
})();
|
||||
|
||||
type PickerValue = SearchProvider | "__skip__";
|
||||
type PickerValue = string;
|
||||
const choice = await prompter.select<PickerValue>({
|
||||
message: "Search provider",
|
||||
options: [
|
||||
@@ -236,7 +189,7 @@ export async function setupSearch(
|
||||
hint: "Configure later with openclaw configure --section web",
|
||||
},
|
||||
],
|
||||
initialValue: defaultProvider as PickerValue,
|
||||
initialValue: defaultProvider,
|
||||
});
|
||||
|
||||
if (choice === "__skip__") {
|
||||
|
||||
@@ -6,6 +6,40 @@ vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: { log: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/web-search-providers.js", () => {
|
||||
const getScoped = (key: string) => (search?: Record<string, unknown>) =>
|
||||
(search?.[key] as { apiKey?: unknown } | undefined)?.apiKey;
|
||||
return {
|
||||
resolvePluginWebSearchProviders: () => [
|
||||
{
|
||||
id: "brave",
|
||||
envVars: ["BRAVE_API_KEY"],
|
||||
getCredentialValue: (search?: Record<string, unknown>) => search?.apiKey,
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
envVars: ["GEMINI_API_KEY"],
|
||||
getCredentialValue: getScoped("gemini"),
|
||||
},
|
||||
{
|
||||
id: "grok",
|
||||
envVars: ["XAI_API_KEY"],
|
||||
getCredentialValue: getScoped("grok"),
|
||||
},
|
||||
{
|
||||
id: "kimi",
|
||||
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
|
||||
getCredentialValue: getScoped("kimi"),
|
||||
},
|
||||
{
|
||||
id: "perplexity",
|
||||
envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
|
||||
getCredentialValue: getScoped("perplexity"),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const { __testing } = await import("../agents/tools/web-search.js");
|
||||
const { resolveSearchProvider } = __testing;
|
||||
|
||||
|
||||
@@ -359,6 +359,7 @@ function createPluginRecord(params: {
|
||||
hookNames: [],
|
||||
channelIds: [],
|
||||
providerIds: [],
|
||||
webSearchProviderIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
|
||||
@@ -47,6 +47,7 @@ import type {
|
||||
PluginHookName,
|
||||
PluginHookHandlerMap,
|
||||
PluginHookRegistration as TypedPluginHookRegistration,
|
||||
WebSearchProviderPlugin,
|
||||
} from "./types.js";
|
||||
|
||||
export type PluginToolRegistration = {
|
||||
@@ -103,6 +104,14 @@ export type PluginProviderRegistration = {
|
||||
rootDir?: string;
|
||||
};
|
||||
|
||||
export type PluginWebSearchProviderRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
provider: WebSearchProviderPlugin;
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
};
|
||||
|
||||
export type PluginHookRegistration = {
|
||||
pluginId: string;
|
||||
entry: HookEntry;
|
||||
@@ -147,6 +156,7 @@ export type PluginRecord = {
|
||||
hookNames: string[];
|
||||
channelIds: string[];
|
||||
providerIds: string[];
|
||||
webSearchProviderIds: string[];
|
||||
gatewayMethods: string[];
|
||||
cliCommands: string[];
|
||||
services: string[];
|
||||
@@ -166,6 +176,7 @@ export type PluginRegistry = {
|
||||
channels: PluginChannelRegistration[];
|
||||
channelSetups: PluginChannelSetupRegistration[];
|
||||
providers: PluginProviderRegistration[];
|
||||
webSearchProviders: PluginWebSearchProviderRegistration[];
|
||||
gatewayHandlers: GatewayRequestHandlers;
|
||||
httpRoutes: PluginHttpRouteRegistration[];
|
||||
cliRegistrars: PluginCliRegistration[];
|
||||
@@ -210,6 +221,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
channels: [],
|
||||
channelSetups: [],
|
||||
providers: [],
|
||||
webSearchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
@@ -541,6 +553,37 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
});
|
||||
};
|
||||
|
||||
const registerWebSearchProvider = (record: PluginRecord, provider: WebSearchProviderPlugin) => {
|
||||
const id = provider.id.trim();
|
||||
if (!id) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "web search provider registration missing id",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existing = registry.webSearchProviders.find((entry) => entry.provider.id === id);
|
||||
if (existing) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `web search provider already registered: ${id} (${existing.pluginId})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
record.webSearchProviderIds.push(id);
|
||||
registry.webSearchProviders.push({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
provider,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
});
|
||||
};
|
||||
|
||||
const registerCli = (
|
||||
record: PluginRecord,
|
||||
registrar: OpenClawPluginCliRegistrar,
|
||||
@@ -749,6 +792,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerChannel: (registration) => registerChannel(record, registration, registrationMode),
|
||||
registerProvider:
|
||||
registrationMode === "full" ? (provider) => registerProvider(record, provider) : () => {},
|
||||
registerWebSearchProvider:
|
||||
registrationMode === "full"
|
||||
? (provider) => registerWebSearchProvider(record, provider)
|
||||
: () => {},
|
||||
registerGatewayMethod:
|
||||
registrationMode === "full"
|
||||
? (method, handler) => registerGatewayMethod(record, method, handler)
|
||||
@@ -818,6 +865,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerTool,
|
||||
registerChannel,
|
||||
registerProvider,
|
||||
registerWebSearchProvider,
|
||||
registerGatewayMethod,
|
||||
registerCli,
|
||||
registerService,
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { InternalHookHandler } from "../hooks/internal-hooks.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
|
||||
@@ -565,6 +566,34 @@ export type ProviderPlugin = {
|
||||
onModelSelected?: (ctx: ProviderModelSelectedContext) => Promise<void>;
|
||||
};
|
||||
|
||||
export type WebSearchProviderId = string;
|
||||
|
||||
export type WebSearchProviderToolDefinition = {
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
execute: (args: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type WebSearchProviderContext = {
|
||||
config?: OpenClawConfig;
|
||||
searchConfig?: Record<string, unknown>;
|
||||
runtimeMetadata?: RuntimeWebSearchMetadata;
|
||||
};
|
||||
|
||||
export type WebSearchProviderPlugin = {
|
||||
id: WebSearchProviderId;
|
||||
label: string;
|
||||
hint: string;
|
||||
envVars: string[];
|
||||
placeholder: string;
|
||||
signupUrl: string;
|
||||
docsUrl?: string;
|
||||
autoDetectOrder?: number;
|
||||
getCredentialValue: (searchConfig?: Record<string, unknown>) => unknown;
|
||||
setCredentialValue: (searchConfigTarget: Record<string, unknown>, value: unknown) => void;
|
||||
createTool: (ctx: WebSearchProviderContext) => WebSearchProviderToolDefinition | null;
|
||||
};
|
||||
|
||||
export type OpenClawPluginGatewayMethod = {
|
||||
method: string;
|
||||
handler: GatewayRequestHandler;
|
||||
@@ -868,6 +897,7 @@ export type OpenClawPluginApi = {
|
||||
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
||||
registerService: (service: OpenClawPluginService) => void;
|
||||
registerProvider: (provider: ProviderPlugin) => void;
|
||||
registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void;
|
||||
registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void;
|
||||
/**
|
||||
* Register a custom command that bypasses the LLM agent.
|
||||
|
||||
137
src/plugins/web-search-providers.test.ts
Normal file
137
src/plugins/web-search-providers.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolvePluginWebSearchProviders } from "./web-search-providers.js";
|
||||
|
||||
const loadOpenClawPluginsMock = vi.fn();
|
||||
|
||||
vi.mock("./loader.js", () => ({
|
||||
loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args),
|
||||
}));
|
||||
|
||||
describe("resolvePluginWebSearchProviders", () => {
|
||||
beforeEach(() => {
|
||||
loadOpenClawPluginsMock.mockReset();
|
||||
loadOpenClawPluginsMock.mockReturnValue({
|
||||
webSearchProviders: [
|
||||
{
|
||||
pluginId: "web-search-gemini",
|
||||
provider: {
|
||||
id: "gemini",
|
||||
label: "Gemini",
|
||||
hint: "hint",
|
||||
envVars: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://example.com",
|
||||
autoDetectOrder: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "web-search-brave",
|
||||
provider: {
|
||||
id: "brave",
|
||||
label: "Brave",
|
||||
hint: "hint",
|
||||
envVars: ["BRAVE_API_KEY"],
|
||||
placeholder: "BSA...",
|
||||
signupUrl: "https://example.com",
|
||||
autoDetectOrder: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards an explicit env to plugin loading", () => {
|
||||
const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv;
|
||||
|
||||
const providers = resolvePluginWebSearchProviders({
|
||||
workspaceDir: "/workspace/explicit",
|
||||
env,
|
||||
});
|
||||
|
||||
expect(providers.map((provider) => provider.id)).toEqual(["brave", "gemini"]);
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: "/workspace/explicit",
|
||||
env,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can augment restrictive allowlists for bundled compatibility", () => {
|
||||
resolvePluginWebSearchProviders({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
},
|
||||
},
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
allow: expect.arrayContaining([
|
||||
"openrouter",
|
||||
"web-search-brave",
|
||||
"web-search-perplexity",
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-enables bundled web search provider plugins when entries are missing", () => {
|
||||
resolvePluginWebSearchProviders({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
openrouter: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
entries: expect.objectContaining({
|
||||
openrouter: { enabled: true },
|
||||
"web-search-brave": { enabled: true },
|
||||
"web-search-gemini": { enabled: true },
|
||||
"web-search-grok": { enabled: true },
|
||||
moonshot: { enabled: true },
|
||||
"web-search-perplexity": { enabled: true },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves explicit bundled provider entry state", () => {
|
||||
resolvePluginWebSearchProviders({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"web-search-perplexity": { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
entries: expect.objectContaining({
|
||||
"web-search-perplexity": { enabled: false },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
110
src/plugins/web-search-providers.ts
Normal file
110
src/plugins/web-search-providers.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
|
||||
import { createPluginLoaderLogger } from "./logger.js";
|
||||
import type { WebSearchProviderPlugin } from "./types.js";
|
||||
|
||||
const log = createSubsystemLogger("plugins");
|
||||
|
||||
const BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS = [
|
||||
"web-search-brave",
|
||||
"web-search-gemini",
|
||||
"web-search-grok",
|
||||
"moonshot",
|
||||
"web-search-perplexity",
|
||||
] as const;
|
||||
|
||||
function withBundledWebSearchAllowlistCompat(
|
||||
config: PluginLoadOptions["config"],
|
||||
): PluginLoadOptions["config"] {
|
||||
const allow = config?.plugins?.allow;
|
||||
if (!Array.isArray(allow) || allow.length === 0) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean));
|
||||
let changed = false;
|
||||
for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) {
|
||||
if (!allowSet.has(pluginId)) {
|
||||
allowSet.add(pluginId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
plugins: {
|
||||
...config?.plugins,
|
||||
allow: [...allowSet],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withBundledWebSearchEnablementCompat(
|
||||
config: PluginLoadOptions["config"],
|
||||
): PluginLoadOptions["config"] {
|
||||
const existingEntries = config?.plugins?.entries ?? {};
|
||||
let changed = false;
|
||||
const nextEntries: Record<string, unknown> = { ...existingEntries };
|
||||
|
||||
for (const pluginId of BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS) {
|
||||
if (existingEntries[pluginId] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
nextEntries[pluginId] = { enabled: true };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
plugins: {
|
||||
...config?.plugins,
|
||||
entries: {
|
||||
...existingEntries,
|
||||
...nextEntries,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePluginWebSearchProviders(params: {
|
||||
config?: PluginLoadOptions["config"];
|
||||
workspaceDir?: string;
|
||||
env?: PluginLoadOptions["env"];
|
||||
bundledAllowlistCompat?: boolean;
|
||||
}): WebSearchProviderPlugin[] {
|
||||
const allowlistCompat = params.bundledAllowlistCompat
|
||||
? withBundledWebSearchAllowlistCompat(params.config)
|
||||
: params.config;
|
||||
const config = withBundledWebSearchEnablementCompat(allowlistCompat);
|
||||
const registry = loadOpenClawPlugins({
|
||||
config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
activate: false,
|
||||
cache: false,
|
||||
onlyPluginIds: [...BUNDLED_WEB_SEARCH_ALLOWLIST_COMPAT_PLUGIN_IDS],
|
||||
});
|
||||
|
||||
return registry.webSearchProviders
|
||||
.map((entry) => ({
|
||||
...entry.provider,
|
||||
pluginId: entry.pluginId,
|
||||
}))
|
||||
.toSorted((a, b) => {
|
||||
const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js";
|
||||
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
|
||||
import { secretRefKey } from "./ref-contract.js";
|
||||
import { resolveSecretRefValues } from "./resolve.js";
|
||||
@@ -9,53 +10,28 @@ import {
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
} from "./runtime-shared.js";
|
||||
import type {
|
||||
RuntimeWebDiagnostic,
|
||||
RuntimeWebDiagnosticCode,
|
||||
RuntimeWebFetchFirecrawlMetadata,
|
||||
RuntimeWebSearchMetadata,
|
||||
RuntimeWebToolsMetadata,
|
||||
} from "./runtime-web-tools.types.js";
|
||||
|
||||
const WEB_SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const;
|
||||
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
|
||||
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
||||
|
||||
type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number];
|
||||
type WebSearchProvider = string;
|
||||
|
||||
type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret
|
||||
type RuntimeWebProviderSource = "configured" | "auto-detect" | "none";
|
||||
|
||||
export type RuntimeWebDiagnosticCode =
|
||||
| "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT"
|
||||
| "WEB_SEARCH_AUTODETECT_SELECTED"
|
||||
| "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED"
|
||||
| "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK"
|
||||
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED"
|
||||
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK";
|
||||
|
||||
export type RuntimeWebDiagnostic = {
|
||||
code: RuntimeWebDiagnosticCode;
|
||||
message: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type RuntimeWebSearchMetadata = {
|
||||
providerConfigured?: WebSearchProvider;
|
||||
providerSource: RuntimeWebProviderSource;
|
||||
selectedProvider?: WebSearchProvider;
|
||||
selectedProviderKeySource?: SecretResolutionSource;
|
||||
perplexityTransport?: "search_api" | "chat_completions";
|
||||
diagnostics: RuntimeWebDiagnostic[];
|
||||
};
|
||||
|
||||
export type RuntimeWebFetchFirecrawlMetadata = {
|
||||
active: boolean;
|
||||
apiKeySource: SecretResolutionSource;
|
||||
diagnostics: RuntimeWebDiagnostic[];
|
||||
};
|
||||
|
||||
export type RuntimeWebToolsMetadata = {
|
||||
search: RuntimeWebSearchMetadata;
|
||||
fetch: {
|
||||
firecrawl: RuntimeWebFetchFirecrawlMetadata;
|
||||
};
|
||||
diagnostics: RuntimeWebDiagnostic[];
|
||||
export type {
|
||||
RuntimeWebDiagnostic,
|
||||
RuntimeWebDiagnosticCode,
|
||||
RuntimeWebFetchFirecrawlMetadata,
|
||||
RuntimeWebSearchMetadata,
|
||||
RuntimeWebToolsMetadata,
|
||||
};
|
||||
|
||||
type FetchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
|
||||
@@ -77,18 +53,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeProvider(value: unknown): WebSearchProvider | undefined {
|
||||
function normalizeProvider(
|
||||
value: unknown,
|
||||
providers: ReturnType<typeof resolvePluginWebSearchProviders>,
|
||||
): WebSearchProvider | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized === "brave" ||
|
||||
normalized === "gemini" ||
|
||||
normalized === "grok" ||
|
||||
normalized === "kimi" ||
|
||||
normalized === "perplexity"
|
||||
) {
|
||||
if (providers.some((provider) => provider.id === normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
@@ -293,16 +266,18 @@ function setResolvedWebSearchApiKey(params: {
|
||||
resolvedConfig: OpenClawConfig;
|
||||
provider: WebSearchProvider;
|
||||
value: string;
|
||||
sourceConfig: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): void {
|
||||
const tools = ensureObject(params.resolvedConfig as Record<string, unknown>, "tools");
|
||||
const web = ensureObject(tools, "web");
|
||||
const search = ensureObject(web, "search");
|
||||
if (params.provider === "brave") {
|
||||
search.apiKey = params.value;
|
||||
return;
|
||||
}
|
||||
const providerConfig = ensureObject(search, params.provider);
|
||||
providerConfig.apiKey = params.value;
|
||||
const provider = resolvePluginWebSearchProviders({
|
||||
config: params.sourceConfig,
|
||||
env: params.env,
|
||||
bundledAllowlistCompat: true,
|
||||
}).find((entry) => entry.id === params.provider);
|
||||
provider?.setCredentialValue(search, params.value);
|
||||
}
|
||||
|
||||
function setResolvedFirecrawlApiKey(params: {
|
||||
@@ -316,34 +291,8 @@ function setResolvedFirecrawlApiKey(params: {
|
||||
firecrawl.apiKey = params.value;
|
||||
}
|
||||
|
||||
function envVarsForProvider(provider: WebSearchProvider): string[] {
|
||||
if (provider === "brave") {
|
||||
return ["BRAVE_API_KEY"];
|
||||
}
|
||||
if (provider === "gemini") {
|
||||
return ["GEMINI_API_KEY"];
|
||||
}
|
||||
if (provider === "grok") {
|
||||
return ["XAI_API_KEY"];
|
||||
}
|
||||
if (provider === "kimi") {
|
||||
return ["KIMI_API_KEY", "MOONSHOT_API_KEY"];
|
||||
}
|
||||
return ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"];
|
||||
}
|
||||
|
||||
function resolveProviderKeyValue(
|
||||
search: Record<string, unknown>,
|
||||
provider: WebSearchProvider,
|
||||
): unknown {
|
||||
if (provider === "brave") {
|
||||
return search.apiKey;
|
||||
}
|
||||
const scoped = search[provider];
|
||||
if (!isRecord(scoped)) {
|
||||
return undefined;
|
||||
}
|
||||
return scoped.apiKey;
|
||||
function keyPathForProvider(provider: WebSearchProvider): string {
|
||||
return provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
|
||||
}
|
||||
|
||||
function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean {
|
||||
@@ -366,6 +315,11 @@ export async function resolveRuntimeWebTools(params: {
|
||||
const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined;
|
||||
const web = isRecord(tools?.web) ? tools.web : undefined;
|
||||
const search = isRecord(web?.search) ? web.search : undefined;
|
||||
const providers = resolvePluginWebSearchProviders({
|
||||
config: params.sourceConfig,
|
||||
env: params.context.env,
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
|
||||
const searchMetadata: RuntimeWebSearchMetadata = {
|
||||
providerSource: "none",
|
||||
@@ -375,7 +329,7 @@ export async function resolveRuntimeWebTools(params: {
|
||||
const searchEnabled = search?.enabled !== false;
|
||||
const rawProvider =
|
||||
typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : "";
|
||||
const configuredProvider = normalizeProvider(rawProvider);
|
||||
const configuredProvider = normalizeProvider(rawProvider, providers);
|
||||
|
||||
if (rawProvider && !configuredProvider) {
|
||||
const diagnostic: RuntimeWebDiagnostic = {
|
||||
@@ -398,7 +352,9 @@ export async function resolveRuntimeWebTools(params: {
|
||||
}
|
||||
|
||||
if (searchEnabled && search) {
|
||||
const candidates = configuredProvider ? [configuredProvider] : [...WEB_SEARCH_PROVIDERS];
|
||||
const candidates = configuredProvider
|
||||
? providers.filter((provider) => provider.id === configuredProvider)
|
||||
: providers;
|
||||
const unresolvedWithoutFallback: Array<{
|
||||
provider: WebSearchProvider;
|
||||
path: string;
|
||||
@@ -409,16 +365,15 @@ export async function resolveRuntimeWebTools(params: {
|
||||
let selectedResolution: SecretResolutionResult | undefined;
|
||||
|
||||
for (const provider of candidates) {
|
||||
const path =
|
||||
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
|
||||
const value = resolveProviderKeyValue(search, provider);
|
||||
const path = keyPathForProvider(provider.id);
|
||||
const value = provider.getCredentialValue(search);
|
||||
const resolution = await resolveSecretInputWithEnvFallback({
|
||||
sourceConfig: params.sourceConfig,
|
||||
context: params.context,
|
||||
defaults,
|
||||
value,
|
||||
path,
|
||||
envVars: envVarsForProvider(provider),
|
||||
envVars: provider.envVars,
|
||||
});
|
||||
|
||||
if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) {
|
||||
@@ -440,32 +395,36 @@ export async function resolveRuntimeWebTools(params: {
|
||||
|
||||
if (resolution.secretRefConfigured && !resolution.value && resolution.unresolvedRefReason) {
|
||||
unresolvedWithoutFallback.push({
|
||||
provider,
|
||||
provider: provider.id,
|
||||
path,
|
||||
reason: resolution.unresolvedRefReason,
|
||||
});
|
||||
}
|
||||
|
||||
if (configuredProvider) {
|
||||
selectedProvider = provider;
|
||||
selectedProvider = provider.id;
|
||||
selectedResolution = resolution;
|
||||
if (resolution.value) {
|
||||
setResolvedWebSearchApiKey({
|
||||
resolvedConfig: params.resolvedConfig,
|
||||
provider,
|
||||
provider: provider.id,
|
||||
value: resolution.value,
|
||||
sourceConfig: params.sourceConfig,
|
||||
env: params.context.env,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (resolution.value) {
|
||||
selectedProvider = provider;
|
||||
selectedProvider = provider.id;
|
||||
selectedResolution = resolution;
|
||||
setResolvedWebSearchApiKey({
|
||||
resolvedConfig: params.resolvedConfig,
|
||||
provider,
|
||||
provider: provider.id,
|
||||
value: resolution.value,
|
||||
sourceConfig: params.sourceConfig,
|
||||
env: params.context.env,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -526,13 +485,12 @@ export async function resolveRuntimeWebTools(params: {
|
||||
}
|
||||
|
||||
if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) {
|
||||
for (const provider of WEB_SEARCH_PROVIDERS) {
|
||||
if (provider === searchMetadata.selectedProvider) {
|
||||
for (const provider of providers) {
|
||||
if (provider.id === searchMetadata.selectedProvider) {
|
||||
continue;
|
||||
}
|
||||
const path =
|
||||
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
|
||||
const value = resolveProviderKeyValue(search, provider);
|
||||
const path = keyPathForProvider(provider.id);
|
||||
const value = provider.getCredentialValue(search);
|
||||
if (!hasConfiguredSecretRef(value, defaults)) {
|
||||
continue;
|
||||
}
|
||||
@@ -543,10 +501,9 @@ export async function resolveRuntimeWebTools(params: {
|
||||
});
|
||||
}
|
||||
} else if (search && !searchEnabled) {
|
||||
for (const provider of WEB_SEARCH_PROVIDERS) {
|
||||
const path =
|
||||
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
|
||||
const value = resolveProviderKeyValue(search, provider);
|
||||
for (const provider of providers) {
|
||||
const path = keyPathForProvider(provider.id);
|
||||
const value = provider.getCredentialValue(search);
|
||||
if (!hasConfiguredSecretRef(value, defaults)) {
|
||||
continue;
|
||||
}
|
||||
@@ -559,13 +516,12 @@ export async function resolveRuntimeWebTools(params: {
|
||||
}
|
||||
|
||||
if (searchEnabled && search && configuredProvider) {
|
||||
for (const provider of WEB_SEARCH_PROVIDERS) {
|
||||
if (provider === configuredProvider) {
|
||||
for (const provider of providers) {
|
||||
if (provider.id === configuredProvider) {
|
||||
continue;
|
||||
}
|
||||
const path =
|
||||
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
|
||||
const value = resolveProviderKeyValue(search, provider);
|
||||
const path = keyPathForProvider(provider.id);
|
||||
const value = provider.getCredentialValue(search);
|
||||
if (!hasConfiguredSecretRef(value, defaults)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
36
src/secrets/runtime-web-tools.types.ts
Normal file
36
src/secrets/runtime-web-tools.types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type RuntimeWebDiagnosticCode =
|
||||
| "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT"
|
||||
| "WEB_SEARCH_AUTODETECT_SELECTED"
|
||||
| "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED"
|
||||
| "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK"
|
||||
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED"
|
||||
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK";
|
||||
|
||||
export type RuntimeWebDiagnostic = {
|
||||
code: RuntimeWebDiagnosticCode;
|
||||
message: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type RuntimeWebSearchMetadata = {
|
||||
providerConfigured?: string;
|
||||
providerSource: "configured" | "auto-detect" | "none";
|
||||
selectedProvider?: string;
|
||||
selectedProviderKeySource?: "config" | "secretRef" | "env" | "missing";
|
||||
perplexityTransport?: "search_api" | "chat_completions";
|
||||
diagnostics: RuntimeWebDiagnostic[];
|
||||
};
|
||||
|
||||
export type RuntimeWebFetchFirecrawlMetadata = {
|
||||
active: boolean;
|
||||
apiKeySource: "config" | "secretRef" | "env" | "missing";
|
||||
diagnostics: RuntimeWebDiagnostic[];
|
||||
};
|
||||
|
||||
export type RuntimeWebToolsMetadata = {
|
||||
search: RuntimeWebSearchMetadata;
|
||||
fetch: {
|
||||
firecrawl: RuntimeWebFetchFirecrawlMetadata;
|
||||
};
|
||||
diagnostics: RuntimeWebDiagnostic[];
|
||||
};
|
||||
Reference in New Issue
Block a user