Merge 706dce9c00e9e7b1a52874ab732060a203b16f79 into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
henry-the-frog 2026-03-21 11:25:51 +08:00 committed by GitHub
commit 15d4ef4320
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 410 additions and 0 deletions

View File

@ -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);
},
});

View File

@ -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();
});
});

View File

@ -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<string, unknown>;
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<string, unknown>).type === "string" &&
((t as Record<string, unknown>).type as string).startsWith("web_search_"),
);
if (!hasServerSearch) {
// Inject the server tool
payloadObj.tools = [...tools, serverTool];
}
}
return originalOnPayload?.(payload, model);
},
});
};
}

View File

@ -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<string, unknown> {
const config = resolveAnthropicWebSearchConfig(searchConfig);
const toolVersion = resolveToolVersion(config);
const tool: Record<string, unknown> = {
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<string, string> = {};
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;

View File

@ -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();
});
});