Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
595 changes: 37 additions & 558 deletions src/websearch/providers.ts

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions src/websearch/providers/anthropic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { providerUrl } from "../provider-endpoints.js";
import type { BuiltSearchRequest, JsonObject, SearchResultItem } from "../types.js";
import type { BuildContext, ProviderModule } from "./shared.js";
import { collect, contentHeaders, getArray, getObject, getString, result } from "./shared.js";

export const anthropicProvider: ProviderModule = {
buildRequest({ config, request, allowedDomains, blockedDomains }: BuildContext): BuiltSearchRequest {
const webSearchTool: JsonObject = { type: "web_search_20250305", name: "web_search", max_uses: 8 };
if (allowedDomains) webSearchTool["allowed_domains"] = allowedDomains;
if (blockedDomains) webSearchTool["blocked_domains"] = blockedDomains;
return {
url: providerUrl(config),
init: {
method: "POST",
headers: contentHeaders({
"x-api-key": config.apiKey ?? "",
"anthropic-version": "2023-06-01",
}),
},
body: {
model: config.model ?? "claude-sonnet-4-5-20250929",
max_tokens: 1024,
messages: [{ role: "user", content: request.query }],
tools: [webSearchTool],
},
};
},
normalizeResponse(data: JsonObject): SearchResultItem[] {
const content = getArray(data["content"]);
const text = content
.map(getObject)
.map((item) => getString(item?.["text"]))
.filter((value): value is string => value !== undefined)
.join("\n");
return collect(
content.flatMap((raw) => {
const item = getObject(raw);
if (item?.["type"] !== "web_search_tool_result") return [];
return getArray(item["content"]).map((searchRaw) => {
const searchItem = getObject(searchRaw);
return result(
getString(searchItem?.["title"]),
getString(searchItem?.["url"]),
getString(searchItem?.["page_age"]) ?? text,
);
});
}),
);
},
};
25 changes: 25 additions & 0 deletions src/websearch/providers/brave.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { providerUrl } from "../provider-endpoints.js";
import type { BuiltSearchRequest, JsonObject, SearchResultItem } from "../types.js";
import type { BuildContext, ProviderModule } from "./shared.js";
import { appendDomainFilters, clamp, collect, getArray, getObject, getString, result } from "./shared.js";

export const braveProvider: ProviderModule = {
buildRequest({ config, request, maxResults, allowedDomains, blockedDomains }: BuildContext): BuiltSearchRequest {
const url = new URL(providerUrl(config));
url.searchParams.set("q", appendDomainFilters(request.query, allowedDomains, blockedDomains));
url.searchParams.set("count", String(clamp(maxResults, 1, 20)));
return {
url: url.toString(),
init: { method: "GET", headers: { Accept: "application/json", "X-Subscription-Token": config.apiKey ?? "" } },
};
},
normalizeResponse(data: JsonObject): SearchResultItem[] {
const web = getObject(data["web"]);
return collect(
getArray(web?.["results"]).map((raw) => {
const item = getObject(raw);
return result(getString(item?.["title"]), getString(item?.["url"]), getString(item?.["description"]));
}),
);
},
};
60 changes: 60 additions & 0 deletions src/websearch/providers/duckduckgo-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { providerUrl } from "../provider-endpoints.js";
import type { BuiltSearchRequest, JsonObject, SearchResultItem } from "../types.js";
import type { BuildContext, ProviderModule } from "./shared.js";
import { appendDomainFilters, collect, getString, result } from "./shared.js";

