mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-23 23:33:38 +03:00
Compare commits
3 Commits
fix/12692-
...
fix/13232-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd058824b | ||
|
|
a24562ece3 | ||
|
|
f0d2d34ee5 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
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