mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
Keep key-free web search providers opt-in (#93616)
Merged via squash. Prepared head SHA:5de02da038Co-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 commitc48b36a255) # Conflicts: # docs/tools/web.md
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user