mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-23 15:22:30 +03:00
Compare commits
3 Commits
fix/13431-
...
fix/13232-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd058824b | ||
|
|
a24562ece3 | ||
|
|
f0d2d34ee5 |
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(sse):** frame post-keepalive `/v1/responses` stream errors with a top-level `type` field so Responses clients (Codex) surface the real upstream error instead of reporting "stream disconnected before completion" (#13431) — thanks @andrea-kingautomation
|
||||
18
open-sse/executors/browserExecutableCheck.ts
Normal file
18
open-sse/executors/browserExecutableCheck.ts
Normal file
@@ -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"))
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,44 +89,6 @@ export const OPENAI_RESPONSES_ERROR_FRAME = ENCODER.encode(
|
||||
})}\n\n`
|
||||
);
|
||||
|
||||
/**
|
||||
* Reshapes an already-sanitized upstream error body into the Responses API
|
||||
* convention (`{"type":"error",...}`) for the dynamic real-upstream-body branch
|
||||
* of the slow path (#13431). The body reaching here is Chat-Completions-shaped
|
||||
* (`{"error":{message,type,code}}`, the combo/handler failure convention) most of
|
||||
* the time, but may also be a bare `{message}` or unparseable text — every shape
|
||||
* must still produce a non-empty `message` so the client never sees an opaque
|
||||
* frame (never crash the stream on a malformed body).
|
||||
*/
|
||||
function buildResponsesErrorDataLine(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
if (trimmed) {
|
||||
try {
|
||||
const candidate = JSON.parse(trimmed);
|
||||
if (candidate && typeof candidate === "object") parsed = candidate as Record<string, unknown>;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
const errorObj =
|
||||
parsed && typeof parsed.error === "object" && parsed.error !== null
|
||||
? (parsed.error as Record<string, unknown>)
|
||||
: null;
|
||||
const message =
|
||||
(typeof errorObj?.message === "string" && errorObj.message) ||
|
||||
(typeof parsed?.message === "string" && parsed.message) ||
|
||||
trimmed ||
|
||||
"Upstream stream failed before completion.";
|
||||
const code = (typeof errorObj?.code === "string" && errorObj.code) || null;
|
||||
const param = (typeof errorObj?.param === "string" && errorObj.param) || null;
|
||||
const extras =
|
||||
parsed && typeof parsed.diagnostics === "object" && parsed.diagnostics !== null
|
||||
? { diagnostics: parsed.diagnostics }
|
||||
: {};
|
||||
return JSON.stringify({ type: "error", code, message, param, ...extras });
|
||||
}
|
||||
|
||||
export type EarlyStreamKeepaliveOptions = {
|
||||
/** Wait this long for the handler before committing to a keepalive stream. */
|
||||
thresholdMs?: number;
|
||||
@@ -206,29 +168,11 @@ export async function withEarlyStreamKeepalive(
|
||||
: null;
|
||||
const extraHeaders = options.extraHeaders ?? {};
|
||||
const errorFrame = options.errorFrame ?? ERROR_FRAME;
|
||||
// Single source of truth for THIS route's error-framing convention, derived from
|
||||
// errorFrame itself so the dynamic real-upstream-body case below stays consistent
|
||||
// with the static default-message case without a second option. Three shapes exist:
|
||||
// - "anthropic": named SSE `event: error` line (Anthropic /v1/messages).
|
||||
// - "responses": plain `data:` line, discriminated by a top-level `type` field
|
||||
// inside the JSON payload (OpenAI Responses API convention).
|
||||
// - "chat": plain `data:` line, discriminated by a top-level `error` key
|
||||
// (OpenAI Chat Completions convention) — the default/fallback.
|
||||
const decodedErrorFrame = new TextDecoder().decode(errorFrame);
|
||||
const errorFrameFormat: "anthropic" | "responses" | "chat" = decodedErrorFrame.startsWith(
|
||||
"event:"
|
||||
)
|
||||
? "anthropic"
|
||||
: (() => {
|
||||
const dataLine = decodedErrorFrame.match(/^data: (.+)\n\n$/);
|
||||
if (!dataLine) return "chat";
|
||||
try {
|
||||
const parsed = JSON.parse(dataLine[1]);
|
||||
return parsed && typeof parsed === "object" && "type" in parsed ? "responses" : "chat";
|
||||
} catch {
|
||||
return "chat";
|
||||
}
|
||||
})();
|
||||
// Single source of truth for whether THIS route's error framing uses a named SSE
|
||||
// `event: error` line (Anthropic) or a plain `data:` line (OpenAI Chat Completions /
|
||||
// Responses) — derived from errorFrame itself so the dynamic real-upstream-body case
|
||||
// below stays consistent with the static default-message case without a second option.
|
||||
const errorFrameUsesNamedEvent = new TextDecoder().decode(errorFrame).startsWith("event:");
|
||||
const correlationId = options.correlationId;
|
||||
const frameDecoder = correlationId ? new TextDecoder() : null;
|
||||
// Records every direct-to-client write EXCEPT the forwarded real response
|
||||
@@ -377,14 +321,11 @@ export async function withEarlyStreamKeepalive(
|
||||
// instead of forwarding raw JSON, which would be malformed SSE.
|
||||
const text = response.body ? await response.text().catch(() => "") : "";
|
||||
const dataLine =
|
||||
errorFrameFormat === "responses"
|
||||
? buildResponsesErrorDataLine(text)
|
||||
: text.trim() ||
|
||||
JSON.stringify({ error: { message: "stream_error", type: "stream_error" } });
|
||||
const framed =
|
||||
errorFrameFormat === "anthropic"
|
||||
? `event: error\ndata: ${dataLine}\n\n`
|
||||
: `data: ${dataLine}\n\n`;
|
||||
text.trim() ||
|
||||
JSON.stringify({ error: { message: "stream_error", type: "stream_error" } });
|
||||
const framed = errorFrameUsesNamedEvent
|
||||
? `event: error\ndata: ${dataLine}\n\n`
|
||||
: `data: ${dataLine}\n\n`;
|
||||
const framedBytes = ENCODER.encode(framed);
|
||||
controller.enqueue(framedBytes);
|
||||
recordClientBytes(framedBytes);
|
||||
|
||||
@@ -1134,7 +1134,8 @@ export function makeExecutorErrorResult(
|
||||
status: number,
|
||||
message: string,
|
||||
body: unknown,
|
||||
url: string
|
||||
url: string,
|
||||
extraResponseHeaders?: Record<string, string>
|
||||
) {
|
||||
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<string, string>,
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* Regression test for #13431.
|
||||
*
|
||||
* `withEarlyStreamKeepalive`'s dynamic real-upstream-body branch
|
||||
* (`open-sse/utils/earlyStreamKeepalive.ts`) only distinguished Anthropic's named
|
||||
* `event: error` framing from a plain `data:` line. It did not distinguish Chat
|
||||
* Completions' `data: {"error":...}` shape from Responses' `data: {"type":"error",...}`
|
||||
* shape, so on `/v1/responses` the raw upstream body (Chat-Completions-shaped) went out
|
||||
* untouched, with no top-level `type` field. Responses clients (openai-python's Responses
|
||||
* stream iterator, Codex's own SSE parser) dispatch on `type` and silently drop a frame
|
||||
* without it, so the stream ends with no `response.completed`/`response.failed` and the
|
||||
* client reports "stream disconnected before completion" instead of the real upstream
|
||||
* error.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
withEarlyStreamKeepalive,
|
||||
OPENAI_RESPONSES_ERROR_FRAME,
|
||||
OPENAI_CHAT_ERROR_FRAME,
|
||||
ANTHROPIC_PING_FRAME,
|
||||
} from "../../open-sse/utils/earlyStreamKeepalive.ts";
|
||||
|
||||
async function readAll(response: Response): Promise<string> {
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let out = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
out += decoder.decode(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lastDataPayload(body: string): Record<string, unknown> {
|
||||
const dataLines = [...body.matchAll(/^data: (.+)$/gm)].map((m) => m[1]);
|
||||
return JSON.parse(dataLines[dataLines.length - 1]);
|
||||
}
|
||||
|
||||
test("Responses route: post-keepalive JSON error body must carry a `type` field (#13431)", async () => {
|
||||
// Shape actually produced by combo failure (Chat-Completions-shaped: top-level
|
||||
// `error` key, no `type` discriminator) — this is the real body from the issue.
|
||||
const upstreamErrorBody = JSON.stringify({
|
||||
error: {
|
||||
message: 'Unknown name "encrypted" ... Cannot find field.',
|
||||
type: "invalid_request_error",
|
||||
code: "bad_request",
|
||||
},
|
||||
diagnostics: { attempted: 9, terminalReason: "[400]: ..." },
|
||||
});
|
||||
|
||||
const slowFail = new Promise<Response>((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
new Response(upstreamErrorBody, {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
),
|
||||
80
|
||||
);
|
||||
});
|
||||
|
||||
const result = await withEarlyStreamKeepalive(slowFail, {
|
||||
thresholdMs: 20,
|
||||
intervalMs: 20,
|
||||
errorFrame: OPENAI_RESPONSES_ERROR_FRAME, // exactly what src/app/api/v1/responses/route.ts passes
|
||||
});
|
||||
|
||||
assert.equal(result.status, 200, "already committed to 200 SSE before the error surfaced");
|
||||
|
||||
const lastPayload = lastDataPayload(await readAll(result));
|
||||
|
||||
assert.ok(
|
||||
typeof lastPayload.type === "string" && lastPayload.type.length > 0,
|
||||
`Responses API events must be discriminated by a top-level \`type\` field; ` +
|
||||
`got ${JSON.stringify(lastPayload)} — a Responses client (Codex) drops any ` +
|
||||
`frame without \`type\` and reports "stream disconnected before completion" ` +
|
||||
`instead of surfacing the real upstream error.`
|
||||
);
|
||||
assert.equal(lastPayload.type, "error");
|
||||
assert.equal(lastPayload.message, 'Unknown name "encrypted" ... Cannot find field.');
|
||||
assert.equal(lastPayload.code, "bad_request");
|
||||
});
|
||||
|
||||
test("Responses route: non-JSON/empty post-keepalive error body falls back to a safe `type:error` frame (#13431)", async () => {
|
||||
const slowFail = new Promise<Response>((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
new Response("not json at all", {
|
||||
status: 502,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
})
|
||||
),
|
||||
80
|
||||
);
|
||||
});
|
||||
|
||||
const result = await withEarlyStreamKeepalive(slowFail, {
|
||||
thresholdMs: 20,
|
||||
intervalMs: 20,
|
||||
errorFrame: OPENAI_RESPONSES_ERROR_FRAME,
|
||||
});
|
||||
|
||||
const lastPayload = lastDataPayload(await readAll(result));
|
||||
|
||||
assert.equal(lastPayload.type, "error");
|
||||
assert.ok(
|
||||
typeof lastPayload.message === "string" && lastPayload.message.length > 0,
|
||||
`fallback frame must never be opaque/empty; got ${JSON.stringify(lastPayload)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("Chat Completions route: post-keepalive JSON error body stays verbatim pass-through (regression guard) (#13431)", async () => {
|
||||
const upstreamErrorBody = JSON.stringify({
|
||||
error: { message: "boom", type: "invalid_request_error", code: "bad_request" },
|
||||
});
|
||||
|
||||
const slowFail = new Promise<Response>((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
new Response(upstreamErrorBody, {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
),
|
||||
80
|
||||
);
|
||||
});
|
||||
|
||||
const result = await withEarlyStreamKeepalive(slowFail, {
|
||||
thresholdMs: 20,
|
||||
intervalMs: 20,
|
||||
errorFrame: OPENAI_CHAT_ERROR_FRAME,
|
||||
});
|
||||
|
||||
const lastPayload = lastDataPayload(await readAll(result));
|
||||
|
||||
// Unchanged: verbatim pass-through, top-level `error` key, no reshaping.
|
||||
assert.equal(lastPayload.type, undefined);
|
||||
assert.equal((lastPayload as { error: { message: string } }).error.message, "boom");
|
||||
});
|
||||
|
||||
test("Anthropic /v1/messages route: post-keepalive named event: error framing stays unaffected (regression guard) (#13431)", async () => {
|
||||
const slowFail = new Promise<Response>((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
new Response(JSON.stringify({ type: "error", error: { message: "boom" } }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
),
|
||||
80
|
||||
);
|
||||
});
|
||||
|
||||
const result = await withEarlyStreamKeepalive(slowFail, {
|
||||
thresholdMs: 20,
|
||||
intervalMs: 20,
|
||||
keepaliveFrame: ANTHROPIC_PING_FRAME,
|
||||
// default errorFrame (Anthropic `event: error`) is used when omitted.
|
||||
});
|
||||
|
||||
const body = await readAll(result);
|
||||
assert.match(body, /^event: error\n/m, "Anthropic path must keep its named SSE event line");
|
||||
});
|
||||
83
tests/unit/zai-web-missing-browser-executable-13232.test.ts
Normal file
83
tests/unit/zai-web-missing-browser-executable-13232.test.ts
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user