mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 13:42:09 +03:00
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 <token>, 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.
This commit is contained in:
@@ -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 <token>`, 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)
|
||||
|
||||
@@ -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<string, RegistryEntry> = {
|
||||
freeaiapikey: freeaiapikeyProvider,
|
||||
qwen: qwenProvider,
|
||||
"qwen-web": qwen_webProvider,
|
||||
"zai-web": zai_webProvider,
|
||||
modal: modalProvider,
|
||||
zenmux: zenmuxProvider,
|
||||
leonardo: leonardoProvider,
|
||||
|
||||
19
open-sse/config/providers/registry/zai-web/index.ts
Normal file
19
open-sse/config/providers/registry/zai-web/index.ts
Normal file
@@ -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)" },
|
||||
],
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
393
open-sse/executors/zai-web.ts
Normal file
393
open-sse/executors/zai-web.ts
Normal file
@@ -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 <token>` —
|
||||
* 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<Record<string, unknown>>): ZaiDelta {
|
||||
const delta = (choices[0]?.delta ?? {}) as Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
data: Record<string, unknown>
|
||||
): 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<string, unknown>;
|
||||
|
||||
const choices = frame.choices as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(choices) && choices.length > 0) {
|
||||
return parseOpenAiShapedFrame(choices);
|
||||
}
|
||||
|
||||
const data = (frame.data ?? frame) as Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"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<string, unknown> {
|
||||
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<Uint8Array>,
|
||||
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<Uint8Array>
|
||||
): 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<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(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<string, unknown>;
|
||||
|
||||
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=<JWT>).",
|
||||
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<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: CHAT_URL,
|
||||
headers: reqHeaders,
|
||||
transformedBody: reqBody,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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=<JWT> cookie)",
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolved public site for a web-session provider (href + display host). */
|
||||
|
||||
@@ -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`,
|
||||
|
||||
223
tests/unit/executor-zai-web.test.ts
Normal file
223
tests/unit/executor-zai-web.test.ts
Normal file
@@ -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<string, string>;
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user