Compare commits

..

3 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
9bd058824b chore: sync release/v3.8.51 into fix/13232-zai-web-missing-browser-executable (base-red fix #13747) 2026-09-15 23:24:21 -03:00
diegosouzapw
a24562ece3 Merge commit '8f55d85d221e8df0b788eab0e598935a1514536a' into fix/13232-zai-web-missing-browser-executable 2026-09-15 23:18:45 -03:00
diegosouzapw
f0d2d34ee5 fix(sse): classify missing Chromium as a Z.ai host/config cooldown (#13232)
The Z.ai web transport drives a real headed Chromium browser (Playwright)
to get past Z.ai's CAPTCHA. When the local Chromium binary is missing,
chromium.launch() throws "Executable doesn't exist at ...", which
zai-web.ts's fetchThroughBrowser catch block wrapped as a plain 502 with
no fallback hint — a status that trips the whole-provider circuit breaker
as if the upstream itself were failing.

gemini-web.ts already classifies this exact failure class for issue
#3516 (isMissingBrowserExecutable). Extracted that helper into a shared
open-sse/executors/browserExecutableCheck.ts (re-exported from
gemini-web.ts for backward compatibility) and applied it to zai-web.ts:
a missing browser now returns 503 + X-Omni-Fallback-Hint:
connection_cooldown with an actionable remediation message, mirroring
the Gemini Web precedent.

Regression test: tests/unit/zai-web-missing-browser-executable-13232.test.ts
2026-09-15 15:10:01 -03:00
11 changed files with 143 additions and 124 deletions

View File

@@ -1 +0,0 @@
- **fix(providers):** xAI requests no longer silently drop an assistant tool call sent in the legacy OpenAI `function_call` shape (instead of `tool_calls[]`) — the call is now translated into the xAI request the same way modern tool calls are (#12692) — thanks @soroush5

View File

@@ -1 +0,0 @@
- **fix(providers):** xAI responses no longer report `total_tokens`/`totalTokenCount` as `0` when upstream usage uses the legacy `prompt_tokens`/`completion_tokens` names instead of `input_tokens`/`output_tokens` (#12700) — thanks @soroush5

View File

@@ -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

View 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"))
);
}

View File

@@ -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";

View File

@@ -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,

View File

@@ -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>,

View File

@@ -263,7 +263,7 @@ function toolsGeminiToXai(tools: GeminiTool[]): XaiTool[] | undefined {
*/
export function geminiRequestToXaiResponses(
req: GeminiRequest,
model: string | null = null
model: string | null = null,
): XaiResponsesRequest {
if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest;
const input: XaiInputItem[] = [];
@@ -275,7 +275,9 @@ export function geminiRequestToXaiResponses(
if (fnItems.length) {
for (const it of fnItems) input.push(it);
// Filter remaining text/image parts
const remaining = (c.parts ?? []).filter((p) => !p?.functionCall && !p?.functionResponse);
const remaining = (c.parts ?? []).filter(
(p) => !p?.functionCall && !p?.functionResponse,
);
if (remaining.length) input.push({ role, content: partsToXaiBlocks(remaining) });
} else {
input.push({ role, content: partsToXaiBlocks(c.parts ?? []) });
@@ -322,7 +324,7 @@ export function geminiRequestToXaiResponses(
*/
export function xaiCompletedToGeminiJson(
completed: XaiCompleted,
origReq: GeminiRequest | null = null
origReq: GeminiRequest | null = null,
): object {
const parts: unknown[] = [];
const finishReason = "STOP";
@@ -362,8 +364,7 @@ export function xaiCompletedToGeminiJson(
promptTokenCount: u.input_tokens ?? u.prompt_tokens ?? 0,
candidatesTokenCount: u.output_tokens ?? u.completion_tokens ?? 0,
totalTokenCount:
u.total_tokens ??
(u.input_tokens ?? u.prompt_tokens ?? 0) + (u.output_tokens ?? u.completion_tokens ?? 0),
u.total_tokens ?? ((u.input_tokens ?? 0) + (u.output_tokens ?? 0)),
};
}
return out;

View File

@@ -38,7 +38,6 @@ interface OpenAiMessage {
content?: MessageContent;
tool_calls?: OpenAiToolCall[];
tool_call_id?: string;
function_call?: { name?: string; arguments?: string };
}
interface OpenAiChatRequest {
@@ -214,22 +213,6 @@ export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesR
}
continue;
}
// Legacy OpenAI Chat Completions form: assistant tool-call carried as a top-level
// `function_call` field instead of `tool_calls[]`. Some OpenAI-compatible clients still
// emit this shape; without this branch the message falls through to the generic case
// below with empty content and the tool invocation is silently dropped (#12692).
if (m.role === "assistant" && m.function_call?.name) {
if (m.content) {
input.push({ role: "assistant", content: messageContentToXaiBlocks(m.content) });
}
input.push({
type: "function_call",
call_id: genId("call"),
name: m.function_call.name,
arguments: m.function_call.arguments ?? "",
});
continue;
}
input.push({ role: m.role ?? "user", content: messageContentToXaiBlocks(m.content ?? "") });
}
@@ -320,9 +303,7 @@ export function xaiCompletedToChatJson(
out.usage = {
prompt_tokens: u.input_tokens ?? u.prompt_tokens ?? 0,
completion_tokens: u.output_tokens ?? u.completion_tokens ?? 0,
total_tokens:
u.total_tokens ??
(u.input_tokens ?? u.prompt_tokens ?? 0) + (u.output_tokens ?? u.completion_tokens ?? 0),
total_tokens: u.total_tokens ?? (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
};
}
return out;

View File

@@ -193,51 +193,6 @@ test("chatRequestToXaiResponses: maps max_tokens to max_output_tokens", () => {
assert.equal(out.max_output_tokens, 512);
});
test("#12692: chatRequestToXaiResponses maps legacy assistant function_call to a function_call item", () => {
const req = {
model: "grok-4",
messages: [
{
role: "assistant",
content: null,
function_call: { name: "get_weather", arguments: '{"city":"Paris"}' },
},
],
};
const out = chatRequestToXaiResponses(req);
const calls = (out.input as Array<{ type: string; name?: string; arguments?: string }>).filter(
(i) => i.type === "function_call"
);
assert.equal(calls.length, 1, "expected a function_call item to be present in xAI input");
assert.equal(calls[0]?.name, "get_weather");
assert.equal(calls[0]?.arguments, '{"city":"Paris"}');
});
test("#12692: chatRequestToXaiResponses preserves leading text alongside legacy function_call", () => {
const req = {
model: "grok-4",
messages: [
{
role: "assistant",
content: "Let me check that for you.",
function_call: { name: "get_weather", arguments: '{"city":"Paris"}' },
},
],
};
const out = chatRequestToXaiResponses(req);
const items = out.input as Array<{
type?: string;
role?: string;
content?: unknown;
name?: string;
}>;
const textItem = items.find((i) => i.role === "assistant");
assert.ok(textItem, "expected the leading assistant text block to be preserved");
const calls = items.filter((i) => i.type === "function_call");
assert.equal(calls.length, 1);
assert.equal(calls[0]?.name, "get_weather");
});
// ─── xaiCompletedToChatJson ──────────────────────────────────────────────────
test("xaiCompletedToChatJson: extracts output_text content into message", () => {
@@ -282,21 +237,6 @@ test("xaiCompletedToChatJson: maps function_call to tool_calls with finish_reaso
assert.equal(fn.name, "get_weather");
});
test("#12700: xaiCompletedToChatJson sums legacy prompt_tokens/completion_tokens into total_tokens", () => {
const completed = {
output: [{ type: "message", content: [{ type: "output_text", text: "hi" }] }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
};
const result = xaiCompletedToChatJson(completed) as { usage?: Record<string, unknown> };
assert.equal(result.usage?.prompt_tokens, 10);
assert.equal(result.usage?.completion_tokens, 5);
assert.equal(
result.usage?.total_tokens,
15,
"total_tokens should sum legacy fields, not report 0"
);
});
// ─── openaiResponsesRequestToXai ─────────────────────────────────────────────
test("openaiResponsesRequestToXai: drops service_tier", () => {
@@ -581,19 +521,3 @@ test("xaiCompletedToGeminiJson: maps usage to usageMetadata", () => {
assert.equal(meta.candidatesTokenCount, 20);
assert.equal(meta.totalTokenCount, 30);
});
test("#12700: xaiCompletedToGeminiJson sums legacy prompt_tokens/completion_tokens into totalTokenCount", () => {
const completed = {
model: "grok-4",
output: [],
usage: { prompt_tokens: 10, completion_tokens: 5 },
};
const result = xaiCompletedToGeminiJson(completed) as { usageMetadata?: Record<string, unknown> };
assert.equal(result.usageMetadata?.promptTokenCount, 10);
assert.equal(result.usageMetadata?.candidatesTokenCount, 5);
assert.equal(
result.usageMetadata?.totalTokenCount,
15,
"totalTokenCount should sum legacy fields, not report 0"
);
});

View 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
);
}
);
});