From a19cb27f9e0ee0ed6d7afde612c16e52557d96f5 Mon Sep 17 00:00:00 2001 From: Henry the Frog Date: Thu, 19 Mar 2026 09:38:44 -0600 Subject: [PATCH 1/2] feat: scaffold Anthropic native web search provider (#49949) Add initial implementation for Anthropic's server-side web_search_20260209 tool as a search provider option. This includes: - Web search provider plugin (extensions/anthropic/src/) - Stream wrapper to inject server tool into API payload - Provider registration in anthropic extension - Added to bundled web search plugin list WIP: Still needs wrapStreamFn wiring, compat flag integration, config schema, tests, and pi-ai server tool support verification. --- extensions/anthropic/index.ts | 2 + .../src/anthropic-native-search-wrapper.ts | 58 ++++++ .../src/anthropic-web-search-provider.ts | 175 ++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 extensions/anthropic/src/anthropic-native-search-wrapper.ts create mode 100644 extensions/anthropic/src/anthropic-web-search-provider.ts diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index 78f5bf3c17a..662ae442c58 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -28,6 +28,7 @@ import { import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models"; import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage"; import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import { createAnthropicWebSearchProvider } from "./src/anthropic-web-search-provider.js"; const PROVIDER_ID = "anthropic"; const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6"; @@ -395,6 +396,7 @@ export default definePluginEntry({ profileId: ctx.profileId, }), }); + api.registerWebSearchProvider(createAnthropicWebSearchProvider()); api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider); }, }); diff --git a/extensions/anthropic/src/anthropic-native-search-wrapper.ts b/extensions/anthropic/src/anthropic-native-search-wrapper.ts new file mode 100644 index 00000000000..598c83e4184 --- /dev/null +++ b/extensions/anthropic/src/anthropic-native-search-wrapper.ts @@ -0,0 +1,58 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { streamSimple } from "@mariozechner/pi-ai"; +import { buildAnthropicWebSearchServerTool } from "./anthropic-web-search-provider.js"; +import type { SearchConfigRecord } from "openclaw/plugin-sdk/provider-web-search"; + +/** + * Stream wrapper that injects Anthropic's native web_search server tool + * into the API payload for anthropic-messages models. + * + * This is the core mechanism: instead of OpenClaw intercepting tool calls + * and routing them to an external search API, we add the server tool to + * the tools array in the Anthropic Messages API request and let Claude + * handle search execution server-side. + * + * Server tool results come back as `server_tool_use` content blocks in + * the assistant message, with `web_search_tool_result` blocks containing + * the search results and encrypted content. + */ +export function createAnthropicNativeSearchStreamWrapper( + baseStreamFn: StreamFn | undefined, + searchConfig?: SearchConfigRecord, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + const serverTool = buildAnthropicWebSearchServerTool(searchConfig); + + return (model, context, options) => { + // Only apply to anthropic-messages API + if (model.api !== "anthropic-messages") { + return underlying(model, context, options); + } + + const originalOnPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + if (payload && typeof payload === "object") { + const payloadObj = payload as Record; + const tools = Array.isArray(payloadObj.tools) ? payloadObj.tools : []; + + // Check if a web_search server tool is already present + const hasServerSearch = tools.some( + (t: unknown) => + t && + typeof t === "object" && + typeof (t as Record).type === "string" && + ((t as Record).type as string).startsWith("web_search_"), + ); + + if (!hasServerSearch) { + // Inject the server tool + payloadObj.tools = [...tools, serverTool]; + } + } + return originalOnPayload?.(payload, model); + }, + }); + }; +} diff --git a/extensions/anthropic/src/anthropic-web-search-provider.ts b/extensions/anthropic/src/anthropic-web-search-provider.ts new file mode 100644 index 00000000000..b97df57a66d --- /dev/null +++ b/extensions/anthropic/src/anthropic-web-search-provider.ts @@ -0,0 +1,175 @@ +import { Type } from "@sinclair/typebox"; +import { + formatCliCommand, + resolveProviderWebSearchPluginConfig, + type SearchConfigRecord, + type WebSearchProviderPlugin, + type WebSearchProviderToolDefinition, +} from "openclaw/plugin-sdk/provider-web-search"; + +/** + * Anthropic native web search provider. + * + * Unlike other providers that intercept the `web_search` tool call and route + * to external APIs, this provider signals that Anthropic's server-side + * `web_search_20260209` tool should be injected directly into the Messages API + * request. Claude handles search execution server-side. + * + * The actual server tool injection happens via a stream wrapper that patches + * the Anthropic API payload (see `createAnthropicNativeSearchStreamWrapper`). + */ + +const ANTHROPIC_WEB_SEARCH_TOOL_VERSIONS = [ + "web_search_20250305", + "web_search_20260209", +] as const; + +type AnthropicWebSearchToolVersion = (typeof ANTHROPIC_WEB_SEARCH_TOOL_VERSIONS)[number]; + +const DEFAULT_TOOL_VERSION: AnthropicWebSearchToolVersion = "web_search_20260209"; + +type AnthropicWebSearchConfig = { + toolVersion?: string; + allowedDomains?: string[]; + blockedDomains?: string[]; + maxUses?: number; + userLocation?: { + type?: string; + city?: string; + region?: string; + country?: string; + timezone?: string; + }; +}; + +function resolveAnthropicWebSearchConfig( + searchConfig?: SearchConfigRecord, +): AnthropicWebSearchConfig { + const anthropic = searchConfig?.anthropic; + return anthropic && typeof anthropic === "object" && !Array.isArray(anthropic) + ? (anthropic as AnthropicWebSearchConfig) + : {}; +} + +function resolveToolVersion(config: AnthropicWebSearchConfig): AnthropicWebSearchToolVersion { + const version = config.toolVersion?.trim(); + if ( + version && + ANTHROPIC_WEB_SEARCH_TOOL_VERSIONS.includes(version as AnthropicWebSearchToolVersion) + ) { + return version as AnthropicWebSearchToolVersion; + } + return DEFAULT_TOOL_VERSION; +} + +/** + * Build the Anthropic server tool definition to inject into the API payload. + * This is NOT a regular OpenClaw tool — it's the raw Anthropic server tool spec + * that gets added to the `tools` array in the Messages API request. + */ +export function buildAnthropicWebSearchServerTool( + searchConfig?: SearchConfigRecord, +): Record { + const config = resolveAnthropicWebSearchConfig(searchConfig); + const toolVersion = resolveToolVersion(config); + + const tool: Record = { + type: toolVersion, + name: "web_search", + }; + + if (config.allowedDomains?.length) { + tool.allowed_domains = config.allowedDomains; + } + if (config.blockedDomains?.length) { + tool.blocked_domains = config.blockedDomains; + } + if (typeof config.maxUses === "number" && config.maxUses > 0) { + tool.max_uses = config.maxUses; + } + if (config.userLocation) { + const loc: Record = {}; + if (config.userLocation.type) loc.type = config.userLocation.type; + if (config.userLocation.city) loc.city = config.userLocation.city; + if (config.userLocation.region) loc.region = config.userLocation.region; + if (config.userLocation.country) loc.country = config.userLocation.country; + if (config.userLocation.timezone) loc.timezone = config.userLocation.timezone; + if (Object.keys(loc).length > 0) { + tool.user_location = loc; + } + } + + return tool; +} + +function createAnthropicWebSearchToolDefinition( + _searchConfig?: SearchConfigRecord, +): WebSearchProviderToolDefinition { + return { + description: + "Search the web using Anthropic's native server-side web search. " + + "Claude executes searches directly via the Messages API with built-in citations. " + + "This is a server tool — searches are handled automatically.", + parameters: Type.Object({ + query: Type.String({ description: "Search query string." }), + }), + execute: async (_args) => { + // This tool should never actually be called — the server tool handles + // execution. If we get here, the provider is misconfigured. + return { + error: "anthropic_native_search_misconfigured", + message: + "Anthropic native web search is configured but the server tool was not injected. " + + "This provider requires direct Anthropic API access (not proxied). " + + `Run \`${formatCliCommand("openclaw configure --section web")}\` to check configuration.`, + }; + }, + }; +} + +export function createAnthropicWebSearchProvider(): WebSearchProviderPlugin { + return { + id: "anthropic", + label: "Anthropic Native Search", + hint: "Server-side search · built-in citations · domain filtering · prompt caching", + envVars: ["ANTHROPIC_API_KEY"], + placeholder: "sk-ant-...", + signupUrl: "https://console.anthropic.com/", + docsUrl: "https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search", + autoDetectOrder: 50, // Lower priority than dedicated search providers + credentialPath: "plugins.entries.anthropic.config.webSearch.enabled", + inactiveSecretPaths: [], + getCredentialValue: (_searchConfig) => { + // Uses the main Anthropic API key — no separate credential needed + return process.env.ANTHROPIC_API_KEY; + }, + setCredentialValue: () => { + // No-op: uses the main Anthropic API key + }, + createTool: (ctx) => + createAnthropicWebSearchToolDefinition( + (() => { + const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; + const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "anthropic"); + if (!pluginConfig) { + return searchConfig; + } + return { + ...(searchConfig ?? {}), + anthropic: { + ...resolveAnthropicWebSearchConfig(searchConfig), + ...pluginConfig, + }, + } as SearchConfigRecord; + })(), + ), + }; +} + +export const __testing = { + resolveAnthropicWebSearchConfig, + resolveToolVersion, + buildAnthropicWebSearchServerTool, + ANTHROPIC_WEB_SEARCH_TOOL_VERSIONS, + DEFAULT_TOOL_VERSION, +} as const; From 706dce9c00e9e7b1a52874ab732060a203b16f79 Mon Sep 17 00:00:00 2001 From: Henry the Frog Date: Thu, 19 Mar 2026 17:18:13 -0600 Subject: [PATCH 2/2] test: add unit tests for Anthropic native web search provider (#49949) - 15 tests for config resolution, tool version, and server tool building - 3 tests for stream wrapper (injection, dedup, non-anthropic passthrough) - All 18 tests green --- .../anthropic-native-search-wrapper.test.ts | 53 ++++++++ .../src/anthropic-web-search.test.ts | 122 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 extensions/anthropic/src/anthropic-native-search-wrapper.test.ts create mode 100644 extensions/anthropic/src/anthropic-web-search.test.ts diff --git a/extensions/anthropic/src/anthropic-native-search-wrapper.test.ts b/extensions/anthropic/src/anthropic-native-search-wrapper.test.ts new file mode 100644 index 00000000000..06978c4befc --- /dev/null +++ b/extensions/anthropic/src/anthropic-native-search-wrapper.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { createAnthropicNativeSearchStreamWrapper } from "./anthropic-native-search-wrapper.js"; + +describe("createAnthropicNativeSearchStreamWrapper", () => { + it("injects web_search server tool into anthropic-messages payloads", () => { + const capturedPayloads: any[] = []; + const fakeStream = vi.fn((_model: any, _context: any, options: any) => { + // Simulate calling onPayload + if (options?.onPayload) { + const payload = { tools: [{ type: "function", name: "other_tool" }] }; + options.onPayload(payload); + capturedPayloads.push(payload); + } + return { type: "stream", events: [] } as any; + }); + + const wrapped = createAnthropicNativeSearchStreamWrapper(fakeStream as any); + wrapped({ api: "anthropic-messages" } as any, {} as any, {} as any); + + expect(fakeStream).toHaveBeenCalledOnce(); + expect(capturedPayloads[0].tools).toHaveLength(2); + expect(capturedPayloads[0].tools[1].type).toMatch(/^web_search_/); + expect(capturedPayloads[0].tools[1].name).toBe("web_search"); + }); + + it("does not inject if web_search server tool already present", () => { + const capturedPayloads: any[] = []; + const fakeStream = vi.fn((_model: any, _context: any, options: any) => { + if (options?.onPayload) { + const payload = { tools: [{ type: "web_search_20260209", name: "web_search" }] }; + options.onPayload(payload); + capturedPayloads.push(payload); + } + return { type: "stream", events: [] } as any; + }); + + const wrapped = createAnthropicNativeSearchStreamWrapper(fakeStream as any); + wrapped({ api: "anthropic-messages" } as any, {} as any, {} as any); + + expect(capturedPayloads[0].tools).toHaveLength(1); + }); + + it("passes through non-anthropic API calls unchanged", () => { + const fakeStream = vi.fn(() => ({ type: "stream" }) as any); + const wrapped = createAnthropicNativeSearchStreamWrapper(fakeStream as any); + + wrapped({ api: "openai-chat" } as any, {} as any, { onPayload: vi.fn() } as any); + + // onPayload should be the original, not our wrapper + const passedOptions = fakeStream.mock.calls[0][2]; + expect(passedOptions.onPayload).toBeDefined(); + }); +}); diff --git a/extensions/anthropic/src/anthropic-web-search.test.ts b/extensions/anthropic/src/anthropic-web-search.test.ts new file mode 100644 index 00000000000..0268bb6d6b5 --- /dev/null +++ b/extensions/anthropic/src/anthropic-web-search.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { __testing, buildAnthropicWebSearchServerTool } from "./anthropic-web-search-provider.js"; + +const { resolveAnthropicWebSearchConfig, resolveToolVersion, DEFAULT_TOOL_VERSION } = __testing; + +describe("resolveAnthropicWebSearchConfig", () => { + it("returns empty object when no config", () => { + expect(resolveAnthropicWebSearchConfig(undefined)).toEqual({}); + expect(resolveAnthropicWebSearchConfig({})).toEqual({}); + }); + + it("returns empty object for non-object anthropic value", () => { + expect(resolveAnthropicWebSearchConfig({ anthropic: "invalid" } as any)).toEqual({}); + expect(resolveAnthropicWebSearchConfig({ anthropic: [] } as any)).toEqual({}); + }); + + it("passes through anthropic config object", () => { + const config = { anthropic: { maxUses: 5, allowedDomains: ["example.com"] } }; + expect(resolveAnthropicWebSearchConfig(config as any)).toEqual({ + maxUses: 5, + allowedDomains: ["example.com"], + }); + }); +}); + +describe("resolveToolVersion", () => { + it("defaults to latest version", () => { + expect(resolveToolVersion({})).toBe(DEFAULT_TOOL_VERSION); + }); + + it("accepts valid versions", () => { + expect(resolveToolVersion({ toolVersion: "web_search_20250305" })).toBe("web_search_20250305"); + expect(resolveToolVersion({ toolVersion: "web_search_20260209" })).toBe("web_search_20260209"); + }); + + it("falls back to default for invalid versions", () => { + expect(resolveToolVersion({ toolVersion: "invalid" })).toBe(DEFAULT_TOOL_VERSION); + expect(resolveToolVersion({ toolVersion: " " })).toBe(DEFAULT_TOOL_VERSION); + }); +}); + +describe("buildAnthropicWebSearchServerTool", () => { + it("builds minimal tool with defaults", () => { + const tool = buildAnthropicWebSearchServerTool(); + expect(tool).toEqual({ + type: DEFAULT_TOOL_VERSION, + name: "web_search", + }); + }); + + it("includes allowed_domains when configured", () => { + const tool = buildAnthropicWebSearchServerTool({ + anthropic: { allowedDomains: ["example.com", "docs.dev"] }, + } as any); + expect(tool.allowed_domains).toEqual(["example.com", "docs.dev"]); + }); + + it("includes blocked_domains when configured", () => { + const tool = buildAnthropicWebSearchServerTool({ + anthropic: { blockedDomains: ["spam.com"] }, + } as any); + expect(tool.blocked_domains).toEqual(["spam.com"]); + }); + + it("includes max_uses when positive", () => { + const tool = buildAnthropicWebSearchServerTool({ + anthropic: { maxUses: 3 }, + } as any); + expect(tool.max_uses).toBe(3); + }); + + it("excludes max_uses when zero or negative", () => { + expect( + buildAnthropicWebSearchServerTool({ anthropic: { maxUses: 0 } } as any).max_uses, + ).toBeUndefined(); + expect( + buildAnthropicWebSearchServerTool({ anthropic: { maxUses: -1 } } as any).max_uses, + ).toBeUndefined(); + }); + + it("includes user_location when configured", () => { + const tool = buildAnthropicWebSearchServerTool({ + anthropic: { + userLocation: { + type: "approximate", + city: "Denver", + region: "Colorado", + country: "US", + timezone: "America/Denver", + }, + }, + } as any); + expect(tool.user_location).toEqual({ + type: "approximate", + city: "Denver", + region: "Colorado", + country: "US", + timezone: "America/Denver", + }); + }); + + it("omits user_location when empty", () => { + const tool = buildAnthropicWebSearchServerTool({ + anthropic: { userLocation: {} }, + } as any); + expect(tool.user_location).toBeUndefined(); + }); + + it("respects custom tool version", () => { + const tool = buildAnthropicWebSearchServerTool({ + anthropic: { toolVersion: "web_search_20250305" }, + } as any); + expect(tool.type).toBe("web_search_20250305"); + }); + + it("omits empty allowed_domains array", () => { + const tool = buildAnthropicWebSearchServerTool({ + anthropic: { allowedDomains: [] }, + } as any); + expect(tool.allowed_domains).toBeUndefined(); + }); +});