Files
OmniRoute/open-sse/executors/zai-web.ts
backryun bd472200d5 [v3.8.50] Fix Z.ai web browser transport and model capabilities (#8451)
* fix: complete Z.ai web browser transport

* refactor: address Z.ai review feedback

* test(zai-web): reconcile the #8014 endpoint guard with the chats/new + signed flow

Rebasing onto release/v3.8.49 pulled in #8503, which repointed CHAT_URL to
/api/v2/chat/completions and added an endpoint probe. This branch already
targets v2, so the executor conflict resolved to this branch's superset
(NEW_CHAT_URL + signature constants alongside the same v2 CHAT_URL). The two
tests needed adapting, because #8503's assertions assume the pre-rework flow:

- executor-zai-web.test.ts: the completion URL now carries the request
  signature as a query string, so an exact-equality check on the endpoint can
  never match. Assert the v2 prefix instead.
- zai-web-chat-endpoint-8014-probe.test.ts: the probe drove the executor with a
  bare cookie credential and no captcha proof, which now routes through the
  browser transport — fetch was never called and the probe captured nothing.
  Supplied a direct-path credential, and matched on pathname across all
  requests (the executor also probes the homepage for the frontend version and
  calls /api/v1/chats/new first).

The guard's intent is unchanged and slightly strengthened: it now asserts no
request reaches the stale unversioned path and that exactly one completions
request is issued, against v2.

54/54 across the zai suites; typecheck:core and eslint clean.

* fix(zai-web): surface upstream error frames instead of finishing empty

Reported on this PR: HTTP 200, `out=0`, stream "complete", no content and no
diagnosis.

Cause. HTTP-level failures are already handled — fetchUpstream turns any !ok
response into a makeErrorResult with the sanitized body. The gap is a 200 whose
SSE body carries an error payload: parseZaiFrame returns null for it,
drainSseDeltas drops it, and buildZaiStreamingBody then closes with an empty
assistant message + stop + [DONE]. The caller reads that as a successful empty
completion, so a rejected signature, an expired captcha and a stale token all
look identical — which is why this had to be diagnosed by reading code rather
than logs. Hard Rule #6.

Fix. parseZaiFrame now classifies an affirmatively error-shaped frame
(`error` at the top level or under `data`, string or {detail|message|msg}) as a
terminal delta, checked before the delta paths so it cannot fall through to the
"no usable delta" null. The stream emits it as `[Z.ai error] <message>`,
matching the mid-stream convention the other web executors already use
(zed-hosted's createErrorChunk) — the 200 is on the wire, so the status cannot
change, but the caller must not be left reading a blank success. Content
streamed before the failure is preserved. Message goes through
sanitizeErrorMessage (Rule #12).

Deliberately NOT changed: a contentless frame still parses to null. That is
live-validated behaviour, not an oversight — z.ai emits phase frames with no
delta_content, and executor-zai-web.test.ts pins it ("returns null for frames
with no usable delta"). Treating "nothing parseable arrived" as a failure would
invent policy on top of an observed protocol and risk false errors on the happy
path, so this only adds recognition of explicit error frames.

Tests (TDD, RED then GREEN): zai-web-silent-empty-repro.test.ts — 7 cases.
Error frame classified and terminal; surfaced through the stream with the
upstream's own text; surfaced after partial content without losing it; plus a
REGRESSION GUARD that contentless/phase-only frames are still skipped, and two
controls that the happy path and reasoning-only output are untouched. The guard
and controls passed before the fix; the four error cases did not.

94/94 across the zai + stream suites; typecheck:core, eslint and check:file-size
clean.

* refactor(sse): extract the zai-web transports so the complexity ratchet holds

The v3.8.49 merge-train rebaseline (#8686) set the ceiling to the tip's own
measurement, leaving zero headroom, so this branch's +5 cyclomatic / +3 cognitive
own-growth had nowhere to sit once rebased onto it.

Eight violations, all in code this branch introduces, resolved by extraction —
no behaviour change:

- `execute` (152 lines, complexity 25, cognitive 20) now delegates to
  `resolveZaiRequest()` for the four client-error rejections and to a
  `fetchViaSignedApi()` method for the CAPTCHA/signature path, so it reads as
  "validate, pick a transport, shape the response".
- `fetchThroughBrowser` (126 lines, cognitive 16) hands its image decoding to
  `resolveZaiBrowserAttachments()`, its Playwright options to
  `buildZaiBrowserChatOptions()`, and its call-log payload to
  `buildZaiBrowserAuditBody()`.
- `configureZaiBrowserEffort` (cognitive 35 — the worst of the set) repeated a
  wrap-and-relabel try/catch four times inside an if/else. `runStage`, which
  already existed one function below, is now module-scoped and reused, and the
  toggle collapses to `checked !== config.enabled` (same four cases).
- `validateWebCookieProvider` (complexity 19) moves its can-we-probe-this
  cascade into `resolveWebCookieProbe()`, which returns either a rejection or
  the URL + headers to use.
- `acquireBrowserContext`'s creation closure (complexity 17) hands cookie and
  localStorage seeding to `seedContextSession()`.

That last extraction also clears a violation that predates this branch —
`acquireBrowserContext` was already over the 80-line ceiling — so cyclomatic
lands at 2187 against a baseline of 2188.

Verified: check:complexity-ratchets green both metrics; typecheck:core clean;
ESLint clean on all four files; 85 tests across the zai-web, web-cookie
validation, browser-pool and model-test-runner suites pass.

* fix(zai-web): surface upstream errors on the non-streaming path

collectZaiNonStreaming ignored delta.error — a 200 whose SSE body carries
an error frame (rejected signature, expired captcha, stale token) came
back as a successful empty completion. Now it throws on an error frame,
matching the streaming path's [Z.ai error] convention; the caller's
existing try/catch returns makeErrorResult(502) instead of an empty 200.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: backryun <busan011@ormbiz.co.kr>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-12 08:41:03 -03:00

598 lines
19 KiB
TypeScript

/**
* ZaiWebExecutor — Z.ai consumer chat (chat.z.ai).
*
* The consumer frontend stores a Bearer JWT in localStorage and requires a
* browser-issued CAPTCHA proof for chat completions. The browser transport is
* the default; callers with a short-lived proof can use the direct HTTP path.
*
* Completions go to /api/v2/chat/completions; the older unversioned
* /api/chat/completions path is stale and 404s model-independently (#8014).
*/
import { createHash, randomUUID } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { configureZaiBrowserRequest } from "./zai-web/browserAutomation.ts";
import {
asRecord,
browserModelName,
browserPrompt,
buildZaiCompletionUrl,
buildZaiHeaders,
buildZaiNewChatBody,
buildZaiRequestBody,
buildZaiSignature,
collectZaiImageUrls,
describeZaiBrowserFailure,
extractZaiToken,
extractZaiUserId,
foldMessages,
getZaiModelCapabilities,
latestUserPrompt,
parseZaiFrontendVersion,
resolveZaiCaptchaVerifyParam,
resolveZaiThinkingConfig,
resolveZaiVlmConfig,
unprefixedModelId,
zaiImageFileName,
ZAI_BASE_URL,
ZAI_CHAT_URL,
ZAI_DEFAULT_FE_VERSION,
ZAI_DEFAULT_MODEL,
ZAI_FE_VERSION_CACHE_TTL_MS,
ZAI_NEW_CHAT_URL,
ZAI_USER_AGENT,
type ZaiReasoningEffort,
type ZaiThinkingConfig,
type ZaiVlmConfig,
} from "./zai-web/protocol.ts";
import {
buildZaiStreamingBody,
collectZaiNonStreaming,
makeZaiChunkEmitter,
} from "./zai-web/stream.ts";
import { browserBackedChat } from "../services/browserBackedChat.ts";
import { CursorImageError, resolveCursorImages } from "../utils/cursorImages.ts";
import {
makeExecutorErrorResult as makeErrorResult,
sanitizeErrorMessage,
} from "../utils/error.ts";
export {
buildZaiSignature,
describeZaiBrowserFailure,
extractZaiCaptchaVerifyParam,
extractZaiToken,
extractZaiUserId,
foldMessages,
getZaiModelCapabilities,
parseZaiFrontendVersion,
resolveZaiThinkingConfig,
resolveZaiVlmConfig,
} from "./zai-web/protocol.ts";
export type {
ZaiModelCapabilities,
ZaiReasoningEffort,
ZaiThinkingConfig,
ZaiVlmConfig,
} from "./zai-web/protocol.ts";
export { parseZaiFrame } from "./zai-web/stream.ts";
export type { ZaiDelta } from "./zai-web/stream.ts";
let cachedFeVersion: { value: string; expiresAt: number } | null = null;
type ZaiBrowserAttachments = NonNullable<Parameters<typeof browserBackedChat>[0]["attachments"]>;
/** Decode the request's image URLs into browser upload attachments. */
async function resolveZaiBrowserAttachments(
imageUrls: string[],
body: unknown
): Promise<
{ attachments: ZaiBrowserAttachments } | { errorResult: ReturnType<typeof makeErrorResult> }
> {
try {
const images = await resolveCursorImages(imageUrls);
return {
attachments: images.map((image, index) => ({
name: zaiImageFileName(image.mimeType, index),
mimeType: image.mimeType,
buffer: image.data,
})),
};
} catch (error) {
const message =
error instanceof CursorImageError
? error.message
: sanitizeErrorMessage(error instanceof Error ? error.message : "invalid image input");
return {
errorResult: makeErrorResult(
error instanceof CursorImageError ? error.status : 400,
`Z.ai image input error: ${message}`,
body,
ZAI_CHAT_URL
),
};
}
}
/**
* The call-log body for a browser-transport turn. There is no real upstream
* request payload to record here, so this reconstructs the equivalent shape the
* signed-API path logs, from the settings the browser UI was driven with.
*/
function buildZaiBrowserAuditBody(input: {
messages: Array<{ role: string; content: unknown }>;
modelId: string;
thinkingConfig: ZaiThinkingConfig;
vlmConfig: ZaiVlmConfig;
imageCount: number;
}): Record<string, unknown> {
const { thinkingConfig: thinking, vlmConfig: vlm } = input;
return {
browser_backed: true,
image_count: input.imageCount,
model: input.modelId,
messages: foldMessages(input.messages),
enable_thinking: thinking.enabled,
auto_web_search: vlm.websiteModeEnabled ? false : vlm.webSearchEnabled,
vlm_tools_enable: vlm.toolsEnabled,
vlm_web_search_enable: vlm.websiteModeEnabled && vlm.webSearchEnabled,
vlm_website_mode: vlm.websiteModeEnabled,
...(thinking.enabled && thinking.effortSupported ? { reasoning_effort: thinking.effort } : {}),
};
}
/**
* Drive-the-real-UI options for chat.z.ai: which selectors to type into and click,
* and the localStorage token the page reads at boot. `beforeSubmit` flips the
* Deep Think / web-search / tools switches to match the request.
*/
function buildZaiBrowserChatOptions(input: {
attachments: ZaiBrowserAttachments;
messages: Array<{ role: string; content: unknown }>;
modelId: string;
signal?: AbortSignal | null;
thinkingConfig: ZaiThinkingConfig;
token: string;
vlmConfig: ZaiVlmConfig;
}): Parameters<typeof browserBackedChat>[0] {
const poolKey = `zai-web:${createHash("sha256").update(input.token).digest("hex").slice(0, 24)}`;
return {
poolKey,
chatUrl: ZAI_CHAT_URL,
chatPageUrl: `${ZAI_BASE_URL}/?model=${encodeURIComponent(browserModelName(input.modelId))}`,
userMessage: browserPrompt(input.messages),
localStorage: { token: input.token },
localStorageOrigin: ZAI_BASE_URL,
cookieDomain: "chat.z.ai",
chatUrlMatchDomain: "chat.z.ai",
userAgent: ZAI_USER_AGENT,
locale: "en-US",
timezone: "Asia/Seoul",
inputSelector: "#chat-input",
submitButtonSelector: '[aria-label="Send Message"] button:not([disabled])',
submitButtonMode: "dom",
attachments: input.attachments,
beforeSubmit: (page) =>
configureZaiBrowserRequest(page, {
modelId: input.modelId,
thinking: input.thinkingConfig,
vlm: input.vlmConfig,
}),
postSubmitWaitMs: 30_000,
signal: input.signal,
reuseContext: true,
};
}
/** What either transport hands back: the upstream stream plus its call-log pair. */
type ZaiTransportResult = {
upstream: Response;
auditHeaders: Record<string, string>;
auditBody: Record<string, unknown>;
};
type ZaiResolvedRequest = {
captchaVerifyParam: string;
imageUrls: string[];
messages: Array<{ role: string; content: unknown }>;
modelId: string;
prompt: string;
thinkingConfig: ZaiThinkingConfig;
token: string;
userId: string;
vlmConfig: ZaiVlmConfig;
};
/**
* Validate the credential and body, and resolve everything both transports need.
*
* All four rejections are client errors that must never reach the upstream: no
* usable session token, no user turn, an image sent to a text-only model, and a
* JWT with no user id (which the signed-API path needs to build its signature).
*/
function resolveZaiRequest(
input: ExecuteInput
): { request: ZaiResolvedRequest } | { errorResult: ReturnType<typeof makeErrorResult> } {
const { body, credentials, model } = input;
const bodyObj = (body || {}) as Record<string, unknown>;
const fail = (message: string) => ({
errorResult: makeErrorResult(400, message, body, ZAI_CHAT_URL),
});
const rawCredential = String(credentials?.apiKey ?? credentials?.accessToken ?? "").trim();
const token = extractZaiToken(rawCredential);
if (!token) {
return fail(
'Missing Z.ai web-session credential — copy the "token" value from chat.z.ai Local Storage.'
);
}
const messages = (bodyObj.messages as Array<{ role: string; content: unknown }>) || [];
const prompt = latestUserPrompt(messages);
const imageUrls = collectZaiImageUrls(messages);
if (!prompt && imageUrls.length === 0) {
return fail("Z.ai requires at least one user message");
}
const modelId = (bodyObj.model as string) || model || ZAI_DEFAULT_MODEL;
if (imageUrls.length > 0 && !getZaiModelCapabilities(modelId).vision) {
return fail(
`Z.ai model ${unprefixedModelId(modelId)} does not accept image input; use GLM-5V-Turbo.`
);
}
const userId = extractZaiUserId(token);
if (!userId) {
return fail(
"Invalid Z.ai web-session credential — its JWT payload does not contain the required user id."
);
}
return {
request: {
captchaVerifyParam: resolveZaiCaptchaVerifyParam(credentials, bodyObj),
imageUrls,
messages,
modelId,
prompt,
thinkingConfig: resolveZaiThinkingConfig(modelId, bodyObj),
token,
userId,
vlmConfig: resolveZaiVlmConfig(modelId, bodyObj),
},
};
}
export class ZaiWebExecutor extends BaseExecutor {
constructor() {
super("zai-web", { id: "zai-web", baseUrl: ZAI_BASE_URL });
}
private async resolveFrontendVersion(signal?: AbortSignal | null): Promise<string> {
if (cachedFeVersion && cachedFeVersion.expiresAt > Date.now()) {
return cachedFeVersion.value;
}
let version = ZAI_DEFAULT_FE_VERSION;
try {
const response = await fetch(`${ZAI_BASE_URL}/`, {
headers: { Accept: "text/html", "User-Agent": ZAI_USER_AGENT },
signal,
});
if (response.ok) {
version = parseZaiFrontendVersion(await response.text()) ?? version;
}
} catch {
// The current verified version remains a safe fallback when homepage probing fails.
}
cachedFeVersion = {
value: version,
expiresAt: Date.now() + ZAI_FE_VERSION_CACHE_TTL_MS,
};
return version;
}
private async createRemoteChat(input: {
messages: Array<{ role: string; content: unknown }>;
modelId: string;
token: string;
enableThinking: boolean;
reasoningEffort: ZaiReasoningEffort;
vlmConfig: ZaiVlmConfig;
signal?: AbortSignal | null;
originalBody: unknown;
}): Promise<
{ chatId: string; userMessageId: string } | { errorResult: ReturnType<typeof makeErrorResult> }
> {
const { userMessageId, payload } = buildZaiNewChatBody(
input.messages,
input.modelId,
input.enableThinking,
input.reasoningEffort,
input.vlmConfig
);
let response: Response;
try {
response = await fetch(ZAI_NEW_CHAT_URL, {
method: "POST",
headers: buildZaiHeaders(input.token, {
accept: "application/json",
}),
body: JSON.stringify(payload),
signal: input.signal,
});
} catch (error) {
const message = sanitizeErrorMessage(
error instanceof Error ? error.message : "unknown network error"
);
return {
errorResult: makeErrorResult(
502,
`Z.ai chat creation failed: ${message}`,
input.originalBody,
ZAI_NEW_CHAT_URL
),
};
}
if (!response.ok) {
const errorText = await response.text().catch(() => "");
return {
errorResult: makeErrorResult(
response.status,
`Z.ai chat creation error: ${sanitizeErrorMessage(errorText)}`,
input.originalBody,
ZAI_NEW_CHAT_URL
),
};
}
const result = asRecord(await response.json().catch(() => null));
const chatId = typeof result?.id === "string" ? result.id : "";
if (!chatId) {
return {
errorResult: makeErrorResult(
502,
"Z.ai chat creation returned no chat id",
input.originalBody,
ZAI_NEW_CHAT_URL
),
};
}
return { chatId, userMessageId };
}
private async fetchUpstream(
completionUrl: string,
reqHeaders: Record<string, string>,
reqBody: Record<string, unknown>,
body: unknown,
signal: AbortSignal | null | undefined
): Promise<{ upstream: Response } | { errorResult: ReturnType<typeof makeErrorResult> }> {
let upstream: Response;
try {
upstream = await fetch(completionUrl, {
method: "POST",
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal,
});
} catch (error) {
const message = sanitizeErrorMessage(
error instanceof Error ? error.message : "unknown network error"
);
return {
errorResult: makeErrorResult(502, `Z.ai fetch failed: ${message}`, body, ZAI_CHAT_URL),
};
}
if (!upstream.ok) {
const errorText = await upstream.text().catch(() => "");
return {
errorResult: makeErrorResult(
upstream.status,
`Z.ai error: ${sanitizeErrorMessage(errorText)}`,
body,
ZAI_CHAT_URL
),
};
}
return { upstream };
}
private async fetchThroughBrowser(input: {
body: unknown;
messages: Array<{ role: string; content: unknown }>;
modelId: string;
imageUrls: string[];
signal?: AbortSignal | null;
thinkingConfig: ZaiThinkingConfig;
token: string;
vlmConfig: ZaiVlmConfig;
}): Promise<ZaiTransportResult | { errorResult: ReturnType<typeof makeErrorResult> }> {
const resolved = await resolveZaiBrowserAttachments(input.imageUrls, input.body);
if ("errorResult" in resolved) return resolved;
const { attachments } = resolved;
let result: Awaited<ReturnType<typeof browserBackedChat>>;
try {
result = await browserBackedChat(buildZaiBrowserChatOptions({ ...input, attachments }));
} catch (error) {
const message = sanitizeErrorMessage(
error instanceof Error ? error.message : "browser transport unavailable"
);
return {
errorResult: makeErrorResult(
502,
`Z.ai browser transport failed: ${message}`,
input.body,
ZAI_CHAT_URL
),
};
}
if (result.status < 200 || result.status >= 300) {
return {
errorResult: makeErrorResult(
result.status || 502,
describeZaiBrowserFailure(result),
input.body,
ZAI_CHAT_URL
),
};
}
return {
upstream: new Response(new Uint8Array(result.body), {
status: result.status,
headers: {
"Content-Type": result.contentType || "text/event-stream",
},
}),
auditHeaders: {
Authorization: "Bearer [REDACTED]",
"X-OmniRoute-Transport": "browser",
},
auditBody: buildZaiBrowserAuditBody({
messages: input.messages,
modelId: input.modelId,
thinkingConfig: input.thinkingConfig,
vlmConfig: input.vlmConfig,
imageCount: attachments.length,
}),
};
}
/**
* Signed-API transport: create a chat server-side, then POST the completion with
* a CAPTCHA proof and a per-request signature. Only reachable when the caller
* supplied a proof and sent no images.
*/
private async fetchViaSignedApi(
request: ZaiResolvedRequest,
input: ExecuteInput
): Promise<ZaiTransportResult | { errorResult: ReturnType<typeof makeErrorResult> }> {
const { body, signal } = input;
const bodyObj = (body || {}) as Record<string, unknown>;
const { messages, modelId, prompt, thinkingConfig, token, userId, vlmConfig } = request;
const frontendVersion = await this.resolveFrontendVersion(signal);
const createdChat = await this.createRemoteChat({
messages,
modelId,
token,
enableThinking: thinkingConfig.enabled,
reasoningEffort: thinkingConfig.effort,
vlmConfig,
signal,
originalBody: body,
});
if ("errorResult" in createdChat) return createdChat;
const timestamp = Date.now();
const requestId = randomUUID();
const signature = buildZaiSignature({ prompt, requestId, timestamp, userId });
const completionUrl = buildZaiCompletionUrl({ requestId, timestamp, token, userId });
const reqHeaders = buildZaiHeaders(token, {
accept: "text/event-stream",
frontendVersion,
signature,
});
const reqBody = buildZaiRequestBody({
body: bodyObj,
captchaVerifyParam: request.captchaVerifyParam,
chatId: createdChat.chatId,
messages,
modelId,
prompt,
userMessageId: createdChat.userMessageId,
enableThinking: thinkingConfig.enabled,
reasoningEffort: thinkingConfig.effort,
reasoningEffortSupported: thinkingConfig.effortSupported,
vlmConfig,
});
const fetched = await this.fetchUpstream(completionUrl, reqHeaders, reqBody, body, signal);
if ("errorResult" in fetched) return fetched;
return {
upstream: fetched.upstream,
auditHeaders: {
...reqHeaders,
Authorization: "Bearer [REDACTED]",
"X-Signature": "[REDACTED]",
},
auditBody: { ...reqBody, captcha_verify_param: "[REDACTED]" },
};
}
async execute(input: ExecuteInput) {
const { body, signal, stream: wantStream } = input;
const resolved = resolveZaiRequest(input);
if ("errorResult" in resolved) return resolved.errorResult;
const request = resolved.request;
const { imageUrls, messages, modelId, thinkingConfig, token, vlmConfig } = request;
const useSignedApi = Boolean(request.captchaVerifyParam) && imageUrls.length === 0;
const fetched = useSignedApi
? await this.fetchViaSignedApi(request, input)
: await this.fetchThroughBrowser({
body,
imageUrls,
messages,
modelId,
signal,
thinkingConfig,
token,
vlmConfig,
});
if ("errorResult" in fetched) return fetched.errorResult;
const { upstream, auditHeaders, auditBody } = fetched;
const id = `chatcmpl-zai-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const sourceBody =
upstream.body ?? new ReadableStream({ start: (controller) => controller.close() });
const emitChunk = makeZaiChunkEmitter(id, created, modelId);
if (wantStream) {
const outStream = buildZaiStreamingBody(sourceBody, emitChunk, signal);
return {
response: new Response(outStream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url: ZAI_CHAT_URL,
headers: auditHeaders,
transformedBody: auditBody,
};
}
let answer: string;
let reasoning: string;
try {
({ answer, reasoning } = await collectZaiNonStreaming(sourceBody));
} catch (error) {
const message = sanitizeErrorMessage(
error instanceof Error ? error.message : "invalid upstream stream"
);
return makeErrorResult(502, `Z.ai stream failed: ${message}`, body, ZAI_CHAT_URL);
}
const message: Record<string, unknown> = { role: "assistant", content: answer };
if (reasoning) message.reasoning_content = reasoning;
const completion = {
id,
object: "chat.completion",
created,
model: modelId,
choices: [{ index: 0, message, finish_reason: "stop" }],
};
return {
response: new Response(JSON.stringify(completion), {
headers: { "Content-Type": "application/json" },
}),
url: ZAI_CHAT_URL,
headers: auditHeaders,
transformedBody: auditBody,
};
}
}