function htmlDecode(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll(""", '"')
.replaceAll("'", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">");
}

function stripHtml(value: string): string {
return htmlDecode(
value
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim(),
);
}

function duckDuckGoResultUrl(rawHref: string): string | undefined {
const decodedHref = htmlDecode(rawHref);
const absoluteHref = decodedHref.startsWith("//") ? `https:${decodedHref}` : decodedHref;
let url: URL;
try {
url = new URL(absoluteHref);
} catch {
return undefined;
}
const redirected = url.searchParams.get("uddg");
return redirected ?? absoluteHref;
}

function normalizeDuckDuckGoHtml(html: string): SearchResultItem[] {
const matches = [...html.matchAll(/<a\b[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g)];
const snippets = [...html.matchAll(/<a\b[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/g)].map(
(match) => stripHtml(match[1] ?? ""),
);
return collect(
matches.map((match, index) => {
const title = stripHtml(match[2] ?? "");
const url = duckDuckGoResultUrl(match[1] ?? "");
return result(title, url, snippets[index]);
}),
);
}

export const duckDuckGoHtmlProvider: ProviderModule = {
buildRequest({ config, request, allowedDomains, blockedDomains }: BuildContext): BuiltSearchRequest {
const url = new URL(providerUrl(config));
url.searchParams.set("q", appendDomainFilters(request.query, allowedDomains, blockedDomains));
return { url: url.toString(), init: { method: "GET", headers: { Accept: "text/html" } } };
},
normalizeResponse(data: JsonObject): SearchResultItem[] {
return normalizeDuckDuckGoHtml(getString(data["html"]) ?? "");
},
};
28 changes: 28 additions & 0 deletions src/websearch/providers/exa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { providerUrl } from "../provider-endpoints.js";
import type { BuiltSearchRequest, JsonObject, SearchResultItem } from "../types.js";
import type { BuildContext, ProviderModule } from "./shared.js";
import { clamp, collect, contentHeaders, getArray, getNumber, getObject, getString, result } from "./shared.js";

export const exaProvider: ProviderModule = {
buildRequest({ config, request, maxResults, allowedDomains, blockedDomains }: BuildContext): BuiltSearchRequest {
const headers = contentHeaders({ "x-api-key": config.apiKey ?? "" });
const body: JsonObject = { query: request.query, numResults: clamp(maxResults, 1, 20) };
if (allowedDomains) body["includeDomains"] = allowedDomains;
if (blockedDomains) body["excludeDomains"] = blockedDomains;
return { url: providerUrl(config), init: { method: "POST", headers }, body };
},
normalizeResponse(data: JsonObject): SearchResultItem[] {
return collect(
getArray(data["results"]).map((raw) => {
const item = getObject(raw);
return result(
getString(item?.["title"]),
getString(item?.["url"]),
getString(item?.["text"]) ?? getString(item?.["snippet"]),
undefined,
getNumber(item?.["score"]),
);
}),
);
},
};
23 changes: 23 additions & 0 deletions src/websearch/providers/google-cse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { providerUrl } from "../provider-endpoints.js";
import type { BuiltSearchRequest, JsonObject, SearchResultItem } from "../types.js";
import type { BuildContext, ProviderModule } from "./shared.js";
import { appendDomainFilters, clamp, collect, getArray, getObject, getString, result } from "./shared.js";

export const googleCseProvider: ProviderModule = {
buildRequest({ config, request, maxResults, allowedDomains, blockedDomains }: BuildContext): BuiltSearchRequest {
const url = new URL(providerUrl(config));
url.searchParams.set("q", appendDomainFilters(request.query, allowedDomains, blockedDomains));
url.searchParams.set("key", config.apiKey ?? "");
url.searchParams.set("cx", config.searchEngineId ?? "");
url.searchParams.set("num", String(clamp(maxResults, 1, 10)));
return { url: url.toString(), init: { method: "GET", headers: { Accept: "application/json" } } };
},
normalizeResponse(data: JsonObject): SearchResultItem[] {
return collect(
getArray(data["items"]).map((raw) => {
const item = getObject(raw);
return result(getString(item?.["title"]), getString(item?.["link"]), getString(item?.["snippet"]));
}),
);
},
};
40 changes: 40 additions & 0 deletions src/websearch/providers/kimi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { providerUrl } from "../provider-endpoints.js";
import type { BuiltSearchRequest, JsonObject, SearchResultItem } from "../types.js";
import type { BuildContext, ProviderModule } from "./shared.js";
import {
appendDomainFilters,
clamp,
collect,
contentHeaders,
getArray,
getObject,
getString,
result,
} from "./shared.js";

export const kimiProvider: ProviderModule = {
buildRequest({ config, request, maxResults, allowedDomains, blockedDomains }: BuildContext): BuiltSearchRequest {
return {
url: providerUrl(config),
init: { method: "POST", headers: contentHeaders({ Authorization: `Bearer ${config.apiKey ?? ""}` }) },
body: {
text_query: appendDomainFilters(request.query, allowedDomains, blockedDomains),
limit: clamp(maxResults, 1, 20),
enable_page_crawling: false,
timeout_seconds: 30,
},
};
},
normalizeResponse(data: JsonObject): SearchResultItem[] {
return collect(
getArray(data["search_results"]).map((raw) => {
const item = getObject(raw);
return result(
getString(item?.["title"]),
getString(item?.["url"]),
getString(item?.["summary"]) ?? getString(item?.["content"]),
);
}),
);
},
};
115 changes: 115 additions & 0 deletions src/websearch/providers/openai-responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { providerUrl } from "../provider-endpoints.js";
import type { BuiltSearchRequest, JsonObject, SearchResultItem } from "../types.js";
import type { BuildContext, ProviderModule } from "./shared.js";
import {
appendDomainFilters,
collect,
contentHeaders,
getArray,
getObject,
getString,
result,
unique,
} from "./shared.js";

function searchOnlyPrompt(query: string): string {
return `Find web pages matching any of these search terms or quoted phrases. If the query contains OR, search each alternative independently. Return only relevant source URLs, one per line. Query: ${query}`;
}

function resultsFromTextUrls(text: string | undefined): SearchResultItem[] {
if (!text) return [];
const urls = text.match(/https?:\/\/[^\s)\]}>"]+/g) ?? [];
return collect(
unique(urls).map((url) => {
const cleaned = url.replace(/[.,;:]+$/, "");
return result(cleaned, cleaned, text);
}),
);
}

export function buildResponsesRequest({
config,
request,
allowedDomains,
blockedDomains,
}: BuildContext): BuiltSearchRequest {
const webSearchTool: JsonObject = {
type: "web_search",
external_web_access: (config.codexMode ?? "live") === "live",
};
if (config.searchContextSize) webSearchTool["search_context_size"] = config.searchContextSize;
if (allowedDomains) webSearchTool["filters"] = { allowed_domains: allowedDomains };
if (config.userLocation) webSearchTool["user_location"] = { type: "approximate", ...config.userLocation };
const input = searchOnlyPrompt(
blockedDomains ? appendDomainFilters(request.query, undefined, blockedDomains) : request.query,
);

return {
url: providerUrl(config),
init: { method: "POST", headers: contentHeaders({ Authorization: `Bearer ${config.apiKey ?? ""}` }) },
body: {
model: config.model ?? "gpt-5.5",
input,
tools: [webSearchTool],
include: ["web_search_call.action.sources"],
tool_choice: "required",
},
};
}

export function normalizeResponsesPayload(
data: JsonObject,
options: { citationsFallback: boolean },
): SearchResultItem[] {
const output = getArray(data["output"]);
const sources = collect(
output.flatMap((raw) => {
const item = getObject(raw);
if (item?.["type"] !== "web_search_call") return [];
const action = getObject(item["action"]);
return getArray(action?.["sources"]).map((sourceRaw) => {
const source = getObject(sourceRaw);
const url = getString(source?.["url"]);
return result(url, url);
});
}),
);
const message = output.map(getObject).find((item) => item?.["type"] === "message");
const content = getArray(message?.["content"])
.map(getObject)
.find((item) => item?.["type"] === "output_text");
const text = getString(content?.["text"]);
const annotationResults = collect(
getArray(content?.["annotations"]).map((raw) => {
const item = getObject(raw);
return item?.["type"] === "url_citation"
? result(getString(item["title"]), getString(item["url"]), text)
: null;
}),
);
if (annotationResults.length > 0) return annotationResults;
if (sources.length > 0) {
return sources.map((source) => {
if (source.snippet || text === undefined) return source;
return { ...source, snippet: text };
});
}
const textUrls = resultsFromTextUrls(text);
if (textUrls.length > 0) return textUrls;
if (!options.citationsFallback) return annotationResults;
return collect(
getArray(data["citations"]).map((raw) => {
const url = getString(raw);
return result(url, url, text);
}),
);
}

export const openAiResponsesProvider: ProviderModule = {
buildRequest(ctx: BuildContext): BuiltSearchRequest {
return buildResponsesRequest(ctx);
},
normalizeResponse(data: JsonObject): SearchResultItem[] {
return normalizeResponsesPayload(data, { citationsFallback: false });
},
};
Loading
Loading