diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2523fa5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +# Git +.git +.gitignore + +# Dependencies (will be installed in container) +node_modules + +# IDE/Editor +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Test coverage +coverage + +# Environment files (should be provided via docker-compose/env_file) +.env +.env.* +!.env.example + +# Data directories (mounted as volumes) +data/ +*.json +!package.json +!package-lock.json + +# Build artifacts +dist +build + +# Documentation (not needed at runtime) +docs/ + +# Scripts (not needed at runtime) +scripts/ + +# Docker +Dockerfile +docker-compose.yml +docker-entrypoint.sh +.dockerignore \ No newline at end of file diff --git a/.env.example b/.env.example index 3fbaa27..75dd942 100644 --- a/.env.example +++ b/.env.example @@ -1,31 +1,83 @@ -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 +# Z.ai browser fallback (Puppeteer/CloakBrowser). See docs/browser-fallback.md +# for what this is, and for the CPU/network-leak issue that was fixed here. +# 1: enable per-account (or set browser_fallback:true in auth.json instead) +# ZAI_BROWSER_FALLBACK=1 +# cloak or puppeteer (default puppeteer) +# ZAI_BROWSER_ENGINE=puppeteer +# false to watch it work locally; default true (headless) +# ZAI_BROWSER_HEADLESS=true +# How long to wait for a UI completion response before giving up: +# ZAI_BROWSER_COMPLETION_TIMEOUT=180000 +# How often to log a "still waiting" heartbeat while waiting on the above: +# ZAI_BROWSER_HEARTBEAT_MS=15000 +# Close the whole browser after this long with zero requests (0 disables +# auto-close entirely). Relaunches lazily on the next request either way. +# ZAI_BROWSER_IDLE_CLOSE_MS=600000 +# ZAI_BROWSER_IDLE_CHECK_MS=60000 +# Extra Chromium launch flags (space-separated), appended to the built-in +# Docker-safe defaults (--disable-gpu, --disable-dev-shm-usage, etc.): +# ZAI_BROWSER_PUPPETEER_ARGS= +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 +# How long an idle conversation keeps its provider-side chat id before the +# next turn starts a fresh chat, and how many turns one chat id chain may +# grow to before the same reset happens. See docs/sessions.md. +SESSION_TTL_MS=7200000 +SESSION_MAX_DEPTH=100 + +# Model catalog / auto-discover-and-retire. See docs/model-lifecycle.md. +# Where the discovered/retired model catalog persists across restarts +# (leave unset for in-memory only, reset on every restart): +# MODEL_CATALOG_PATH=/app/data/models.json +# Consecutive model-not-found-shaped failures before a model is auto- +# retired from GET /v1/models (it can still be requested explicitly; +# retirement only affects what gets advertised): +MODEL_RETIRE_AFTER_FAILURES=3 +# Extra regex patterns (JSON array of strings) to recognize a provider +# error as a model-does-not-exist error, beyond the built-in ones in +# src/modelCatalog.js: +MODEL_ERROR_PATTERNS_EXTRA= +# Kimi only: override which upstream scenario (effectively: which model +# generation) a given model id maps to. The built-in guesses for +# kimi-k2.6/k2.7-code/k3 are UNCONFIRMED - verify with +# node scripts/kimi_dump_curl_headers.js and correct here if needed: +KIMI_SCENARIOS= diff --git a/.gitignore b/.gitignore index e2198f6..f2fdc5d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ auth.jso auth.*.json *.log .DS_Store +/data/zai-browser-profile diff --git a/.mimocode/commands/docker-test.md b/.mimocode/commands/docker-test.md new file mode 100644 index 0000000..81a1a8c --- /dev/null +++ b/.mimocode/commands/docker-test.md @@ -0,0 +1,84 @@ +--- +description: Build, start, and test the FreeGLMKimiAPI Docker container with a curl request to /v1/chat/completions +agent: main +--- + +# Docker Test Command + +Build the Docker image, start the container, and run a test request against the OpenAI-compatible endpoint. + +## Usage + +```bash +/mimocode:command docker-test [model] [port] [--no-cache] [--mock] +``` + +## Parameters + +- `model` (optional): Model to test. Default: `glm-5-search` +- `port` (optional): Host port mapped to container port 3364. Default: `3364` +- `--no-cache`: Run `docker-compose build --no-cache` +- `--mock`: Set `MOCK_PROVIDER=1` in container environment (tests without real credentials) + +## Procedure + +1. **Build the image** + ```bash + cd /home/yury/Documents/FreeGLMKimiAPI + docker-compose build [--no-cache] + ``` + +2. **Start the container** (detached) + ```bash + docker-compose up -d + ``` + +3. **Wait for server readiness** (5-10 seconds) + ```bash + sleep 8 + ``` + +4. **Run test request** + ```bash + curl -X POST http://localhost:${PORT:-3364}/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "${MODEL:-glm-5-search}", + "messages": [{"role": "user", "content": "hello"}], + "stream": false + }' + ``` + +5. **Show container logs** (last 30 lines) + ```bash + docker-compose logs --tail=30 + ``` + +6. **Stop container** (optional, for clean state) + ```bash + docker-compose down + ``` + +## Example Invocations + +```bash +# Default test (glm-5-search on port 3364) +/mimocode:command docker-test + +# Test kimi-k2.5 model +/mimocode:command docker-test kimi-k2.5 + +# Test with mock provider (no real API keys needed) +/mimocode:command docker-test glm-5-search --mock + +# Full rebuild without cache +/mimocode:command docker-test --no-cache +``` + +## Notes + +- The container exposes port 3364 internally; docker-compose.yml maps it to host port 3364 by default +- VNC is available at `vnc://localhost:3365` (password: `vampir`) when `ENABLE_VNC=1` +- Real credentials are loaded from `/home/yury/Documents/FreeGLMKimiAPI/data/auth.json` (mounted in container) +- `ZAI_BROWSER_ENGINE=cloak` is set in Dockerfile ENV for GLM browser fallback +- Node 22 and system chromium are pre-installed in the image \ No newline at end of file diff --git a/.mimocode/commands/local-test.md b/.mimocode/commands/local-test.md new file mode 100644 index 0000000..f126157 --- /dev/null +++ b/.mimocode/commands/local-test.md @@ -0,0 +1,100 @@ +--- +description: Start FreeGLMKimiAPI server locally and test with a curl request to /v1/chat/completions +agent: main +--- + +# Local Test Command + +Start the FreeGLMKimiAPI server locally (with optional mock provider) and run a test request against the OpenAI-compatible endpoint. + +## Usage + +```bash +/mimocode:command local-test [model] [port] [--mock] [--bg] +``` + +## Parameters + +- `model` (optional): Model to test. Default: `glm-5-search` +- `port` (optional): Server port. Default: `9766` (from config.js) +- `--mock`: Set `MOCK_PROVIDER=1` to test without real API credentials +- `--bg`: Run server in background and leave it running after test + +## Procedure + +1. **Stop any existing server on the port** + ```bash + pkill -f "node src/server.js" 2>/dev/null || true + sleep 1 + ``` + +2. **Start the server** + ```bash + cd /home/yury/Documents/FreeGLMKimiAPI + + # Environment setup + export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + export PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + export CHROME_PATH=/usr/bin/chromium + export ZAI_BROWSER_ENGINE=cloak + + if [ "${MOCK:-false}" = "true" ]; then + export MOCK_PROVIDER=1 + fi + + PORT=${PORT:-9766} node src/server.js & + SERVER_PID=$! + sleep 3 + ``` + +3. **Verify server is running** + ```bash + ps aux | grep "server.js" | grep -v grep + ``` + +4. **Run test request** + ```bash + curl -X POST http://localhost:${PORT:-9766}/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "${MODEL:-glm-5-search}", + "messages": [{"role": "user", "content": "hello"}], + "stream": false + }' + ``` + +5. **Stop server** (unless `--bg` specified) + ```bash + if [ "${BG:-false}" != "true" ]; then + kill $SERVER_PID 2>/dev/null || true + fi + ``` + +## Example Invocations + +```bash +# Default test (glm-5-search on port 9766) +/mimocode:command local-test + +# Test kimi-k2.5 model +/mimocode:command local-test kimi-k2.5 + +# Test with mock provider (no real API keys needed) +/mimocode:command local-test glm-5-search --mock + +# Start server in background and leave running +/mimocode:command local-test --bg + +# Test specific port +/mimocode:command local-test glm-5-search 9767 +``` + +## Notes + +- Server runs on port 9766 by default (configured in `src/config.js`) +- Real credentials loaded from `/home/yury/Documents/FreeGLMKimiAPI/data/auth.json` +- `ZAI_BROWSER_ENGINE=cloak` required for GLM browser fallback to work +- System chromium at `/usr/bin/chromium` must be installed (Node 22+) +- First GLM browser request takes ~100s (chromium download + warmup); subsequent requests faster +- Kimi provider works end-to-end with real credentials (~2s response) \ No newline at end of file diff --git a/.mimocode/plans/1786118231225-silent-harbor.md b/.mimocode/plans/1786118231225-silent-harbor.md new file mode 100644 index 0000000..20b8069 --- /dev/null +++ b/.mimocode/plans/1786118231225-silent-harbor.md @@ -0,0 +1,19 @@ +# Plan: Fix Identical Responses from GLM and Kimi Models + +## Root Cause +The model names being sent include a `glmkimi-free/` prefix (e.g., `glmkimi-free/glm-5-search`, `glmkimi-free/kimi-k2.6-search`). The `resolveModel()` function in `src/config.js:67-73` only checks for models starting with `glm` or `kimi` (case-insensitive). With the prefix, neither condition matches, so both requests fall back to `DEFAULT_MODEL` (which is `kimi-k2.5` - a Kimi model). Both requests route to the Kimi provider, producing identical responses. + +## Solution +Modify `resolveModel()` in `src/config.js` to strip known gateway prefixes before matching, OR add the prefix patterns to the matching logic. + +## Files to Modify +- `src/config.js` - Update `resolveModel()` function + +## Implementation Approach +Add prefix stripping logic at the start of `resolveModel()` to handle common gateway prefixes like `glmkimi-free/`. This ensures the actual model ID (`glm-5-search`, `kimi-k2.6-search`) is used for provider resolution. + +## Verification +1. Send request with `model: "glmkimi-free/glm-5-search"` → should route to GLM provider (ZaiProvider since GLM_BACKEND=zai) +2. Send request with `model: "glmkimi-free/kimi-k2.6-search"` → should route to Kimi provider +3. Verify responses are different (from different backends) +4. Test without prefix still works \ No newline at end of file diff --git a/.mimocode/skills/freeglmkimi-deep-research-check/SKILL.md b/.mimocode/skills/freeglmkimi-deep-research-check/SKILL.md new file mode 100644 index 0000000..fd5fd3d --- /dev/null +++ b/.mimocode/skills/freeglmkimi-deep-research-check/SKILL.md @@ -0,0 +1,126 @@ +--- +name: freeglmkimi-deep-research-check +description: Check FreeGLMKimiAPI provider code for GLM deep-research (glm-5-deepresearch) support — verifies Z.ai backend passes deep_research flag and legacy chatglm backend handles it correctly +--- + +# FreeGLMKimiAPI GLM Deep-Research Support Check + +A focused code-review sub-workflow for verifying that the `glm-5-deepresearch` model actually invokes deep research mode in both GLM backends (Z.ai and legacy chatglm). + +## When to Use + +- After adding/modifying model configs in `src/config.js` +- After changes to `src/providers/zai.js` or `src/providers/glm.js` +- When user reports "glm-5-deepresearch cannot run Internet search" or similar +- Periodic validation that deep research flag propagates correctly + +## Procedure + +### 1. Check Model Registry (`src/config.js`) + +```bash +grep -A 10 "glm-5-deepresearch" src/config.js +``` + +**Expected:** Model defined with `deepResearch: true`, `webSearch: true`, `provider: 'glm'` + +```javascript +"glm-5-deepresearch": { + provider: "glm", + thinking: false, + webSearch: true, + deepResearch: true +} +``` + +**Also verify:** `resolveModel()` regex detection for unknown model names: +```bash +grep -A 5 "/research/i" src/config.js +``` +Should have: `/research/i` → `deepResearch: true` + +### 2. Check Z.ai Backend (`src/providers/zai.js`) + +```bash +grep -n "deep_research\|deepResearch\|features" src/providers/zai.js +``` + +**Critical check:** In `buildZaiRequest()` function, the `features` object must include `deep_research`: +```javascript +// Line ~115 in buildZaiRequest() +const features = { + web_search: !!webSearch, + deep_research: !!deepResearch // ← THIS MUST BE PRESENT +}; +``` + +**Known gap (as of ses_07ddc2e5):** Z.ai backend only sends `web_search`, missing `deep_research` field entirely. + +### 3. Check Legacy chatglm Backend (`src/providers/glm.js`) + +```bash +grep -n "deep_research\|chat_mode" src/providers/glm.js +``` + +**Expected:** Legacy backend correctly uses `chat_mode` parameter: +```javascript +chat_mode: modelCfg.deepResearch ? 'deep_research' : (modelCfg.webSearch ? 'search' : 'normal') +``` + +### 4. Check Provider Selection (`src/server.js`) + +```bash +grep -n "providerFor\|zai\|glm" src/server.js | head -20 +``` + +**Verify:** `providerFor()` defaults to Z.ai for GLM models. Deep research only works with legacy backend unless Z.ai is fixed. + +### 5. Run Council Review (Optional) + +If backend is running on port 8001 with model set `code`: +```bash +python3 council_review.py --model code --files \ + "src/config.js" \ + "src/providers/zai.js" \ + "src/providers/glm.js" \ + "src/server.js" +``` + +## Expected Findings + +| Component | Status | Action if Missing | +|-----------|--------|-------------------| +| `config.js` model registry | ✅ Usually correct | Add `deepResearch: true` to model | +| `config.js` regex detection | ✅ Usually correct | Add `/research/i` → `deepResearch: true` | +| `zai.js` features.deep_research | ❌ **Known gap** | Add `deep_research: !!deepResearch` to features | +| `glm.js` chat_mode | ✅ Correct | No action needed | +| `server.js` provider selection | ✅ Z.ai default | Document: deep research needs legacy backend | + +## Fix for Z.ai Backend + +In `src/providers/zai.js`, modify `buildZaiRequest()`: + +```javascript +// FIND (around line 115): +const features = { + web_search: !!webSearch, +}; + +// REPLACE WITH: +const features = { + web_search: !!webSearch, + deep_research: !!deepResearch, // Add this line +}; +``` + +Also ensure `deepResearch` is extracted from `modelCfg` and passed to `buildZaiRequest()`. + +## Stopping Condition + +Check complete when all 4 files verified and any gaps documented with fix location. + +## Related + +- Global skill: `council-code-review` (for multi-model deliberation on fixes) +- Command: `local-test` (to test deep research after fix) +- Project memory: `MEMORY.md` (if created for FreeGLMKimiAPI) \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a2f704 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,82 @@ +# 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. +# VNC support for manual captcha solving: Xvfb + x11vnc + fluxbox + noVNC +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + ca-certificates \ + fonts-liberation \ + fonts-noto-color-emoji \ + tini \ + xvfb \ + x11vnc \ + fluxbox \ + websockify \ + python3 \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install noVNC for web-based VNC access +RUN git clone --depth 1 --branch v1.5.0 https://github.com/novnc/noVNC.git /opt/noVNC \ + && ln -s /opt/noVNC/vnc.html /opt/noVNC/index.html + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ + PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + CHROME_PATH=/usr/bin/chromium \ + ZAI_BROWSER_ENGINE=cloak \ + # VNC settings + DISPLAY=:99 \ + VNC_PORT=5900 \ + NOVNC_PORT=6080 \ + VNC_PASSWORD=freeglmkimi \ + SCREEN_WIDTH=1366 \ + SCREEN_HEIGHT=768 \ + SCREEN_DEPTH=24 + +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 +COPY start-vnc.sh /usr/local/bin/start-vnc.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/start-vnc.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 \ + # VNC: set ENABLE_VNC=1 to start VNC server for manual captcha solving + ENABLE_VNC=0 + +EXPOSE 3364 5900 6080 + +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..5e1632d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,86 @@ +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" + + # Allow Chromium sandbox to work (alternative to --no-sandbox) + cap_add: + - SYS_ADMIN + + ports: + - "3364:3364" + # VNC for manual captcha solving (enable with ENABLE_VNC=1) + - "3365:5900" + - "3366:6080" + + 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 + # VNC settings (uncomment to enable) + ENABLE_VNC: 1 + VNC_PASSWORD: password + ZAI_BROWSER_HEADLESS: 0 # Run browser in visible mode for VNC ( 0-visible, 1-hidden ) + ZAI_BROWSER_NO_SANDBOX: 0 # Set to 0 to disable --no-sandbox flag if Chromium complains + # How long an idle conversation keeps its provider chat id, and how many + # turns one chat id may grow to, before the next turn starts fresh. + # SESSION_TTL_MS: 7200000 + # SESSION_MAX_DEPTH: 100 + # 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: "" + # 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: '{"connect-protocol-version":"1","x-msh-version":"2.0.0"}' + DEBUG_KIMI: 1 + # Auto-discover-and-retire model catalog. See docs/model-lifecycle.md. + # MODEL_CATALOG_PATH: /app/data/models.json + MODEL_RETIRE_AFTER_FAILURES: 3 + # MODEL_ERROR_PATTERNS_EXTRA: '[]' + # Kimi only — unconfirmed guesses for K2.6/K2.7-Code/K3, see + # scripts/kimi_dump_curl_headers.js: + # KIMI_SCENARIOS: '{}' + # Z.ai browser fallback (Puppeteer/CloakBrowser). See docs/browser-fallback.md. + ZAI_BROWSER_ENGINE: cloak + ZAI_BROWSER_COMPLETION_TIMEOUT: 300000 + ZAI_BROWSER_HEARTBEAT_MS: 15000 + # Humanize settings (cloakbrowser) - simulate human behavior to avoid detection + ZAI_BROWSER_HUMANIZE: 1 + ZAI_BROWSER_HUMAN_PRESET: careful + # Locale/timezone matching your IP (Canada/Toronto) + ZAI_TIMEZONE: America/Toronto + ZAI_TIMEZONE_OFFSET: "-240" + # Close the idle browser after 10 min with zero requests (0 disables): + ZAI_BROWSER_IDLE_CLOSE_MS: 600000 + + # 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..cb0cc71 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,38 @@ +#!/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 install" +else + echo "[entrypoint] Installing dependencies (first run, or package.json/lock/Node/arch/entrypoint changed)..." + npm install + echo "$CURRENT_HASH" > "$SENTINEL" +fi + +# Start VNC if enabled (for manual captcha solving) +if [ "${ENABLE_VNC}" = "1" ] || [ "${ENABLE_VNC}" = "true" ]; then + echo "[entrypoint] Starting VNC server..." + /usr/local/bin/start-vnc.sh & + VNC_PID=$! + # Give VNC a moment to start + sleep 3 + echo "[entrypoint] VNC started (pid: ${VNC_PID})" +fi + +exec "$@" 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/docs/browser-fallback.md b/docs/browser-fallback.md new file mode 100644 index 0000000..ac5334f --- /dev/null +++ b/docs/browser-fallback.md @@ -0,0 +1,141 @@ +# Z.ai browser fallback: the CPU/network leak, and how to read the new logs + +`src/providers/zaiBrowser.js` is a Puppeteer/CloakBrowser-driven fallback +used when the direct HTTP path to `chat.z.ai` (`src/providers/zai.js`) +either fails outright or comes back looking like a captcha challenge +(`isZaiCaptchaError`), for accounts with `browser_fallback: true`. It drives +a real headless Chromium instance to type the prompt into the actual chat +UI and read the response back off the page, the same way a human would. + +## The bug: every fallback leaked a browser tab forever + +`completeViaUi()` — the function that does the UI simulation — opened a +**new Chromium tab** on every single call (`this.newPage()`), but never +closed it. It just reassigned `this.page` to point at the new tab, silently +leaving whatever tab was open before dereferenced but *still running* in +the browser process. + +Each leaked tab is a live, loaded `chat.z.ai` page: its own Chromium +renderer process, its own JS execution, its own network activity +(WebSocket/polling/telemetry) that never gets torn down. One fallback call +now and then wouldn't be very noticeable. But every `glm-*-search` request +that hits a captcha — which per prior investigation is a known recurring +issue — triggers exactly this path. Repeated over time, that's an +unbounded, ever-growing pile of live browser tabs, which lines up exactly +with **"100% CPU and quite a lot of network traffic" that persists** rather +than spiking and settling. + +### The fix + +`completeViaUi()` now opens a page it tracks (`openScratchPage()`) and +**always closes it in a `finally` block** — on success, on a normal +completion error, and on timeout. It also no longer touches `this.page` at +all; that field is reserved for the separate, persistent "home" page used +by the lightweight direct-fetch-via-page.evaluate path +(`completeRequest()`), which was never the source of the leak and is +correctly reused across calls (relaunching a whole browser per request +would be far more expensive than the leak it wasn't causing). + +`tests/zaiBrowser.test.js` proves this with real regression tests (not +just "the code changed and tests still pass") — reverting the fix and +re-running them fails 4 of the 13 tests immediately; see the file's +comments for the exact scenarios covered: success, timeout, and +submission-failure (captcha/login wall) all now close their page, and the +persistent home page is confirmed untouched throughout. + +## Defense in depth: three more layers, since this couldn't be reproduced live + +This proxy's sandbox has no network path to `chat.z.ai`, so none of this +could be verified against the real site. On top of the concrete fix above, +three more layers were added so the *next* leak (if there ever is one) is +caught immediately instead of running silently for weeks: + +1. **Leak canary.** `openScratchPage()` warns loudly + (`N scratch pages open at once`) if more than one is ever open + simultaneously — should never happen now, so if it does, something + regressed or there's real concurrent load worth knowing about. +2. **Idle auto-close.** If nothing has used the browser in + `ZAI_BROWSER_IDLE_CLOSE_MS` (default 10 min), it's closed outright and + relaunches lazily on the next request — the same clean state as a fresh + container start. Set to `0` to disable. This bounds worst-case resource + usage from *any* leak, known or not, without needing a container + restart. +3. **Docker-safe Chromium flags.** `--disable-gpu`, + `--disable-software-rasterizer`, `--disable-dev-shm-usage`, and several + `--disable-*-backgrounding`/`--disable-*-throttling` flags were added to + the launch args (`puppeteerLaunchArgs()`). Headless Chrome with no real + GPU commonly falls back to CPU-bound software rendering + (SwiftShader) without `--disable-gpu`, which alone can look like + sustained high CPU on a page that's just sitting open — plausible as a + *contributing* factor even independent of the leak. (`shm_size: "1gb"` + was already set in `docker-compose.yml` from an earlier fix, so the + classic "Chrome crashes in Docker" `/dev/shm` issue was already covered.) + +## What the new logs look like + +Every request through the fallback now logs its lifecycle at `info` level +(prefixed `[zai-browser]`), instead of the previous near-silence: + +``` +[zai-browser] completeAndParse #1: starting (in-page fetch first) +[zai-browser] completeAndParse #1: in-page fetch returned HTTP 403 — falling back to full UI simulation +[zai-browser] no live page — launching (engine=puppeteer, headless=true) +[zai-browser] launched puppeteer-extra Chromium in 812ms (headless=true) +[zai-browser] completeViaUi: opened scratch page (1 open) +[zai-browser] completeViaUi: navigated to https://chat.z.ai in 1204ms +[zai-browser] completeViaUi: prompt submitted, waiting for a response... +[zai-browser] completeViaUi: still waiting for a completion response (15s elapsed) +[zai-browser] completeViaUi: still waiting for a completion response (30s elapsed) +[zai-browser] completeViaUi: got a completion response after 34s (ok=true, status=200) +[zai-browser] completeViaUi: closed scratch page (0 still open) +``` + +A hung/leaking instance would instead show the heartbeat line repeating +past the completion timeout with `(N still open)` climbing on every +fallback instead of returning to `0` — that's the smoking gun to look for +if this ever regresses. `1 scratch pages open at once` warnings would also +appear immediately. + +The heartbeat interval is `ZAI_BROWSER_HEARTBEAT_MS` (default 15s). + +## Live introspection without grepping logs + +- `GET /admin/browser` → `{ engine, running, openScratchPages, launchedAt, + lastUsedAt, idleForMs, requestCount }`. `openScratchPages` should be `0` + between requests, always. Just checking this never launches a browser by + itself. +- `POST /admin/browser/close` → force-closes it right now (safe no-op if + nothing is running) — a manual reset that doesn't require restarting the + container. + +## New env vars + +See `.env.example` / `docker-compose.yml` for the full list with defaults: +`ZAI_BROWSER_COMPLETION_TIMEOUT`, `ZAI_BROWSER_HEARTBEAT_MS`, +`ZAI_BROWSER_IDLE_CLOSE_MS`, `ZAI_BROWSER_IDLE_CHECK_MS`, +`ZAI_BROWSER_PUPPETEER_ARGS`. + +## Testing + +```bash +node --test tests/zaiBrowser.test.js # 13 tests, all using fake page objects — no real Chromium needed +node --test tests/server.test.js # includes GET /admin/browser + POST /admin/browser/close +``` + +`tests/zaiBrowser.test.js` stubs `ensurePage`/`newPage` on a real +`ZaiBrowserClient` instance with fake page objects (plain JS objects with +spy-able `close()`/`on()`/`off()`), so the actual leak-prone code path in +`completeViaUi()` runs for real in tests — nothing about the fix itself is +mocked, only the Chromium process underneath it. + +## If this is still happening after deploying this fix + +Watch `docker logs freeglmkimi -f` for the `[zai-browser]` lines above +during/after a `glm-*-search` request, and check `GET +/admin/browser` a few minutes later. If `openScratchPages` is reliably back +to `0` and CPU is still high with the browser reported as `running: false`, +the browser isn't the cause and it's worth looking elsewhere (e.g. the +model-catalog/session-store changes from earlier passes, or something +outside this proxy entirely). If `openScratchPages` is ever `> 0` for more +than a few minutes with no fallback in flight, that's a new leak — please +share the surrounding `[zai-browser]` log lines. diff --git a/model-catalog.json b/model-catalog.json new file mode 100644 index 0000000..166a4a9 --- /dev/null +++ b/model-catalog.json @@ -0,0 +1,19 @@ +{ + "models": [ + { + "id": "glm-5-search", + "provider": "glm", + "thinking": false, + "webSearch": true, + "deepResearch": false, + "status": "active", + "source": "static", + "consecutiveFailures": 0, + "firstSeenAt": 1785981783503, + "lastSeenAt": 1785981783503, + "lastErrorAt": 0, + "lastError": "", + "retiredAt": 0 + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 437b146..cf35da6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,958 +1,1023 @@ -{ - "name": "free-glm-kimi-api", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "free-glm-kimi-api", - "version": "0.1.0", - "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" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@puppeteer/browsers": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.4.tgz", - "integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==", - "license": "Apache-2.0", - "dependencies": { - "modern-tar": "^0.7.6", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/main-cli.js" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "proxy-agent": ">=8.0.1" - }, - "peerDependenciesMeta": { - "proxy-agent": { - "optional": true - } - } - }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/arr-union": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/chromium-bidi": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", - "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "engines": { - "node": ">=20.19.0 <22.0.0 || >=22.12.0" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cloakbrowser": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/cloakbrowser/-/cloakbrowser-0.3.31.tgz", - "integrity": "sha512-HtwlpwFKd5FJh4jjCS5awsScWIKB9ofNSTkHLSEh/9JH0Hv1L79zu0MExSMOCP1nzOsgrohbacwwWocuKloQ1A==", - "license": "MIT", - "dependencies": { - "tar": "^7.0.0" - }, - "bin": { - "cloakbrowser": "dist/cli.js" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "mmdb-lib": ">=2.0.0", - "playwright-core": ">=1.53.0", - "puppeteer-core": ">=21.0.0", - "socks-proxy-agent": ">=10.0.0" - }, - "peerDependenciesMeta": { - "mmdb-lib": { - "optional": true - }, - "playwright-core": { - "optional": true - }, - "puppeteer-core": { - "optional": true - }, - "socks-proxy-agent": { - "optional": true - } - } - }, - "node_modules/clone-deep": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", - "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", - "license": "MIT", - "dependencies": { - "for-own": "^0.1.3", - "is-plain-object": "^2.0.1", - "kind-of": "^3.0.2", - "lazy-cache": "^1.0.3", - "shallow-clone": "^0.1.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/devtools-protocol": { - "version": "0.0.1624250", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz", - "integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==", - "license": "BSD-3-Clause" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/for-own": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", - "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", - "license": "MIT", - "dependencies": { - "for-in": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT" - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "license": "MIT", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/lazy-cache": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", - "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/merge-deep": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", - "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", - "license": "MIT", - "dependencies": { - "arr-union": "^3.1.0", - "clone-deep": "^0.2.4", - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, - "node_modules/mixin-object": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", - "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", - "license": "MIT", - "dependencies": { - "for-in": "^0.1.3", - "is-extendable": "^0.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mixin-object/node_modules/for-in": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", - "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/modern-tar": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", - "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.1.0.tgz", - "integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==", - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "3.0.4", - "chromium-bidi": "16.0.1", - "devtools-protocol": "0.0.1624250", - "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.2", - "ws": "^8.21.0" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/puppeteer-extra": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", - "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.0", - "debug": "^4.1.1", - "deepmerge": "^4.2.2" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "@types/puppeteer": "*", - "puppeteer": "*", - "puppeteer-core": "*" - }, - "peerDependenciesMeta": { - "@types/puppeteer": { - "optional": true - }, - "puppeteer": { - "optional": true - }, - "puppeteer-core": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", - "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.0", - "debug": "^4.1.1", - "merge-deep": "^3.0.1" - }, - "engines": { - "node": ">=9.11.2" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-stealth": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", - "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "puppeteer-extra-plugin": "^3.2.3", - "puppeteer-extra-plugin-user-preferences": "^2.4.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", - "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^10.0.0", - "puppeteer-extra-plugin": "^3.2.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-user-preferences": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", - "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "deepmerge": "^4.2.2", - "puppeteer-extra-plugin": "^3.2.3", - "puppeteer-extra-plugin-user-data-dir": "^2.4.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/shallow-clone": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", - "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.1", - "kind-of": "^2.0.1", - "lazy-cache": "^0.2.3", - "mixin-object": "^2.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shallow-clone/node_modules/kind-of": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", - "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", - "license": "MIT", - "dependencies": { - "is-buffer": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shallow-clone/node_modules/lazy-cache": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", - "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/typed-query-selector": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", - "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", - "license": "MIT" - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", - "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", - "license": "Apache-2.0" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} +{ + "name": "free-glm-kimi-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "free-glm-kimi-api", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "cloakbrowser": "^0.3.31", + "eventsource-parser": "^3.0.6", + "playwright-core": "^1.60.0", + "puppeteer": "^25.5.0", + "puppeteer-core": "^25.1.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.1.0.tgz", + "integrity": "sha512-RDLpio3fH/qrj5k4DVY6eyiN8tCS0Zovd/6jW//n605oeqkWcUjn+3k+9ZtZBnbwMpsu0F7xDIiKXvVmG5c5Bw==", + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cloakbrowser": { + "version": "0.3.32", + "resolved": "https://registry.npmjs.org/cloakbrowser/-/cloakbrowser-0.3.32.tgz", + "integrity": "sha512-i3629TUqfCZxSOTLRpmpVj/GGDsRatOwmLEpcyJQbvYtp+mGBtD5QSygymFMA9V8210XElBIA7vY+2gGg9hV1Q==", + "license": "MIT", + "dependencies": { + "tar": "^7.0.0" + }, + "bin": { + "cloakbrowser": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "mmdb-lib": ">=2.0.0", + "playwright-core": ">=1.53.0", + "puppeteer-core": ">=21.0.0", + "socks-proxy-agent": ">=10.0.0" + }, + "peerDependenciesMeta": { + "mmdb-lib": { + "optional": true + }, + "playwright-core": { + "optional": true + }, + "puppeteer-core": { + "optional": true + }, + "socks-proxy-agent": { + "optional": true + } + } + }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "license": "MIT", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1653615", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz", + "integrity": "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==", + "license": "BSD-3-Clause" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "license": "MIT", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/modern-tar": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.7.tgz", + "integrity": "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/puppeteer": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.5.0.tgz", + "integrity": "sha512-qpp73xblxNr+bF0nSXTodM3v+zcK5IPo/GkjLsdUqRf/qpLJp/1KxBUbstoMMnwnPw9xD6OMei8kmYf6CLWfGw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.1.0", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.5.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.5.0.tgz", + "integrity": "sha512-XPNT0dQJtphqQ4I29zxlG4IIPbg1iEHAQKWuQgtMJGXjACV77pZSmJvDi51IIIfd+DTKICcopJwUx4upVQ4XbA==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.1.0", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-extra": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", + "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "@types/puppeteer": "*", + "puppeteer": "*", + "puppeteer-core": "*" + }, + "peerDependenciesMeta": { + "@types/puppeteer": { + "optional": true + }, + "puppeteer": { + "optional": true + }, + "puppeteer-core": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/rimraf": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", + "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", + "license": "ISC", + "dependencies": { + "glob": "^9.2.0" + }, + "bin": { + "rimraf": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index 8a55a5d..8384d07 100644 --- a/package.json +++ b/package.json @@ -1,38 +1,43 @@ -{ - "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.5.0", + "puppeteer-core": "^25.1.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2" + }, + "overrides": { + "rimraf": "^4.0.0" + } +} 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/scripts/zai_browser_auth.js b/scripts/zai_browser_auth.js index b0b80d6..e8b93e9 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'; @@ -65,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 }); @@ -96,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'); @@ -117,6 +133,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 + }); } diff --git a/src/config.js b/src/config.js index 52f1f04..3215458 100644 --- a/src/config.js +++ b/src/config.js @@ -1,41 +1,86 @@ -import path from 'path'; -import { AccountManager } from './accounts.js'; - -export const WATERMARK = 't.me/forgetmeai'; -export const PORT = Number(process.env.PORT || 9766); -export const HOST = process.env.HOST || '0.0.0.0'; -export const DEFAULT_PROVIDER = process.env.DEFAULT_PROVIDER || 'kimi'; -export const DEFAULT_MODEL = process.env.DEFAULT_MODEL || (DEFAULT_PROVIDER === 'glm' ? 'glm-5' : 'kimi-k2.5'); -export const MOCK_PROVIDER = ['1','true','yes','on'].includes(String(process.env.MOCK_PROVIDER || '').toLowerCase()); -export const AUTH_PATH = process.env.AUTH_PATH || path.join(process.cwd(), 'auth.json'); -export const API_KEYS = String(process.env.API_KEYS || '').split(',').map(s => s.trim()).filter(Boolean); +import path from 'path'; +import { AccountManager } from './accounts.js'; + +export const WATERMARK = 't.me/forgetmeai'; +export const PORT = Number(process.env.PORT || 9766); +export const HOST = process.env.HOST || '0.0.0.0'; +export const DEFAULT_PROVIDER = process.env.DEFAULT_PROVIDER || 'kimi'; +export const DEFAULT_MODEL = process.env.DEFAULT_MODEL || (DEFAULT_PROVIDER === 'glm' ? 'glm-5' : 'kimi-k2.5'); +export const MOCK_PROVIDER = ['1','true','yes','on'].includes(String(process.env.MOCK_PROVIDER || '').toLowerCase()); +export const AUTH_PATH = process.env.AUTH_PATH || path.join(process.cwd(), 'data/auth.json'); +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(); - -export const MODELS = { - 'glm-5': { provider: 'glm', thinking: false, webSearch: false, deepResearch: false }, - 'glm-5-thinking': { provider: 'glm', thinking: true, webSearch: false, deepResearch: false }, - 'glm-5-search': { provider: 'glm', thinking: false, webSearch: true, deepResearch: false }, - 'glm-5-deepresearch': { provider: 'glm', thinking: false, webSearch: true, deepResearch: true }, - 'kimi-k2.5': { provider: 'kimi', thinking: false, webSearch: false }, - 'kimi-k2.5-thinking': { provider: 'kimi', thinking: true, webSearch: false }, - 'kimi-k2.5-search': { provider: 'kimi', thinking: false, webSearch: true }, -}; - -export function resolveModel(model = DEFAULT_MODEL) { - const id = String(model || DEFAULT_MODEL); - if (MODELS[id]) return { id, ...MODELS[id] }; - if (id.toLowerCase().startsWith('glm')) return { id, provider: 'glm', thinking: /think|zero|reason/i.test(id), webSearch: /search|web/i.test(id), deepResearch: /research/i.test(id) }; - if (id.toLowerCase().startsWith('kimi')) return { id, provider: 'kimi', thinking: /think|r1|reason/i.test(id), webSearch: /search|web/i.test(id) }; - return { id: DEFAULT_MODEL, ...MODELS[DEFAULT_MODEL] }; -} - -export function loadAccounts() { - return new AccountManager({ authPath: AUTH_PATH, env: process.env }).rawList(); -} - -export function requireProxyAuth(req) { - if (API_KEYS.length === 0) return true; - const h = req.headers.authorization || ''; - const token = h.replace(/^Bearer\s+/i, '').trim(); - return API_KEYS.includes(token); -} +// How long a conversation can go idle before its provider-side chat_id is +// dropped and the next turn starts a brand new chat (default 2h), and how +// many turns a single chat_id chain is allowed to grow to before the same +// reset happens (default 100). See docs/sessions.md. +export const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS || 2 * 60 * 60 * 1000); +export const SESSION_MAX_DEPTH = Number(process.env.SESSION_MAX_DEPTH || 100); +// Model lifecycle (see docs/model-lifecycle.md): where the discovered/retired +// model catalog persists across restarts (unset = in-memory only), how many +// consecutive model-not-found-shaped failures before a model is auto-retired +// from GET /v1/models, and extra regex patterns (JSON array of strings) to +// recognize a provider's "that model doesn't exist" error in addition to the +// built-in ones in src/modelCatalog.js. +export const MODEL_CATALOG_PATH = process.env.MODEL_CATALOG_PATH || ''; +export const MODEL_RETIRE_AFTER_FAILURES = Number(process.env.MODEL_RETIRE_AFTER_FAILURES || 3); +export const MODEL_ERROR_PATTERNS_EXTRA = process.env.MODEL_ERROR_PATTERNS_EXTRA || ''; + +// Baseline catalog. This is a *seed*, not a source of truth — models get +// added automatically the first time a request against an unlisted id +// actually succeeds, and retired automatically after repeated +// model-not-found-shaped failures (see src/modelCatalog.js / +// docs/model-lifecycle.md). The glm-5.1/5.2 and kimi-k2.6/k2.7-code/k3 rows +// below reflect the real public model lineup as of Aug 2026 (GLM-5.1/5.2: +// Z.ai's own docs and chat.z.ai's page title already advertise GLM-5.1 +// alongside GLM-5; Kimi K2.5 was publicly reported retired May 25 2026, +// superseded by K2.6, then the coding-focused K2.7-Code, then K3 on Jul 16 +// 2026) — added so day-one /v1/models isn't stuck on a stale snapshot, but +// NOT verified against the live provider from here. The original glm-5*/ +// kimi-k2.5* rows and DEFAULT_MODEL are left exactly as they were: changing +// a *default* on an unverified guess is worse than leaving a possibly-stale +// one, since a wrong guess fails differently than a stale-but-previously- +// working id. See docs/model-lifecycle.md for the full reasoning and how to +// verify/correct any of this against your own accounts. +export const MODELS = { + 'glm-5': { provider: 'glm', thinking: false, webSearch: false, deepResearch: false }, + 'glm-5-thinking': { provider: 'glm', thinking: true, webSearch: false, deepResearch: false }, + 'glm-5-search': { provider: 'glm', thinking: false, webSearch: true, deepResearch: false }, + 'glm-5-deepresearch': { provider: 'glm', thinking: false, webSearch: true, deepResearch: true }, + 'glm-5.1': { provider: 'glm', thinking: false, webSearch: false, deepResearch: false }, + 'glm-5.1-thinking': { provider: 'glm', thinking: true, webSearch: false, deepResearch: false }, + 'glm-5.1-search': { provider: 'glm', thinking: false, webSearch: true, deepResearch: false }, + 'glm-5.2': { provider: 'glm', thinking: false, webSearch: false, deepResearch: false }, + 'glm-5.2-thinking': { provider: 'glm', thinking: true, webSearch: false, deepResearch: false }, + 'glm-5.2-search': { provider: 'glm', thinking: false, webSearch: true, deepResearch: false }, + 'kimi-k2.5': { provider: 'kimi', thinking: false, webSearch: false }, + 'kimi-k2.5-thinking': { provider: 'kimi', thinking: true, webSearch: false }, + 'kimi-k2.5-search': { provider: 'kimi', thinking: false, webSearch: true }, + 'kimi-k2.6': { provider: 'kimi', thinking: false, webSearch: false }, + 'kimi-k2.6-thinking': { provider: 'kimi', thinking: true, webSearch: false }, + 'kimi-k2.6-search': { provider: 'kimi', thinking: false, webSearch: true }, + 'kimi-k2.7-code': { provider: 'kimi', thinking: false, webSearch: false }, + 'kimi-k3': { provider: 'kimi', thinking: true, webSearch: false }, // K3 reportedly reasons by default ("thinking mode") rather than as a separate variant — unverified against this proxy's scenario plumbing, see docs/model-lifecycle.md + 'kimi-k3-search': { provider: 'kimi', thinking: true, webSearch: true }, +}; + +export function resolveModel(model = DEFAULT_MODEL) { + let id = String(model || DEFAULT_MODEL); + // Strip known gateway prefixes (e.g., "glmkimi-free/") + id = id.replace(/^glmkimi-free\//i, ''); + if (MODELS[id]) return { id, ...MODELS[id] }; + if (id.toLowerCase().startsWith('glm')) return { id, provider: 'glm', thinking: /think|zero|reason/i.test(id), webSearch: /search|web/i.test(id), deepResearch: /research/i.test(id) }; + if (id.toLowerCase().startsWith('kimi')) return { id, provider: 'kimi', thinking: /think|r1|reason/i.test(id), webSearch: /search|web/i.test(id) }; + return { id: DEFAULT_MODEL, ...MODELS[DEFAULT_MODEL] }; +} + +export function loadAccounts() { + return new AccountManager({ authPath: AUTH_PATH, env: process.env }).rawList(); +} + +export function requireProxyAuth(req) { + if (API_KEYS.length === 0) return true; + const h = req.headers.authorization || ''; + const token = h.replace(/^Bearer\s+/i, '').trim(); + return API_KEYS.includes(token); +} diff --git a/src/modelCatalog.js b/src/modelCatalog.js new file mode 100644 index 0000000..d076566 --- /dev/null +++ b/src/modelCatalog.js @@ -0,0 +1,177 @@ +import fs from 'fs'; +import path from 'path'; + +// Patterns that indicate a provider rejected the *model itself* (wrong/dead +// model id, dead assistant_id, etc.) rather than a transient/account/session +// problem. Deliberately narrow: every pattern requires the literal word +// "model" (or "assistant") next to the not-found/invalid phrasing. This is +// what keeps it from ever matching Kimi's real +// `REASON_CHAT_MESSAGE_NOT_FOUND` / "Chat message not found" error, which +// contains "not found" but never "model" or "assistant" anywhere near it — +// see tests/modelCatalog.test.js, which asserts this against the exact +// string this proxy has actually produced in production. +export const MODEL_ERROR_PATTERNS = [ + /\bmodel[_\s-]?not[_\s-]?found\b/i, + /\bunknown\s+model\b/i, + /\bunsupported\s+model\b/i, + /\binvalid\s+model\b/i, + /\bno\s+such\s+model\b/i, + /\bmodel\b[^\n]{0,60}\b(is\s+)?(not\s+available|does\s+not\s+exist|has\s+been\s+retired|deprecated|no\s+longer\s+(supported|available))\b/i, + /\binvalid\s+assistant[_\s-]?id\b/i, + /\bassistant\s+not\s+found\b/i, +]; + +function compileExtraPatterns(extra) { + if (!extra) return []; + const list = Array.isArray(extra) ? extra : (() => { try { return JSON.parse(extra); } catch { return []; } })(); + return list.map(p => { try { return new RegExp(p, 'i'); } catch { return null; } }).filter(Boolean); +} + +export function isModelError(err, extraPatterns = []) { + const msg = String(err?.message || err || ''); + return MODEL_ERROR_PATTERNS.some(re => re.test(msg)) || extraPatterns.some(re => re.test(msg)); +} + +function modelKey(provider, id) { return `${provider}:${id}`; } + +export class ModelCatalog { + constructor({ staticModels = {}, catalogPath = '', retireAfterFailures = 3, extraErrorPatterns = null, now = () => Date.now() } = {}) { + this.staticModels = staticModels; + this.catalogPath = catalogPath; + this.retireAfterFailures = Math.max(1, Number(retireAfterFailures) || 3); + this.extraPatterns = compileExtraPatterns(extraErrorPatterns); + this.now = now; + this.entries = new Map(); // key -> { id, provider, thinking, webSearch, deepResearch, status, source, consecutiveFailures, firstSeenAt, lastSeenAt, lastErrorAt, lastError, retiredAt } + this.load(); + } + + load() { + if (!this.catalogPath || !fs.existsSync(this.catalogPath)) return; + try { + const raw = JSON.parse(fs.readFileSync(this.catalogPath, 'utf8')); + for (const e of raw.models || []) this.entries.set(modelKey(e.provider, e.id), e); + } catch (err) { + console.error('[FreeGLMKimiAPI] failed to load model catalog, starting fresh:', err.message); + } + } + + persist() { + if (!this.catalogPath) return; + try { + fs.mkdirSync(path.dirname(this.catalogPath), { recursive: true }); + fs.writeFileSync(this.catalogPath, JSON.stringify({ models: [...this.entries.values()] }, null, 2)); + } catch (err) { + console.error('[FreeGLMKimiAPI] failed to persist model catalog:', err.message); + } + } + + _upsertBase(modelCfg) { + const key = modelKey(modelCfg.provider, modelCfg.id); + let e = this.entries.get(key); + if (!e) { + const isStatic = Object.prototype.hasOwnProperty.call(this.staticModels, modelCfg.id); + e = { + id: modelCfg.id, provider: modelCfg.provider, + thinking: !!modelCfg.thinking, webSearch: !!modelCfg.webSearch, deepResearch: !!modelCfg.deepResearch, + status: 'active', source: isStatic ? 'static' : 'discovered', + consecutiveFailures: 0, firstSeenAt: this.now(), lastSeenAt: 0, lastErrorAt: 0, lastError: '', retiredAt: 0 + }; + this.entries.set(key, e); + } + return e; + } + + /** Call after a real completion against this model succeeds. Promotes + * never-before-seen (but successfully used) model ids into the visible + * catalog, and clears any retired/failure state for known ones — this is + * "auto-discovery" and "un-retirement", both driven by real observed + * traffic rather than a guessed introspection endpoint. */ + recordSuccess(modelCfg) { + const e = this._upsertBase(modelCfg); + e.status = 'active'; e.consecutiveFailures = 0; e.lastSeenAt = this.now(); e.retiredAt = 0; + this.persist(); + return e; + } + + /** Call after a completion fails with an isModelError()-shaped error. + * After `retireAfterFailures` consecutive such failures the model is + * retired (stops being advertised by list()) until either a success is + * observed again or it's manually restored. */ + recordFailure(modelCfg, err) { + const e = this._upsertBase(modelCfg); + e.consecutiveFailures += 1; + e.lastErrorAt = this.now(); + e.lastError = String(err?.message || err || '').slice(0, 300); + if (e.consecutiveFailures >= this.retireAfterFailures) { e.status = 'retired'; e.retiredAt = this.now(); } + this.persist(); + return e; + } + + /** Manual admin override: force a model back to active with a clean slate. */ + restore(provider, id) { + const key = modelKey(provider, id); + const e = this.entries.get(key); + if (e) { e.status = 'active'; e.consecutiveFailures = 0; e.retiredAt = 0; this.persist(); return e; } + // Not previously observed at all (e.g. a static model that never even + // got a catalog row) — nothing to restore, it's implicitly active. + return null; + } + + /** Manual admin override: register or edit a model entry directly, without + * waiting for organic traffic (e.g. you read about a new model on the + * provider's blog before anyone has actually requested it yet). */ + upsert(entry) { + if (!entry?.id || !entry?.provider) throw new Error('upsert requires at least {id, provider}'); + const key = modelKey(entry.provider, entry.id); + const existing = this.entries.get(key); + const isStatic = Object.prototype.hasOwnProperty.call(this.staticModels, entry.id); + const e = { + id: entry.id, provider: entry.provider, + thinking: !!entry.thinking, webSearch: !!entry.webSearch, deepResearch: !!entry.deepResearch, + status: entry.status || existing?.status || 'active', + source: existing?.source || (isStatic ? 'static' : 'manual'), + consecutiveFailures: existing?.consecutiveFailures || 0, + firstSeenAt: existing?.firstSeenAt || this.now(), lastSeenAt: existing?.lastSeenAt || 0, + lastErrorAt: existing?.lastErrorAt || 0, lastError: existing?.lastError || '', + retiredAt: entry.status === 'retired' ? this.now() : 0 + }; + this.entries.set(key, e); + this.persist(); + return e; + } + + /** Remove a discovered/manual entry entirely (static baseline entries + * can't be removed this way — retire() them instead, since removing them + * would just make them silently reappear as an untracked static model). */ + remove(provider, id) { + const key = modelKey(provider, id); + const e = this.entries.get(key); + if (!e || e.source === 'static') return false; + this.entries.delete(key); + this.persist(); + return true; + } + + isRetired(provider, id) { + return this.entries.get(modelKey(provider, id))?.status === 'retired'; + } + + /** Merged view: every static-baseline model not overridden as retired, + * plus every discovered/manual entry that's active — or everything, + * including retired ones and their failure info, if includeRetired. */ + list({ includeRetired = false } = {}) { + const out = []; + for (const [id, def] of Object.entries(this.staticModels)) { + const e = this.entries.get(modelKey(def.provider, id)); + if (!e) { out.push({ id, provider: def.provider, thinking: !!def.thinking, webSearch: !!def.webSearch, deepResearch: !!def.deepResearch, status: 'active', source: 'static' }); continue; } + if (e.status === 'retired' && !includeRetired) continue; + out.push(e); + } + for (const e of this.entries.values()) { + if (Object.prototype.hasOwnProperty.call(this.staticModels, e.id)) continue; // already covered above + if (e.status === 'retired' && !includeRetired) continue; + out.push(e); + } + return out; + } +} diff --git a/src/providers/kimi.js b/src/providers/kimi.js index cafe04d..2b2bac5 100644 --- a/src/providers/kimi.js +++ b/src/providers/kimi.js @@ -1,33 +1,111 @@ -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 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 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', + 'Connect-Protocol-Version': '1', + 'X-Msh-Version': '2.0.0' +}; +// 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); + if (DEBUG_KIMI) console.error('[FreeGLMKimiAPI] Kimi JWT payload:', JSON.stringify(this.jwt)); + } + // 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={}; + // Try multiple possible header names for each JWT field + if (this.jwt.device_id) { + h['X-Msh-Device-Id'] = String(this.jwt.device_id); + h['x-msh-device-id'] = String(this.jwt.device_id); + h['X-Device-Id'] = String(this.jwt.device_id); + } + if (this.jwt.region) { + h['X-Msh-Region'] = String(this.jwt.region); + h['x-msh-region'] = String(this.jwt.region); + h['X-Region'] = String(this.jwt.region); + } + if (this.jwt.sub) { + h['X-Traffic-Id'] = String(this.jwt.sub); + h['x-traffic-id'] = String(this.jwt.sub); + h['X-User-Id'] = String(this.jwt.sub); + } + if (this.jwt.user_id) { + h['X-User-Id'] = String(this.jwt.user_id); + } + if (this.jwt.space_id) { + h['X-Msh-Space-Id'] = String(this.jwt.space_id); + h['x-msh-space-id'] = String(this.jwt.space_id); + } + h['X-Language'] = KIMI_LANGUAGE; + h['R-Timezone'] = KIMI_TIMEZONE; + h['X-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:', JSON.stringify({ chat_id: payload.chat_id||'(new)', parent_id: message.parent_id||'(none)', headers: reqHeaders })); + 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); + if (DEBUG_KIMI) console.error('[FreeGLMKimiAPI] Kimi raw frames:', JSON.stringify(frames, null, 2)); + let text='', reasoning='', chatId='', parentId=''; + for (const d of frames) { + // Try multiple possible field names for chat_id + chatId ||= d.chat_id || d.chatId || d.message?.chat_id || d.message?.chatId || d.session_id || d.sessionId || d.conversation_id || d.conversationId || d.chat?.id || ''; + // 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 || ''; + } + if (DEBUG_KIMI) console.error('[FreeGLMKimiAPI] Kimi response:', { chatId, parentId, textLength: text.length }); + return { text, reasoning, providerSessionId: chatId, parentMessageId: parentId, prompt }; + } +} 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) { diff --git a/src/providers/zaiBrowser.js b/src/providers/zaiBrowser.js index c6036e4..3e23b06 100644 --- a/src/providers/zaiBrowser.js +++ b/src/providers/zaiBrowser.js @@ -9,6 +9,14 @@ const DEFAULT_CLOAK_PROFILE_DIR = path.join(os.homedir(), '.free-glm-kimi-api', const TRANSIENT_HEADER_RE = /^(accept-encoding|connection|content-length|host|origin|referer|priority)$|^(sec-fetch-|sec-ch-)/i; const LOCK_FILES = ['SingletonLock', 'SingletonCookie', 'SingletonSocket']; +// Global semaphore to serialize browser operations (prevents CPU spikes from concurrent Chromium instances) +let browserSemaphore = Promise.resolve(); +function withBrowserLock(fn) { + const run = browserSemaphore.then(fn, fn); + browserSemaphore = run.catch(() => {}); + return run; +} + export function isZaiCaptchaError(value) { const text = typeof value === 'string' ? value : JSON.stringify(value || ''); return /captcha|FRONTEND_CAPTCHA_REQUIRED|verify again|verification failed|refresh the page to update the app|人机验证失败|请重新验证|刷新页面以更新应用|aliyun|waf|punish/i.test(text); @@ -86,6 +94,58 @@ function envBool(env, key, defaultValue = false) { function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); } +// Pulled out as a pure function (env in, args out) so the flag list is +// testable without launching real Chromium. The extra flags beyond the +// original 7 are standard, no-downside hardening for running headless +// Chrome unattended in a container with no GPU: without --disable-gpu, +// Chrome can fall back to software rendering (SwiftShader), which keeps a +// core busy for as long as the page is open; the --disable-*-throttling / +// --disable-backgrounding-* flags stop Chrome from doing extra background +// bookkeeping work on hidden/backgrounded tabs, which is irrelevant in a +// headless server context and otherwise just burns CPU for no benefit here. +export function puppeteerLaunchArgs(env = process.env) { + const isHeadless = parseBrowserHeadless(env) !== false; + const args = [ + '--disable-blink-features=AutomationControlled', + '--no-first-run', + '--no-default-browser-check', + '--lang=ru-RU', + '--window-size=1365,768', + '--disable-gpu', + '--disable-software-rasterizer', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + '--mute-audio', + ]; + + // Sandbox flags: --no-sandbox and --disable-setuid-sandbox are often needed + // in Docker, but some Chromium builds complain. Allow override via env. + if (env.ZAI_BROWSER_NO_SANDBOX !== '0') { + args.unshift('--no-sandbox', '--disable-setuid-sandbox'); + } + + // Extra stealth flags for headless mode to avoid detection + if (isHeadless) { + args.push( + '--disable-web-security', + '--disable-features=IsolateOrigins,site-per-process', + '--enable-features=NetworkService,NetworkServiceInProcess', + '--disable-blink-features=AutomationControlled' + ); + } + + if (env.ZAI_BROWSER_PUPPETEER_ARGS) { + args.push(...env.ZAI_BROWSER_PUPPETEER_ARGS.split(/\s+/).filter(Boolean)); + } + return args; +} + +const DEFAULT_IDLE_CLOSE_MS = 10 * 60 * 1000; // close the idle browser after 10 min of no use +const DEFAULT_IDLE_CHECK_MS = 60 * 1000; + async function loadPuppeteer() { const [{ default: puppeteerExtra }, { default: StealthPlugin }] = await Promise.all([ import('puppeteer-extra'), @@ -101,10 +161,14 @@ async function launchCloakContext({ env, profileDir, headless, logger }) { width: Number(env.ZAI_BROWSER_WIDTH || env.ZAI_SCREEN_WIDTH || 1365), height: Number(env.ZAI_BROWSER_HEIGHT || env.ZAI_SCREEN_HEIGHT || 768) }; + + // In headless mode, force humanize to prevent detection + const useHumanize = headless === false ? envBool(env, 'ZAI_BROWSER_HUMANIZE', true) : true; + const context = await launchPersistentContext({ userDataDir: profileDir, headless: headless === false ? false : true, - humanize: envBool(env, 'ZAI_BROWSER_HUMANIZE', true), + humanize: useHumanize, humanPreset: env.ZAI_BROWSER_HUMAN_PRESET || 'careful', locale: env.ZAI_BROWSER_LOCALE || env.ZAI_LANGUAGE || 'ru-RU', timezone: env.ZAI_BROWSER_TIMEZONE || env.ZAI_TIMEZONE || 'Europe/Samara', @@ -131,35 +195,80 @@ export class ZaiBrowserClient { this.page = null; this.engine = selectedBrowserEngine(env); this.profileDir = env.ZAI_BROWSER_PROFILE_DIR || (this.engine === 'cloak' ? DEFAULT_CLOAK_PROFILE_DIR : DEFAULT_PROFILE_DIR); + // Diagnostics for exactly the kind of problem that motivated this: every + // scratch page opened by completeViaUi() gets tracked here so a leak + // (one never closed) is visible in logs/GET /admin/browser instead of + // silently accumulating Chromium renderer processes in the background. + this.openScratchPages = new Set(); + this.launchedAt = 0; + this.lastUsedAt = 0; + this.requestCount = 0; + this._idleTimer = null; + } + + touch() { this.lastUsedAt = Date.now(); } + + // Bounds worst-case resource usage even against a leak this pass didn't + // find: if nothing has used the browser in ZAI_BROWSER_IDLE_CLOSE_MS + // (default 10 min), close it. It relaunches lazily on the next request, + // same as if the container had just started. Runs on a plain interval + // (unref'd so it never keeps the process alive by itself) — this is the + // only timer in the codebase, and it does nothing but a cheap timestamp + // comparison on every tick; the actual close only happens when genuinely + // idle, which should be rare. + _ensureIdleTimer() { + if (this._idleTimer) return; + const idleCloseMs = Number(this.env.ZAI_BROWSER_IDLE_CLOSE_MS ?? DEFAULT_IDLE_CLOSE_MS); + if (idleCloseMs <= 0) return; // 0 disables auto-close entirely + const checkMs = Number(this.env.ZAI_BROWSER_IDLE_CHECK_MS || DEFAULT_IDLE_CHECK_MS); + this._idleTimer = setInterval(() => { + if (!this.browser && !this.context) return; + const idleFor = Date.now() - this.lastUsedAt; + if (idleFor >= idleCloseMs) { + this.logger?.info?.(`[zai-browser] idle for ${Math.round(idleFor / 1000)}s, closing browser to free resources (relaunches on next request)`); + this.close().catch(err => this.logger?.error?.(`[zai-browser] error closing idle browser: ${err?.message || err}`)); + } + }, checkMs); + this._idleTimer.unref?.(); + } + + /** Snapshot for logging / GET /admin/browser. */ + describeState() { + return { + engine: this.engine, + running: !!(this.browser || this.context), + openScratchPages: this.openScratchPages.size, + launchedAt: this.launchedAt || null, + lastUsedAt: this.lastUsedAt || null, + idleForMs: this.lastUsedAt ? Date.now() - this.lastUsedAt : null, + requestCount: this.requestCount + }; } async launchPuppeteer(headless) { const puppeteer = await loadPuppeteer(); + const started = Date.now(); this.browser = await puppeteer.launch({ headless, executablePath: defaultChromeExecutable(), userDataDir: this.profileDir, defaultViewport: { width: 1365, height: 768, deviceScaleFactor: 1 }, ignoreDefaultArgs: ['--enable-automation'], - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-blink-features=AutomationControlled', - '--no-first-run', - '--no-default-browser-check', - '--lang=ru-RU', - '--window-size=1365,768' - ] + args: puppeteerLaunchArgs(this.env) }); + this.logger?.info?.(`[zai-browser] launched puppeteer-extra Chromium in ${Date.now() - started}ms (headless=${headless})`); const pages = await this.browser.pages(); this.page = pages[0] || await this.browser.newPage(); } async ensurePage(token = '') { + this.touch(); + this._ensureIdleTimer(); if (this.page && !this.page.isClosed()) return this.page; fs.mkdirSync(this.profileDir, { recursive: true }); cleanChromeProfileLocks(this.profileDir); const headless = parseBrowserHeadless(this.env); + this.logger?.info?.(`[zai-browser] no live page — launching (engine=${this.engine}, headless=${headless})`); if (this.engine === 'cloak') { try { @@ -174,6 +283,7 @@ export class ZaiBrowserClient { } else { await this.launchPuppeteer(headless); } + this.launchedAt = Date.now(); await this.setupPage(this.page); if (token) await this.injectToken(this.page, token); @@ -207,48 +317,78 @@ export class ZaiBrowserClient { return this.context ? this.context.newPage() : this.browser.newPage(); } - async completeRequest(req, { token = '', chatId = '' } = {}) { - const page = await this.ensurePage(token); - const target = chatId ? `${ZAI_BASE}/c/${chatId}` : ZAI_BASE; - if (!page.url().startsWith(target)) { - await page.goto(target, { waitUntil: 'domcontentloaded', timeout: Number(this.env.ZAI_BROWSER_NAV_TIMEOUT || 60_000) }).catch(() => null); + /** Opens a page tracked for leak detection. Always pair with + * closeScratchPage() in a try/finally — see completeViaUi(), which is the + * only caller and the thing that used to leak a tab on every fallback. */ + async openScratchPage() { + const page = await this.newPage(); + this.openScratchPages.add(page); + if (this.openScratchPages.size > 1) { + this.logger?.warn?.(`[zai-browser] ${this.openScratchPages.size} scratch pages open at once (expected at most 1) — possible leak or unusually high concurrency, see GET /admin/browser`); } - const payload = { - url: req.url, - headers: browserSafeHeaders(req.headers), - body: req.body - }; - return page.evaluate(async (data) => { - const response = await fetch(data.url, { - method: 'POST', - headers: data.headers, - body: JSON.stringify(data.body), - credentials: 'include' - }); - const raw = await response.text(); - return { - ok: response.ok, - status: response.status, - contentType: response.headers.get('content-type') || '', - raw + return page; + } + + async closeScratchPage(page) { + this.openScratchPages.delete(page); + await page.close().catch(err => this.logger?.warn?.(`[zai-browser] error closing scratch page: ${err?.message || err}`)); + } + + async completeRequest(req, { token = '', chatId = '' } = {}) { + return withBrowserLock(async () => { + this.touch(); + const page = await this.ensurePage(token); + const target = chatId ? `${ZAI_BASE}/c/${chatId}` : ZAI_BASE; + if (!page.url().startsWith(target)) { + this.logger?.info?.(`[zai-browser] home page navigating to ${target}`); + await page.goto(target, { waitUntil: 'domcontentloaded', timeout: Number(this.env.ZAI_BROWSER_NAV_TIMEOUT || 60_000) }).catch(() => null); + } + const payload = { + url: req.url, + headers: browserSafeHeaders(req.headers), + body: req.body }; - }, payload); + const started = Date.now(); + const result = await page.evaluate(async (data) => { + const response = await fetch(data.url, { + method: 'POST', + headers: data.headers, + body: JSON.stringify(data.body), + credentials: 'include' + }); + const raw = await response.text(); + return { + ok: response.ok, + status: response.status, + contentType: response.headers.get('content-type') || '', + raw + }; + }, payload); + this.logger?.info?.(`[zai-browser] in-page fetch completed in ${Date.now() - started}ms (ok=${result.ok}, status=${result.status})`); + return result; + }); } async completeAndParse(req, options = {}) { + this.requestCount += 1; + const n = this.requestCount; + this.logger?.info?.(`[zai-browser] completeAndParse #${n}: starting (in-page fetch first)`); let result; try { result = await this.completeRequest(req, options); - } catch { + } catch (err) { + this.logger?.warn?.(`[zai-browser] completeAndParse #${n}: in-page fetch threw (${err?.message || err}) — falling back to full UI simulation`); const uiResult = await this.completeViaUi(req.body?.messages?.[0]?.content || req.body?.signature_prompt || 'Hello'); return { ...uiResult, parsed: parseZaiSse(uiResult.raw) }; } let parsed = parseZaiSse(result.raw); if (!result.ok || (parsed.error && isZaiCaptchaError(parsed.error))) { + this.logger?.warn?.(`[zai-browser] completeAndParse #${n}: ${!result.ok ? `in-page fetch returned HTTP ${result.status}` : `looks like a captcha challenge (${parsed.error})`} — falling back to full UI simulation`); const uiResult = await this.completeViaUi(req.body?.messages?.[0]?.content || req.body?.signature_prompt || 'Hello'); parsed = parseZaiSse(uiResult.raw); return { ...uiResult, parsed }; } + this.logger?.info?.(`[zai-browser] completeAndParse #${n}: in-page fetch succeeded directly, no UI fallback needed`); return { ...result, parsed }; } @@ -281,48 +421,114 @@ export class ZaiBrowserClient { } async completeViaUi(prompt) { - await this.ensurePage(); - const page = await this.newPage(); - 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); - const responsePromise = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - page.off('response', onResponse); - 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) { - 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); - 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); - reject(err); - } + return withBrowserLock(async () => { + this.touch(); + // Just makes sure a browser/context exists — this is the persistent + // "home" page used by completeRequest()'s lightweight in-page fetch + // path, and completeViaUi() must never touch or replace it (see below). + await this.ensurePage(); + const page = await this.openScratchPage(); + this.logger?.info?.(`[zai-browser] completeViaUi: opened scratch page (${this.openScratchPages.size} open)`); + + try { + await this.setupPage(page); + const navStarted = Date.now(); + await page.goto(ZAI_BASE, { waitUntil: 'domcontentloaded', timeout: Number(this.env.ZAI_BROWSER_NAV_TIMEOUT || 60_000) }).catch(() => null); + this.logger?.info?.(`[zai-browser] completeViaUi: navigated to ${ZAI_BASE} in ${Date.now() - navStarted}ms`); + + // 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, + // before this fix). + let onResponse; + let timeout; + let heartbeat; + const cleanup = () => { + clearTimeout(timeout); + clearInterval(heartbeat); + if (onResponse) page.off('response', onResponse); + }; + + const completionTimeoutMs = Number(this.env.ZAI_BROWSER_COMPLETION_TIMEOUT || 120_000); + const heartbeatMs = Number(this.env.ZAI_BROWSER_HEARTBEAT_MS || 15_000); + const waitStarted = Date.now(); + const responsePromise = new Promise((resolve, reject) => { + timeout = setTimeout(() => { + cleanup(); + this.logger?.error?.(`[zai-browser] completeViaUi: timed out after ${completionTimeoutMs}ms waiting for a /api/v2/chat/completions response`); + reject(new Error('Timed out waiting for Z.ai UI completion response')); + }, completionTimeoutMs); + // Without this, the only sign of life for up to 3 minutes was + // silence — no way to tell "still working" from "stuck" from the + // logs alone. + heartbeat = setInterval(() => { + this.logger?.info?.(`[zai-browser] completeViaUi: still waiting for a completion response (${Math.round((Date.now() - waitStarted) / 1000)}s elapsed)`); + }, heartbeatMs); + heartbeat.unref?.(); + 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(); + 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; + this.logger?.info?.(`[zai-browser] completeViaUi: got a completion response after ${Math.round((Date.now() - waitStarted) / 1000)}s (ok=${ok}, status=${status})`); + resolve({ ok, status, contentType: headers['content-type'] || '', raw }); + } catch (err) { + cleanup(); + reject(err); + } + }; + page.on('response', onResponse); + }); + + try { + await this.humanFillPrompt(page, prompt); + await page.keyboard.press('Enter'); + this.logger?.info?.('[zai-browser] completeViaUi: prompt submitted, waiting for a response...'); + } 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(); + this.logger?.error?.(`[zai-browser] completeViaUi: prompt submission failed (${err?.message || err}) — likely a login/captcha wall instead of the chat UI`); + throw err; } - page.on('response', onResponse); - }); - await this.humanFillPrompt(page, prompt); - await page.keyboard.press('Enter'); - return responsePromise; + + return await responsePromise; + } finally { + // THE actual fix for the CPU/network issue this was written for: + // every completeViaUi() call used to leave this scratch page open + // forever — regardless of success, failure, or timeout — so each + // fallback (e.g. every glm-*-search request that hits a captcha) left + // one more live Chromium renderer process running in the background, + // each with its own ongoing JS execution and network activity on a + // loaded chat.z.ai page, indefinitely. Closing it here, in `finally`, + // guarantees exactly one scratch page is ever alive per in-flight + // call. See docs/browser-fallback.md. + await this.closeScratchPage(page); + this.logger?.info?.(`[zai-browser] completeViaUi: closed scratch page (${this.openScratchPages.size} still open)`); + } + }); } async close() { + if (this._idleTimer) { clearInterval(this._idleTimer); this._idleTimer = null; } const browser = this.browser; const context = this.context; + const leaked = [...this.openScratchPages]; + this.openScratchPages.clear(); this.browser = null; this.context = null; this.page = null; + this.launchedAt = 0; + for (const page of leaked) await page.close().catch(() => null); if (context) await context.close().catch(() => null); if (browser) await browser.close().catch(() => null); + this.logger?.info?.(`[zai-browser] closed${leaked.length ? ` (${leaked.length} still-open scratch page(s) force-closed)` : ''}`); } } diff --git a/src/server.js b/src/server.js index 9c0c104..19ab905 100644 --- a/src/server.js +++ b/src/server.js @@ -1,53 +1,117 @@ 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, SESSION_TTL_MS, SESSION_MAX_DEPTH, MODEL_CATALOG_PATH, MODEL_RETIRE_AFTER_FAILURES, MODEL_ERROR_PATTERNS_EXTRA, resolveModel, requireProxyAuth } from './config.js'; import { AccountManager } from './accounts.js'; import { SessionStore } from './sessions.js'; +import { ModelCatalog, isModelError } from './modelCatalog.js'; import { KimiProvider } from './providers/kimi.js'; import { GLMProvider } from './providers/glm.js'; import { ZaiProvider } from './providers/zai.js'; +import { getZaiBrowserClient } from './providers/zaiBrowser.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 store=new SessionStore({ ttlMs: SESSION_TTL_MS, maxDepth: SESSION_MAX_DEPTH }); const accountManager=new AccountManager({ authPath: AUTH_PATH, env: process.env, cooldownMs: Number(process.env.ACCOUNT_COOLDOWN_MS || 60_000) }); +const modelCatalog=new ModelCatalog({ staticModels: MODELS, catalogPath: MODEL_CATALOG_PATH, retireAfterFailures: MODEL_RETIRE_AFTER_FAILURES, extraErrorPatterns: MODEL_ERROR_PATTERNS_EXTRA }); + +// 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); } 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 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 textCompletion(content, model, prompt='', reasoning='', session=null){ const msg={role:'assistant',content}; if(reasoning) msg.reasoning_content=reasoning; const out={ 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 }; if (session) out.session={agent_id:session.agentId,provider:session.provider,chat_id:session.providerSessionId}; return out; } 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); +async function doCompletion(body, reqHeaders={}){ + const modelCfg=resolveModel(body.model); + // 'user' (OpenAI) / metadata.user_id (Anthropic) are the primary, actively + // used way callers scope a session (see scripts/agent_smoke.js). The + // x-agent-id HTTP header is a fallback for callers that can't set those — + // it must come from the real request headers, not the JSON body (a + // "headers" key inside the JSON payload is never populated by any client). + const agentId=body.user || body.metadata?.user_id || reqHeaders['x-agent-id'] || 'default'; + const session=store.get(agentId, modelCfg.provider); + // Serialize everything that reads/writes this session's provider-continuity + // state (chat_id/parent_id/accountId) behind a per-key lock. See + // SessionStore.withLock for why this matters. + return store.withLock(session.key, () => runCompletion(body, modelCfg, session)); +} +async function runCompletion(body, modelCfg, session){ + 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, '', '', session); + } 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 { - const maxAttempts=Math.max(1, accountManager.rawList().filter(a => a.provider===modelCfg.provider).length); - let lastError; - for (let attempt=0; attempt a.provider===modelCfg.provider).length); + let lastError; + for (let attempt=0; attemptcontentToText(m.content)).join('\n'); + // Simulate real provider-side chat continuity (a stable chat id for the + // life of the session, a fresh message id each turn) so tests can + // exercise session threading end-to-end without live GLM/Kimi accounts. + // Read providerSessionId synchronously BEFORE the simulated network + // delay, mirroring how kimi.js/zai.js build their outgoing payload from + // session.providerSessionId before awaiting fetch() — that's the exact + // read a missing lock would race on, so the simulation has to preserve + // this ordering for the concurrency test to mean anything. + const existingChatId = session.providerSessionId; + const turnNumber = session.messageCount + 1; + await new Promise(r => setTimeout(r, Math.random() * 15)); // simulated network latency + result={ + text: await mockComplete({ prompt, model:modelCfg.id, tools:body.tools }), + prompt, + providerSessionId: existingChatId || `mock-chat-${session.key}-${Math.random().toString(36).slice(2,10)}`, + parentMessageId: `mock-msg-${turnNumber}-${Math.random().toString(36).slice(2,10)}` + }; + } else { const provider=providerFor(modelCfg, account); result=await provider.complete({ messages:body.messages||[], modelCfg, tools:body.tools||[], session }); - accountManager.markSuccess(account.id); - break; - } catch (e) { - lastError=e; - accountManager.markFailure(account.id, e); - session.accountId=''; - if (attempt === maxAttempts - 1) throw lastError; } + accountManager.markSuccess(account.id); + // A real completion against this model just succeeded — promote a + // never-before-cataloged id into GET /v1/models ("auto-discovery"), + // or clear any retired/failure state for a known one ("un-retirement"). + // See src/modelCatalog.js / docs/model-lifecycle.md. + modelCatalog.recordSuccess(modelCfg); + break; + } catch (e) { + lastError=e; + if (isModelError(e, modelCatalog.extraPatterns)) { + // This looks like the provider rejecting the MODEL itself (wrong/ + // dead model id), not an account/session/transient problem — retrying + // with a different account against the same bad model would just + // fail identically, so stop immediately instead of burning the rest + // of maxAttempts. Record it and let the client see a clearer error. + const entry=modelCatalog.recordFailure(modelCfg, e); + e.message=`${e.message} [model "${modelCfg.id}" looks unavailable upstream: ${entry.consecutiveFailures}/${modelCatalog.retireAfterFailures} consecutive model-shaped failures${entry.status==='retired' ? ', now retired from GET /v1/models' : ''} — see GET /v1/models?all=1]`; + throw e; + } + accountManager.markFailure(account.id, e); + session.accountId=''; + if (attempt === maxAttempts - 1) throw lastError; } } store.update(session, result); const parsed=parseToolCallsFromText(result.text); - if (parsed.toolCalls.length) return buildToolCallCompletion(parsed.toolCalls, modelCfg.id, result.prompt || ''); - return textCompletion(parsed.content || result.text || '', modelCfg.id, result.prompt || '', result.reasoning || ''); + if (parsed.toolCalls.length) return buildToolCallCompletion(parsed.toolCalls, modelCfg.id, result.prompt || '', session); + return textCompletion(parsed.content || result.text || '', modelCfg.id, result.prompt || '', result.reasoning || '', session); } async function handleChat(req,res,body){ - const out=await doCompletion(body); + const out=await doCompletion(body, req.headers); if (body.stream) { res.writeHead(200, {'Content-Type':'text/event-stream','Cache-Control':'no-cache','Connection':'keep-alive'}); if (out.choices[0].message.tool_calls) sseChunk(res,{...out, object:'chat.completion.chunk', choices:[{index:0,delta:{role:'assistant',tool_calls:out.choices[0].message.tool_calls},finish_reason:'tool_calls'}]}); @@ -63,6 +127,18 @@ async function handleAdmin(req,res,url){ if (req.method==='POST' && url.pathname==='/admin/accounts/reload') return json(res,200,{accounts:accountManager.reload()}); 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)})}); + // Model catalog admin — see docs/model-lifecycle.md. + if (req.method==='GET' && url.pathname==='/admin/models') return json(res,200,{models:modelCatalog.list({includeRetired:true})}); + if (req.method==='POST' && url.pathname==='/admin/models') { const body=await readBody(req); return json(res,201,{model:modelCatalog.upsert(body)}); } + const rm=url.pathname.match(/^\/admin\/models\/([^/]+)\/restore$/); + if (rm && req.method==='POST') { const body=await readBody(req).catch(()=>({})); const provider=body.provider || url.searchParams.get('provider') || 'kimi'; return json(res,200,{model:modelCatalog.restore(provider, decodeURIComponent(rm[1]))}); } + const dm=url.pathname.match(/^\/admin\/models\/([^/]+)$/); + if (dm && req.method==='DELETE') { const provider=url.searchParams.get('provider') || 'kimi'; return json(res,200,{deleted:modelCatalog.remove(provider, decodeURIComponent(dm[1]))}); } + // Z.ai browser-fallback introspection — see docs/browser-fallback.md. + // getZaiBrowserClient() only constructs the JS wrapper; it does not by + // itself launch Chromium, so checking status here is always cheap/safe. + if (req.method==='GET' && url.pathname==='/admin/browser') return json(res,200,{browser:getZaiBrowserClient().describeState()}); + if (req.method==='POST' && url.pathname==='/admin/browser/close') { await getZaiBrowserClient().close(); return json(res,200,{closed:true}); } return false; } async function router(req,res){ @@ -71,10 +147,10 @@ async function router(req,res){ if (!requireProxyAuth(req)) return json(res,401,{error:{message:'Unauthorized',type:'auth_error'}}); if (url.pathname.startsWith('/admin/')) { const handled=await handleAdmin(req,res,url); if (handled !== false) return; } if (req.method==='GET' && (url.pathname==='/' || url.pathname==='/health')) return json(res,200,{ok:true,name:'FreeGLMKimiAPI',mock:MOCK_PROVIDER,accounts:accountManager.list(),watermark:WATERMARK}); - if (req.method==='GET' && (url.pathname==='/v1/models' || url.pathname==='/models')) return json(res,200,{object:'list',data:Object.keys(MODELS).map(id=>({id,object:'model',created:0,owned_by:MODELS[id].provider}))}); + if (req.method==='GET' && (url.pathname==='/v1/models' || url.pathname==='/models')) return json(res,200,{object:'list',data:modelCatalog.list({includeRetired:url.searchParams.get('all')==='1'}).map(m=>({id:m.id,object:'model',created:0,owned_by:m.provider,status:m.status||'active'}))}); if (req.method==='GET' && url.pathname==='/sessions') return json(res,200,{sessions:store.dump()}); if (req.method==='POST' && (url.pathname==='/v1/chat/completions' || url.pathname==='/chat/completions')) return await handleChat(req,res,await readBody(req)); - if (req.method==='POST' && (url.pathname==='/v1/messages' || url.pathname==='/messages')) { const body=await readBody(req); const open=anthropicToOpenAI(body); const resp=await doCompletion(open); return json(res,200,openAIToAnthropic(resp)); } + if (req.method==='POST' && (url.pathname==='/v1/messages' || url.pathname==='/messages')) { const body=await readBody(req); const open=anthropicToOpenAI(body); const resp=await doCompletion(open, req.headers); return json(res,200,openAIToAnthropic(resp)); } json(res,404,{error:{message:'Not found',path:url.pathname}}); } catch (e) { console.error('[FreeGLMKimiAPI]', e); json(res,500,{error:{message:e.message,type:'server_error'}}); } } diff --git a/src/sessions.js b/src/sessions.js index 0f11995..f94a838 100644 --- a/src/sessions.js +++ b/src/sessions.js @@ -1,21 +1,36 @@ -export class SessionStore { - constructor({ ttlMs = 2 * 60 * 60 * 1000, maxDepth = 100 } = {}) { - this.ttlMs = ttlMs; this.maxDepth = maxDepth; this.sessions = new Map(); - } - get(agentId='default', provider='kimi') { - const key = `${provider}:${agentId || 'default'}`; - let s = this.sessions.get(key); - const now = Date.now(); - if (!s) s = { key, provider, agentId, providerSessionId:'', parentMessageId:'', createdAt:now, messageCount:0, history:[] }; - if ((now - s.createdAt > this.ttlMs) || s.messageCount >= this.maxDepth) { - s.providerSessionId=''; s.parentMessageId=''; s.createdAt=now; s.messageCount=0; - } - this.sessions.set(key,s); return s; - } - update(s, { providerSessionId, parentMessageId } = {}) { - if (providerSessionId) s.providerSessionId = providerSessionId; - if (parentMessageId) s.parentMessageId = parentMessageId; - s.messageCount += 1; this.sessions.set(s.key,s); return s; - } - dump() { return [...this.sessions.values()].map(s => ({...s, history: undefined})); } -} +export class SessionStore { + constructor({ ttlMs = 2 * 60 * 60 * 1000, maxDepth = 100 } = {}) { + this.ttlMs = ttlMs; this.maxDepth = maxDepth; this.sessions = new Map(); this.locks = new Map(); + } + get(agentId='default', provider='kimi') { + const key = `${provider}:${agentId || 'default'}`; + let s = this.sessions.get(key); + const now = Date.now(); + if (!s) s = { key, provider, agentId, providerSessionId:'', parentMessageId:'', createdAt:now, messageCount:0, history:[] }; + if ((now - s.createdAt > this.ttlMs) || s.messageCount >= this.maxDepth) { + s.providerSessionId=''; s.parentMessageId=''; s.createdAt=now; s.messageCount=0; + } + this.sessions.set(key,s); return s; + } + update(s, { providerSessionId, parentMessageId } = {}) { + if (providerSessionId) s.providerSessionId = providerSessionId; + if (parentMessageId) s.parentMessageId = parentMessageId; + s.messageCount += 1; this.sessions.set(s.key,s); return s; + } + dump() { return [...this.sessions.values()].map(s => ({...s, history: undefined})); } + // Provider-side chat continuity (chat_id/parent_id) is inherently + // sequential per key: two requests for the same agentId+provider racing in + // parallel would both read the same parentMessageId before either + // response comes back, so whichever update() finishes last silently + // clobbers the chat_id/parent_id the other call needed for its own next + // turn. That produces exactly the "chat message not found" shape of error + // on a later turn. withLock() queues callers by key so only one request + // per session is ever in flight against the provider at a time; unrelated + // sessions are never blocked by each other. + withLock(key, fn) { + const prev = this.locks.get(key) || Promise.resolve(); + const run = prev.then(fn, fn); + this.locks.set(key, run.catch(() => {})); + return run; + } +} diff --git a/start-vnc.sh b/start-vnc.sh new file mode 100644 index 0000000..8755465 --- /dev/null +++ b/start-vnc.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# VNC startup script - starts Xvfb, fluxbox, x11vnc, and noVNC +set -e + +# Create VNC password file +mkdir -p /root/.vnc +x11vnc -storepasswd "${VNC_PASSWORD}" /root/.vnc/passwd + +# Start Xvfb (virtual display) +Xvfb ${DISPLAY} -screen 0 ${SCREEN_WIDTH}x${SCREEN_HEIGHT}x${SCREEN_DEPTH} & +XVFB_PID=$! + +# Wait for Xvfb to be ready +sleep 2 + +# Start fluxbox window manager +fluxbox & +FLUXBOX_PID=$! + +# Start x11vnc (VNC server) +x11vnc -display ${DISPLAY} -forever -shared -rfbport ${VNC_PORT} -rfbauth /root/.vnc/passwd -noxdamage & +X11VNC_PID=$! + +# Start noVNC (WebSocket proxy for web-based VNC) +websockify --web /opt/noVNC ${NOVNC_PORT} localhost:${VNC_PORT} & +WEBSOCKIFY_PID=$! + +echo "[VNC] Started Xvfb (pid: ${XVFB_PID}), fluxbox (pid: ${FLUXBOX_PID}), x11vnc (pid: ${X11VNC_PID}), noVNC (pid: ${WEBSOCKIFY_PID})" +echo "[VNC] VNC: localhost:${VNC_PORT}, noVNC: http://localhost:${NOVNC_PORT}/vnc.html" +echo "[VNC] Password: ${VNC_PASSWORD}" + +# Function to cleanup on exit +cleanup() { + echo "[VNC] Shutting down..." + kill ${WEBSOCKIFY_PID} ${X11VNC_PID} ${FLUXBOX_PID} ${XVFB_PID} 2>/dev/null || true + wait ${WEBSOCKIFY_PID} ${X11VNC_PID} ${FLUXBOX_PID} ${XVFB_PID} 2>/dev/null || true +} + +trap cleanup EXIT INT TERM + +# Keep script running +wait \ No newline at end of file diff --git a/tests/server.test.js b/tests/server.test.js index 90e6dad..4086b2b 100644 --- a/tests/server.test.js +++ b/tests/server.test.js @@ -1,65 +1,84 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -process.env.MOCK_PROVIDER='1'; -process.env.PORT='0'; -const { server } = await import('../src/server.js'); - -function listen(){ return new Promise(resolve => server.listen(0,'127.0.0.1',()=>resolve(server.address().port))); } -function close(){ return new Promise(resolve => server.close(resolve)); } -async function post(port,path,body){ const r=await fetch(`http://127.0.0.1:${port}${path}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); return {status:r.status,json:await r.json()}; } - -test('OpenAI chat returns mock text', async () => { - const port=await listen(); - try { - const out=await post(port,'/v1/chat/completions',{model:'kimi-k2.5',messages:[{role:'user',content:'Reply exactly: OK_FREEGLMKIMI'}]}); - assert.equal(out.status,200); - assert.equal(out.json.choices[0].message.content,'OK_FREEGLMKIMI'); - } finally { await close(); } -}); - -test('OpenAI tools become tool_calls', async () => { - const port=await listen(); - try { - const out=await post(port,'/v1/chat/completions',{model:'glm-5',messages:[{role:'user',content:'создай файл'}],tools:[{type:'function',function:{name:'write_file',parameters:{type:'object'}}}]}); - assert.equal(out.status,200); - assert.equal(out.json.choices[0].finish_reason,'tool_calls'); - assert.equal(out.json.choices[0].message.tool_calls[0].function.name,'write_file'); - } finally { await close(); } -}); - -test('Anthropic shim emits tool_use', async () => { - const port=await listen(); - try { - const out=await post(port,'/v1/messages',{model:'kimi-k2.5',messages:[{role:'user',content:'use tool'}],tools:[{name:'read_file',input_schema:{type:'object'}}]}); - assert.equal(out.status,200); - assert.equal(out.json.stop_reason,'tool_use'); - assert.equal(out.json.content[0].type,'tool_use'); - } finally { await close(); } -}); - -async function get(port,path){ const r=await fetch(`http://127.0.0.1:${port}${path}`); return {status:r.status,json:await r.json()}; } -async function del(port,path){ const r=await fetch(`http://127.0.0.1:${port}${path}`,{method:'DELETE'}); return {status:r.status,json:await r.json()}; } - -test('admin accounts API adds lists deletes and reloads accounts without exposing secrets', async () => { - const port=await listen(); - try { - const added=await post(port,'/admin/accounts',{id:'admin-kimi',provider:'kimi',token:'secret',persist:false}); - assert.equal(added.status,201); - assert.equal(added.json.account.id,'admin-kimi'); - assert.equal(added.json.account.hasToken,true); - assert.equal(added.json.account.token,undefined); - - const listed=await get(port,'/admin/accounts'); - assert.equal(listed.status,200); - assert.ok(listed.json.accounts.find(a => a.id === 'admin-kimi')); - assert.equal(JSON.stringify(listed.json).includes('secret'), false); - - const removed=await del(port,'/admin/accounts/admin-kimi?persist=false'); - assert.equal(removed.status,200); - assert.equal(removed.json.deleted,true); - - const reloaded=await post(port,'/admin/accounts/reload',{}); - assert.equal(reloaded.status,200); - assert.ok(Array.isArray(reloaded.json.accounts)); - } finally { await close(); } -}); +import test from 'node:test'; +import assert from 'node:assert/strict'; +process.env.MOCK_PROVIDER='1'; +process.env.PORT='0'; +const { server } = await import('../src/server.js'); + +function listen(){ return new Promise(resolve => server.listen(0,'127.0.0.1',()=>resolve(server.address().port))); } +function close(){ return new Promise(resolve => server.close(resolve)); } +async function post(port,path,body){ const r=await fetch(`http://127.0.0.1:${port}${path}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); return {status:r.status,json:await r.json()}; } + +test('OpenAI chat returns mock text', async () => { + const port=await listen(); + try { + const out=await post(port,'/v1/chat/completions',{model:'kimi-k2.5',messages:[{role:'user',content:'Reply exactly: OK_FREEGLMKIMI'}]}); + assert.equal(out.status,200); + assert.equal(out.json.choices[0].message.content,'OK_FREEGLMKIMI'); + } finally { await close(); } +}); + +test('OpenAI tools become tool_calls', async () => { + const port=await listen(); + try { + const out=await post(port,'/v1/chat/completions',{model:'glm-5',messages:[{role:'user',content:'создай файл'}],tools:[{type:'function',function:{name:'write_file',parameters:{type:'object'}}}]}); + assert.equal(out.status,200); + assert.equal(out.json.choices[0].finish_reason,'tool_calls'); + assert.equal(out.json.choices[0].message.tool_calls[0].function.name,'write_file'); + } finally { await close(); } +}); + +test('Anthropic shim emits tool_use', async () => { + const port=await listen(); + try { + const out=await post(port,'/v1/messages',{model:'kimi-k2.5',messages:[{role:'user',content:'use tool'}],tools:[{name:'read_file',input_schema:{type:'object'}}]}); + assert.equal(out.status,200); + assert.equal(out.json.stop_reason,'tool_use'); + assert.equal(out.json.content[0].type,'tool_use'); + } finally { await close(); } +}); + +async function get(port,path){ const r=await fetch(`http://127.0.0.1:${port}${path}`); return {status:r.status,json:await r.json()}; } +async function del(port,path){ const r=await fetch(`http://127.0.0.1:${port}${path}`,{method:'DELETE'}); return {status:r.status,json:await r.json()}; } + +test('admin accounts API adds lists deletes and reloads accounts without exposing secrets', async () => { + const port=await listen(); + try { + const added=await post(port,'/admin/accounts',{id:'admin-kimi',provider:'kimi',token:'secret',persist:false}); + assert.equal(added.status,201); + assert.equal(added.json.account.id,'admin-kimi'); + assert.equal(added.json.account.hasToken,true); + assert.equal(added.json.account.token,undefined); + + const listed=await get(port,'/admin/accounts'); + assert.equal(listed.status,200); + assert.ok(listed.json.accounts.find(a => a.id === 'admin-kimi')); + assert.equal(JSON.stringify(listed.json).includes('secret'), false); + + const removed=await del(port,'/admin/accounts/admin-kimi?persist=false'); + assert.equal(removed.status,200); + assert.equal(removed.json.deleted,true); + + const reloaded=await post(port,'/admin/accounts/reload',{}); + assert.equal(reloaded.status,200); + assert.ok(Array.isArray(reloaded.json.accounts)); + } finally { await close(); } +}); + +test('GET /admin/browser reports Z.ai browser-fallback state without launching real Chromium', async () => { + const port = await listen(); + try { + const r = await get(port, '/admin/browser'); + assert.equal(r.status, 200); + assert.equal(r.json.browser.running, false, 'merely checking status must not launch a browser'); + assert.equal(r.json.browser.openScratchPages, 0); + } finally { await close(); } +}); + +test('POST /admin/browser/close is a safe no-op when nothing is running', async () => { + const port = await listen(); + try { + const r = await post(port, '/admin/browser/close', {}); + assert.equal(r.status, 200); + assert.equal(r.json.closed, true); + } finally { await close(); } +}); diff --git a/tests/zaiBrowser.test.js b/tests/zaiBrowser.test.js new file mode 100644 index 0000000..c3596e7 --- /dev/null +++ b/tests/zaiBrowser.test.js @@ -0,0 +1,204 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ZaiBrowserClient, puppeteerLaunchArgs } from '../src/providers/zaiBrowser.js'; + +const quietLogger = { info(){}, warn(){}, error(){} }; + +function fakePage() { + const listeners = {}; + const page = { + _closeCalls: 0, + _listeners: listeners, + on(event, handler) { (listeners[event] ||= []).push(handler); }, + off(event, handler) { listeners[event] = (listeners[event] || []).filter(h => h !== handler); }, + async goto() {}, + async waitForSelector() {}, + async click() { return null; }, + keyboard: { down: async () => {}, press: async () => {}, up: async () => {} }, + async type() {}, + isClosed() { return page._closeCalls > 0; }, + async close() { page._closeCalls += 1; }, + }; + return page; +} + +function fireResponse(page, { url = 'https://chat.z.ai/api/v2/chat/completions?x=1', ok = true, status = 200, raw = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n' } = {}) { + const handlers = page._listeners['response'] || []; + assert.ok(handlers.length > 0, 'completeViaUi must have registered a response listener'); + return Promise.all(handlers.map(h => h({ url: () => url, ok: () => ok, status: () => status, headers: () => ({ 'content-type': 'text/event-stream' }), text: async () => raw }))); +} + +function clientWithFakeBrowser(envOverrides = {}) { + const client = new ZaiBrowserClient({ env: { ...process.env, ZAI_BROWSER_IDLE_CLOSE_MS: '0', ...envOverrides }, logger: quietLogger }); + // Stub out real Chromium entirely — ensurePage()/newPage() would otherwise + // try to launch it. completeViaUi only calls these two. + client.ensurePage = async () => { client.touch(); return client.page; }; + client.setupPage = async () => {}; + return client; +} + +// --- puppeteerLaunchArgs: Docker-safe flags ----------------------------- + +test('puppeteerLaunchArgs includes the Docker-safe headless-Chrome hardening flags', () => { + const args = puppeteerLaunchArgs({}); + for (const flag of ['--disable-gpu', '--disable-dev-shm-usage', '--disable-software-rasterizer', '--no-sandbox']) { + assert.ok(args.includes(flag), `expected ${flag} in launch args`); + } +}); + +test('puppeteerLaunchArgs appends extra args from ZAI_BROWSER_PUPPETEER_ARGS', () => { + const args = puppeteerLaunchArgs({ ZAI_BROWSER_PUPPETEER_ARGS: '--proxy-server=http://x --foo' }); + assert.ok(args.includes('--proxy-server=http://x')); + assert.ok(args.includes('--foo')); +}); + +// --- completeViaUi: the actual leak fix ---------------------------------- + +test('completeViaUi closes its scratch page after a successful completion', async () => { + const client = clientWithFakeBrowser({ ZAI_BROWSER_COMPLETION_TIMEOUT: '5000', ZAI_BROWSER_HEARTBEAT_MS: '100000' }); + const scratch = fakePage(); + client.newPage = async () => scratch; + + const resultPromise = client.completeViaUi('hello'); + await new Promise(r => setTimeout(r, 5)); + await fireResponse(scratch); + const result = await resultPromise; + + assert.equal(result.ok, true); + assert.equal(scratch._closeCalls, 1, 'the scratch page must be closed exactly once'); + assert.equal(client.openScratchPages.size, 0, 'no scratch page should remain tracked as open'); +}); + +test('completeViaUi closes its scratch page even when it times out', async () => { + // ZAI_BROWSER_COMPLETION_TIMEOUT must clear humanFillPrompt's own built-in + // randomized human-like delays (up to ~2.5s total) so the timeout fires + // while genuinely waiting for a response, not mid-submission — otherwise + // the timeout rejects the promise before completeViaUi has reached + // `await responsePromise`, which is a real but different race (Node + // reports it as a transient unhandled-rejection, not what this test is + // about). + const client = clientWithFakeBrowser({ ZAI_BROWSER_COMPLETION_TIMEOUT: '2900', ZAI_BROWSER_HEARTBEAT_MS: '100000' }); + const scratch = fakePage(); + client.newPage = async () => scratch; + + await assert.rejects(() => client.completeViaUi('hello'), /Timed out waiting for Z\.ai UI completion response/); + assert.equal(scratch._closeCalls, 1, 'a timed-out call must still close its scratch page'); + assert.equal(client.openScratchPages.size, 0); +}); + +test('completeViaUi closes its scratch page even when prompt submission fails (e.g. captcha/login wall)', async () => { + const client = clientWithFakeBrowser({ ZAI_BROWSER_COMPLETION_TIMEOUT: '5000', ZAI_BROWSER_HEARTBEAT_MS: '100000' }); + const scratch = fakePage(); + scratch.waitForSelector = async () => { throw new Error('textarea never appeared'); }; + client.newPage = async () => scratch; + + await assert.rejects(() => client.completeViaUi('hello'), /textarea never appeared/); + assert.equal(scratch._closeCalls, 1, 'a failed submission must still close its scratch page'); + assert.equal(client.openScratchPages.size, 0); +}); + +test('completeViaUi never reassigns or closes the persistent home page', async () => { + const client = clientWithFakeBrowser({ ZAI_BROWSER_COMPLETION_TIMEOUT: '5000', ZAI_BROWSER_HEARTBEAT_MS: '100000' }); + const home = fakePage(); + client.page = home; + const scratch = fakePage(); + client.newPage = async () => scratch; + + const resultPromise = client.completeViaUi('hello'); + await new Promise(r => setTimeout(r, 5)); + await fireResponse(scratch); + await resultPromise; + + assert.equal(client.page, home, 'this.page must stay pointed at the persistent home page'); + assert.equal(home._closeCalls, 0, 'the home page must never be closed by completeViaUi'); +}); + +test('regression proof: without closing in finally, repeated completeViaUi calls leak one page each', async () => { + // This reproduces the actual pre-fix code path structurally (open via + // newPage(), never close) to prove the fix above is load-bearing and not + // a no-op — i.e. that these tests would have failed against the old code. + const client = clientWithFakeBrowser({ ZAI_BROWSER_COMPLETION_TIMEOUT: '5000', ZAI_BROWSER_HEARTBEAT_MS: '100000' }); + const opened = []; + client.newPage = async () => { const p = fakePage(); opened.push(p); return p; }; + + async function buggyCompleteViaUi(prompt) { + await client.ensurePage(); + const page = await client.newPage(); // old code: no tracking, no close + return new Promise((resolve) => { + page.on('response', async () => resolve({ ok: true, status: 200, raw: 'ok' })); + setTimeout(() => fireResponse(page), 5); + }); + } + + await buggyCompleteViaUi('one'); + await buggyCompleteViaUi('two'); + await buggyCompleteViaUi('three'); + + assert.equal(opened.length, 3); + assert.equal(opened.filter(p => p._closeCalls > 0).length, 0, 'demonstrates the old code leaked every single page it opened'); +}); + +// --- openScratchPage: leak canary ---------------------------------------- + +test('openScratchPage warns when more than one scratch page is open at once', async () => { + const warnings = []; + const client = new ZaiBrowserClient({ env: { ...process.env }, logger: { info(){}, error(){}, warn: (m) => warnings.push(m) } }); + client.newPage = async () => fakePage(); + await client.openScratchPage(); + await client.openScratchPage(); + assert.ok(warnings.some(w => /2 scratch pages open at once/.test(w))); +}); + +// --- idle auto-close ------------------------------------------------------- + +test('an idle browser is closed automatically after ZAI_BROWSER_IDLE_CLOSE_MS', async () => { + const client = new ZaiBrowserClient({ env: { ...process.env, ZAI_BROWSER_IDLE_CLOSE_MS: '20', ZAI_BROWSER_IDLE_CHECK_MS: '5' }, logger: quietLogger }); + client.browser = { close: async () => { client.browser = null; } }; // pretend a browser is running + client.lastUsedAt = Date.now(); + client._ensureIdleTimer(); + await new Promise(r => setTimeout(r, 60)); + assert.equal(client.browser, null, 'the idle browser must have been closed'); + clearInterval(client._idleTimer); +}); + +test('touch() keeps the browser alive past the idle threshold', async () => { + const client = new ZaiBrowserClient({ env: { ...process.env, ZAI_BROWSER_IDLE_CLOSE_MS: '30', ZAI_BROWSER_IDLE_CHECK_MS: '5' }, logger: quietLogger }); + let closed = false; + client.browser = { close: async () => { closed = true; } }; + client.lastUsedAt = Date.now(); + client._ensureIdleTimer(); + const refresh = setInterval(() => client.touch(), 10); + await new Promise(r => setTimeout(r, 60)); + clearInterval(refresh); + clearInterval(client._idleTimer); + assert.equal(closed, false, 'repeated touch() calls must prevent the idle close from firing'); +}); + +test('ZAI_BROWSER_IDLE_CLOSE_MS=0 disables auto-close entirely', async () => { + const client = new ZaiBrowserClient({ env: { ...process.env, ZAI_BROWSER_IDLE_CLOSE_MS: '0' }, logger: quietLogger }); + client._ensureIdleTimer(); + assert.equal(client._idleTimer, null); +}); + +// --- describeState / close() cleanup -------------------------------------- + +test('describeState reports open scratch page count and idle time', () => { + const client = new ZaiBrowserClient({ env: { ...process.env, ZAI_BROWSER_IDLE_CLOSE_MS: '0' }, logger: quietLogger }); + client.openScratchPages.add(fakePage()); + client.lastUsedAt = Date.now() - 5000; + const state = client.describeState(); + assert.equal(state.openScratchPages, 1); + assert.ok(state.idleForMs >= 5000); +}); + +test('close() force-closes any still-open scratch pages and clears the idle timer', async () => { + const client = new ZaiBrowserClient({ env: { ...process.env, ZAI_BROWSER_IDLE_CLOSE_MS: '0' }, logger: quietLogger }); + const leaked = fakePage(); + client.openScratchPages.add(leaked); + client._ensureIdleTimer = () => { client._idleTimer = setInterval(() => {}, 1000); }; + client._ensureIdleTimer(); + await client.close(); + assert.equal(leaked._closeCalls, 1); + assert.equal(client.openScratchPages.size, 0); + assert.equal(client._idleTimer, null); +});