@oh-my-pi/anthropic-websearch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @oh-my-pi/anthropic-websearch
2
+
3
+ Claude web search tool for [pi](https://github.com/badlogic/pi-mono).
4
+
5
+ Uses Anthropic's built-in `web_search_20250305` tool to search the web and synthesize answers with citations.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ omp install @oh-my-pi/anthropic-websearch
11
+ ```
12
+
13
+ ## Authentication
14
+
15
+ The plugin checks for credentials in this order:
16
+
17
+ 1. **Explicit override**: `ANTHROPIC_SEARCH_API_KEY` / `ANTHROPIC_SEARCH_BASE_URL`
18
+ 2. **models.json**: Provider with `api: "anthropic-messages"` in `~/.pi/agent/models.json`
19
+ 3. **OAuth**: Anthropic OAuth credentials in `~/.pi/agent/auth.json`
20
+ 4. **Fallback**: `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
21
+
22
+ This ordering prevents accidentally charging your console account if you have a proxy or OAuth set up.
23
+
24
+ ### Example: Using a proxy
25
+
26
+ If your `~/.pi/agent/models.json` has:
27
+
28
+ ```json
29
+ {
30
+ "providers": {
31
+ "my-proxy": {
32
+ "baseUrl": "http://localhost:4000",
33
+ "apiKey": "none",
34
+ "api": "anthropic-messages",
35
+ "models": [...]
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ The plugin will automatically use `http://localhost:4000` with no API key.
42
+
43
+ ### Example: Direct API key
44
+
45
+ ```bash
46
+ export ANTHROPIC_SEARCH_API_KEY=sk-ant-xxx
47
+ ```
48
+
49
+ ## Tools
50
+
51
+ ### `anthropic_web_search`
52
+
53
+ Search the web using Claude's built-in web search capability.
54
+
55
+ **Parameters:**
56
+
57
+ - `query` (required): The search query or question
58
+ - `system_prompt`: Guide the response style and focus
59
+ - `max_tokens`: Maximum tokens in response (default: 4096)
60
+
61
+ **Response includes:**
62
+
63
+ - Synthesized answer with inline citations
64
+ - List of sources with titles, URLs, and page ages
65
+ - Search queries Claude generated
66
+
67
+ ## Configuration
68
+
69
+ | Variable | Env | Description |
70
+ | --------- | --------------------------- | -------------------------------------------------- |
71
+ | `apiKey` | `ANTHROPIC_SEARCH_API_KEY` | API key (optional if using proxy/oauth) |
72
+ | `baseUrl` | `ANTHROPIC_SEARCH_BASE_URL` | Base URL override |
73
+ | `model` | `ANTHROPIC_SEARCH_MODEL` | Model to use (default: `claude-sonnet-4-20250514`) |
74
+
75
+ ## License
76
+
77
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@oh-my-pi/anthropic-websearch",
3
+ "version": "0.1.0",
4
+ "description": "Anthropic Claude web search tool for pi",
5
+ "keywords": [
6
+ "omp-plugin",
7
+ "anthropic",
8
+ "claude",
9
+ "web-search"
10
+ ],
11
+ "author": "Can Bölük <me@can.ac>",
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/can1357/oh-my-pi.git",
16
+ "directory": "plugins/anthropic-websearch"
17
+ },
18
+ "omp": {
19
+ "install": [
20
+ {
21
+ "src": "tools/anthropic-websearch/index.ts",
22
+ "dest": "agent/tools/anthropic-websearch/index.ts"
23
+ }
24
+ ],
25
+ "variables": {
26
+ "apiKey": {
27
+ "type": "string",
28
+ "env": "ANTHROPIC_SEARCH_API_KEY",
29
+ "description": "Anthropic API key for web search (optional if using models.json provider or oauth)",
30
+ "required": false
31
+ },
32
+ "baseUrl": {
33
+ "type": "string",
34
+ "env": "ANTHROPIC_SEARCH_BASE_URL",
35
+ "description": "Anthropic API base URL for web search (optional)",
36
+ "required": false
37
+ },
38
+ "model": {
39
+ "type": "string",
40
+ "env": "ANTHROPIC_SEARCH_MODEL",
41
+ "description": "Model to use for web search",
42
+ "default": "claude-sonnet-4-20250514"
43
+ }
44
+ }
45
+ },
46
+ "files": [
47
+ "tools"
48
+ ]
49
+ }
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Anthropic Web Search Tool
3
+ *
4
+ * Uses Claude's built-in web_search_20250305 tool to search the web.
5
+ *
6
+ * Auth resolution order:
7
+ * 1. ANTHROPIC_SEARCH_API_KEY / ANTHROPIC_SEARCH_BASE_URL env vars
8
+ * 2. Provider with api="anthropic-messages" in ~/.pi/agent/models.json
9
+ * 3. OAuth credentials in ~/.pi/agent/auth.json
10
+ * 4. ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL as final fallback
11
+ */
12
+
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { Type, type TSchema } from "@sinclair/typebox";
17
+ import type {
18
+ CustomAgentTool,
19
+ CustomToolFactory,
20
+ ToolAPI,
21
+ } from "@mariozechner/pi-coding-agent";
22
+
23
+ const DEFAULT_BASE_URL = "https://api.anthropic.com";
24
+ const DEFAULT_MODEL = "claude-sonnet-4-20250514";
25
+
26
+ interface AuthConfig {
27
+ apiKey: string;
28
+ baseUrl: string;
29
+ }
30
+
31
+ interface ModelsJson {
32
+ providers?: Record<string, {
33
+ baseUrl?: string;
34
+ apiKey?: string;
35
+ api?: string;
36
+ }>;
37
+ }
38
+
39
+ interface AuthJson {
40
+ anthropic?: {
41
+ type: "oauth";
42
+ access: string;
43
+ expires: number;
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Parse a .env file and return key-value pairs
49
+ */
50
+ function parseEnvFile(filePath: string): Record<string, string> {
51
+ const result: Record<string, string> = {};
52
+ if (!fs.existsSync(filePath)) return result;
53
+
54
+ try {
55
+ const content = fs.readFileSync(filePath, "utf-8");
56
+ for (const line of content.split("\n")) {
57
+ const trimmed = line.trim();
58
+ if (!trimmed || trimmed.startsWith("#")) continue;
59
+
60
+ const eqIndex = trimmed.indexOf("=");
61
+ if (eqIndex === -1) continue;
62
+
63
+ const key = trimmed.slice(0, eqIndex).trim();
64
+ let value = trimmed.slice(eqIndex + 1).trim();
65
+
66
+ if (
67
+ (value.startsWith('"') && value.endsWith('"')) ||
68
+ (value.startsWith("'") && value.endsWith("'"))
69
+ ) {
70
+ value = value.slice(1, -1);
71
+ }
72
+
73
+ result[key] = value;
74
+ }
75
+ } catch {
76
+ // Ignore read errors
77
+ }
78
+
79
+ return result;
80
+ }
81
+
82
+ /**
83
+ * Get env var from process.env or .env files
84
+ */
85
+ function getEnv(key: string): string | undefined {
86
+ if (process.env[key]) return process.env[key];
87
+
88
+ const localEnv = parseEnvFile(path.join(process.cwd(), ".env"));
89
+ if (localEnv[key]) return localEnv[key];
90
+
91
+ const homeEnv = parseEnvFile(path.join(os.homedir(), ".env"));
92
+ if (homeEnv[key]) return homeEnv[key];
93
+
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * Read JSON file safely
99
+ */
100
+ function readJson<T>(filePath: string): T | null {
101
+ try {
102
+ if (!fs.existsSync(filePath)) return null;
103
+ const content = fs.readFileSync(filePath, "utf-8");
104
+ return JSON.parse(content) as T;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Find auth config using priority order:
112
+ * 1. ANTHROPIC_SEARCH_API_KEY / ANTHROPIC_SEARCH_BASE_URL
113
+ * 2. Provider with api="anthropic-messages" in models.json
114
+ * 3. OAuth in auth.json
115
+ * 4. ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL fallback
116
+ */
117
+ function findAuthConfig(): AuthConfig | null {
118
+ const piAgentDir = path.join(os.homedir(), ".pi", "agent");
119
+
120
+ // 1. Explicit search-specific env vars
121
+ const searchApiKey = getEnv("ANTHROPIC_SEARCH_API_KEY");
122
+ const searchBaseUrl = getEnv("ANTHROPIC_SEARCH_BASE_URL");
123
+ if (searchApiKey) {
124
+ return {
125
+ apiKey: searchApiKey,
126
+ baseUrl: searchBaseUrl ?? DEFAULT_BASE_URL,
127
+ };
128
+ }
129
+
130
+ // 2. Provider with api="anthropic-messages" in models.json
131
+ const modelsJson = readJson<ModelsJson>(path.join(piAgentDir, "models.json"));
132
+ if (modelsJson?.providers) {
133
+ for (const [_name, provider] of Object.entries(modelsJson.providers)) {
134
+ if (provider.api === "anthropic-messages" && provider.apiKey && provider.apiKey !== "none") {
135
+ return {
136
+ apiKey: provider.apiKey,
137
+ baseUrl: provider.baseUrl ?? DEFAULT_BASE_URL,
138
+ };
139
+ }
140
+ }
141
+ // Also check for providers with baseUrl but apiKey="none" (proxy)
142
+ for (const [_name, provider] of Object.entries(modelsJson.providers)) {
143
+ if (provider.api === "anthropic-messages" && provider.baseUrl) {
144
+ return {
145
+ apiKey: provider.apiKey ?? "",
146
+ baseUrl: provider.baseUrl,
147
+ };
148
+ }
149
+ }
150
+ }
151
+
152
+ // 3. OAuth credentials in auth.json
153
+ const authJson = readJson<AuthJson>(path.join(piAgentDir, "auth.json"));
154
+ if (authJson?.anthropic?.type === "oauth" && authJson.anthropic.access) {
155
+ // Check if not expired
156
+ if (authJson.anthropic.expires > Date.now()) {
157
+ return {
158
+ apiKey: authJson.anthropic.access,
159
+ baseUrl: DEFAULT_BASE_URL,
160
+ };
161
+ }
162
+ }
163
+
164
+ // 4. Generic ANTHROPIC_API_KEY fallback
165
+ const apiKey = getEnv("ANTHROPIC_API_KEY");
166
+ const baseUrl = getEnv("ANTHROPIC_BASE_URL");
167
+ if (apiKey) {
168
+ return {
169
+ apiKey,
170
+ baseUrl: baseUrl ?? DEFAULT_BASE_URL,
171
+ };
172
+ }
173
+
174
+ return null;
175
+ }
176
+
177
+ // Response types
178
+ interface ServerToolUse {
179
+ type: "server_tool_use";
180
+ id: string;
181
+ name: "web_search";
182
+ input: { query: string };
183
+ }
184
+
185
+ interface WebSearchResult {
186
+ type: "web_search_result";
187
+ title: string;
188
+ url: string;
189
+ encrypted_content: string;
190
+ page_age: string | null;
191
+ }
192
+
193
+ interface WebSearchToolResult {
194
+ type: "web_search_tool_result";
195
+ tool_use_id: string;
196
+ content: WebSearchResult[];
197
+ }
198
+
199
+ interface Citation {
200
+ type: "web_search_result_location";
201
+ url: string;
202
+ title: string;
203
+ cited_text: string;
204
+ encrypted_index: string;
205
+ }
206
+
207
+ interface TextBlock {
208
+ type: "text";
209
+ text: string;
210
+ citations?: Citation[];
211
+ }
212
+
213
+ type ContentBlock = ServerToolUse | WebSearchToolResult | TextBlock;
214
+
215
+ interface MessagesResponse {
216
+ id: string;
217
+ type: string;
218
+ role: string;
219
+ content: ContentBlock[];
220
+ model: string;
221
+ stop_reason: string;
222
+ usage: {
223
+ input_tokens: number;
224
+ output_tokens: number;
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Call Anthropic Messages API with web search tool
230
+ */
231
+ async function callAnthropicWebSearch(
232
+ auth: AuthConfig,
233
+ query: string,
234
+ model: string,
235
+ systemPrompt?: string,
236
+ maxTokens?: number,
237
+ ): Promise<MessagesResponse> {
238
+ const url = `${auth.baseUrl}/v1/messages`;
239
+
240
+ const body: Record<string, unknown> = {
241
+ model,
242
+ max_tokens: maxTokens ?? 4096,
243
+ messages: [{ role: "user", content: query }],
244
+ tools: [{ type: "web_search_20250305", name: "web_search" }],
245
+ };
246
+
247
+ if (systemPrompt) {
248
+ body.system = systemPrompt;
249
+ }
250
+
251
+ const headers: Record<string, string> = {
252
+ "Content-Type": "application/json",
253
+ "anthropic-version": "2023-06-01",
254
+ };
255
+
256
+ // Handle different auth types
257
+ if (auth.apiKey.startsWith("sk-ant-")) {
258
+ headers["x-api-key"] = auth.apiKey;
259
+ } else if (auth.apiKey && auth.apiKey !== "none") {
260
+ // OAuth token or other bearer token
261
+ headers["Authorization"] = `Bearer ${auth.apiKey}`;
262
+ }
263
+
264
+ const response = await fetch(url, {
265
+ method: "POST",
266
+ headers,
267
+ body: JSON.stringify(body),
268
+ });
269
+
270
+ if (!response.ok) {
271
+ const errorText = await response.text();
272
+ throw new Error(`Anthropic API error (${response.status}): ${errorText}`);
273
+ }
274
+
275
+ return response.json() as Promise<MessagesResponse>;
276
+ }
277
+
278
+ /**
279
+ * Format response for display
280
+ */
281
+ function formatResponse(response: MessagesResponse): { text: string; details: unknown } {
282
+ const parts: string[] = [];
283
+ const searchQueries: string[] = [];
284
+ const sources: Array<{ title: string; url: string; age: string | null }> = [];
285
+ const citations: Citation[] = [];
286
+
287
+ for (const block of response.content) {
288
+ if (block.type === "server_tool_use" && block.name === "web_search") {
289
+ searchQueries.push(block.input.query);
290
+ } else if (block.type === "web_search_tool_result") {
291
+ for (const result of block.content) {
292
+ if (result.type === "web_search_result") {
293
+ sources.push({
294
+ title: result.title,
295
+ url: result.url,
296
+ age: result.page_age,
297
+ });
298
+ }
299
+ }
300
+ } else if (block.type === "text") {
301
+ parts.push(block.text);
302
+ if (block.citations) {
303
+ citations.push(...block.citations);
304
+ }
305
+ }
306
+ }
307
+
308
+ let text = parts.join("\n\n");
309
+
310
+ // Add sources
311
+ if (sources.length > 0) {
312
+ text += "\n\n## Sources";
313
+ for (const [i, src] of sources.entries()) {
314
+ const age = src.age ? ` (${src.age})` : "";
315
+ text += `\n[${i + 1}] ${src.title}${age}\n ${src.url}`;
316
+ }
317
+ }
318
+
319
+ return {
320
+ text,
321
+ details: {
322
+ model: response.model,
323
+ usage: response.usage,
324
+ searchQueries,
325
+ sources,
326
+ citations: citations.map((c) => ({
327
+ url: c.url,
328
+ title: c.title,
329
+ citedText: c.cited_text,
330
+ })),
331
+ },
332
+ };
333
+ }
334
+
335
+ // Tool schema
336
+ const SearchSchema = Type.Object({
337
+ query: Type.String({
338
+ description: "The search query or question to answer using web search",
339
+ }),
340
+ system_prompt: Type.Optional(
341
+ Type.String({
342
+ description: "System prompt to guide the response style and focus",
343
+ }),
344
+ ),
345
+ max_tokens: Type.Optional(
346
+ Type.Number({
347
+ description: "Maximum tokens in response (default: 4096)",
348
+ minimum: 1,
349
+ maximum: 16384,
350
+ }),
351
+ ),
352
+ });
353
+
354
+ type SearchParams = {
355
+ query: string;
356
+ system_prompt?: string;
357
+ max_tokens?: number;
358
+ };
359
+
360
+ const factory: CustomToolFactory = async (
361
+ _toolApi: ToolAPI,
362
+ ): Promise<CustomAgentTool<TSchema, unknown>[] | null> => {
363
+ const auth = findAuthConfig();
364
+ if (!auth) {
365
+ console.error("anthropic-websearch: No auth config found. Set ANTHROPIC_SEARCH_API_KEY or configure models.json/auth.json");
366
+ return null;
367
+ }
368
+
369
+ const model = getEnv("ANTHROPIC_SEARCH_MODEL") ?? DEFAULT_MODEL;
370
+
371
+ const tool: CustomAgentTool<typeof SearchSchema, unknown> = {
372
+ name: "anthropic_web_search",
373
+ label: "Anthropic Web Search",
374
+ description: `Web search powered by Claude (${model}). Uses Claude's built-in web search capability to find current information and synthesize answers with citations. Best for questions requiring up-to-date information from the web.`,
375
+ parameters: SearchSchema,
376
+ async execute(_toolCallId, params) {
377
+ try {
378
+ const p = (params ?? {}) as SearchParams;
379
+ const response = await callAnthropicWebSearch(
380
+ auth,
381
+ p.query,
382
+ model,
383
+ p.system_prompt,
384
+ p.max_tokens,
385
+ );
386
+ const { text, details } = formatResponse(response);
387
+ return {
388
+ content: [{ type: "text" as const, text }],
389
+ details,
390
+ };
391
+ } catch (error) {
392
+ const message = error instanceof Error ? error.message : String(error);
393
+ return {
394
+ content: [{ type: "text" as const, text: `Error: ${message}` }],
395
+ details: { error: message },
396
+ };
397
+ }
398
+ },
399
+ };
400
+
401
+ return [tool];
402
+ };
403
+
404
+ export default factory;