From 2d262c79ca0726ac1972b885b8bb6b1e29a422f7 Mon Sep 17 00:00:00 2001 From: yhurynovich Date: Wed, 8 Jul 2026 18:16:20 -0400 Subject: [PATCH 1/5] 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/5] 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/5] =?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); } From c63ae1034c57e8001d66ef7329213567f0fe6ec4 Mon Sep 17 00:00:00 2001 From: yhurynovich Date: Mon, 20 Jul 2026 22:58:52 -0400 Subject: [PATCH 4/5] deep_research support for GLM models Now when glm-5-deepresearch is used, the Z.ai API will receive deep_research: true in the features payload, enabling the deep research mode alongside web search. --- src/providers/zai.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/providers/zai.js b/src/providers/zai.js index 575a895..45f1f45 100644 --- a/src/providers/zai.js +++ b/src/providers/zai.js @@ -90,7 +90,7 @@ export function parseZaiSse(raw) { return { text, reasoning, parentMessageId, providerSessionId }; } -export function buildZaiRequest({ token, model='glm-5', prompt='', chatId='', parentMessageId=null, thinking=false, webSearch=false, captchaVerifyParam=null, cookie=null, now=()=>Date.now(), uuid=randomUuid } = {}) { +export function buildZaiRequest({ token, model='glm-5', prompt='', chatId='', parentMessageId=null, thinking=false, webSearch=false, deepResearch=false, captchaVerifyParam=null, cookie=null, now=()=>Date.now(), uuid=randomUuid } = {}) { const timestamp = now(); const requestId = uuid(); const messageId = uuid(); @@ -112,7 +112,7 @@ export function buildZaiRequest({ token, model='glm-5', prompt='', chatId='', pa signature_prompt: prompt, params: {}, extra: {}, - features: { image_generation: false, web_search: !!webSearch, auto_web_search: false, preview_mode: true, flags: [], vlm_tools_enable: false, vlm_web_search_enable: false, vlm_website_mode: false, enable_thinking: !!thinking }, + features: { image_generation: false, web_search: !!webSearch, deep_research: !!deepResearch, auto_web_search: false, preview_mode: true, flags: [], vlm_tools_enable: false, vlm_web_search_enable: false, vlm_website_mode: false, enable_thinking: !!thinking }, variables: { '{{USER_NAME}}': process.env.ZAI_USER_NAME || 'test', '{{USER_LOCATION}}':'Unknown', '{{CURRENT_DATETIME}}':localTime.toISOString().replace('T',' ').substring(0,19), '{{CURRENT_DATE}}':localTime.toISOString().substring(0,10), '{{CURRENT_TIME}}':localTime.toISOString().substring(11,19), '{{CURRENT_WEEKDAY}}':['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][localTime.getDay()], '{{CURRENT_TIMEZONE}}':ZAI_TIMEZONE, '{{USER_LANGUAGE}}':'en-US' @@ -154,7 +154,7 @@ export class ZaiProvider { 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 req = buildZaiRequest({ token:this.token, model:modelCfg.id, prompt, chatId, parentMessageId:parentId, thinking:modelCfg.thinking, webSearch:modelCfg.webSearch, deepResearch:modelCfg.deepResearch, 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) { From f799db40d31fbec546d6d40cacea46093eeeb22c Mon Sep 17 00:00:00 2001 From: yhurynovich Date: Tue, 21 Jul 2026 23:30:29 -0400 Subject: [PATCH 5/5] message lookup failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: KimiProvider.complete() was building the request with chat_id: session.providerSessionId || '' and message.parent_id: session.parentMessageId || ''. On a brand new session — which is every session after a container restart, since SessionStore is just an in-memory Map that resets on process start — this sends an explicit empty string "" for parent_id. Compare to zai.js, which handles the same "no prior message" case with null instead of ''. Kimi's backend apparently treats an explicit "" as a real message ID it should look up rather than "no parent," so it comes back with REASON_CHAT_MESSAGE_NOT_FOUND — which matches the error text exactly (it's a message lookup failure, not a chat lookup failure, which is why chat_id alone wasn't the suspect). Fix: only set chat_id / parent_id on the payload when there's an actual prior value; omit the fields entirely for a fresh conversation instead of sending --- src/providers/kimi.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/providers/kimi.js b/src/providers/kimi.js index cafe04d..407244c 100644 --- a/src/providers/kimi.js +++ b/src/providers/kimi.js @@ -14,7 +14,10 @@ export class KimiProvider { constructor(account){ this.account=account; this.token=account.token || account.accessToken || account.refreshToken || account.refresh_token; if(!this.token) throw new Error('Kimi token missing'); } async complete({ messages, modelCfg, tools, session }) { const prompt=preparePrompt(messages, tools, { simpleTools:true, isMultiTurn: !!session.providerSessionId }); - const payload={ scenario:'SCENARIO_K2D5', chat_id:session.providerSessionId || '', tools:modelCfg.webSearch ? [{type:'TOOL_TYPE_SEARCH',search:{}}] : [], message:{ parent_id:session.parentMessageId || '', role:'user', blocks:[{message_id:'', text:{content:prompt}}], scenario:'SCENARIO_K2D5' }, options:{ thinking: !!modelCfg.thinking } }; + const message={ role:'user', blocks:[{message_id:'', text:{content:prompt}}], scenario:'SCENARIO_K2D5' }; + if (session.parentMessageId) message.parent_id = session.parentMessageId; + const payload={ scenario:'SCENARIO_K2D5', tools:modelCfg.webSearch ? [{type:'TOOL_TYPE_SEARCH',search:{}}] : [], message, options:{ thinking: !!modelCfg.thinking } }; + if (session.providerSessionId) payload.chat_id = session.providerSessionId; const resp=await fetch(`${BASE}/apiv2/kimi.gateway.chat.v1.ChatService/Chat`, { method:'POST', headers:{...HEADERS,Authorization:`Bearer ${this.token}`,'Content-Type':'application/connect+json'}, body:frameJson(payload) }); if (!resp.ok) throw new Error(`Kimi HTTP ${resp.status}: ${(await resp.text()).slice(0,200)}`); const arr=Buffer.from(await resp.arrayBuffer());