diff --git a/next.config.mjs b/next.config.mjs
index 3853b4b00b..f2f41679f9 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -13,7 +13,7 @@ const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE
const nextConfig = {
distDir: process.env.NEXT_DIST_DIR || ".next",
output: "standalone",
- serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"],
+ serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite", "cloakbrowser", "playwright-core"],
turbopack: {
root: tracingRoot
},
diff --git a/open-sse/executors/glm.js b/open-sse/executors/glm.js
new file mode 100644
index 0000000000..7789a1f198
--- /dev/null
+++ b/open-sse/executors/glm.js
@@ -0,0 +1,208 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+import { DefaultExecutor } from "./default.js";
+import zcodeConfig from "../../src/lib/zcode/config.js";
+import { GLM_CODING_PLAN_MODEL_MAP } from "../../src/lib/zcode/constants.js";
+import { injectZcodeSystemPrompt } from "../../src/lib/zcode/systemPrompt.js";
+import { getModelUpstreamId } from "../config/providerModels.js";
+import {
+ getCaptchaManager,
+ getZcodeCaptchaPort,
+ isCaptchaError,
+} from "../../src/lib/zcode/captcha-service.js";
+import {
+ applyZcodeApiKeyHeaders,
+ applyZcodeCodingPlanHeaders,
+} from "../../src/lib/zcode/headers.js";
+
+const MAX_CAPTCHA_RETRIES = 3;
+
+/** Per-request context — avoids singleton executor cross-request credential races. */
+export const glmRequestContext = new AsyncLocalStorage();
+
+function getGlmRequestContext() {
+ return glmRequestContext.getStore();
+}
+
+export class GlmExecutor extends DefaultExecutor {
+ constructor() {
+ super("glm");
+ }
+
+ usesCodingPlan(credentials) {
+ if (!credentials?.providerSpecificData?.useCodingPlan) return false;
+ return !!(credentials.providerSpecificData?.zcodeJwtToken || credentials.accessToken);
+ }
+
+ usesApiKeyOnly(credentials) {
+ return !!credentials?.apiKey && !this.usesCodingPlan(credentials);
+ }
+
+ getUrlIndex() {
+ return getGlmRequestContext()?.urlIndex ?? 0;
+ }
+
+ usesZcodeApiKeyUpstream(credentials) {
+ const urlIndex = this.getUrlIndex();
+ return (
+ this.usesApiKeyOnly(credentials) ||
+ (this.usesCodingPlan(credentials) && urlIndex === 1)
+ );
+ }
+
+ canFallbackToApiKey(credentials) {
+ return this.usesCodingPlan(credentials) && !!credentials?.apiKey;
+ }
+
+ getFallbackCount() {
+ const credentials = getGlmRequestContext()?.credentials;
+ if (!credentials) return 1;
+ return this.canFallbackToApiKey(credentials) ? 2 : 1;
+ }
+
+ buildUrl(model, stream, urlIndex = 0, credentials = null) {
+ const ctx = getGlmRequestContext();
+ if (ctx) ctx.urlIndex = urlIndex;
+
+ if (this.usesCodingPlan(credentials)) {
+ if (urlIndex === 0) return zcodeConfig.codingPlanUrl;
+ if (this.canFallbackToApiKey(credentials)) {
+ return zcodeConfig.apiKeyFallbackUrl;
+ }
+ }
+ if (this.usesApiKeyOnly(credentials)) {
+ return zcodeConfig.apiKeyFallbackUrl;
+ }
+ return super.buildUrl(model, stream, urlIndex, credentials);
+ }
+
+ transformRequest(model, body, stream, credentials) {
+ const transformed = super.transformRequest(model, body, stream, credentials);
+ if (!transformed || typeof transformed !== "object") return transformed;
+
+ const modelId = transformed.model || model;
+ if (typeof modelId !== "string") return transformed;
+
+ const mapped =
+ getModelUpstreamId("glm", modelId) ||
+ GLM_CODING_PLAN_MODEL_MAP[modelId.toLowerCase()] ||
+ modelId;
+
+ if (mapped !== modelId) {
+ transformed.model = mapped;
+ }
+
+ if (this.usesCodingPlan(credentials) && this.getUrlIndex() === 0) {
+ return injectZcodeSystemPrompt(transformed, {
+ modelRef: `builtin:zai-start-plan/${transformed.model || mapped}`,
+ });
+ }
+
+ return transformed;
+ }
+
+ buildHeaders(credentials, stream = true) {
+ const headers = super.buildHeaders(credentials, stream);
+
+ if (this.usesCodingPlan(credentials) && this.getUrlIndex() === 0) {
+ applyZcodeCodingPlanHeaders(headers, credentials);
+ } else if (this.usesZcodeApiKeyUpstream(credentials)) {
+ applyZcodeApiKeyHeaders(headers, credentials);
+ }
+
+ return headers;
+ }
+
+ shouldRetry(status, urlIndex) {
+ const credentials = getGlmRequestContext()?.credentials;
+ if (
+ status === 401 &&
+ urlIndex === 0 &&
+ credentials &&
+ this.canFallbackToApiKey(credentials)
+ ) {
+ return true;
+ }
+ return super.shouldRetry(status, urlIndex);
+ }
+
+ parseError(response, bodyText) {
+ if (!bodyText) {
+ return super.parseError(response, bodyText);
+ }
+
+ try {
+ const json = JSON.parse(bodyText);
+ const err = json?.error;
+ const code = err?.code ?? json?.code;
+ const message = err?.message ?? json?.msg ?? json?.message;
+
+ if (code === "1113" || (typeof message === "string" && message.includes("1113"))) {
+ return {
+ status: response.status || 429,
+ message:
+ "GLM quota exhausted or no active resource package for this model. " +
+ "Check ZCode Start/Coding Plan balance, try glm-5-turbo, or wait for daily reset.",
+ };
+ }
+
+ if (code === "3010" || (typeof message === "string" && message.includes("concurrency limit"))) {
+ return {
+ status: response.status || 429,
+ message:
+ "Z.AI model admission concurrency limit exceeded. Close other ZCode/9router sessions and retry.",
+ };
+ }
+
+ if (typeof message === "string" && message.length > 0) {
+ return { status: response.status, message };
+ }
+ } catch {
+ /* fall through */
+ }
+
+ return super.parseError(response, bodyText);
+ }
+
+ async executeWithCaptcha(params) {
+ const { credentials } = params;
+ const captchaManager = getCaptchaManager();
+ const port = getZcodeCaptchaPort();
+
+ for (let attempt = 1; attempt <= MAX_CAPTCHA_RETRIES; attempt++) {
+ let verifyParam;
+ try {
+ verifyParam = await captchaManager.getVerifyParam(port);
+ } catch (err) {
+ throw new Error(`Captcha verification failed: ${err.message}`);
+ }
+
+ const credsWithCaptcha = {
+ ...credentials,
+ providerSpecificData: {
+ ...(credentials.providerSpecificData || {}),
+ _captchaVerifyParam: verifyParam,
+ },
+ };
+
+ const result = await super.execute({ ...params, credentials: credsWithCaptcha });
+
+ if (result.response.status === 403 && (await isCaptchaError(result.response))) {
+ captchaManager.invalidate();
+ continue;
+ }
+
+ return result;
+ }
+
+ throw new Error("Captcha expired multiple times. Restart the service or check CloakBrowser.");
+ }
+
+ async execute(params) {
+ const { credentials } = params;
+ return glmRequestContext.run({ credentials, urlIndex: 0 }, () =>
+ this.executeWithCaptcha(params)
+ );
+ }
+}
+
+export default GlmExecutor;
\ No newline at end of file
diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js
index bac26447bb..c94fb51e08 100644
--- a/open-sse/executors/index.js
+++ b/open-sse/executors/index.js
@@ -18,8 +18,10 @@ import { CommandCodeExecutor } from "./commandcode.js";
import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
import { MimoFreeExecutor } from "./mimo-free.js";
import { DefaultExecutor } from "./default.js";
+import { GlmExecutor } from "./glm.js";
const executors = {
+ glm: new GlmExecutor(),
antigravity: new AntigravityExecutor(),
azure: new AzureExecutor(),
"gemini-cli": new GeminiCLIExecutor(),
@@ -77,3 +79,4 @@ export { OllamaLocalExecutor } from "./ollama-local.js";
export { CommandCodeExecutor } from "./commandcode.js";
export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
export { MimoFreeExecutor } from "./mimo-free.js";
+export { GlmExecutor } from "./glm.js";
diff --git a/open-sse/providers/registry/glm.js b/open-sse/providers/registry/glm.js
index fa003ccffa..a93e43b27d 100644
--- a/open-sse/providers/registry/glm.js
+++ b/open-sse/providers/registry/glm.js
@@ -9,19 +9,23 @@ export default {
icon: "code",
color: "#2563EB",
textIcon: "GL",
- website: "https://open.bigmodel.cn",
+ deprecated: true,
+ deprecationNotice: "RISK_NOTICE",
+ website: "https://z.ai",
notice: {
- apiKeyUrl: "https://open.bigmodel.cn/usercenter/apikeys",
+ text: "Z.AI international (z.ai). OAuth Coding Plan or API key via api.z.ai.",
+ apiKeyUrl: "https://z.ai",
+ signupUrl: "https://z.ai",
},
},
- category: "apikey",
+ category: "oauth",
+ authModes: ["oauth", "apikey"],
+ hasOAuth: true,
transport: {
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
format: "claude",
- urlSuffix: "?beta=true",
headers: {
- "Anthropic-Version": "2023-06-01",
- "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
+ ...CLAUDE_API_HEADERS,
},
auth: {
combined: true,
@@ -33,13 +37,19 @@ export default {
},
},
models: [
- { id: "glm-5.1", name: "GLM 5.1" },
+ { id: "glm-5.2", name: "GLM 5.2", upstreamModelId: "GLM-5.2" },
+ { id: "glm-5.1", name: "GLM 5.1", upstreamModelId: "GLM-5.1" },
+ { id: "glm-5-turbo", name: "GLM 5 Turbo", upstreamModelId: "GLM-5-Turbo" },
{ id: "glm-5", name: "GLM 5" },
- { id: "glm-4.7", name: "GLM 4.7" },
+ { id: "glm-4.7", name: "GLM 4.7", upstreamModelId: "GLM-4.7" },
+ { id: "glm-4.6", name: "GLM 4.6" },
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
],
+ oauth: {
+ refreshLeadMs: 600000,
+ },
features: {
usage: true,
usageApikey: true,
},
-};
+};
\ No newline at end of file
diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js
index 10d8efea2a..52dba50550 100644
--- a/open-sse/services/tokenRefresh.js
+++ b/open-sse/services/tokenRefresh.js
@@ -2,6 +2,7 @@ import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS, REFRESH_LEAD_MS } from "../config/appConstants.js";
import {
refreshXaiToken,
+ refreshGlmToken,
refreshAccessToken,
refreshClaudeOAuthToken,
refreshGoogleToken,
@@ -127,6 +128,7 @@ const REFRESH_HANDLERS = {
github: (c, log) => refreshGitHubToken(c.refreshToken, log),
kiro: (c, log) => refreshKiroToken(c.refreshToken, c.providerSpecificData, log),
xai: (c, log) => refreshXaiToken(c.refreshToken, log),
+ glm: (c, log) => refreshGlmToken(c.refreshToken, c, log),
vertex: vertexRefreshHandler,
"vertex-partner": vertexRefreshHandler
};
diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js
index 33d6baf650..1f350c9a50 100644
--- a/open-sse/services/tokenRefresh/providers.js
+++ b/open-sse/services/tokenRefresh/providers.js
@@ -481,6 +481,36 @@ export async function refreshGitHubToken(refreshToken, log) {
}, log);
}
+export async function refreshGlmToken(refreshToken, credentials, log) {
+ if (!refreshToken) return null;
+ return dedupRefresh("glm", refreshToken, async () => {
+ try {
+ const mod = await import("../../../src/lib/zcode/oauth.js");
+ const refreshed = await mod.refreshGlmOAuthCredentials({
+ ...credentials,
+ refreshToken,
+ });
+ if (!refreshed) {
+ log?.warn?.("TOKEN_REFRESH", "glm refresh skipped (no client secret or refresh token)");
+ return null;
+ }
+ log?.info?.("TOKEN_REFRESH", "Successfully refreshed GLM OAuth credentials", {
+ hasNewAccessToken: !!refreshed.accessToken,
+ hasNewApiKey: !!refreshed.apiKey,
+ expiresIn: refreshed.expiresIn,
+ });
+ return refreshed;
+ } catch (e) {
+ log?.warn?.("TOKEN_REFRESH", `glm refresh failed: ${e?.message || e}`);
+ const msg = String(e?.message || "");
+ if (msg.includes("invalid_grant") || msg.includes("invalid_request")) {
+ return { error: "invalid_grant" };
+ }
+ return null;
+ }
+ }, log);
+}
+
export async function refreshCopilotToken(githubAccessToken, log) {
if (!githubAccessToken) return null;
return dedupRefresh("copilot", githubAccessToken, async () => {
diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js
index d88167090f..7df18960be 100644
--- a/open-sse/services/usage.js
+++ b/open-sse/services/usage.js
@@ -36,8 +36,8 @@ const USAGE_HANDLERS = {
qwen: (c) => getQwenUsage(c.accessToken, c.providerSpecificData),
iflow: (c) => getIflowUsage(c.accessToken),
ollama: (c) => getOllamaUsage(c.accessToken),
- glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),
- "glm-cn": (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),
+ glm: (c) => getGlmUsage(c, c.proxyOptions),
+ "glm-cn": (c) => getGlmUsage(c, c.proxyOptions),
minimax: (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions),
"minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions),
"vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions),
diff --git a/open-sse/services/usage/misc.js b/open-sse/services/usage/misc.js
index 6ce012faa1..dd74f66b29 100644
--- a/open-sse/services/usage/misc.js
+++ b/open-sse/services/usage/misc.js
@@ -3,7 +3,7 @@
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
-import { U } from "./shared.js";
+import { U, parseResetTime } from "./shared.js";
// GLM quota endpoints (region-aware) — url from registry transport.usage
const GLM_QUOTA_URLS = {
@@ -66,10 +66,90 @@ export async function getOllamaUsage(accessToken, providerSpecificData) {
}
}
+async function getGlmCodingPlanUsage(jwtToken, proxyOptions = null) {
+ const headers = {
+ Authorization: `Bearer ${jwtToken}`,
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ };
+
+ try {
+ const [billingRes, balanceRes] = await Promise.all([
+ proxyAwareFetch("https://zcode.z.ai/api/v1/zcode-plan/billing/current", { headers }, proxyOptions),
+ proxyAwareFetch("https://zcode.z.ai/api/v1/zcode-plan/billing/balance", { headers }, proxyOptions).catch(() => null),
+ ]);
+
+ if (!billingRes.ok) {
+ if (billingRes.status === 401) {
+ return { message: "Z.AI Coding Plan token invalid or expired." };
+ }
+ return { message: `Coding Plan billing API error (${billingRes.status}).` };
+ }
+
+ const billingJson = await billingRes.json();
+ const billingData = billingJson?.data || billingJson;
+ const balanceJson = balanceRes?.ok ? await balanceRes.json() : null;
+ const balanceData = balanceJson?.data || balanceJson;
+
+ const plans = Array.isArray(billingData?.plans) ? billingData.plans : [];
+ const planName = plans[0]?.name || billingData?.planName || "Coding Plan";
+
+ const quotas = {};
+ const balances = Array.isArray(balanceData?.balances) ? balanceData.balances : [];
+
+ for (const bal of balances) {
+ if (!bal?.show_name) continue;
+
+ const total = Number(bal.total_units) || 0;
+ const used = Number(bal.used_units) || 0;
+ const remainingUnits = Number(bal.remaining_units);
+ const remaining = Number.isFinite(remainingUnits)
+ ? Math.max(0, remainingUnits)
+ : Math.max(0, total - used);
+
+ quotas[bal.show_name] = {
+ used,
+ total,
+ remainingPercentage: total > 0 ? Math.round((remaining / total) * 100) : 0,
+ resetAt: parseResetTime(bal.expires_at),
+ unlimited: false,
+ unit: "token",
+ };
+ }
+
+ if (Object.keys(quotas).length === 0) {
+ return {
+ plan: planName,
+ message: "Coding Plan connected. No per-model balance data available.",
+ quotas: {},
+ };
+ }
+
+ return {
+ plan: planName,
+ quotas,
+ };
+ } catch (error) {
+ return { message: `GLM Coding Plan error: ${error.message}` };
+ }
+}
+
/**
- * GLM Coding Plan usage (international + China regions)
+ * GLM usage — OAuth Coding Plan JWT or API key quota APIs
*/
-export async function getGlmUsage(apiKey, provider, proxyOptions = null) {
+export async function getGlmUsage(connection, proxyOptions = null) {
+ const provider = connection?.provider || "glm";
+ const apiKey = connection?.apiKey;
+ const providerSpecificData = connection?.providerSpecificData || {};
+ const useCodingPlan =
+ providerSpecificData.useCodingPlan &&
+ (providerSpecificData.zcodeJwtToken || connection?.accessToken);
+
+ if (useCodingPlan) {
+ const zcodeJwt = providerSpecificData.zcodeJwtToken || connection.accessToken;
+ return getGlmCodingPlanUsage(zcodeJwt, proxyOptions);
+ }
+
if (!apiKey) {
return { message: "GLM API key not available." };
}
diff --git a/package.json b/package.json
index 9211d5ba1e..7f245810c8 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@monaco-editor/react": "^4.7.0",
"@xyflow/react": "^12.10.1",
"bcryptjs": "^3.0.3",
+ "cloakbrowser": "^0.3.31",
"confbox": "^0.2.4",
"express": "^5.2.1",
"fs": "^0.0.1-security",
@@ -34,6 +35,7 @@
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
"ora": "^9.1.0",
+ "playwright-core": "^1.60.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-is": "^16.13.1",
diff --git a/public/zcode/captcha.html b/public/zcode/captcha.html
new file mode 100644
index 0000000000..a32f5e0b88
--- /dev/null
+++ b/public/zcode/captcha.html
@@ -0,0 +1,448 @@
+
+
+
+
+
+ ZCode Verification Center
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Security Verification
+
Complete captcha verification to continue with chat requests
+
+
+
+
+
+
Initializing traceless security verification...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
index bc97317ec8..93e122b5ee 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
@@ -72,8 +72,25 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
const authIcon = isCookieConnection ? "cookie" : isOAuthConnection ? "lock" : "key";
const authLabel = isOAuthConnection ? "OAuth" : isCookieConnection ? "Cookie" : "API Key";
const isEmail = (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
+ const isUuid = (v) =>
+ typeof v === "string" &&
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v.trim());
+ const defaultOAuthLabel = isEmail(connection.email)
+ ? connection.email
+ : isEmail(connection.name)
+ ? connection.name
+ : (connection.name || connection.email || connection.displayName || "OAuth Account");
+ const glmOAuthLabel = (() => {
+ for (const value of [connection.email, connection.name, connection.displayName]) {
+ if (isEmail(value)) return value;
+ }
+ for (const value of [connection.email, connection.name, connection.displayName]) {
+ if (typeof value === "string" && value.trim() && !isUuid(value)) return value.trim();
+ }
+ return "OAuth Account";
+ })();
const displayName = isOAuthConnection
- ? (isEmail(connection.email) ? connection.email : (isEmail(connection.name) ? connection.name : (connection.name || connection.email || connection.displayName || "OAuth Account")))
+ ? (connection.provider === "glm" ? glmOAuthLabel : defaultOAuthLabel)
: (connection.name || connection.email || connection.displayName || "API Key");
// Use useState + useEffect for impure Date.now() to avoid calling during render
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js
index c06fbaa5ff..095bac34af 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
-import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
+import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, ZaiOAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
@@ -139,7 +139,8 @@ export default function ProviderDetailPage() {
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
const hasDualAuthModes = !isCompatible && isOAuth && supportsApiKeyAuth;
- const oauthConnectionLabel = providerId === "xai" ? "Grok Build OAuth" : "OAuth";
+ const oauthConnectionLabel =
+ providerId === "xai" ? "Grok Build OAuth" : providerId === "glm" ? "Z.AI OAuth" : "OAuth";
const apiKeyConnectionLabel = providerId === "xai" ? "xAI API Key" : "API Key";
const thinkingConfig = AI_PROVIDERS[providerId]?.thinkingConfig || THINKING_CONFIG.extended;
@@ -1500,6 +1501,13 @@ export default function ProviderDetailPage() {
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
+ ) : providerId === "glm" ? (
+ setShowOAuthModal(false)}
+ />
) : (
{
+ const provider =
+ APIKEY_PROVIDERS[providerId] || OAUTH_PROVIDERS[providerId];
+ const authModes = provider?.authModes;
+ // Dual-auth providers (e.g. glm, xai): one card, count oauth + apikey connections.
+ if (authModes?.includes("oauth") && authModes?.includes("apikey")) {
+ return authModes;
+ }
+ return [sectionAuthType];
+ };
+
const getProviderStats = (providerId, authType) => {
+ const authTypes = getProviderAuthTypes(providerId, authType);
const providerConnections = connections.filter(
- (c) => c.provider === providerId && c.authType === authType,
+ (c) => c.provider === providerId && authTypes.includes(c.authType),
);
const getEffectiveStatus = (conn) => {
@@ -210,12 +222,13 @@ export default function ProvidersPage() {
// Toggle all connections for a provider on/off
const handleToggleProvider = async (providerId, authType, newActive) => {
+ const authTypes = getProviderAuthTypes(providerId, authType);
const providerConns = connections.filter(
- (c) => c.provider === providerId && c.authType === authType,
+ (c) => c.provider === providerId && authTypes.includes(c.authType),
);
setConnections((prev) =>
prev.map((c) =>
- c.provider === providerId && c.authType === authType
+ c.provider === providerId && authTypes.includes(c.authType)
? { ...c, isActive: newActive }
: c,
),
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
index df5edab586..88650b1ed9 100644
--- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
@@ -432,6 +432,25 @@ export function parseQuotaData(provider, data) {
}
break;
+ case "glm":
+ case "glm-cn":
+ // Z.AI Coding Plan: per-model token buckets from billing/balance.
+ // Do not forward `remaining` — it's an absolute token count, not a percentage.
+ if (data.quotas) {
+ Object.entries(data.quotas).forEach(([name, quota]) => {
+ normalizedQuotas.push({
+ name,
+ modelKey: name.toLowerCase().replace(/\s+/g, "-"),
+ used: quota.used || 0,
+ total: quota.total || 0,
+ resetAt: quota.resetAt || null,
+ remainingPercentage: quota.remainingPercentage,
+ unit: quota.unit || "token",
+ });
+ });
+ }
+ break;
+
default:
// Generic fallback for unknown providers
if (data.quotas) {
diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js
index 5b7ee8a00e..6553558d06 100644
--- a/src/app/api/models/test/ping.js
+++ b/src/app/api/models/test/ping.js
@@ -37,6 +37,15 @@ function createSilentWavFile() {
return new Blob([buffer], { type: "audio/wav" });
}
+function getPingTimeoutMs(model) {
+ const normalized = String(model || "").toLowerCase();
+ // Z.AI Coding Plan solves Aliyun captcha in CloakBrowser on first request (~60–120s).
+ if (normalized.startsWith("glm/") || normalized.startsWith("glm-cn/")) {
+ return 120_000;
+ }
+ return 15_000;
+}
+
async function getInternalHeaders() {
let apiKey = null;
try {
@@ -52,6 +61,7 @@ async function getInternalHeaders() {
export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:${process.env.PORT || UPDATER_CONFIG.appPort}`) {
const headers = await getInternalHeaders();
+ const timeoutMs = getPingTimeoutMs(model);
const start = Date.now();
if (kind === "embedding") {
@@ -59,7 +69,7 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
method: "POST",
headers,
body: JSON.stringify({ model, input: "test" }),
- signal: AbortSignal.timeout(15000),
+ signal: AbortSignal.timeout(timeoutMs),
});
const latencyMs = Date.now() - start;
const rawText = await res.text().catch(() => "");
@@ -82,7 +92,7 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
method: "POST",
headers,
body: JSON.stringify({ model, prompt: "test" }),
- signal: AbortSignal.timeout(15000),
+ signal: AbortSignal.timeout(timeoutMs),
});
const latencyMs = Date.now() - start;
const rawText = await res.text().catch(() => "");
@@ -111,7 +121,7 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
method: "POST",
headers: Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== "content-type")),
body: form,
- signal: AbortSignal.timeout(15000),
+ signal: AbortSignal.timeout(timeoutMs),
});
const latencyMs = Date.now() - start;
const rawText = await res.text().catch(() => "");
@@ -139,7 +149,7 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
stream: false,
messages: [{ role: "user", content: "hi" }],
}),
- signal: AbortSignal.timeout(15000),
+ signal: AbortSignal.timeout(timeoutMs),
});
const latencyMs = Date.now() - start;
diff --git a/src/app/api/oauth/zai/init/route.js b/src/app/api/oauth/zai/init/route.js
new file mode 100644
index 0000000000..75cfd2b44f
--- /dev/null
+++ b/src/app/api/oauth/zai/init/route.js
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+import { ZaiAuthFlow } from "@/lib/zcode/auth";
+import { createZaiSession } from "@/lib/zcode/sessions";
+
+/**
+ * POST /api/oauth/zai/init — start Z.AI CLI OAuth flow
+ */
+export async function POST() {
+ try {
+ const flow = new ZaiAuthFlow();
+ const { flowId, authorizeUrl, pollToken } = await flow.init();
+ await createZaiSession({ flowId, pollToken });
+
+ return NextResponse.json({ flowId, authorizeUrl });
+ } catch (error) {
+ console.error("[Z.AI OAuth] init error:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/oauth/zai/poll/route.js b/src/app/api/oauth/zai/poll/route.js
new file mode 100644
index 0000000000..0597417463
--- /dev/null
+++ b/src/app/api/oauth/zai/poll/route.js
@@ -0,0 +1,68 @@
+import { NextResponse } from "next/server";
+import { ZaiAuthFlow } from "@/lib/zcode/auth";
+import { getZaiSession, deleteZaiSession } from "@/lib/zcode/sessions";
+import { createProviderConnection } from "@/models";
+
+/**
+ * POST /api/oauth/zai/poll — poll Z.AI OAuth status; create connection when ready
+ * Body: { flowId: string }
+ */
+export async function POST(request) {
+ try {
+ const { flowId } = await request.json();
+ if (!flowId) {
+ return NextResponse.json({ error: "flowId is required" }, { status: 400 });
+ }
+
+ const session = await getZaiSession(flowId);
+ if (!session) {
+ return NextResponse.json({ error: "OAuth session expired or not found" }, { status: 404 });
+ }
+
+ const flow = new ZaiAuthFlow(undefined, session.pollToken);
+ const data = await flow.poll(flowId);
+
+ if (data.status === "pending") {
+ return NextResponse.json({ status: "pending" });
+ }
+
+ if (data.status === "failed") {
+ await deleteZaiSession(flowId);
+ return NextResponse.json({ status: "failed", error: "Authorization denied or failed" });
+ }
+
+ if (data.status !== "ready") {
+ return NextResponse.json({ status: data.status || "unknown" });
+ }
+
+ const accessToken = data.zai?.access_token;
+ const zcodeJwtToken = data.token;
+ if (!accessToken) {
+ return NextResponse.json({ error: "Access token missing from OAuth response" }, { status: 500 });
+ }
+
+ const tokenData = await flow.exchangeForConnection(accessToken, zcodeJwtToken, data);
+ await deleteZaiSession(flowId);
+
+ const connection = await createProviderConnection({
+ provider: "glm",
+ authType: "oauth",
+ ...tokenData,
+ testStatus: "active",
+ isActive: true,
+ });
+
+ return NextResponse.json({
+ status: "ready",
+ connection: {
+ id: connection.id,
+ provider: connection.provider,
+ email: connection.email,
+ name: connection.name,
+ },
+ });
+ } catch (error) {
+ console.error("[Z.AI OAuth] poll error:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js
index 9ec2311d53..4c64ad6ae5 100644
--- a/src/app/api/providers/[id]/test/testUtils.js
+++ b/src/app/api/providers/[id]/test/testUtils.js
@@ -221,7 +221,50 @@ function isTokenExpired(connection) {
return shouldRefreshCredentials(connection.provider, connection);
}
+async function testGlmOAuthConnection(connection, effectiveProxy = null) {
+ const psd = connection.providerSpecificData || {};
+ const useCodingPlan = psd.useCodingPlan && (psd.zcodeJwtToken || connection.accessToken);
+
+ if (useCodingPlan) {
+ const jwt = psd.zcodeJwtToken || connection.accessToken;
+ try {
+ const res = await fetchWithConnectionProxy(
+ "https://zcode.z.ai/api/v1/zcode-plan/billing/current",
+ {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ Accept: "application/json",
+ },
+ },
+ effectiveProxy
+ );
+ if (res.ok) return { valid: true, error: null, refreshed: false, newTokens: null };
+ if (res.status === 401) {
+ return { valid: false, error: "Coding Plan token invalid or expired", refreshed: false };
+ }
+ return { valid: false, error: `Coding Plan API returned ${res.status}`, refreshed: false };
+ } catch (err) {
+ return { valid: false, error: err.message, refreshed: false };
+ }
+ }
+
+ if (connection.apiKey) {
+ const apiKeyResult = await testApiKeyConnection(
+ { ...connection, authType: "apikey" },
+ effectiveProxy
+ );
+ return { ...apiKeyResult, refreshed: false, newTokens: null };
+ }
+
+ return { valid: false, error: "No GLM credentials available", refreshed: false };
+}
+
async function testOAuthConnection(connection, effectiveProxy = null) {
+ if (connection.provider === "glm") {
+ return testGlmOAuthConnection(connection, effectiveProxy);
+ }
+
const config = OAUTH_TEST_CONFIG[connection.provider];
if (!config) return { valid: false, error: "Provider test not supported", refreshed: false };
if (!connection.accessToken) return { valid: false, error: "No access token", refreshed: false };
diff --git a/src/app/api/zcode/captcha/config/route.js b/src/app/api/zcode/captcha/config/route.js
new file mode 100644
index 0000000000..71c708dbe2
--- /dev/null
+++ b/src/app/api/zcode/captcha/config/route.js
@@ -0,0 +1,11 @@
+import { NextResponse } from "next/server";
+import { getCaptchaManager } from "@/lib/zcode/captcha-service";
+
+export async function GET() {
+ try {
+ const captchaConfig = await getCaptchaManager().fetchCaptchaConfig();
+ return NextResponse.json(captchaConfig);
+ } catch (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/zcode/captcha/needs-interactive/route.js b/src/app/api/zcode/captcha/needs-interactive/route.js
new file mode 100644
index 0000000000..2aa78f79f0
--- /dev/null
+++ b/src/app/api/zcode/captcha/needs-interactive/route.js
@@ -0,0 +1,11 @@
+import { NextResponse } from "next/server";
+import { getCaptchaManager } from "@/lib/zcode/captcha-service";
+
+export async function POST() {
+ try {
+ await getCaptchaManager().onNeedsInteractive();
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/zcode/captcha/submit/route.js b/src/app/api/zcode/captcha/submit/route.js
new file mode 100644
index 0000000000..8ac7e0e10b
--- /dev/null
+++ b/src/app/api/zcode/captcha/submit/route.js
@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { getCaptchaManager } from "@/lib/zcode/captcha-service";
+
+export async function POST(request) {
+ try {
+ const { verifyParam } = await request.json();
+ if (!verifyParam) {
+ return NextResponse.json({ error: "verifyParam is required" }, { status: 400 });
+ }
+
+ getCaptchaManager().submit(verifyParam);
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js
index 47ced96345..9055f0963d 100644
--- a/src/dashboardGuard.js
+++ b/src/dashboardGuard.js
@@ -34,6 +34,9 @@ const PUBLIC_API_PATHS = [
// Public top-level prefixes (LLM API endpoints with their own API key auth).
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta", "/codex"];
+// Loopback-only API paths (CloakBrowser captcha page has no dashboard session cookie).
+const LOCALHOST_PUBLIC_API_PATHS = ["/api/zcode/captcha"];
+
// Always require JWT token regardless of requireLogin setting
const ALWAYS_PROTECTED = [
"/api/shutdown",
@@ -194,6 +197,10 @@ export async function proxy(request) {
// Deny-by-default for /api/* — public allow-list bypasses, everything else requires auth.
if (pathname.startsWith("/api/")) {
+ if (LOCALHOST_PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`))) {
+ if (isLocalRequest(request)) return NextResponse.next();
+ return NextResponse.json({ error: "Local only: captcha API" }, { status: 403 });
+ }
if (isPublicApi(pathname)) return NextResponse.next();
if (await hasValidCliToken(request) || await isAuthenticated(request))
return NextResponse.next();
diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js
index d1bfe341bc..0bf2508fef 100644
--- a/src/lib/db/repos/connectionsRepo.js
+++ b/src/lib/db/repos/connectionsRepo.js
@@ -97,7 +97,15 @@ export async function createProviderConnection(data) {
const all = db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [data.provider]).map(rowToConn);
let existing = null;
- if (data.authType === "oauth" && data.email) {
+ const incomingZcodeUserId = data.providerSpecificData?.zcodeUserId;
+ if (data.authType === "oauth" && data.provider === "glm" && incomingZcodeUserId) {
+ existing = all.find(
+ (c) =>
+ c.authType === "oauth" &&
+ c.providerSpecificData?.zcodeUserId === incomingZcodeUserId
+ );
+ }
+ if (!existing && data.authType === "oauth" && data.email) {
const incomingWs = data.providerSpecificData?.chatgptAccountId;
existing = all.find(c => {
if (c.authType !== "oauth" || c.email !== data.email) return false;
diff --git a/src/lib/zcode/auth.js b/src/lib/zcode/auth.js
new file mode 100644
index 0000000000..3b9a0b8440
--- /dev/null
+++ b/src/lib/zcode/auth.js
@@ -0,0 +1,259 @@
+import crypto from "node:crypto";
+import config from "./config.js";
+import { ZCODE_ZAI_DEFAULT_EXPIRES_IN } from "./constants.js";
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function decodeJwtPayload(jwt) {
+ try {
+ if (!jwt || typeof jwt !== "string") return null;
+ const parts = jwt.split(".");
+ if (parts.length !== 3) return null;
+ const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
+ const padding = (4 - (base64.length % 4)) % 4;
+ const padded = base64 + "=".repeat(padding);
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
+ } catch {
+ return null;
+ }
+}
+
+function isValidEmail(value) {
+ return typeof value === "string" && EMAIL_RE.test(value.trim());
+}
+
+function normalizeEmail(value) {
+ if (!isValidEmail(value)) return undefined;
+ return value.trim();
+}
+
+function extractEmailFromJwt(jwt) {
+ const payload = decodeJwtPayload(jwt);
+ if (!payload) return undefined;
+ return normalizeEmail(payload.email || payload.preferred_username);
+}
+
+function extractZcodeUserId(jwt) {
+ const payload = decodeJwtPayload(jwt);
+ if (!payload) return undefined;
+ const id = payload.user_id || payload.userId;
+ return typeof id === "string" && id.trim() ? id.trim() : undefined;
+}
+
+function extractEmailFromPollData(pollData) {
+ if (!pollData || typeof pollData !== "object") return undefined;
+ const zai = pollData.zai || {};
+ const candidates = [
+ pollData.email,
+ pollData.user_email,
+ pollData.userEmail,
+ zai.email,
+ zai.user_email,
+ zai.userEmail,
+ zai.account_email,
+ pollData.user?.email,
+ zai.user?.email,
+ ];
+ for (const candidate of candidates) {
+ const email = normalizeEmail(candidate);
+ if (email) return email;
+ }
+ return undefined;
+}
+
+function extractEmailFromCustomerInfo(data) {
+ if (!data || typeof data !== "object") return undefined;
+ const candidates = [
+ data.email,
+ data.userEmail,
+ data.user_email,
+ data.customerEmail,
+ data.accountEmail,
+ data.loginEmail,
+ data.mail,
+ data.user?.email,
+ data.customer?.email,
+ data.profile?.email,
+ data.accountInfo?.email,
+ ];
+ for (const candidate of candidates) {
+ const email = normalizeEmail(candidate);
+ if (email) return email;
+ }
+ for (const org of data.organizations || []) {
+ const email = normalizeEmail(org.email || org.ownerEmail || org.contactEmail);
+ if (email) return email;
+ }
+ return undefined;
+}
+
+export class ZaiAuthFlow {
+ constructor(apiBaseUrl = config.apiBaseUrl, pollToken = null) {
+ this.apiBaseUrl = apiBaseUrl;
+ this.pollToken = pollToken || crypto.randomBytes(32).toString("hex");
+ }
+
+ async init() {
+ const url = `${this.apiBaseUrl}/oauth/cli/init`;
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${this.pollToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ provider: "zai" }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Initialization failed: ${response.status} ${response.statusText}`);
+ }
+
+ const json = await response.json();
+ const flowId = json.data?.flow_id;
+ const authorizeUrl = json.data?.authorize_url;
+
+ if (!flowId || !authorizeUrl) {
+ throw new Error("Incomplete OAuth flow data in response");
+ }
+
+ return { flowId, authorizeUrl, pollToken: this.pollToken };
+ }
+
+ async poll(flowId) {
+ const url = `${this.apiBaseUrl}/oauth/cli/poll/${encodeURIComponent(flowId)}`;
+ const response = await fetch(url, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${this.pollToken}` },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Polling failed: ${response.status}`);
+ }
+
+ const json = await response.json();
+ return json.data;
+ }
+
+ async exchangeForConnection(accessToken, zcodeJwtToken, pollData = null) {
+ const { bizToken, loginData } = await this._fetchBizToken(accessToken);
+ const { orgId, projId, customerInfo } = await this._getOrgAndProject(bizToken);
+ const fullKey = await this._getOrCreateApiKey(bizToken, orgId, projId);
+ const zcodeUserId = extractZcodeUserId(zcodeJwtToken) || extractZcodeUserId(accessToken);
+
+ const email =
+ extractEmailFromPollData(pollData) ||
+ extractEmailFromCustomerInfo(customerInfo) ||
+ normalizeEmail(loginData.email || loginData.userEmail || loginData.user_email) ||
+ extractEmailFromJwt(zcodeJwtToken) ||
+ extractEmailFromJwt(accessToken) ||
+ undefined;
+
+ const zai = pollData?.zai || {};
+ const refreshToken =
+ typeof zai.refresh_token === "string" && zai.refresh_token.trim()
+ ? zai.refresh_token.trim()
+ : undefined;
+ const expiresIn =
+ typeof zai.expires_in === "number" && zai.expires_in > 0
+ ? zai.expires_in
+ : ZCODE_ZAI_DEFAULT_EXPIRES_IN;
+
+ return {
+ apiKey: fullKey,
+ accessToken: zcodeJwtToken || undefined,
+ ...(refreshToken ? { refreshToken } : {}),
+ ...(refreshToken || zai.expires_in
+ ? {
+ expiresIn,
+ expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
+ }
+ : {}),
+ email,
+ name: email || undefined,
+ providerSpecificData: {
+ authMethod: "zcode_oauth",
+ useCodingPlan: !!zcodeJwtToken,
+ zcodeJwtToken: zcodeJwtToken || undefined,
+ zaiAccessToken: accessToken,
+ ...(refreshToken ? { zaiRefreshToken: refreshToken } : {}),
+ ...(zcodeUserId ? { zcodeUserId } : {}),
+ },
+ };
+ }
+
+ async _fetchBizToken(accessToken) {
+ const loginRes = await fetch("https://api.z.ai/api/auth/z/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: accessToken }),
+ });
+ if (!loginRes.ok) throw new Error("Failed to exchange business token");
+ const loginJson = await loginRes.json();
+ const bizToken = loginJson.data?.access_token || loginJson.data?.accessToken;
+ if (!bizToken) throw new Error("Business credentials missing from response");
+ return { bizToken, loginData: loginJson.data || {} };
+ }
+
+ async _getOrgAndProject(bizToken) {
+ const infoRes = await fetch("https://api.z.ai/api/biz/customer/getCustomerInfo", {
+ method: "GET",
+ headers: { Authorization: `Bearer ${bizToken}` },
+ });
+ if (!infoRes.ok) throw new Error("Failed to fetch organization info");
+ const infoJson = await infoRes.json();
+
+ const orgs = infoJson.data?.organizations || [];
+ const targetOrg = orgs.find((o) => o.organizationName?.includes("默认机构")) || orgs[0];
+ if (!targetOrg) throw new Error("No available organization found");
+
+ const projects = targetOrg.projects || [];
+ const targetProj = projects.find((p) => p.projectName?.includes("默认项目")) || projects[0];
+ if (!targetProj) throw new Error("No available project found");
+
+ return {
+ orgId: targetOrg.organizationId,
+ projId: targetProj.projectId,
+ customerInfo: infoJson.data || {},
+ };
+ }
+
+ async _getOrCreateApiKey(bizToken, orgId, projId) {
+ const keyUrl = `https://api.z.ai/api/biz/v1/organization/${orgId}/projects/${projId}/api_keys`;
+ const keysRes = await fetch(keyUrl, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${bizToken}` },
+ });
+ if (!keysRes.ok) throw new Error("Failed to fetch API Keys");
+ const keysJson = await keysRes.json();
+ const keys = keysJson.data || [];
+
+ let keyObj = keys.find((k) => k.name === "zcode-api-key");
+ if (!keyObj) {
+ const createRes = await fetch(keyUrl, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${bizToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name: "zcode-api-key" }),
+ });
+ if (!createRes.ok) throw new Error("Failed to create API Key");
+ const createJson = await createRes.json();
+ keyObj = createJson.data;
+ }
+
+ const apiKey = keyObj?.apiKey;
+ if (!apiKey) throw new Error("Failed to obtain API Key");
+
+ const copyRes = await fetch(`${keyUrl}/copy/${encodeURIComponent(apiKey)}`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${bizToken}` },
+ });
+ if (!copyRes.ok) throw new Error("Failed to fetch Secret Key");
+ const copyJson = await copyRes.json();
+ const secretKey = copyJson.data?.secretKey;
+ if (!secretKey) throw new Error("Failed to decrypt Secret Key");
+
+ return `${apiKey}.${secretKey}`;
+ }
+}
\ No newline at end of file
diff --git a/src/lib/zcode/browser.js b/src/lib/zcode/browser.js
new file mode 100644
index 0000000000..2e1ccf3cd4
--- /dev/null
+++ b/src/lib/zcode/browser.js
@@ -0,0 +1,53 @@
+import { launch as cbLaunch } from "cloakbrowser";
+import path from "node:path";
+import os from "node:os";
+
+const USER_DATA_DIR = path.join(os.homedir(), ".cloakbrowser", "profiles", "9router-zcode");
+
+let browserInstance = null;
+let currentMode = null;
+
+export async function launch(opts = {}) {
+ const headless = opts.headless !== false;
+ const requestedMode = headless ? "headless" : "headed";
+
+ if (browserInstance && currentMode === requestedMode) {
+ try {
+ browserInstance.contexts();
+ return browserInstance;
+ } catch {
+ browserInstance = null;
+ }
+ }
+
+ if (browserInstance) {
+ await close();
+ }
+
+ browserInstance = await cbLaunch({
+ headless,
+ userDataDir: USER_DATA_DIR,
+ args: ["--no-sandbox", "--no-first-run", "--disable-default-apps"],
+ });
+
+ browserInstance.on("disconnected", () => {
+ browserInstance = null;
+ currentMode = null;
+ });
+
+ currentMode = requestedMode;
+ return browserInstance;
+}
+
+export async function close() {
+ if (browserInstance) {
+ try {
+ await browserInstance.close();
+ } catch {
+ // ignore
+ }
+ browserInstance = null;
+ currentMode = null;
+ }
+}
+
diff --git a/src/lib/zcode/captcha-manager.js b/src/lib/zcode/captcha-manager.js
new file mode 100644
index 0000000000..1f2aa5b460
--- /dev/null
+++ b/src/lib/zcode/captcha-manager.js
@@ -0,0 +1,239 @@
+import { launch as launchBrowser } from "./browser.js";
+import config from "./config.js";
+
+export class CaptchaManager {
+ constructor() {
+ this.cachedVerifyParam = null;
+ this.pendingPromise = null;
+ this.resolveCallback = null;
+ this.rejectCallback = null;
+ this.captchaPage = null;
+ this.captchaConfigCache = null;
+ this.captchaConfigCacheTime = 0;
+ this._clearCacheTimer = null;
+ this._captchaTimeoutId = null;
+ this._headlessTimeoutId = null;
+ this._verificationPhase = null;
+ this._headedFallbackAttempted = false;
+ this._activePort = config.captchaPort;
+ }
+
+ async fetchCaptchaConfig() {
+ const now = Date.now();
+ if (this.captchaConfigCache && now - this.captchaConfigCacheTime < config.captchaConfigCacheTTL) {
+ return this.captchaConfigCache;
+ }
+
+ try {
+ const res = await fetch(
+ `https://zcode.z.ai/api/v1/client/configs?app_version=${config.appVersion}&platform=win32`
+ );
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const json = await res.json();
+ const captchaConfig = json.data?.configs?.captcha;
+ if (captchaConfig) {
+ this.captchaConfigCache = captchaConfig;
+ this.captchaConfigCacheTime = now;
+ return captchaConfig;
+ }
+ } catch (err) {
+ console.error("[ZCode Captcha] Failed to fetch config, using defaults:", err.message);
+ }
+
+ return {
+ enabled: true,
+ prefix: "no8xfe",
+ region: "sgp",
+ sceneId: "11xygtvd",
+ };
+ }
+
+ _clearVerificationTimers() {
+ if (this._captchaTimeoutId) {
+ clearTimeout(this._captchaTimeoutId);
+ this._captchaTimeoutId = null;
+ }
+ if (this._headlessTimeoutId) {
+ clearTimeout(this._headlessTimeoutId);
+ this._headlessTimeoutId = null;
+ }
+ }
+
+ _rejectPending(err) {
+ this._clearVerificationTimers();
+ if (this.rejectCallback) {
+ this.rejectCallback(err);
+ }
+ this.pendingPromise = null;
+ this.resolveCallback = null;
+ this.rejectCallback = null;
+ this._verificationPhase = null;
+ this._headedFallbackAttempted = false;
+ }
+
+ _resolvePending(verifyParam) {
+ this._clearVerificationTimers();
+ if (this.resolveCallback) {
+ this.resolveCallback(verifyParam);
+ }
+ this.pendingPromise = null;
+ this.resolveCallback = null;
+ this.rejectCallback = null;
+ this._verificationPhase = null;
+ this._headedFallbackAttempted = false;
+ }
+
+ _armPhaseTimeout(phase) {
+ this._clearVerificationTimers();
+
+ const timeoutMs =
+ phase === "headed"
+ ? config.captchaInteractiveTimeoutMs
+ : config.captchaHeadlessTimeoutMs;
+
+ this._captchaTimeoutId = setTimeout(() => {
+ if (phase === "headless" && config.captchaHeadedFallback && !this._headedFallbackAttempted) {
+ console.warn(
+ `[ZCode Captcha] Traceless verification timed out after ${Math.round(timeoutMs / 1000)}s, opening visible browser...`
+ );
+ this.onNeedsInteractive().catch((err) => {
+ console.error("[ZCode Captcha] Headed fallback failed:", err.message);
+ this._rejectPending(
+ new Error(
+ `Captcha verification timed out. Complete the puzzle in the browser window or retry later. (${err.message})`
+ )
+ );
+ });
+ return;
+ }
+
+ this._rejectPending(
+ new Error(
+ phase === "headed"
+ ? `Interactive captcha timed out after ${Math.round(timeoutMs / 1000)}s. Complete the puzzle in the browser window and retry.`
+ : `Captcha verification timed out after ${Math.round(timeoutMs / 1000)}s. Ensure CloakBrowser can reach /zcode/captcha.html and retry.`
+ )
+ );
+ }, timeoutMs);
+ }
+
+ async _closeCaptchaPage() {
+ if (!this.captchaPage) return;
+ try {
+ await this.captchaPage.close();
+ } catch {
+ // ignore
+ }
+ this.captchaPage = null;
+ }
+
+ async openVerificationPage(port = config.captchaPort, { headless = true, interactive = false } = {}) {
+ this._activePort = port;
+ this._verificationPhase = headless ? "headless" : "headed";
+
+ if (this.captchaPage && !this.captchaPage.isClosed()) {
+ if (!interactive) {
+ try {
+ await this.captchaPage.evaluate(() => {
+ if (typeof window.__resetCaptcha === "function") {
+ return window.__resetCaptcha();
+ }
+ });
+ this._armPhaseTimeout(this._verificationPhase);
+ return;
+ } catch (err) {
+ console.warn("[ZCode Captcha] page.evaluate failed, reopening page:", err.message);
+ await this._closeCaptchaPage();
+ }
+ } else {
+ await this._closeCaptchaPage();
+ }
+ }
+
+ const browserInstance = await launchBrowser({ headless });
+ const context = browserInstance.contexts()[0] || (await browserInstance.newContext());
+ this.captchaPage = await context.newPage();
+
+ const query = interactive ? "?mode=interactive" : "";
+ await this.captchaPage.goto(`http://localhost:${port}/zcode/captcha.html${query}`, {
+ waitUntil: "domcontentloaded",
+ timeout: 30000,
+ });
+
+ this.captchaPage.on("close", () => {
+ this.captchaPage = null;
+ });
+
+ this._armPhaseTimeout(this._verificationPhase);
+
+ if (!headless) {
+ console.log(
+ "[ZCode Captcha] Visible browser opened — complete the security puzzle in the window to continue."
+ );
+ }
+ }
+
+ async onNeedsInteractive() {
+ if (!this.pendingPromise || this._headedFallbackAttempted || !config.captchaHeadedFallback) {
+ return;
+ }
+
+ this._headedFallbackAttempted = true;
+ await this._closeCaptchaPage();
+ await this.openVerificationPage(this._activePort, { headless: false, interactive: true });
+ }
+
+ async getVerifyParam(port = config.captchaPort) {
+ if (this.cachedVerifyParam) {
+ return this.cachedVerifyParam;
+ }
+
+ if (this.pendingPromise) {
+ return this.pendingPromise;
+ }
+
+ this._headedFallbackAttempted = false;
+ this._activePort = port;
+
+ this.pendingPromise = new Promise((resolve, reject) => {
+ this.resolveCallback = resolve;
+ this.rejectCallback = reject;
+ });
+
+ this.openVerificationPage(port, { headless: true, interactive: false }).catch((err) => {
+ this._rejectPending(new Error("Browser launch failed: " + err.message));
+ });
+
+ return this.pendingPromise;
+ }
+
+ submit(verifyParam) {
+ if (this.resolveCallback) {
+ this._resolvePending(verifyParam);
+ }
+
+ if (this._clearCacheTimer) {
+ clearTimeout(this._clearCacheTimer);
+ this._clearCacheTimer = null;
+ }
+
+ this.cachedVerifyParam = verifyParam;
+ this._clearCacheTimer = setTimeout(() => {
+ this.cachedVerifyParam = null;
+ this._clearCacheTimer = null;
+ }, config.captchaCacheTTL);
+ }
+
+ invalidate() {
+ this.cachedVerifyParam = null;
+ if (this._clearCacheTimer) {
+ clearTimeout(this._clearCacheTimer);
+ this._clearCacheTimer = null;
+ }
+ }
+
+ async close() {
+ this._clearVerificationTimers();
+ await this._closeCaptchaPage();
+ }
+}
\ No newline at end of file
diff --git a/src/lib/zcode/captcha-service.js b/src/lib/zcode/captcha-service.js
new file mode 100644
index 0000000000..299b3e2252
--- /dev/null
+++ b/src/lib/zcode/captcha-service.js
@@ -0,0 +1,31 @@
+import { CaptchaManager } from "./captcha-manager.js";
+import config from "./config.js";
+
+// Next.js webpack can load this module twice (API routes vs open-sse executor).
+// Use a process-global singleton so captcha submit resolves the waiting executor.
+const CAPTCHA_MANAGER_KEY = Symbol.for("9router.zcode.captchaManager");
+
+export function getCaptchaManager() {
+ if (!globalThis[CAPTCHA_MANAGER_KEY]) {
+ globalThis[CAPTCHA_MANAGER_KEY] = new CaptchaManager();
+ }
+ return globalThis[CAPTCHA_MANAGER_KEY];
+}
+
+export function getZcodeCaptchaPort() {
+ return config.captchaPort;
+}
+
+export async function isCaptchaError(response) {
+ try {
+ const clone = response.clone();
+ const text = await clone.text();
+ return (
+ text.toLowerCase().includes("captcha") ||
+ text.includes("verify token") ||
+ text.includes("verify failed")
+ );
+ } catch {
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/lib/zcode/config.js b/src/lib/zcode/config.js
new file mode 100644
index 0000000000..e296a8929a
--- /dev/null
+++ b/src/lib/zcode/config.js
@@ -0,0 +1,17 @@
+export default {
+ apiBaseUrl: process.env.ZCODE_API_BASE_URL || "https://zcode.z.ai/api/v1",
+ captchaPort: parseInt(process.env.ZCODE_CAPTCHA_PORT || process.env.PORT || "20128", 10),
+ captchaCacheTTL: parseInt(process.env.CAPTCHA_CACHE_TTL || "45000", 10),
+ captchaVerifyTimeoutMs: parseInt(process.env.CAPTCHA_VERIFY_TIMEOUT_MS || "120000", 10),
+ captchaHeadedFallback: process.env.ZCODE_CAPTCHA_HEADED_FALLBACK !== "false",
+ captchaHeadlessTimeoutMs: parseInt(process.env.CAPTCHA_HEADLESS_TIMEOUT_MS || "45000", 10),
+ captchaInteractiveTimeoutMs: parseInt(process.env.CAPTCHA_INTERACTIVE_TIMEOUT_MS || "300000", 10),
+ captchaConfigCacheTTL: parseInt(process.env.CAPTCHA_CONFIG_CACHE_TTL || "600000", 10),
+ codingPlanUrl:
+ process.env.ZAI_CODING_PLAN_URL ||
+ "https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages",
+ apiKeyFallbackUrl:
+ process.env.ZAI_FALLBACK_URL || "https://api.z.ai/api/anthropic/v1/messages",
+ appVersion: process.env.ZCODE_APP_VERSION || "3.1.0",
+ userAgent: process.env.UPSTREAM_USER_AGENT || "ZCode/3.1.0",
+};
\ No newline at end of file
diff --git a/src/lib/zcode/constants.js b/src/lib/zcode/constants.js
new file mode 100644
index 0000000000..da7f1b2774
--- /dev/null
+++ b/src/lib/zcode/constants.js
@@ -0,0 +1,19 @@
+/** Z.AI OAuth via ZCode CLI flow (chat.z.ai authorization server). */
+export const ZCODE_ZAI_CLIENT_ID =
+ process.env.ZCODE_ZAI_CLIENT_ID || "client_P8X5CMWmlaRO9gyO-KSqtg";
+
+export const ZCODE_ZAI_CLIENT_SECRET = process.env.ZCODE_ZAI_CLIENT_SECRET || "";
+
+export const ZCODE_ZAI_TOKEN_URL =
+ process.env.ZCODE_ZAI_TOKEN_URL || "https://chat.z.ai/api/oauth/token";
+
+/** Z.AI OAuth access_token lifetime when poll omits expires_in (~1h). */
+export const ZCODE_ZAI_DEFAULT_EXPIRES_IN = 3600;
+
+/** Upstream model IDs for Coding Plan (case-sensitive). */
+export const GLM_CODING_PLAN_MODEL_MAP = {
+ "glm-5.2": "GLM-5.2",
+ "glm-5.1": "GLM-5.1",
+ "glm-5-turbo": "GLM-5-Turbo",
+ "glm-4.7": "GLM-4.7",
+};
\ No newline at end of file
diff --git a/src/lib/zcode/headers.js b/src/lib/zcode/headers.js
new file mode 100644
index 0000000000..5e50090a03
--- /dev/null
+++ b/src/lib/zcode/headers.js
@@ -0,0 +1,158 @@
+import crypto from "crypto";
+import zcodeConfig from "./config.js";
+
+const sessionIdByConnection = new Map();
+
+function randomUuid() {
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+}
+
+function sessionKey(credentials) {
+ return (
+ credentials?.connectionId ||
+ credentials?.providerSpecificData?.zcodeUserId ||
+ credentials?.providerSpecificData?.zcodeJwtToken?.slice(-24) ||
+ "default"
+ );
+}
+
+/** Stable x-session-id per connection (matches ZCode app session affinity). */
+export function getZcodeSessionId(credentials) {
+ const key = sessionKey(credentials);
+ if (!sessionIdByConnection.has(key)) {
+ sessionIdByConnection.set(key, randomUuid());
+ }
+ return sessionIdByConnection.get(key);
+}
+
+const ANTHROPIC_HEADER_KEYS = [
+ "Anthropic-Version",
+ "anthropic-version",
+ "Anthropic-Beta",
+ "anthropic-beta",
+ "Anthropic-Dangerous-Direct-Browser-Access",
+ "anthropic-dangerous-direct-browser-access",
+];
+
+export function stripAnthropicHeadersForZcodePlan(headers) {
+ if (!headers || typeof headers !== "object") return headers;
+ for (const key of ANTHROPIC_HEADER_KEYS) {
+ delete headers[key];
+ }
+ return headers;
+}
+
+const ZCODE_CODING_PLAN_HEADER_KEYS = [
+ "Authorization",
+ "User-Agent",
+ "X-ZCode-App-Version",
+ "X-ZCode-Agent",
+ "X-Title",
+ "HTTP-Referer",
+ "X-Aliyun-Captcha-Verify-Param",
+ "X-Aliyun-Captcha-Verify-Region",
+ "x-request-id",
+ "x-zcode-trace-id",
+ "x-query-id",
+ "x-session-id",
+];
+
+export function clearZcodeCodingPlanHeaders(headers) {
+ if (!headers || typeof headers !== "object") return headers;
+ for (const key of ZCODE_CODING_PLAN_HEADER_KEYS) {
+ delete headers[key];
+ }
+ return headers;
+}
+
+/**
+ * Build ZCode Coding Plan upstream headers (zcode-plan URL fingerprint).
+ * @param {object} credentials
+ * @param {{ verifyParam?: string }} [options]
+ */
+export function buildZcodeCodingPlanHeaders(credentials, options = {}) {
+ const jwt =
+ credentials?.providerSpecificData?.zcodeJwtToken || credentials?.accessToken;
+ const verifyParam =
+ options.verifyParam ?? credentials?.providerSpecificData?._captchaVerifyParam;
+ const appVersion = zcodeConfig.appVersion;
+ const userAgent = zcodeConfig.userAgent;
+
+ const headers = {
+ Authorization: `Bearer ${jwt}`,
+ "User-Agent": userAgent,
+ "X-ZCode-App-Version": appVersion,
+ "X-ZCode-Agent": "glm",
+ "X-Title": "Z Code@electron",
+ "HTTP-Referer": "https://zcode.z.ai/",
+ "x-request-id": randomUuid(),
+ "x-zcode-trace-id": randomUuid(),
+ "x-query-id": randomUuid(),
+ "x-session-id": getZcodeSessionId(credentials),
+ };
+
+ if (verifyParam) {
+ headers["X-Aliyun-Captcha-Verify-Param"] = verifyParam;
+ headers["X-Aliyun-Captcha-Verify-Region"] = "sgp";
+ }
+
+ return headers;
+}
+
+/** Merge ZCode Coding Plan headers into an existing header bag; strips Anthropic CLI headers. */
+export function applyZcodeCodingPlanHeaders(headers, credentials, options = {}) {
+ stripAnthropicHeadersForZcodePlan(headers);
+ delete headers["x-api-key"];
+ Object.assign(headers, buildZcodeCodingPlanHeaders(credentials, options));
+ return headers;
+}
+
+/**
+ * Build ZCode API key upstream headers (api.z.ai fingerprint — matches zcode_proxy).
+ * @param {object} credentials
+ * @param {{ verifyParam?: string }} [options]
+ */
+export function buildZcodeApiKeyHeaders(credentials, options = {}) {
+ const verifyParam =
+ options.verifyParam ?? credentials?.providerSpecificData?._captchaVerifyParam;
+
+ const headers = {
+ "anthropic-version": "2023-06-01",
+ "User-Agent": zcodeConfig.userAgent,
+ "X-ZCode-App-Version": zcodeConfig.appVersion,
+ "X-ZCode-Agent": "glm",
+ "HTTP-Referer": "https://zcode.z.ai/",
+ };
+
+ if (credentials?.apiKey) {
+ headers["x-api-key"] = credentials.apiKey;
+ }
+
+ if (verifyParam) {
+ headers["X-Aliyun-Captcha-Verify-Param"] = verifyParam;
+ }
+
+ return headers;
+}
+
+/** Merge ZCode API key headers; strips Coding Plan / Claude Code beta headers. */
+export function applyZcodeApiKeyHeaders(headers, credentials, options = {}) {
+ clearZcodeCodingPlanHeaders(headers);
+ delete headers["Authorization"];
+ delete headers["Anthropic-Beta"];
+ delete headers["anthropic-beta"];
+ delete headers["Anthropic-Version"];
+ Object.assign(headers, buildZcodeApiKeyHeaders(credentials, options));
+ return headers;
+}
+
+export const __test__ = {
+ randomUuid,
+ sessionKey,
+ sessionIdByConnection,
+};
\ No newline at end of file
diff --git a/src/lib/zcode/oauth.js b/src/lib/zcode/oauth.js
new file mode 100644
index 0000000000..ce1eb10a18
--- /dev/null
+++ b/src/lib/zcode/oauth.js
@@ -0,0 +1,67 @@
+import { ZaiAuthFlow } from "./auth.js";
+import {
+ ZCODE_ZAI_CLIENT_ID,
+ ZCODE_ZAI_CLIENT_SECRET,
+ ZCODE_ZAI_TOKEN_URL,
+} from "./constants.js";
+
+/**
+ * Refresh Z.AI OAuth access_token via chat.z.ai (requires client_secret).
+ * Mirrors xAI refresh pattern; zcodeJwt (Coding Plan JWT) is issued only during CLI poll
+ * and cannot be renewed without re-authentication.
+ */
+export async function refreshZaiAccessToken(refreshToken) {
+ if (!refreshToken || !ZCODE_ZAI_CLIENT_SECRET) {
+ return null;
+ }
+
+ const res = await fetch(ZCODE_ZAI_TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken,
+ client_id: ZCODE_ZAI_CLIENT_ID,
+ client_secret: ZCODE_ZAI_CLIENT_SECRET,
+ }),
+ });
+
+ if (!res.ok) {
+ const err = await res.text().catch(() => "");
+ throw new Error(`Z.AI token refresh failed: ${res.status} ${err}`);
+ }
+
+ return await res.json();
+}
+
+/**
+ * Refresh GLM OAuth credentials: rotate zai access_token (when configured) and
+ * re-fetch the biz API key. Coding Plan JWT is left unchanged until user re-OAuths.
+ */
+export async function refreshGlmOAuthCredentials(credentials) {
+ const refreshToken = credentials?.refreshToken;
+ if (!refreshToken) return null;
+
+ const tokens = await refreshZaiAccessToken(refreshToken);
+ if (!tokens?.access_token) return null;
+
+ const flow = new ZaiAuthFlow();
+ const connectionPatch = await flow.exchangeForConnection(
+ tokens.access_token,
+ credentials.providerSpecificData?.zcodeJwtToken || credentials.accessToken,
+ { zai: tokens }
+ );
+
+ return {
+ accessToken: connectionPatch.accessToken,
+ apiKey: connectionPatch.apiKey,
+ refreshToken: tokens.refresh_token || refreshToken,
+ expiresIn: tokens.expires_in,
+ providerSpecificData: {
+ ...credentials.providerSpecificData,
+ ...connectionPatch.providerSpecificData,
+ zaiAccessToken: tokens.access_token,
+ ...(tokens.refresh_token ? { zaiRefreshToken: tokens.refresh_token } : {}),
+ },
+ };
+}
\ No newline at end of file
diff --git a/src/lib/zcode/sessions.js b/src/lib/zcode/sessions.js
new file mode 100644
index 0000000000..7797eadb7d
--- /dev/null
+++ b/src/lib/zcode/sessions.js
@@ -0,0 +1,77 @@
+import { makeKv } from "../db/helpers/kvStore.js";
+
+const TTL_MS = 10 * 60 * 1000;
+const SESSIONS_KEY = Symbol.for("9router.zcode.oauthSessions");
+const kv = makeKv("zaiOAuthSessions");
+
+function getSessionMap() {
+ if (!globalThis[SESSIONS_KEY]) {
+ globalThis[SESSIONS_KEY] = new Map();
+ }
+ return globalThis[SESSIONS_KEY];
+}
+
+async function pruneKv(now = Date.now()) {
+ try {
+ const all = await kv.getAll();
+ for (const [id, session] of Object.entries(all)) {
+ if (!session?.expiresAt || session.expiresAt <= now) {
+ await kv.remove(id);
+ getSessionMap().delete(id);
+ }
+ }
+ } catch {
+ // ignore kv prune errors
+ }
+}
+
+function pruneMemory(now = Date.now()) {
+ const sessions = getSessionMap();
+ for (const [id, session] of sessions) {
+ if (!session?.expiresAt || session.expiresAt <= now) {
+ sessions.delete(id);
+ }
+ }
+}
+
+export async function createZaiSession({ flowId, pollToken }) {
+ const now = Date.now();
+ pruneMemory(now);
+ await pruneKv(now);
+
+ const session = {
+ flowId,
+ pollToken,
+ expiresAt: now + TTL_MS,
+ };
+
+ getSessionMap().set(flowId, session);
+ await kv.set(flowId, session);
+ return flowId;
+}
+
+export async function getZaiSession(sessionId) {
+ const now = Date.now();
+ pruneMemory(now);
+
+ let session = getSessionMap().get(sessionId);
+ if (!session) {
+ session = await kv.get(sessionId);
+ if (session) {
+ getSessionMap().set(sessionId, session);
+ }
+ }
+
+ if (!session || session.expiresAt <= now) {
+ getSessionMap().delete(sessionId);
+ await kv.remove(sessionId).catch(() => {});
+ return null;
+ }
+
+ return session;
+}
+
+export async function deleteZaiSession(sessionId) {
+ getSessionMap().delete(sessionId);
+ await kv.remove(sessionId).catch(() => {});
+}
\ No newline at end of file
diff --git a/src/lib/zcode/systemPrompt.js b/src/lib/zcode/systemPrompt.js
new file mode 100644
index 0000000000..78e3fab28e
--- /dev/null
+++ b/src/lib/zcode/systemPrompt.js
@@ -0,0 +1,113 @@
+import os from "node:os";
+
+/** Substring used to detect ZCode identity blocks (idempotent injection). */
+export const ZCODE_SYSTEM_IDENTITY_MARKER = "You are ZCode, an interactive coding agent";
+
+export const ZCODE_SYSTEM_IDENTITY = ZCODE_SYSTEM_IDENTITY_MARKER;
+
+export const ZCODE_SYSTEM_HARNESS = `You are an interactive ZCode agent that helps users with software engineering tasks.
+
+IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.
+
+# Harness
+- Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal.
+- Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim.
+- \`\` tags in messages and tool results are injected by the harness, not the user. Hooks may intercept tool calls; treat hook output as user feedback.
+- Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response.
+- Reference code as \`file_path:line_number\` — it's clickable.`;
+
+export const ZCODE_SYSTEM_GUIDANCE = `Write code that reads like the surrounding code: match its comment density, naming, and idiom.
+
+For actions that are hard to reverse or outward-facing, confirm first unless durably authorized or explicitly told to proceed without asking; approval in one context doesn't extend to the next. Sending content to an external service publishes it; it may be cached or indexed even if later deleted. Before deleting or overwriting, look at the target — if what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding. Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging.
+
+# Session-specific guidance
+- When the user types \`/\`, invoke it via Skill. Only use skills listed in the user-invocable skills section — don't guess.
+
+# Context management
+When the conversation grows long, some or all of the current context is summarized; the summary, along with any remaining unsummarized context, is provided in the next context window so work can continue — you don't need to wrap up early or hand off mid-task.`;
+
+const CLAUDE_CODE_SYSTEM_MARKERS = [
+ "You are Claude Code",
+ "Anthropic's official CLI for Claude",
+];
+
+function textFromSystemBlock(block) {
+ if (!block || typeof block !== "object") return "";
+ return typeof block.text === "string" ? block.text : "";
+}
+
+function isClaudeCodeSystemBlock(block) {
+ const text = textFromSystemBlock(block);
+ return CLAUDE_CODE_SYSTEM_MARKERS.some((marker) => text.includes(marker));
+}
+
+function hasZcodeSystemMarker(system) {
+ const blocks = Array.isArray(system) ? system : [];
+ return blocks.some((block) => textFromSystemBlock(block).includes(ZCODE_SYSTEM_IDENTITY_MARKER));
+}
+
+/**
+ * Build the ZCode environment block (matches ZCode app shape; paths are resolved at request time).
+ */
+export function buildZcodeEnvironmentBlock({
+ modelRef = "builtin:zai-start-plan/GLM-5.2",
+ workingDirectory = process.cwd(),
+ platform = process.platform,
+ shell = process.env.SHELL?.split("/").pop() || "sh",
+ osVersion = `${os.type()} ${os.release()} ${os.arch()}`,
+ isGitRepository = false,
+} = {}) {
+ return `${ZCODE_SYSTEM_GUIDANCE}
+
+# Environment
+You have been invoked in the following environment:
+- Primary working directory: ${workingDirectory}
+- Is a git repository: ${isGitRepository ? "yes" : "no"}
+- Platform: ${platform}
+- Shell: ${shell}
+- OS Version: ${osVersion}
+- You are powered by the model named ${modelRef}.`;
+}
+
+function zcodeSystemBlocks({ modelRef, workingDirectory } = {}) {
+ const cache = { type: "ephemeral" };
+ return [
+ { type: "text", text: ZCODE_SYSTEM_IDENTITY, cache_control: cache },
+ { type: "text", text: ZCODE_SYSTEM_HARNESS, cache_control: cache },
+ {
+ type: "text",
+ text: buildZcodeEnvironmentBlock({ modelRef, workingDirectory }),
+ cache_control: cache,
+ },
+ ];
+}
+
+/**
+ * Replace Claude Code default system prompt with ZCode blocks for Coding Plan upstream.
+ * Preserves caller-provided system text (non-Claude-Code blocks).
+ */
+export function injectZcodeSystemPrompt(body, options = {}) {
+ if (!body || typeof body !== "object") return body;
+
+ const next = { ...body };
+ const existing = Array.isArray(next.system) ? [...next.system] : [];
+
+ if (hasZcodeSystemMarker(existing)) {
+ return next;
+ }
+
+ const preserved = existing.filter((block) => !isClaudeCodeSystemBlock(block));
+ const modelName =
+ typeof next.model === "string" && next.model.length > 0 ? next.model : "GLM-5.2";
+ const modelRef = options.modelRef || `builtin:zai-start-plan/${modelName}`;
+
+ next.system = [
+ ...zcodeSystemBlocks({
+ modelRef,
+ workingDirectory: options.workingDirectory,
+ }),
+ ...preserved,
+ ];
+
+ return next;
+}
\ No newline at end of file
diff --git a/src/shared/components/ZaiOAuthModal.js b/src/shared/components/ZaiOAuthModal.js
new file mode 100644
index 0000000000..d1df5beca5
--- /dev/null
+++ b/src/shared/components/ZaiOAuthModal.js
@@ -0,0 +1,247 @@
+"use client";
+
+import { useState, useEffect, useRef, useCallback } from "react";
+import PropTypes from "prop-types";
+import { Modal, Button } from "@/shared/components";
+
+const POLL_INTERVAL_MS = 2000;
+const MAX_POLLS = 150;
+const SESSION_NOT_FOUND_RETRIES = 5;
+
+export default function ZaiOAuthModal({ isOpen, providerInfo, onSuccess, onClose }) {
+ const [step, setStep] = useState("idle");
+ const [flowId, setFlowId] = useState(null);
+ const [authorizeUrl, setAuthorizeUrl] = useState(null);
+ const [error, setError] = useState(null);
+ const pollCountRef = useRef(0);
+ const pollTimerRef = useRef(null);
+ const initAbortRef = useRef(null);
+ const activeFlowIdRef = useRef(null);
+ const sessionMissRef = useRef(0);
+
+ const stopPolling = useCallback(() => {
+ if (pollTimerRef.current) {
+ clearInterval(pollTimerRef.current);
+ pollTimerRef.current = null;
+ }
+ pollCountRef.current = 0;
+ }, []);
+
+ const resetInternal = useCallback(() => {
+ initAbortRef.current?.abort();
+ initAbortRef.current = null;
+ activeFlowIdRef.current = null;
+ sessionMissRef.current = 0;
+ stopPolling();
+ setStep("idle");
+ setFlowId(null);
+ setAuthorizeUrl(null);
+ setError(null);
+ }, [stopPolling]);
+
+ const handleClose = useCallback(() => {
+ resetInternal();
+ onClose?.();
+ }, [onClose, resetInternal]);
+
+ const startPoll = useCallback(
+ (id) => {
+ stopPolling();
+ setStep("polling");
+
+ pollTimerRef.current = setInterval(async () => {
+ pollCountRef.current += 1;
+ if (pollCountRef.current > MAX_POLLS) {
+ stopPolling();
+ setError("Authorization timed out. Please try again.");
+ setStep("error");
+ return;
+ }
+
+ try {
+ const res = await fetch("/api/oauth/zai/poll", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ flowId: id }),
+ });
+ const data = await res.json();
+
+ if (!res.ok) {
+ if (res.status === 404) {
+ sessionMissRef.current += 1;
+ if (sessionMissRef.current <= SESSION_NOT_FOUND_RETRIES) {
+ return;
+ }
+ }
+ stopPolling();
+ setError(
+ res.status === 404
+ ? "OAuth session was lost (server may have restarted). Click Retry to start again."
+ : (data.error || "Polling failed")
+ );
+ setStep("error");
+ return;
+ }
+
+ sessionMissRef.current = 0;
+
+ if (data.status === "pending") return;
+
+ if (data.status === "failed") {
+ stopPolling();
+ setError(data.error || "Authorization failed");
+ setStep("error");
+ return;
+ }
+
+ if (data.status === "ready") {
+ stopPolling();
+ setStep("success");
+ setTimeout(() => {
+ onSuccess?.();
+ handleClose();
+ }, 1500);
+ }
+ } catch (err) {
+ // Ignore transient network errors during polling
+ if (pollCountRef.current > MAX_POLLS) {
+ stopPolling();
+ setError(err.message);
+ setStep("error");
+ }
+ }
+ }, POLL_INTERVAL_MS);
+ },
+ [handleClose, onSuccess, stopPolling]
+ );
+
+ const startOAuth = useCallback(async () => {
+ initAbortRef.current?.abort();
+ const abortController = new AbortController();
+ initAbortRef.current = abortController;
+ sessionMissRef.current = 0;
+ setError(null);
+ setStep("init");
+
+ try {
+ const res = await fetch("/api/oauth/zai/init", {
+ method: "POST",
+ signal: abortController.signal,
+ });
+ const data = await res.json();
+
+ if (abortController.signal.aborted) return;
+
+ if (!res.ok) {
+ throw new Error(data.error || "Failed to start OAuth");
+ }
+
+ activeFlowIdRef.current = data.flowId;
+ setFlowId(data.flowId);
+ setAuthorizeUrl(data.authorizeUrl);
+ setStep("authorize");
+ startPoll(data.flowId);
+ } catch (err) {
+ if (abortController.signal.aborted) return;
+ setError(err.message);
+ setStep("error");
+ }
+ }, [startPoll]);
+
+ useEffect(() => {
+ if (!isOpen) {
+ resetInternal();
+ return undefined;
+ }
+
+ startOAuth();
+
+ return () => {
+ initAbortRef.current?.abort();
+ stopPolling();
+ };
+ }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const openAuthorizeUrl = () => {
+ if (authorizeUrl) {
+ window.open(authorizeUrl, "_blank", "noopener,noreferrer");
+ }
+ };
+
+ const providerName = providerInfo?.name || "GLM Coding";
+
+ return (
+
+
+ {step === "success" ? (
+
+
✅
+
Account connected!
+
Coding Plan credentials saved
+
+ ) : step === "error" ? (
+ <>
+
+
+
+ Cancel
+
+
+ Retry
+
+
+ >
+ ) : (
+ <>
+
+ Sign in with your Z.AI account to connect a Coding Plan subscription. You can add
+ multiple accounts and switch between them via priority or round-robin.
+
+
+ {step === "init" && (
+
+ ⏳
+ Initializing OAuth...
+
+ )}
+
+ {(step === "authorize" || step === "polling") && (
+
+
+
Complete authorization in your browser:
+
+ Click "Open Authorization Page" below
+ Log in and approve access on z.ai
+ Return here — connection completes automatically
+
+
+
+
+ Open Authorization Page
+
+
+
+ ⏳
+ Waiting for authorization...
+
+
+ )}
+
+
+ Cancel
+
+ >
+ )}
+
+
+ );
+}
+
+ZaiOAuthModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ providerInfo: PropTypes.object,
+ onSuccess: PropTypes.func,
+ onClose: PropTypes.func,
+};
\ No newline at end of file
diff --git a/src/shared/components/index.js b/src/shared/components/index.js
index c04453a9a1..138e5ae912 100644
--- a/src/shared/components/index.js
+++ b/src/shared/components/index.js
@@ -29,6 +29,7 @@ export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as IFlowCookieModal } from "./IFlowCookieModal";
+export { default as ZaiOAuthModal } from "./ZaiOAuthModal";
export { default as GitLabAuthModal } from "./GitLabAuthModal";
export { default as EditConnectionModal } from "./EditConnectionModal";
export { default as AddCustomEmbeddingModal } from "./AddCustomEmbeddingModal";
diff --git a/tests/unit/glm-executor.test.js b/tests/unit/glm-executor.test.js
new file mode 100644
index 0000000000..d852400934
--- /dev/null
+++ b/tests/unit/glm-executor.test.js
@@ -0,0 +1,170 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { GlmExecutor, glmRequestContext } from "../../open-sse/executors/glm.js";
+
+function withGlmRequest(credentials, fn, urlIndex = 0) {
+ return glmRequestContext.run({ credentials, urlIndex }, fn);
+}
+
+/** buildUrl sets urlIndex on the active ALS store (same as production execute loop). */
+function atUrlIndex(executor, urlIndex, credentials, fn) {
+ return withGlmRequest(credentials, () => {
+ executor.buildUrl("glm-5.2", true, urlIndex, credentials);
+ return fn();
+ });
+}
+
+describe("GlmExecutor", () => {
+ let executor;
+
+ beforeEach(() => {
+ executor = new GlmExecutor();
+ });
+
+ const codingPlanCreds = {
+ apiKey: "org.key.secret",
+ accessToken: "jwt-token",
+ providerSpecificData: {
+ useCodingPlan: true,
+ zcodeJwtToken: "jwt-token",
+ },
+ };
+
+ it("maps glm-5.2 model in transformRequest", () => {
+ const body = executor.transformRequest("glm-5.2", { model: "glm-5.2", messages: [] });
+ expect(body.model).toBe("GLM-5.2");
+ });
+
+ it("uses full ZCode fingerprint on coding plan primary URL", () => {
+ const headers = atUrlIndex(executor, 0, codingPlanCreds, () =>
+ executor.buildHeaders({
+ ...codingPlanCreds,
+ connectionId: "conn-1",
+ providerSpecificData: {
+ ...codingPlanCreds.providerSpecificData,
+ _captchaVerifyParam: "captcha-token",
+ },
+ })
+ );
+ expect(headers.Authorization).toBe("Bearer jwt-token");
+ expect(headers["x-api-key"]).toBeUndefined();
+ expect(headers["User-Agent"]).toBe("ZCode/3.1.0");
+ expect(headers["X-ZCode-App-Version"]).toBe("3.1.0");
+ expect(headers["X-ZCode-Agent"]).toBe("glm");
+ expect(headers["X-Title"]).toBe("Z Code@electron");
+ expect(headers["HTTP-Referer"]).toBe("https://zcode.z.ai/");
+ expect(headers["X-Aliyun-Captcha-Verify-Param"]).toBe("captcha-token");
+ expect(headers["X-Aliyun-Captcha-Verify-Region"]).toBe("sgp");
+ expect(headers["x-request-id"]).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+ );
+ expect(headers["x-zcode-trace-id"]).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+ );
+ expect(headers["x-query-id"]).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+ );
+ expect(headers["x-session-id"]).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+ );
+ expect(headers["Anthropic-Version"]).toBeUndefined();
+ expect(headers["Anthropic-Beta"]).toBeUndefined();
+ });
+
+ it("reuses x-session-id for the same connection", () => {
+ const creds = { ...codingPlanCreds, connectionId: "conn-stable" };
+ const first = atUrlIndex(executor, 0, creds, () => executor.buildHeaders(creds));
+ const second = atUrlIndex(executor, 0, creds, () => executor.buildHeaders(creds));
+ expect(second["x-session-id"]).toBe(first["x-session-id"]);
+ expect(second["x-request-id"]).not.toBe(first["x-request-id"]);
+ });
+
+ it("uses ZCode API key fingerprint on fallback URL after JWT expiry", () => {
+ const headers = atUrlIndex(executor, 1, codingPlanCreds, () =>
+ executor.buildHeaders({
+ ...codingPlanCreds,
+ providerSpecificData: {
+ ...codingPlanCreds.providerSpecificData,
+ _captchaVerifyParam: "captcha-token",
+ },
+ })
+ );
+ expect(headers["x-api-key"]).toBe("org.key.secret");
+ expect(headers.Authorization).toBeUndefined();
+ expect(headers["User-Agent"]).toBe("ZCode/3.1.0");
+ expect(headers["X-ZCode-App-Version"]).toBe("3.1.0");
+ expect(headers["X-ZCode-Agent"]).toBe("glm");
+ expect(headers["HTTP-Referer"]).toBe("https://zcode.z.ai/");
+ expect(headers["anthropic-version"]).toBe("2023-06-01");
+ expect(headers["X-Aliyun-Captcha-Verify-Param"]).toBe("captcha-token");
+ expect(headers["X-Aliyun-Captcha-Verify-Region"]).toBeUndefined();
+ expect(headers["Anthropic-Beta"]).toBeUndefined();
+ expect(headers["x-session-id"]).toBeUndefined();
+ });
+
+ it("uses api.z.ai fallback URL without beta for API key only connections", () => {
+ const apiKeyCreds = { apiKey: "org.key.secret" };
+ expect(executor.buildUrl("glm-5.2", true, 0, apiKeyCreds)).toBe(
+ "https://api.z.ai/api/anthropic/v1/messages"
+ );
+ });
+
+ it("applies ZCode API key headers for API key only connections", () => {
+ const headers = executor.buildHeaders({
+ apiKey: "org.key.secret",
+ providerSpecificData: { _captchaVerifyParam: "verify-abc" },
+ });
+ expect(headers["x-api-key"]).toBe("org.key.secret");
+ expect(headers["X-Aliyun-Captcha-Verify-Param"]).toBe("verify-abc");
+ expect(headers["User-Agent"]).toBe("ZCode/3.1.0");
+ expect(headers["Anthropic-Beta"]).toBeUndefined();
+ expect(headers["x-zcode-trace-id"]).toBeUndefined();
+ });
+
+ it("parses GLM 1113 quota errors into a readable message", () => {
+ const parsed = executor.parseError(
+ { status: 429 },
+ JSON.stringify({
+ type: "error",
+ error: {
+ type: "rate_limit_error",
+ code: "1113",
+ message: "[1113][Insufficient balance or no resource package. Please recharge.][req]",
+ },
+ })
+ );
+ expect(parsed.message).toContain("GLM quota exhausted");
+ expect(parsed.message).not.toContain('{"type":"error"');
+ });
+
+ it("retries with API key fallback on 401 when apiKey is present", () => {
+ withGlmRequest(codingPlanCreds, () => {
+ expect(executor.shouldRetry(401, 0)).toBe(true);
+ expect(executor.shouldRetry(401, 1)).toBe(false);
+ });
+ });
+
+ it("exposes two URL fallbacks only within request scope for coding plan + api key", () => {
+ withGlmRequest(codingPlanCreds, () => {
+ expect(executor.getFallbackCount()).toBe(2);
+ });
+ expect(executor.getFallbackCount()).toBe(1);
+ });
+
+ it("maps glm-5.2 and applies ZCode system on coding plan URL", () => {
+ const body = atUrlIndex(executor, 0, codingPlanCreds, () =>
+ executor.transformRequest(
+ "glm-5.2",
+ {
+ model: "glm-5.2",
+ system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }],
+ messages: [{ role: "user", content: "hi" }],
+ },
+ false,
+ codingPlanCreds
+ )
+ );
+ expect(body.model).toBe("GLM-5.2");
+ expect(JSON.stringify(body.system)).toContain("You are ZCode");
+ expect(JSON.stringify(body.system)).not.toContain("Claude Code");
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/glm-systemPrompt.test.js b/tests/unit/glm-systemPrompt.test.js
new file mode 100644
index 0000000000..81ac1d2b2a
--- /dev/null
+++ b/tests/unit/glm-systemPrompt.test.js
@@ -0,0 +1,109 @@
+import { describe, it, expect } from "vitest";
+import { GlmExecutor, glmRequestContext } from "../../open-sse/executors/glm.js";
+
+function atUrlIndex(executor, urlIndex, credentials, fn) {
+ return glmRequestContext.run({ credentials, urlIndex }, () => {
+ executor.buildUrl("glm-5.2", true, urlIndex, credentials);
+ return fn();
+ });
+}
+import {
+ injectZcodeSystemPrompt,
+ ZCODE_SYSTEM_IDENTITY_MARKER,
+ buildZcodeEnvironmentBlock,
+} from "../../src/lib/zcode/systemPrompt.js";
+
+describe("injectZcodeSystemPrompt", () => {
+ it("replaces Claude Code system prompt with ZCode blocks", () => {
+ const body = {
+ model: "GLM-5.2",
+ system: [
+ { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
+ ],
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
+ };
+
+ const out = injectZcodeSystemPrompt(body, { modelRef: "builtin:zai-start-plan/GLM-5.2" });
+
+ expect(JSON.stringify(out.system)).toContain(ZCODE_SYSTEM_IDENTITY_MARKER);
+ expect(JSON.stringify(out.system)).not.toContain("Claude Code");
+ expect(out.system).toHaveLength(3);
+ expect(out.system[0].cache_control).toEqual({ type: "ephemeral" });
+ });
+
+ it("is idempotent when ZCode blocks are already present", () => {
+ const body = injectZcodeSystemPrompt(
+ { model: "GLM-5.2", system: [] },
+ { modelRef: "builtin:zai-start-plan/GLM-5.2" }
+ );
+ const again = injectZcodeSystemPrompt(body);
+ expect(again.system).toEqual(body.system);
+ });
+
+ it("preserves non-Claude caller system blocks after ZCode blocks", () => {
+ const out = injectZcodeSystemPrompt({
+ model: "GLM-5.2",
+ system: [
+ { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
+ { type: "text", text: "Custom project rules" },
+ ],
+ });
+
+ expect(out.system).toHaveLength(4);
+ expect(out.system[3].text).toBe("Custom project rules");
+ });
+
+ it("includes model ref in environment block", () => {
+ const text = buildZcodeEnvironmentBlock({
+ modelRef: "builtin:zai-start-plan/GLM-5.2",
+ workingDirectory: "/tmp/proj",
+ platform: "darwin",
+ shell: "zsh",
+ osVersion: "darwin 25.5.0 arm64",
+ isGitRepository: false,
+ });
+ expect(text).toContain("builtin:zai-start-plan/GLM-5.2");
+ expect(text).toContain("Primary working directory: /tmp/proj");
+ });
+});
+
+describe("GlmExecutor ZCode system prompt", () => {
+ const codingPlanCreds = {
+ apiKey: "org.key.secret",
+ accessToken: "jwt-token",
+ providerSpecificData: {
+ useCodingPlan: true,
+ zcodeJwtToken: "jwt-token",
+ },
+ };
+
+ it("injects ZCode system on coding plan primary URL", () => {
+ const executor = new GlmExecutor();
+ const body = atUrlIndex(executor, 0, codingPlanCreds, () => executor.transformRequest(
+ "glm-5.2",
+ {
+ model: "glm-5.2",
+ system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }],
+ messages: [],
+ },
+ false,
+ codingPlanCreds
+ ));
+
+ expect(JSON.stringify(body.system)).toContain(ZCODE_SYSTEM_IDENTITY_MARKER);
+ expect(JSON.stringify(body.system)).not.toContain("Claude Code");
+ });
+
+ it("does not inject ZCode system on API key fallback URL", () => {
+ const executor = new GlmExecutor();
+ const input = {
+ model: "glm-5.2",
+ system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }],
+ messages: [],
+ };
+ const body = atUrlIndex(executor, 1, codingPlanCreds, () =>
+ executor.transformRequest("glm-5.2", input, false, codingPlanCreds)
+ );
+ expect(body.system).toEqual(input.system);
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/glm-tokenRefresh.test.js b/tests/unit/glm-tokenRefresh.test.js
new file mode 100644
index 0000000000..79cc2f3052
--- /dev/null
+++ b/tests/unit/glm-tokenRefresh.test.js
@@ -0,0 +1,55 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { getModelUpstreamId } from "../../open-sse/config/providerModels.js";
+
+describe("glm model mapping", () => {
+ it("maps glm-5.2 to GLM-5.2 upstream id", () => {
+ expect(getModelUpstreamId("glm", "glm-5.2")).toBe("GLM-5.2");
+ });
+
+ it("maps glm-5-turbo to GLM-5-Turbo upstream id", () => {
+ expect(getModelUpstreamId("glm", "glm-5-turbo")).toBe("GLM-5-Turbo");
+ });
+});
+
+describe("glm/token-refresh wrapper", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ vi.doUnmock("../../src/lib/zcode/oauth.js");
+ vi.resetModules();
+ });
+
+ it("refreshTokenByProvider returns null when refreshToken missing", async () => {
+ const mod = await import("../../open-sse/services/tokenRefresh.js");
+ const out = await mod.refreshTokenByProvider("glm", { refreshToken: "" }, null);
+ expect(out).toBeNull();
+ });
+
+ it("refreshTokenByProvider returns refreshed glm credentials", async () => {
+ vi.doMock("../../src/lib/zcode/oauth.js", () => ({
+ refreshGlmOAuthCredentials: vi.fn(async () => ({
+ accessToken: "new-jwt",
+ apiKey: "key.secret",
+ refreshToken: "rotated-refresh",
+ expiresIn: 3600,
+ providerSpecificData: { zcodeJwtToken: "new-jwt", zaiAccessToken: "new-zai" },
+ })),
+ }));
+
+ const mod = await import("../../open-sse/services/tokenRefresh.js");
+ const out = await mod.refreshTokenByProvider(
+ "glm",
+ { refreshToken: "old-refresh", providerSpecificData: { zcodeJwtToken: "jwt" } },
+ null
+ );
+
+ expect(out).toMatchObject({
+ accessToken: "new-jwt",
+ apiKey: "key.secret",
+ refreshToken: "rotated-refresh",
+ expiresIn: 3600,
+ });
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/glm-usage.test.js b/tests/unit/glm-usage.test.js
new file mode 100644
index 0000000000..135ba64ce1
--- /dev/null
+++ b/tests/unit/glm-usage.test.js
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
+ proxyAwareFetch: vi.fn(),
+}));
+
+import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js";
+import { getUsageForProvider } from "../../open-sse/services/usage.js";
+
+function jsonResponse(body, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("GLM Coding Plan usage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("parses per-model token balances from billing/balance", async () => {
+ proxyAwareFetch
+ .mockResolvedValueOnce(
+ jsonResponse({
+ data: {
+ plans: [{ name: "ZCode Start Plan", description: "Trial", starts_at: 1700000000, ends_at: 1800000000 }],
+ },
+ })
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ data: {
+ balances: [
+ {
+ show_name: "GLM-5.2",
+ total_units: 3000000,
+ used_units: 223000,
+ remaining_units: 2777000,
+ expires_at: 1781625599,
+ },
+ {
+ show_name: "GLM-5-Turbo",
+ total_units: 2000000,
+ used_units: 0,
+ remaining_units: 2000000,
+ expires_at: 1781625599,
+ },
+ ],
+ },
+ })
+ );
+
+ const usage = await getUsageForProvider({
+ provider: "glm",
+ authType: "oauth",
+ accessToken: "jwt-token",
+ providerSpecificData: {
+ authMethod: "zcode_oauth",
+ useCodingPlan: true,
+ zcodeJwtToken: "jwt-token",
+ },
+ });
+
+ expect(usage.plan).toBe("ZCode Start Plan");
+ expect(usage.quotas["GLM-5.2"]).toMatchObject({
+ used: 223000,
+ total: 3000000,
+ remainingPercentage: 93,
+ unit: "token",
+ });
+ expect(usage.quotas["GLM-5-Turbo"]).toMatchObject({
+ used: 0,
+ total: 2000000,
+ remainingPercentage: 100,
+ unit: "token",
+ });
+ expect(usage.quotas["GLM-5.2"].resetAt).toBe("2026-06-16T15:59:59.000Z");
+ });
+
+ it("returns auth message when billing/current is unauthorized", async () => {
+ proxyAwareFetch
+ .mockResolvedValueOnce(jsonResponse({ message: "unauthorized" }, 401))
+ .mockResolvedValueOnce(jsonResponse({ data: { balances: [] } }));
+
+ const usage = await getUsageForProvider({
+ provider: "glm",
+ accessToken: "bad-jwt",
+ providerSpecificData: { useCodingPlan: true, zcodeJwtToken: "bad-jwt" },
+ });
+
+ expect(usage.message).toMatch(/invalid or expired/i);
+ expect(usage.quotas).toBeUndefined();
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/zcode-headers.test.js b/tests/unit/zcode-headers.test.js
new file mode 100644
index 0000000000..7783844e33
--- /dev/null
+++ b/tests/unit/zcode-headers.test.js
@@ -0,0 +1,97 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import {
+ applyZcodeApiKeyHeaders,
+ applyZcodeCodingPlanHeaders,
+ buildZcodeApiKeyHeaders,
+ buildZcodeCodingPlanHeaders,
+ clearZcodeCodingPlanHeaders,
+ stripAnthropicHeadersForZcodePlan,
+ __test__,
+} from "../../src/lib/zcode/headers.js";
+
+describe("zcode headers", () => {
+ beforeEach(() => {
+ __test__.sessionIdByConnection.clear();
+ });
+
+ const credentials = {
+ connectionId: "conn-a",
+ accessToken: "jwt-token",
+ providerSpecificData: {
+ useCodingPlan: true,
+ zcodeJwtToken: "jwt-token",
+ _captchaVerifyParam: "verify-123",
+ },
+ };
+
+ it("strips Anthropic headers for zcode-plan upstream", () => {
+ const headers = {
+ "Anthropic-Version": "2023-06-01",
+ "Anthropic-Beta": "claude-code-20250219",
+ "Content-Type": "application/json",
+ };
+ stripAnthropicHeadersForZcodePlan(headers);
+ expect(headers["Anthropic-Version"]).toBeUndefined();
+ expect(headers["Anthropic-Beta"]).toBeUndefined();
+ expect(headers["Content-Type"]).toBe("application/json");
+ });
+
+ it("builds ZCode 3.1.0 headers with captcha region", () => {
+ const headers = buildZcodeCodingPlanHeaders(credentials);
+ expect(headers["User-Agent"]).toBe("ZCode/3.1.0");
+ expect(headers["X-ZCode-App-Version"]).toBe("3.1.0");
+ expect(headers["X-Aliyun-Captcha-Verify-Region"]).toBe("sgp");
+ expect(headers["x-session-id"]).toBeTruthy();
+ });
+
+ it("applyZcodeCodingPlanHeaders merges into existing headers", () => {
+ const headers = {
+ "Anthropic-Version": "2023-06-01",
+ "x-api-key": "should-remove",
+ };
+ applyZcodeCodingPlanHeaders(headers, credentials);
+ expect(headers.Authorization).toBe("Bearer jwt-token");
+ expect(headers["x-api-key"]).toBeUndefined();
+ expect(headers["Anthropic-Version"]).toBeUndefined();
+ expect(headers["X-Title"]).toBe("Z Code@electron");
+ });
+
+ it("builds ZCode API key headers with captcha but no region (zcode_proxy parity)", () => {
+ const headers = buildZcodeApiKeyHeaders(
+ { apiKey: "org.key.secret", providerSpecificData: { _captchaVerifyParam: "verify-123" } }
+ );
+ expect(headers["x-api-key"]).toBe("org.key.secret");
+ expect(headers["X-Aliyun-Captcha-Verify-Param"]).toBe("verify-123");
+ expect(headers["X-Aliyun-Captcha-Verify-Region"]).toBeUndefined();
+ expect(headers["anthropic-version"]).toBe("2023-06-01");
+ expect(headers["User-Agent"]).toBe("ZCode/3.1.0");
+ });
+
+ it("applyZcodeApiKeyHeaders strips Claude beta headers", () => {
+ const headers = {
+ "Anthropic-Version": "2023-06-01",
+ "Anthropic-Beta": "claude-code-20250219",
+ Authorization: "Bearer stale",
+ };
+ applyZcodeApiKeyHeaders(headers, {
+ apiKey: "org.key.secret",
+ providerSpecificData: { _captchaVerifyParam: "verify-123" },
+ });
+ expect(headers.Authorization).toBeUndefined();
+ expect(headers["Anthropic-Beta"]).toBeUndefined();
+ expect(headers["anthropic-version"]).toBe("2023-06-01");
+ expect(headers["x-api-key"]).toBe("org.key.secret");
+ });
+
+ it("clearZcodeCodingPlanHeaders removes ZCode fields only", () => {
+ const headers = {
+ Authorization: "Bearer jwt-token",
+ "X-ZCode-Agent": "glm",
+ "Anthropic-Version": "2023-06-01",
+ };
+ clearZcodeCodingPlanHeaders(headers);
+ expect(headers.Authorization).toBeUndefined();
+ expect(headers["X-ZCode-Agent"]).toBeUndefined();
+ expect(headers["Anthropic-Version"]).toBe("2023-06-01");
+ });
+});
\ No newline at end of file