Files
2026-07-13 13:39:12 +08:00

783 lines
26 KiB
TypeScript

import { generateKeyPairSync, randomUUID } from "node:crypto";
import vm from "node:vm";
import { solveDuckDuckGoChallenge, makeDuckDuckGoFeSignals } from "./duckduckgo-web/challenge.ts";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts";
import type { Session } from "../services/sessionPool/session.ts";
import { tryBackedChat } from "../services/browserBackedChat.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
export const DUCKDUCKGO_BASE = "https://duckduckgo.com";
// #4037: the live DuckDuckGo AI Chat backend is served from duckduckgo.com. The
// status/chat fetches, Origin, and Referer must all use this host so the request's
// same-origin triplet (host + Origin + Referer) stays consistent with
// `Sec-Fetch-Site: same-origin`; pointing them at duck.ai produced an inconsistent
// triplet the backend rejected with HTTP 400.
const AUTH_TOKEN_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/auth/token`;
const COUNTRY_URL = `${DUCKDUCKGO_BASE}/country.json`;
export const STATUS_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/status`;
export const CHAT_URL = `${DUCKDUCKGO_BASE}/duckchat/v1/chat`;
const DEFAULT_FE_VERSION = "serp_20260424_180649_ET-0bdc33b2a02ebf8f235def65d887787f694720a1";
// #4037: the real served x-fe-version token has a 20-hex tail (e.g.
// `serp_20250401_100419_ET-19d438eb199b2bf7c300`); the previous `{40}` requirement
// never matched the live token, so the scrape silently fell back to DEFAULT_FE_VERSION.
// Bounded `{20,40}` keeps the pattern ReDoS-safe.
export const FE_VERSION_PATTERN = /serp_\d{8}_\d{6}_[A-Z]{2}-[0-9a-f]{20,40}/;
const DEFAULT_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
export const FAKE_HEADERS: Record<string, string> = {
Accept: "*/*",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9",
"Cache-Control": "no-cache",
Origin: DUCKDUCKGO_BASE,
Pragma: "no-cache",
Referer: `${DUCKDUCKGO_BASE}/`,
Priority: "u=1, i",
"Sec-Ch-Ua": '"Chromium";v="149", "Not-A.Brand";v="24", "Google Chrome";v="149"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"Linux"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": DEFAULT_USER_AGENT,
};
const SEEDED_COOKIES: ReadonlyArray<readonly [string, string]> = [
["5", "1"],
["ah", "wt-wt"],
["dcs", "1"],
["dcm", "3"],
["isRecentChatOn", "1"],
];
function shouldUseBrowserBacked(): boolean {
const flag = process.env.WEB_COOKIE_USE_BROWSER;
if (flag === "1" || flag === "true" || flag === "on") return true;
const poolFlag = process.env.OMNIROUTE_BROWSER_POOL;
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
}
interface DuckDuckGoVqdHeaders {
vqd4: string | null;
vqdHash1: string | null;
}
interface DuckDuckGoAuthHeaders {
vqd4: string | null;
vqdHash1: string | null;
}
interface DuckDuckGoModelCapabilities {
reasoningEffort: string | null;
}
type DuckDuckGoChallengeResult = {
client_hashes?: unknown;
[key: string]: unknown;
};
let durablePublicKey: JsonWebKey | null = null;
function extractDuckDuckGoContent(data: unknown): string {
if (!data || typeof data !== "object") return "";
const record = data as Record<string, unknown>;
const content = record.content;
if (typeof content === "string") return content;
const message = record.message;
if (typeof message === "string") return message;
return "";
}
function parseDuckDuckGoDataLine(line: string): unknown | null {
if (!line.startsWith("data: ")) return null;
try {
return JSON.parse(line.slice(6));
} catch (error) {
void error;
return null;
}
}
function parseDuckDuckGoError(body: string): { type?: unknown; overrideCode?: unknown } | null {
try {
return JSON.parse(body) as { type?: unknown; overrideCode?: unknown };
} catch (error) {
void error;
return null;
}
}
function splitSetCookieHeader(header: string): string[] {
const cookies: string[] = [];
let start = 0;
for (let index = 0; index < header.length; index++) {
if (header[index] !== ",") continue;
const rest = header.slice(index + 1);
if (/^\s*[^=;\s]+\s*=/.test(rest)) {
cookies.push(header.slice(start, index).trim());
start = index + 1;
}
}
cookies.push(header.slice(start).trim());
return cookies.filter(Boolean);
}
function collectSetCookieHeaders(headers: Headers): string[] {
const getSetCookie = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
if (typeof getSetCookie === "function") return getSetCookie.call(headers);
const combined = headers.get("set-cookie");
return combined ? splitSetCookieHeader(combined) : [];
}
function applySetCookie(cookieJar: Map<string, string>, setCookie: string): void {
const pair = setCookie.split(";", 1)[0]?.trim();
if (!pair) return;
const separator = pair.indexOf("=");
if (separator <= 0) return;
cookieJar.set(pair.slice(0, separator), pair.slice(separator + 1));
}
function serializeCookieJar(cookieJar: Map<string, string>): string {
return Array.from(cookieJar.entries())
.map(([name, value]) => `${name}=${value}`)
.join("; ");
}
function mergeHeadersCaseInsensitive(
...sources: Array<Record<string, string> | undefined>
): Record<string, string> {
const merged: Record<string, string> = {};
const canonicalNames = new Map<string, string>();
for (const source of sources) {
if (!source) continue;
for (const [name, value] of Object.entries(source)) {
const lowerName = name.toLowerCase();
const previousName = canonicalNames.get(lowerName);
if (previousName) delete merged[previousName];
canonicalNames.set(lowerName, name);
merged[name] = value;
}
}
return merged;
}
function normalizeDuckDuckGoModel(model: string | undefined): string {
if (!model) return "gpt-4o-mini";
const clean = model.startsWith("duckduckgo-web/") ? model.slice("duckduckgo-web/".length) : model;
if (clean === "claude-3-5-haiku-20241022") return "claude-haiku-4-5";
if (clean === "llama-4-scout") return "meta-llama/Llama-4-Scout-17B-16E-Instruct";
if (clean === "mistral-small-2501") return "mistral-small-2603";
if (clean === "gpt-oss-120b") return "tinfoil/gpt-oss-120b";
return clean;
}
function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilities {
if (model === "gpt-5-mini") return { reasoningEffort: "minimal" };
if (model === "claude-haiku-4-5") return { reasoningEffort: "low" };
if (model === "tinfoil/gpt-oss-120b") return { reasoningEffort: "low" };
return { reasoningEffort: null };
}
function extractDuckDuckGoFeVersion(html: string): string | null {
return html.match(FE_VERSION_PATTERN)?.[0] ?? null;
}
function getDurablePublicKey(): JsonWebKey {
if (!durablePublicKey) {
const { publicKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicExponent: 0x10001,
});
durablePublicKey = {
...publicKey.export({ format: "jwk" }),
alg: "RSA-OAEP-256",
ext: true,
key_ops: ["encrypt"],
use: "enc",
};
}
return durablePublicKey;
}
function buildDuckDuckGoPayload(
model: string,
messages: Array<Record<string, unknown>>,
canUseTools = true
): Record<string, unknown> {
const capabilities = getDuckDuckGoModelCapabilities(model);
const payload: Record<string, unknown> = {
model,
metadata: {
toolChoice: {
NewsSearch: false,
VideosSearch: false,
LocalSearch: false,
WeatherForecast: false,
},
},
messages,
canUseTools,
...(capabilities.reasoningEffort ? { reasoningEffort: capabilities.reasoningEffort } : {}),
canUseApproxLocation: null,
canDelegateImageGeneration: null,
durableStream: {
messageId: randomUUID(),
conversationId: randomUUID(),
publicKey: getDurablePublicKey(),
},
};
return payload;
}
function normalizeDuckDuckGoError(status: number, body: string): string {
const parsed = parseDuckDuckGoError(body);
if (parsed) {
const type = typeof parsed.type === "string" ? parsed.type : "";
const overrideCode = typeof parsed.overrideCode === "string" ? parsed.overrideCode : "";
if (type === "ERR_CHALLENGE" || type === "ERR_BN_LIMIT") {
const codeSuffix = overrideCode ? ` (${overrideCode})` : "";
return (
`DuckDuckGo AI Chat anti-abuse challenge failed: ${type}${codeSuffix}. ` +
"Retry later or from a less rate-limited IP; DuckDuckGo is rejecting this anonymous session."
);
}
if (type) return `DuckDuckGo AI Chat error: ${type}`;
}
return `DuckDuckGo AI Chat returned HTTP ${status}`;
}
/**
* DuckDuckGoWebExecutor handles anonymous, free access to DuckDuckGo AI Chat.
*
* Authentication flow:
* 1. GET /duckchat/v1/status → get x-vqd-hash-1 header (VQD token)
* 2. POST /duckchat/v1/chat with VQD header + model + messages
* 3. Parse NDJSON SSE stream and transform to OpenAI format
*
* VQD tokens are per-request; no caching or cleanup needed.
*/
export class DuckDuckGoWebExecutor extends BaseExecutor {
protected poolConfig = {
minSessions: 2,
maxSessions: 5,
cooldownBase: 1000,
cooldownMax: 10000,
cooldownJitter: 500,
requestTimeout: 30000,
requestJitter: 50,
};
constructor() {
super("duckduckgo-web", { baseUrl: DUCKDUCKGO_BASE });
}
private warmed = false;
private seeded = false;
private feVersion = DEFAULT_FE_VERSION;
private pendingVqdHash1: string | null = null;
private readonly cookieJar = new Map<string, string>();
private buildRequestHeaders(extra: Record<string, string> = {}): Record<string, string> {
const headers = { ...FAKE_HEADERS, ...extra };
const cookie = serializeCookieJar(this.cookieJar);
return cookie ? { ...headers, Cookie: cookie } : headers;
}
private rememberResponseCookies(response: Response): void {
for (const cookie of collectSetCookieHeaders(response.headers)) {
applySetCookie(this.cookieJar, cookie);
}
}
private seedBrowserCookies(): void {
for (const [name, value] of SEEDED_COOKIES) {
if (!this.cookieJar.has(name)) this.cookieJar.set(name, value);
}
}
private async warmFetch(
url: string,
headers: Record<string, string>,
signal: AbortSignal
): Promise<Response | null> {
try {
const response = await fetch(url, { headers, signal });
this.rememberResponseCookies(response);
return response;
} catch (error) {
void error;
return null;
}
}
async testConnection(
_credentials: Record<string, unknown>,
signal?: AbortSignal
): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const mergedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
const resp = await fetch(STATUS_URL, {
method: "GET",
headers: this.buildRequestHeaders({
Accept: "*/*",
"Cache-Control": "no-store",
"x-vqd-accept": "1",
}),
signal: mergedSignal,
});
this.rememberResponseCookies(resp);
clearTimeout(timeout);
return (
resp.ok &&
(resp.headers.get("x-vqd-4") !== null || resp.headers.get("x-vqd-hash-1") !== null)
);
} catch {
return false;
}
}
async execute(input: ExecuteInput): Promise<{
response: Response;
url: string;
headers: Record<string, string>;
transformedBody: unknown;
}> {
const { model, body, stream, signal, upstreamExtraHeaders } = input;
const upstreamModel = normalizeDuckDuckGoModel(model);
const bodyObj = (body || {}) as Record<string, unknown>;
const rawMessages = Array.isArray((body as { messages?: unknown[] } | null)?.messages)
? ((body as { messages: unknown[] }).messages as Array<Record<string, unknown>>)
: [];
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
bodyObj,
rawMessages
);
const messages = effectiveMessages as Array<Record<string, unknown>>;
const isStreaming = stream !== false;
const upstreamHeaders = upstreamExtraHeaders || {};
const errorResponse = (status: number, message: string): Response =>
new Response(JSON.stringify({ error: { message } }), {
status,
headers: { "Content-Type": "application/json" },
});
if (messages.length === 0) {
return errorResponse(400, "No messages provided");
}
// Browser-backed path: opt-in via OMNIROUTE_BROWSER_POOL=on or
// WEB_COOKIE_USE_BROWSER=1. Routes the chat through a shared
// Playwright/Cloakbrowser page so DDG's VQD challenge is solved by
// a real browser. Latency is dominated by page navigation + AI wait
// (~10-25s), but it's the only way to get HTTP 200 from this
// environment once the Node vm solver hits its anti-bot ceiling.
if (shouldUseBrowserBacked()) {
const lastUser = [...messages].reverse().find((m) => m.role === "user");
const userText = extractDuckDuckGoContent(lastUser ?? { content: "" });
const result = await tryBackedChat({
poolKey: "duckduckgo-web",
chatPageUrl: "https://duck.ai/chat",
chatUrl: CHAT_URL,
chatUrlMatchDomain: "duck.ai",
userMessage: userText || "Reply with OK",
inputSelector: "textarea",
submitButtonSelector: "button[aria-label='Ask']",
signal: signal ?? null,
postSubmitWaitMs: 15000,
});
if (result.status > 0) {
// Wrap the captured body as a Response so processResponse
// (already a streaming/non-streaming transformer) can be
// reused unchanged.
const upstreamResp = new Response(result.body, {
status: result.status,
headers: {
"Content-Type": result.contentType || "text/event-stream",
},
});
return await this.processResponse(upstreamResp, isStreaming, hasTools, requestedTools);
}
// status 0 means no response captured (selector/navigation error).
return errorResponse(502, "Browser-backed chat captured no upstream response");
}
// Acquire session from pool for fingerprint rotation
const pool = this.getPool();
let session: Session | null;
try {
session = pool ? await pool.acquireBlocking(10_000) : null;
} catch {
session = null;
}
const sessionHeaders = session ? session.buildHeaders() : {};
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const mergedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
const sendChat = async (vqdHeaders: DuckDuckGoAuthHeaders): Promise<Response> => {
const payload = buildDuckDuckGoPayload(upstreamModel, messages);
const response = await fetch(CHAT_URL, {
method: "POST",
headers: mergeHeadersCaseInsensitive(
sessionHeaders,
this.buildRequestHeaders(),
upstreamHeaders,
{
Accept: "text/event-stream",
"Content-Type": "application/json",
"x-ddg-journey-id": randomUUID().replaceAll("-", ""),
"x-fe-signals": makeDuckDuckGoFeSignals(),
"x-fe-version": this.feVersion,
...(vqdHeaders.vqd4 ? { "x-vqd-4": vqdHeaders.vqd4 } : {}),
...(vqdHeaders.vqdHash1 ? { "x-vqd-hash-1": vqdHeaders.vqdHash1 } : {}),
}
),
body: JSON.stringify(payload),
signal: mergedSignal,
});
this.rememberResponseCookies(response);
this.rememberChallengeHeader(response);
return response;
};
if (mergedSignal.aborted) {
clearTimeout(timeout);
return errorResponse(499, "Request cancelled");
}
await this.warmSession(mergedSignal);
await this.seedChallengeChain(upstreamModel, mergedSignal);
const vqdHeaders = await this.acquireAuthHeaders(mergedSignal);
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
clearTimeout(timeout);
return errorResponse(503, "Failed to acquire VQD token");
}
let chatResponse = await sendChat(vqdHeaders);
if (chatResponse.status === 418) {
this.pendingVqdHash1 = null;
const freshVqd = await this.acquireAuthHeaders(mergedSignal);
if (freshVqd.vqd4 || freshVqd.vqdHash1) {
chatResponse = await sendChat(freshVqd);
}
}
clearTimeout(timeout);
if (chatResponse.status === 429) {
if (pool && session) pool.reportCooldown(session);
return await this.processResponse(chatResponse, isStreaming, hasTools, requestedTools);
}
if (chatResponse.status === 401 || chatResponse.status === 403) {
this.pendingVqdHash1 = null;
const freshVqd = await this.acquireAuthHeaders(mergedSignal);
if (freshVqd.vqd4 || freshVqd.vqdHash1) {
const retryResponse = await sendChat(freshVqd);
return await this.processResponse(retryResponse, isStreaming, hasTools, requestedTools);
}
return errorResponse(503, "Service unavailable");
}
if (chatResponse.status >= 500) {
if (pool && session) pool.reportDead(session);
return errorResponse(502, "Upstream error");
}
const result = await this.processResponse(
chatResponse,
isStreaming,
hasTools,
requestedTools
);
// Report pool status based on response
if (pool && session) {
if (chatResponse.status === 429) {
pool.reportCooldown(session);
} else if (chatResponse.status >= 500) {
pool.reportDead(session);
} else {
pool.reportSuccess(session);
}
}
return result;
} catch (error) {
if (pool && session) {
pool.reportCooldown(session);
}
if (error instanceof DOMException && error.name === "AbortError") {
return errorResponse(499, "Request cancelled");
}
return errorResponse(
500,
sanitizeErrorMessage(error instanceof Error ? error.message : "Unknown error")
);
} finally {
session?.release();
}
}
private async acquireVqdHeaders(signal: AbortSignal): Promise<DuckDuckGoVqdHeaders> {
try {
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
const resp = await fetch(STATUS_URL, {
method: "GET",
headers: this.buildRequestHeaders({
Accept: "*/*",
"Cache-Control": "no-store",
"x-vqd-accept": "1",
}),
signal,
});
this.rememberResponseCookies(resp);
if (!resp.ok) return { vqd4: null, vqdHash1: null };
return {
vqd4: resp.headers.get("x-vqd-4"),
vqdHash1: resp.headers.get("x-vqd-hash-1"),
};
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
return { vqd4: null, vqdHash1: null };
}
}
private async acquireAuthHeaders(signal: AbortSignal): Promise<DuckDuckGoAuthHeaders> {
if (this.pendingVqdHash1) {
const challenge = this.pendingVqdHash1;
this.pendingVqdHash1 = null;
try {
return {
vqd4: null,
vqdHash1: await solveDuckDuckGoChallenge(challenge, FAKE_HEADERS["User-Agent"]),
};
} catch (error) {
void error;
}
}
const headers = await this.acquireVqdHeaders(signal);
if (headers.vqdHash1) {
try {
return {
vqd4: headers.vqd4,
vqdHash1: await solveDuckDuckGoChallenge(headers.vqdHash1, FAKE_HEADERS["User-Agent"]),
};
} catch (error) {
void error;
return headers;
}
}
return headers;
}
private rememberChallengeHeader(response: Response): void {
const nextHash = response.headers.get("x-vqd-hash-1");
if (nextHash) this.pendingVqdHash1 = nextHash;
}
private async warmSession(signal: AbortSignal): Promise<void> {
if (this.warmed || signal.aborted) return;
this.warmed = true;
this.seedBrowserCookies();
const homepageResponse = await this.warmFetch(
`${DUCKDUCKGO_BASE}/`,
this.buildRequestHeaders({
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Upgrade-Insecure-Requests": "1",
}),
signal
);
if (homepageResponse) {
try {
const homepageHtml = await homepageResponse.clone().text();
const feVersion = extractDuckDuckGoFeVersion(homepageHtml);
if (feVersion) this.feVersion = feVersion;
} catch (error) {
void error;
}
}
await this.warmFetch(COUNTRY_URL, this.buildRequestHeaders({ Accept: "*/*" }), signal);
await this.warmFetch(AUTH_TOKEN_URL, this.buildRequestHeaders({ Accept: "*/*" }), signal);
await this.warmFetch(
`${DUCKDUCKGO_BASE}/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=1`,
this.buildRequestHeaders({
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Origin: DUCKDUCKGO_BASE,
Referer: `${DUCKDUCKGO_BASE}/`,
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Upgrade-Insecure-Requests": "1",
}),
signal
);
}
private async seedChallengeChain(model: string, signal: AbortSignal): Promise<void> {
if (this.seeded || signal.aborted) return;
this.seeded = true;
const seedMessages = [{ role: "user", content: "hi" }];
const previousPending = this.pendingVqdHash1;
try {
const vqdHeaders = await this.acquireAuthHeaders(signal);
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
this.pendingVqdHash1 = previousPending;
return;
}
const response = await fetch(CHAT_URL, {
method: "POST",
headers: mergeHeadersCaseInsensitive(this.buildRequestHeaders(), {
Accept: "text/event-stream",
"Content-Type": "application/json",
"x-ddg-journey-id": randomUUID().replaceAll("-", ""),
"x-fe-signals": makeDuckDuckGoFeSignals(),
"x-fe-version": this.feVersion,
...(vqdHeaders.vqd4 ? { "x-vqd-4": vqdHeaders.vqd4 } : {}),
...(vqdHeaders.vqdHash1 ? { "x-vqd-hash-1": vqdHeaders.vqdHash1 } : {}),
}),
body: JSON.stringify(buildDuckDuckGoPayload(model, seedMessages, false)),
signal,
});
this.rememberResponseCookies(response);
if (response.ok) this.rememberChallengeHeader(response);
else this.pendingVqdHash1 = previousPending;
await response.body?.cancel().catch(() => {});
} catch (error) {
void error;
this.pendingVqdHash1 = previousPending;
}
}
private async processResponse(
response: Response,
streaming: boolean,
hasTools?: boolean,
requestedTools?: unknown
): Promise<Response> {
if (!response.ok) {
const body = await response.text();
return new Response(
JSON.stringify({ error: { message: normalizeDuckDuckGoError(response.status, body) } }),
{
status: response.status,
headers: { "Content-Type": "application/json" },
}
);
}
if (streaming) {
if (!response.body) {
return new Response(JSON.stringify({ error: { message: "No response body" } }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
const transformStream = new TransformStream({
async transform(chunk, controller) {
const text = new TextDecoder().decode(chunk);
const lines = text.split("\n");
for (const line of lines) {
if (!line.trim()) continue;
if (line === "[DONE]") {
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
continue;
}
const data = parseDuckDuckGoDataLine(line);
const content = extractDuckDuckGoContent(data);
if (content) {
const openaiFormat = {
choices: [
{
delta: { content },
index: 0,
},
],
};
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(openaiFormat)}\n\n`);
controller.enqueue(encoded);
}
}
},
});
const transformedBody = response.body.pipeThrough(transformStream);
return new Response(transformedBody, {
headers: { "Content-Type": "text/event-stream" },
});
} else {
const text = await response.text();
let fullContent = "";
const lines = text.split("\n");
for (const line of lines) {
if (!line.trim() || line === "[DONE]") continue;
fullContent += extractDuckDuckGoContent(parseDuckDuckGoDataLine(line));
}
const openaiResponse = hasTools
? (() => {
const { content, toolCalls, finishReason } = buildToolAwareResult(
fullContent,
requestedTools,
"ddg"
);
const message: Record<string, unknown> = { role: "assistant", content };
if (toolCalls) {
message.tool_calls = toolCalls;
message.content = null;
}
return { choices: [{ index: 0, message, finish_reason: finishReason }] };
})()
: {
choices: [
{
message: { content: fullContent, role: "assistant" },
index: 0,
finish_reason: "stop",
},
],
};
return new Response(JSON.stringify(openaiResponse), {
headers: { "Content-Type": "application/json" },
});
}
}
}
export const duckduckgoWebExecutor = new DuckDuckGoWebExecutor();