feat: add Brave Search LLM Context API mode for web_search
Add support for Brave's LLM Context API endpoint (/res/v1/llm/context) as an optional mode for the web_search tool. When configured with tools.web.search.brave.mode set to llm-context, the tool returns pre-extracted page content optimized for LLM grounding instead of standard URL/snippet results. The llm-context cache key excludes count and ui_lang parameters that the LLM Context API does not accept, preventing unnecessary cache misses. Closes #14992 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Peter Steinberger
parent
38f4ac5e3c
commit
8a1015f1aa
@@ -15,6 +15,7 @@ const {
|
||||
resolveKimiModel,
|
||||
resolveKimiBaseUrl,
|
||||
extractKimiCitations,
|
||||
resolveBraveMode,
|
||||
} = __testing;
|
||||
|
||||
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
|
||||
@@ -276,3 +277,25 @@ describe("extractKimiCitations", () => {
|
||||
).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBraveMode", () => {
|
||||
it("defaults to 'web' when no config is provided", () => {
|
||||
expect(resolveBraveMode({})).toBe("web");
|
||||
});
|
||||
|
||||
it("defaults to 'web' when mode is undefined", () => {
|
||||
expect(resolveBraveMode({ mode: undefined })).toBe("web");
|
||||
});
|
||||
|
||||
it("returns 'llm-context' when configured", () => {
|
||||
expect(resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
|
||||
});
|
||||
|
||||
it("returns 'web' when mode is explicitly 'web'", () => {
|
||||
expect(resolveBraveMode({ mode: "web" })).toBe("web");
|
||||
});
|
||||
|
||||
it("falls back to 'web' for unrecognized mode values", () => {
|
||||
expect(resolveBraveMode({ mode: "invalid" })).toBe("web");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ const DEFAULT_SEARCH_COUNT = 5;
|
||||
const MAX_SEARCH_COUNT = 10;
|
||||
|
||||
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
||||
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
|
||||
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
|
||||
|
||||
const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
|
||||
@@ -247,6 +248,17 @@ type BraveSearchResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
type BraveLlmContextSnippet = { text: string };
|
||||
type BraveLlmContextResult = { url: string; title: string; snippets: BraveLlmContextSnippet[] };
|
||||
type BraveLlmContextResponse = {
|
||||
grounding: { generic?: BraveLlmContextResult[] };
|
||||
sources?: { url?: string; hostname?: string; date?: string }[];
|
||||
};
|
||||
|
||||
type BraveConfig = {
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
type PerplexityConfig = {
|
||||
apiKey?: string;
|
||||
};
|
||||
@@ -550,6 +562,21 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
|
||||
return "perplexity";
|
||||
}
|
||||
|
||||
function resolveBraveConfig(search?: WebSearchConfig): BraveConfig {
|
||||
if (!search || typeof search !== "object") {
|
||||
return {};
|
||||
}
|
||||
const brave = "brave" in search ? search.brave : undefined;
|
||||
if (!brave || typeof brave !== "object") {
|
||||
return {};
|
||||
}
|
||||
return brave as BraveConfig;
|
||||
}
|
||||
|
||||
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
|
||||
return brave.mode === "llm-context" ? "llm-context" : "web";
|
||||
}
|
||||
|
||||
function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
|
||||
if (!search || typeof search !== "object") {
|
||||
return {};
|
||||
@@ -1213,6 +1240,67 @@ async function runKimiSearch(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function runBraveLlmContextSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
freshness?: string;
|
||||
}): Promise<{
|
||||
results: Array<{
|
||||
url: string;
|
||||
title: string;
|
||||
snippets: string[];
|
||||
siteName?: string;
|
||||
}>;
|
||||
sources?: BraveLlmContextResponse["sources"];
|
||||
}> {
|
||||
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
}
|
||||
if (params.search_lang) {
|
||||
url.searchParams.set("search_lang", params.search_lang);
|
||||
}
|
||||
if (params.freshness) {
|
||||
url.searchParams.set("freshness", params.freshness);
|
||||
}
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: url.toString(),
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": params.apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
|
||||
const detail = detailResult.text;
|
||||
throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BraveLlmContextResponse;
|
||||
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
|
||||
const mapped = genericResults.map((entry) => ({
|
||||
url: entry.url ?? "",
|
||||
title: entry.title ?? "",
|
||||
snippets: (entry.snippets ?? []).map((s) => s.text ?? "").filter(Boolean),
|
||||
siteName: resolveSiteName(entry.url) || undefined,
|
||||
}));
|
||||
|
||||
return { results: mapped, sources: data.sources };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function runWebSearch(params: {
|
||||
query: string;
|
||||
count: number;
|
||||
@@ -1235,7 +1323,9 @@ async function runWebSearch(params: {
|
||||
geminiModel?: string;
|
||||
kimiBaseUrl?: string;
|
||||
kimiModel?: string;
|
||||
braveMode?: "web" | "llm-context";
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const effectiveBraveMode = params.braveMode ?? "web";
|
||||
const providerSpecificKey =
|
||||
params.provider === "grok"
|
||||
? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`
|
||||
@@ -1245,7 +1335,9 @@ async function runWebSearch(params: {
|
||||
? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}`
|
||||
: "";
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`,
|
||||
params.provider === "brave" && effectiveBraveMode === "llm-context"
|
||||
? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}`
|
||||
: `${params.provider}:${effectiveBraveMode}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`,
|
||||
);
|
||||
const cached = readCache(SEARCH_CACHE, cacheKey);
|
||||
if (cached) {
|
||||
@@ -1372,6 +1464,42 @@ async function runWebSearch(params: {
|
||||
throw new Error("Unsupported web search provider.");
|
||||
}
|
||||
|
||||
if (effectiveBraveMode === "llm-context") {
|
||||
const { results: llmResults, sources } = await runBraveLlmContextSearch({
|
||||
query: params.query,
|
||||
apiKey: params.apiKey,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
country: params.country,
|
||||
search_lang: params.search_lang,
|
||||
freshness: params.freshness,
|
||||
});
|
||||
|
||||
const mapped = llmResults.map((entry) => ({
|
||||
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
|
||||
url: entry.url,
|
||||
snippets: entry.snippets.map((s) => wrapWebContent(s, "web_search")),
|
||||
siteName: entry.siteName,
|
||||
}));
|
||||
|
||||
const payload = {
|
||||
query: params.query,
|
||||
provider: params.provider,
|
||||
mode: "llm-context" as const,
|
||||
count: mapped.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: params.provider,
|
||||
wrapped: true,
|
||||
},
|
||||
results: mapped,
|
||||
sources,
|
||||
};
|
||||
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
|
||||
const url = new URL(BRAVE_SEARCH_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
url.searchParams.set("count", String(params.count));
|
||||
@@ -1465,6 +1593,8 @@ export function createWebSearchTool(options?: {
|
||||
const grokConfig = resolveGrokConfig(search);
|
||||
const geminiConfig = resolveGeminiConfig(search);
|
||||
const kimiConfig = resolveKimiConfig(search);
|
||||
const braveConfig = resolveBraveConfig(search);
|
||||
const braveMode = resolveBraveMode(braveConfig);
|
||||
|
||||
const description =
|
||||
provider === "perplexity"
|
||||
@@ -1475,7 +1605,9 @@ export function createWebSearchTool(options?: {
|
||||
? "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search."
|
||||
: provider === "gemini"
|
||||
? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search."
|
||||
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
|
||||
: braveMode === "llm-context"
|
||||
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
|
||||
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
|
||||
|
||||
return {
|
||||
label: "Web Search",
|
||||
@@ -1660,6 +1792,7 @@ export function createWebSearchTool(options?: {
|
||||
geminiModel: resolveGeminiModel(geminiConfig),
|
||||
kimiBaseUrl: resolveKimiBaseUrl(kimiConfig),
|
||||
kimiModel: resolveKimiModel(kimiConfig),
|
||||
braveMode,
|
||||
});
|
||||
return jsonResult(result);
|
||||
},
|
||||
@@ -1684,4 +1817,5 @@ export const __testing = {
|
||||
resolveKimiBaseUrl,
|
||||
extractKimiCitations,
|
||||
resolveRedirectUrl: resolveCitationRedirectUrl,
|
||||
resolveBraveMode,
|
||||
} as const;
|
||||
|
||||
@@ -666,6 +666,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).",
|
||||
"tools.web.search.perplexity.model":
|
||||
'Perplexity model override (default: "perplexity/sonar-pro").',
|
||||
"tools.web.search.brave.mode":
|
||||
'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).',
|
||||
"tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).",
|
||||
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
|
||||
"tools.web.fetch.maxCharsCap":
|
||||
|
||||
@@ -224,6 +224,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.web.search.gemini.model": "Gemini Search Model",
|
||||
"tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret
|
||||
"tools.web.search.grok.model": "Grok Search Model",
|
||||
"tools.web.search.brave.mode": "Brave Search Mode",
|
||||
"tools.web.search.kimi.apiKey": "Kimi Search API Key", // pragma: allowlist secret
|
||||
"tools.web.search.kimi.baseUrl": "Kimi Search Base URL",
|
||||
"tools.web.search.kimi.model": "Kimi Search Model",
|
||||
|
||||
@@ -485,6 +485,11 @@ export type ToolsConfig = {
|
||||
/** Model to use (defaults to "moonshot-v1-128k"). */
|
||||
model?: string;
|
||||
};
|
||||
/** Brave-specific configuration (used when provider="brave"). */
|
||||
brave?: {
|
||||
/** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */
|
||||
mode?: "web" | "llm-context";
|
||||
};
|
||||
};
|
||||
fetch?: {
|
||||
/** Enable web fetch tool (default: true). */
|
||||
|
||||
@@ -308,6 +308,12 @@ export const ToolsWebSearchSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
brave: z
|
||||
.object({
|
||||
mode: z.union([z.literal("web"), z.literal("llm-context")]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
Reference in New Issue
Block a user