Files
OmniRoute/open-sse/services/browserPool.ts
backryun bd472200d5 [v3.8.50] Fix Z.ai web browser transport and model capabilities (#8451)
* fix: complete Z.ai web browser transport

* refactor: address Z.ai review feedback

* test(zai-web): reconcile the #8014 endpoint guard with the chats/new + signed flow

Rebasing onto release/v3.8.49 pulled in #8503, which repointed CHAT_URL to
/api/v2/chat/completions and added an endpoint probe. This branch already
targets v2, so the executor conflict resolved to this branch's superset
(NEW_CHAT_URL + signature constants alongside the same v2 CHAT_URL). The two
tests needed adapting, because #8503's assertions assume the pre-rework flow:

- executor-zai-web.test.ts: the completion URL now carries the request
  signature as a query string, so an exact-equality check on the endpoint can
  never match. Assert the v2 prefix instead.
- zai-web-chat-endpoint-8014-probe.test.ts: the probe drove the executor with a
  bare cookie credential and no captcha proof, which now routes through the
  browser transport — fetch was never called and the probe captured nothing.
  Supplied a direct-path credential, and matched on pathname across all
  requests (the executor also probes the homepage for the frontend version and
  calls /api/v1/chats/new first).

The guard's intent is unchanged and slightly strengthened: it now asserts no
request reaches the stale unversioned path and that exactly one completions
request is issued, against v2.

54/54 across the zai suites; typecheck:core and eslint clean.

* fix(zai-web): surface upstream error frames instead of finishing empty

Reported on this PR: HTTP 200, `out=0`, stream "complete", no content and no
diagnosis.

Cause. HTTP-level failures are already handled — fetchUpstream turns any !ok
response into a makeErrorResult with the sanitized body. The gap is a 200 whose
SSE body carries an error payload: parseZaiFrame returns null for it,
drainSseDeltas drops it, and buildZaiStreamingBody then closes with an empty
assistant message + stop + [DONE]. The caller reads that as a successful empty
completion, so a rejected signature, an expired captcha and a stale token all
look identical — which is why this had to be diagnosed by reading code rather
than logs. Hard Rule #6.

Fix. parseZaiFrame now classifies an affirmatively error-shaped frame
(`error` at the top level or under `data`, string or {detail|message|msg}) as a
terminal delta, checked before the delta paths so it cannot fall through to the
"no usable delta" null. The stream emits it as `[Z.ai error] <message>`,
matching the mid-stream convention the other web executors already use
(zed-hosted's createErrorChunk) — the 200 is on the wire, so the status cannot
change, but the caller must not be left reading a blank success. Content
streamed before the failure is preserved. Message goes through
sanitizeErrorMessage (Rule #12).

Deliberately NOT changed: a contentless frame still parses to null. That is
live-validated behaviour, not an oversight — z.ai emits phase frames with no
delta_content, and executor-zai-web.test.ts pins it ("returns null for frames
with no usable delta"). Treating "nothing parseable arrived" as a failure would
invent policy on top of an observed protocol and risk false errors on the happy
path, so this only adds recognition of explicit error frames.

Tests (TDD, RED then GREEN): zai-web-silent-empty-repro.test.ts — 7 cases.
Error frame classified and terminal; surfaced through the stream with the
upstream's own text; surfaced after partial content without losing it; plus a
REGRESSION GUARD that contentless/phase-only frames are still skipped, and two
controls that the happy path and reasoning-only output are untouched. The guard
and controls passed before the fix; the four error cases did not.

94/94 across the zai + stream suites; typecheck:core, eslint and check:file-size
clean.

* refactor(sse): extract the zai-web transports so the complexity ratchet holds

The v3.8.49 merge-train rebaseline (#8686) set the ceiling to the tip's own
measurement, leaving zero headroom, so this branch's +5 cyclomatic / +3 cognitive
own-growth had nowhere to sit once rebased onto it.

Eight violations, all in code this branch introduces, resolved by extraction —
no behaviour change:

- `execute` (152 lines, complexity 25, cognitive 20) now delegates to
  `resolveZaiRequest()` for the four client-error rejections and to a
  `fetchViaSignedApi()` method for the CAPTCHA/signature path, so it reads as
  "validate, pick a transport, shape the response".
- `fetchThroughBrowser` (126 lines, cognitive 16) hands its image decoding to
  `resolveZaiBrowserAttachments()`, its Playwright options to
  `buildZaiBrowserChatOptions()`, and its call-log payload to
  `buildZaiBrowserAuditBody()`.
- `configureZaiBrowserEffort` (cognitive 35 — the worst of the set) repeated a
  wrap-and-relabel try/catch four times inside an if/else. `runStage`, which
  already existed one function below, is now module-scoped and reused, and the
  toggle collapses to `checked !== config.enabled` (same four cases).
- `validateWebCookieProvider` (complexity 19) moves its can-we-probe-this
  cascade into `resolveWebCookieProbe()`, which returns either a rejection or
  the URL + headers to use.
- `acquireBrowserContext`'s creation closure (complexity 17) hands cookie and
  localStorage seeding to `seedContextSession()`.

That last extraction also clears a violation that predates this branch —
`acquireBrowserContext` was already over the 80-line ceiling — so cyclomatic
lands at 2187 against a baseline of 2188.

Verified: check:complexity-ratchets green both metrics; typecheck:core clean;
ESLint clean on all four files; 85 tests across the zai-web, web-cookie
validation, browser-pool and model-test-runner suites pass.

* fix(zai-web): surface upstream errors on the non-streaming path

collectZaiNonStreaming ignored delta.error — a 200 whose SSE body carries
an error frame (rejected signature, expired captcha, stale token) came
back as a successful empty completion. Now it throws on an error frame,
matching the streaming path's [Z.ai error] convention; the caller's
existing try/catch returns makeErrorResult(502) instead of an empty 200.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: backryun <busan011@ormbiz.co.kr>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-12 08:41:03 -03:00

542 lines
16 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 caller-defined isolation key) 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;
localStorage?: Record<string, string>;
localStorageOrigin?: string;
warmupUrl?: string | null;
userAgent?: string;
locale?: string;
timezone?: string;
preferCloakbrowser?: boolean;
proxyProviderKey?: string;
}
export interface PooledContext {
id: string;
context: BrowserContext;
warmupPage: Page | null;
lastUsed: number;
isStealth: boolean;
}
// #3368 PR7 — lightweight, cumulative browser-pool telemetry. Counters are
// incremented at lifecycle points and surfaced via getBrowserPoolMetrics()
// (and the omniroute_browser_pool_status MCP tool), giving the previously
// caller-less getBrowserPoolStatus() an observability home.
export interface BrowserPoolMetrics {
browserLaunches: number;
browserLaunchFailures: number;
contextsCreated: number;
contextsReused: number;
contextsEvicted: number;
contextsReleased: number;
contextCreateFailures: number;
shutdowns: number;
lastShutdownReason: string | null;
}
function createBrowserPoolMetrics(): BrowserPoolMetrics {
return {
browserLaunches: 0,
browserLaunchFailures: 0,
contextsCreated: 0,
contextsReused: 0,
contextsEvicted: 0,
contextsReleased: 0,
contextCreateFailures: 0,
shutdowns: 0,
lastShutdownReason: null,
};
}
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;
metrics: BrowserPoolMetrics;
}
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/149.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,
metrics: createBrowserPoolMetrics(),
};
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",
"(idle",
((now - pooled.lastUsed) / 1000).toFixed(0) + "s)"
);
state.contexts.delete(key);
state.metrics.contextsEvicted++;
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";
// Build explicitly instead of a conditional object spread: the spread form
// widens username/password to `{}` under the LaunchOptions["proxy"] type,
// tripping typecheck once browserPool.ts is pulled into typecheck-core scope.
const proxy: NonNullable<import("playwright").LaunchOptions["proxy"]> = {
server: `${scheme}://${p.host}:${p.port}`,
};
if (p.username) {
proxy.username = String(p.username);
proxy.password = p.password == null ? "" : String(p.password);
}
return proxy;
} catch (err) {
console.warn("[BrowserPool] Failed to resolve proxy from DB:", err);
return undefined;
}
}
export async function resolveBrowserContextProxy(
contextKey: string,
options: Pick<BrowserPoolContextOptions, "proxyProviderKey">,
deps?: ResolvePlaywrightProxyDeps
): Promise<import("playwright").LaunchOptions["proxy"] | undefined> {
return resolvePlaywrightProxy(options.proxyProviderKey ?? contextKey, deps);
}
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;
state.metrics.browserLaunches++;
return browser;
})();
try {
return await state.launching;
} catch (err) {
state.launching = null;
state.metrics.browserLaunchFailures++;
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";
}>;
}
// Clear a key from the pending-creation map once its promise settles, counting
// failures. Kept as a leaf helper so acquireBrowserContext stays under the
// function-length ceiling (#3368 PR7 metrics).
function settlePendingContext(key: string, failed: boolean): void {
if (failed) state.metrics.contextCreateFailures++;
state.pendingContexts.delete(key);
}
// Seed a freshly created context with whatever session material the caller
// supplied — cookies for cookie-auth providers, localStorage for the ones (zai-web)
// whose session is a Bearer JWT the page reads at boot. Kept as a leaf helper so
// the creation closure stays under the complexity ceiling.
async function seedContextSession(
context: BrowserContext,
options: BrowserPoolContextOptions
): Promise<void> {
if (options.cookieString) {
const cookies = parseCookieString(options.cookieString, options.cookieDomain);
if (cookies.length > 0) {
await context.addCookies(cookies);
}
}
if (!options.localStorage || Object.keys(options.localStorage).length === 0) return;
const origin = new URL(options.localStorageOrigin || options.warmupUrl || "").origin;
await context.addInitScript(
({ expectedOrigin, entries }) => {
if (window.location.origin !== expectedOrigin) return;
for (const [name, value] of entries) {
window.localStorage.setItem(name, value);
}
},
{
expectedOrigin: origin,
entries: Object.entries(options.localStorage),
}
);
}
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();
state.metrics.contextsReused++;
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(),
resolveBrowserContextProxy(key, options),
]);
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 } : {}),
});
await seedContextSession(context, options);
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.metrics.contextsCreated++;
state.lastActivity = Date.now();
resetIdleTimer();
startEvictTimer();
return pooled;
})();
state.pendingContexts.set(key, createPromise);
createPromise
.then(() => settlePendingContext(key, false))
.catch(() => settlePendingContext(key, true));
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);
state.metrics.contextsReleased++;
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> {
state.metrics.shutdowns++;
state.metrics.lastShutdownReason = reason;
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,
};
}
/**
* #3368 PR7 — browser-pool observability. Returns live status plus cumulative
* lifecycle telemetry (launches, context create/reuse/evict/release counts,
* failures, shutdowns). Surfaced via the omniroute_browser_pool_status MCP tool.
*/
export function getBrowserPoolMetrics(): {
status: ReturnType<typeof getBrowserPoolStatus>;
metrics: BrowserPoolMetrics;
} {
return { status: getBrowserPoolStatus(), metrics: { ...state.metrics } };
}
/** Test-only: reset cumulative metrics so assertions start from a clean slate. */
export function __resetBrowserPoolMetricsForTest(): void {
state.metrics = createBrowserPoolMetrics();
}
export async function readPageResponseBody(
response: import("playwright").Response
): Promise<{ status: number; headers: Record<string, string>; body: Buffer<ArrayBuffer> }> {
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) };
}