From 9074ede9dc6fa11052861e1b7e089b265f3fde99 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 10 Jul 2026 09:47:22 -0300 Subject: [PATCH] feat(providers): add Z.ai Web free web-cookie provider (#4056) New zai-web web-session provider drives the free chat.z.ai consumer chat UI via a pasted browser cookie, distinct from the existing API-key zai/glm/glm-cn/glmt providers (api.z.ai). ZaiWebExecutor posts to chat.z.ai/api/chat/completions with the cookie forwarded both as Cookie and Authorization: Bearer , and normalizes both z.ai's internal delta_content/phase SSE envelope and a pass-through OpenAI-shaped choices[].delta frame into standard chat-completion chunks. Registered in WEB_COOKIE_PROVIDERS, WEB_SESSION_CREDENTIAL_REQUIREMENTS, the provider registry (GLM-4.6/4.5/4.5V models), the executor factory, and tokenExtractionConfig.ts for in-app cookie capture. --- CHANGELOG.md | 1 + open-sse/config/providers/index.ts | 2 + .../providers/registry/zai-web/index.ts | 19 + open-sse/executors/index.ts | 3 + open-sse/executors/zai-web.ts | 393 ++++++++++++++++++ open-sse/services/tokenExtractionConfig.ts | 11 + src/shared/constants/providers/web-cookie.ts | 15 + src/shared/providers/webSessionCredentials.ts | 7 + tests/unit/executor-zai-web.test.ts | 223 ++++++++++ 9 files changed, 674 insertions(+) create mode 100644 open-sse/config/providers/registry/zai-web/index.ts create mode 100644 open-sse/executors/zai-web.ts create mode 100644 tests/unit/executor-zai-web.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64098b3fd6..5edaa1d8e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral ### ✨ New Features +- **Z.ai Web (free web-session provider)**: new `zai-web` web-cookie provider drives the free chat.z.ai consumer chat UI via a pasted browser session cookie, distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers (`api.z.ai`) — modeled on the `doubao-web`/`venice-web` cookie executors and the pre-existing `chatglm-web` credential requirement/token-extraction entries. `ZaiWebExecutor` (`open-sse/executors/zai-web.ts`) posts to `chat.z.ai/api/chat/completions` with the cookie forwarded both as `Cookie` and as `Authorization: Bearer `, and normalizes both z.ai's internal `delta_content`/`phase` SSE envelope and a pass-through OpenAI-shaped `choices[].delta` frame into standard chat-completion chunks. Registered in `WEB_COOKIE_PROVIDERS`, `WEB_SESSION_CREDENTIAL_REQUIREMENTS`, the provider registry (`zai-web` entry, GLM-4.6/4.5/4.5V models), and `tokenExtractionConfig.ts` for in-app cookie capture. Regression guard: `tests/unit/executor-zai-web.test.ts` (16 tests — token extraction, frame parsing for both SSE shapes, streaming and non-streaming aggregation, error paths). (#4056) - **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625) - **Per-combo reasoning token buffer toggle**: the combo builder now exposes an explicit checkbox for the `#3587` reasoning-model `max_tokens` buffer, defaulting to the existing enabled behavior, so a combo can opt out without hand-editing raw JSON config (#6702 — thanks @xz-dev) - **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen) diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index a7e0fa51eb..5e712d45ab 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -76,6 +76,7 @@ import { chipotleProvider } from "./registry/chipotle/index.ts"; import { freeaiapikeyProvider } from "./registry/freeaiapikey/index.ts"; import { qwenProvider } from "./registry/qwen/index.ts"; import { qwen_webProvider } from "./registry/qwen/web/index.ts"; +import { zai_webProvider } from "./registry/zai-web/index.ts"; import { modalProvider } from "./registry/modal/index.ts"; import { zenmuxProvider } from "./registry/zenmux/index.ts"; import { leonardoProvider } from "./registry/leonardo/index.ts"; @@ -259,6 +260,7 @@ export const REGISTRY: Record = { freeaiapikey: freeaiapikeyProvider, qwen: qwenProvider, "qwen-web": qwen_webProvider, + "zai-web": zai_webProvider, modal: modalProvider, zenmux: zenmuxProvider, leonardo: leonardoProvider, diff --git a/open-sse/config/providers/registry/zai-web/index.ts b/open-sse/config/providers/registry/zai-web/index.ts new file mode 100644 index 0000000000..47ad4d5b2f --- /dev/null +++ b/open-sse/config/providers/registry/zai-web/index.ts @@ -0,0 +1,19 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const zai_webProvider: RegistryEntry = { + id: "zai-web", + alias: "zw", + format: "openai", + executor: "zai-web", + // Free consumer web chat at chat.z.ai (Zhipu AI) — see + // `open-sse/executors/zai-web.ts` for the cookie/session wire format. + // Distinct from the API-key `zai`/`glm` providers (api.z.ai). + baseUrl: "https://chat.z.ai", + authType: "apikey", + authHeader: "cookie", + models: [ + { id: "glm-4.6", name: "GLM-4.6" }, + { id: "glm-4.5", name: "GLM-4.5" }, + { id: "glm-4.5v", name: "GLM-4.5V (Vision)" }, + ], +}; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index a922247c8c..039e7fa637 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -49,6 +49,7 @@ import { V0VercelWebExecutor } from "./v0-vercel-web.ts"; import { KimiWebExecutor } from "./kimi-web.ts"; import { DoubaoWebExecutor } from "./doubao-web.ts"; import { QwenWebExecutor } from "./qwen-web.ts"; +import { ZaiWebExecutor } from "./zai-web.ts"; import { KimiExecutor } from "./kimi.ts"; import { TheOldLlmExecutor } from "./theoldllm.ts"; import { ChipotleExecutor } from "./chipotle.ts"; @@ -147,6 +148,8 @@ const executors = { db: new DoubaoWebExecutor(), // Alias "qwen-web": new QwenWebExecutor(), qw: new QwenWebExecutor(), // Alias + "zai-web": new ZaiWebExecutor(), + zw: new ZaiWebExecutor(), // Alias theoldllm: new TheOldLlmExecutor(), tllm: new TheOldLlmExecutor(), // Alias chipotle: new ChipotleExecutor(), diff --git a/open-sse/executors/zai-web.ts b/open-sse/executors/zai-web.ts new file mode 100644 index 0000000000..7b5808892f --- /dev/null +++ b/open-sse/executors/zai-web.ts @@ -0,0 +1,393 @@ +/** + * ZaiWebExecutor — Z.ai Web Chat (chat.z.ai, free web-session/cookie auth) + * + * Distinct from the existing API-key `zai`/`glm`/`glm-cn`/`glmt` providers + * (Anthropic/OpenAI-compatible `api.z.ai`, see `providers/apikey/regional.ts`). + * This executor targets the *consumer chat* frontend at chat.z.ai — the same + * product family as `chatglm.cn` (Zhipu AI), but the international domain — + * so users without an API key can drive it for free via their browser session, + * modeled on the `chatglm-web` credential entry (#4056) and the `doubao-web` / + * `venice-web` cookie executors. + * + * Endpoint: POST https://chat.z.ai/api/chat/completions + * Auth: full Cookie header from chat.z.ai (must contain the `token` JWT). + * Sent both as `Cookie` and as `Authorization: Bearer ` — + * the SPA's own fetch client sets both, and stripping either one + * has been reported (upstream repos) to 401 the request. + * Response: SSE. Frames are z.ai's internal envelope + * `{"type":"chat:completion","data":{"delta_content":"...","phase":"answer","done":false}}` + * — mirrored from the shared Zhipu chatglm.cn/chat.z.ai frontend + * protocol. Some deployments/models pass through an already + * OpenAI-shaped `{"choices":[{"delta":{"content":"..."}}]}` frame + * instead, so the parser accepts both shapes defensively. + */ +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { + makeExecutorErrorResult as makeErrorResult, + normalizeCookie, + sanitizeErrorMessage, +} from "../utils/error.ts"; + +const BASE_URL = "https://chat.z.ai"; +const CHAT_URL = `${BASE_URL}/api/chat/completions`; +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +/** Extract the `token` cookie value (JWT) from a full Cookie header string. */ +export function extractZaiToken(rawCookie: string): string { + const cookie = normalizeCookie(rawCookie.trim()); + if (!cookie) return ""; + const match = cookie.match(/(?:^|;\s*)token=([^;]+)/); + if (match) return match[1].trim(); + // Users may paste the bare JWT with no `token=` prefix. + return cookie.includes(";") || cookie.includes("=") ? "" : cookie; +} + +/** + * One parsed delta out of a z.ai SSE frame: either a content/reasoning chunk + * or a signal that the stream has finished. + */ +export interface ZaiDelta { + content: string; + reasoning: string; + done: boolean; +} + +/** Parse an already OpenAI-shaped `{choices:[{delta}]}` pass-through frame. */ +function parseOpenAiShapedFrame(choices: Array>): ZaiDelta { + const delta = (choices[0]?.delta ?? {}) as Record; + const finishReason = choices[0]?.finish_reason; + return { + content: typeof delta.content === "string" ? delta.content : "", + reasoning: typeof delta.reasoning_content === "string" ? delta.reasoning_content : "", + done: finishReason != null, + }; +} + +/** Parse the z.ai / chatglm internal `{data:{delta_content,phase,done}}` envelope. */ +function parseInternalEnvelopeFrame( + frame: Record, + data: Record +): ZaiDelta | null { + const phase = String(data.phase ?? ""); + const deltaContent = data.delta_content ?? data.edit_content ?? data.content; + const done = + data.done === true || + phase === "done" || + phase === "finish" || + String(frame.type ?? "") === "chat:completion:finish"; + + if (typeof deltaContent === "string" && deltaContent) { + const isThinking = phase === "thinking"; + return { + content: isThinking ? "" : deltaContent, + reasoning: isThinking ? deltaContent : "", + done, + }; + } + if (done) return { content: "", reasoning: "", done: true }; + return null; +} + +/** + * Parse a single decoded z.ai SSE `data:` JSON payload into a normalized + * delta. Handles both the internal `{data:{delta_content,phase,done}}` + * envelope and a pass-through OpenAI-shaped `{choices:[{delta}]}` frame. + */ +export function parseZaiFrame(raw: unknown): ZaiDelta | null { + if (!raw || typeof raw !== "object") return null; + const frame = raw as Record; + + const choices = frame.choices as Array> | undefined; + if (Array.isArray(choices) && choices.length > 0) { + return parseOpenAiShapedFrame(choices); + } + + const data = (frame.data ?? frame) as Record; + return parseInternalEnvelopeFrame(frame, data); +} + +export function foldMessages( + messages: Array<{ role: string; content: unknown }> +): Array<{ role: string; content: string }> { + return messages.map((m) => ({ + role: m.role, + content: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""), + })); +} + +/** Split a chunk of decoded SSE text into complete `data:` payload strings. */ +function extractSseDataPayloads(buffer: { text: string }, incoming: string): string[] { + buffer.text += incoming; + const lines = buffer.text.split("\n"); + buffer.text = lines.pop() || ""; + const payloads: string[] = []; + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (!data || data === "[DONE]") continue; + payloads.push(data); + } + return payloads; +} + +/** Parse a raw SSE payload string into a normalized delta, or null if unusable. */ +function parseSsePayload(data: string): ZaiDelta | null { + try { + return parseZaiFrame(JSON.parse(data)); + } catch { + return null; + } +} + +type ChunkEmitter = ( + controller: ReadableStreamDefaultController, + delta: Record, + finish?: string | null +) => void; + +/** Emit role/reasoning/content/stop chunks for one delta. Returns true when the stream ended. */ +function emitDeltaChunks( + controller: ReadableStreamDefaultController, + delta: ZaiDelta, + emitChunk: ChunkEmitter, + roleState: { emitted: boolean } +): boolean { + if (!roleState.emitted && (delta.content || delta.reasoning)) { + roleState.emitted = true; + emitChunk(controller, { role: "assistant", content: "" }); + } + if (delta.reasoning) emitChunk(controller, { reasoning_content: delta.reasoning }); + if (delta.content) emitChunk(controller, { content: delta.content }); + if (delta.done) { + emitChunk(controller, {}, "stop"); + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + controller.close(); + return true; + } + return false; +} + +export class ZaiWebExecutor extends BaseExecutor { + constructor() { + super("zai-web", { id: "zai-web", baseUrl: BASE_URL }); + } + + private buildHeaders(rawCookie: string, token: string): Record { + const headers: Record = { + "Content-Type": "application/json", + Accept: "text/event-stream", + "User-Agent": USER_AGENT, + Origin: BASE_URL, + Referer: `${BASE_URL}/`, + }; + if (rawCookie) headers.Cookie = rawCookie; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; + } + + private buildRequestBody( + messages: Array<{ role: string; content: unknown }>, + modelId: string + ): Record { + return { + stream: true, + model: modelId, + messages: foldMessages(messages), + params: {}, + features: { + image_generation: false, + web_search: false, + auto_web_search: false, + }, + }; + } + + /** Drain the streaming response body into an OpenAI-shaped SSE ReadableStream. */ + private buildStreamingBody( + sourceBody: ReadableStream, + modelId: string, + emitChunk: ChunkEmitter, + signal: AbortSignal | null | undefined + ): ReadableStream { + const decoder = new TextDecoder(); + return new ReadableStream({ + async start(controller) { + const reader = sourceBody.getReader(); + const buffer = { text: "" }; + const roleState = { emitted: false }; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const payloads = extractSseDataPayloads(buffer, decoder.decode(value, { stream: true })); + + for (const raw of payloads) { + const delta = parseSsePayload(raw); + if (!delta) continue; + if (emitDeltaChunks(controller, delta, emitChunk, roleState)) return; + } + } + if (!roleState.emitted) emitChunk(controller, { role: "assistant", content: "" }); + emitChunk(controller, {}, "stop"); + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + controller.close(); + } catch (err) { + if (!signal?.aborted) { + try { + controller.error(err); + } catch { + /* controller already closed */ + } + } + } + }, + }); + } + + /** Drain the response body and aggregate all deltas into a single answer/reasoning pair. */ + private async collectNonStreaming( + sourceBody: ReadableStream + ): Promise<{ answer: string; reasoning: string }> { + const decoder = new TextDecoder(); + let answer = ""; + let reasoning = ""; + const reader = sourceBody.getReader(); + const buffer = { text: "" }; + try { + outer: while (true) { + const { done, value } = await reader.read(); + if (done) break; + const payloads = extractSseDataPayloads(buffer, decoder.decode(value, { stream: true })); + for (const raw of payloads) { + const delta = parseSsePayload(raw); + if (!delta) continue; + if (delta.reasoning) reasoning += delta.reasoning; + if (delta.content) answer += delta.content; + if (delta.done) break outer; + } + } + } catch { + /* best-effort — return what we have */ + } + return { answer, reasoning }; + } + + /** POST the chat request upstream. Returns either the upstream Response or an error result. */ + private async fetchUpstream( + reqHeaders: Record, + reqBody: Record, + body: unknown, + signal: AbortSignal | null | undefined + ): Promise<{ upstream: Response } | { errorResult: ReturnType }> { + let upstream: Response; + try { + upstream = await fetch(CHAT_URL, { + method: "POST", + headers: reqHeaders, + body: JSON.stringify(reqBody), + signal, + }); + } catch (err) { + return { + errorResult: makeErrorResult( + 502, + `Z.ai fetch failed: ${err instanceof Error ? err.message : "unknown"}`, + body, + CHAT_URL + ), + }; + } + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + return { + errorResult: makeErrorResult( + upstream.status, + `Z.ai error: ${sanitizeErrorMessage(errText)}`, + body, + CHAT_URL + ), + }; + } + return { upstream }; + } + + private makeChunkEmitter(id: string, created: number, modelId: string): ChunkEmitter { + return (controller, delta, finish = null) => { + const chunk = { + id, + object: "chat.completion.chunk", + created, + model: modelId, + choices: [{ index: 0, delta, finish_reason: finish }], + }; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); + }; + } + + async execute(input: ExecuteInput) { + const { body, credentials, signal, stream: wantStream } = input; + const bodyObj = (body || {}) as Record; + + const rawCookie = normalizeCookie(String(credentials?.apiKey ?? "").trim()); + const token = extractZaiToken(rawCookie); + if (!rawCookie && !token) { + return makeErrorResult( + 400, + "Missing Z.ai session — paste the full Cookie header from chat.z.ai (must contain token=).", + body, + CHAT_URL + ); + } + + const messages = (bodyObj.messages as Array<{ role: string; content: unknown }>) || []; + const modelId = (bodyObj.model as string) || "glm-4.6"; + const reqBody = this.buildRequestBody(messages, modelId); + const reqHeaders = this.buildHeaders(rawCookie, token); + + const fetched = await this.fetchUpstream(reqHeaders, reqBody, body, signal); + if ("errorResult" in fetched) return fetched.errorResult; + const { upstream } = fetched; + + const id = `chatcmpl-zai-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const sourceBody = upstream.body ?? new ReadableStream({ start: (c) => c.close() }); + const emitChunk = this.makeChunkEmitter(id, created, modelId); + + if (wantStream) { + const outStream = this.buildStreamingBody(sourceBody, modelId, emitChunk, signal); + return { + response: new Response(outStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: CHAT_URL, + headers: reqHeaders, + transformedBody: reqBody, + }; + } + + const { answer, reasoning } = await this.collectNonStreaming(sourceBody); + const message: Record = { 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: CHAT_URL, + headers: reqHeaders, + transformedBody: reqBody, + }; + } +} diff --git a/open-sse/services/tokenExtractionConfig.ts b/open-sse/services/tokenExtractionConfig.ts index 8d7736f7e1..7238a38ddd 100644 --- a/open-sse/services/tokenExtractionConfig.ts +++ b/open-sse/services/tokenExtractionConfig.ts @@ -374,6 +374,17 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [ "Log in to Manus at manus.im. The session cookie will be extracted.", { cookieDomain: ".manus.im" } ), + + // ── Z.ai Web (#4056) ──────────────────────────────────────── + config( + "zai-web", + "Z.ai Web (Free)", + "https://chat.z.ai/", + "https://chat.z.ai", + [{ type: "cookie", name: "token", domain: ".z.ai" }], + "Log in to Z.ai at chat.z.ai. The session token will be extracted.", + { cookieDomain: ".z.ai" } + ), ]; // ─── Registry ─────────────────────────────────────────────────────────────── diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index 1d8e767189..e5508e76c0 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -319,6 +319,21 @@ export const WEB_COOKIE_PROVIDERS = { authHint: "Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days.", }, + "zai-web": { + id: "zai-web", + alias: "zw", + name: "Z.ai Web (Free)", + icon: "auto_awesome", + color: "#2563EB", + textIcon: "ZW", + website: "https://chat.z.ai", + hasFree: true, + freeNote: + "Free consumer web session — GLM chat models via chat.z.ai. Distinct from the API-key zai/glm providers. No subscription required.", + subscriptionRisk: true, + riskNoticeVariant: "webCookie", + authHint: "Paste the full Cookie header from chat.z.ai (must include the token= cookie)", + }, }; /** Resolved public site for a web-session provider (href + display host). */ diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 77d3d3ca5e..56a4423185 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -227,6 +227,13 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: true, storageKeys: ["cookie", "manus_session"], }, + "zai-web": { + kind: "cookie", + credentialName: "token", + placeholder: "token=... or full Cookie header from chat.z.ai", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "token"], + }, lmarena: { kind: "cookie", // lmarena.ai's auth cookie is `arena-auth-prod-v1` (the legacy hint said `session`, diff --git a/tests/unit/executor-zai-web.test.ts b/tests/unit/executor-zai-web.test.ts new file mode 100644 index 0000000000..2634c73e20 --- /dev/null +++ b/tests/unit/executor-zai-web.test.ts @@ -0,0 +1,223 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const mod = await import("../../open-sse/executors/zai-web.ts"); + +describe("ZaiWebExecutor", () => { + it("can be instantiated", () => { + const executor = new mod.ZaiWebExecutor(); + assert.ok(executor); + }); + + it("extracts the token cookie value from a full Cookie header", () => { + assert.equal(mod.extractZaiToken("token=abc123; other=xyz"), "abc123"); + assert.equal(mod.extractZaiToken("Cookie: other=xyz; token=abc123"), "abc123"); + }); + + it("accepts a bare JWT/token with no cookie name prefix", () => { + // a bare token with no '=' and no ';' falls through to the raw string + assert.equal( + mod.extractZaiToken("eyJhbGciOiJIUzI1NiJ9.payload.sig"), + "eyJhbGciOiJIUzI1NiJ9.payload.sig" + ); + assert.equal(mod.extractZaiToken("plainsessiontoken"), "plainsessiontoken"); + }); + + it("returns empty string when no cookie is provided", () => { + assert.equal(mod.extractZaiToken(""), ""); + }); + + it("parses the internal z.ai delta_content/phase SSE envelope", () => { + const delta = mod.parseZaiFrame({ + type: "chat:completion", + data: { delta_content: "Hello", phase: "answer", done: false }, + }); + assert.deepEqual(delta, { content: "Hello", reasoning: "", done: false }); + }); + + it("routes thinking-phase content into the reasoning field", () => { + const delta = mod.parseZaiFrame({ + type: "chat:completion", + data: { delta_content: "pondering...", phase: "thinking", done: false }, + }); + assert.deepEqual(delta, { content: "", reasoning: "pondering...", done: false }); + }); + + it("detects end-of-stream from the internal envelope", () => { + const delta = mod.parseZaiFrame({ + type: "chat:completion", + data: { phase: "done", done: true }, + }); + assert.equal(delta?.done, true); + }); + + it("parses an OpenAI-shaped pass-through frame", () => { + const delta = mod.parseZaiFrame({ + choices: [{ delta: { content: "Hi there" }, finish_reason: null }], + }); + assert.deepEqual(delta, { content: "Hi there", reasoning: "", done: false }); + }); + + it("detects end-of-stream from an OpenAI-shaped finish_reason", () => { + const delta = mod.parseZaiFrame({ + choices: [{ delta: {}, finish_reason: "stop" }], + }); + assert.equal(delta?.done, true); + }); + + it("returns null for frames with no usable delta", () => { + assert.equal(mod.parseZaiFrame(null), null); + assert.equal(mod.parseZaiFrame({}), null); + assert.equal(mod.parseZaiFrame({ data: { phase: "answer" } }), null); + }); + + it("folds non-string message content into JSON strings", () => { + const folded = mod.foldMessages([ + { role: "user", content: "hi" }, + { role: "user", content: { foo: "bar" } }, + ]); + assert.deepEqual(folded, [ + { role: "user", content: "hi" }, + { role: "user", content: '{"foo":"bar"}' }, + ]); + }); + + it("returns a credential error when no cookie is provided", async () => { + const executor = new mod.ZaiWebExecutor(); + const result = await executor.execute({ + model: "glm-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "" }, + signal: null, + }); + + assert.equal(result.response.status, 400); + assert.equal(new URL(result.url).hostname, "chat.z.ai"); + const parsed = await result.response.json(); + assert.match(parsed.error.message, /Z\.ai session/); + }); + + it("sends the cookie + bearer token and builds the request body", async () => { + const originalFetch = globalThis.fetch; + let capturedUrl = ""; + let capturedInit: RequestInit | undefined; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + capturedUrl = String(url); + capturedInit = init; + return new Response("data: [DONE]\n\n", { + headers: { "Content-Type": "text/event-stream" }, + }); + }) as typeof fetch; + + try { + const executor = new mod.ZaiWebExecutor(); + await executor.execute({ + model: "glm-4.6", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: false, + credentials: { apiKey: "token=abc123; foo=bar" }, + signal: null, + }); + + assert.equal(capturedUrl, "https://chat.z.ai/api/chat/completions"); + const headers = capturedInit?.headers as Record; + assert.equal(headers.Cookie, "token=abc123; foo=bar"); + assert.equal(headers.Authorization, "Bearer abc123"); + + const parsedBody = JSON.parse(String(capturedInit?.body)); + assert.equal(parsedBody.model, "glm-4.6"); + assert.equal(parsedBody.stream, true); + assert.deepEqual(parsedBody.messages, [{ role: "user", content: "hello" }]); + assert.equal(parsedBody.features.web_search, false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("aggregates streamed internal-envelope deltas into a non-streaming completion", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + [ + `data: ${JSON.stringify({ type: "chat:completion", data: { delta_content: "Hel", phase: "answer", done: false } })}`, + `data: ${JSON.stringify({ type: "chat:completion", data: { delta_content: "lo", phase: "answer", done: false } })}`, + `data: ${JSON.stringify({ type: "chat:completion", data: { phase: "done", done: true } })}`, + "data: [DONE]", + "", + "", + ].join("\n"), + { headers: { "Content-Type": "text/event-stream" } } + )) as typeof fetch; + + try { + const executor = new mod.ZaiWebExecutor(); + const result = await executor.execute({ + model: "glm-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "token=abc123" }, + signal: null, + }); + + const completion = await result.response.json(); + assert.equal(completion.choices[0].message.content, "Hello"); + assert.equal(completion.choices[0].finish_reason, "stop"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("streams internal-envelope deltas as OpenAI-shaped SSE chunks", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + [ + `data: ${JSON.stringify({ type: "chat:completion", data: { delta_content: "Hi", phase: "answer", done: false } })}`, + `data: ${JSON.stringify({ type: "chat:completion", data: { phase: "done", done: true } })}`, + "", + "", + ].join("\n"), + { headers: { "Content-Type": "text/event-stream" } } + )) as typeof fetch; + + try { + const executor = new mod.ZaiWebExecutor(); + const result = await executor.execute({ + model: "glm-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: { apiKey: "token=abc123" }, + signal: null, + }); + + const text = await result.response.text(); + assert.match(text, /"content":"Hi"/); + assert.match(text, /"finish_reason":"stop"/); + assert.match(text, /data: \[DONE\]/); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("propagates upstream HTTP errors", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("session expired", { status: 401 })) as typeof fetch; + + try { + const executor = new mod.ZaiWebExecutor(); + const result = await executor.execute({ + model: "glm-4.6", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "token=abc123" }, + signal: null, + }); + + assert.equal(result.response.status, 401); + } finally { + globalThis.fetch = originalFetch; + } + }); +});