Merge 706dce9c00e9e7b1a52874ab732060a203b16f79 into 598f1826d8b2bc969aace2c6459824737667218c
This commit is contained in:
commit
15d4ef4320
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
58
extensions/anthropic/src/anthropic-native-search-wrapper.ts
Normal file
58
extensions/anthropic/src/anthropic-native-search-wrapper.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
175
extensions/anthropic/src/anthropic-web-search-provider.ts
Normal file
175
extensions/anthropic/src/anthropic-web-search-provider.ts
Normal 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;
|
||||
122
extensions/anthropic/src/anthropic-web-search.test.ts
Normal file
122
extensions/anthropic/src/anthropic-web-search.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user