mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 07:42:13 +03:00
* chore(release): open v3.8.14 development cycle Version bump 3.8.13 -> 3.8.14 (root + electron + open-sse + openapi + lockfiles). Seed the v3.8.14 changelog with the four post-tag hotfixes that shipped to Docker/Electron in v3.8.13 but missed the immutable npm 3.8.13 (#3336 SSRF / CodeQL #323, #3334/#3335/#3339 Electron packaging). i18n CHANGELOG mirrors get the in-progress placeholder section. * feat: add per-provider custom headers support for OpenAI/Anthropic-compatible nodes (#3338) Integrated into release/v3.8.14 * fix: Kiro Builder ID token import fails with Bad credentials (#3333) Integrated into release/v3.8.14 — adds Builder ID cached-creds + OIDC refresh path for Kiro token import, with regression tests (#3333). * Improve code quality: auto-pr/docstrings-1780792063 (#3337) Integrated into release/v3.8.14 — docstring for context analytics route re-export. * fix(catalog): remove minimaxai/minimax-m3 from NVIDIA NIM tier (404 upstream) (#3329) (#3341) NVIDIA NIM does not host minimaxai/minimax-m3 — every request returns 404 page not found, while sibling minimaxai/minimax-m2.7 on the same provider works. Advertising a model that 404s is a catalog bug; remove it from the nvidia tier (it remains on the tiers that actually serve MiniMax M3). Re-add only once NVIDIA serves it. Co-authored-by: mikmaneggahommie <mikmaneggahommie@users.noreply.github.com> * fix(cli): write OpenCode config to ~/.config on all platforms incl. Windows (#3330) (#3343) resolveOpencodeConfigDir used %APPDATA% on Windows, but OpenCode reads its config from XDG ~/.config/opencode/ on every platform (on Windows: %USERPROFILE%\.config\opencode\, NOT %APPDATA%). So a Windows user who configured OpenCode via the dashboard had the file written where OpenCode never looks — it silently had no effect. Use the XDG path (XDG_CONFIG_HOME || ~/.config) unconditionally. Update the UI note + route JSDoc, and flip the three tests that encoded the old %APPDATA% behavior (t40 per-platform + card-note, cli-runtime-extended getCliConfigPaths). Co-authored-by: abdulkadirozyurt <abdulkadirozyurt@users.noreply.github.com> * fix(proxy): make auto-selection fallback opt-in (#3332) (#3344) selectWorkingProxyFallback (Step 11 of resolveProxyForConnection) listed ALL registry proxies, ignoring assignments and per-connection proxy_enabled, and returned the first working one with level:'autoSelect'. So a single proxy added to the registry silently became a global fallback for every connection's traffic. Gate it behind a new PROXY_AUTO_SELECT_ENABLED feature flag (default off): the fallback now no-ops unless the operator opts in. No registry proxy becomes a silent global default anymore. Co-authored-by: hertznsk <hertznsk@users.noreply.github.com> * fix(sse): treat MiniMax M3 as multimodal so vision isn't stripped (#3328) (#3342) MiniMax M3 via the opencode provider (oc/minimax-m3-free) appeared blind: image inputs didn't reach the model, while the same model in Cline could see them. Verified empirically that MiniMax M3 on the opencode upstream IS multimodal -- a base64 image is described correctly (it returns 403 only for remote image URLs, which it doesn't accept). Root cause: OmniRoute treated MiniMax M3 as a non-vision model in two places, so when compression was active the image was replaced with a text placeholder before dispatch: - compression's modelSupportsVision() heuristic (lite.ts) only matched gpt-4/4o/claude-3/gemini/vision -- minimax was absent -> replaceImageUrls stripped the image. - the opencode minimax-m3-free catalog entry lacked supportsVision, so the combo vision-capability gate could also exclude/mishandle it. Add 'minimax-m3' to the vision heuristic and supportsVision: true to the opencode minimax-m3-free entry. TDD: a failing-then-passing test in compression/lite.test.ts proves replaceImageUrls now keeps images for minimax-m3 ids, plus a registry assertion mirroring the #2822 qwen test. Reported-by: @mikmaneggahommie * docs(i18n): translate 25 core documentation files to Indonesian (#3348) Integrated into release/v3.8.14 — Indonesian i18n docs. * fix(review): resolve /review-reviews battery findings (LEDGER-1..11) on v3.8.14 (#3350) Integrated into release/v3.8.14 — /review-reviews battery hardening (LEDGER-1..11) for #3338 custom-headers + #3333 kiro, plus cycle-test drift fixes (#3329/#3330/#3332). * fix(provider-proxy): honor per-account proxy toggles (#3349) Integrated into release/v3.8.14 — honor per-account proxy toggles + auto-fallback opt-in via PROXY_AUTO_SELECT_ENABLED. * fix(dashboard): remove duplicate Distribute Proxies button on provider page (#3352) * fix(providers): reduce proxy label noise (#3346) Integrated into release/v3.8.14 — reduce proxy label noise + a11y (aria-label/sr-only). * fix(duckduckgo): restore bare Response contract and rebase onto release/v3.8.14 (#3323) Integrated into release/v3.8.14 — browser-backed cookie providers (duckduckgo/claude-web) with restored executor contract + unit tests. * fix(noauth): expose only usable model aliases (#3345) Integrated into release/v3.8.14 — noauth usable-alias filtering + registry alias plumbing (veo-free). * fix(dashboard): stop infinite config-load loop on Hermes Agent detail page (#3353) * fix(electron): tree-kill the server on exit/update to release the omniroute.exe lock (#3347) (#3354) * chore(release): finalize v3.8.14 changelog + clear release-gate drift - CHANGELOG: finalize the v3.8.14 section (date, full New Features/Bug Fixes/ Maintenance coverage of all 16 cycle commits, Contributors hall of 12). - docs: document OMNIROUTE_BROWSER_POOL + WEB_COOKIE_USE_BROWSER (#3323) in .env.example + ENVIRONMENT.md; regenerate the id/llm.txt strict mirror (#3348 had translated it; llm.txt mirrors must match root). - test(proxy-fetch): #3323 made tlsClient.available a computed getter — stub it via Object.defineProperty instead of assignment (5 tests were red on the base). * fix(translator): coerce Gemini functionDeclaration parameters to an OBJECT schema (#3357) (#3360) * fix(gemini): resolve truncation/suppression of false positive textual tool call markers in backticks (#3358) Integrated into release/v3.8.14 — Gemini/Antigravity textual tool-call marker normalization (no false-positive suppression + split-chunk buffering). * docs(changelog): add #3358 Gemini textual tool-call normalization to v3.8.14 * fix(dashboard): surface real analytics error instead of generic placeholder (#3356) (#3361) The Analytics page discarded the server's error body on a non-OK response and rendered a generic "An error occurred", so users (and maintainers) could not see why /api/usage/analytics 500'd after an upgrade. Now the route returns the real reason via buildErrorBody (sanitized, Hard Rule #12) and the page surfaces it via a new readFetchErrorMessage helper that handles both the OpenAI-style and legacy error shapes. Reported-by: @superti4r --------- Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com> Co-authored-by: Someres <168349709+quanturbo@users.noreply.github.com> Co-authored-by: Dong Mengzhe <154944819+Lang-Qiu@users.noreply.github.com> Co-authored-by: mikmaneggahommie <mikmaneggahommie@users.noreply.github.com> Co-authored-by: abdulkadirozyurt <abdulkadirozyurt@users.noreply.github.com> Co-authored-by: hertznsk <hertznsk@users.noreply.github.com> Co-authored-by: Krisna Santosa <54174372+KrisnaSantosa15@users.noreply.github.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Wilson <pedbookmed@gmail.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Ardem2025 <ardemb22@gmail.com>
850 lines
26 KiB
TypeScript
850 lines
26 KiB
TypeScript
/**
|
|
* browserBackedChat.ts — Provider-agnostic browser-backed chat helper.
|
|
*
|
|
* Opens a page on a shared browser context, navigates to the provider's
|
|
* chat page, types the user's message, clicks Send, and returns the
|
|
* upstream SSE/JSON response body as a Node Response.
|
|
*
|
|
* Used by duckduckgo-web and claude-web executors when
|
|
* OMNIROUTE_BROWSER_POOL=on (or WEB_COOKIE_USE_BROWSER=1) is set and
|
|
* the user wants guaranteed live working from this environment, even at
|
|
* the cost of 5-15s of browser navigation overhead per request.
|
|
*
|
|
* The browser solves the provider's challenge natively (VQD, Cloudflare
|
|
* Turnstile, etc.) by computing real DOM measurement values. The
|
|
* Node-side challenge solver in duckduckgo-web.ts still runs as a
|
|
* first-line best-effort; this module is the fallback.
|
|
*/
|
|
|
|
import { Buffer } from "node:buffer";
|
|
import {
|
|
acquireBrowserContext,
|
|
openPage,
|
|
readPageResponseBody,
|
|
shutdownPool,
|
|
type PooledContext,
|
|
} from "./browserPool.ts";
|
|
import tlsClient from "../utils/tlsClient.ts";
|
|
import { sanitizeErrorMessage } from "../utils/error.ts";
|
|
|
|
// Safety constants
|
|
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; // Cache fresh cookies for 5 minutes
|
|
const COOKIE_POLL_INTERVAL_MS = 500; // Poll for cookies every 500ms
|
|
const COOKIE_POLL_TIMEOUT_MS = 5000; // Max poll time for cookies
|
|
const CIRCUIT_BASE_COOLDOWN_MS = 30_000; // 30s base cooldown
|
|
const CIRCUIT_MAX_COOLDOWN_MS = 600_000; // 10 min max cooldown
|
|
|
|
// Cookie cache — avoids repeated browser launches when cookies are still valid
|
|
interface CachedCookies {
|
|
cookieString: string;
|
|
expiresAt: number;
|
|
domain: string;
|
|
}
|
|
const cookieCache = new Map<string, CachedCookies>();
|
|
|
|
function getCachedCookies(domain: string): string | null {
|
|
const cached = cookieCache.get(domain);
|
|
if (cached && Date.now() < cached.expiresAt) return cached.cookieString;
|
|
cookieCache.delete(domain);
|
|
return null;
|
|
}
|
|
|
|
function setCachedCookies(domain: string, cookieString: string, ttlMs?: number): void {
|
|
cookieCache.set(domain, {
|
|
cookieString,
|
|
expiresAt: Date.now() + (ttlMs ?? COOKIE_CACHE_TTL_MS),
|
|
domain,
|
|
});
|
|
}
|
|
|
|
// Dedup pending cookie refreshes per pool key
|
|
const pendingRefreshes = new Map<string, Promise<string | null>>();
|
|
|
|
// Test-only injection point. Tests call __setBrowserBackedChatOverrideForTesting()
|
|
// to replace the real browser-backed chat with a mock; production never touches this.
|
|
let testOverride: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null =
|
|
null;
|
|
|
|
let httpOverride: ((req: BrowserBackedChatRequest) => Promise<BrowserBackedChatResult>) | null =
|
|
null;
|
|
|
|
export function __setBrowserBackedChatOverrideForTesting(fn: typeof testOverride): void {
|
|
testOverride = fn;
|
|
}
|
|
|
|
export function __resetBrowserBackedChatOverrideForTesting(): void {
|
|
testOverride = null;
|
|
cookieCache.clear();
|
|
}
|
|
|
|
export function __setHttpBackedChatOverrideForTesting(fn: typeof httpOverride): void {
|
|
httpOverride = fn;
|
|
}
|
|
|
|
export function __resetHttpBackedChatOverrideForTesting(): void {
|
|
httpOverride = null;
|
|
cookieCache.clear();
|
|
}
|
|
|
|
// Helper to make Playwright waitForTimeout abortable via AbortSignal
|
|
function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
|
|
const onAbort = () => {
|
|
clearTimeout(timer);
|
|
reject(new DOMException("Aborted", "AbortError"));
|
|
};
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
resolve();
|
|
}, ms);
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
}).catch((err) => {
|
|
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
|
});
|
|
}
|
|
|
|
export interface BrowserBackedChatRequest {
|
|
/**
|
|
* Pool key — typically a provider id like "duckduckgo-web" or
|
|
* "claude-web", optionally suffixed by user/account id if cookies
|
|
* differ.
|
|
*/
|
|
poolKey: string;
|
|
/**
|
|
* Chat URL the page should submit to. The page's `fetch` will hit
|
|
* this URL when the user clicks Send, and we capture the response.
|
|
*/
|
|
chatUrl: string;
|
|
/**
|
|
* Chat page URL to navigate to before typing. The page must already
|
|
* have its chat UI rendered for the input/button selectors to work.
|
|
*/
|
|
chatPageUrl: string;
|
|
/**
|
|
* The text the user wants to send. Combined with the model message
|
|
* prefix (e.g. "Reply with exactly: ...") so the user message is the
|
|
* literal text typed into the chat box.
|
|
*/
|
|
userMessage: string;
|
|
/**
|
|
* Cookie string (raw) to inject into the browser context. Used by
|
|
* Claude web (cookies from `docs/CLAUDE_COOKIE.md` or similar).
|
|
* For DDG this is empty — the browser is anonymous.
|
|
*/
|
|
cookieString?: string | null;
|
|
/**
|
|
* Cookie domain. Used together with cookieString.
|
|
*/
|
|
cookieDomain?: string;
|
|
/**
|
|
* Domain for the page's `fetch` to identify which path on the
|
|
* upstream is the chat endpoint. e.g. "duckduckgo.com" for DDG,
|
|
* "claude.ai" for Claude.
|
|
*/
|
|
chatUrlMatchDomain: string;
|
|
/**
|
|
* User-Agent string for the browser context.
|
|
*/
|
|
userAgent?: string;
|
|
/**
|
|
* Locale (BCP 47). Defaults to en-US.
|
|
*/
|
|
locale?: string;
|
|
/**
|
|
* IANA timezone. Defaults to America/New_York.
|
|
*/
|
|
timezone?: string;
|
|
/**
|
|
* Selector for the chat input. DDG uses `textarea` with the "Ask
|
|
* anything privately" placeholder; Claude uses a contenteditable
|
|
* div. Override per provider.
|
|
*/
|
|
inputSelector: string;
|
|
/**
|
|
* Selector for the submit button. If the page exposes one, click
|
|
* it. Otherwise the helper falls back to pressing Enter in the
|
|
* input.
|
|
*/
|
|
submitButtonSelector?: string;
|
|
/**
|
|
* Wait after submit for SSE/JSON to arrive. Default 15 seconds.
|
|
*/
|
|
postSubmitWaitMs?: number;
|
|
/**
|
|
* Optional AbortSignal. Cancels navigation/submit.
|
|
*/
|
|
signal?: AbortSignal | null;
|
|
/**
|
|
* Reuse the same context across requests when true. When false, a
|
|
* fresh context is opened each time (slower but bypasses
|
|
* per-context rate limits). Default true.
|
|
*/
|
|
reuseContext?: boolean;
|
|
}
|
|
|
|
export interface BrowserBackedChatResult {
|
|
status: number;
|
|
contentType: string | null;
|
|
body: Buffer;
|
|
isStealth: boolean;
|
|
timing: {
|
|
acquireContextMs: number;
|
|
navigateMs: number;
|
|
submitMs: number;
|
|
captureResponseMs: number;
|
|
totalMs: number;
|
|
};
|
|
}
|
|
|
|
async function settlePoolKey(
|
|
requestedKey: string,
|
|
reuseContext: boolean
|
|
): Promise<{ key: string; acquired: boolean }> {
|
|
if (reuseContext) return { key: requestedKey, acquired: true };
|
|
// Use a unique key per non-reuse call so the pool always creates a
|
|
// fresh context. Slower but isolates state.
|
|
return {
|
|
key: `${requestedKey}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
acquired: false,
|
|
};
|
|
}
|
|
|
|
// Match by stable path prefix and stable trailing suffix, allowing a
|
|
// dynamic id segment between them. e.g. for Claude:
|
|
// configured: "/api/organizations/{orgId}/chat_conversations/PLACEHOLDER/completion"
|
|
// observed: "/api/organizations/{orgId}/chat_conversations/{convId}/completion"
|
|
// -> prefix "/api/organizations/{orgId}/chat_conversations" must match,
|
|
// suffix "completion" must match, dynamic segment in between is
|
|
// ignored.
|
|
export function chatUrlMatcher(u: string, matchDomain: string, chatUrl: string): boolean {
|
|
if (u === chatUrl) return true;
|
|
let parsed: URL;
|
|
let chatParsed: URL;
|
|
try {
|
|
parsed = new URL(u);
|
|
chatParsed = new URL(chatUrl);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!parsed.host.endsWith(matchDomain)) return false;
|
|
const chatSeg = chatParsed.pathname.split("/").filter(Boolean);
|
|
const reqSeg = parsed.pathname.split("/").filter(Boolean);
|
|
if (chatSeg.length < 2 || reqSeg.length !== chatSeg.length) return false;
|
|
// All segments except the PLACEHOLDER segment must match.
|
|
let allowedDynamic = 1;
|
|
for (let i = 0; i < chatSeg.length; i++) {
|
|
if (chatSeg[i] === reqSeg[i]) continue;
|
|
if (chatSeg[i] === "PLACEHOLDER" && allowedDynamic > 0) {
|
|
allowedDynamic--;
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export async function browserBackedChat(
|
|
req: BrowserBackedChatRequest
|
|
): Promise<BrowserBackedChatResult> {
|
|
if (testOverride) return testOverride(req);
|
|
const t0 = Date.now();
|
|
const {
|
|
poolKey,
|
|
chatUrl,
|
|
chatPageUrl,
|
|
userMessage,
|
|
cookieString,
|
|
cookieDomain,
|
|
chatUrlMatchDomain,
|
|
userAgent,
|
|
locale,
|
|
timezone,
|
|
inputSelector,
|
|
submitButtonSelector,
|
|
postSubmitWaitMs = 15000,
|
|
signal,
|
|
reuseContext = true,
|
|
} = req;
|
|
|
|
const { key, acquired: reuseAcquired } = await settlePoolKey(poolKey, reuseContext);
|
|
const tAcquireStart = Date.now();
|
|
const pooled: PooledContext = await acquireBrowserContext(key, {
|
|
cookieDomain: cookieDomain || chatUrlMatchDomain,
|
|
cookieString: cookieString || null,
|
|
warmupUrl: chatPageUrl,
|
|
userAgent,
|
|
locale,
|
|
timezone,
|
|
});
|
|
const acquireContextMs = Date.now() - tAcquireStart;
|
|
|
|
const page = await openPage(pooled);
|
|
try {
|
|
const tNavStart = Date.now();
|
|
await page.goto(chatPageUrl, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 60000,
|
|
signal: signal ?? undefined,
|
|
});
|
|
await waitWithSignal(2500, signal);
|
|
const navigateMs = Date.now() - tNavStart;
|
|
|
|
const inputLocator = page.locator(inputSelector).first();
|
|
await inputLocator.waitFor({ state: "visible", timeout: 10000, signal: signal ?? undefined });
|
|
await inputLocator.fill(userMessage);
|
|
await waitWithSignal(800, signal);
|
|
|
|
const tSubmitStart = Date.now();
|
|
const responsePromise = page.waitForResponse(
|
|
(r) =>
|
|
r.request().method() === "POST" && chatUrlMatcher(r.url(), chatUrlMatchDomain, chatUrl),
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
// Wire signal to responsePromise via Promise.race
|
|
let abortListener: (() => void) | undefined;
|
|
const signalPromise = signal
|
|
? new Promise<never>((_, reject) => {
|
|
if (signal.aborted) return reject(new DOMException("Aborted", "AbortError"));
|
|
abortListener = () => reject(new DOMException("Aborted", "AbortError"));
|
|
signal.addEventListener("abort", abortListener, { once: true });
|
|
})
|
|
: null;
|
|
|
|
if (submitButtonSelector) {
|
|
const btn = page.locator(submitButtonSelector).first();
|
|
if ((await btn.count()) > 0) {
|
|
try {
|
|
await btn.click({ timeout: 2000 });
|
|
} catch {
|
|
await page.keyboard.press("Enter");
|
|
}
|
|
} else {
|
|
await page.keyboard.press("Enter");
|
|
}
|
|
} else {
|
|
await page.keyboard.press("Enter");
|
|
}
|
|
const tCaptureStart = Date.now();
|
|
const response = signalPromise
|
|
? await Promise.race([responsePromise, signalPromise]).catch(() => null)
|
|
: await responsePromise.catch(() => null);
|
|
if (signal && abortListener) {
|
|
signal.removeEventListener("abort", abortListener);
|
|
}
|
|
if (response) {
|
|
// Wait for the upstream SSE to finish streaming
|
|
await waitWithSignal(Math.min(postSubmitWaitMs, 30000), signal);
|
|
} else {
|
|
await waitWithSignal(postSubmitWaitMs, signal);
|
|
}
|
|
const captureResponseMs = Date.now() - tCaptureStart;
|
|
const submitMs = captureResponseMs;
|
|
|
|
let status = 0;
|
|
let contentType: string | null = null;
|
|
let body = Buffer.alloc(0);
|
|
if (response) {
|
|
const captured = await readPageResponseBody(response);
|
|
// OOM guard: reject responses larger than MAX_RESPONSE_BYTES
|
|
if (captured.body.length > MAX_RESPONSE_BYTES) {
|
|
body = Buffer.from(
|
|
JSON.stringify({
|
|
error: {
|
|
message: "Response too large",
|
|
type: "upstream_error",
|
|
},
|
|
})
|
|
);
|
|
status = 502;
|
|
contentType = "application/json";
|
|
} else {
|
|
status = captured.status;
|
|
contentType = captured.headers["content-type"] || null;
|
|
body = captured.body;
|
|
}
|
|
}
|
|
|
|
return {
|
|
status,
|
|
contentType,
|
|
body,
|
|
isStealth: pooled.isStealth,
|
|
timing: {
|
|
acquireContextMs,
|
|
navigateMs,
|
|
submitMs,
|
|
captureResponseMs,
|
|
totalMs: Date.now() - t0,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
// Emit a structured JSON error so the executor can wrap it.
|
|
const body = Buffer.from(
|
|
JSON.stringify({
|
|
error: {
|
|
message: sanitizeErrorMessage(`browserBackedChat failed: ${msg}`),
|
|
type: "upstream_error",
|
|
},
|
|
})
|
|
);
|
|
return {
|
|
status: 502,
|
|
contentType: "application/json",
|
|
body,
|
|
isStealth: pooled.isStealth,
|
|
timing: {
|
|
acquireContextMs,
|
|
navigateMs: 0,
|
|
submitMs: 0,
|
|
captureResponseMs: 0,
|
|
totalMs: Date.now() - t0,
|
|
},
|
|
};
|
|
} finally {
|
|
await page.close();
|
|
if (!reuseAcquired) {
|
|
// Non-reused contexts are uniquely keyed. Close the page's context
|
|
// so we don't leak Chromium resources for one-shot calls.
|
|
try {
|
|
await pooled.context.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* httpBackedChat — Lightweight HTTP-backed alternative to browserBackedChat.
|
|
*
|
|
* Same interface, zero browser overhead. Uses tlsClient (Chrome 124 TLS
|
|
* fingerprint) to make direct HTTP POST requests to the provider's chat
|
|
* endpoint with browser-emulated headers.
|
|
*
|
|
* ~0.5-2s per request vs 10-25s for Playwright. The trade-off: the HTTP
|
|
* path may be blocked by advanced anti-bot challenges (VQD, Turnstile)
|
|
* that only a real browser can solve. When httpBackedChat fails, callers
|
|
* should fall back to browserBackedChat.
|
|
*
|
|
* Supported providers:
|
|
* - duckduckgo-web: POST to duckduckgo.com/duckchat/v1/chat
|
|
* - claude-web: POST to claude.ai API completion endpoint
|
|
*/
|
|
export async function httpBackedChat(
|
|
req: BrowserBackedChatRequest
|
|
): Promise<BrowserBackedChatResult> {
|
|
if (httpOverride) return httpOverride(req);
|
|
const t0 = Date.now();
|
|
|
|
const { chatUrl, userMessage, cookieString, cookieDomain, chatUrlMatchDomain, signal } = req;
|
|
|
|
// Build browser-emulated headers
|
|
const headers: Record<string, string> = {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
Accept: "text/event-stream, application/json, text/plain, */*",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
"Content-Type": "application/json",
|
|
Origin:
|
|
chatUrlMatchDomain === "duckduckgo.com"
|
|
? "https://duckduckgo.com"
|
|
: `https://${chatUrlMatchDomain}`,
|
|
Referer:
|
|
chatUrlMatchDomain === "duckduckgo.com"
|
|
? "https://duckduckgo.com/"
|
|
: `https://${chatUrlMatchDomain}/`,
|
|
"Sec-Fetch-Dest": "empty",
|
|
"Sec-Fetch-Mode": "cors",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
"Sec-Ch-Ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
|
|
"Sec-Ch-Ua-Mobile": "?0",
|
|
"Sec-Ch-Ua-Platform": '"macOS"',
|
|
Priority: "u=1, i",
|
|
};
|
|
|
|
// Inject cookie if provided
|
|
if (cookieString) {
|
|
headers["Cookie"] = cookieString;
|
|
}
|
|
|
|
// Build provider-specific request body
|
|
let body: string;
|
|
const parsedUrl = new URL(chatUrl);
|
|
if (parsedUrl.hostname.includes("duckduckgo")) {
|
|
body = JSON.stringify({
|
|
model: "gpt-4o-mini",
|
|
messages: [{ role: "user", content: userMessage }],
|
|
});
|
|
} else {
|
|
// Default: send as OpenAI-style or as raw text based on endpoint
|
|
body = JSON.stringify({
|
|
messages: [{ role: "user", content: userMessage }],
|
|
});
|
|
}
|
|
|
|
try {
|
|
const fetchStart = Date.now();
|
|
|
|
if (!tlsClient.available) {
|
|
return {
|
|
status: 501,
|
|
contentType: "application/json",
|
|
body: Buffer.from(
|
|
JSON.stringify({
|
|
error: {
|
|
message: "httpBackedChat unavailable: wreq-js (TLS client) not installed",
|
|
type: "configuration_error",
|
|
},
|
|
})
|
|
),
|
|
isStealth: false,
|
|
timing: {
|
|
acquireContextMs: 0,
|
|
navigateMs: 0,
|
|
submitMs: Date.now() - t0,
|
|
captureResponseMs: 0,
|
|
totalMs: Date.now() - t0,
|
|
},
|
|
};
|
|
}
|
|
|
|
const response = await tlsClient.fetch(chatUrl, {
|
|
method: "POST",
|
|
headers,
|
|
body,
|
|
signal: signal ?? undefined,
|
|
});
|
|
|
|
const fetchMs = Date.now() - fetchStart;
|
|
|
|
// OOM guard: check content-length before reading body
|
|
const contentLengthHeader = response.headers.get("content-length");
|
|
if (contentLengthHeader) {
|
|
const contentLength = parseInt(contentLengthHeader, 10);
|
|
if (contentLength > MAX_RESPONSE_BYTES) {
|
|
throw new Error("Response too large");
|
|
}
|
|
}
|
|
|
|
const responseBody = Buffer.from(await response.text());
|
|
const responseStatus = response.status;
|
|
const contentType = response.headers.get("content-type") || "text/event-stream";
|
|
|
|
return {
|
|
status: responseStatus,
|
|
contentType,
|
|
body: responseBody,
|
|
isStealth: true,
|
|
timing: {
|
|
acquireContextMs: 0,
|
|
navigateMs: 0,
|
|
submitMs: fetchMs,
|
|
captureResponseMs: 0,
|
|
totalMs: Date.now() - t0,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
// Let AbortError propagate — tryBackedChat handles it, returns 504
|
|
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
const body = Buffer.from(
|
|
JSON.stringify({
|
|
error: {
|
|
message: sanitizeErrorMessage(`httpBackedChat failed: ${msg}`),
|
|
type: "upstream_error",
|
|
},
|
|
})
|
|
);
|
|
return {
|
|
status: 502,
|
|
contentType: "application/json",
|
|
body,
|
|
isStealth: true,
|
|
timing: {
|
|
acquireContextMs: 0,
|
|
navigateMs: 0,
|
|
submitMs: 0,
|
|
captureResponseMs: 0,
|
|
totalMs: Date.now() - t0,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* waitForCookiesWithPolling — Poll for cookies every 500ms up to 5s.
|
|
* Returns as soon as challenge cookies appear, instead of always
|
|
* waiting the full timeout. Saves 1-4s when anti-bot resolves quickly.
|
|
*/
|
|
async function waitForCookiesWithPolling(
|
|
context: import("playwright").BrowserContext,
|
|
cookieDomain: string,
|
|
signal: AbortSignal | null
|
|
): Promise<string | null> {
|
|
const deadline = Date.now() + COOKIE_POLL_TIMEOUT_MS;
|
|
while (Date.now() < deadline) {
|
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
const cookies = await context.cookies(cookieDomain);
|
|
const cookieString = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
if (cookieString) return cookieString;
|
|
const remaining = deadline - Date.now();
|
|
if (remaining <= 0) break;
|
|
await waitWithSignal(Math.min(COOKIE_POLL_INTERVAL_MS, remaining), signal);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* doCookieRefreshOnContext — Run cookie extraction on an already-acquired
|
|
* browser context. Opens a temporary page, navigates to the chat URL,
|
|
* polls for cookies, and returns the result.
|
|
*/
|
|
async function doCookieRefreshOnContext(
|
|
pooled: import("./browserPool.ts").PooledContext,
|
|
chatPageUrl: string,
|
|
cookieDomain: string,
|
|
signal: AbortSignal | null
|
|
): Promise<string | null> {
|
|
const page = await openPage(pooled);
|
|
try {
|
|
await page.goto(chatPageUrl, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 60000,
|
|
signal: signal ?? undefined,
|
|
});
|
|
return await waitForCookiesWithPolling(pooled.context, cookieDomain, signal);
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
|
return null;
|
|
} finally {
|
|
await page.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* refreshCookiesViaBrowser — Launch a stealth browser to solve the provider's
|
|
* anti-bot challenge and extract fresh session cookies.
|
|
*
|
|
* Features:
|
|
* - Cookie cache: skip browser launch if we have fresh cached cookies
|
|
* - Dedup: concurrent calls for the same poolKey share one browser launch
|
|
* - Polling: returns as soon as cookies appear (avg 1-3s vs fixed 5s)
|
|
*/
|
|
async function refreshCookiesViaBrowser(
|
|
poolKey: string,
|
|
chatPageUrl: string,
|
|
cookieDomain: string,
|
|
signal: AbortSignal | null
|
|
): Promise<string | null> {
|
|
if (httpOverride !== null) return null;
|
|
|
|
// Check cookie cache first — avoids browser launch entirely
|
|
const cached = getCachedCookies(cookieDomain);
|
|
if (cached) return cached;
|
|
|
|
// Dedup concurrent refreshes for the same pool key
|
|
const pending = pendingRefreshes.get(poolKey);
|
|
if (pending) return pending;
|
|
|
|
const promise = doRefresh(poolKey, chatPageUrl, cookieDomain, signal);
|
|
pendingRefreshes.set(poolKey, promise);
|
|
promise.finally(() => pendingRefreshes.delete(poolKey));
|
|
return promise;
|
|
}
|
|
|
|
async function doRefresh(
|
|
poolKey: string,
|
|
chatPageUrl: string,
|
|
cookieDomain: string,
|
|
signal: AbortSignal | null
|
|
): Promise<string | null> {
|
|
const { key } = await settlePoolKey(poolKey, true);
|
|
let pooled: import("./browserPool.ts").PooledContext;
|
|
try {
|
|
pooled = await acquireBrowserContext(key, {
|
|
cookieDomain,
|
|
cookieString: null,
|
|
warmupUrl: chatPageUrl,
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const result = await doCookieRefreshOnContext(pooled, chatPageUrl, cookieDomain, signal);
|
|
|
|
// Cache for subsequent calls
|
|
if (result) setCachedCookies(cookieDomain, result);
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* startBrowserWarmup — Start acquireBrowserContext in parallel with
|
|
* httpBackedChat. If httpBackedChat succeeds, the context stays in the
|
|
* pool for the next request (saves ~2-3s on the first challenge hit).
|
|
* If a challenge is detected, the browser is already partially ready.
|
|
*/
|
|
async function startBrowserWarmup(
|
|
req: BrowserBackedChatRequest
|
|
): Promise<import("./browserPool.ts").PooledContext | null> {
|
|
if (!req.cookieDomain || httpOverride !== null) return null;
|
|
const flag = process.env.OMNIROUTE_BROWSER_POOL;
|
|
if (flag === "off" || flag === "0" || flag === "false") return null;
|
|
try {
|
|
const { key } = await settlePoolKey(req.poolKey, true);
|
|
return await acquireBrowserContext(key, {
|
|
cookieDomain: req.cookieDomain,
|
|
cookieString: null,
|
|
// No warmupUrl — if httpBackedChat succeeds, the 1.5s warmup wait
|
|
// would be wasted. Navigating fresh in doCookieRefreshOnContext
|
|
// is fast once the browser context already exists.
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* getFreshCookiesWithWarmup — Try the pre-warmed context first, then
|
|
* fall through to refreshCookiesViaBrowser if unavailable.
|
|
*/
|
|
async function getFreshCookiesWithWarmup(
|
|
poolKey: string,
|
|
chatPageUrl: string,
|
|
cookieDomain: string,
|
|
signal: AbortSignal | null,
|
|
warmupPromise: Promise<import("./browserPool.ts").PooledContext | null> | null
|
|
): Promise<string | null> {
|
|
if (warmupPromise) {
|
|
try {
|
|
const pooled = await warmupPromise;
|
|
if (pooled) {
|
|
const result = await doCookieRefreshOnContext(pooled, chatPageUrl, cookieDomain, signal);
|
|
if (result) {
|
|
setCachedCookies(cookieDomain, result);
|
|
return result;
|
|
}
|
|
}
|
|
} catch {
|
|
// Warmup failed — fall through to fresh refresh
|
|
}
|
|
}
|
|
return refreshCookiesViaBrowser(poolKey, chatPageUrl, cookieDomain, signal);
|
|
}
|
|
|
|
function isChallengeResponse(status: number): boolean {
|
|
return status >= 400 && status !== 501;
|
|
}
|
|
|
|
/**
|
|
* tryBackedChat — Combined fast-then-slow chat executor.
|
|
*
|
|
* Strategy:
|
|
* 1. httpBackedChat (fast TLS, ~0.5-2s) + parallel browser warmup
|
|
* 2. Cookie cache check (0ms) — skip browser if cookies still fresh
|
|
* 3. refreshCookiesViaBrowser (~1-5s) with polling + dedup — Opens a
|
|
* Playwright page, polls for cookies, caches the result
|
|
* 4. httpBackedChat retry (fast, ~0.5-2s) — Retries with fresh cookies
|
|
* 5. browserBackedChat (slow, ~10-25s) — Full chat through browser
|
|
*
|
|
* Returns the first successful (2xx) response, or the last error.
|
|
* Skips browser steps when OMNIROUTE_BROWSER_POOL=off.
|
|
*/
|
|
export async function tryBackedChat(
|
|
req: BrowserBackedChatRequest
|
|
): Promise<BrowserBackedChatResult> {
|
|
const abortController = req.signal ? null : new AbortController();
|
|
const effectiveSignal = req.signal ?? abortController?.signal ?? null;
|
|
|
|
if (abortController) {
|
|
setTimeout(() => abortController.abort(), 45000);
|
|
}
|
|
|
|
// Parallel browser warmup: start acquireBrowserContext while
|
|
// httpBackedChat is in flight. If httpBackedChat succeeds, the
|
|
// warmup context stays in the pool for the next request. If it
|
|
// fails with a challenge, the browser is already partially ready,
|
|
// saving ~2-3s on the cookie refresh path.
|
|
const warmupPromise = startBrowserWarmup(req);
|
|
|
|
try {
|
|
const fast = await httpBackedChat({ ...req, signal: effectiveSignal ?? undefined });
|
|
if (fast.status >= 200 && fast.status < 300) return fast;
|
|
|
|
if (!isChallengeResponse(fast.status)) return fast;
|
|
|
|
let freshCookie: string | null = null;
|
|
if (req.cookieDomain) {
|
|
// Cookie cache check — skips browser launch on repeat challenges
|
|
freshCookie = getCachedCookies(req.cookieDomain);
|
|
if (freshCookie) {
|
|
const retry = await httpBackedChat({
|
|
...req,
|
|
cookieString: freshCookie,
|
|
signal: effectiveSignal ?? undefined,
|
|
});
|
|
if (retry.status >= 200 && retry.status < 300) return retry;
|
|
// Cache is stale — fall through to fresh browser refresh
|
|
freshCookie = null;
|
|
}
|
|
|
|
if (!freshCookie) {
|
|
// Use pre-warmed context if available, otherwise fresh refresh
|
|
freshCookie = await getFreshCookiesWithWarmup(
|
|
req.poolKey,
|
|
req.chatPageUrl,
|
|
req.cookieDomain,
|
|
effectiveSignal,
|
|
warmupPromise
|
|
);
|
|
|
|
if (freshCookie) {
|
|
const retry = await httpBackedChat({
|
|
...req,
|
|
cookieString: freshCookie,
|
|
signal: effectiveSignal ?? undefined,
|
|
});
|
|
if (retry.status >= 200 && retry.status < 300) return retry;
|
|
}
|
|
}
|
|
}
|
|
|
|
const slowReq = freshCookie
|
|
? { ...req, cookieString: freshCookie, signal: effectiveSignal ?? undefined }
|
|
: { ...req, signal: effectiveSignal ?? undefined };
|
|
const slow = await browserBackedChat(slowReq);
|
|
if (slow.status >= 200 && slow.status < 300) return slow;
|
|
return slow;
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
return {
|
|
status: 504,
|
|
contentType: "application/json",
|
|
body: Buffer.from(
|
|
JSON.stringify({
|
|
error: {
|
|
message: "tryBackedChat timed out",
|
|
type: "timeout_error",
|
|
},
|
|
})
|
|
),
|
|
isStealth: false,
|
|
timing: {
|
|
acquireContextMs: 0,
|
|
navigateMs: 0,
|
|
submitMs: 0,
|
|
captureResponseMs: 0,
|
|
totalMs: 0,
|
|
},
|
|
};
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export { shutdownPool };
|