diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e86fe801..10518c4765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - **fix(routing):** the `auto` model keyword now works on the Codex `/v1/responses` path — `resolveResponsesApiModel` rewrote the bare `auto` keyword to `codex/auto`, which ChatGPT rejects (`The 'auto' model is not supported when using Codex with a ChatGPT account`). `auto` (OmniRoute's zero-config auto-routing keyword) now passes through untouched so combo routing handles it. ([#3509](https://github.com/diegosouzapw/OmniRoute/issues/3509)) - **fix(cli-tools):** saving the OpenCode/CLI tool config no longer 400s in cloud mode — every CLI tool card posts `apiKey: null` (the real key is resolved server-side from `keyId`), but `guideSettingsSaveSchema` used `z.string().optional()`, which rejects `null`. The schema now normalizes `null` → `undefined`, so the save succeeds and the `keyId`/default path is used. ([#3552](https://github.com/diegosouzapw/OmniRoute/issues/3552)) - **fix(catalog):** PublicAI is no longer miscatalogued as keyless/free — it requires an API key (registry `authType:"apikey"`; signup grants a one-time credit, then it bills). The three PublicAI models moved from `freeType:"keyless"` (which could pick them into the no-auth pool and dispatch with no `Authorization` header) to `"one-time-initial"`, and the provider's `hasFree` flag is now `false` — matching `freeTierCatalog.ts`, which already excluded publicai. ([#3558](https://github.com/diegosouzapw/OmniRoute/issues/3558)) +- **fix(gemini-web):** a missing Playwright Chromium browser no longer loops and trips the provider breaker — when the browser binary is not installed, `chromium.launch()` threw an error surfaced as a retryable **500**, so accountFallback marked the account unavailable and retry-looped. It is now classified as a host/config problem and returns **503** with an actionable message (`npx playwright install chromium`) and the `X-Omni-Fallback-Hint: connection_cooldown` header, which skips the provider circuit breaker and applies a short non-exponential cooldown. ([#3516](https://github.com/diegosouzapw/OmniRoute/issues/3516)) --- diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 0d21f99437..4c06e2a9f0 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -19,6 +19,19 @@ import { sanitizeErrorMessage } from "../utils/error.ts"; // ─── Constants ────────────────────────────────────────────────────────────── 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; + return /executable doesn't exist|executablenotfound|playwright install|chromium.*download/i.test( + message + ); +} const GEMINI_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; @@ -292,10 +305,36 @@ export class GeminiWebExecutor extends BaseExecutor { transformedBody: body, }; } catch (error) { + const rawMessage = error instanceof Error ? error.message : "Unknown error"; + // #3516: a missing Playwright browser is a host/config problem, not a transient upstream + // fault. Surface an actionable error and tag it with the connection-cooldown hint so + // accountFallback skips the provider circuit breaker and applies a short, non-exponential + // cooldown instead of looping on a retryable 500. + if (isMissingBrowserExecutable(rawMessage)) { + return { + response: new Response( + JSON.stringify({ + error: + "Gemini Web requires the Playwright Chromium browser, which is not installed. " + + "Run `npx playwright install chromium` on the host (or rebuild the Docker image with browsers).", + }), + { + status: 503, + headers: { + "Content-Type": "application/json", + "X-Omni-Fallback-Hint": "connection_cooldown", + }, + } + ), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } return { response: new Response( JSON.stringify({ - error: sanitizeErrorMessage(error instanceof Error ? error.message : "Unknown error"), + error: sanitizeErrorMessage(rawMessage), }), { status: 500, headers: { "Content-Type": "application/json" } } ), diff --git a/tests/unit/gemini-web-missing-browser-3516.test.ts b/tests/unit/gemini-web-missing-browser-3516.test.ts new file mode 100644 index 0000000000..b52da4fe2e --- /dev/null +++ b/tests/unit/gemini-web-missing-browser-3516.test.ts @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { isMissingBrowserExecutable } from "../../open-sse/executors/gemini-web.ts"; + +// Regression for #3516: when the Playwright Chromium binary isn't installed, chromium.launch() +// throws "browserType.launch: Executable doesn't exist at ...". gemini-web returned that as a +// generic 500, which accountFallback treats as a retryable upstream error → the account is +// marked unavailable and the request loops/trips the provider breaker. A missing browser is a +// host/config problem, not a transient upstream fault — it must be classified so the executor +// can surface an actionable error and use the connection-cooldown hint instead of the 500 loop. + +test("#3516 detects the Playwright missing-executable launch error", () => { + const msg = + "browserType.launch: Executable doesn't exist at /home/node/.cache/ms-playwright/" + + "chromium_headless_shell-1223/chrome-linux/headless_shell\n" + + "╔════════════════════════════════════════════════════════════╗\n" + + "║ Looks like Playwright Test or Playwright was just installed ║"; + assert.equal(isMissingBrowserExecutable(msg), true); +}); + +test("#3516 detects the 'npx playwright install' guidance variant", () => { + assert.equal(isMissingBrowserExecutable("Please run the following command: npx playwright install"), true); +}); + +test("#3516 does NOT classify a normal upstream/network error as missing-executable", () => { + assert.equal(isMissingBrowserExecutable("No response from Gemini"), false); + assert.equal(isMissingBrowserExecutable("fetch failed: ECONNRESET"), false); + assert.equal(isMissingBrowserExecutable(""), false); +}); diff --git a/tests/unit/gemini-web.test.ts b/tests/unit/gemini-web.test.ts index 492aba830e..b4f837c80b 100644 --- a/tests/unit/gemini-web.test.ts +++ b/tests/unit/gemini-web.test.ts @@ -75,18 +75,22 @@ test("Provider: gemini-web has correct models", async () => { assert.ok(modelIds.includes("gemini-2.0-flash")); }); -// ─── Regression: #2832 — Playwright missing in Docker (runner-base) ────────── +// ─── Regression: #2832 / #3516 — Playwright missing in Docker (runner-base) ── // // When the `runner-base` Docker image is used (no Playwright browsers installed), // `import("playwright")` succeeds but `chromium.launch()` throws the well-known -// "Executable doesn't exist" error. The executor MUST surface this as a sanitized -// 500 — never an unhandled rejection — so users get a clear error message rather -// than a silent stream abort. +// "Executable doesn't exist" error. The executor MUST surface this as a structured, +// sanitized response — never an unhandled rejection / silent stream abort. // -// Hard rule #12: error must go through sanitizeErrorMessage (no raw err.message -// or stack trace in the response body). +// #3516 superseded the original 500: a missing browser is a host/config problem, +// not a transient upstream fault, so it now returns 503 with the +// `X-Omni-Fallback-Hint: connection_cooldown` header (skips the provider circuit +// breaker, short non-exponential cooldown) and an actionable message — instead of a +// retryable 500 that marked the account unavailable and looped. +// +// Hard rule #12: the body carries no raw err.message stack trace. -test("#2832: Playwright launch failure returns sanitized 500, not unhandled rejection", async () => { +test("#2832/#3516: missing Playwright browser returns an actionable 503 with cooldown hint, not a retryable 500", async () => { const playwrightError = new Error( "browserType.launch: Executable doesn't exist at /home/node/.cache/ms-playwright/chromium_headless_shell-1161/chrome-linux/headless_shell\n" + " at /app/node_modules/playwright-core/lib/server/browserType.js:123:19" @@ -110,10 +114,17 @@ test("#2832: Playwright launch failure returns sanitized 500, not unhandled reje log: null, }); - assert.equal(result.response.status, 500, "should return HTTP 500"); + // #3516: missing browser → 503 + connection-cooldown hint (not a retryable 500 loop). + assert.equal(result.response.status, 503, "missing browser should return HTTP 503"); + assert.equal( + result.response.headers.get("X-Omni-Fallback-Hint"), + "connection_cooldown", + "must signal connection cooldown so the provider breaker is skipped" + ); const json = (await result.response.json()) as any; assert.ok(typeof json.error === "string", "error field must be a string"); - // Hard rule #12: sanitizeErrorMessage must strip the stack trace tail. + assert.match(json.error, /playwright install|not installed/i, "message must be actionable"); + // No raw stack trace / source path leaks into the body. assert.ok(!json.error.includes("\n at "), "must not contain multi-line stack trace"); assert.ok(!json.error.includes("node_modules/playwright-core"), "must not contain node_modules source path"); } finally {