diff --git a/.env.example b/.env.example index 3fbaa27..55ba0f2 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,13 @@ API_KEYS= PERSIST_ADMIN_ACCOUNTS=1 # Milliseconds to skip an account after provider error before retrying it ACCOUNT_COOLDOWN_MS=60000 +# ── Z.ai auto-relogin via CDP ── +# CDP HTTP endpoint of a running Chrome with a logged-in chat.z.ai session. +# When set, the bridge will auto-refresh expired JWTs by reading localStorage from the browser. +ZAI_CDP_URL=http://127.0.0.1:9222 +# Proactive refresh: refresh token when TTL drops below this (ms, default 300000 = 5min) +ZAI_TOKEN_REFRESH_THRESHOLD_MS=300000 +# Cooldown between refresh attempts (ms, default 30000 = 30s) +ZAI_TOKEN_REFRESH_COOLDOWN_MS=30000 +# Proactive check interval (ms, default 60000 = 60s) +ZAI_TOKEN_REFRESH_INTERVAL_MS=60000 diff --git a/src/accounts.js b/src/accounts.js index 8060ee2..f16f320 100644 --- a/src/accounts.js +++ b/src/accounts.js @@ -120,6 +120,22 @@ export class AccountManager { return a; } + /** + * Update an account's token (and optionally cookie) in-place and persist. + * Called by the auto-relogin flow after a CDP token refresh. + */ + updateToken(id, { token, cookie } = {}, { persist = truthy(this.env.PERSIST_ADMIN_ACCOUNTS ?? '1') } = {}) { + const a = this.get(id); + if (!a) return null; + if (token) a.token = token; + if (cookie) a.cookie = cookie; + a.ok = true; + a.lastError = ''; + a.cooldownUntil = 0; + if (persist) this.persist(); + return this._safe(a); + } + markSuccess(id) { const a = this.get(id); if (!a) return; diff --git a/src/config.js b/src/config.js index 52f1f04..6a36964 100644 --- a/src/config.js +++ b/src/config.js @@ -11,6 +11,13 @@ export const AUTH_PATH = process.env.AUTH_PATH || path.join(process.cwd(), 'auth export const API_KEYS = String(process.env.API_KEYS || '').split(',').map(s => s.trim()).filter(Boolean); export const GLM_BACKEND = (process.env.GLM_BACKEND || 'zai').toLowerCase(); +// ── Z.ai auto-relogin via CDP ────────────────────────────────── +export const ZAI_CDP_URL = process.env.ZAI_CDP_URL || ''; // e.g. 'http://127.0.0.1:9222' +export const ZAI_TOKEN_REFRESH_ENABLED = ['1','true','yes','on'].includes(String(process.env.ZAI_TOKEN_REFRESH_ENABLED || '').toLowerCase()) || !!ZAI_CDP_URL; +export const ZAI_TOKEN_REFRESH_THRESHOLD_MS = Number(process.env.ZAI_TOKEN_REFRESH_THRESHOLD_MS || 300_000); // 5 min +export const ZAI_TOKEN_REFRESH_COOLDOWN_MS = Number(process.env.ZAI_TOKEN_REFRESH_COOLDOWN_MS || 30_000); +export const ZAI_TOKEN_REFRESH_INTERVAL_MS = Number(process.env.ZAI_TOKEN_REFRESH_INTERVAL_MS || 60_000); + export const MODELS = { 'glm-5': { provider: 'glm', thinking: false, webSearch: false, deepResearch: false }, 'glm-5-thinking': { provider: 'glm', thinking: true, webSearch: false, deepResearch: false }, diff --git a/src/providers/zai.js b/src/providers/zai.js index 575a895..18f7403 100644 --- a/src/providers/zai.js +++ b/src/providers/zai.js @@ -1,6 +1,7 @@ import crypto from 'crypto'; import { preparePrompt } from '../message.js'; import { getZaiBrowserClient, isZaiCaptchaError, shouldUseZaiBrowserFallback } from './zaiBrowser.js'; +import { isZaiAuthError, getTokenTtlMs } from './zaiTokenRefresh.js'; export const ZAI_BASE = 'https://chat.z.ai'; export const ZAI_FE_VERSION = process.env.ZAI_FE_VERSION || 'prod-fe-1.1.46'; @@ -135,7 +136,17 @@ export function buildZaiRequest({ token, model='glm-5', prompt='', chatId='', pa } export class ZaiProvider { - constructor(account){ this.account=account; this.token=account.token || account.accessToken || account.access_token || account.jwt || account.refresh_token || account.refreshToken; this.cookie = account.cookie || account.cookies || process.env.ZAI_COOKIE || null; this.captchaVerifyParam = account.captcha_verify_param || account.captchaVerifyParam || process.env.ZAI_CAPTCHA_VERIFY_PARAM || null; this.browserFallback = shouldUseZaiBrowserFallback(process.env, account); if(!this.token) throw new Error('Z.ai token missing'); } + constructor(account, { tokenRefresher = null, accountManager = null, logger = console } = {}) { + this.account = account; + this.token = account.token || account.accessToken || account.access_token || account.jwt || account.refresh_token || account.refreshToken; + this.cookie = account.cookie || account.cookies || process.env.ZAI_COOKIE || null; + this.captchaVerifyParam = account.captcha_verify_param || account.captchaVerifyParam || process.env.ZAI_CAPTCHA_VERIFY_PARAM || null; + this.browserFallback = shouldUseZaiBrowserFallback(process.env, account); + this.tokenRefresher = tokenRefresher; + this.accountManager = accountManager; + this.logger = logger; + if(!this.token) throw new Error('Z.ai token missing'); + } async createChat(model, prompt) { const timestamp = Math.floor(Date.now()/1000); const messageId = randomUuid(); @@ -149,35 +160,92 @@ export class ZaiProvider { const prompt = preparePrompt(messages, tools, { simpleTools:false, isMultiTurn:!!session.providerSessionId }); let chatId = session.providerSessionId || ''; let parentId = session.parentMessageId || null; - if (!chatId) { - const created = await this.createChat(modelCfg.id, prompt); - chatId = created.chatId; - parentId = created.messageId; - } - const req = buildZaiRequest({ token:this.token, model:modelCfg.id, prompt, chatId, parentMessageId:parentId, thinking:modelCfg.thinking, webSearch:modelCfg.webSearch, captchaVerifyParam:this.captchaVerifyParam, cookie:this.cookie }); - const resp = await fetch(req.url, { method:'POST', headers:req.headers, body:JSON.stringify(req.body) }); - const raw = await resp.text(); - if (!resp.ok) { - if (this.browserFallback) { + + // ── Attempt with auto-relogin on auth error ── + for (let attempt = 0; attempt < 2; attempt++) { + // Create chat only once (or retry if it failed with auth error on first attempt) + if (!chatId) { + try { + const created = await this.createChat(modelCfg.id, prompt); + chatId = created.chatId; + parentId = created.messageId; + } catch (err) { + if (isZaiAuthError(0, err.message) && this.tokenRefresher && attempt === 0) { + this.logger.warn?.(`[zai] createChat auth error — triggering CDP relogin`); + const refreshed = await this.tokenRefresher.refresh(); + if (refreshed?.ok && refreshed?.token) { + this.token = refreshed.token; + if (refreshed.cookie) this.cookie = refreshed.cookie; + if (this.accountManager && this.account?.id) { + this.accountManager.updateToken(this.account.id, { token: refreshed.token, cookie: refreshed.cookie }); + } + this.logger.info?.('[zai] token refreshed via CDP — retrying createChat'); + continue; + } + } + throw err; + } + } + const req = buildZaiRequest({ token:this.token, model:modelCfg.id, prompt, chatId, parentMessageId:parentId, thinking:modelCfg.thinking, webSearch:modelCfg.webSearch, captchaVerifyParam:this.captchaVerifyParam, cookie:this.cookie }); + const resp = await fetch(req.url, { method:'POST', headers:req.headers, body:JSON.stringify(req.body) }); + const raw = await resp.text(); + + // Auth error detection → auto-relogin via CDP + if (!resp.ok && isZaiAuthError(resp.status, raw) && this.tokenRefresher && attempt === 0) { + this.logger.warn?.(`[zai] auth error (HTTP ${resp.status}) — triggering CDP relogin`); + const refreshed = await this.tokenRefresher.refresh(); + if (refreshed?.ok && refreshed?.token) { + this.token = refreshed.token; + if (refreshed.cookie) this.cookie = refreshed.cookie; + // Update the account in AccountManager + persist + if (this.accountManager && this.account?.id) { + this.accountManager.updateToken(this.account.id, { token: refreshed.token, cookie: refreshed.cookie }); + } + this.logger.info?.('[zai] token refreshed via CDP — retrying request'); + continue; // retry with new token + } + this.logger.error?.('[zai] CDP relogin failed — falling through to normal error handling'); + } + + // Browser fallback for non-auth errors (captcha, etc.) + if (!resp.ok) { + if (this.browserFallback && !isZaiAuthError(resp.status, raw)) { + const browser = getZaiBrowserClient(); + const browserResult = await browser.completeAndParse(req, { token:this.token, chatId }); + if (!browserResult.ok) throw new Error(`Z.ai browser HTTP ${browserResult.status}: ${browserResult.raw.slice(0,200)}`); + const browserParsed = browserResult.parsed; + if (browserParsed.error) throw new Error(`Z.ai browser error: ${browserParsed.error}`); + return { text: browserParsed.text || browserResult.raw, reasoning: browserParsed.reasoning, providerSessionId: browserParsed.providerSessionId || chatId, parentMessageId: browserParsed.parentMessageId || req.messageId, prompt }; + } + throw new Error(`Z.ai HTTP ${resp.status}: ${raw.slice(0,200)}`); + } + let parsed = parseZaiSse(raw); + if (parsed.error && this.browserFallback && isZaiCaptchaError(parsed.error)) { const browser = getZaiBrowserClient(); const browserResult = await browser.completeAndParse(req, { token:this.token, chatId }); if (!browserResult.ok) throw new Error(`Z.ai browser HTTP ${browserResult.status}: ${browserResult.raw.slice(0,200)}`); - const browserParsed = browserResult.parsed; - if (browserParsed.error) throw new Error(`Z.ai browser error: ${browserParsed.error}`); - return { text: browserParsed.text || browserResult.raw, reasoning: browserParsed.reasoning, providerSessionId: browserParsed.providerSessionId || chatId, parentMessageId: browserParsed.parentMessageId || req.messageId, prompt }; + parsed = browserResult.parsed; + if (parsed.error) throw new Error(`Z.ai browser error: ${parsed.error}`); + } else if (parsed.error) { + // Auth error in SSE body → try relogin + if (isZaiAuthError(0, parsed.error) && this.tokenRefresher && attempt === 0) { + this.logger.warn?.(`[zai] SSE auth error — triggering CDP relogin`); + const refreshed = await this.tokenRefresher.refresh(); + if (refreshed?.ok && refreshed?.token) { + this.token = refreshed.token; + if (refreshed.cookie) this.cookie = refreshed.cookie; + if (this.accountManager && this.account?.id) { + this.accountManager.updateToken(this.account.id, { token: refreshed.token, cookie: refreshed.cookie }); + } + this.logger.info?.('[zai] token refreshed via CDP — retrying request'); + continue; + } + } + throw new Error(`Z.ai error: ${parsed.error}`); } - throw new Error(`Z.ai HTTP ${resp.status}: ${raw.slice(0,200)}`); - } - let parsed = parseZaiSse(raw); - if (parsed.error && this.browserFallback && isZaiCaptchaError(parsed.error)) { - const browser = getZaiBrowserClient(); - const browserResult = await browser.completeAndParse(req, { token:this.token, chatId }); - if (!browserResult.ok) throw new Error(`Z.ai browser HTTP ${browserResult.status}: ${browserResult.raw.slice(0,200)}`); - parsed = browserResult.parsed; - if (parsed.error) throw new Error(`Z.ai browser error: ${parsed.error}`); - } else if (parsed.error) { - throw new Error(`Z.ai error: ${parsed.error}`); + return { text: parsed.text || raw, reasoning: parsed.reasoning, providerSessionId: parsed.providerSessionId || chatId, parentMessageId: parsed.parentMessageId || req.messageId, prompt }; } - return { text: parsed.text || raw, reasoning: parsed.reasoning, providerSessionId: parsed.providerSessionId || chatId, parentMessageId: parsed.parentMessageId || req.messageId, prompt }; + // Should not reach here — both attempts exhausted + throw new Error('Z.ai: both attempts exhausted after relogin'); } } diff --git a/src/providers/zaiTokenRefresh.js b/src/providers/zaiTokenRefresh.js new file mode 100644 index 0000000..587cc1c --- /dev/null +++ b/src/providers/zaiTokenRefresh.js @@ -0,0 +1,334 @@ +import http from 'http'; + +/** + * zaiTokenRefresh.js — JWT TTL checking, auth-error detection, and CDP-based + * auto-relogin for the Z.ai (chat.z.ai) bridge. + * + * Flow: + * 1. Proactive: periodically check token TTL; if below threshold, refresh. + * 2. Reactive: on 401 / auth-error response, refresh and retry. + * 3. Refresh: connect to a running Chrome CDP endpoint, find or navigate + * to chat.z.ai, read localStorage `token` + cookies, return them. + */ + +// ── JWT utilities ────────────────────────────────────────────── + +export function decodeJwtPayload(token) { + try { + const parts = String(token || '').split('.'); + if (parts.length < 2) return null; + const payload = Buffer.from(parts[1], 'base64url').toString('utf8'); + return JSON.parse(payload); + } catch { + return null; + } +} + +export function getTokenExpiry(token) { + const payload = decodeJwtPayload(token); + if (!payload) return null; + // Z.ai JWT uses `exp` (seconds since epoch) — standard JWT claim. + if (typeof payload.exp === 'number') return payload.exp * 1000; + // Some tokens use `exp_ms` or `expires_at`. + if (typeof payload.exp_ms === 'number') return payload.exp_ms; + if (typeof payload.expires_at === 'number') return payload.expires_at * 1000; + return null; +} + +export function getTokenTtlMs(token, now = Date.now) { + const expiry = getTokenExpiry(token); + if (expiry == null) return Infinity; // no exp claim → assume non-expiring + return expiry - now(); +} + +export function isTokenExpired(token, { thresholdMs = 0, now = Date.now } = {}) { + const ttl = getTokenTtlMs(token, now); + return ttl <= thresholdMs; +} + +// ── Auth error detection ─────────────────────────────────────── + +const AUTH_ERROR_PATTERNS = [ + /"code"\s*:\s*["']?UNAUTHORIZED["']?/i, + /"code"\s*:\s*["']?TOKEN_EXPIRED["']?/i, + /"code"\s*:\s*["']?INVALID_TOKEN["']?/i, + /"code"\s*:\s*["']?AUTH_?ERROR["']?/i, + /"detail"\s*:\s*["'].*token.*expired.*["']/i, + /"detail"\s*:\s*["'].*unauthorized.*["']/i, + /"message"\s*:\s*["'].*token.*expired.*["']/i, + /"message"\s*:\s*["'].*not.*authenticated.*["']/i, + /authentication required/i, + /token expired/i, + /invalid token/i, + /请先登录/i, // "please log in first" (Z.ai CN) + /登录已过期/i, // "login has expired" + /未授权/i, // "unauthorized" +]; + +export function isZaiAuthError(status, raw) { + if (status === 401 || status === 403) { + // 403 can be captcha/WAF, not auth — check the body for auth-specific signals. + if (status === 403) { + const text = String(raw || ''); + // If it looks like captcha/WAF, it's not an auth error. + if (/captcha|waf|aliyun|punish|forbidden/i.test(text)) return false; + // Otherwise treat 403 as auth error if body has auth signals. + return AUTH_ERROR_PATTERNS.some(re => re.test(text)); + } + return true; + } + const text = String(raw || ''); + return AUTH_ERROR_PATTERNS.some(re => re.test(text)); +} + +// ── CDP token extraction ─────────────────────────────────────── + +/** + * Discover the browser WebSocket URL from a CDP HTTP endpoint. + * @param {string} cdpUrl — e.g. 'http://127.0.0.1:9222' + * @returns {Promise<{browserWSEndpoint: string, targets: Array}>} + */ +export async function discoverCdpBrowser(cdpUrl) { + const [versionRes, listRes] = await Promise.all([ + cdpHttp(cdpUrl, '/json/version'), + cdpHttp(cdpUrl, '/json/list'), + ]); + if (!versionRes || !versionRes.webSocketDebuggerUrl) { + throw new Error(`CDP: no browser WS endpoint at ${cdpUrl}`); + } + return { + browserWSEndpoint: versionRes.webSocketDebuggerUrl, + targets: Array.isArray(listRes) ? listRes : [], + }; +} + +function cdpHttp(baseUrl, path) { + return new Promise((resolve, reject) => { + const url = new URL(path, baseUrl); + const req = http.get(url, { timeout: 5000 }, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { resolve(JSON.parse(data)); } catch { resolve(null); } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error(`CDP HTTP timeout: ${url.href}`)); }); + }); +} + +/** + * Connect to a running Chrome instance via CDP (using puppeteer-core), + * find or navigate to a chat.z.ai tab, and extract the JWT token + cookies + * from localStorage / cookie jar. + * + * @param {object} opts + * @param {string} opts.cdpUrl — CDP HTTP endpoint (e.g. 'http://127.0.0.1:9222') + * @param {object} [opts.logger] — console-like logger + * @param {number} [opts.navTimeoutMs] — navigation timeout (default 30s) + * @param {number} [opts.settleMs] — wait after load for localStorage to settle (default 3s) + * @returns {Promise<{token: string, cookie: string, ok: boolean}>} + */ +export async function extractTokenFromCdpBrowser({ cdpUrl, logger = console, navTimeoutMs = 30_000, settleMs = 3000 }) { + const puppeteer = await import('puppeteer-core').then(m => m.default || m); + const { browserWSEndpoint, targets } = await discoverCdpBrowser(cdpUrl); + + // Try to find an existing chat.z.ai tab first. + const existingZaiTarget = targets.find(t => + t.type === 'page' && String(t.url || '').includes('chat.z.ai') + ); + + let browser; + try { + browser = await puppeteer.connect({ browserWSEndpoint, defaultViewport: null }); + } catch (err) { + throw new Error(`CDP connect failed: ${err.message}`); + } + + try { + let page; + if (existingZaiTarget) { + // Reuse existing tab — puppeteer connects to it by targetId. + page = await browser.targets() + .find(t => t._targetId === existingZaiTarget.targetId) + ?.page?.(); + if (!page) { + // Fallback: search all pages. + const pages = await browser.pages(); + page = pages.find(p => p.url().includes('chat.z.ai')); + } + } + + if (!page) { + // Open a new tab to chat.z.ai. + page = await browser.newPage(); + logger.info?.('[zai-refresh] opening new tab to chat.z.ai'); + } + + const currentUrl = page.url(); + if (!currentUrl.includes('chat.z.ai')) { + logger.info?.('[zai-refresh] navigating to chat.z.ai'); + await page.goto('https://chat.z.ai', { waitUntil: 'domcontentloaded', timeout: navTimeoutMs }); + } else { + // Refresh the page to force token regeneration if session is still valid. + logger.info?.('[zai-refresh] reloading existing chat.z.ai tab'); + await page.reload({ waitUntil: 'domcontentloaded', timeout: navTimeoutMs }); + } + + // Wait for localStorage to settle (Z.ai writes token after initial JS load). + await new Promise(r => setTimeout(r, settleMs)); + + const token = await page.evaluate(() => { + try { return localStorage.getItem('token') || ''; } catch { return ''; } + }); + + if (!token || !token.startsWith('eyJ')) { + // Maybe the app needs more time — try waiting a bit longer. + await new Promise(r => setTimeout(r, settleMs)); + const retryToken = await page.evaluate(() => { + try { return localStorage.getItem('token') || ''; } catch { return ''; } + }); + if (!retryToken || !retryToken.startsWith('eyJ')) { + return { token: '', cookie: '', ok: false, reason: 'no_token_in_localStorage' }; + } + var finalToken = retryToken; + } else { + var finalToken = token; + } + + // Extract cookies for anti-bot compatibility. + const cookies = await page.cookies('https://chat.z.ai').catch(() => []); + const cookieHeader = cookies.map(c => `${c.name}=${c.value}`).join('; '); + + return { token: finalToken, cookie: cookieHeader, ok: true }; + } finally { + // disconnect (not close!) — we don't want to kill the user's browser. + browser.disconnect(); + } +} + +// ── ZaiTokenRefresher ────────────────────────────────────────── + +export class ZaiTokenRefresher { + /** + * @param {object} opts + * @param {string} opts.cdpUrl — CDP HTTP endpoint + * @param {number} [opts.thresholdMs] — proactive refresh threshold (default 5min) + * @param {number} [opts.cooldownMs] — cooldown between refresh attempts (default 30s) + * @param {number} [opts.maxRetries] — retries per refresh (default 2) + * @param {object} [opts.logger] + * @param {function} [opts.now] — injectable clock + * @param {function} [opts.extractFn] — injectable extraction function (for testing) + */ + constructor({ + cdpUrl, + thresholdMs = 300_000, + cooldownMs = 30_000, + maxRetries = 2, + logger = console, + now = Date.now, + extractFn = extractTokenFromCdpBrowser, + } = {}) { + if (!cdpUrl) throw new Error('ZaiTokenRefresher requires cdpUrl'); + this.cdpUrl = cdpUrl; + this.thresholdMs = thresholdMs; + this.cooldownMs = cooldownMs; + this.maxRetries = maxRetries; + this.logger = logger; + this.now = now; + this.extractFn = extractFn; + this._lastRefreshAt = 0; + this._refreshing = null; // in-flight promise (dedup concurrent calls) + } + + /** + * Check if a token needs refreshing (TTL below threshold or expired). + */ + needsRefresh(token) { + return isTokenExpired(token, { thresholdMs: this.thresholdMs, now: this.now }); + } + + /** + * Refresh the token via CDP. Deduplicates concurrent calls. + * @returns {Promise<{token: string, cookie: string, ok: boolean} | null>} + */ + async refresh() { + // Dedup: if a refresh is already in flight, await it. + if (this._refreshing) return this._refreshing; + + // Cooldown check. + const elapsed = this.now() - this._lastRefreshAt; + if (elapsed < this.cooldownMs) { + this.logger.warn?.(`[zai-refresh] cooldown — ${Math.round((this.cooldownMs - elapsed) / 1000)}s remaining`); + return null; + } + + this._refreshing = this._doRefresh(); + try { + return await this._refreshing; + } finally { + this._refreshing = null; + } + } + + async _doRefresh() { + let lastError; + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + try { + this.logger.info?.(`[zai-refresh] CDP extract attempt ${attempt + 1}/${this.maxRetries + 1}`); + const result = await this.extractFn({ + cdpUrl: this.cdpUrl, + logger: this.logger, + }); + if (result?.ok && result?.token) { + this._lastRefreshAt = this.now(); + this.logger.info?.(`[zai-refresh] success — token len=${result.token.length} cookie len=${result.cookie?.length || 0}`); + return result; + } + lastError = new Error(result?.reason || 'extract returned no token'); + } catch (err) { + lastError = err; + } + if (attempt < this.maxRetries) { + const backoff = 2000 * (attempt + 1); + this.logger.warn?.(`[zai-refresh] attempt ${attempt + 1} failed: ${lastError.message}; retrying in ${backoff}ms`); + await new Promise(r => setTimeout(r, backoff)); + } + } + this._lastRefreshAt = this.now(); // set cooldown even on failure + this.logger.error?.(`[zai-refresh] all attempts failed: ${lastError.message}`); + return null; + } + + /** + * Start a periodic proactive refresh check. + * @param {function} getTokens — () => Array<{id, token}> — accounts to check + * @param {function} onRefresh — async (id, {token, cookie}) => void — called when token refreshed + * @param {number} [intervalMs] — check interval (default 60s) + * @returns {NodeJS.Timeout} interval handle + */ + startProactiveRefresh(getTokens, onRefresh, intervalMs = 60_000) { + const tick = async () => { + try { + const accounts = getTokens(); + for (const acct of accounts) { + if (!acct.token) continue; + if (this.needsRefresh(acct.token)) { + const ttl = getTokenTtlMs(acct.token, this.now); + this.logger.info?.(`[zai-refresh] proactive refresh for ${acct.id} (TTL=${Math.round(ttl / 1000)}s)`); + const result = await this.refresh(); + if (result?.ok) { + await onRefresh(acct.id, result); + } + break; // one refresh per tick is enough — all accounts share the same browser session + } + } + } catch (err) { + this.logger.error?.(`[zai-refresh] proactive tick error: ${err.message}`); + } + }; + // Fire immediately, then on interval. + tick(); + return setInterval(tick, intervalMs); + } +} diff --git a/src/server.js b/src/server.js index 9c0c104..37286d6 100644 --- a/src/server.js +++ b/src/server.js @@ -1,11 +1,12 @@ import http from 'http'; import { pathToFileURL } from 'node:url'; -import { PORT, HOST, MODELS, WATERMARK, MOCK_PROVIDER, AUTH_PATH, GLM_BACKEND, resolveModel, requireProxyAuth } from './config.js'; +import { PORT, HOST, MODELS, WATERMARK, MOCK_PROVIDER, AUTH_PATH, GLM_BACKEND, resolveModel, requireProxyAuth, ZAI_CDP_URL, ZAI_TOKEN_REFRESH_ENABLED, ZAI_TOKEN_REFRESH_THRESHOLD_MS, ZAI_TOKEN_REFRESH_COOLDOWN_MS, ZAI_TOKEN_REFRESH_INTERVAL_MS } from './config.js'; import { AccountManager } from './accounts.js'; import { SessionStore } from './sessions.js'; import { KimiProvider } from './providers/kimi.js'; import { GLMProvider } from './providers/glm.js'; import { ZaiProvider } from './providers/zai.js'; +import { ZaiTokenRefresher } from './providers/zaiTokenRefresh.js'; import { mockComplete } from './mockProvider.js'; import { parseToolCallsFromText, buildToolCallCompletion, usage } from './tooling.js'; import { anthropicToOpenAI, openAIToAnthropic } from './anthropic.js'; @@ -13,10 +14,38 @@ import { anthropicToOpenAI, openAIToAnthropic } from './anthropic.js'; const store=new SessionStore(); const accountManager=new AccountManager({ authPath: AUTH_PATH, env: process.env, cooldownMs: Number(process.env.ACCOUNT_COOLDOWN_MS || 60_000) }); +// ── Z.ai auto-relogin via CDP ── +let zaiTokenRefresher = null; +if (ZAI_TOKEN_REFRESH_ENABLED && ZAI_CDP_URL) { + zaiTokenRefresher = new ZaiTokenRefresher({ + cdpUrl: ZAI_CDP_URL, + thresholdMs: ZAI_TOKEN_REFRESH_THRESHOLD_MS, + cooldownMs: ZAI_TOKEN_REFRESH_COOLDOWN_MS, + logger: console, + }); + console.log(`[FreeGLMKimiAPI] Z.ai auto-relogin enabled — CDP: ${ZAI_CDP_URL}, threshold: ${Math.round(ZAI_TOKEN_REFRESH_THRESHOLD_MS/1000)}s, interval: ${Math.round(ZAI_TOKEN_REFRESH_INTERVAL_MS/1000)}s`); + // Start proactive refresh check + zaiTokenRefresher.startProactiveRefresh( + () => accountManager.rawList().filter(a => a.provider === 'glm' && (a.backend || GLM_BACKEND) !== 'chatglm'), + async (id, { token, cookie }) => { + const updated = accountManager.updateToken(id, { token, cookie }); + console.log(`[FreeGLMKimiAPI] proactive token refresh for ${id} — ok=${!!updated}`); + }, + ZAI_TOKEN_REFRESH_INTERVAL_MS + ); +} else if (ZAI_TOKEN_REFRESH_ENABLED) { + console.warn('[FreeGLMKimiAPI] ZAI_TOKEN_REFRESH_ENABLED but no ZAI_CDP_URL set — auto-relogin disabled'); +} + function json(res,status,obj){ const data=JSON.stringify(obj); res.writeHead(status, {'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)}); res.end(data); } async function readBody(req){ const chunks=[]; for await (const c of req) chunks.push(c); const raw=Buffer.concat(chunks).toString('utf8'); return raw ? JSON.parse(raw) : {}; } function selectAccount(provider, session){ if (MOCK_PROVIDER) return { id:`mock-${provider}`, provider }; return accountManager.select(provider, session); } -function providerFor(modelCfg, account){ if (modelCfg.provider==='kimi') return new KimiProvider(account); const backend=(account.backend || account.endpoint || GLM_BACKEND).toLowerCase(); return backend==='chatglm' || backend==='chatglm.cn' ? new GLMProvider(account) : new ZaiProvider(account); } +function providerFor(modelCfg, account){ + if (modelCfg.provider==='kimi') return new KimiProvider(account); + const backend=(account.backend || account.endpoint || GLM_BACKEND).toLowerCase(); + if (backend==='chatglm' || backend==='chatglm.cn') return new GLMProvider(account); + return new ZaiProvider(account, { tokenRefresher: zaiTokenRefresher, accountManager, logger: console }); +} function textCompletion(content, model, prompt='', reasoning=''){ const msg={role:'assistant',content}; if(reasoning) msg.reasoning_content=reasoning; return { id:`fgk-${Date.now()}`, object:'chat.completion', created:Math.floor(Date.now()/1000), model, choices:[{index:0,message:msg,finish_reason:'stop'}], usage:usage(prompt,content), watermark:WATERMARK }; } function sseChunk(res,obj){ res.write(`data: ${JSON.stringify(obj)}\n\n`); } async function doCompletion(body){ @@ -61,6 +90,17 @@ async function handleAdmin(req,res,url){ if (req.method==='GET' && url.pathname==='/admin/accounts') return json(res,200,{accounts:accountManager.list()}); if (req.method==='POST' && url.pathname==='/admin/accounts') { const body=await readBody(req); const { persist, ...account }=body; const saved=accountManager.add(account,{persist:persistFrom(url,body)}); return json(res,201,{account:saved,accounts:accountManager.list()}); } if (req.method==='POST' && url.pathname==='/admin/accounts/reload') return json(res,200,{accounts:accountManager.reload()}); + if (req.method==='POST' && url.pathname==='/admin/zai/refresh-token') { + if (!zaiTokenRefresher) return json(res,400,{error:{message:'Z.ai auto-relogin not configured — set ZAI_CDP_URL'}}); + const result = await zaiTokenRefresher.refresh(); + if (!result?.ok) return json(res,502,{error:{message:'CDP token refresh failed',detail:result?.reason || 'unknown'}}); + // Auto-update all zai accounts with the fresh token + const zaiAccounts = accountManager.rawList().filter(a => a.provider === 'glm' && (a.backend || GLM_BACKEND) !== 'chatglm'); + for (const acct of zaiAccounts) { + accountManager.updateToken(acct.id, { token: result.token, cookie: result.cookie }); + } + return json(res,200,{ok:true, tokenLength: result.token.length, cookieLength: result.cookie?.length || 0, accounts: accountManager.list()}); + } const m=url.pathname.match(/^\/admin\/accounts\/([^/]+)$/); if (m && req.method==='DELETE') return json(res,200,{deleted:accountManager.delete(decodeURIComponent(m[1]),{persist:persistFrom(url)})}); return false; diff --git a/tests/zaiTokenRefresh.test.js b/tests/zaiTokenRefresh.test.js new file mode 100644 index 0000000..588f235 --- /dev/null +++ b/tests/zaiTokenRefresh.test.js @@ -0,0 +1,226 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + decodeJwtPayload, + getTokenExpiry, + getTokenTtlMs, + isTokenExpired, + isZaiAuthError, + ZaiTokenRefresher, +} from '../src/providers/zaiTokenRefresh.js'; +import { AccountManager } from '../src/accounts.js'; + +function unsignedJwt(payload) { + return `eyJ.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.y`; +} + +// ── JWT utilities ────────────────────────────────────────────── + +test('decodeJwtPayload extracts payload from JWT', () => { + const payload = { id: 'user-1', email: 'a@b.c', exp: 1781079000 }; + const token = unsignedJwt(payload); + assert.deepEqual(decodeJwtPayload(token), payload); +}); + +test('decodeJwtPayload returns null for non-JWT input', () => { + assert.equal(decodeJwtPayload('not-jwt'), null); + assert.equal(decodeJwtPayload(''), null); + assert.equal(decodeJwtPayload(null), null); + assert.equal(decodeJwtPayload('a.b'), null); // only 2 parts +}); + +test('getTokenExpiry reads exp claim and converts to milliseconds', () => { + const token = unsignedJwt({ exp: 1781079000 }); + assert.equal(getTokenExpiry(token), 1781079000000); +}); + +test('getTokenExpiry returns null when no exp claim', () => { + const token = unsignedJwt({ id: 'user-1' }); + assert.equal(getTokenExpiry(token), null); +}); + +test('getTokenTtlMs returns remaining time until expiry', () => { + const now = 1781078000000; + const token = unsignedJwt({ exp: 1781079000 }); // 1000s in the future + const ttl = getTokenTtlMs(token, () => now); + assert.equal(ttl, 1_000_000); // 1000 * 1000 +}); + +test('getTokenTtlMs returns negative for expired tokens', () => { + const now = 1781080000000; + const token = unsignedJwt({ exp: 1781079000 }); // 1000s in the past + const ttl = getTokenTtlMs(token, () => now); + assert.equal(ttl, -1_000_000); +}); + +test('getTokenTtlMs returns Infinity when no exp claim', () => { + const token = unsignedJwt({ id: 'user-1' }); + assert.equal(getTokenTtlMs(token), Infinity); +}); + +test('isTokenExpired returns true when TTL <= threshold', () => { + const now = 1781078000000; + const token = unsignedJwt({ exp: 1781079000 }); // 1000s = 1_000_000ms in future + assert.equal(isTokenExpired(token, { thresholdMs: 0, now: () => now }), false); + assert.equal(isTokenExpired(token, { thresholdMs: 1_100_000, now: () => now }), true); // threshold > TTL + // Expired token + const expiredToken = unsignedJwt({ exp: 1781077000 }); + assert.equal(isTokenExpired(expiredToken, { thresholdMs: 0, now: () => now }), true); +}); + +// ── Auth error detection ─────────────────────────────────────── + +test('isZaiAuthError detects 401 status', () => { + assert.equal(isZaiAuthError(401, ''), true); + assert.equal(isZaiAuthError(401, 'anything'), true); +}); + +test('isZaiAuthError detects auth error patterns in response body', () => { + assert.equal(isZaiAuthError(200, '{"code":"TOKEN_EXPIRED"}'), true); + assert.equal(isZaiAuthError(200, '{"code":"UNAUTHORIZED"}'), true); + assert.equal(isZaiAuthError(200, '{"detail":"Token has expired"}'), true); + assert.equal(isZaiAuthError(200, '{"message":"token expired, please login again"}'), true); + assert.equal(isZaiAuthError(200, 'Authentication required'), true); + assert.equal(isZaiAuthError(200, '请先登录'), true); + assert.equal(isZaiAuthError(200, '登录已过期'), true); +}); + +test('isZaiAuthError does not false-positive on captcha/normal errors', () => { + assert.equal(isZaiAuthError(200, '{"error":"Captcha verification failed"}'), false); + assert.equal(isZaiAuthError(200, 'Rate limit exceeded'), false); + assert.equal(isZaiAuthError(500, 'Internal server error'), false); +}); + +test('isZaiAuthError distinguishes 403 captcha from 403 auth', () => { + assert.equal(isZaiAuthError(403, 'captcha verification required'), false); + assert.equal(isZaiAuthError(403, 'aliyun waf block'), false); + assert.equal(isZaiAuthError(403, '{"code":"UNAUTHORIZED"}'), true); + assert.equal(isZaiAuthError(403, 'token expired'), true); +}); + +// ── ZaiTokenRefresher ────────────────────────────────────────── + +test('ZaiTokenRefresher.needsRefresh checks TTL against threshold', () => { + const now = 1781078000000; + const refresher = new ZaiTokenRefresher({ + cdpUrl: 'http://127.0.0.1:9222', + thresholdMs: 300_000, + now: () => now, + }); + // Token with 600s TTL — not expiring + const fresh = unsignedJwt({ exp: 1781078600 }); + assert.equal(refresher.needsRefresh(fresh), false); + // Token with 200s TTL — below 300s threshold + const stale = unsignedJwt({ exp: 1781078200 }); + assert.equal(refresher.needsRefresh(stale), true); + // Expired token + const expired = unsignedJwt({ exp: 1781077000 }); + assert.equal(refresher.needsRefresh(expired), true); +}); + +test('ZaiTokenRefresher.refresh calls extractFn and returns result', async () => { + const mockExtract = async () => ({ + token: unsignedJwt({ id: 'user-1', exp: Math.floor(Date.now() / 1000) + 3600 }), + cookie: 'token=abc; cdn_sec_tc=def', + ok: true, + }); + const refresher = new ZaiTokenRefresher({ + cdpUrl: 'http://127.0.0.1:9222', + extractFn: mockExtract, + maxRetries: 0, + }); + const result = await refresher.refresh(); + assert.equal(result.ok, true); + assert.ok(result.token.startsWith('eyJ')); + assert.ok(result.cookie.includes('token=')); +}); + +test('ZaiTokenRefresher.refresh returns null on cooldown', async () => { + let extractCalls = 0; + const mockExtract = async () => { + extractCalls++; + return { token: unsignedJwt({ id: 'u' }), cookie: '', ok: true }; + }; + const refresher = new ZaiTokenRefresher({ + cdpUrl: 'http://127.0.0.1:9222', + extractFn: mockExtract, + cooldownMs: 60_000, + }); + // First call succeeds + const r1 = await refresher.refresh(); + assert.equal(r1.ok, true); + assert.equal(extractCalls, 1); + // Second call within cooldown → null + const r2 = await refresher.refresh(); + assert.equal(r2, null); + assert.equal(extractCalls, 1); // extract not called again +}); + +test('ZaiTokenRefresher.refresh deduplicates concurrent calls', async () => { + let extractCalls = 0; + const mockExtract = async () => { + extractCalls++; + await new Promise(r => setTimeout(r, 50)); + return { token: unsignedJwt({ id: 'u' }), cookie: '', ok: true }; + }; + const refresher = new ZaiTokenRefresher({ + cdpUrl: 'http://127.0.0.1:9222', + extractFn: mockExtract, + maxRetries: 0, + }); + // Fire two concurrent refreshes + const [r1, r2] = await Promise.all([refresher.refresh(), refresher.refresh()]); + assert.equal(r1.ok, true); + assert.equal(r2.ok, true); + assert.equal(extractCalls, 1); // only one extract call +}); + +test('ZaiTokenRefresher.refresh retries on failure then returns null', async () => { + let calls = 0; + const mockExtract = async () => { + calls++; + throw new Error('CDP connection refused'); + }; + const refresher = new ZaiTokenRefresher({ + cdpUrl: 'http://127.0.0.1:9222', + extractFn: mockExtract, + maxRetries: 1, + cooldownMs: 0, + }); + const result = await refresher.refresh(); + assert.equal(result, null); + assert.equal(calls, 2); // initial + 1 retry +}); + +// ── AccountManager.updateToken ───────────────────────────────── + +test('AccountManager.updateToken updates token and persists', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'zai-acct-')); + const authPath = path.join(dir, 'auth.json'); + const mgr = new AccountManager({ authPath, env: {} }); + mgr.add({ id: 'zai1', provider: 'glm', token: unsignedJwt({ id: 'old' }) }, { persist: true }); + + const newToken = unsignedJwt({ id: 'new', exp: Math.floor(Date.now() / 1000) + 3600 }); + const updated = mgr.updateToken('zai1', { token: newToken, cookie: 'token=new; cdn=abc' }); + assert.equal(updated.id, 'zai1'); + assert.equal(updated.ok, true); + + // Verify persisted to disk + const raw = JSON.parse(fs.readFileSync(authPath, 'utf8')); + const persisted = raw.accounts.find(a => a.id === 'zai1'); + assert.equal(persisted.token, newToken); + assert.equal(persisted.cookie, 'token=new; cdn=abc'); + + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('AccountManager.updateToken returns null for unknown account', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'zai-acct-empty-')); + const authPath = path.join(dir, 'auth.json'); + const mgr = new AccountManager({ authPath, env: {} }); + assert.equal(mgr.updateToken('nonexistent', { token: 'x' }), null); + fs.rmSync(dir, { recursive: true, force: true }); +});