diff --git a/changelog.d/fixes/13232-zai-web-missing-browser-executable.md b/changelog.d/fixes/13232-zai-web-missing-browser-executable.md new file mode 100644 index 0000000000..52794607eb --- /dev/null +++ b/changelog.d/fixes/13232-zai-web-missing-browser-executable.md @@ -0,0 +1 @@ +- **fix(sse):** classify a missing Playwright Chromium install on the Z.ai web transport as an actionable 503 host/config cooldown instead of a generic 502 that trips the provider circuit breaker (#13232) — thanks @oleksandr1811 diff --git a/open-sse/executors/browserExecutableCheck.ts b/open-sse/executors/browserExecutableCheck.ts new file mode 100644 index 0000000000..fadd5b5a0d --- /dev/null +++ b/open-sse/executors/browserExecutableCheck.ts @@ -0,0 +1,18 @@ +/** + * Shared classification for browser-backed executors: distinguishes a missing Playwright + * Chromium binary (`chromium.launch: Executable doesn't exist at ...`) from a transient upstream + * fault. This is a host/config problem, not something a retry loop can fix, so executors must + * NOT surface it as a plain retryable 5xx (which marks the account unavailable / trips the + * provider circuit breaker). Originally added for `gemini-web.ts` (#3516); extracted here so + * every browser-backed executor (Gemini Web, Z.ai Web, ...) can share the same detection. + */ +export function isMissingBrowserExecutable(message: string): boolean { + if (!message) return false; + const lower = message.toLowerCase(); + return ( + lower.includes("executable doesn't exist") || + lower.includes("executablenotfound") || + lower.includes("playwright install") || + (lower.includes("chromium") && lower.includes("download")) + ); +} diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index adbff8329a..a55d8bb331 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -15,6 +15,7 @@ import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts"; +import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts"; import { normalizeGeminiCookieInput } from "../utils/geminiCookies.ts"; import { prepareToolMessages } from "../translator/webTools.ts"; import { buildToolModeResponse } from "./chatgptWebTools.ts"; @@ -27,22 +28,12 @@ import { const GEMINI_URL = "https://gemini.google.com/app"; -/** - * Whether an error came from Playwright failing to launch because the browser binary is not - * installed (`chromium.launch: Executable doesn't exist at ...`). This is a host/config - * problem, not a transient upstream fault, so the executor must NOT surface it as a retryable - * 500 (which marks the account unavailable and loops / trips the provider breaker). See #3516. - */ -export function isMissingBrowserExecutable(message: string): boolean { - if (!message) return false; - const lower = message.toLowerCase(); - return ( - lower.includes("executable doesn't exist") || - lower.includes("executablenotfound") || - lower.includes("playwright install") || - (lower.includes("chromium") && lower.includes("download")) - ); -} +// Re-exported for backward compatibility: some tests/callers import this classification helper +// from gemini-web.ts, its original home (#3516). The implementation now lives in +// browserExecutableCheck.ts so other browser-backed executors (e.g. zai-web.ts, #13232) can +// share it without importing this whole executor module. +export { isMissingBrowserExecutable } from "./browserExecutableCheck.ts"; + const GEMINI_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; diff --git a/open-sse/executors/zai-web.ts b/open-sse/executors/zai-web.ts index fb09e4ea82..dafed25c63 100644 --- a/open-sse/executors/zai-web.ts +++ b/open-sse/executors/zai-web.ts @@ -51,6 +51,7 @@ import { makeZaiChunkEmitter, } from "./zai-web/stream.ts"; import { browserBackedChat } from "../services/browserBackedChat.ts"; +import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts"; import { CursorImageError, resolveCursorImages } from "../utils/cursorImages.ts"; import { makeExecutorErrorResult as makeErrorResult, @@ -424,9 +425,26 @@ export class ZaiWebExecutor extends BaseExecutor { try { result = await browserBackedChat(buildZaiBrowserChatOptions({ ...input, attachments })); } catch (error) { - const message = sanitizeErrorMessage( - error instanceof Error ? error.message : "browser transport unavailable" - ); + const rawMessage = error instanceof Error ? error.message : "browser transport unavailable"; + // #13232: a missing Playwright browser binary is a host/config problem, not a transient + // upstream fault (same class as #3516 in gemini-web.ts). Surface an actionable message and + // tag it with the connection-cooldown hint so accountFallback skips the whole-provider + // circuit breaker (502/500 would trip it) and applies a short, non-exponential cooldown + // instead. + if (isMissingBrowserExecutable(rawMessage)) { + return { + errorResult: makeErrorResult( + 503, + "Z.ai requires the Playwright Chromium browser, which is not installed. " + + "Run `npx playwright install chromium` on the host (or rebuild the Docker image " + + "with browsers).", + input.body, + ZAI_CHAT_URL, + { "X-Omni-Fallback-Hint": "connection_cooldown" } + ), + }; + } + const message = sanitizeErrorMessage(rawMessage); return { errorResult: makeErrorResult( 502, diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index b8b8785c89..e77d513259 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -1134,7 +1134,8 @@ export function makeExecutorErrorResult( status: number, message: string, body: unknown, - url: string + url: string, + extraResponseHeaders?: Record ) { return { response: new Response( @@ -1145,7 +1146,10 @@ export function makeExecutorErrorResult( code: `HTTP_${status}`, }, }), - { status, headers: { "Content-Type": "application/json" } } + { + status, + headers: { "Content-Type": "application/json", ...extraResponseHeaders }, + } ), url, headers: {} as Record, diff --git a/tests/unit/zai-web-missing-browser-executable-13232.test.ts b/tests/unit/zai-web-missing-browser-executable-13232.test.ts new file mode 100644 index 0000000000..8795c2e030 --- /dev/null +++ b/tests/unit/zai-web-missing-browser-executable-13232.test.ts @@ -0,0 +1,83 @@ +/** + * Regression for GitHub issue #13232 — "[BUG] Z.ai web error". + * + * The Z.ai web transport drives a real headed Chromium browser (via Playwright) to get past + * Z.ai's CAPTCHA. When the local Playwright Chromium binary is missing, + * `browserType.launch()` throws "Executable doesn't exist at ...". Before this fix, zai-web.ts + * had no classification for that failure and surfaced it as a plain 502 with no fallback hint — + * a status that trips the whole-provider circuit breaker (`AGENTS.md` → "Provider Circuit + * Breaker") as if the upstream itself were failing, instead of applying the intended + * host/config connection cooldown. This mirrors the exact failure class already handled for + * Gemini Web in #3516 (`isMissingBrowserExecutable`, now shared via + * `open-sse/executors/browserExecutableCheck.ts`). + */ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Buffer } from "node:buffer"; + +const mod = await import("../../open-sse/executors/zai-web.ts"); + +const TEST_TOKEN = `e30.${Buffer.from(JSON.stringify({ id: "user-123" })).toString("base64url")}.sig`; + +describe("issue #13232 — Z.ai browser transport classifies a missing Chromium install", () => { + let emptyBrowsersDir: string; + let originalBrowsersPath: string | undefined; + + before(() => { + emptyBrowsersDir = fs.mkdtempSync(path.join(os.tmpdir(), "playwright-empty-")); + originalBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH; + // Force chromium.launch() to genuinely fail with the exact class of error the reporter hit + // ("Executable doesn't exist at ..."), without touching any real ~/.cache/ms-playwright + // install. + process.env.PLAYWRIGHT_BROWSERS_PATH = emptyBrowsersDir; + }); + + after(() => { + if (originalBrowsersPath === undefined) { + delete process.env.PLAYWRIGHT_BROWSERS_PATH; + } else { + process.env.PLAYWRIGHT_BROWSERS_PATH = originalBrowsersPath; + } + fs.rmSync(emptyBrowsersDir, { recursive: true, force: true }); + }); + + it( + "returns a classified 503 + X-Omni-Fallback-Hint: connection_cooldown instead of a bare " + + "502 (contrast: gemini-web.ts isMissingBrowserExecutable, #3516)", + async () => { + const executor = new mod.ZaiWebExecutor(); + const body = { model: "glm-5.3-flash", messages: [{ role: "user", content: "hi" }] }; + const result = await executor.execute({ + model: "glm-5.3-flash", + body, + stream: false, + credentials: { apiKey: TEST_TOKEN }, + signal: null, + }); + + assert.ok("response" in result, "expected an error Response, not a stream result"); + const response = (result as { response: Response }).response; + const payload = (await response.json()) as { error?: { message?: string } }; + + assert.equal( + response.status, + 503, + "zai-web must classify a missing local Chromium install as a host/config error (503), " + + "not a generic retryable 502 that trips the whole-provider circuit breaker." + ); + assert.equal( + response.headers.get("X-Omni-Fallback-Hint"), + "connection_cooldown", + "the connection-cooldown hint must be set so accountFallback applies a short cooldown " + + "instead of tripping the provider circuit breaker." + ); + assert.match( + payload.error?.message ?? "", + /Playwright Chromium browser.*not installed.*npx playwright install chromium/s + ); + } + ); +});