Keep key-free web search providers opt-in (#93616)

Merged via squash.

Prepared head SHA: 5de02da038
Co-authored-by: davemorin <78139+davemorin@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc

(cherry picked from commit c48b36a255)

# Conflicts:
#	docs/tools/web.md
This commit is contained in:
Dave Morin
2026-06-16 15:02:07 +02:00
committed by Vincent Koc
parent 6af6e1e37b
commit 94c72acd6b
18 changed files with 463 additions and 153 deletions
+1 -1
View File
@@ -140,7 +140,7 @@ See [Memory](/concepts/memory).
- **Ollama Web Search**: key-free for a reachable signed-in local Ollama host; direct `https://ollama.com` search uses `OLLAMA_API_KEY`, and auth-protected hosts can reuse normal Ollama provider bearer auth
- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey`
- **Tavily**: `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
- **DuckDuckGo**: key-free fallback (no API billing, but unofficial and HTML-based)
- **DuckDuckGo**: key-free provider when explicitly selected (no API billing, but unofficial and HTML-based)
- **SearXNG**: `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (key-free/self-hosted; no hosted API billing)
Legacy `tools.web.search.*` provider paths still load through the temporary compatibility shim, but they are no longer the recommended config surface.
+6 -6
View File
@@ -1,9 +1,9 @@
---
summary: "DuckDuckGo web search -- key-free fallback provider (experimental, HTML-based)"
summary: "DuckDuckGo web search -- key-free provider (experimental, HTML-based)"
read_when:
- You want a web search provider that requires no API key
- You want to use DuckDuckGo for web_search
- You need a zero-config search fallback
- You want an explicitly selected key-free search provider
title: "DuckDuckGo search"
---
@@ -85,16 +85,16 @@ parameters override config values per-query.
## Notes
- **No API key** - works out of the box, zero configuration
- **No API key** - works after you select DuckDuckGo as your `web_search`
provider
- **Experimental** - gathers results from DuckDuckGo's non-JavaScript HTML
search pages, not an official API or SDK
- **Bot-challenge risk** - DuckDuckGo may serve CAPTCHAs or block requests
under heavy or automated use
- **HTML parsing** - results depend on page structure, which can change without
notice
- **Auto-detection order** - DuckDuckGo is the first key-free fallback
(order 100) in auto-detection. API-backed providers with configured keys run
first, then Ollama Web Search (order 110), then SearXNG (order 200)
- **Explicit selection** - OpenClaw does not choose DuckDuckGo automatically
when no API-backed provider is configured
- **SafeSearch defaults to moderate** when not configured
<Tip>
+3 -2
View File
@@ -140,8 +140,9 @@ Direct hosted Ollama Web Search:
that env key to the local host.
- OpenClaw warns during setup if Ollama is unreachable or not signed in, but
it does not block selection.
- Runtime auto-detect can fall back to Ollama Web Search when no higher-priority
credentialed provider is configured.
- OpenClaw does not auto-select Ollama Web Search when no higher-priority
credentialed provider is configured; choose it explicitly with
`tools.web.search.provider: "ollama"`.
- Local Ollama daemon hosts use the local proxy endpoint
`/api/experimental/web_search`, which signs and forwards to Ollama Cloud.
- `https://ollama.com` hosts use the public hosted endpoint
+6 -4
View File
@@ -11,8 +11,8 @@ OpenClaw bundles two [Parallel](https://parallel.ai/) `web_search` providers:
- **Parallel Search (Free)** (`parallel-free`) -- Parallel's free
[Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp). Requires no
account or API key. OpenClaw selects it automatically when no other web search
provider is configured, so `web_search` works without setup.
account or API key. Select it explicitly when you want Parallel's hosted
key-free search path.
- **Parallel Search** (`parallel`) -- Parallel's paid Search API. Requires a
`PARALLEL_API_KEY` and offers higher rate limits and objective tuning.
@@ -29,8 +29,8 @@ explicitly.
## API key (paid provider)
`parallel-free` requires no setup. The paid `parallel` provider needs an API
key:
`parallel-free` requires no API key, but it still must be selected as the
managed provider. The paid `parallel` provider needs an API key:
<Steps>
<Step title="Create an account">
@@ -66,6 +66,8 @@ key:
tools: {
web: {
search: {
// Use "parallel-free" for the free Search MCP, or "parallel" for
// the paid API-backed provider shown here.
provider: "parallel",
},
},
+4 -4
View File
@@ -120,9 +120,9 @@ key wins first).
- **Network guard** -- private/internal SearXNG endpoints opt in to
private-network access; public `https://` SearXNG endpoints keep strict SSRF
protection
- **Auto-detection order** -- SearXNG is checked last (order 200) in
auto-detection. API-backed providers with configured keys run first, then
DuckDuckGo (order 100), then Ollama Web Search (order 110)
- **Auto-detection order** -- SearXNG is checked after API-backed providers
with configured keys (order 200). Key-free providers such as DuckDuckGo or
Ollama Web Search are not auto-selected without an explicit provider choice
- **Self-hosted** -- you control the instance, queries, and upstream search engines
- **Categories** default to `general` when not configured
- **Category fallback** -- if a non-`general` category request succeeds but
@@ -137,5 +137,5 @@ key wins first).
## Related
- [Web Search overview](/tools/web) -- all providers and auto-detection
- [DuckDuckGo Search](/tools/duckduckgo-search) -- another key-free fallback
- [DuckDuckGo Search](/tools/duckduckgo-search) -- another key-free provider
- [Brave Search](/tools/brave-search) -- structured results with free tier
+15 -10
View File
@@ -6,7 +6,7 @@ read_when:
- You want to enable or configure web_search
- You want to enable or configure x_search
- You need to choose a search provider
- You want to understand auto-detection and provider fallback
- You want to understand auto-detection and provider selection
---
The `web_search` tool searches the web using your configured provider and
@@ -60,8 +60,11 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
<Card title="Brave Search" icon="shield" href="/tools/brave-search">
Structured results with snippets. Supports `llm-context` mode, country/language filters. Free tier available.
</Card>
<Card title="Codex Hosted Search" icon="search" href="/plugins/codex-harness">
AI-synthesized grounded answers through your Codex app-server account.
</Card>
<Card title="DuckDuckGo" icon="bird" href="/tools/duckduckgo-search">
Key-free fallback. No API key needed. Unofficial HTML-based integration.
Key-free provider. No API key needed. Unofficial HTML-based integration.
</Card>
<Card title="Exa" icon="brain" href="/tools/exa-search">
Neural + keyword search with content extraction (highlights, text, summaries).
@@ -88,7 +91,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
Paid Parallel Search API (`PARALLEL_API_KEY`); higher rate limits and objective tuning.
</Card>
<Card title="Parallel Search (Free)" icon="layer-group" href="/tools/parallel-search">
Zero-config default. Parallel's free Search MCP, with LLM-optimized dense excerpts and no API key.
Key-free opt-in. Parallel's free Search MCP, with LLM-optimized dense excerpts and no API key.
</Card>
<Card title="Perplexity" icon="search" href="/tools/perplexity-search">
Structured results with content extraction controls and domain filtering.
@@ -106,6 +109,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
| Provider | Result style | Filters | API key |
| ------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------- |
| [Brave](/tools/brave-search) | Structured snippets | Country, language, time, `llm-context` mode | `BRAVE_API_KEY` |
| [Codex Hosted Search](/plugins/codex-harness) | AI-synthesized + source URLs | Domains, context size, user location | None; uses Codex/OpenAI sign-in |
| [DuckDuckGo](/tools/duckduckgo-search) | Structured snippets | -- | None (key-free) |
| [Exa](/tools/exa-search) | Structured + extracted | Neural/keyword mode, date, content extraction | `EXA_API_KEY` |
| [Firecrawl](/tools/firecrawl) | Structured snippets | Via `firecrawl_search` tool | `FIRECRAWL_API_KEY` |
@@ -194,15 +198,16 @@ API-backed providers first:
9. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey` (order 70)
10. **Parallel** -- paid Parallel Search API via `PARALLEL_API_KEY` or `plugins.entries.parallel.config.webSearch.apiKey`; optional `plugins.entries.parallel.config.webSearch.baseUrl` overrides the endpoint (order 75)
Key-free fallbacks after that:
Configured endpoint providers after that:
11. **Parallel Search (Free)** -- the zero-config default: works with no account or API key via Parallel's free hosted [Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) (order 76)
12. **DuckDuckGo** -- key-free HTML fallback with no account or API key (order 100)
13. **Ollama Web Search** -- key-free fallback via your configured local Ollama host when it is reachable and signed in with `ollama signin`; can reuse Ollama provider bearer auth when the host needs it, and can call direct `https://ollama.com` search when configured with `OLLAMA_API_KEY` (order 110)
14. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200)
11. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200)
When no API-backed provider is configured, OpenClaw defaults to **Parallel
Search (Free)**, so `web_search` works without an API key.
Key-free providers such as **Parallel Search (Free)**, **DuckDuckGo**,
**Ollama Web Search**, and **Codex Hosted Search** are available only when you
select them explicitly with `tools.web.search.provider` or through
`openclaw configure --section web`. OpenClaw does not send managed
`web_search` queries to a key-free provider just because no API-backed provider
is configured.
OpenAI Responses models are an exception: while `tools.web.search.provider` is
unset, they use OpenAI's native web search instead of the managed providers
+2 -2
View File
@@ -7,8 +7,8 @@ export default definePluginEntry({
name: "Parallel Plugin",
description: "Bundled Parallel web search plugin",
register(api) {
// Free hosted Search MCP (keyless, zero-config default) and the paid v1 REST
// API (requires PARALLEL_API_KEY) are registered as two distinct providers.
// Free hosted Search MCP (keyless, opt-in) and the paid v1 REST API
// (requires PARALLEL_API_KEY) are registered as two distinct providers.
api.registerWebSearchProvider(createParallelFreeWebSearchProvider());
api.registerWebSearchProvider(createParallelWebSearchProvider());
},
@@ -8,16 +8,15 @@ export function createParallelFreeWebSearchProviderBase() {
label: "Parallel Search (Free)",
hint: "Free web search via Parallel's hosted Search MCP — no API key required",
onboardingScopes: [...PARALLEL_FREE_ONBOARDING_SCOPES],
// Keyless: always uses Parallel's free hosted Search MCP. This is the
// zero-config default web_search provider (autoDetectOrder 76 keeps it ahead
// of the other key-free fallbacks: duckduckgo 100, ollama 110). The paid
// `parallel` provider (v1 REST, requires a key) is a separate entry.
// Keyless: always uses Parallel's free hosted Search MCP. Keep it
// selectable, but never auto-detected; users must opt in before search
// traffic is sent to Parallel's hosted free tier. The paid `parallel`
// provider (v1 REST, requires a key) is a separate entry.
requiresCredential: false,
envVars: [],
placeholder: "(no key needed)",
signupUrl: "https://parallel.ai",
docsUrl: "https://docs.openclaw.ai/tools/parallel-search",
autoDetectOrder: 76,
credentialPath: "",
...createWebSearchProviderContractFields({
credentialPath: "",
@@ -62,13 +62,13 @@ describe("parallel-free web search provider", () => {
endpointMockState.responses = [];
});
it("exposes keyless metadata as the zero-config default", () => {
it("exposes keyless metadata without claiming auto-detect fallback", () => {
const provider = createParallelFreeWebSearchProvider();
expect(provider.id).toBe("parallel-free");
expect(provider.label).toBe("Parallel Search (Free)");
expect(provider.requiresCredential).toBe(false);
expect(provider.envVars).toEqual([]);
expect(provider.autoDetectOrder).toBe(76);
expect(provider.autoDetectOrder).toBeUndefined();
});
it("advertises the free MCP's tighter 100-char session_id cap in its tool schema", () => {
@@ -3,8 +3,8 @@ import { createRequire } from "node:module";
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
import { withTrustedWebSearchEndpoint } from "openclaw/plugin-sdk/provider-web-search";
// Free hosted Search MCP — anonymous-friendly, used when no PARALLEL_API_KEY is
// configured. This is the zero-config default web_search transport. Docs:
// Free hosted Search MCP. This keyless transport is used only after the user
// explicitly selects the `parallel-free` web_search provider. Docs:
// https://docs.parallel.ai/integrations/mcp/search-mcp
export const PARALLEL_MCP_SEARCH_URL = "https://search.parallel.ai/mcp";
// Initial protocol version we advertise on `initialize`; we then echo whatever
@@ -224,7 +224,7 @@ describe("onboard-search provider resolution", () => {
expect(mod.applySearchProviderSelection(cfg, "firecrawl")).toBe(cfg);
});
it("defaults to a keyless provider when no search credentials exist", async () => {
it("supports explicit keyless provider selection without defaulting to it", async () => {
const duckduckgoEntry = createBundledDuckDuckGoEntry();
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
params?.config ? [duckduckgoEntry] : [duckduckgoEntry],
@@ -248,6 +248,9 @@ describe("onboard-search provider resolution", () => {
const result = await mod.setupSearch({} as OpenClawConfig, {} as never, prompter as never);
expect(prompter.select).toHaveBeenCalledWith(
expect.objectContaining({ initialValue: "__skip__" }),
);
expect(result.tools?.web?.search?.provider).toBe("duckduckgo");
expect(result.plugins?.entries?.duckduckgo?.enabled).toBe(true);
expect(notes.join("\n")).toContain("works without an API key");
@@ -595,8 +595,8 @@ describe("web search provider auto-detection", () => {
vi.restoreAllMocks();
});
it("falls back to brave when no keys available", () => {
expect(resolveSearchProvider({})).toBe("brave");
it("returns no provider when no credentials are available", () => {
expect(resolveSearchProvider({})).toBe("");
});
it("auto-detects brave when only BRAVE_API_KEY is set", () => {
+15 -14
View File
@@ -432,22 +432,21 @@ export async function runSearchSetupFlow(
const existingProvider = config.tools?.web?.search?.provider;
const defaultProvider: SearchProvider = (() => {
const defaultChoice: SearchProvider = (() => {
if (existingProvider && providerOptions.some((entry) => entry.id === existingProvider)) {
return existingProvider;
}
// Mirror the runtime auto-detect selection (honors autoDetectOrder and
// configured credentials, incl. keyless defaults) so accepting the setup
// default writes the same provider the gateway would pick — e.g. Parallel
// Search (Free), not whichever ready provider happens to sort first
// alphabetically. Resolve over the providers actually shown in setup
// (`providerOptions`) so the default can't pick an option that isn't listed
// or force-load runtime code for an install-catalog-only provider.
// Mirror runtime auto-detect only when it has a concrete configured signal.
// Keyless providers are selectable, but never preselected; pressing through
// setup should not opt the user into a third-party search destination.
// Resolve over the providers actually shown in setup (`providerOptions`) so
// the default can't pick an option that isn't listed or force-load runtime
// code for an install-catalog-only provider.
// Clear any existing provider id before auto-detecting: the valid-existing
// case already returned above, so a leftover value here is stale/invalid/
// disabled and would otherwise make the resolver short-circuit to
// providers[0] (e.g. Brave) instead of the keyless default. Keep the rest of
// the search config so configured credentials are still detected.
// disabled and would otherwise make the resolver short-circuit to that
// invalid selection. Keep the rest of the search config so configured
// credentials are still detected.
const searchForAutoDetect = {
...config.tools?.web?.search,
provider: undefined,
@@ -461,11 +460,13 @@ export async function runSearchSetupFlow(
if (autoDetected) {
return autoDetected.id;
}
const detected = providerOptions.find((entry) => providerIsReady(config, entry));
const detected = providerOptions.find(
(entry) => providerNeedsCredential(entry) && providerIsReady(config, entry),
);
if (detected) {
return detected.id;
}
return providerOptions[0].id;
return "__skip__";
})();
const options = providerOptions.map((entry) => {
@@ -488,7 +489,7 @@ export async function runSearchSetupFlow(
hint: t("wizard.search.configureLaterHint"),
},
],
initialValue: defaultProvider,
initialValue: defaultChoice,
searchable: true,
});
+17 -4
View File
@@ -66,6 +66,8 @@ export type RuntimeWebProviderSelectionParams<
resolvedConfig: OpenClawConfig;
context: ResolverContext;
defaults: SecretDefaults | undefined;
/** Allow keyless providers to be selected when no provider is explicitly configured. */
allowKeylessAutoSelect: boolean;
/** Defer keyless providers until credential-bearing auto-detect candidates are exhausted. */
deferKeylessFallback: boolean;
fallbackUsedCode: RuntimeWebWarningCode;
@@ -339,11 +341,17 @@ export async function resolveRuntimeWebProviderSurface<
params.rawProvider,
params.normalizeConfiguredProviderAgainstActiveProviders ? providers : allProviders,
);
const invalidConfiguredProvider =
params.normalizeConfiguredProviderAgainstActiveProviders === true &&
Boolean(params.rawProvider) &&
!configuredProvider;
if (params.rawProvider && !configuredProvider) {
const diagnostic: RuntimeWebDiagnostic = {
code: params.invalidAutoDetectCode,
message: `${params.providerPath} is "${params.rawProvider}". Falling back to auto-detect precedence.`,
message: invalidConfiguredProvider
? `${params.providerPath} is "${params.rawProvider}". No provider will be selected.`
: `${params.providerPath} is "${params.rawProvider}". Falling back to auto-detect precedence.`,
path: params.providerPath,
};
params.diagnostics.push(diagnostic);
@@ -359,7 +367,9 @@ export async function resolveRuntimeWebProviderSurface<
providers,
configuredProvider,
enabled:
hasConfiguredSurface && (!isRecord(params.toolConfig) || params.toolConfig.enabled !== false),
hasConfiguredSurface &&
!invalidConfiguredProvider &&
(!isRecord(params.toolConfig) || params.toolConfig.enabled !== false),
hasConfiguredSurface,
};
}
@@ -395,6 +405,9 @@ export async function resolveRuntimeWebProviderSelection<
for (const provider of candidates) {
if (provider.requiresCredential === false) {
if (!params.configuredProvider && !params.allowKeylessAutoSelect) {
continue;
}
if (params.deferKeylessFallback && !params.configuredProvider) {
keylessFallbackProvider ||= provider;
continue;
@@ -529,7 +542,7 @@ export async function resolveRuntimeWebProviderSelection<
}
}
if (!selectedProvider && keylessFallbackProvider) {
if (!selectedProvider && keylessFallbackProvider && params.allowKeylessAutoSelect) {
selectedProvider = keylessFallbackProvider.id;
selectedResolution = {
source: "missing" as TSource,
@@ -570,7 +583,7 @@ export async function resolveRuntimeWebProviderSelection<
);
const selectedDetails =
selectedProviderEntry?.requiresCredential === false
? `${params.scopePath} auto-detected keyless provider "${selectedProvider}" as the default fallback.`
? `${params.scopePath} auto-detected keyless provider "${selectedProvider}".`
: `${params.scopePath} auto-detected provider "${selectedProvider}" from available credentials.`;
const diagnostic: RuntimeWebDiagnostic = {
code: params.autoDetectSelectedCode,
+12 -11
View File
@@ -582,7 +582,7 @@ describe("runtime web tools resolution", () => {
});
});
it("auto-selects a keyless provider when no credentials are configured", async () => {
it("does not auto-select a keyless provider when no credentials are configured", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
@@ -595,12 +595,9 @@ describe("runtime web tools resolution", () => {
}),
});
expect(metadata.search.selectedProvider).toBe("duckduckgo");
expect(metadata.search.providerSource).toBe("auto-detect");
expectDiagnostic(metadata.search.diagnostics, {
code: "WEB_SEARCH_AUTODETECT_SELECTED",
messageIncludes: 'keyless provider "duckduckgo"',
});
expect(metadata.search.selectedProvider).toBeUndefined();
expect(metadata.search.providerSource).toBe("none");
expect(metadata.search.diagnostics).toEqual([]);
});
it.each([
@@ -958,7 +955,7 @@ describe("runtime web tools resolution", () => {
expect(resolvedConfig.models?.providers?.google?.apiKey).toBe("google-provider-ref-key");
});
it("warns when provider is invalid and falls back to auto-detect", async () => {
it("keeps an invalid provider unselected without resolving another provider", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
@@ -987,9 +984,13 @@ describe("runtime web tools resolution", () => {
});
expect(metadata.search.providerConfigured).toBeUndefined();
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("gemini");
expect(readProviderKey(resolvedConfig, "gemini")).toBe("gemini-runtime-key");
expect(metadata.search.providerSource).toBe("none");
expect(metadata.search.selectedProvider).toBeUndefined();
expect(readProviderKey(resolvedConfig, "gemini")).toEqual({
source: "env",
provider: "default",
id: "GEMINI_API_KEY_REF",
});
expectDiagnostic(metadata.search.diagnostics, {
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
path: "tools.web.search.provider",
+2
View File
@@ -681,6 +681,7 @@ export async function resolveRuntimeWebTools(params: {
resolvedConfig: params.resolvedConfig,
context: params.context,
defaults,
allowKeylessAutoSelect: false,
deferKeylessFallback: true,
fallbackUsedCode: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED",
noFallbackCode: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
@@ -793,6 +794,7 @@ export async function resolveRuntimeWebTools(params: {
resolvedConfig: params.resolvedConfig,
context: params.context,
defaults,
allowKeylessAutoSelect: true,
deferKeylessFallback: false,
fallbackUsedCode: "WEB_FETCH_PROVIDER_KEY_UNRESOLVED_FALLBACK_USED",
noFallbackCode: "WEB_FETCH_PROVIDER_KEY_UNRESOLVED_NO_FALLBACK",
+257 -63
View File
@@ -1,8 +1,13 @@
// Web search runtime tests cover provider resolution and search execution.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots,
} from "../agents/auth-profiles/store.js";
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginWebSearchProviderEntry } from "../plugins/web-provider-types.js";
import {
@@ -108,11 +113,32 @@ function createGoogleSearchProvider(
id: "google",
credentialPath: "tools.web.search.google.apiKey",
autoDetectOrder: 1,
getConfiguredCredentialValue: () => "configured",
getCredentialValue: () => "configured",
...overrides,
});
}
function createOAuthAuthProfileStore(params: {
provider: string;
profileId: string;
access: string;
refresh: string;
}): AuthProfileStore {
return {
version: 1,
profiles: {
[params.profileId]: {
type: "oauth",
provider: params.provider,
access: params.access,
refresh: params.refresh,
expires: Date.now() + 3_600_000,
},
},
};
}
function requireRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected a non-array record");
@@ -139,13 +165,14 @@ function createDuckDuckGoSearchProvider(
describe("web search runtime", () => {
let runWebSearch: typeof import("./runtime.js").runWebSearch;
let resolveWebSearchDefinition: typeof import("./runtime.js").resolveWebSearchDefinition;
let activateSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").activateSecretsRuntimeSnapshot;
let clearSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").clearSecretsRuntimeSnapshot;
let setRuntimeConfigSnapshot: typeof import("../config/config.js").setRuntimeConfigSnapshot;
const tempDirs: string[] = [];
beforeAll(async () => {
({ runWebSearch } = await import("./runtime.js"));
({ resolveWebSearchDefinition, runWebSearch } = await import("./runtime.js"));
({ activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } =
await import("../secrets/runtime.js"));
({ setRuntimeConfigSnapshot } = await import("../config/config.js"));
@@ -162,6 +189,7 @@ describe("web search runtime", () => {
afterEach(() => {
clearSecretsRuntimeSnapshot();
clearRuntimeAuthProfileStoreSnapshots();
for (const tempDir of tempDirs.splice(0)) {
rmSync(tempDir, { recursive: true, force: true });
}
@@ -177,7 +205,15 @@ describe("web search runtime", () => {
await expect(
runWebSearch({
config: {},
config: {
tools: {
web: {
search: {
provider: "custom",
},
},
},
},
args: { query: "hello" },
}),
).resolves.toEqual({
@@ -209,7 +245,15 @@ describe("web search runtime", () => {
await expect(
runWebSearch({
config: {},
config: {
tools: {
web: {
search: {
provider: "custom",
},
},
},
},
args: { query: "abort plumbing" },
signal: controller.signal,
}),
@@ -286,22 +330,17 @@ describe("web search runtime", () => {
it("auto-detects a provider from a model-provider auth profile", async () => {
const agentDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-web-search-auth-"));
tempDirs.push(agentDir);
mkdirSync(agentDir, { recursive: true });
writeFileSync(
path.join(agentDir, "auth-profiles.json"),
JSON.stringify({
version: 1,
profiles: {
"xai:default": {
type: "oauth",
provider: "xai",
access: "xai-oauth-access-token",
refresh: "xai-oauth-refresh-token",
expires: Date.now() + 3_600_000,
},
},
}),
);
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: createOAuthAuthProfileStore({
provider: "xai",
profileId: "xai:default",
access: "xai-oauth-access-token",
refresh: "xai-oauth-refresh-token",
}),
},
]);
const provider = createCustomSearchProvider({
pluginId: "xai",
@@ -334,23 +373,17 @@ describe("web search runtime", () => {
const defaultAgentDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-web-search-default-"));
const activeAgentDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-web-search-active-"));
tempDirs.push(defaultAgentDir, activeAgentDir);
mkdirSync(defaultAgentDir, { recursive: true });
mkdirSync(activeAgentDir, { recursive: true });
writeFileSync(
path.join(activeAgentDir, "auth-profiles.json"),
JSON.stringify({
version: 1,
profiles: {
"xai:active": {
type: "oauth",
provider: "xai",
access: "xai-active-oauth-token",
refresh: "xai-active-refresh-token",
expires: Date.now() + 3_600_000,
},
},
}),
);
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir: activeAgentDir,
store: createOAuthAuthProfileStore({
provider: "xai",
profileId: "xai:active",
access: "xai-active-oauth-token",
refresh: "xai-active-refresh-token",
}),
},
]);
const provider = createCustomSearchProvider({
pluginId: "xai",
@@ -404,7 +437,15 @@ describe("web search runtime", () => {
await expect(
runWebSearch({
agentDir: activeAgentDir,
config: {},
config: {
tools: {
web: {
search: {
provider: "custom",
},
},
},
},
args: { query: "active-agent tool context" },
}),
).resolves.toEqual({
@@ -528,11 +569,18 @@ describe("web search runtime", () => {
});
});
it("falls back to a keyless provider when no credentials are available", async () => {
it("does not auto-select keyless providers when no provider is configured", async () => {
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
createDuckDuckGoSearchProvider({
getCredentialValue: () => "duckduckgo-no-key-needed",
}),
createWebSearchTestProvider({
pluginId: "parallel",
id: "parallel-free",
credentialPath: "",
autoDetectOrder: 76,
requiresCredential: false,
}),
]);
await expect(
@@ -540,9 +588,38 @@ describe("web search runtime", () => {
config: {},
args: { query: "fallback" },
}),
).rejects.toThrow("web_search is disabled or no provider is available.");
});
it("does not resolve a keyless provider definition when no provider is configured", () => {
resolvePluginWebSearchProvidersMock.mockReturnValue([createDuckDuckGoSearchProvider()]);
const resolved = resolveWebSearchDefinition({
config: {},
});
expect(resolved).toBeNull();
});
it("uses a keyless provider when the user explicitly selects it", async () => {
resolveRuntimeWebSearchProvidersMock.mockReturnValue([createDuckDuckGoSearchProvider()]);
await expect(
runWebSearch({
config: {
tools: {
web: {
search: {
provider: "duckduckgo",
},
},
},
},
args: { query: "explicit-keyless" },
}),
).resolves.toEqual({
provider: "duckduckgo",
result: { query: "fallback", provider: "duckduckgo" },
result: { query: "explicit-keyless", provider: "duckduckgo" },
});
});
@@ -553,6 +630,7 @@ describe("web search runtime", () => {
id: "alpha",
credentialPath: "tools.web.search.alpha.apiKey",
autoDetectOrder: 1,
getConfiguredCredentialValue: () => "alpha-configured",
getCredentialValue: () => "alpha-configured",
createTool: ({ runtimeMetadata }) => ({
description: "alpha",
@@ -569,6 +647,7 @@ describe("web search runtime", () => {
id: "beta",
credentialPath: "tools.web.search.beta.apiKey",
autoDetectOrder: 2,
getConfiguredCredentialValue: () => "beta-configured",
getCredentialValue: () => "beta-configured",
createTool: ({ runtimeMetadata }) => ({
description: "beta",
@@ -612,10 +691,115 @@ describe("web search runtime", () => {
});
});
it("ignores auto-detected keyless runtime metadata when no provider is configured", async () => {
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
createWebSearchTestProvider({
pluginId: "parallel",
id: "parallel-free",
credentialPath: "",
autoDetectOrder: 76,
requiresCredential: false,
}),
]);
activateSecretsRuntimeSnapshot({
sourceConfig: {},
config: {},
authStores: [],
warnings: [],
webTools: {
search: {
providerSource: "auto-detect",
selectedProvider: "parallel-free",
diagnostics: [],
},
fetch: {
providerSource: "none",
diagnostics: [],
},
diagnostics: [],
},
});
await expect(
runWebSearch({
config: {},
args: { query: "stale-keyless-runtime" },
}),
).rejects.toThrow("web_search is disabled or no provider is available.");
});
it("ignores auto-detected runtime metadata after config names an unknown provider", async () => {
const createTool = vi.fn(() => createCustomSearchTool());
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
createGoogleSearchProvider({
createTool,
}),
]);
const config = {
tools: {
web: {
search: {
provider: "missing-id",
},
},
},
};
activateSecretsRuntimeSnapshot({
sourceConfig: config,
config: structuredClone(config),
authStores: [],
warnings: [],
webTools: {
search: {
providerSource: "auto-detect",
selectedProvider: "google",
diagnostics: [],
},
fetch: {
providerSource: "none",
diagnostics: [],
},
diagnostics: [],
},
});
await expect(
runWebSearch({
config: structuredClone(config),
args: { query: "runtime-config-typo" },
}),
).rejects.toThrow("web_search is disabled or no provider is available.");
expect(createTool).not.toHaveBeenCalled();
});
it("ignores auto-detected keyless runtime metadata when resolving a provider definition", () => {
resolvePluginWebSearchProvidersMock.mockReturnValue([
createWebSearchTestProvider({
pluginId: "parallel",
id: "parallel-free",
credentialPath: "",
autoDetectOrder: 76,
requiresCredential: false,
}),
]);
const resolved = resolveWebSearchDefinition({
config: {},
runtimeWebSearch: {
providerSource: "auto-detect",
selectedProvider: "parallel-free",
diagnostics: [],
},
});
expect(resolved).toBeNull();
});
it("falls back to another provider when auto-selected search execution fails", async () => {
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
createGoogleSearchProvider({
requiresCredential: false,
createTool: () => ({
description: "google",
parameters: {},
@@ -624,7 +808,14 @@ describe("web search runtime", () => {
},
}),
}),
createDuckDuckGoSearchProvider(),
createWebSearchTestProvider({
pluginId: "backup-search",
id: "backup",
credentialPath: "tools.web.search.backup.apiKey",
autoDetectOrder: 2,
getConfiguredCredentialValue: () => "backup-configured",
getCredentialValue: () => "backup-configured",
}),
]);
await expect(
@@ -633,15 +824,14 @@ describe("web search runtime", () => {
args: { query: "fallback" },
}),
).resolves.toEqual({
provider: "duckduckgo",
result: { query: "fallback", provider: "duckduckgo" },
provider: "backup",
result: { query: "fallback", provider: "backup" },
});
});
it("falls back when an auto-selected provider returns a structured error payload", async () => {
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
createGoogleSearchProvider({
requiresCredential: false,
createTool: () => ({
description: "google",
parameters: {},
@@ -651,7 +841,14 @@ describe("web search runtime", () => {
}),
}),
}),
createDuckDuckGoSearchProvider(),
createWebSearchTestProvider({
pluginId: "backup-search",
id: "backup",
credentialPath: "tools.web.search.backup.apiKey",
autoDetectOrder: 2,
getConfiguredCredentialValue: () => "backup-configured",
getCredentialValue: () => "backup-configured",
}),
]);
await expect(
@@ -660,15 +857,14 @@ describe("web search runtime", () => {
args: { query: "fallback-structured-error" },
}),
).resolves.toEqual({
provider: "duckduckgo",
result: { query: "fallback-structured-error", provider: "duckduckgo" },
provider: "backup",
result: { query: "fallback-structured-error", provider: "backup" },
});
});
it("does not fall back when an auto-selected provider returns a validation error payload", async () => {
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
createGoogleSearchProvider({
requiresCredential: false,
createTool: () => ({
description: "google",
parameters: {},
@@ -920,7 +1116,7 @@ describe("web search runtime", () => {
).rejects.toThrow('Unknown web_search provider "missing-id".');
});
it("still falls back when config names an unknown provider id", async () => {
it("does not fall back when config names an unknown provider id", async () => {
resolveRuntimeWebSearchProvidersMock.mockReturnValue([
createGoogleSearchProvider({
createTool: () => {
@@ -930,22 +1126,20 @@ describe("web search runtime", () => {
createDuckDuckGoSearchProvider(),
]);
const result = await runWebSearch({
config: {
tools: {
web: {
search: {
provider: "missing-id",
await expect(
runWebSearch({
config: {
tools: {
web: {
search: {
provider: "missing-id",
},
},
},
},
},
args: { query: "config-typo" },
});
expect(result.provider).toBe("duckduckgo");
const searchResult = requireRecord(result.result);
expect(searchResult.provider).toBe("duckduckgo");
expect(searchResult.query).toBe("config-typo");
args: { query: "config-typo" },
}),
).rejects.toThrow("web_search is disabled or no provider is available.");
});
it("honors preferRuntimeProviders during execution", async () => {
+109 -20
View File
@@ -118,6 +118,28 @@ function hasEntryCredential(
});
}
function hasImplicitProviderSelectionSignal(
provider: Pick<
PluginWebSearchProviderEntry,
| "credentialPath"
| "id"
| "authProviderId"
| "envVars"
| "getConfiguredCredentialValue"
| "getConfiguredCredentialFallback"
| "getCredentialValue"
| "requiresCredential"
>,
config: OpenClawConfig | undefined,
search: WebSearchConfig | undefined,
agentDir?: string,
): boolean {
if (!providerRequiresCredential(provider)) {
return false;
}
return hasEntryCredential(provider, config, search, agentDir);
}
/** Reports whether a web_search provider has usable configured credentials. */
export function isWebSearchProviderConfigured(params: {
provider: Pick<
@@ -183,13 +205,8 @@ export function resolveWebSearchProviderId(params: {
}
if (!raw) {
let keylessFallbackProviderId = "";
for (const provider of providers) {
if (!providerRequiresCredential(provider)) {
keylessFallbackProviderId ||= provider.id;
continue;
}
if (!hasEntryCredential(provider, config, search, params.agentDir)) {
if (!hasImplicitProviderSelectionSignal(provider, config, search, params.agentDir)) {
continue;
}
logVerbose(
@@ -197,17 +214,63 @@ export function resolveWebSearchProviderId(params: {
);
return provider.id;
}
if (keylessFallbackProviderId) {
// Keyless providers are only used after credential-backed providers fail
// auto-detection, so configured API keys win when present.
logVerbose(
`web_search: no provider configured and no credentials found, falling back to keyless provider "${keylessFallbackProviderId}"`,
);
return keylessFallbackProviderId;
}
return "";
}
return providers[0]?.id ?? "";
return "";
}
function resolveRuntimePreferredWebSearchProviderId(params: {
config?: OpenClawConfig;
search?: WebSearchConfig;
runtimeWebSearch?: RuntimeWebSearchMetadata;
providers?: PluginWebSearchProviderEntry[];
agentDir?: string;
}): string | undefined {
const runtimeProviderId = normalizeOptionalLowercaseString(
params.runtimeWebSearch?.selectedProvider ?? params.runtimeWebSearch?.providerConfigured,
);
if (!runtimeProviderId) {
return undefined;
}
const configuredProviderId =
params.search && "provider" in params.search
? normalizeOptionalLowercaseString(params.search.provider)
: undefined;
if (configuredProviderId) {
const configuredProvider = params.providers?.find((entry) => entry.id === configuredProviderId);
return configuredProvider?.id === runtimeProviderId ? runtimeProviderId : undefined;
}
if (params.runtimeWebSearch?.providerSource === "configured") {
return runtimeProviderId;
}
const provider = params.providers?.find((entry) => entry.id === runtimeProviderId);
return provider &&
hasImplicitProviderSelectionSignal(provider, params.config, params.search, params.agentDir)
? provider.id
: undefined;
}
function resolveTrustedRuntimeWebSearchMetadata(params: {
config?: OpenClawConfig;
search?: WebSearchConfig;
runtimeWebSearch?: RuntimeWebSearchMetadata;
providers?: PluginWebSearchProviderEntry[];
agentDir?: string;
}): RuntimeWebSearchMetadata | undefined {
const runtimeWebSearch = params.runtimeWebSearch;
if (!runtimeWebSearch) {
return undefined;
}
const trustedProviderId = resolveRuntimePreferredWebSearchProviderId(params);
const runtimeProviderId = normalizeOptionalLowercaseString(
runtimeWebSearch.selectedProvider ?? runtimeWebSearch.providerConfigured,
);
if (trustedProviderId && trustedProviderId === runtimeProviderId) {
return runtimeWebSearch;
}
const { selectedProvider: _selectedProvider, ...metadataWithoutSelection } = runtimeWebSearch;
return metadataWithoutSelection;
}
function resolveExplicitWebSearchProviderId(params: {
@@ -331,10 +394,17 @@ export function resolveWebSearchDefinition(
providerId: options?.providerId,
preferRuntimeProviders: options?.preferRuntimeProviders,
});
const trustedRuntimeWebSearch = resolveTrustedRuntimeWebSearchMetadata({
config,
search,
runtimeWebSearch,
providers,
agentDir: options?.agentDir,
});
return resolveWebProviderDefinition({
config,
toolConfig: search as Record<string, unknown> | undefined,
runtimeMetadata: runtimeWebSearch,
runtimeMetadata: trustedRuntimeWebSearch,
sandboxed: options?.sandboxed,
providerId: options?.providerId,
providers,
@@ -356,7 +426,7 @@ export function resolveWebSearchDefinition(
agentDir: options?.agentDir,
search: toolConfig as WebSearchConfig | undefined,
providers: providersLocal,
}) || providersLocal[0]?.id,
}),
createTool: ({ provider, config: configLocal, toolConfig, runtimeMetadata }) =>
provider.createTool({
config: configLocal,
@@ -389,8 +459,13 @@ function resolveWebSearchCandidates(
const preferredIds = uniqueStrings(
[
options?.providerId,
runtimeWebSearch?.selectedProvider,
runtimeWebSearch?.providerConfigured,
resolveRuntimePreferredWebSearchProviderId({
config,
search,
runtimeWebSearch,
providers,
agentDir: options?.agentDir,
}),
resolveWebSearchProviderId({ config, agentDir: options?.agentDir, search, providers }),
].filter((value): value is string => Boolean(value)),
);
@@ -399,12 +474,26 @@ function resolveWebSearchCandidates(
if (explicitProviderId && !providers.some((entry) => entry.id === explicitProviderId)) {
throw new Error(`Unknown web_search provider "${explicitProviderId}".`);
}
const explicitSelection = hasExplicitWebSearchSelection({
search,
runtimeWebSearch,
providerId: options?.providerId,
providers,
});
if (preferredIds.length === 0 && !explicitSelection) {
return [];
}
const fallbackProviders = explicitSelection
? providers
: providers.filter((provider) =>
hasImplicitProviderSelectionSignal(provider, config, search, options?.agentDir),
);
const orderedProviders = [
...preferredIds
.map((id) => providers.find((entry) => entry.id === id))
.filter((entry): entry is PluginWebSearchProviderEntry => Boolean(entry)),
...providers.filter((entry) => !preferredIds.includes(entry.id)),
...fallbackProviders.filter((entry) => !preferredIds.includes(entry.id)),
];
return orderedProviders;
}