From 4dfd2cd60c06e8531f72121562ae6689c299100c Mon Sep 17 00:00:00 2001 From: Lakshya Agarwal Date: Sat, 28 Mar 2026 04:36:59 -0400 Subject: [PATCH] feat: add support for extra headers in Tavily API requests (#55335) * feat: add support for extra headers in Tavily API requests * test(tavily-client): add unit tests for X-Client-Source header in API calls * fix(tavily): add client source attribution (#55335) (thanks @lakshyaag-tavily) --------- Co-authored-by: Nimrod Gutman --- CHANGELOG.md | 1 + extensions/tavily/src/tavily-client.test.ts | 58 +++++++++++++++++++ extensions/tavily/src/tavily-client.ts | 2 + .../tools/web-search-provider-common.ts | 2 + 4 files changed, 63 insertions(+) create mode 100644 extensions/tavily/src/tavily-client.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a61334c083..e5deddada4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Docs: add `pnpm docs:check-links:anchors` for Mintlify anchor validation while keeping `scripts/docs-link-audit.mjs` as the stable link-audit entrypoint. (#55912) Thanks @velvet-shark. - Plugins/hooks: add async `requireApproval` to `before_tool_call` hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel. The `/approve` command now handles both exec and plugin approvals with automatic fallback. (#55339) Thanks @vaclavbelak and @joshavant. - ACP/channels: add current-conversation ACP binds for Discord, BlueBubbles, and iMessage so `/acp spawn codex --bind here` can turn the current chat into a Codex-backed workspace without creating a child thread, and document the distinction between chat surface, ACP session, and runtime workspace. +- Tavily: mark outbound API requests with `X-Client-Source: openclaw` so Tavily can attribute OpenClaw-originated traffic. (#55335) Thanks @lakshyaag-tavily. ### Fixes diff --git a/extensions/tavily/src/tavily-client.test.ts b/extensions/tavily/src/tavily-client.test.ts new file mode 100644 index 00000000000..e4adf464909 --- /dev/null +++ b/extensions/tavily/src/tavily-client.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Capture every call to postTrustedWebToolsJson so we can assert on extraHeaders. +const postTrustedWebToolsJson = vi.fn(); + +vi.mock("openclaw/plugin-sdk/provider-web-search", () => ({ + DEFAULT_CACHE_TTL_MINUTES: 5, + normalizeCacheKey: (k: string) => k, + postTrustedWebToolsJson, + readCache: () => undefined, + resolveCacheTtlMs: () => 300_000, + writeCache: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/security-runtime", () => ({ + wrapExternalContent: (v: string) => v, + wrapWebContent: (v: string) => v, +})); + +vi.mock("./config.js", () => ({ + DEFAULT_TAVILY_BASE_URL: "https://api.tavily.com", + resolveTavilyApiKey: () => "test-key", + resolveTavilyBaseUrl: () => "https://api.tavily.com", + resolveTavilySearchTimeoutSeconds: () => 30, + resolveTavilyExtractTimeoutSeconds: () => 60, +})); + +describe("tavily client X-Client-Source header", () => { + let runTavilySearch: typeof import("./tavily-client.js").runTavilySearch; + let runTavilyExtract: typeof import("./tavily-client.js").runTavilyExtract; + + beforeEach(async () => { + vi.resetModules(); + postTrustedWebToolsJson.mockReset(); + postTrustedWebToolsJson.mockImplementation( + async (_params: unknown, parse: (r: Response) => Promise) => + parse(Response.json({ results: [] })), + ); + + ({ runTavilySearch, runTavilyExtract } = await import("./tavily-client.js")); + }); + + it("runTavilySearch sends X-Client-Source: openclaw", async () => { + await runTavilySearch({ query: "test query" }); + + expect(postTrustedWebToolsJson).toHaveBeenCalledOnce(); + const params = postTrustedWebToolsJson.mock.calls[0][0]; + expect(params.extraHeaders).toEqual({ "X-Client-Source": "openclaw" }); + }); + + it("runTavilyExtract sends X-Client-Source: openclaw", async () => { + await runTavilyExtract({ urls: ["https://example.com"] }); + + expect(postTrustedWebToolsJson).toHaveBeenCalledOnce(); + const params = postTrustedWebToolsJson.mock.calls[0][0]; + expect(params.extraHeaders).toEqual({ "X-Client-Source": "openclaw" }); + }); +}); diff --git a/extensions/tavily/src/tavily-client.ts b/extensions/tavily/src/tavily-client.ts index c57f5850af3..867e1e3deaa 100644 --- a/extensions/tavily/src/tavily-client.ts +++ b/extensions/tavily/src/tavily-client.ts @@ -119,6 +119,7 @@ export async function runTavilySearch( apiKey, body, errorLabel: "Tavily Search", + extraHeaders: { "X-Client-Source": "openclaw" }, }, async (response) => (await response.json()) as Record, ); @@ -200,6 +201,7 @@ export async function runTavilyExtract( apiKey, body, errorLabel: "Tavily Extract", + extraHeaders: { "X-Client-Source": "openclaw" }, }, async (response) => (await response.json()) as Record, ); diff --git a/src/agents/tools/web-search-provider-common.ts b/src/agents/tools/web-search-provider-common.ts index 8b8ce68c0a5..614025f3258 100644 --- a/src/agents/tools/web-search-provider-common.ts +++ b/src/agents/tools/web-search-provider-common.ts @@ -100,6 +100,7 @@ export async function postTrustedWebToolsJson( body: Record; errorLabel: string; maxErrorBytes?: number; + extraHeaders?: Record; }, parseResponse: (response: Response) => Promise, ): Promise { @@ -110,6 +111,7 @@ export async function postTrustedWebToolsJson( init: { method: "POST", headers: { + ...params.extraHeaders, Accept: "application/json", Authorization: `Bearer ${params.apiKey}`, "Content-Type": "application/json",