From 8041703a29ce844453f858498f2f97443f5a4a41 Mon Sep 17 00:00:00 2001 From: Roger M Date: Mon, 26 Jan 2026 15:26:22 +0000 Subject: [PATCH] tools: add Serper Google Search API as web_search provider option --- .../tools/web-tools.enabled-defaults.test.ts | 84 +++++++++++++++++++ src/config/config.web-search-provider.test.ts | 16 ++++ src/config/zod-schema.agent-runtime.ts | 4 +- 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 41d44b12d..ff14c1f72 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -307,3 +307,87 @@ describe("web_search perplexity baseUrl defaults", () => { expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions"); }); }); + +describe("web_search serper provider", () => { + const priorFetch = global.fetch; + + afterEach(() => { + vi.unstubAllEnvs(); + // @ts-expect-error global fetch cleanup + global.fetch = priorFetch; + }); + + it("works with Serper provider and config API key", async () => { + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + organic: [ + { title: "Test Result", link: "https://example.com", snippet: "Test snippet" }, + ], + }), + } as Response), + ); + // @ts-expect-error mock fetch + global.fetch = mockFetch; + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "serper", + apiKey: "serper-test-key", + }, + }, + }, + }, + sandboxed: true, + }); + const result = await tool?.execute?.(1, { query: "test serper" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockFetch.mock.calls[0]?.[0]).toBe("https://google.serper.dev/search"); + expect(mockFetch.mock.calls[0]?.[1]?.method).toBe("POST"); + expect(mockFetch.mock.calls[0]?.[1]?.headers?.["X-API-KEY"]).toBe("serper-test-key"); + expect(result?.details).toMatchObject({ + provider: "serper", + query: "test serper", + }); + }); + + it("works with Serper provider and SERPER_API_KEY env var", async () => { + vi.stubEnv("SERPER_API_KEY", "serper-env-key"); + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + organic: [ + { title: "Test Result", link: "https://example.com", snippet: "Test snippet" }, + ], + }), + } as Response), + ); + // @ts-expect-error mock fetch + global.fetch = mockFetch; + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "serper", + }, + }, + }, + }, + sandboxed: true, + }); + await tool?.execute?.(1, { query: "test serper env" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockFetch.mock.calls[0]?.[1]?.headers?.["X-API-KEY"]).toBe("serper-env-key"); + }); +}); diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index a91c0f438..d0d214396 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -22,4 +22,20 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + + it("accepts serper provider and config", () => { + const res = validateConfigObject({ + tools: { + web: { + search: { + enabled: true, + provider: "serper", + apiKey: "test-key", + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); }); diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index b5a03a3ea..dc9fb7a4a 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -168,7 +168,9 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), - provider: z.union([z.literal("brave"), z.literal("perplexity")]).optional(), + provider: z + .union([z.literal("brave"), z.literal("perplexity"), z.literal("serper")]) + .optional(), apiKey: z.string().optional(), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(),