From 2d262c79ca0726ac1972b885b8bb6b1e29a422f7 Mon Sep 17 00:00:00 2001 From: yhurynovich Date: Wed, 8 Jul 2026 18:16:20 -0400 Subject: [PATCH 1/3] Better error handling --- scripts/zai_browser_auth.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/zai_browser_auth.js b/scripts/zai_browser_auth.js index b0b80d6..0be0c9a 100644 --- a/scripts/zai_browser_auth.js +++ b/scripts/zai_browser_auth.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; +import { pathToFileURL } from 'url'; import { defaultChromeExecutable } from '../src/providers/zaiBrowser.js'; const outPath = process.argv[2] || process.env.AUTH_PATH || './auth.json'; @@ -117,6 +118,14 @@ async function main() { process.exit(2); } -if (import.meta.url === `file://${process.argv[1]}`) { +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { main().catch(err => { console.error(err); process.exit(1); }); +} else if (process.env.ZAI_AUTH_DEBUG_ENTRY) { + // Diagnostic: set ZAI_AUTH_DEBUG_ENTRY=1 to see why the entry-point check didn't match + console.error('zai_browser_auth.js loaded but not run as main:', { + 'import.meta.url': import.meta.url, + 'argv[1]': process.argv[1], + 'expected': process.argv[1] ? pathToFileURL(process.argv[1]).href : null + }); } From 2dbb80d772073ad15a93a9b36d9d668baf87bed7 Mon Sep 17 00:00:00 2001 From: yhurynovich Date: Sat, 18 Jul 2026 04:03:48 -0400 Subject: [PATCH 2/3] fixes --- Dockerfile | 59 +++++++++++++++++++++++++++++++++++++ commands.txt | 14 +++++++++ docker-compose.yml | 46 +++++++++++++++++++++++++++++ docker-entrypoint.sh | 28 ++++++++++++++++++ scripts/zai_browser_auth.js | 17 ++++++++++- 5 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 commands.txt create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a9216b9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +# FreeGLMKimiAPI — https://github.com/ForgetMeAI/FreeGLMKimiAPI +FROM node:22-bookworm-slim + +# The proxy drives a real browser for the Z.ai/GLM browser-fallback login flow +# and anti-bot handling (playwright-core, puppeteer-core, puppeteer-extra + +# stealth plugin, cloakbrowser). None of these packages bundle their own +# Chromium build ("-core" packages never auto-download), so we install one +# system-wide and point every relevant env var at it. +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + ca-certificates \ + fonts-liberation \ + fonts-noto-color-emoji \ + tini \ + && rm -rf /var/lib/apt/lists/* + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ + PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + CHROME_PATH=/usr/bin/chromium + +WORKDIR /app + +# Install deps once at build time so the image works standalone even if you +# don't mount a persistent node_modules volume. NOT using --omit=dev here: +# this repo's browser-fallback packages (puppeteer-extra, stealth plugin, +# cloakbrowser) are needed at runtime for the Z.ai/Kimi fallback path even +# though some are listed under devDependencies upstream. +COPY package.json package-lock.json* ./ +RUN npm ci || npm install + +# Now bring in the rest of the source +COPY . . + +# auth.json, .env overrides, and the persistent Z.ai browser profile all live +# under /app/data so they survive image rebuilds via a bind/volume mount. +RUN mkdir -p /app/data + +# If /app/node_modules is bind-mounted from the host (recommended — see +# docker-compose.yml), this entrypoint skips `npm ci` on every container +# restart/NAS reboot and only reinstalls when package.json/lock/Node/arch +# actually changed. First boot after mounting an empty volume still installs +# once and writes the sentinel. +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +ENV NODE_ENV=production \ + PORT=3364 \ + HOST=0.0.0.0 \ + AUTH_PATH=/app/data/auth.json \ + ZAI_BROWSER_PROFILE_DIR=/app/data/zai-browser-profile + +EXPOSE 3364 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+ (process.env.PORT||3364) +'/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + +ENTRYPOINT ["tini", "--", "/usr/local/bin/docker-entrypoint.sh"] +CMD ["node", "src/server.js"] diff --git a/commands.txt b/commands.txt new file mode 100644 index 0000000..575dec2 --- /dev/null +++ b/commands.txt @@ -0,0 +1,14 @@ + +sudo docker compose build --no-cache +sudo docker compose up -d + +curl http://192.168.31.66:3364/health + +curl -X POST http://192.168.31.66:3364/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "kimi-k2.5", + "messages": [{"role": "user", "content": "hello"}], + "stream": false + }' + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bfaf750 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + freeglmkimi: + build: + context: ./repo # cloned ForgetMeAI/FreeGLMKimiAPI; Dockerfile + docker-entrypoint.sh live here too + dockerfile: Dockerfile + container_name: freeglmkimi + restart: unless-stopped + + # Headless Chromium (Z.ai browser-fallback login / anti-bot handling) needs + # more than the Docker default 64mb of /dev/shm or it will randomly crash. + shm_size: "1gb" + + ports: + - "3364:3364" + + environment: + PORT: 3364 + HOST: 0.0.0.0 + DEFAULT_PROVIDER: kimi + DEFAULT_MODEL: kimi-k2.5 + GLM_BACKEND: zai + AUTH_PATH: /app/data/auth.json + MOCK_PROVIDER: 0 + PERSIST_ADMIN_ACCOUNTS: 1 + ACCOUNT_COOLDOWN_MS: 60000 + # Single-account shortcut (skip if you're using auth.json instead): + # GLM_TOKEN: "" + # KIMI_TOKEN: "" + # Protect the proxy if you expose it beyond localhost/LAN: + # API_KEYS: "" + + # Optional: keep real tokens out of this file entirely and drop them in a + # .env next to this compose file instead (same keys as .env.example). + # env_file: + # - .env + + volumes: + # Persists auth.json, the admin-added account pool, and the Z.ai browser + # profile across container rebuilds/restarts. + - /volume1/docker/freeglmkimi/data:/app/data + # Persists node_modules across restarts/NAS reboots. The entrypoint's + # sentinel check means `npm ci` only reruns when package.json/lock (or + # Node version/arch) actually changes — a plain restart or reboot skips + # straight to `node src/server.js`. First start after creating this + # volume will still install once, as normal. + - /volume1/docker/freeglmkimi/node_modules:/app/node_modules diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..c58273a --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Speeds up container restarts/NAS reboots by skipping `npm ci` when the +# bind-mounted /app/node_modules already matches the current package.json / +# package-lock.json / Node version / arch / *this script*. Only reinstalls +# when one of those actually changed. Including this script's own contents +# in the hash means editing the install logic here (e.g. adding/removing +# --omit=dev) automatically invalidates old sentinels — no manual `rm` +# needed. Mirrors the sentinel-file pattern used for Hermes' package +# persistence. +set -e + +SENTINEL="/app/node_modules/.install-sentinel" +SELF="$0" + +CURRENT_HASH=$(cat package.json package-lock.json "$SELF" 2>/dev/null | \ + { command -v sha256sum >/dev/null 2>&1 && sha256sum || md5sum; } | \ + awk '{print $1}') +CURRENT_HASH="${CURRENT_HASH}-node$(node --version)-$(uname -m)" + +if [ -f "$SENTINEL" ] && [ "$(cat "$SENTINEL")" = "$CURRENT_HASH" ]; then + echo "[entrypoint] node_modules matches sentinel — skipping npm ci" +else + echo "[entrypoint] Installing dependencies (first run, or package.json/lock/Node/arch/entrypoint changed)..." + npm ci + echo "$CURRENT_HASH" > "$SENTINEL" +fi + +exec "$@" diff --git a/scripts/zai_browser_auth.js b/scripts/zai_browser_auth.js index 0be0c9a..e8b93e9 100644 --- a/scripts/zai_browser_auth.js +++ b/scripts/zai_browser_auth.js @@ -66,6 +66,11 @@ async function cookieHeader(page) { return cookies.map(c => `${c.name}=${c.value}`).join('; '); } +function isTransientNavigationError(err) { + const msg = String(err && err.message || err || ''); + return /Execution context was destroyed|Cannot find context|Target closed|Session closed|detached Frame|Node is detached/i.test(msg); +} + async function main() { const puppeteer = await loadPuppeteer(); fs.mkdirSync(profileDir, { recursive: true }); @@ -97,7 +102,17 @@ async function main() { while (Date.now() - started < timeoutMs) { // Prefer localStorage: Z.ai may keep an early guest Authorization request in memory, // while localStorage is already updated to the real logged-in account token. - const localToken = await readToken(page); + let localToken = ''; + try { + localToken = await readToken(page); + } catch (err) { + if (isTransientNavigationError(err)) { + // Page navigated (e.g. right after login) mid-read; just retry next tick. + await new Promise(r => setTimeout(r, 500)); + continue; + } + throw err; + } const candidates = [localToken, networkToken].filter(Boolean); const usable = candidates.map(token => ({ token, state: isUsableZaiAuthToken(token, { allowGuest: allowGuestAuth }) })).find(item => item.state.ok); const guestSeen = candidates.some(token => isUsableZaiAuthToken(token, { allowGuest: false }).reason === 'guest_token'); From 182c1ae55a12933d9202dad899e077673242935c Mon Sep 17 00:00:00 2001 From: yhurynovich Date: Sat, 18 Jul 2026 23:00:39 -0400 Subject: [PATCH 3/3] =?UTF-8?q?unhandled=20promise=20rejection=20in=20src/?= =?UTF-8?q?providers/zaiBrowser.js,=20in=20completeViaUi();=20Fix=20?= =?UTF-8?q?=E2=80=94=20make=20sure=20the=20timer/listener=20always=20gets?= =?UTF-8?q?=20torn=20down,=20even=20if=20submission=20fails,=20so=20nothin?= =?UTF-8?q?g=20is=20left=20ticking=20unattended:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/providers/zaiBrowser.js | 40 +++++++++++++++++++++++++++---------- src/server.js | 9 +++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/providers/zaiBrowser.js b/src/providers/zaiBrowser.js index c6036e4..459d8a7 100644 --- a/src/providers/zaiBrowser.js +++ b/src/providers/zaiBrowser.js @@ -286,32 +286,52 @@ export class ZaiBrowserClient { this.page = page; await this.setupPage(page); await page.goto(ZAI_BASE, { waitUntil: 'domcontentloaded', timeout: Number(this.env.ZAI_BROWSER_NAV_TIMEOUT || 60_000) }).catch(() => null); + + // Tracked outside the Promise executor so a failure during prompt + // submission (below) can tear down the timer/listener instead of + // leaving them to fire later on a promise nobody is listening to + // anymore (that orphaned rejection is what was crashing the process). + let onResponse; + let timeout; + const cleanup = () => { + clearTimeout(timeout); + if (onResponse) page.off('response', onResponse); + }; + const responsePromise = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - page.off('response', onResponse); + timeout = setTimeout(() => { + cleanup(); reject(new Error('Timed out waiting for Z.ai UI completion response')); }, Number(this.env.ZAI_BROWSER_COMPLETION_TIMEOUT || 180_000)); - async function onResponse(response) { + onResponse = async (response) => { const url = typeof response.url === 'function' ? response.url() : response.url; if (!String(url).includes('/api/v2/chat/completions')) return; try { const raw = await response.text(); - clearTimeout(timeout); - page.off('response', onResponse); + cleanup(); const headers = typeof response.headers === 'function' ? response.headers() : (response.headers || {}); const ok = typeof response.ok === 'function' ? response.ok() : response.ok; const status = typeof response.status === 'function' ? response.status() : response.status; resolve({ ok, status, contentType: headers['content-type'] || '', raw }); } catch (err) { - clearTimeout(timeout); - page.off('response', onResponse); + cleanup(); reject(err); } - } + }; page.on('response', onResponse); }); - await this.humanFillPrompt(page, prompt); - await page.keyboard.press('Enter'); + + try { + await this.humanFillPrompt(page, prompt); + await page.keyboard.press('Enter'); + } catch (err) { + // Submission failed (e.g. textarea never appeared because the page + // landed on a login/captcha wall instead of the chat UI). Kill the + // pending timer instead of leaving it to reject unattended later. + cleanup(); + throw err; + } + return responsePromise; } diff --git a/src/server.js b/src/server.js index 9c0c104..810bdfb 100644 --- a/src/server.js +++ b/src/server.js @@ -13,6 +13,15 @@ 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) }); +// Safety net: log any stray unhandled rejection instead of letting Node's +// default behavior kill the whole process. This does NOT replace fixing the +// actual source (see zaiBrowser.js completeViaUi) — it just stops one bad +// promise from taking down every in-flight request when the fleet is under +// heavy parallel load. +process.on('unhandledRejection', (err) => { + console.error('[FreeGLMKimiAPI] unhandled rejection (recovered):', err); +}); + 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); }