From 2d262c79ca0726ac1972b885b8bb6b1e29a422f7 Mon Sep 17 00:00:00 2001 From: yhurynovich Date: Wed, 8 Jul 2026 18:16:20 -0400 Subject: [PATCH 1/7] 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/7] 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/7] =?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/7] 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/7] 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()); From b18002d358aad244f7a6f946e3c79e3de08db18f Mon Sep 17 00:00:00 2001 From: yhurynovich Date: Wed, 22 Jul 2026 11:18:25 -0400 Subject: [PATCH 6/7] Start new session om "/new" --- src/server.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/server.js b/src/server.js index 810bdfb..c956adc 100644 --- a/src/server.js +++ b/src/server.js @@ -9,6 +9,7 @@ import { ZaiProvider } from './providers/zai.js'; import { mockComplete } from './mockProvider.js'; import { parseToolCallsFromText, buildToolCallCompletion, usage } from './tooling.js'; import { anthropicToOpenAI, openAIToAnthropic } from './anthropic.js'; +import { contentToText } from './message.js'; const store=new SessionStore(); const accountManager=new AccountManager({ authPath: AUTH_PATH, env: process.env, cooldownMs: Number(process.env.ACCOUNT_COOLDOWN_MS || 60_000) }); @@ -30,6 +31,11 @@ function textCompletion(content, model, prompt='', reasoning=''){ const msg={rol function sseChunk(res,obj){ res.write(`data: ${JSON.stringify(obj)}\n\n`); } async function doCompletion(body){ const modelCfg=resolveModel(body.model); const agentId=body.user || body.metadata?.user_id || body.headers?.['x-agent-id'] || 'default'; const session=store.get(agentId, modelCfg.provider); + const lastMsg=body.messages?.[body.messages.length - 1]; + if (contentToText(lastMsg?.content).trim() === '/new') { + session.providerSessionId = ''; session.parentMessageId = ''; session.messageCount = 0; + return textCompletion('Session reset. You can now start a new conversation.', modelCfg.id); + } let result; if (MOCK_PROVIDER) result={ text: await mockComplete({ prompt:(body.messages||[]).map(m=>m.content).join('\n'), model:modelCfg.id, tools:body.tools }), prompt:(body.messages||[]).map(m=>m.content).join('\n') }; else { From 3372d5199c57e60ed5fc70e758143bd6cc68e0d2 Mon Sep 17 00:00:00 2001 From: yhurynovich Date: Thu, 30 Jul 2026 16:17:08 -0400 Subject: [PATCH 7/7] REASON_CHAT_MESSAGE_NOT_FOUND fix attempt --- .env.example | 72 +++++++++++-------- docker-compose.yml | 5 ++ docs/KIMI_troubleshooting.md | 75 ++++++++++++++++++++ package.json | 78 +++++++++++---------- scripts/kimi_dump_curl_headers.js | 86 +++++++++++++++++++++++ src/providers/kimi.js | 113 ++++++++++++++++++++---------- 6 files changed, 324 insertions(+), 105 deletions(-) create mode 100644 docs/KIMI_troubleshooting.md create mode 100644 scripts/kimi_dump_curl_headers.js diff --git a/.env.example b/.env.example index 3fbaa27..a323a8f 100644 --- a/.env.example +++ b/.env.example @@ -1,31 +1,41 @@ -PORT=9766 -HOST=0.0.0.0 -DEFAULT_PROVIDER=kimi -DEFAULT_MODEL=kimi-k2.5 -AUTH_PATH=./auth.json -# Preferred for current GLM web app (chat.z.ai) -GLM_TOKEN= -# Legacy China ChatGLM backend (chatglm.cn); set GLM_BACKEND=chatglm to use this -GLM_REFRESH_TOKEN= -GLM_BACKEND=zai -# Optional: full Cookie header copied from a successful chat.z.ai browser completion request (keeps cdn/acw/ssxmod anti-bot cookies) -ZAI_COOKIE= -# Optional: captcha_verify_param copied from Z.ai browser completion request body. It is usually a base64 string, not JSON. -ZAI_CAPTCHA_VERIFY_PARAM= -# Optional browser fingerprint overrides for Z.ai request query/headers -ZAI_USER_AGENT= -ZAI_ACCEPT_LANGUAGE=en-US -ZAI_LANGUAGE=ru-RU -ZAI_LANGUAGES=ru-RU,ru,en-US,en -ZAI_TIMEZONE=Europe/Samara -ZAI_TIMEZONE_OFFSET=-240 -KIMI_TOKEN= -# Set to 1 for local tests without real GLM/Kimi credentials -MOCK_PROVIDER=0 -# Optional local keys accepted by proxy; empty disables auth check -API_KEYS= -# Admin / account-pool behavior -# 1 = POST/DELETE /admin/accounts persists auth.json by default; use persist:false per request to keep runtime-only -PERSIST_ADMIN_ACCOUNTS=1 -# Milliseconds to skip an account after provider error before retrying it -ACCOUNT_COOLDOWN_MS=60000 +PORT=9766 +HOST=0.0.0.0 +DEFAULT_PROVIDER=kimi +DEFAULT_MODEL=kimi-k2.5 +AUTH_PATH=./auth.json +# Preferred for current GLM web app (chat.z.ai) +GLM_TOKEN= +# Legacy China ChatGLM backend (chatglm.cn); set GLM_BACKEND=chatglm to use this +GLM_REFRESH_TOKEN= +GLM_BACKEND=zai +# Optional: full Cookie header copied from a successful chat.z.ai browser completion request (keeps cdn/acw/ssxmod anti-bot cookies) +ZAI_COOKIE= +# Optional: captcha_verify_param copied from Z.ai browser completion request body. It is usually a base64 string, not JSON. +ZAI_CAPTCHA_VERIFY_PARAM= +# Optional browser fingerprint overrides for Z.ai request query/headers +ZAI_USER_AGENT= +ZAI_ACCEPT_LANGUAGE=en-US +ZAI_LANGUAGE=ru-RU +ZAI_LANGUAGES=ru-RU,ru,en-US,en +ZAI_TIMEZONE=Europe/Samara +ZAI_TIMEZONE_OFFSET=-240 +KIMI_TOKEN= +# Debugging REASON_CHAT_MESSAGE_NOT_FOUND on Kimi turn 2+: www.kimi.com's +# Connect-RPC Chat endpoint likely needs session/device identity headers we're +# not confirmed to be sending correctly. kimi.js best-effort-derives some from +# the JWT; KIMI_EXTRA_HEADERS (JSON) always overrides those guesses. Capture +# the real ones with `npm run kimi:dump-headers` (see script header for steps). +KIMI_LANGUAGE=en-US +KIMI_TIMEZONE=America/Los_Angeles +KIMI_EXTRA_HEADERS= +# Set to 1 to log every outgoing Kimi request headers + chat_id/parent_id +DEBUG_KIMI=0 +# Set to 1 for local tests without real GLM/Kimi credentials +MOCK_PROVIDER=0 +# Optional local keys accepted by proxy; empty disables auth check +API_KEYS= +# Admin / account-pool behavior +# 1 = POST/DELETE /admin/accounts persists auth.json by default; use persist:false per request to keep runtime-only +PERSIST_ADMIN_ACCOUNTS=1 +# Milliseconds to skip an account after provider error before retrying it +ACCOUNT_COOLDOWN_MS=60000 diff --git a/docker-compose.yml b/docker-compose.yml index bfaf750..3b0ad5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,11 @@ services: # KIMI_TOKEN: "" # Protect the proxy if you expose it beyond localhost/LAN: # API_KEYS: "" + # Debugging REASON_CHAT_MESSAGE_NOT_FOUND on Kimi turn 2+ (see .env.example + # and src/providers/kimi.js for context). Capture real values with + # `npm run kimi:dump-headers` before setting KIMI_EXTRA_HEADERS. + # KIMI_EXTRA_HEADERS: '{}' + # DEBUG_KIMI: 1 # 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). diff --git a/docs/KIMI_troubleshooting.md b/docs/KIMI_troubleshooting.md new file mode 100644 index 0000000..685bd42 --- /dev/null +++ b/docs/KIMI_troubleshooting.md @@ -0,0 +1,75 @@ +# FreeGLMKimiAPI: REASON_CHAT_MESSAGE_NOT_FOUND fix attempt + +## Diagnosis + +`src/providers/kimi.js` targets `www.kimi.com`'s international Connect-RPC +surface (`/apiv2/kimi.gateway.chat.v1.ChatService/Chat`), but only ever sent +one custom header: `X-Msh-Platform: web`. A `jwtPayload()` helper existed in +the file to decode the account's JWT but was never actually used anywhere — +dead code that looks like an unfinished attempt to derive session/device +identity headers. + +That matches how this API behaves in the wild: the international Kimi web +surface is known (from other reverse-engineering projects hitting the same +endpoint) to send additional `x-msh-*` / `x-traffic-id` / `x-language` / +`r-timezone` headers alongside the JWT, with the JWT payload itself carrying +`device_id`, `region`, and a subject/session id that likely need to be +echoed back in headers. Your Kimi JWT (`data/auth.json`) does contain +`device_id` and `region: "overseas"` fields. + +Working theory: `chat_id`/`parent_id` are scoped server-side to a specific +device/session identity. Without the matching identity headers, the server +can create/continue the RPC call but can't resolve the chat message under an +unrecognized identity context — hence `REASON_CHAT_MESSAGE_NOT_FOUND`. + +**This is a well-supported hypothesis, not a confirmed fix** — the exact +header names are unverified even in the public reverse-engineering notes I +found. Ship it, then use the capture script below to nail down the real +names if it's still failing. + +## What changed + +`src/providers/kimi.js`: +- Wires the previously-unused `jwtPayload()` into the request: derives + `X-Msh-Device-Id`, `X-Msh-Region`, `X-Traffic-Id` from the JWT's + `device_id` / `region` / `sub` fields, plus static `X-Language` / + `R-Timezone` headers (env-overridable via `KIMI_LANGUAGE` / + `KIMI_TIMEZONE`). +- Adds `KIMI_EXTRA_HEADERS` (JSON env var) that always overrides the guessed + headers above — this is the important one if the guessed header names turn + out to be wrong. +- Adds `DEBUG_KIMI=1` to log every outgoing request's headers + chat_id/ + parent_id to `docker logs`, so you can diff against a real browser capture. +- Minor hardening: `parentId` now takes the *last* non-empty message id seen + across response frames instead of the first, in case an early frame echoes + the user's own turn before a later frame carries the assistant reply's id + (this shouldn't be your main issue, since the errors happen consistently, + but it's a plausible secondary contributor and is free to fix). + +`scripts/kimi_dump_curl_headers.js` (new): paste a "Copy as cURL" of a real +`www.kimi.com` **second-message** Chat request from Chrome DevTools, and it +prints every header plus a ready-to-paste `KIMI_EXTRA_HEADERS=` line for any +header we're not already sending/guessing. + +`package.json`: adds `npm run kimi:dump-headers` alias for the script above. + +`.env.example` / `docker-compose.yml`: document the new env vars +(`KIMI_LANGUAGE`, `KIMI_TIMEZONE`, `KIMI_EXTRA_HEADERS`, `DEBUG_KIMI`). + +## How to apply + +1. Copy `repo/src/providers/kimi.js` and `repo/scripts/kimi_dump_curl_headers.js` + into your `freeglmkimi/repo/` tree, and merge the `package.json` / + `.env.example` / `docker-compose.yml` diffs (or just replace them — they're + additive). +2. `sudo docker compose build --no-cache && sudo docker compose up -d` +3. Send a 2-message conversation through the proxy and watch: + `sudo docker logs freeglmkimi -f --tail 30` +4. Still failing? Set `DEBUG_KIMI=1` in `docker-compose.yml`, restart, and + compare the logged headers against a real browser capture: + - Open www.kimi.com logged in, DevTools > Network, send 2 messages in one + chat, right-click the 2nd `.../ChatService/Chat` request > Copy > Copy as + cURL (bash). + - `npm run kimi:dump-headers path/to/pasted-curl.txt` (or pipe it in). + - Drop any header it flags as missing into `KIMI_EXTRA_HEADERS` (JSON) in + `.env` or `docker-compose.yml`, restart, retest. diff --git a/package.json b/package.json index 8a55a5d..3e071e6 100644 --- a/package.json +++ b/package.json @@ -1,38 +1,40 @@ -{ - "name": "free-glm-kimi-api", - "version": "0.1.0", - "description": "OpenAI/Anthropic-compatible free web API proxy for GLM and Kimi with prompt-simulated tool use.", - "type": "module", - "main": "src/server.js", - "scripts": { - "start": "node src/server.js", - "test": "node --test tests/*.test.js", - "smoke": "node scripts/smoke.js", - "agent:hermes": "node scripts/agent_smoke.js hermes", - "agent:claude": "node scripts/agent_smoke.js claude", - "agent:opencode": "node scripts/agent_smoke.js opencode", - "agent:openclaw": "node scripts/agent_smoke.js openclaw", - "agent:all": "node scripts/agent_smoke.js all", - "doctor": "node scripts/doctor.js", - "smoke:zai": "node scripts/zai_real_smoke.js", - "auth:from-curl": "node scripts/import_zai_curl.js", - "auth:browser": "node scripts/zai_browser_auth.js" - }, - "keywords": [ - "glm", - "kimi", - "openai", - "anthropic", - "tool-calling", - "proxy" - ], - "license": "MIT", - "dependencies": { - "cloakbrowser": "^0.3.31", - "eventsource-parser": "^3.0.6", - "playwright-core": "^1.60.0", - "puppeteer-core": "^25.1.0", - "puppeteer-extra": "^3.3.6", - "puppeteer-extra-plugin-stealth": "^2.11.2" - } -} +{ + "name": "free-glm-kimi-api", + "version": "0.1.0", + "description": "OpenAI/Anthropic-compatible free web API proxy for GLM and Kimi with prompt-simulated tool use.", + "type": "module", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "test": "node --test tests/*.test.js", + "smoke": "node scripts/smoke.js", + "agent:hermes": "node scripts/agent_smoke.js hermes", + "agent:claude": "node scripts/agent_smoke.js claude", + "agent:opencode": "node scripts/agent_smoke.js opencode", + "agent:openclaw": "node scripts/agent_smoke.js openclaw", + "agent:all": "node scripts/agent_smoke.js all", + "doctor": "node scripts/doctor.js", + "smoke:zai": "node scripts/zai_real_smoke.js", + "auth:from-curl": "node scripts/import_zai_curl.js", + "auth:browser": "node scripts/zai_browser_auth.js", + "kimi:dump-headers": "node scripts/kimi_dump_curl_headers.js" + }, + "keywords": [ + "glm", + "kimi", + "openai", + "anthropic", + "tool-calling", + "proxy" + ], + "license": "MIT", + "dependencies": { + "cloakbrowser": "^0.3.31", + "eventsource-parser": "^3.0.6", + "playwright-core": "^1.60.0", + "puppeteer": "^25.3.0", + "puppeteer-core": "^25.1.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2" + } +} diff --git a/scripts/kimi_dump_curl_headers.js b/scripts/kimi_dump_curl_headers.js new file mode 100644 index 0000000..f7536d4 --- /dev/null +++ b/scripts/kimi_dump_curl_headers.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Diagnostic helper for the Kimi "REASON_CHAT_MESSAGE_NOT_FOUND" error. +// +// www.kimi.com's Chat RPC (Connect-RPC) most likely needs a session/device +// identity header that FreeGLMKimiAPI currently isn't sending (see the note +// above HEADERS in src/providers/kimi.js). Instead of guessing header names, +// capture the real ones straight from a logged-in browser and diff them +// against what we already send. +// +// How to use: +// 1. Open www.kimi.com in a normal browser, logged in to the account whose +// token is in auth.json. +// 2. Open DevTools > Network, send a message, then send a SECOND message in +// the same chat (the bug only reproduces on turn 2+, once chat_id/parent_id +// are in play). +// 3. Find the POST to /apiv2/kimi.gateway.chat.v1.ChatService/Chat for that +// second message, right-click it > Copy > Copy as cURL (bash). +// 4. Paste it into a file (or pipe it directly) and run: +// node scripts/kimi_dump_curl_headers.js path/to/pasted-curl.txt +// or: +// pbpaste | node scripts/kimi_dump_curl_headers.js +// 5. Compare the "not currently sent" section against src/providers/kimi.js. +// Copy the suggested KIMI_EXTRA_HEADERS line into .env / docker-compose.yml, +// restart the container, and retry. +import fs from 'fs'; + +const inputPath = process.argv[2] || ''; +const curl = inputPath ? fs.readFileSync(inputPath, 'utf8') : fs.readFileSync(0, 'utf8'); + +function unquoteShell(s) { + s = String(s || '').trim(); + if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"'))) s = s.slice(1, -1); + return s.replace(/'\\''/g, "'").replace(/\\\n/g, '').replace(/\\'/g, "'").replace(/\\"/g, '"'); +} + +function allHeaders() { + const re = /(?:^|\s)-H\s+((?:'[^']*')|(?:"[^"]*")|(?:\S+))/g; + const headers = {}; + let m; + while ((m = re.exec(curl))) { + const raw = unquoteShell(m[1]); + const idx = raw.indexOf(':'); + if (idx > 0) headers[raw.slice(0, idx).trim()] = raw.slice(idx + 1).trim(); + } + return headers; +} + +function urlFromCurl() { + const m = curl.match(/curl\s+(?:--\S+\s+)*['"]?(https?:\/\/[^\s'"]+)['"]?/); + return m ? m[1] : '(not found)'; +} + +// Headers FreeGLMKimiAPI's kimi.js already sends (base HEADERS + the ones +// this patch derives) plus generic browser/proxy noise we don't care about. +const KNOWN = new Set([ + 'accept', 'cache-control', 'pragma', 'origin', 'user-agent', 'x-msh-platform', + 'authorization', 'content-type', 'x-language', 'r-timezone', 'x-msh-device-id', + 'x-msh-region', 'x-traffic-id', + 'host', 'content-length', 'connection', 'accept-encoding', 'accept-language', + 'referer', 'cookie', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', + 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site', 'priority' +]); + +const headers = allHeaders(); +if (!Object.keys(headers).length) { + console.error('No -H headers found in the input. Make sure you pasted a full "Copy as cURL" command.'); + process.exit(1); +} + +const extra = {}; +for (const [k, v] of Object.entries(headers)) { + if (!KNOWN.has(k.toLowerCase())) extra[k] = v; +} + +console.log('URL:', urlFromCurl()); +console.log('\nAll headers captured from the browser request:'); +console.log(JSON.stringify(headers, null, 2)); +console.log('\nHeaders NOT currently sent by FreeGLMKimiAPI (candidates to confirm/add):'); +console.log(JSON.stringify(extra, null, 2)); +if (Object.keys(extra).length) { + console.log('\nTo apply directly (bypasses the JWT-derived guesses in kimi.js), add to .env / docker-compose.yml:'); + console.log(`KIMI_EXTRA_HEADERS=${JSON.stringify(extra)}`); +} else { + console.log('\nNo extra headers found beyond what kimi.js already sends/guesses — the identity-header'); + console.log('hypothesis may not be it; re-check chat_id/parent_id handling instead.'); +} diff --git a/src/providers/kimi.js b/src/providers/kimi.js index 407244c..0027808 100644 --- a/src/providers/kimi.js +++ b/src/providers/kimi.js @@ -1,36 +1,77 @@ -import { contentToText, preparePrompt } from '../message.js'; - -const BASE='https://www.kimi.com'; -const HEADERS={ Accept:'*/*','Cache-Control':'no-cache',Pragma:'no-cache',Origin:BASE,'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131 Safari/537.36','X-Msh-Platform':'web' }; -function ts(){return Math.floor(Date.now()/1000)} -function jwtPayload(token){ try { return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); } catch { return {}; } } -function frameJson(obj){ const b=Buffer.from(JSON.stringify(obj)); const f=Buffer.alloc(5+b.length); f.writeUInt8(0,0); f.writeUInt32BE(b.length,1); b.copy(f,5); return f; } -export function parseKimiFrames(buffer){ - const out=[]; let off=0; - while (off+5<=buffer.length) { const len=buffer.readUInt32BE(off+1); if (off+5+len>buffer.length) break; const raw=buffer.slice(off+5,off+5+len).toString('utf8'); try { out.push(JSON.parse(raw)); } catch {} off += 5+len; } - return out; -} -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 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()); - const frames=parseKimiFrames(arr); - let text='', reasoning='', chatId='', parentId=''; - for (const d of frames) { - chatId ||= d.chat_id || d.chatId || d.message?.chat_id || d.message?.chatId || ''; - parentId ||= d.message_id || d.messageId || d.message?.message_id || d.message?.id || ''; - if (d.error) throw new Error(`Kimi API Error: ${d.error.message || JSON.stringify(d.error)}`); - const parts=[d.block?.text?.content,d.text?.content,d.message?.text?.content,d.message?.content,d.content,d.delta?.content].filter(Boolean); - if ((d.op === 'set' || d.op === 'append') || parts.length) text += parts.join(''); - reasoning += d.reasoning_content || d.thinking?.content || ''; - } - return { text, reasoning, providerSessionId: chatId, parentMessageId: parentId, prompt }; - } -} +import { contentToText, preparePrompt } from '../message.js'; + +const BASE='https://www.kimi.com'; +const HEADERS={ Accept:'*/*','Cache-Control':'no-cache',Pragma:'no-cache',Origin:BASE,'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131 Safari/537.36','X-Msh-Platform':'web' }; +// www.kimi.com (international, Connect-RPC) appears to scope a chat_id/message +// to a particular session identity. Our JWT carries device_id/region (see +// jwtPayload below); the x-msh-platform header alone is not enough to prove +// that identity to the server, which is the most likely cause of +// REASON_CHAT_MESSAGE_NOT_FOUND on the 2nd+ turn of a session (chat_id/parent_id +// point at a chat that, from the server's point of view, belongs to a +// different/unidentified session). The header names below are a best-effort +// guess from the JWT shape — confirm the real ones with +// `node scripts/kimi_dump_curl_headers.js` (paste a "Copy as cURL" of a real +// browser Chat request) and drop any corrected values into KIMI_EXTRA_HEADERS, +// which always wins over the guesses below. +const KIMI_LANGUAGE = process.env.KIMI_LANGUAGE || 'en-US'; +const KIMI_TIMEZONE = process.env.KIMI_TIMEZONE || 'America/Los_Angeles'; +const KIMI_EXTRA_HEADERS = (() => { + try { return JSON.parse(process.env.KIMI_EXTRA_HEADERS || '{}'); } catch { return {}; } +})(); +const DEBUG_KIMI = ['1','true','yes','on'].includes(String(process.env.DEBUG_KIMI || '').toLowerCase()); +function ts(){return Math.floor(Date.now()/1000)} +function jwtPayload(token){ try { return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); } catch { return {}; } } +function frameJson(obj){ const b=Buffer.from(JSON.stringify(obj)); const f=Buffer.alloc(5+b.length); f.writeUInt8(0,0); f.writeUInt32BE(b.length,1); b.copy(f,5); return f; } +export function parseKimiFrames(buffer){ + const out=[]; let off=0; + while (off+5<=buffer.length) { const len=buffer.readUInt32BE(off+1); if (off+5+len>buffer.length) break; const raw=buffer.slice(off+5,off+5+len).toString('utf8'); try { out.push(JSON.parse(raw)); } catch {} off += 5+len; } + return out; +} +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'); + this.jwt=jwtPayload(this.token); + } + // Best-effort session-identity headers derived from the JWT, so a chat + // created on turn 1 is recognized as belonging to the same + // device/session on turn 2+. Real header names are unconfirmed (see the + // note above HEADERS) — KIMI_EXTRA_HEADERS overrides anything here. + identityHeaders(){ + const h={}; + if (this.jwt.device_id) h['X-Msh-Device-Id']=String(this.jwt.device_id); + if (this.jwt.region) h['X-Msh-Region']=String(this.jwt.region); + if (this.jwt.sub) h['X-Traffic-Id']=String(this.jwt.sub); + h['X-Language']=KIMI_LANGUAGE; + h['R-Timezone']=KIMI_TIMEZONE; + return { ...h, ...KIMI_EXTRA_HEADERS }; + } + async complete({ messages, modelCfg, tools, session }) { + const prompt=preparePrompt(messages, tools, { simpleTools:true, isMultiTurn: !!session.providerSessionId }); + 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 reqHeaders={...HEADERS,...this.identityHeaders(),Authorization:`Bearer ${this.token}`,'Content-Type':'application/connect+json'}; + if (DEBUG_KIMI) console.error('[FreeGLMKimiAPI] Kimi request headers:', JSON.stringify(reqHeaders), 'chat_id:', payload.chat_id||'(new)', 'parent_id:', message.parent_id||'(none)'); + const resp=await fetch(`${BASE}/apiv2/kimi.gateway.chat.v1.ChatService/Chat`, { method:'POST', headers:reqHeaders, 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()); + const frames=parseKimiFrames(arr); + let text='', reasoning='', chatId='', parentId=''; + for (const d of frames) { + chatId ||= d.chat_id || d.chatId || d.message?.chat_id || d.message?.chatId || ''; + // Keep overwriting (not ||=) so the LAST message id seen in the stream + // wins. If an early frame carries the user turn's own echoed id and a + // later frame carries the assistant reply's id, we want the latter as + // next turn's parent_id. + parentId = d.message_id || d.messageId || d.message?.message_id || d.message?.id || parentId; + if (d.error) throw new Error(`Kimi API Error: ${d.error.message || JSON.stringify(d.error)}`); + const parts=[d.block?.text?.content,d.text?.content,d.message?.text?.content,d.message?.content,d.content,d.delta?.content].filter(Boolean); + if ((d.op === 'set' || d.op === 'append') || parts.length) text += parts.join(''); + reasoning += d.reasoning_content || d.thinking?.content || ''; + } + return { text, reasoning, providerSessionId: chatId, parentMessageId: parentId, prompt }; + } +}