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 { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models";
|
||||||
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
|
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||||
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||||
|
import { createAnthropicWebSearchProvider } from "./src/anthropic-web-search-provider.js";
|
||||||
|
|
||||||
const PROVIDER_ID = "anthropic";
|
const PROVIDER_ID = "anthropic";
|
||||||
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6";
|
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6";
|
||||||
@ -395,6 +396,7 @@ export default definePluginEntry({
|
|||||||
profileId: ctx.profileId,
|
profileId: ctx.profileId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
api.registerWebSearchProvider(createAnthropicWebSearchProvider());
|
||||||
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
|
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