|
|
import UserAgent from "user-agents"; |
|
|
import { JSDOM } from "jsdom"; |
|
|
import { RateLimitStore } from "./rate-limit-store"; |
|
|
import { SharedRateLimitMonitor } from "./shared-rate-limit-monitor"; |
|
|
import type { |
|
|
ChatCompletionMessage, |
|
|
VQDResponse, |
|
|
DuckAIRequest, |
|
|
} from "./types"; |
|
|
import { createHash } from "node:crypto"; |
|
|
import { Buffer } from "node:buffer"; |
|
|
|
|
|
|
|
|
interface RateLimitInfo { |
|
|
requestTimestamps: number[]; |
|
|
lastRequestTime: number; |
|
|
isLimited: boolean; |
|
|
retryAfter?: number; |
|
|
} |
|
|
|
|
|
export class DuckAI { |
|
|
private rateLimitInfo: RateLimitInfo = { |
|
|
requestTimestamps: [], |
|
|
lastRequestTime: 0, |
|
|
isLimited: false, |
|
|
}; |
|
|
private rateLimitStore: RateLimitStore; |
|
|
private rateLimitMonitor: SharedRateLimitMonitor; |
|
|
|
|
|
|
|
|
private readonly MAX_REQUESTS_PER_MINUTE = 20; |
|
|
private readonly WINDOW_SIZE_MS = 60 * 1000; |
|
|
private readonly MIN_REQUEST_INTERVAL_MS = 1000; |
|
|
|
|
|
constructor() { |
|
|
this.rateLimitStore = new RateLimitStore(); |
|
|
this.rateLimitMonitor = new SharedRateLimitMonitor(); |
|
|
this.loadRateLimitFromStore(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private cleanOldTimestamps(): void { |
|
|
const now = Date.now(); |
|
|
const cutoff = now - this.WINDOW_SIZE_MS; |
|
|
this.rateLimitInfo.requestTimestamps = |
|
|
this.rateLimitInfo.requestTimestamps.filter( |
|
|
(timestamp) => timestamp > cutoff |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private getCurrentRequestCount(): number { |
|
|
this.cleanOldTimestamps(); |
|
|
return this.rateLimitInfo.requestTimestamps.length; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private loadRateLimitFromStore(): void { |
|
|
const stored = this.rateLimitStore.read(); |
|
|
if (stored) { |
|
|
|
|
|
const storedAny = stored as any; |
|
|
if ("requestCount" in storedAny && "windowStart" in storedAny) { |
|
|
|
|
|
this.rateLimitInfo = { |
|
|
requestTimestamps: [], |
|
|
lastRequestTime: storedAny.lastRequestTime || 0, |
|
|
isLimited: storedAny.isLimited || false, |
|
|
retryAfter: storedAny.retryAfter, |
|
|
}; |
|
|
} else { |
|
|
|
|
|
this.rateLimitInfo = { |
|
|
requestTimestamps: storedAny.requestTimestamps || [], |
|
|
lastRequestTime: storedAny.lastRequestTime || 0, |
|
|
isLimited: storedAny.isLimited || false, |
|
|
retryAfter: storedAny.retryAfter, |
|
|
}; |
|
|
} |
|
|
|
|
|
this.cleanOldTimestamps(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private saveRateLimitToStore(): void { |
|
|
this.cleanOldTimestamps(); |
|
|
this.rateLimitStore.write({ |
|
|
requestTimestamps: this.rateLimitInfo.requestTimestamps, |
|
|
lastRequestTime: this.rateLimitInfo.lastRequestTime, |
|
|
isLimited: this.rateLimitInfo.isLimited, |
|
|
retryAfter: this.rateLimitInfo.retryAfter, |
|
|
} as any); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getRateLimitStatus(): { |
|
|
requestsInCurrentWindow: number; |
|
|
maxRequestsPerMinute: number; |
|
|
timeUntilWindowReset: number; |
|
|
isCurrentlyLimited: boolean; |
|
|
recommendedWaitTime: number; |
|
|
} { |
|
|
|
|
|
this.loadRateLimitFromStore(); |
|
|
|
|
|
const now = Date.now(); |
|
|
const currentRequestCount = this.getCurrentRequestCount(); |
|
|
|
|
|
|
|
|
|
|
|
const oldestTimestamp = this.rateLimitInfo.requestTimestamps[0]; |
|
|
const timeUntilReset = oldestTimestamp |
|
|
? Math.max(0, oldestTimestamp + this.WINDOW_SIZE_MS - now) |
|
|
: 0; |
|
|
|
|
|
const timeSinceLastRequest = now - this.rateLimitInfo.lastRequestTime; |
|
|
const recommendedWait = Math.max( |
|
|
0, |
|
|
this.MIN_REQUEST_INTERVAL_MS - timeSinceLastRequest |
|
|
); |
|
|
|
|
|
return { |
|
|
requestsInCurrentWindow: currentRequestCount, |
|
|
maxRequestsPerMinute: this.MAX_REQUESTS_PER_MINUTE, |
|
|
timeUntilWindowReset: timeUntilReset, |
|
|
isCurrentlyLimited: this.rateLimitInfo.isLimited, |
|
|
recommendedWaitTime: recommendedWait, |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private shouldWaitBeforeRequest(): { shouldWait: boolean; waitTime: number } { |
|
|
|
|
|
this.loadRateLimitFromStore(); |
|
|
|
|
|
const now = Date.now(); |
|
|
const currentRequestCount = this.getCurrentRequestCount(); |
|
|
|
|
|
|
|
|
if (currentRequestCount >= this.MAX_REQUESTS_PER_MINUTE) { |
|
|
|
|
|
const oldestTimestamp = this.rateLimitInfo.requestTimestamps[0]; |
|
|
if (oldestTimestamp) { |
|
|
|
|
|
const waitTime = oldestTimestamp + this.WINDOW_SIZE_MS - now + 100; |
|
|
return { shouldWait: true, waitTime: Math.max(0, waitTime) }; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const timeSinceLastRequest = now - this.rateLimitInfo.lastRequestTime; |
|
|
if (timeSinceLastRequest < this.MIN_REQUEST_INTERVAL_MS) { |
|
|
const waitTime = this.MIN_REQUEST_INTERVAL_MS - timeSinceLastRequest; |
|
|
return { shouldWait: true, waitTime }; |
|
|
} |
|
|
|
|
|
return { shouldWait: false, waitTime: 0 }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async waitIfNeeded(): Promise<void> { |
|
|
const { shouldWait, waitTime } = this.shouldWaitBeforeRequest(); |
|
|
|
|
|
if (shouldWait) { |
|
|
console.log(`Rate limiting: waiting ${waitTime}ms before next request`); |
|
|
await new Promise((resolve) => setTimeout(resolve, waitTime)); |
|
|
} |
|
|
} |
|
|
|
|
|
private async getEncodedVqdHash(vqdHash: string): Promise<string> { |
|
|
const jsScript = Buffer.from(vqdHash, 'base64').toString('utf-8'); |
|
|
|
|
|
const dom = new JSDOM( |
|
|
`<iframe id="jsa" sandbox="allow-scripts allow-same-origin" srcdoc="<!DOCTYPE html> |
|
|
<html> |
|
|
<head> |
|
|
<meta http-equiv="Content-Security-Policy"; content="default-src 'none'; script-src 'unsafe-inline'"> |
|
|
</head> |
|
|
<body></body> |
|
|
</html>" style="position: absolute; left: -9999px; top: -9999px;"></iframe>`, |
|
|
{ runScripts: 'dangerously' } |
|
|
); |
|
|
dom.window.top.__DDG_BE_VERSION__ = 1; |
|
|
dom.window.top.__DDG_FE_CHAT_HASH__ = 1; |
|
|
const jsa = dom.window.top.document.querySelector('#jsa') as HTMLIFrameElement; |
|
|
const contentDoc = jsa.contentDocument || jsa.contentWindow!.document; |
|
|
|
|
|
const meta = contentDoc.createElement('meta'); |
|
|
meta.setAttribute('http-equiv', 'Content-Security-Policy'); |
|
|
meta.setAttribute('content', "default-src 'none'; script-src 'unsafe-inline';"); |
|
|
contentDoc.head.appendChild(meta); |
|
|
const result = await dom.window.eval(jsScript) as { |
|
|
client_hashes: string[]; |
|
|
[key: string]: any; |
|
|
}; |
|
|
|
|
|
result.client_hashes[0] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; |
|
|
result.client_hashes = result.client_hashes.map((t) => { |
|
|
const hash = createHash('sha256'); |
|
|
hash.update(t); |
|
|
|
|
|
return hash.digest('base64'); |
|
|
}); |
|
|
|
|
|
return btoa(JSON.stringify(result)); |
|
|
} |
|
|
|
|
|
private async getVQD(userAgent: string): Promise<VQDResponse> { |
|
|
const response = await fetch("https://duckduckgo.com/duckchat/v1/status", { |
|
|
headers: { |
|
|
accept: "*/*", |
|
|
"accept-language": "en-US,en;q=0.9,fa;q=0.8", |
|
|
"cache-control": "no-store", |
|
|
pragma: "no-cache", |
|
|
priority: "u=1, i", |
|
|
"sec-fetch-dest": "empty", |
|
|
"sec-fetch-mode": "cors", |
|
|
"sec-fetch-site": "same-origin", |
|
|
"x-vqd-accept": "1", |
|
|
"User-Agent": userAgent, |
|
|
}, |
|
|
referrer: "https://duckduckgo.com/", |
|
|
referrerPolicy: "origin", |
|
|
method: "GET", |
|
|
mode: "cors", |
|
|
credentials: "include", |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error( |
|
|
`Failed to get VQD: ${response.status} ${response.statusText}` |
|
|
); |
|
|
} |
|
|
|
|
|
const hashHeader = response.headers.get("x-Vqd-hash-1"); |
|
|
|
|
|
if (!hashHeader) { |
|
|
throw new Error( |
|
|
`Missing VQD headers: hash=${!!hashHeader}` |
|
|
); |
|
|
} |
|
|
|
|
|
const encodedHash = await this.getEncodedVqdHash(hashHeader); |
|
|
|
|
|
return { hash: encodedHash }; |
|
|
} |
|
|
|
|
|
private async hashClientHashes(clientHashes: string[]): Promise<string[]> { |
|
|
return Promise.all( |
|
|
clientHashes.map(async (hash) => { |
|
|
const encoder = new TextEncoder(); |
|
|
const data = encoder.encode(hash); |
|
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data); |
|
|
const hashArray = new Uint8Array(hashBuffer); |
|
|
return btoa( |
|
|
hashArray.reduce((str, byte) => str + String.fromCharCode(byte), "") |
|
|
); |
|
|
}) |
|
|
); |
|
|
} |
|
|
|
|
|
async chat(request: DuckAIRequest): Promise<string> { |
|
|
|
|
|
await this.waitIfNeeded(); |
|
|
|
|
|
const userAgent = new UserAgent().toString(); |
|
|
const vqd = await this.getVQD(userAgent); |
|
|
|
|
|
|
|
|
const now = Date.now(); |
|
|
this.rateLimitInfo.requestTimestamps.push(now); |
|
|
this.rateLimitInfo.lastRequestTime = now; |
|
|
this.saveRateLimitToStore(); |
|
|
|
|
|
|
|
|
this.rateLimitMonitor.printCompactStatus(); |
|
|
|
|
|
const response = await fetch("https://duckduckgo.com/duckchat/v1/chat", { |
|
|
headers: { |
|
|
accept: "text/event-stream", |
|
|
"accept-language": "en-US,en;q=0.9,fa;q=0.8", |
|
|
"cache-control": "no-cache", |
|
|
"content-type": "application/json", |
|
|
pragma: "no-cache", |
|
|
priority: "u=1, i", |
|
|
"sec-fetch-dest": "empty", |
|
|
"sec-fetch-mode": "cors", |
|
|
"sec-fetch-site": "same-origin", |
|
|
"x-fe-version": "serp_20250401_100419_ET-19d438eb199b2bf7c300", |
|
|
"User-Agent": userAgent, |
|
|
"x-vqd-hash-1": vqd.hash, |
|
|
}, |
|
|
referrer: "https://duckduckgo.com/", |
|
|
referrerPolicy: "origin", |
|
|
body: JSON.stringify(request), |
|
|
method: "POST", |
|
|
mode: "cors", |
|
|
credentials: "include", |
|
|
}); |
|
|
|
|
|
|
|
|
if (response.status === 429) { |
|
|
const retryAfter = response.headers.get("retry-after"); |
|
|
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : 60000; |
|
|
throw new Error( |
|
|
`Rate limited. Retry after ${waitTime}ms. Status: ${response.status}` |
|
|
); |
|
|
} |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error( |
|
|
`DuckAI API error: ${response.status} ${response.statusText}` |
|
|
); |
|
|
} |
|
|
|
|
|
const text = await response.text(); |
|
|
|
|
|
|
|
|
try { |
|
|
const parsed = JSON.parse(text); |
|
|
if (parsed.action === "error") { |
|
|
throw new Error(`Duck.ai error: ${JSON.stringify(parsed)}`); |
|
|
} |
|
|
} catch (e) { |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
let llmResponse = ""; |
|
|
const lines = text.split("\n"); |
|
|
for (const line of lines) { |
|
|
if (line.startsWith("data: ")) { |
|
|
try { |
|
|
const json = JSON.parse(line.slice(6)); |
|
|
if (json.message) { |
|
|
llmResponse += json.message; |
|
|
} |
|
|
} catch (e) { |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
const finalResponse = llmResponse.trim(); |
|
|
|
|
|
|
|
|
if (!finalResponse) { |
|
|
console.warn("Duck.ai returned empty response, using fallback"); |
|
|
return "I apologize, but I'm unable to provide a response at the moment. Please try again."; |
|
|
} |
|
|
|
|
|
return finalResponse; |
|
|
} |
|
|
|
|
|
async chatStream(request: DuckAIRequest): Promise<ReadableStream<string>> { |
|
|
|
|
|
await this.waitIfNeeded(); |
|
|
|
|
|
const userAgent = new UserAgent().toString(); |
|
|
const vqd = await this.getVQD(userAgent); |
|
|
|
|
|
|
|
|
const now = Date.now(); |
|
|
this.rateLimitInfo.requestTimestamps.push(now); |
|
|
this.rateLimitInfo.lastRequestTime = now; |
|
|
this.saveRateLimitToStore(); |
|
|
|
|
|
|
|
|
this.rateLimitMonitor.printCompactStatus(); |
|
|
|
|
|
const response = await fetch("https://duckduckgo.com/duckchat/v1/chat", { |
|
|
headers: { |
|
|
accept: "text/event-stream", |
|
|
"accept-language": "en-US,en;q=0.9,fa;q=0.8", |
|
|
"cache-control": "no-cache", |
|
|
"content-type": "application/json", |
|
|
pragma: "no-cache", |
|
|
priority: "u=1, i", |
|
|
"sec-fetch-dest": "empty", |
|
|
"sec-fetch-mode": "cors", |
|
|
"sec-fetch-site": "same-origin", |
|
|
"x-fe-version": "serp_20250401_100419_ET-19d438eb199b2bf7c300", |
|
|
"User-Agent": userAgent, |
|
|
"x-vqd-hash-1": vqd.hash, |
|
|
}, |
|
|
referrer: "https://duckduckgo.com/", |
|
|
referrerPolicy: "origin", |
|
|
body: JSON.stringify(request), |
|
|
method: "POST", |
|
|
mode: "cors", |
|
|
credentials: "include", |
|
|
}); |
|
|
|
|
|
|
|
|
if (response.status === 429) { |
|
|
const retryAfter = response.headers.get("retry-after"); |
|
|
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : 60000; |
|
|
throw new Error( |
|
|
`Rate limited. Retry after ${waitTime}ms. Status: ${response.status}` |
|
|
); |
|
|
} |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error( |
|
|
`DuckAI API error: ${response.status} ${response.statusText}` |
|
|
); |
|
|
} |
|
|
|
|
|
if (!response.body) { |
|
|
throw new Error("No response body"); |
|
|
} |
|
|
|
|
|
return new ReadableStream({ |
|
|
start(controller) { |
|
|
const reader = response.body!.getReader(); |
|
|
const decoder = new TextDecoder(); |
|
|
|
|
|
function pump(): Promise<void> { |
|
|
return reader.read().then(({ done, value }) => { |
|
|
if (done) { |
|
|
controller.close(); |
|
|
return; |
|
|
} |
|
|
|
|
|
const chunk = decoder.decode(value, { stream: true }); |
|
|
const lines = chunk.split("\n"); |
|
|
|
|
|
for (const line of lines) { |
|
|
if (line.startsWith("data: ")) { |
|
|
try { |
|
|
const json = JSON.parse(line.slice(6)); |
|
|
if (json.message) { |
|
|
controller.enqueue(json.message); |
|
|
} |
|
|
} catch (e) { |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return pump(); |
|
|
}); |
|
|
} |
|
|
|
|
|
return pump(); |
|
|
}, |
|
|
}); |
|
|
} |
|
|
|
|
|
getAvailableModels(): string[] { |
|
|
return [ |
|
|
"gpt-4o-mini", |
|
|
"gpt-5-mini", |
|
|
"claude-3-5-haiku-latest", |
|
|
"meta-llama/Llama-4-Scout-17B-16E-Instruct", |
|
|
"mistralai/Mistral-Small-24B-Instruct-2501", |
|
|
"openai/gpt-oss-120b" |
|
|
]; |
|
|
} |
|
|
} |
|
|
|