-
Notifications
You must be signed in to change notification settings - Fork 993
feat: proxy Tavily web search through API server #1576
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,75 @@ | ||||||||||||||||||||||
| import { auth } from "@superset/auth/server"; | ||||||||||||||||||||||
| import { tavily } from "@tavily/core"; | ||||||||||||||||||||||
| import { Ratelimit } from "@upstash/ratelimit"; | ||||||||||||||||||||||
| import { Redis } from "@upstash/redis"; | ||||||||||||||||||||||
| import { env } from "@/env"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const ratelimit = new Ratelimit({ | ||||||||||||||||||||||
| redis: new Redis({ | ||||||||||||||||||||||
| url: env.KV_REST_API_URL, | ||||||||||||||||||||||
| token: env.KV_REST_API_TOKEN, | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| limiter: Ratelimit.slidingWindow(1000, "1 d"), | ||||||||||||||||||||||
| prefix: "ratelimit:web-search", | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export async function POST(request: Request): Promise<Response> { | ||||||||||||||||||||||
| const session = await auth.api.getSession({ | ||||||||||||||||||||||
| headers: request.headers, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (!session?.user) { | ||||||||||||||||||||||
| return new Response("Unauthorized", { status: 401 }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const { success } = await ratelimit.limit(session.user.id); | ||||||||||||||||||||||
| if (!success) { | ||||||||||||||||||||||
| return Response.json( | ||||||||||||||||||||||
| { error: "Rate limit exceeded. Try again later." }, | ||||||||||||||||||||||
| { status: 429 }, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| let body: { query?: string; maxResults?: number }; | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| body = await request.json(); | ||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||
| return Response.json({ error: "Invalid JSON body" }, { status: 400 }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (!body.query || typeof body.query !== "string") { | ||||||||||||||||||||||
| return Response.json( | ||||||||||||||||||||||
| { error: "Missing or invalid 'query' field" }, | ||||||||||||||||||||||
| { status: 400 }, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const rawMax = body.maxResults; | ||||||||||||||||||||||
| const maxResults = | ||||||||||||||||||||||
| typeof rawMax === "number" && Number.isFinite(rawMax) | ||||||||||||||||||||||
| ? Math.min(Math.max(rawMax, 1), 10) | ||||||||||||||||||||||
| : 5; | ||||||||||||||||||||||
|
Comment on lines
+47
to
+51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: I couldn’t find any public docs/source for That said, in TypeScript there is no distinct
and rely on runtime validation (or the API) to enforce “must be an integer”. Practical guidance: await tavily.search("query", { maxResults: 10 }); // pass an integer numberIf you want to enforce integer-ness yourself: const maxResults = 10;
if (!Number.isInteger(maxResults)) throw new Error("maxResults must be an integer");🏁 Script executed: # Check for package.json files
fd -t f 'package.json' | head -5
# Look for `@tavily/core` in lock files
rg '@tavily/core' --type json -A 3 2>/dev/null | head -30Repository: superset-sh/superset Length of output: 395 🏁 Script executed: # Search for uses of tavily client.search
rg 'client\.search' --type ts --type tsx -B 2 -A 3
# Look for TypeScript type definitions related to tavily
fd -e 'd.ts' | xargs rg 'maxResults|Tavily' 2>/dev/null | head -20Repository: superset-sh/superset Length of output: 91 🏁 Script executed: # Search for uses of tavily client.search properly
rg 'client\.search' -A 3 -B 2
# Also check for type definitions in node_modules
find . -path ./node_modules -prune -o -name '*tavily*' -type f -print | head -20Repository: superset-sh/superset Length of output: 576 🌐 Web query:
💡 Result: In the official Tavily Search API, the parameter is named
If you’re seeing Sources:
🛠️ Proposed fix const rawMax = body.maxResults;
const maxResults =
- typeof rawMax === "number" && Number.isFinite(rawMax)
+ Number.isInteger(rawMax)
? Math.min(Math.max(rawMax, 1), 10)
: 5;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (!env.TAVILY_API_KEY) { | ||||||||||||||||||||||
| return Response.json( | ||||||||||||||||||||||
| { error: "Web search is not configured" }, | ||||||||||||||||||||||
| { status: 503 }, | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| const client = tavily({ apiKey: env.TAVILY_API_KEY }); | ||||||||||||||||||||||
| const response = await client.search(body.query, { maxResults }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return Response.json({ | ||||||||||||||||||||||
| results: response.results.map((r) => ({ | ||||||||||||||||||||||
| title: r.title, | ||||||||||||||||||||||
| url: r.url, | ||||||||||||||||||||||
| content: r.content, | ||||||||||||||||||||||
| })), | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||
| const message = error instanceof Error ? error.message : "Search failed"; | ||||||||||||||||||||||
| return Response.json({ error: message }, { status: 502 }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+60
to
+74
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tavily client instantiated on every request; raw Tavily error text forwarded to caller. Two separate concerns here:
♻️ Proposed refactor+// Module-level singleton — initialised once per cold start
+let _client: ReturnType<typeof tavily> | null = null;
+function getClient() {
+ if (!_client) _client = tavily({ apiKey: env.TAVILY_API_KEY! });
+ return _client;
+}
+
export async function POST(request: Request): Promise<Response> {
// ... auth / body parsing ...
try {
- const client = tavily({ apiKey: env.TAVILY_API_KEY });
- const response = await client.search(body.query, { maxResults });
+ const response = await getClient().search(body.query, { maxResults });
return Response.json({ results: response.results.map(...) });
} catch (error) {
- const message = error instanceof Error ? error.message : "Search failed";
- return Response.json({ error: message }, { status: 502 });
+ console.error("[web-search] Tavily error:", error);
+ return Response.json({ error: "Search failed" }, { status: 502 });
}
}🤖 Prompt for AI Agents
Comment on lines
+71
to
+74
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tavily error messages forwarded verbatim to the client may leak internal details.
🛡️ Proposed fix } catch (error) {
- const message = error instanceof Error ? error.message : "Search failed";
- return Response.json({ error: message }, { status: 502 });
+ console.error("[web-search] Tavily error:", error);
+ return Response.json({ error: "Search failed" }, { status: 502 });
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,16 @@ | ||
| import { createTool } from "@mastra/core/tools"; | ||
| import { tavily } from "@tavily/core"; | ||
| import { z } from "zod"; | ||
|
|
||
| const resultSchema = z.object({ | ||
| results: z.array( | ||
| z.object({ | ||
| title: z.string(), | ||
| url: z.string(), | ||
| content: z.string(), | ||
| }), | ||
| ), | ||
| }); | ||
|
|
||
| export const webSearchTool = createTool({ | ||
| id: "web_search", | ||
| description: | ||
|
|
@@ -16,34 +25,35 @@ export const webSearchTool = createTool({ | |
| .default(5) | ||
| .describe("Maximum number of results to return (1-10)"), | ||
| }), | ||
| outputSchema: z.object({ | ||
| results: z.array( | ||
| z.object({ | ||
| title: z.string(), | ||
| url: z.string(), | ||
| content: z.string(), | ||
| }), | ||
| ), | ||
| }), | ||
| execute: async (input) => { | ||
| const apiKey = process.env.TAVILY_API_KEY; | ||
| if (!apiKey) { | ||
| outputSchema: resultSchema, | ||
| execute: async (input, context) => { | ||
| const apiUrl = context?.requestContext?.get("apiUrl"); | ||
| const authToken = context?.requestContext?.get("authToken"); | ||
|
Comment on lines
+29
to
+31
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Search for RequestContext type definition in the repository
rg -n "RequestContext" --type ts -A 5 -B 1 | head -100Repository: superset-sh/superset Length of output: 2938 🏁 Script executed: #!/bin/bash
# Also look specifically for interface or type definitions of RequestContext
rg -n "interface RequestContext|type RequestContext" --type ts -A 10Repository: superset-sh/superset Length of output: 46 🏁 Script executed: #!/bin/bash
# Check the web-search.ts file to see the actual usage on lines 29-43
cat -n packages/agent/src/tools/web-search.ts | sed -n '25,45p'Repository: superset-sh/superset Length of output: 827 🌐 Web query:
💡 Result: In Mastra, From
The same file defines the class and its constructor shape (iterable of Also, Sources: UNPKG type definition [1], Mastra release notes mentioning Add type narrowing or assertions for
🤖 Prompt for AI Agents |
||
|
|
||
| if (!apiUrl || !authToken) { | ||
| throw new Error( | ||
| "TAVILY_API_KEY environment variable is not set. Web search is unavailable.", | ||
| "Web search requires apiUrl and authToken in request context.", | ||
| ); | ||
| } | ||
|
Comment on lines
+29
to
37
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing local-dev fallback — breaks stated test plan requirement. The test plan specifies: "Local dev without the proxy (TAVILY_API_KEY in env) should continue to work as a fallback." But the code throws unconditionally when Any invocation of Either restore the fallback path (and re-add 🛠️ Proposed fix — proxy-first with direct Tavily fallbackRe-add +import { tavily } from "@tavily/core";
import { createTool } from "@mastra/core/tools";
import { z } from "zod"; execute: async (input, context) => {
const apiUrl = context?.requestContext?.get("apiUrl");
const authToken = context?.requestContext?.get("authToken");
- if (!apiUrl || !authToken) {
- throw new Error(
- "Web search requires apiUrl and authToken in request context.",
- );
- }
+ if (apiUrl && authToken) {
+ const response = await fetch(`${apiUrl}/api/chat/tools/web-search`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${authToken}`,
+ },
+ body: JSON.stringify({
+ query: input.query,
+ maxResults: input.maxResults,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Web search proxy returned ${response.status}: ${await response.text()}`,
+ );
+ }
+
+ return resultSchema.parse(await response.json());
+ }
+
+ // Fallback: direct Tavily call for local dev / server-side use
+ const tavilyApiKey = process.env.TAVILY_API_KEY;
+ if (!tavilyApiKey) {
+ throw new Error(
+ "Web search requires either (apiUrl + authToken) or TAVILY_API_KEY in the environment.",
+ );
+ }
+ const client = tavily({ apiKey: tavilyApiKey });
+ const tvResponse = await client.search(input.query, {
+ maxResults: input.maxResults,
+ });
+ return {
+ results: tvResponse.results.map((r) => ({
+ title: r.title,
+ url: r.url,
+ content: r.content,
+ })),
+ };
-
- const response = await fetch(`${apiUrl}/api/chat/tools/web-search`, {
- ...
- });
-
- if (!response.ok) { ... }
-
- return resultSchema.parse(await response.json());
}, |
||
|
|
||
| const client = tavily({ apiKey }); | ||
| const response = await client.search(input.query, { | ||
| maxResults: input.maxResults, | ||
| const response = await fetch(`${apiUrl}/api/chat/tools/web-search`, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${authToken}`, | ||
| }, | ||
| body: JSON.stringify({ | ||
| query: input.query, | ||
| maxResults: input.maxResults, | ||
| }), | ||
| }); | ||
|
|
||
| return { | ||
| results: response.results.map((r) => ({ | ||
| title: r.title, | ||
| url: r.url, | ||
| content: r.content, | ||
| })), | ||
| }; | ||
| if (!response.ok) { | ||
| throw new Error( | ||
| `Web search proxy returned ${response.status}: ${await response.text()}`, | ||
| ); | ||
| } | ||
|
|
||
| return resultSchema.parse(await response.json()); | ||
| }, | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
@tavily/core npm package latest version💡 Result:
The latest published version of the
@tavily/corenpm package is0.5.11. [1]Update
@tavily/coreto a valid published version — 0.7.1 does not exist.The latest published version of
@tavily/coreis 0.5.11. Specifying^0.7.1will cause npm install to fail due to an unresolvable dependency. Update the version constraint to^0.5.11or lower.🤖 Prompt for AI Agents