mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 16:42:16 +03:00
Landed with the design call resolved per the owner's pick — **option 1**: the synced store is now endpoint-agnostic (persistDiscoveredModels and managedModelImport no longer drop non-chat models at write time), and chat selectability moved to read time (auto-pool expansion in autoStrategy applies filterChatSelectableModels; the models-route projection already had its chatOnly filter). Your discovery test now passes end-to-end (3/3): /api/show capabilities persist per connection and image/embedding requests route through the advertising host. Reconciliation notes: conflicted areas merged onto the current tip (adobe discovery import, requestedModel preflight signature, resolvedProvider fast-path coexists with the synced-route override — explicit resolution wins); carried base-red drains (#10055 memoization, #11071 test variants) dropped as already-landed; the managed-model-import exclusion test was propagated to the new contract (image/video models persist; the read filter still hides them from chat pickers — pinned by a new assertion). Full battery: 205/206 focused (the one red is a confirmed periodic-timer timing flake on the loaded devbox — 20/20 isolated), autoCombo vitest 30/30, combo suites 46/46, gates + typecheck clean. Thank you @yourspraveen — the capability probe + routing design was right; it just needed the store contract opened up. Fixes #11087.
555 lines
17 KiB
TypeScript
555 lines
17 KiB
TypeScript
import { Buffer } from "node:buffer";
|
|
import { createHmac, randomUUID } from "node:crypto";
|
|
import type { ProviderCredentials } from "../base.ts";
|
|
import { extractImageUrls } from "../../utils/cursorImages.ts";
|
|
import { normalizeCookie, sanitizeErrorMessage } from "../../utils/error.ts";
|
|
|
|
export const ZAI_BASE_URL = "https://chat.z.ai";
|
|
export const ZAI_NEW_CHAT_URL = `${ZAI_BASE_URL}/api/v1/chats/new`;
|
|
export const ZAI_CHAT_URL = `${ZAI_BASE_URL}/api/v2/chat/completions`;
|
|
export const ZAI_DEFAULT_MODEL = "GLM-5.1";
|
|
export const ZAI_DEFAULT_FE_VERSION = "prod-fe-1.1.79";
|
|
export const ZAI_USER_AGENT =
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
|
|
export const ZAI_FE_VERSION_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
|
|
const CLIENT_PROTOCOL_VERSION = "0.0.1";
|
|
const SIGNATURE_KEY = "key-@@@@)))()((9))-xxxx&&&%%%%%";
|
|
|
|
export interface NewChatRequest {
|
|
payload: Record<string, unknown>;
|
|
userMessageId: string;
|
|
}
|
|
|
|
export type ZaiReasoningEffort = "high" | "max";
|
|
|
|
export interface ZaiThinkingConfig {
|
|
enabled: boolean;
|
|
effort: ZaiReasoningEffort;
|
|
effortSupported: boolean;
|
|
supported: boolean;
|
|
}
|
|
|
|
export interface ZaiModelCapabilities {
|
|
mcp: boolean;
|
|
reasoningEffort: boolean;
|
|
returnFc: boolean;
|
|
thinking: boolean;
|
|
vision: boolean;
|
|
vlmTools: boolean;
|
|
vlmWebSearch: boolean;
|
|
vlmWebsiteMode: boolean;
|
|
webSearch: boolean;
|
|
}
|
|
|
|
export interface ZaiVlmConfig {
|
|
toolsEnabled: boolean;
|
|
webSearchEnabled: boolean;
|
|
websiteModeEnabled: boolean;
|
|
}
|
|
|
|
const NO_ZAI_MODEL_CAPABILITIES: ZaiModelCapabilities = Object.freeze({
|
|
mcp: false,
|
|
reasoningEffort: false,
|
|
returnFc: false,
|
|
thinking: false,
|
|
vision: false,
|
|
vlmTools: false,
|
|
vlmWebSearch: false,
|
|
vlmWebsiteMode: false,
|
|
webSearch: false,
|
|
});
|
|
|
|
/**
|
|
* Verified against chat.z.ai/api/models (prod-fe-1.1.79).
|
|
* `returnFc` is the site's internal function-call result capability; it is
|
|
* distinct from accepting caller-supplied OpenAI `tools`.
|
|
*/
|
|
const ZAI_MODEL_CAPABILITIES: Record<string, ZaiModelCapabilities> = {
|
|
"glm-5.2": {
|
|
mcp: true,
|
|
reasoningEffort: true,
|
|
returnFc: true,
|
|
thinking: true,
|
|
vision: false,
|
|
vlmTools: false,
|
|
vlmWebSearch: false,
|
|
vlmWebsiteMode: false,
|
|
webSearch: true,
|
|
},
|
|
"glm-5.1": {
|
|
mcp: true,
|
|
reasoningEffort: false,
|
|
returnFc: true,
|
|
thinking: true,
|
|
vision: false,
|
|
vlmTools: false,
|
|
vlmWebSearch: false,
|
|
vlmWebsiteMode: false,
|
|
webSearch: true,
|
|
},
|
|
"glm-5-turbo": {
|
|
mcp: true,
|
|
reasoningEffort: false,
|
|
returnFc: true,
|
|
thinking: true,
|
|
vision: false,
|
|
vlmTools: false,
|
|
vlmWebSearch: false,
|
|
vlmWebsiteMode: false,
|
|
webSearch: true,
|
|
},
|
|
"glm-5v-turbo": {
|
|
mcp: false,
|
|
reasoningEffort: false,
|
|
returnFc: true,
|
|
thinking: true,
|
|
vision: true,
|
|
vlmTools: true,
|
|
vlmWebSearch: true,
|
|
vlmWebsiteMode: true,
|
|
webSearch: true,
|
|
},
|
|
};
|
|
|
|
export function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function browserFailureDetail(body: Buffer): string {
|
|
const raw = body.toString("utf8").trim();
|
|
if (!raw) return "";
|
|
try {
|
|
const parsed = asRecord(JSON.parse(raw));
|
|
const error = asRecord(parsed?.error);
|
|
const detail = error?.message ?? parsed?.detail ?? parsed?.message;
|
|
if (typeof detail === "string") return sanitizeErrorMessage(detail).slice(0, 500);
|
|
} catch {
|
|
// Non-JSON upstream errors are still useful after sanitizing and bounding them.
|
|
}
|
|
return sanitizeErrorMessage(raw).slice(0, 500);
|
|
}
|
|
|
|
export function describeZaiBrowserFailure(result: {
|
|
status: number;
|
|
body: Buffer;
|
|
observedPostUrls?: string[];
|
|
timing: { captureResponseMs: number; totalMs: number };
|
|
}): string {
|
|
const status = result.status > 0 ? String(result.status) : "no matching response";
|
|
const timing = `capture ${result.timing.captureResponseMs}ms, total ${result.timing.totalMs}ms`;
|
|
const observed =
|
|
result.observedPostUrls && result.observedPostUrls.length > 0
|
|
? ` Observed POST targets: ${result.observedPostUrls.join(", ")}.`
|
|
: "";
|
|
const detail =
|
|
browserFailureDetail(result.body) ||
|
|
(result.status === 0
|
|
? `The page did not issue the expected authenticated chat completion request.${observed}`
|
|
: "The browser response body was empty.");
|
|
return `Z.ai browser transport failed (${status}; ${timing}): ${detail}`;
|
|
}
|
|
|
|
function parseCredentialJson(raw: string): Record<string, unknown> | null {
|
|
if (!raw.trim().startsWith("{")) return null;
|
|
try {
|
|
return asRecord(JSON.parse(raw));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Extract the localStorage Bearer token, while accepting legacy token= input. */
|
|
export function extractZaiToken(rawCredential: string): string {
|
|
const trimmed = rawCredential.trim();
|
|
const json = parseCredentialJson(trimmed);
|
|
if (json) {
|
|
const token = json.token ?? json.accessToken ?? json.access_token;
|
|
return typeof token === "string" ? token.trim() : "";
|
|
}
|
|
|
|
const bearer = trimmed.match(/^(?:Authorization:\s*)?Bearer\s+(.+)$/i);
|
|
if (bearer) return bearer[1].trim();
|
|
|
|
const normalized = normalizeCookie(trimmed);
|
|
if (!normalized) return "";
|
|
const match = normalized.match(/(?:^|;\s*)token=([^;]+)/);
|
|
if (match) return match[1].trim();
|
|
return normalized.includes(";") || normalized.includes("=") ? "" : normalized;
|
|
}
|
|
|
|
/** Read the short-lived browser CAPTCHA proof from supported input locations. */
|
|
export function extractZaiCaptchaVerifyParam(value: unknown): string {
|
|
const record = asRecord(value);
|
|
if (record) {
|
|
const direct =
|
|
record.captcha_verify_param ?? record.captchaVerifyParam ?? record.zaiCaptchaVerifyParam;
|
|
if (typeof direct === "string" && direct.trim()) return direct.trim();
|
|
const nested = asRecord(record.providerSpecificData);
|
|
if (nested) return extractZaiCaptchaVerifyParam(nested);
|
|
return "";
|
|
}
|
|
|
|
if (typeof value !== "string") return "";
|
|
const json = parseCredentialJson(value);
|
|
if (json) return extractZaiCaptchaVerifyParam(json);
|
|
const match = value.match(/(?:^|;\s*)captcha_verify_param=([^;]+)/);
|
|
return match?.[1]?.trim() ?? "";
|
|
}
|
|
|
|
export function resolveZaiCaptchaVerifyParam(
|
|
credentials: ProviderCredentials,
|
|
body: Record<string, unknown>
|
|
): string {
|
|
return (
|
|
extractZaiCaptchaVerifyParam(body) ||
|
|
extractZaiCaptchaVerifyParam(credentials.providerSpecificData) ||
|
|
extractZaiCaptchaVerifyParam(credentials.apiKey) ||
|
|
extractZaiCaptchaVerifyParam(credentials.accessToken)
|
|
);
|
|
}
|
|
|
|
export function extractZaiUserId(token: string): string {
|
|
const payload = token.split(".")[1];
|
|
if (!payload) return "";
|
|
try {
|
|
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
return typeof decoded?.id === "string" ? decoded.id : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function buildZaiSignature(input: {
|
|
prompt: string;
|
|
requestId: string;
|
|
timestamp: number | string;
|
|
userId: string;
|
|
}): string {
|
|
const timestamp = String(input.timestamp);
|
|
const sortedPayload = Object.entries({
|
|
timestamp,
|
|
requestId: input.requestId,
|
|
user_id: input.userId,
|
|
})
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.join(",");
|
|
const encodedPrompt = Buffer.from(input.prompt, "utf8").toString("base64");
|
|
const bucket = Math.floor(Number(timestamp) / (5 * 60 * 1000));
|
|
const derivedKey = createHmac("sha256", SIGNATURE_KEY).update(String(bucket)).digest("hex");
|
|
return createHmac("sha256", derivedKey)
|
|
.update(`${sortedPayload}|${encodedPrompt}|${timestamp}`)
|
|
.digest("hex");
|
|
}
|
|
|
|
export function parseZaiFrontendVersion(html: string): string | null {
|
|
return html.match(/\/frontend\/(prod-fe-\d+(?:\.\d+)*)\/assets\//)?.[1] ?? null;
|
|
}
|
|
|
|
function textContent(content: unknown): string {
|
|
if (typeof content === "string") return content;
|
|
if (!Array.isArray(content)) return "";
|
|
return content
|
|
.flatMap((part) => {
|
|
const record = asRecord(part);
|
|
if (!record || (record.type !== "text" && record.type !== "input_text")) return [];
|
|
const text = record.text ?? record.content;
|
|
return typeof text === "string" ? [text] : [];
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
export function latestUserPrompt(messages: Array<{ role: string; content: unknown }>): string {
|
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
if (messages[index]?.role !== "user") continue;
|
|
return textContent(messages[index].content);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export function foldMessages(
|
|
messages: Array<{ role: string; content: unknown }>
|
|
): Array<{ role: string; content: string }> {
|
|
return messages.map((message) => ({
|
|
role: message.role,
|
|
content: textContent(message.content),
|
|
}));
|
|
}
|
|
|
|
export function browserPrompt(messages: Array<{ role: string; content: unknown }>): string {
|
|
const folded = foldMessages(messages);
|
|
if (folded.length === 1 && folded[0]?.role === "user") return folded[0].content;
|
|
return folded.map((message) => `${message.role.toUpperCase()}:\n${message.content}`).join("\n\n");
|
|
}
|
|
|
|
export function collectZaiImageUrls(messages: Array<{ role: string; content: unknown }>): string[] {
|
|
return messages.flatMap((message) =>
|
|
message.role === "user" ? extractImageUrls(message.content) : []
|
|
);
|
|
}
|
|
|
|
export function zaiImageFileName(mimeType: string, index: number): string {
|
|
const normalized = mimeType.toLowerCase().split(";")[0].trim();
|
|
const extension =
|
|
normalized === "image/jpeg"
|
|
? "jpg"
|
|
: normalized === "image/svg+xml"
|
|
? "svg"
|
|
: normalized.startsWith("image/")
|
|
? normalized.slice("image/".length).replace(/[^a-z0-9]/g, "") || "png"
|
|
: "png";
|
|
return `omniroute-image-${index + 1}.${extension}`;
|
|
}
|
|
|
|
export function unprefixedModelId(modelId: string): string {
|
|
return modelId.trim().split("/").at(-1) || modelId.trim();
|
|
}
|
|
|
|
export function browserModelName(modelId: string): string {
|
|
const unprefixed = unprefixedModelId(modelId);
|
|
if (unprefixed.toLowerCase() === "glm-5.2") return "GLM-5.2";
|
|
if (unprefixed.toLowerCase() === "glm-5v-turbo") return "GLM-5V-Turbo";
|
|
return unprefixed;
|
|
}
|
|
|
|
export function getZaiModelCapabilities(modelId: string): ZaiModelCapabilities {
|
|
return (
|
|
ZAI_MODEL_CAPABILITIES[unprefixedModelId(modelId).toLowerCase()] ?? NO_ZAI_MODEL_CAPABILITIES
|
|
);
|
|
}
|
|
|
|
function getFeatureOption(body: Record<string, unknown>, key: string): unknown {
|
|
if (body[key] !== undefined) return body[key];
|
|
return asRecord(body.features)?.[key];
|
|
}
|
|
|
|
/** Resolve each model's Deep Think control; only GLM-5.2 accepts High/Max effort. */
|
|
export function resolveZaiThinkingConfig(
|
|
modelId: string,
|
|
body: Record<string, unknown>
|
|
): ZaiThinkingConfig {
|
|
const capabilities = getZaiModelCapabilities(modelId);
|
|
const supported = capabilities.thinking;
|
|
const reasoning = asRecord(body.reasoning);
|
|
const rawEffort =
|
|
typeof body.reasoning_effort === "string"
|
|
? body.reasoning_effort.trim().toLowerCase()
|
|
: typeof reasoning?.effort === "string"
|
|
? reasoning.effort.trim().toLowerCase()
|
|
: "";
|
|
const disabled = body.enable_thinking === false || rawEffort === "none" || rawEffort === "off";
|
|
const effort: ZaiReasoningEffort =
|
|
rawEffort === "low" || rawEffort === "medium" || rawEffort === "high" ? "high" : "max";
|
|
|
|
return {
|
|
supported,
|
|
enabled: supported && !disabled,
|
|
effort,
|
|
effortSupported: capabilities.reasoningEffort,
|
|
};
|
|
}
|
|
|
|
/** Resolve GLM-5V-Turbo's visible Web Search and Tools controls. */
|
|
export function resolveZaiVlmConfig(modelId: string, body: Record<string, unknown>): ZaiVlmConfig {
|
|
const capabilities = getZaiModelCapabilities(modelId);
|
|
const toolsOption = getFeatureOption(body, "vlm_tools_enable");
|
|
const webSearchOption =
|
|
getFeatureOption(body, "vlm_web_search_enable") ??
|
|
getFeatureOption(body, "auto_web_search") ??
|
|
getFeatureOption(body, "web_search");
|
|
const webSearchEnabled =
|
|
webSearchOption === true || (webSearchOption !== false && capabilities.vlmWebSearch);
|
|
return {
|
|
toolsEnabled: capabilities.vlmTools && toolsOption !== false,
|
|
webSearchEnabled: capabilities.webSearch && webSearchEnabled,
|
|
websiteModeEnabled: capabilities.vlmWebsiteMode,
|
|
};
|
|
}
|
|
|
|
export function buildZaiHeaders(
|
|
token: string,
|
|
options: {
|
|
accept: "application/json" | "text/event-stream";
|
|
frontendVersion?: string;
|
|
signature?: string;
|
|
}
|
|
): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
Accept: options.accept,
|
|
"Accept-Language": "en-US",
|
|
"User-Agent": ZAI_USER_AGENT,
|
|
Origin: ZAI_BASE_URL,
|
|
Referer: `${ZAI_BASE_URL}/`,
|
|
Authorization: `Bearer ${token}`,
|
|
};
|
|
if (options.frontendVersion) headers["X-FE-Version"] = options.frontendVersion;
|
|
if (options.signature) headers["X-Signature"] = options.signature;
|
|
return headers;
|
|
}
|
|
|
|
export function buildZaiCompletionUrl(input: {
|
|
requestId: string;
|
|
timestamp: number;
|
|
token: string;
|
|
userId: string;
|
|
}): string {
|
|
const now = new Date(input.timestamp);
|
|
const params = new URLSearchParams({
|
|
timestamp: String(input.timestamp),
|
|
requestId: input.requestId,
|
|
user_id: input.userId,
|
|
version: CLIENT_PROTOCOL_VERSION,
|
|
platform: "web",
|
|
token: input.token,
|
|
user_agent: ZAI_USER_AGENT,
|
|
language: "en-US",
|
|
languages: "en-US,en",
|
|
timezone: "UTC",
|
|
cookie_enabled: "true",
|
|
screen_width: "1280",
|
|
screen_height: "800",
|
|
screen_resolution: "1280x800",
|
|
viewport_height: "800",
|
|
viewport_width: "1280",
|
|
viewport_size: "1280x800",
|
|
color_depth: "24",
|
|
pixel_ratio: "1",
|
|
current_url: `${ZAI_BASE_URL}/`,
|
|
pathname: "/",
|
|
search: "",
|
|
hash: "",
|
|
host: "chat.z.ai",
|
|
hostname: "chat.z.ai",
|
|
protocol: "https:",
|
|
referrer: "",
|
|
title: "Z.ai - Advanced AI Chatbot & Agent powered by GLM-5.2",
|
|
timezone_offset: "0",
|
|
local_time: now.toISOString(),
|
|
utc_time: now.toUTCString(),
|
|
is_mobile: "false",
|
|
is_touch: "false",
|
|
max_touch_points: "0",
|
|
browser_name: "Chrome",
|
|
os_name: "Mac OS",
|
|
signature_timestamp: String(input.timestamp),
|
|
});
|
|
return `${ZAI_CHAT_URL}?${params.toString()}`;
|
|
}
|
|
|
|
export function buildZaiNewChatBody(
|
|
messages: Array<{ role: string; content: unknown }>,
|
|
modelId: string,
|
|
enableThinking: boolean,
|
|
reasoningEffort: ZaiReasoningEffort,
|
|
vlmConfig: ZaiVlmConfig
|
|
): NewChatRequest {
|
|
const prompt = latestUserPrompt(messages);
|
|
const userMessageId = randomUUID();
|
|
return {
|
|
userMessageId,
|
|
payload: {
|
|
chat: {
|
|
id: "",
|
|
title: "New Chat",
|
|
models: [modelId],
|
|
params: {},
|
|
history: {
|
|
messages: {
|
|
[userMessageId]: {
|
|
id: userMessageId,
|
|
parentId: null,
|
|
childrenIds: [],
|
|
role: "user",
|
|
content: prompt,
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
models: [modelId],
|
|
},
|
|
},
|
|
currentId: userMessageId,
|
|
},
|
|
tags: [],
|
|
flags: [],
|
|
features: [
|
|
{
|
|
server: "tool_selector_h",
|
|
status: "hidden",
|
|
type: "tool_selector",
|
|
},
|
|
],
|
|
mcp_servers: [],
|
|
enable_thinking: enableThinking,
|
|
reasoning_effort: reasoningEffort,
|
|
auto_web_search: vlmConfig.webSearchEnabled,
|
|
message_version: 1,
|
|
extra: {
|
|
vlm_tools_enable: vlmConfig.toolsEnabled,
|
|
vlm_web_search_enable: vlmConfig.websiteModeEnabled && vlmConfig.webSearchEnabled,
|
|
vlm_website_mode: vlmConfig.websiteModeEnabled,
|
|
},
|
|
timestamp: Date.now(),
|
|
type: "default",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildZaiRequestBody(input: {
|
|
body: Record<string, unknown>;
|
|
captchaVerifyParam: string;
|
|
chatId: string;
|
|
messages: Array<{ role: string; content: unknown }>;
|
|
modelId: string;
|
|
prompt: string;
|
|
userMessageId: string;
|
|
enableThinking: boolean;
|
|
reasoningEffort: ZaiReasoningEffort;
|
|
reasoningEffortSupported: boolean;
|
|
vlmConfig: ZaiVlmConfig;
|
|
}): Record<string, unknown> {
|
|
const params = Object.fromEntries(
|
|
["temperature", "top_p", "max_tokens", "stop"]
|
|
.filter((key) => input.body[key] !== undefined)
|
|
.map((key) => [key, input.body[key]])
|
|
);
|
|
const features: Record<string, unknown> = {
|
|
image_generation: false,
|
|
web_search: false,
|
|
auto_web_search: input.vlmConfig.websiteModeEnabled ? false : input.vlmConfig.webSearchEnabled,
|
|
preview_mode: true,
|
|
flags: [],
|
|
vlm_tools_enable: input.vlmConfig.toolsEnabled,
|
|
vlm_web_search_enable: input.vlmConfig.websiteModeEnabled && input.vlmConfig.webSearchEnabled,
|
|
vlm_website_mode: input.vlmConfig.websiteModeEnabled,
|
|
enable_thinking: input.enableThinking,
|
|
};
|
|
if (input.enableThinking && input.reasoningEffortSupported) {
|
|
features.reasoning_effort = input.reasoningEffort;
|
|
}
|
|
return {
|
|
stream: true,
|
|
model: input.modelId,
|
|
messages: foldMessages(input.messages),
|
|
signature_prompt: input.prompt,
|
|
params,
|
|
extra: {
|
|
vlm_tools_enable: input.vlmConfig.toolsEnabled,
|
|
vlm_web_search_enable: input.vlmConfig.websiteModeEnabled && input.vlmConfig.webSearchEnabled,
|
|
vlm_website_mode: input.vlmConfig.websiteModeEnabled,
|
|
},
|
|
features,
|
|
variables: {},
|
|
chat_id: input.chatId,
|
|
id: randomUUID(),
|
|
current_user_message_id: input.userMessageId,
|
|
current_user_message_parent_id: null,
|
|
background_tasks: {
|
|
title_generation: true,
|
|
tags_generation: true,
|
|
},
|
|
captcha_verify_param: input.captchaVerifyParam,
|
|
};
|
|
}
|