webseek 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.
@@ -0,0 +1,550 @@
1
+ import { z } from "zod";
2
+ //#region src/utils/error.ts
3
+ var WebseekError = class extends Error {
4
+ code;
5
+ constructor(options) {
6
+ super(options.message, { cause: options.cause });
7
+ this.name = "WebseekError";
8
+ this.code = options.code;
9
+ }
10
+ };
11
+ /**
12
+ * Map a thrown value to a process exit code: `2` for usage mistakes, `1` for
13
+ * any other failure.
14
+ */
15
+ function errorExitCode(error) {
16
+ if (error instanceof WebseekError) return error.code === "invalid_usage" ? 2 : 1;
17
+ return 1;
18
+ }
19
+ /** Render any thrown value into a single-line, user-facing message. */
20
+ function formatError(error) {
21
+ if (error instanceof WebseekError) return `[${error.code}] ${error.message}`;
22
+ if (error instanceof Error) return error.message;
23
+ if (typeof error === "string") return error;
24
+ return JSON.stringify(error);
25
+ }
26
+ //#endregion
27
+ //#region src/config/env.ts
28
+ /**
29
+ * Resolves provider credentials and configuration from environment variables.
30
+ *
31
+ * API keys are read from the environment only (never from CLI flags/args) so
32
+ * they don't leak into shell history or process listings.
33
+ *
34
+ * Each provider's base URL can be overridden via an environment variable. This
35
+ * keeps the defaults pointed at the real services while letting tests (and
36
+ * proxies) redirect traffic to a local endpoint.
37
+ */
38
+ const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com";
39
+ const DEFAULT_GOOGLE_BASE_URL = "https://www.googleapis.com";
40
+ const DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
41
+ const DEFAULT_VERTEX_BASE_URL = "https://aiplatform.googleapis.com";
42
+ function stripTrailingSlash(value) {
43
+ return value.endsWith("/") ? value.slice(0, -1) : value;
44
+ }
45
+ function resolveOpenAIConfig(params = {}) {
46
+ const env = params.env ?? process.env;
47
+ const apiKey = env.OPENAI_API_KEY;
48
+ if (!apiKey) throw new WebseekError({
49
+ code: "missing_config",
50
+ message: "OPENAI_API_KEY is not set. Export it to use the openai provider."
51
+ });
52
+ return {
53
+ apiKey,
54
+ baseUrl: stripTrailingSlash(env.WEBSEEK_OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL)
55
+ };
56
+ }
57
+ function resolveGoogleCseConfig(params = {}) {
58
+ const env = params.env ?? process.env;
59
+ const apiKey = env.GOOGLE_API_KEY;
60
+ const cx = env.GOOGLE_CSE_CX ?? env.GOOGLE_CSE_ID;
61
+ if (!apiKey) throw new WebseekError({
62
+ code: "missing_config",
63
+ message: "GOOGLE_API_KEY is not set. Export it to use the google provider."
64
+ });
65
+ if (!cx) throw new WebseekError({
66
+ code: "missing_config",
67
+ message: "GOOGLE_CSE_CX is not set. Export your Programmable Search Engine ID to use the google provider."
68
+ });
69
+ return {
70
+ apiKey,
71
+ cx,
72
+ baseUrl: stripTrailingSlash(env.WEBSEEK_GOOGLE_BASE_URL ?? DEFAULT_GOOGLE_BASE_URL)
73
+ };
74
+ }
75
+ function resolveGeminiConfig(params) {
76
+ const env = params.env ?? process.env;
77
+ const { backend } = params;
78
+ if (backend === "vertex-express") {
79
+ const apiKey = env.VERTEX_API_KEY;
80
+ if (!apiKey) throw new WebseekError({
81
+ code: "missing_config",
82
+ message: "VERTEX_API_KEY is not set. Export it to use the gemini provider with the vertex-express backend."
83
+ });
84
+ return {
85
+ apiKey,
86
+ backend,
87
+ baseUrl: stripTrailingSlash(env.WEBSEEK_VERTEX_BASE_URL ?? DEFAULT_VERTEX_BASE_URL)
88
+ };
89
+ }
90
+ const apiKey = env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY;
91
+ if (!apiKey) throw new WebseekError({
92
+ code: "missing_config",
93
+ message: "GEMINI_API_KEY is not set. Export it to use the gemini provider."
94
+ });
95
+ return {
96
+ apiKey,
97
+ backend,
98
+ baseUrl: stripTrailingSlash(env.WEBSEEK_GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL)
99
+ };
100
+ }
101
+ //#endregion
102
+ //#region src/providers/gemini.ts
103
+ /**
104
+ * Gemini web search provider via "Grounding with Google Search".
105
+ *
106
+ * Grounded provider: the model runs searches and returns an answer plus
107
+ * grounding metadata (sources + the queries it ran).
108
+ *
109
+ * Two backends share the same `generateContent` request/response shape; only
110
+ * the host, auth style, and the search-tool field name differ:
111
+ * - `gemini-api` → generativelanguage.googleapis.com, `x-goog-api-key`,
112
+ * tool field `google_search` (snake_case).
113
+ * - `vertex-express` → aiplatform.googleapis.com, `?key=`, tool field
114
+ * `googleSearch` (camelCase).
115
+ *
116
+ * We deliberately use the classic `generateContent` endpoint (response carries
117
+ * `groundingMetadata`) rather than the newer Interactions API.
118
+ *
119
+ * Docs: https://ai.google.dev/gemini-api/docs/google-search
120
+ * https://cloud.google.com/vertex-ai/generative-ai/docs/grounding/grounding-with-google-search
121
+ */
122
+ const DEFAULT_MODEL$1 = "gemini-2.5-flash";
123
+ const BACKENDS = {
124
+ "gemini-api": {
125
+ searchToolField: "google_search",
126
+ buildRequest: ({ model, apiKey, baseUrl }) => ({
127
+ url: `${baseUrl}/v1beta/models/${model}:generateContent`,
128
+ headers: {
129
+ "x-goog-api-key": apiKey,
130
+ "content-type": "application/json"
131
+ }
132
+ })
133
+ },
134
+ "vertex-express": {
135
+ searchToolField: "googleSearch",
136
+ buildRequest: ({ model, apiKey, baseUrl }) => ({
137
+ url: `${baseUrl}/v1/publishers/google/models/${model}:generateContent?key=${encodeURIComponent(apiKey)}`,
138
+ headers: { "content-type": "application/json" }
139
+ })
140
+ }
141
+ };
142
+ const webSchema = z.looseObject({
143
+ uri: z.string().optional(),
144
+ title: z.string().optional()
145
+ });
146
+ const groundingMetadataSchema = z.looseObject({
147
+ webSearchQueries: z.array(z.string()).optional(),
148
+ groundingChunks: z.array(z.looseObject({ web: webSchema.optional() })).optional()
149
+ });
150
+ const candidateSchema = z.looseObject({
151
+ content: z.looseObject({ parts: z.array(z.looseObject({ text: z.string().optional() })).optional() }).optional(),
152
+ groundingMetadata: groundingMetadataSchema.optional()
153
+ });
154
+ const responseSchema$2 = z.looseObject({
155
+ candidates: z.array(candidateSchema).optional(),
156
+ error: z.looseObject({ message: z.string().optional() }).optional()
157
+ });
158
+ function createGeminiProvider(params) {
159
+ const { config } = params;
160
+ const wire = BACKENDS[config.backend];
161
+ return {
162
+ name: "gemini",
163
+ async search(searchParams) {
164
+ const fetchImpl = searchParams.fetchImpl ?? fetch;
165
+ const model = searchParams.model ?? DEFAULT_MODEL$1;
166
+ const { url, headers } = wire.buildRequest({
167
+ model,
168
+ apiKey: config.apiKey,
169
+ baseUrl: config.baseUrl
170
+ });
171
+ const response = await fetchImpl(url, {
172
+ method: "POST",
173
+ headers,
174
+ body: JSON.stringify({
175
+ contents: [{ parts: [{ text: searchParams.query }] }],
176
+ tools: [{ [wire.searchToolField]: {} }]
177
+ })
178
+ });
179
+ const body = await response.json().catch(() => void 0);
180
+ const parsed = responseSchema$2.safeParse(body);
181
+ if (!response.ok || !parsed.success) throw toError$2({
182
+ status: response.status,
183
+ body
184
+ });
185
+ const { answer, citations, searchQueries } = extract$1(parsed.data);
186
+ return {
187
+ provider: "gemini",
188
+ query: searchParams.query,
189
+ results: [],
190
+ answer,
191
+ citations,
192
+ searchQueries,
193
+ raw: searchParams.includeRaw ? body : void 0
194
+ };
195
+ }
196
+ };
197
+ }
198
+ function extract$1(data) {
199
+ const candidate = data.candidates?.[0];
200
+ const answer = (candidate?.content?.parts ?? []).map((part) => part.text ?? "").join("");
201
+ const metadata = candidate?.groundingMetadata;
202
+ return {
203
+ answer,
204
+ citations: (metadata?.groundingChunks ?? []).map((chunk) => chunk.web).filter((web) => web !== void 0 && web.uri !== void 0).map((web) => ({
205
+ url: web.uri,
206
+ title: web.title
207
+ })),
208
+ searchQueries: metadata?.webSearchQueries ?? []
209
+ };
210
+ }
211
+ function toError$2(params) {
212
+ const parsed = responseSchema$2.safeParse(params.body);
213
+ const message = (parsed.success ? parsed.data.error?.message : void 0) ?? `Gemini grounding request failed (HTTP ${params.status}).`;
214
+ if (params.status === 401 || params.status === 403) return new WebseekError({
215
+ code: "auth_failed",
216
+ message
217
+ });
218
+ if (params.status === 429) return new WebseekError({
219
+ code: "rate_limited",
220
+ message
221
+ });
222
+ return new WebseekError({
223
+ code: "provider_error",
224
+ message
225
+ });
226
+ }
227
+ //#endregion
228
+ //#region src/providers/google-cse.ts
229
+ /**
230
+ * Google Custom Search (Programmable Search Engine) JSON API provider.
231
+ *
232
+ * This is a SERP-style provider: it returns a ranked list of links.
233
+ *
234
+ * NOTE: The Custom Search JSON API is closed to new customers; existing
235
+ * customers must migrate before 2027-01-01. See the README for details.
236
+ *
237
+ * Docs: https://developers.google.com/custom-search/v1/reference/rest/v1/cse/list
238
+ */
239
+ const PATH$1 = "/customsearch/v1";
240
+ /** Max results the API returns per request, and the hard cap on total results. */
241
+ const MAX_PER_REQUEST = 10;
242
+ const MAX_TOTAL_RESULTS = 100;
243
+ const itemSchema = z.looseObject({
244
+ title: z.string().optional(),
245
+ link: z.string().optional(),
246
+ snippet: z.string().optional(),
247
+ displayLink: z.string().optional()
248
+ });
249
+ const responseSchema$1 = z.looseObject({
250
+ items: z.array(itemSchema).optional(),
251
+ error: z.looseObject({
252
+ code: z.number().optional(),
253
+ message: z.string().optional()
254
+ }).optional()
255
+ });
256
+ function createGoogleCseProvider(params) {
257
+ const { config } = params;
258
+ return {
259
+ name: "google",
260
+ async search(searchParams) {
261
+ const fetchImpl = searchParams.fetchImpl ?? fetch;
262
+ const desired = clampDesired(searchParams.maxResults ?? MAX_PER_REQUEST);
263
+ const items = [];
264
+ let lastRaw;
265
+ for (let start = 1; start <= MAX_TOTAL_RESULTS && items.length < desired; start += MAX_PER_REQUEST) {
266
+ const num = Math.min(MAX_PER_REQUEST, desired - items.length);
267
+ const response = await fetchImpl(buildUrl({
268
+ config,
269
+ query: searchParams.query,
270
+ start,
271
+ num
272
+ }));
273
+ const body = await response.json().catch(() => void 0);
274
+ lastRaw = body;
275
+ const parsed = responseSchema$1.safeParse(body);
276
+ if (!response.ok || !parsed.success) throw toError$1({
277
+ status: response.status,
278
+ body: parsed.success ? parsed.data : body
279
+ });
280
+ const page = parsed.data.items ?? [];
281
+ items.push(...page);
282
+ if (page.length < num) break;
283
+ }
284
+ return {
285
+ provider: "google",
286
+ query: searchParams.query,
287
+ results: items.slice(0, desired).map((item) => ({
288
+ title: item.title ?? "",
289
+ url: item.link ?? "",
290
+ snippet: item.snippet ?? "",
291
+ displayLink: item.displayLink
292
+ })),
293
+ citations: [],
294
+ searchQueries: [searchParams.query],
295
+ raw: searchParams.includeRaw ? lastRaw : void 0
296
+ };
297
+ }
298
+ };
299
+ }
300
+ function clampDesired(value) {
301
+ if (!Number.isFinite(value) || value < 1) return MAX_PER_REQUEST;
302
+ return Math.min(Math.floor(value), MAX_TOTAL_RESULTS);
303
+ }
304
+ function buildUrl(params) {
305
+ const url = new URL(`${params.config.baseUrl}${PATH$1}`);
306
+ url.searchParams.set("key", params.config.apiKey);
307
+ url.searchParams.set("cx", params.config.cx);
308
+ url.searchParams.set("q", params.query);
309
+ url.searchParams.set("num", String(params.num));
310
+ url.searchParams.set("start", String(params.start));
311
+ return url.toString();
312
+ }
313
+ function toError$1(params) {
314
+ const message = extractMessage(params.body) ?? `Google Custom Search request failed (HTTP ${params.status}).`;
315
+ if (params.status === 401 || params.status === 403) return new WebseekError({
316
+ code: "auth_failed",
317
+ message
318
+ });
319
+ if (params.status === 429) return new WebseekError({
320
+ code: "rate_limited",
321
+ message
322
+ });
323
+ return new WebseekError({
324
+ code: "provider_error",
325
+ message
326
+ });
327
+ }
328
+ function extractMessage(body) {
329
+ const parsed = responseSchema$1.safeParse(body);
330
+ return parsed.success ? parsed.data.error?.message : void 0;
331
+ }
332
+ //#endregion
333
+ //#region src/providers/openai.ts
334
+ /**
335
+ * OpenAI web search provider (Responses API `web_search` tool).
336
+ *
337
+ * This is a grounded provider: the model runs searches and returns a
338
+ * synthesized answer plus `url_citation` annotations.
339
+ *
340
+ * Docs: https://developers.openai.com/api/docs/guides/tools-web-search
341
+ */
342
+ const PATH = "/v1/responses";
343
+ const DEFAULT_MODEL = "gpt-5.5";
344
+ const annotationSchema = z.looseObject({
345
+ type: z.string().optional(),
346
+ url: z.string().optional(),
347
+ title: z.string().optional(),
348
+ start_index: z.number().optional(),
349
+ end_index: z.number().optional()
350
+ });
351
+ const contentSchema = z.looseObject({
352
+ type: z.string().optional(),
353
+ text: z.string().optional(),
354
+ annotations: z.array(annotationSchema).optional()
355
+ });
356
+ const outputItemSchema = z.looseObject({
357
+ type: z.string().optional(),
358
+ content: z.array(contentSchema).optional(),
359
+ action: z.looseObject({ query: z.string().optional() }).optional()
360
+ });
361
+ const responseSchema = z.looseObject({
362
+ output: z.array(outputItemSchema).optional(),
363
+ output_text: z.string().optional(),
364
+ error: z.looseObject({ message: z.string().optional() }).optional()
365
+ });
366
+ function createOpenAIProvider(params) {
367
+ const { config } = params;
368
+ return {
369
+ name: "openai",
370
+ async search(searchParams) {
371
+ const fetchImpl = searchParams.fetchImpl ?? fetch;
372
+ const model = searchParams.model ?? DEFAULT_MODEL;
373
+ const response = await fetchImpl(`${config.baseUrl}${PATH}`, {
374
+ method: "POST",
375
+ headers: {
376
+ authorization: `Bearer ${config.apiKey}`,
377
+ "content-type": "application/json"
378
+ },
379
+ body: JSON.stringify({
380
+ model,
381
+ tools: [{ type: "web_search" }],
382
+ input: searchParams.query
383
+ })
384
+ });
385
+ const body = await response.json().catch(() => void 0);
386
+ const parsed = responseSchema.safeParse(body);
387
+ if (!response.ok || !parsed.success) throw toError({
388
+ status: response.status,
389
+ body
390
+ });
391
+ const { answer, citations, searchQueries } = extract(parsed.data);
392
+ return {
393
+ provider: "openai",
394
+ query: searchParams.query,
395
+ results: [],
396
+ answer,
397
+ citations,
398
+ searchQueries,
399
+ raw: searchParams.includeRaw ? body : void 0
400
+ };
401
+ }
402
+ };
403
+ }
404
+ function extract(data) {
405
+ const textParts = [];
406
+ const citations = [];
407
+ const searchQueries = [];
408
+ for (const item of data.output ?? []) {
409
+ if (item.type === "web_search_call" && item.action?.query) searchQueries.push(item.action.query);
410
+ for (const content of item.content ?? []) {
411
+ if (content.type === "output_text" && content.text) textParts.push(content.text);
412
+ for (const annotation of content.annotations ?? []) if (annotation.type === "url_citation" && annotation.url) citations.push({
413
+ url: annotation.url,
414
+ title: annotation.title,
415
+ startIndex: annotation.start_index,
416
+ endIndex: annotation.end_index
417
+ });
418
+ }
419
+ }
420
+ return {
421
+ answer: textParts.length > 0 ? textParts.join("") : data.output_text ?? "",
422
+ citations,
423
+ searchQueries
424
+ };
425
+ }
426
+ function toError(params) {
427
+ const parsed = responseSchema.safeParse(params.body);
428
+ const message = (parsed.success ? parsed.data.error?.message : void 0) ?? `OpenAI web search request failed (HTTP ${params.status}).`;
429
+ if (params.status === 401) return new WebseekError({
430
+ code: "auth_failed",
431
+ message
432
+ });
433
+ if (params.status === 429) return new WebseekError({
434
+ code: "rate_limited",
435
+ message
436
+ });
437
+ return new WebseekError({
438
+ code: "provider_error",
439
+ message
440
+ });
441
+ }
442
+ //#endregion
443
+ //#region src/lib/search.ts
444
+ const PROVIDER_NAMES = [
445
+ "openai",
446
+ "google",
447
+ "gemini"
448
+ ];
449
+ const GEMINI_BACKENDS = ["gemini-api", "vertex-express"];
450
+ /** Validate an arbitrary string as a provider name, or throw a usage error. */
451
+ function coerceProvider(value) {
452
+ if (typeof value === "string" && PROVIDER_NAMES.includes(value)) return value;
453
+ throw new WebseekError({
454
+ code: "invalid_usage",
455
+ message: `Invalid provider. Choose one of: ${PROVIDER_NAMES.join(", ")}.`
456
+ });
457
+ }
458
+ /** Validate an arbitrary string as a Gemini backend, or throw a usage error. */
459
+ function coerceGeminiBackend(value) {
460
+ if (typeof value === "string" && GEMINI_BACKENDS.includes(value)) return value;
461
+ throw new WebseekError({
462
+ code: "invalid_usage",
463
+ message: `Invalid gemini backend. Choose one of: ${GEMINI_BACKENDS.join(", ")}.`
464
+ });
465
+ }
466
+ /** Validate a max-results value as a positive integer, or throw a usage error. */
467
+ function coerceMaxResults(value) {
468
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
469
+ if (!Number.isInteger(parsed) || parsed < 1) throw new WebseekError({
470
+ code: "invalid_usage",
471
+ message: "max-results must be a positive integer."
472
+ });
473
+ return parsed;
474
+ }
475
+ function runSearch(params) {
476
+ return resolveProvider(params).search({
477
+ query: params.query,
478
+ maxResults: params.maxResults,
479
+ model: params.model,
480
+ includeRaw: params.includeRaw,
481
+ fetchImpl: params.fetchImpl
482
+ });
483
+ }
484
+ function resolveProvider(params) {
485
+ switch (params.provider) {
486
+ case "openai": return createOpenAIProvider({ config: resolveOpenAIConfig({ env: params.env }) });
487
+ case "google": return createGoogleCseProvider({ config: resolveGoogleCseConfig({ env: params.env }) });
488
+ case "gemini": return createGeminiProvider({ config: resolveGeminiConfig({
489
+ env: params.env,
490
+ backend: params.geminiBackend ?? "gemini-api"
491
+ }) });
492
+ default: throw new WebseekError({
493
+ code: "invalid_usage",
494
+ message: `Unknown provider: ${String(params.provider)}`
495
+ });
496
+ }
497
+ }
498
+ //#endregion
499
+ //#region src/mcp/tools.ts
500
+ /**
501
+ * The `web_search` MCP tool. It is the MCP-mode counterpart to the CLI `search`
502
+ * command: both validate their inputs and call the same `runSearch` core.
503
+ */
504
+ const webSearchInputShape = {
505
+ query: z.string().min(1).describe("The search query."),
506
+ provider: z.enum(PROVIDER_NAMES).describe("Which provider to search with."),
507
+ maxResults: z.number().int().positive().optional().describe("Desired number of results (SERP providers; best-effort otherwise)."),
508
+ model: z.string().optional().describe("Model override for LLM-backed providers."),
509
+ geminiBackend: z.enum(GEMINI_BACKENDS).optional().describe("Gemini backend: gemini-api (default) or vertex-express."),
510
+ includeRaw: z.boolean().optional().describe("Include the provider's raw response.")
511
+ };
512
+ z.object(webSearchInputShape);
513
+ function createWebSearchTool(params = {}) {
514
+ return {
515
+ name: "web_search",
516
+ config: {
517
+ title: "Web Search",
518
+ description: "Search the web using a provider's API key (OpenAI, Google Custom Search, or Gemini). Returns a normalized JSON result with SERP results and/or a grounded answer with citations.",
519
+ inputSchema: webSearchInputShape
520
+ },
521
+ handler: async (args) => {
522
+ try {
523
+ const result = await runSearch({
524
+ provider: args.provider,
525
+ query: args.query,
526
+ maxResults: args.maxResults,
527
+ model: args.model,
528
+ geminiBackend: args.geminiBackend,
529
+ includeRaw: args.includeRaw,
530
+ env: params.env,
531
+ fetchImpl: params.fetchImpl
532
+ });
533
+ return { content: [{
534
+ type: "text",
535
+ text: JSON.stringify(result, null, 2)
536
+ }] };
537
+ } catch (error) {
538
+ return {
539
+ content: [{
540
+ type: "text",
541
+ text: formatError(error)
542
+ }],
543
+ isError: true
544
+ };
545
+ }
546
+ }
547
+ };
548
+ }
549
+ //#endregion
550
+ export { coerceGeminiBackend as a, runSearch as c, formatError as d, PROVIDER_NAMES as i, WebseekError as l, webSearchInputShape as n, coerceMaxResults as o, GEMINI_BACKENDS as r, coerceProvider as s, createWebSearchTool as t, errorExitCode as u };