mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
* chore(release): open v3.8.18 development cycle * fix(catalog): stop Codex CLI model-catalog refresh from erroring (#3481) Codex's model-catalog refresh (codex_models_manager) does GET /v1/models?client_version=<v> and decodes a JSON object with a TOP-LEVEL `models` array. OmniRoute answers in the OpenAI-standard `{object,data}` shape, so codex fails with "missing field `models`" and logs "failed to refresh available models" on every startup. Detect codex clients via the `originator` / `user-agent` = `codex_*` headers they send and add an EMPTY top-level `models: []` so the decode succeeds. Non-codex OpenAI clients keep the byte-identical `{object,data}` response. The array is intentionally empty: codex replaces its built-in per-model agent prompt (`base_instructions`, ~21k chars) with whatever a populated entry carries for the selected model, so emitting our catalog would drop the agent prompt to nothing and break codex's agent behaviour (verified empirically against codex 0.137). An empty list keeps codex on its built-in model info — same inference as before, minus the error. Validated end-to-end with the real handler against codex 0.137: "failed to refresh available models" → 0 occurrences, instructions preserved (built-in Codex agent prompt, not empty). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: ignore quality reports and local prompt artifacts Add generated quality gate reports, metrics files, and local setup prompt artifacts to .gitignore to prevent committing environment-specific or temporary files. * fix(provider): detect Responses API format when body has `input` but … (#3490) Integrated into release/v3.8.18 * fix(sse): normalize numeric provider ids to strings (#3451) Integrated into release/v3.8.18 * feat(browserPool): resolve Playwright proxy from proxy_registry DB (#3492) Integrated into release/v3.8.18 * fix(theoldllm): generate X-Request-Token server-side, drop Playwright (#3491) Integrated into release/v3.8.18 * feat(plugins): add lifecycle hooks and theme-manager plugin (#3473) Integrated into release/v3.8.18 * fix(combo): parallel pre-screen + circuit-breaker fast-exit for priority combos (#3169) Integrated into release/v3.8.18 * feat(ui): unifi active and finished requests into single view #1422 (#3401) Integrated into release/v3.8.18 * docs(changelog): record #3401, #3473, #3492, #3490, #3451, #3491, #3169 under v3.8.18 * feat(docs): add doc accuracy gate + refresh AGENTS.md counts (#3510) Integrated into release/v3.8.18 * fix(sse): drop empty-choices chunks without usage instead of injecting retry text (#3513) PR #3422 ('allow OpenAI usage-only empty choices chunks') reintroduced the assistant-content injection '[OmniRoute] Upstream returned an empty response. Please retry.' for empty `choices: []` chunks that carry no valid usage. Clients (Goose/opencode) feed that text back as a turn and spin in a retry loop -- the exact regression #3400 had fixed by dropping the chunk. Restore the drop behavior for the no-usage case while preserving #3422's standards-compliant forwarding of usage-only `include_usage` final chunks. Realign the mislabeled stream-utils test (it asserted the injection) and add a dedicated regression guard. Reported-by: @mochizzan Refs: #3502, #3388, #3400, #3422 * fix(authz): fall back to URL token when Authorization isn't a usable Bearer (#3504) Integrated into release/v3.8.18 * fix(playground): authenticate via session, test key policy by id (#3503) Integrated into release/v3.8.18 * docs(changelog): record #3510, #3504, #3503 under v3.8.18 * fix: llama base url normalization (#3519) * docs(changelog): reconcile v3.8.18 — add #3519, #3513, #3435-repair, gitignore chore (full commit↔changelog coverage) * fix(opencode-plugin): bound regex quantifiers in normaliseFreeLabel (polynomial-ReDoS) CodeQL js/polynomial-redos: unbounded \s* before an anchored \s*$ allowed O(n²) backtracking on attacker-influenced display names. Bounded to {0,8}/{1,8} (ample for any real label spacing). Plugin builds + 254 tests green. * fix(types): restore clean typecheck:core for v3.8.18 release gate - getPendingRequests() typed to real shape (was widened to object) → fixes unknown 'count' in the unified-requests view (#3401) - streamChunks log payload cast to its declared type (callLogs.ts) - preScreenTargets aligned to canonical IsModelAvailable signature (#3169), Promise.resolve-normalized so .catch never hits a bare boolean All 5 gates green: lint(0 err) + typecheck:core + cycles + docs-all + unit + vitest(146). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Andrey Borodulin <borodulin@gmail.com> Co-authored-by: Dmitrii Safronov <zimniy@cyberbrain.cc> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com> Co-authored-by: Markus Hartung <mail@hartmark.se> Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com>
430 lines
12 KiB
TypeScript
430 lines
12 KiB
TypeScript
/**
|
|
* browserPool.ts — Shared stealth browser pool for web-cookie providers.
|
|
*
|
|
* The DuckDuckGo VQD challenge and Claude web's Cloudflare Turnstile both
|
|
* validate values that only a real browser can produce (DOM layout
|
|
* measurements like offsetWidth/Height, getBoundingClientRect,
|
|
* getComputedStyle, iframe contentWindow probes). Plain Node fetch + a
|
|
* VM-stubs solver structurally runs the JS but cannot match those values,
|
|
* so the server rejects the request.
|
|
*
|
|
* This pool keeps one Chromium instance warm and serves "browser contexts"
|
|
* (one per provider) on demand. Each context owns one or more pages; the
|
|
* caller is expected to be polite (one page per request, close on done).
|
|
*
|
|
* The pool prefers `cloakbrowser` (npm) when available — its binary-level
|
|
* fingerprint patches (--fingerprint-timezone, --fingerprint-locale, and
|
|
* dozens more) are the only thing that gets past DuckDuckGo's anti-bot
|
|
* in this environment. Falls back to plain `playwright` if cloakbrowser
|
|
* is not installed; the fallback works for Claude web (which only needs
|
|
* valid cookies) but not for DDG's VQD challenge.
|
|
*
|
|
* Opt-in: pool only launches Chromium when an executor explicitly asks
|
|
* for a context, so users who never use the browser-backed path pay zero
|
|
* startup cost. Set OMNIROUTE_BROWSER_POOL=off to fully disable.
|
|
*/
|
|
|
|
import { Buffer } from "node:buffer";
|
|
|
|
type Browser = import("playwright").Browser;
|
|
type BrowserContext = import("playwright").BrowserContext;
|
|
type Page = import("playwright").Page;
|
|
|
|
export interface BrowserPoolContextOptions {
|
|
cookieDomain: string;
|
|
cookieString?: string | null;
|
|
warmupUrl?: string | null;
|
|
userAgent?: string;
|
|
locale?: string;
|
|
timezone?: string;
|
|
preferCloakbrowser?: boolean;
|
|
}
|
|
|
|
export interface PooledContext {
|
|
id: string;
|
|
context: BrowserContext;
|
|
warmupPage: Page | null;
|
|
lastUsed: number;
|
|
isStealth: boolean;
|
|
}
|
|
|
|
interface PoolState {
|
|
browser: Browser | null;
|
|
contexts: Map<string, PooledContext>;
|
|
pendingContexts: Map<string, Promise<PooledContext>>;
|
|
launching: Promise<Browser> | null;
|
|
lastActivity: number;
|
|
idleTimer: NodeJS.Timeout | null;
|
|
evictTimer: NodeJS.Timeout | null;
|
|
cloakLaunch: ((opts: unknown) => Promise<Browser>) | null;
|
|
cloakLaunchResolved: boolean;
|
|
}
|
|
|
|
const POOL_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
const CONTEXT_TTL_MS = 10 * 60 * 1000; // 10 min — evict stale contexts
|
|
const EVICT_INTERVAL_MS = 60 * 1000; // check every 60s
|
|
const DEFAULT_USER_AGENT =
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
|
|
|
|
const state: PoolState = {
|
|
browser: null,
|
|
contexts: new Map(),
|
|
pendingContexts: new Map(),
|
|
launching: null,
|
|
lastActivity: 0,
|
|
idleTimer: null,
|
|
evictTimer: null,
|
|
cloakLaunch: null,
|
|
cloakLaunchResolved: false,
|
|
};
|
|
|
|
function getCloakbrowserModuleId(): string {
|
|
// Keep this computed: cloakbrowser is an optional runtime enhancer, and a literal
|
|
// dynamic import with the package name makes Turbopack resolve it during route compilation.
|
|
return ["cloak", "browser"].join("");
|
|
}
|
|
|
|
async function resolveCloakLaunch(): Promise<((opts: unknown) => Promise<Browser>) | null> {
|
|
if (state.cloakLaunchResolved) return state.cloakLaunch;
|
|
state.cloakLaunchResolved = true;
|
|
try {
|
|
const mod = (await import(getCloakbrowserModuleId())) as unknown as {
|
|
launch?: (opts: unknown) => Promise<Browser>;
|
|
};
|
|
state.cloakLaunch = mod.launch ?? null;
|
|
} catch {
|
|
state.cloakLaunch = null;
|
|
}
|
|
return state.cloakLaunch;
|
|
}
|
|
|
|
function isPoolEnabled(): boolean {
|
|
const flag = process.env.OMNIROUTE_BROWSER_POOL;
|
|
if (flag === undefined) return true;
|
|
return flag !== "off" && flag !== "0" && flag !== "false";
|
|
}
|
|
|
|
function resetIdleTimer(): void {
|
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
state.idleTimer = setTimeout(() => {
|
|
void shutdownPool("idle-timeout");
|
|
}, POOL_IDLE_TIMEOUT_MS);
|
|
state.idleTimer.unref?.();
|
|
}
|
|
|
|
function evictStaleContexts(): void {
|
|
const now = Date.now();
|
|
for (const [key, pooled] of state.contexts) {
|
|
if (now - pooled.lastUsed > CONTEXT_TTL_MS) {
|
|
console.log(
|
|
"[BrowserPool] Evicted stale context:",
|
|
key,
|
|
"(idle",
|
|
((now - pooled.lastUsed) / 1000).toFixed(0) + "s)"
|
|
);
|
|
state.contexts.delete(key);
|
|
pooled.context.close().catch(() => {});
|
|
}
|
|
}
|
|
if (state.contexts.size === 0 && !state.launching) {
|
|
void shutdownPool("all-contexts-evicted");
|
|
}
|
|
}
|
|
|
|
function startEvictTimer(): void {
|
|
if (state.evictTimer) clearInterval(state.evictTimer);
|
|
state.evictTimer = setInterval(() => evictStaleContexts(), EVICT_INTERVAL_MS);
|
|
state.evictTimer.unref?.();
|
|
}
|
|
|
|
interface ProxyRecord {
|
|
type?: string;
|
|
host: string;
|
|
port: number;
|
|
username?: string | null;
|
|
password?: string | null;
|
|
}
|
|
|
|
interface ResolvePlaywrightProxyDeps {
|
|
resolveProxy?: (providerId: string) => Promise<ProxyRecord | null | undefined>;
|
|
}
|
|
|
|
// Exported for tests (deps injection avoids mock.module()).
|
|
export async function resolvePlaywrightProxy(
|
|
providerKey: string,
|
|
deps?: ResolvePlaywrightProxyDeps,
|
|
): Promise<import("playwright").LaunchOptions["proxy"] | undefined> {
|
|
try {
|
|
const resolver =
|
|
deps?.resolveProxy ??
|
|
(async (id: string) => {
|
|
const { resolveProxyForProvider } = await import("../../src/lib/db/proxies");
|
|
return resolveProxyForProvider(id);
|
|
});
|
|
const p = await resolver(providerKey);
|
|
if (!p?.host) return undefined;
|
|
const scheme = p.type === "socks5" ? "socks5" : "http";
|
|
return {
|
|
server: `${scheme}://${p.host}:${p.port}`,
|
|
...(p.username ? { username: p.username, password: p.password ?? "" } : {}),
|
|
};
|
|
} catch (err) {
|
|
console.warn("[BrowserPool] Failed to resolve proxy from DB:", err);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function launchBrowser(): Promise<Browser> {
|
|
if (state.browser) return state.browser;
|
|
if (state.launching) return state.launching;
|
|
state.launching = (async () => {
|
|
const cloakLaunch = await resolveCloakLaunch();
|
|
let browser: Browser;
|
|
if (cloakLaunch) {
|
|
browser = await cloakLaunch({
|
|
headless: true,
|
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
});
|
|
} else {
|
|
// Fallback: plain Playwright. Works for Claude web (cookie-only
|
|
// auth) but DDG's VQD challenge will detect this Chromium build.
|
|
const { chromium } = await import("playwright");
|
|
browser = await chromium.launch({
|
|
headless: true,
|
|
args: [
|
|
"--no-sandbox",
|
|
"--disable-dev-shm-usage",
|
|
"--disable-blink-features=AutomationControlled",
|
|
],
|
|
});
|
|
}
|
|
state.browser = browser;
|
|
state.launching = null;
|
|
return browser;
|
|
})();
|
|
try {
|
|
return await state.launching;
|
|
} catch (err) {
|
|
state.launching = null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function parseCookieString(
|
|
raw: string,
|
|
domain: string
|
|
): Array<{
|
|
name: string;
|
|
value: string;
|
|
domain: string;
|
|
path: string;
|
|
expires: number;
|
|
httpOnly: boolean;
|
|
secure: boolean;
|
|
sameSite: "Lax" | "Strict" | "None";
|
|
}> {
|
|
return raw
|
|
.split(";")
|
|
.map((p) => p.trim())
|
|
.filter(Boolean)
|
|
.map((pair) => {
|
|
const eq = pair.indexOf("=");
|
|
if (eq < 0) return null;
|
|
const name = pair.slice(0, eq).trim();
|
|
const value = pair.slice(eq + 1).trim();
|
|
if (!name || !value) return null;
|
|
return {
|
|
name,
|
|
value,
|
|
domain: domain.startsWith(".") ? domain : `.${domain}`,
|
|
path: "/",
|
|
expires: -1,
|
|
httpOnly: false,
|
|
secure: true,
|
|
sameSite: "Lax" as const,
|
|
};
|
|
})
|
|
.filter(Boolean) as Array<{
|
|
name: string;
|
|
value: string;
|
|
domain: string;
|
|
path: string;
|
|
expires: number;
|
|
httpOnly: boolean;
|
|
secure: boolean;
|
|
sameSite: "Lax" | "Strict" | "None";
|
|
}>;
|
|
}
|
|
|
|
export async function acquireBrowserContext(
|
|
key: string,
|
|
options: BrowserPoolContextOptions
|
|
): Promise<PooledContext> {
|
|
if (!isPoolEnabled()) {
|
|
throw new Error(
|
|
"browserPool: OMNIROUTE_BROWSER_POOL=off — context requested but pool is disabled"
|
|
);
|
|
}
|
|
const existing = state.contexts.get(key);
|
|
if (existing) {
|
|
existing.lastUsed = Date.now();
|
|
state.lastActivity = Date.now();
|
|
resetIdleTimer();
|
|
return existing;
|
|
}
|
|
|
|
// Dedup concurrent creations for the same key
|
|
const pending = state.pendingContexts.get(key);
|
|
if (pending) return pending;
|
|
|
|
const createPromise = (async (): Promise<PooledContext> => {
|
|
const [browser, proxy] = await Promise.all([launchBrowser(), resolvePlaywrightProxy(key)]);
|
|
const isStealth = state.cloakLaunch !== null;
|
|
const context = await browser.newContext({
|
|
userAgent: options.userAgent || DEFAULT_USER_AGENT,
|
|
locale: options.locale || "en-US",
|
|
timezoneId: options.timezone || "America/New_York",
|
|
viewport: { width: 1280, height: 800 },
|
|
...(proxy ? { proxy } : {}),
|
|
});
|
|
|
|
if (options.cookieString) {
|
|
const cookies = parseCookieString(options.cookieString, options.cookieDomain);
|
|
if (cookies.length > 0) {
|
|
await context.addCookies(cookies);
|
|
}
|
|
}
|
|
|
|
let warmupPage: Page | null = null;
|
|
if (options.warmupUrl) {
|
|
try {
|
|
warmupPage = await context.newPage();
|
|
await warmupPage.goto(options.warmupUrl, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30000,
|
|
});
|
|
// Give the warmup a moment for the upstream's status/auth/country
|
|
// JSON endpoints to fire. Without this, the first chat request would
|
|
// pay the warmup cost on the hot path.
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
} catch (err) {
|
|
try {
|
|
await warmupPage?.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
warmupPage = null;
|
|
void err;
|
|
}
|
|
}
|
|
|
|
// Guard: if shutdownPool() ran while we were creating this context,
|
|
// the browser we obtained is now closed. Close our temp context and
|
|
// throw so the caller knows to retry.
|
|
if (state.browser !== browser) {
|
|
await context.close().catch(() => {});
|
|
if (warmupPage) {
|
|
await warmupPage.close().catch(() => {});
|
|
}
|
|
throw new Error("Pool shut down during context creation");
|
|
}
|
|
|
|
const pooled: PooledContext = {
|
|
id: key,
|
|
context,
|
|
warmupPage,
|
|
lastUsed: Date.now(),
|
|
isStealth,
|
|
};
|
|
state.contexts.set(key, pooled);
|
|
state.lastActivity = Date.now();
|
|
resetIdleTimer();
|
|
startEvictTimer();
|
|
return pooled;
|
|
})();
|
|
|
|
state.pendingContexts.set(key, createPromise);
|
|
createPromise
|
|
.then(() => state.pendingContexts.delete(key))
|
|
.catch(() => state.pendingContexts.delete(key));
|
|
|
|
return createPromise;
|
|
}
|
|
|
|
export async function openPage(pooled: PooledContext): Promise<Page> {
|
|
return pooled.context.newPage();
|
|
}
|
|
|
|
export async function releaseBrowserContext(key: string): Promise<void> {
|
|
const pooled = state.contexts.get(key);
|
|
if (!pooled) return;
|
|
state.contexts.delete(key);
|
|
try {
|
|
await pooled.context.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (state.contexts.size === 0) {
|
|
await shutdownPool("last-context-closed");
|
|
}
|
|
}
|
|
|
|
export async function shutdownPool(reason: string): Promise<void> {
|
|
if (state.idleTimer) {
|
|
clearTimeout(state.idleTimer);
|
|
state.idleTimer = null;
|
|
}
|
|
if (state.evictTimer) {
|
|
clearInterval(state.evictTimer);
|
|
state.evictTimer = null;
|
|
}
|
|
state.pendingContexts.clear();
|
|
for (const [key, pooled] of state.contexts) {
|
|
try {
|
|
await pooled.context.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
state.contexts.delete(key);
|
|
}
|
|
if (state.browser) {
|
|
try {
|
|
await state.browser.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
state.browser = null;
|
|
}
|
|
state.lastActivity = Date.now();
|
|
// Avoid unused-parameter lint: log reason via debug if anyone hooks
|
|
// process.on('exit') and prints state.
|
|
void reason;
|
|
}
|
|
|
|
export function getBrowserPoolStatus(): {
|
|
enabled: boolean;
|
|
contexts: number;
|
|
browserRunning: boolean;
|
|
stealthAvailable: boolean;
|
|
lastActivityAgoMs: number;
|
|
} {
|
|
return {
|
|
enabled: isPoolEnabled(),
|
|
contexts: state.contexts.size,
|
|
browserRunning: state.browser !== null,
|
|
stealthAvailable: state.cloakLaunch !== null,
|
|
lastActivityAgoMs: state.lastActivity === 0 ? -1 : Date.now() - state.lastActivity,
|
|
};
|
|
}
|
|
|
|
export async function readPageResponseBody(
|
|
response: import("playwright").Response
|
|
): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
|
|
const headers: Record<string, string> = {};
|
|
for (const [name, value] of Object.entries(response.headers())) {
|
|
headers[name] = value;
|
|
}
|
|
const body = await response.body();
|
|
return { status: response.status(), headers, body: Buffer.from(body) };
|
|
}
|