feat(web-search): add DuckDuckGo bundled plugin (#52629)

* feat(web-search): add DuckDuckGo bundled plugin

* chore(changelog): restore main changelog

* fix(web-search): harden DuckDuckGo challenge detection
This commit is contained in:
Vincent Koc
2026-03-22 22:05:33 -07:00
committed by GitHub
parent 827c441902
commit c6ca11e5a5
27 changed files with 1222 additions and 217 deletions

View File

@@ -7,7 +7,7 @@ import * as secretResolve from "./resolve.js";
import { createResolverContext } from "./runtime-shared.js";
import { resolveRuntimeWebTools } from "./runtime-web-tools.js";
type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity";
type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "duckduckgo";
const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
@@ -36,6 +36,8 @@ function asConfig(value: unknown): OpenClawConfig {
function providerPluginId(provider: ProviderUnderTest): string {
switch (provider) {
case "duckduckgo":
return "duckduckgo";
case "gemini":
return "google";
case "grok":
@@ -81,13 +83,15 @@ function createTestProvider(params: {
id: params.provider,
label: params.provider,
hint: `${params.provider} test provider`,
envVars: [`${params.provider.toUpperCase()}_API_KEY`],
placeholder: `${params.provider}-...`,
requiresCredential: params.provider === "duckduckgo" ? false : undefined,
envVars: params.provider === "duckduckgo" ? [] : [`${params.provider.toUpperCase()}_API_KEY`],
placeholder: params.provider === "duckduckgo" ? "(no key needed)" : `${params.provider}-...`,
signupUrl: `https://example.com/${params.provider}`,
autoDetectOrder: params.order,
credentialPath,
inactiveSecretPaths: [credentialPath],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
credentialPath: params.provider === "duckduckgo" ? "" : credentialPath,
inactiveSecretPaths: params.provider === "duckduckgo" ? [] : [credentialPath],
getCredentialValue: (searchConfig) =>
params.provider === "duckduckgo" ? "duckduckgo-no-key-needed" : searchConfig?.apiKey,
setCredentialValue: (searchConfigTarget, value) => {
searchConfigTarget.apiKey = value;
},
@@ -117,6 +121,7 @@ function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] {
createTestProvider({ provider: "grok", pluginId: "xai", order: 30 }),
createTestProvider({ provider: "kimi", pluginId: "moonshot", order: 40 }),
createTestProvider({ provider: "perplexity", pluginId: "perplexity", order: 50 }),
createTestProvider({ provider: "duckduckgo", pluginId: "duckduckgo", order: 100 }),
];
}
@@ -231,6 +236,31 @@ describe("runtime web tools resolution", () => {
expect(metadata.fetch.firecrawl.apiKeySource).toBe("env");
});
it("auto-selects a keyless provider when no credentials are configured", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
}),
});
expect(metadata.search.selectedProvider).toBe("duckduckgo");
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "WEB_SEARCH_AUTODETECT_SELECTED",
message: expect.stringContaining('keyless provider "duckduckgo"'),
}),
]),
);
});
it.each([
{
provider: "brave" as const,

View File

@@ -268,6 +268,9 @@ function keyPathForProvider(provider: PluginWebSearchProviderEntry): string {
}
function inactivePathsForProvider(provider: PluginWebSearchProviderEntry): string[] {
if (provider.requiresCredential === false) {
return [];
}
return provider.inactiveSecretPaths?.length
? provider.inactiveSecretPaths
: [provider.credentialPath];
@@ -357,8 +360,19 @@ export async function resolveRuntimeWebTools(params: {
let selectedProvider: WebSearchProvider | undefined;
let selectedResolution: SecretResolutionResult | undefined;
let keylessFallbackProvider: PluginWebSearchProviderEntry | undefined;
for (const provider of candidates) {
if (provider.requiresCredential === false) {
if (!keylessFallbackProvider) {
keylessFallbackProvider = provider;
}
if (configuredProvider) {
selectedProvider = provider.id;
break;
}
continue;
}
const path = keyPathForProvider(provider);
const value =
provider.getConfiguredCredentialValue?.(params.sourceConfig) ??
@@ -422,6 +436,15 @@ export async function resolveRuntimeWebTools(params: {
}
}
if (!selectedProvider && keylessFallbackProvider) {
selectedProvider = keylessFallbackProvider.id;
selectedResolution = {
source: "missing",
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
}
const failUnresolvedSearchNoFallback = (unresolved: { path: string; reason: string }) => {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
@@ -449,9 +472,14 @@ export async function resolveRuntimeWebTools(params: {
}
if (selectedProvider) {
const selectedProviderEntry = providers.find((entry) => entry.id === selectedProvider);
const selectedDetails =
selectedProviderEntry?.requiresCredential === false
? `tools.web.search auto-detected keyless provider "${selectedProvider}" as the default fallback.`
: `tools.web.search auto-detected provider "${selectedProvider}" from available credentials.`;
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_AUTODETECT_SELECTED",
message: `tools.web.search auto-detected provider "${selectedProvider}" from available credentials.`,
message: selectedDetails,
path: "tools.web.search.provider",
};
diagnostics.push(diagnostic);