| 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" |
| ]; |
| } |
| } |
|
|