diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..1867d91 --- /dev/null +++ b/.containerignore @@ -0,0 +1,9 @@ +# Keep the build context free of browser sessions, auth exports, source-only +# helpers, and any future unlisted files. Containerfile copies this allow-list. +* +!Containerfile +!.containerignore +!package.json +!server.js +!lib/ +!lib/pow.js diff --git a/.env.example b/.env.example index 2347d62..2efef3d 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,18 @@ # ── HTTP server ── PORT=9655 -HOST=0.0.0.0 +# Loopback-only by default. Use 0.0.0.0 only when remote access is intentional. +HOST=127.0.0.1 +# Optional bearer token required by every non-health endpoint. Strongly +# recommended whenever HOST is not a loopback address. +# PROXY_API_KEY=replace-with-a-long-random-value +# Read the bearer token from a mounted secret instead of the environment. +# PROXY_API_KEY_FILE=/run/secrets/proxy-api-key +# Fail startup when neither key source is set (enabled by Containerfile). +REQUIRE_PROXY_API_KEY=0 +# Browser requests are accepted from loopback origins. Add exact remote UI +# origins as a comma-separated allowlist when needed. +# PROXY_CORS_ORIGINS=https://ui.example.com,http://192.168.1.20:3000 # ── Account auth source ── # The pool loads DeepSeek accounts from files. Use either a single file or a @@ -17,6 +28,17 @@ DEEPSEEK_AUTH_PATH=./deepseek-auth.json # (default: 600000 = 10 minutes). DEEPSEEK_ACCOUNT_COOLDOWN_MS=600000 +# ── Long agent sessions ── +# Maximum characters sent to DeepSeek Web after compacting old context. +# Lower this if the Web endpoint reports that the content is too long. +DEEPSEEK_MAX_PROMPT_CHARS=80000 +# Fresh-session retries for an empty/context-too-long response (default: 2). +DEEPSEEK_MAX_RETRIES=2 +# Clear session history on server start (1/true). Useful for development/testing. +# DEEPSEEK_CLEAR_SESSIONS_ON_START=0 +# Log MCP tool names loaded per agent (1/true). For debugging only. +# DEEPSEEK_LOG_TOOLS=0 + # ── Startup (deploy / CI) ── # Set either to 1/true to start the proxy immediately and skip the menu. NON_INTERACTIVE=0 diff --git a/.gitignore b/.gitignore index a8cab52..e6d4f84 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,11 @@ node_modules/ *.log auth.json deepseek-auth.json +# Aggregate account export holding live tokens/cookies — never commit. +deepseek-accounts.json # Runtime-added auth files contain secrets — keep the dir, ignore its contents. data/accounts/*.json +.chrome-profile/ .chrome-profile-deepseek/ .chrome-for-testing-profile-deepseek/ .chrome-for-testing-profile-deepseek.stale-*/ diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..cdf6a51 --- /dev/null +++ b/Containerfile @@ -0,0 +1,25 @@ +FROM docker.io/library/node:22-alpine + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=9655 \ + NON_INTERACTIVE=1 \ + DEEPSEEK_AUTH_PATH=/run/secrets/deepseek-auth.json \ + PROXY_API_KEY_FILE=/run/secrets/proxy-api-key \ + REQUIRE_PROXY_API_KEY=1 + +WORKDIR /app + +# FreeDeepseekAPI has no npm dependencies. Copy only the files needed by the +# non-interactive proxy; browser auth helpers and credentials stay on the host. +COPY --chown=1000:1000 package.json server.js ./ +COPY --chown=1000:1000 lib/pow.js ./lib/pow.js + +USER 1000:1000 + +EXPOSE 9655 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["node", "-e", "const http=require('http');const req=http.get({host:'127.0.0.1',port:process.env.PORT||9655,path:'/health'},res=>{res.resume();process.exit(res.statusCode===200?0:1)});req.on('error',()=>process.exit(1));req.setTimeout(4000,()=>{req.destroy();process.exit(1)})"] + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 00c5ee9..189a3ac 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # FreeDeepseekAPI

- Локальный OpenAI-compatible API proxy для DeepSeek Web Chat + Local OpenAI-compatible API proxy for DeepSeek Web Chat

@@ -12,38 +12,39 @@

- Быстрый старт • - Возможности • - Примеры • - Модели • + Quick Start • + Features • + Examples • + ModelsEndpointsOpen WebUI

-FreeDeepseekAPI поднимает локальный API-сервер для **DeepSeek Web Chat** (`chat.deepseek.com`) и позволяет подключать DeepSeek Web к Open WebUI, LiteLLM, Hermes, Claude Code, OpenAI SDK-style клиентам и другим OpenAI-compatible инструментам. +FreeDeepseekAPI runs a local API server for **DeepSeek Web Chat** (`chat.deepseek.com`) and lets you connect DeepSeek Web to Open WebUI, LiteLLM, Hermes, Claude Code, OpenAI SDK-style clients, and other OpenAI-compatible tools. -Проект работает через ваш обычный залогиненный аккаунт DeepSeek в отдельном Chrome-профиле. Локальный сервер принимает API-запросы, а дальше сам ходит в DeepSeek Web через сохранённую browser-сессию. +The project works through your regular logged-in DeepSeek account in a separate Chrome profile. The local server accepts API requests and then talks to DeepSeek Web through the saved browser session. -> ⚠️ Это экспериментальный web-chat proxy. DeepSeek может менять внутренний Web API без предупреждения. Для production-кейсов надёжнее официальный платный API DeepSeek. +> ⚠️ This is an experimental web-chat proxy. DeepSeek may change the internal Web API without warning. For production use cases, the official paid DeepSeek API is more reliable. ForgetMeAI: https://t.me/forgetmeai --- -## Навигация +## Navigation -- [Что это даёт](#-что-это-даёт) -- [Возможности](#-возможности) -- [Быстрый старт](#-быстрый-старт) -- [Windows запуск](#-windows-запуск) -- [Linux / Chromium запуск](#-linux--chromium-запуск) -- [VPS / headless запуск](#-vps--headless-запуск) +- [What this gives you](#-what-this-gives-you) +- [Features](#-features) +- [Quick Start](#-quick-start) +- [Windows launch](#-windows-launch) +- [Linux / Chromium launch](#-linux--chromium-launch) +- [VPS / headless launch](#-vps--headless-launch) +- [Rootless Podman](#-rootless-podman) - [Diagnostics / doctor](#-diagnostics--doctor) -- [Session reuse и сброс чатов](#-session-reuse-и-сброс-чатов) +- [Session reuse and chat reset](#-session-reuse-and-chat-reset) - [Multi-account pool](#-multi-account-pool) -- [Идеи для консольной авторизации](#-идеи-для-консольной-авторизации) -- [Проверка работы](#-проверка-работы) -- [Примеры запросов](#-примеры-запросов) +- [Console auth ideas](#-console-auth-ideas) +- [Verify it works](#-verify-it-works) +- [Request examples](#-request-examples) - [Chat Completions](#chat-completions) - [Reasoning](#reasoning) - [Web search](#web-search) @@ -51,40 +52,41 @@ ForgetMeAI: https://t.me/forgetmeai - [Anthropic Messages API](#anthropic-messages-api) - [OpenAI Responses API](#openai-responses-api) - [Tool calling](#tool-calling) -- [Модели](#-модели) +- [Models](#-models) - [Endpoints](#-endpoints) - [Open WebUI](#-open-webui) -- [Обновить логин](#-обновить-логин) -- [Статус проекта](#-статус-проекта) +- [Update login](#-update-login) +- [Tests](#-tests) +- [Project status](#-project-status) --- -## ✨ Что это даёт +## ✨ What this gives you -- Использовать DeepSeek Web как локальный API endpoint. -- Подключать DeepSeek к Open WebUI и другим OpenAI-compatible клиентам. -- Получать обычные JSON-ответы или streaming SSE. -- Использовать reasoning-модели с отдельным `reasoning_content`. -- Работать с Anthropic Messages API shim для Claude Code / Anthropic SDK. -- Использовать OpenAI Responses API shim для новых OpenAI/Codex-style клиентов. -- Держать отдельные web-сессии для разных агентов/users. +- Use DeepSeek Web as a local API endpoint. +- Connect DeepSeek to Open WebUI and other OpenAI-compatible clients. +- Get regular JSON responses or streaming SSE. +- Use reasoning models with separate `reasoning_content`. +- Work with Anthropic Messages API shim for Claude Code / Anthropic SDK. +- Use OpenAI Responses API shim for new OpenAI/Codex-style clients. +- Keep separate web sessions for different agents/users. -## 🚀 Возможности +## Features - **OpenAI-compatible API:** `POST /v1/chat/completions` - **Anthropic-compatible shim:** `POST /v1/messages` - **OpenAI Responses shim:** `POST /v1/responses` -- **Streaming:** SSE chunks и обычные non-stream JSON-ответы -- **Reasoning output:** отдельный `reasoning_content` для thinking-моделей -- **Tool calling:** парсинг OpenAI tools, Anthropic tools и Responses function tools -- **Model capabilities:** `GET /v1/model-capabilities` с alias → real web mode -- **Agent sessions:** отдельная DeepSeek-сессия на `user` / agent id -- **Session recovery:** авто-сброс устаревших chains/sessions -- **Zero dependencies:** Node.js 18+, без npm-зависимостей +- **Streaming:** SSE chunks and regular non-stream JSON responses +- **Reasoning output:** separate `reasoning_content` for thinking models +- **Tool calling:** parsing OpenAI tools, Anthropic tools, and Responses function tools +- **Model capabilities:** `GET /v1/model-capabilities` with alias → real web mode +- **Agent sessions:** separate DeepSeek session per `user` / agent id +- **Session recovery:** auto-reset of stale chains/sessions +- **Zero dependencies:** Node.js 18+, no npm dependencies --- -## ⚡ Быстрый старт +## ⚡ Quick Start ```bash git clone https://github.com/ForgetMeAI/FreeDeepseekAPI.git @@ -93,37 +95,52 @@ npm run auth npm start ``` -`npm run auth` открывает меню авторизации: +`npm run auth` opens the auth menu: -1. выберите пункт `1`; -2. войдите в DeepSeek в отдельном Chrome-профиле; -3. отправьте короткое сообщение вроде `ok`; -4. вернитесь в терминал и нажмите Enter. +1. select item `1`; +2. log in to DeepSeek in a separate Chrome profile; +3. send a short message like `ok`; +4. return to terminal and press Enter. -`npm start` показывает меню запуска: +`npm start` shows the launch menu: -- `1` — авторизоваться / обновить DeepSeek login -- `2` — показать модели и статусы -- `3` — запустить proxy -- `4` — выйти +- `1` — authorize / update DeepSeek login +- `2` — show models and statuses +- `3` — start proxy +- `4` — exit -Для headless/CI-запуска без меню: +For headless/CI launch without menu: ```bash NON_INTERACTIVE=1 npm start -# или +# or SKIP_ACCOUNT_MENU=1 npm start ``` -По умолчанию сервер слушает: +By default the server listens on: ```text http://localhost:9655 ``` +By default the proxy is only accessible from the same machine. To allow +network access, explicitly set the bind address and a proxy API key: + +```bash +HOST=0.0.0.0 PROXY_API_KEY='replace-with-a-long-random-value' npm start +``` + +Then pass the key as `Authorization: Bearer `. Without `PROXY_API_KEY` +non-health endpoints remain unauthenticated, so do not expose such an +instance to the network. + +Browser requests are allowed from loopback origins. If the UI is opened on a +different address, add its exact origin via comma, e.g. +`PROXY_CORS_ORIGINS=https://ui.example.com,http://192.168.1.20:3000`. + --- -## 🪟 Windows запуск +## Windows Launch ```powershell git clone https://github.com/ForgetMeAI/FreeDeepseekAPI.git @@ -132,18 +149,18 @@ npm run auth npm start ``` -Если Chrome установлен нестандартно, явно укажите путь: +If Chrome is installed in a non-standard location, specify the path explicitly: ```powershell $env:CHROME_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe" npm run auth ``` -Если Chrome не найден, `npm run auth` теперь печатает готовые инструкции для Windows/macOS/Linux вместо загадочного stack trace. +If Chrome is not found, `npm run auth` now prints ready-to-use instructions for Windows/macOS/Linux instead of a cryptic stack trace. --- -## 🐧 Linux / Chromium запуск +## Linux / Chromium Launch ```bash git clone https://github.com/ForgetMeAI/FreeDeepseekAPI.git @@ -152,33 +169,33 @@ CHROME_PATH=$(which chromium) npm run auth npm start ``` -Если Chromium называется иначе: +If Chromium has a different name: ```bash CHROME_PATH=$(which chromium-browser) npm run auth -# или +# or CHROME_PATH=$(which google-chrome) npm run auth ``` --- -## 🖥 VPS / headless запуск +## VPS / Headless Launch -Самый надёжный flow без Chrome на сервере: +The most reliable flow without Chrome on the server: -1. На домашнем ПК, где есть GUI/Chrome: +1. On your home PC (where you have GUI/Chrome): ```bash npm run auth ``` -2. Скопируйте `deepseek-auth.json` на VPS: +2. Copy `deepseek-auth.json` to VPS: ```bash scp deepseek-auth.json user@your-vps:/opt/FreeDeepseekAPI/deepseek-auth.json ``` -3. На VPS импортируйте/проверьте файл и выставьте безопасные права: +3. On VPS import/verify the file and set safe permissions: ```bash cd /opt/FreeDeepseekAPI @@ -186,87 +203,191 @@ npm run auth:import -- --input ./deepseek-auth.json npm run doctor -- --offline ``` -4. Запускайте proxy без интерактивного меню: +4. Start proxy without interactive menu: ```bash NON_INTERACTIVE=1 npm start ``` -Можно импортировать не только готовый `deepseek-auth.json`, но и browser cookie export: +You can import not only a ready-made `deepseek-auth.json`, but also a browser cookie export: ```bash DEEPSEEK_TOKEN="" npm run auth:import -- --input ./cookies.json ``` -> Важно: `deepseek-auth.json` — это доступ к вашему DeepSeek Web login. Не коммитьте, не публикуйте, храните с правами `0600`. +> Important: `deepseek-auth.json` gives access to your DeepSeek Web login. Do not commit, do not publish, store with `0600` permissions. + +--- + +## 🦭 Rootless Podman + +The container is intended for non-interactive proxy launch only. Perform +browser-based authorization on the host with `npm run auth`: auth scripts and +`deepseek-auth.json` are not copied into the image. + +Run Podman as a regular user, without `sudo`. + +1. Build the local image: + +```bash +podman build --tag localhost/free-deepseek-api:local --file Containerfile . +``` + +2. Pass DeepSeek auth and a separate proxy API key via Podman secrets: + +```bash +podman secret create --replace free-deepseek-auth ./deepseek-auth.json + +printf 'Proxy API key: ' +IFS= read -r -s PROXY_API_KEY +printf '\n' +printf '%s' "$PROXY_API_KEY" | + podman secret create --replace free-deepseek-proxy-key - +``` + +Use a long random key. The value stays in the current shell's `PROXY_API_KEY` +variable for API verification; it does not end up in the image or the Podman +command line. + +3. Run the container with minimal privileges: + +```bash +podman run --detach \ + --name free-deepseek-api \ + --publish 127.0.0.1:9655:9655 \ + --secret free-deepseek-auth,target=deepseek-auth.json,uid=1000,gid=1000,mode=0400 \ + --secret free-deepseek-proxy-key,target=proxy-api-key,uid=1000,gid=1000,mode=0400 \ + --read-only \ + --cap-drop=ALL \ + --security-opt=no-new-privileges \ + localhost/free-deepseek-api:local +``` + +Inside the container, `NON_INTERACTIVE=1`, `HOST=0.0.0.0`, and paths to both +secrets are pre-configured. `REQUIRE_PROXY_API_KEY=1` prevents the container +from starting if the key secret is missing or empty. On the host, the port is +only published to `127.0.0.1`; do not remove this address without a separate +network firewall/access policy. + +4. Verify liveness, account readiness, and the protected endpoint: + +```bash +podman healthcheck run free-deepseek-api +curl --fail http://127.0.0.1:9655/readyz +curl --fail \ + -H "Authorization: Bearer $PROXY_API_KEY" \ + http://127.0.0.1:9655/v1/models +``` + +The built-in healthcheck verifies the local `/health` (whether the process is +alive). `/readyz` additionally returns `503` if no DeepSeek auth account is +currently ready to serve requests. Container diagnostics: + +```bash +podman logs free-deepseek-api +podman inspect --format '{{.State.Health.Status}}' free-deepseek-api +``` + +Stop and remove the container along with the saved Podman secrets: + +```bash +podman stop free-deepseek-api +podman rm free-deepseek-api +podman secret rm free-deepseek-auth free-deepseek-proxy-key +unset PROXY_API_KEY +``` + +When rotating auth or proxy key, replace the corresponding secret and recreate +the container so that behavior does not depend on the Podman version. --- -## 🩺 Diagnostics / doctor +## Diagnostics / doctor ```bash npm run doctor -# без сетевых запросов к DeepSeek: +# without network requests to DeepSeek: npm run doctor -- --offline ``` -`doctor` проверяет: +`doctor` checks: -- найден ли `deepseek-auth.json` / `DEEPSEEK_AUTH_DIR`; -- валидный ли JSON; -- есть ли `token`, `cookie`, `wasmUrl`; -- безопасные ли права файла на macOS/Linux (`0600`); -- при обычном запуске — доступен ли DeepSeek PoW endpoint. +- whether `deepseek-auth.json` / `DEEPSEEK_AUTH_DIR` is found; +- whether JSON is valid; +- whether `token`, `cookie`, `wasmUrl` exist; +- whether file permissions are safe on macOS/Linux (`0600`); +- on normal run — whether DeepSeek PoW endpoint is reachable. -Если видите `data.biz_data is null`, `fetch failed`, `401/403/429` или Hermes/OpenCode не видит модели — первым делом запускайте `npm run doctor`. +If you see `data.biz_data is null`, `fetch failed`, `401/403/429` or Hermes/OpenCode doesn't see models — first run `npm run doctor`. --- -## ♻️ Session reuse и сброс чатов +## ♻️ Session reuse and chat reset -FreeDeepseekAPI не создаёт новый DeepSeek чат на каждый HTTP-запрос без причины. Логика такая: +FreeDeepseekAPI doesn't create a new DeepSeek chat on every HTTP request without reason. The logic is: -- один `x-agent-session`, `session` или `user` → одна DeepSeek chat session; -- если session id уже есть — proxy переиспользует его и продолжает chain через `parent_message_id`; -- auto-reset происходит при TTL, ошибке DeepSeek session или слишком длинной цепочке сообщений; -- локальная history сохраняется коротким контекстом, чтобы новая DeepSeek session могла продолжить разговор. +- one `x-agent-session`, `session`, or `user` → one DeepSeek chat session; +- if session id already exists — proxy reuses it and continues chain via `parent_message_id`; +- auto-reset happens on TTL, DeepSeek session error, or too long message chain; +- local history is saved as short context so new DeepSeek session can continue the conversation. +- long agent requests are trimmed before sending via `DEEPSEEK_MAX_PROMPT_CHARS` (default 80,000 characters): the beginning of the task, fresh tool results, and tool adapter are preserved; +- if the client already sent multi-turn history, local recovery-history is not added a second time; +- empty responses are retried up to `DEEPSEEK_MAX_RETRIES` times (default 2), with context shrinking on each retry. -Явно задать agent/session: +Explicitly set agent/session: ```bash curl -X POST http://localhost:9655/v1/chat/completions \ -H "Content-Type: application/json" \ -H "x-agent-session: my-agent" \ - -d '{"model":"deepseek-chat","messages":[{"role":"user","content":"Привет"}]}' + -d '{"model":"deepseek-chat","messages":[{"role":"user","content":"Hi"}]}' ``` -Посмотреть активные sessions: +View active sessions: ```bash curl http://localhost:9655/v1/sessions ``` -Сбросить одну session: +Reset a single session: ```bash curl -X POST "http://localhost:9655/reset-session?agent=my-agent" ``` -Сбросить все sessions: +Reset all sessions: ```bash curl -X POST "http://localhost:9655/reset-session?agent=all" ``` -Почему чаты всё равно появляются в DeepSeek Web: proxy работает через внутренний Web Chat API, а DeepSeek хранит реальные chat sessions у себя. Это нормально для web-proxy. Задача session reuse — не плодить новые чаты без необходимости и аккуратно сбрасываться только когда chain протух/сломался. +Clear sessions on server restart: + +```bash +# Start server with sessions cleared (fresh context each restart) +DEEPSEEK_CLEAR_SESSIONS_ON_START=1 npm start +``` + +This deletes `sessions.json` on startup, so the model starts with no memory of previous conversations. Useful for development/testing when you want clean context each restart. + +Debug tool loading: + +```bash +# Log all MCP tool names loaded per agent (for debugging) +DEEPSEEK_LOG_TOOLS=1 npm start +``` + +Shows which tools are being sent by the client and cached by the server. Disabled by default. + +Why chats still appear in DeepSeek Web: proxy works through internal Web Chat API, and DeepSeek stores real chat sessions on their side. This is normal for web-proxy. The task of session reuse is not to spawn new chats unnecessarily and to reset cleanly only when the chain has gone stale/broken. --- -## 👥 Multi-account pool +## Multi-account pool -Можно подключить несколько auth-файлов. Правильная модель: sticky account per agent/session — proxy не переключает аккаунт внутри живой DeepSeek-сессии. Если аккаунт получил `401/403/429` и ушёл в cooldown, session безопасно сбрасывается и новый запрос может перейти на другой доступный аккаунт. +You can connect multiple auth files. Correct model: sticky account per agent/session — proxy doesn't switch account inside a live DeepSeek session. If an account gets `401/403/429` and goes into cooldown, the session is safely reset and a new request may switch to another available account. -Вариант 1 — директория с auth-файлами: +Option 1 — directory with auth files: ```bash mkdir -p accounts @@ -276,22 +397,22 @@ chmod 600 accounts/*.json DEEPSEEK_AUTH_DIR=./accounts NON_INTERACTIVE=1 npm start ``` -Вариант 2 — список файлов: +Option 2 — file list: ```bash DEEPSEEK_AUTH_PATH="./accounts/main.json,./accounts/backup.json" NON_INTERACTIVE=1 npm start ``` -Как работает pool: +How the pool works: -- новый agent/session получает доступный аккаунт round-robin; -- выбранный аккаунт закрепляется за session (`sticky`); -- при `401`, `403`, `429` аккаунт уходит в cooldown; -- если sticky-аккаунт session ушёл в cooldown, старая DeepSeek-сессия сбрасывается, чтобы не долбить rate-limited/expired аккаунт; -- статус аккаунтов виден в `/health` без путей к auth-файлам и без имён файлов; -- auth-файлы должны храниться с правами `0600`. +- new agent/session gets an available account round-robin; +- selected account is pinned to session (`sticky`); +- on `401`, `403`, `429` account goes into cooldown; +- if sticky-account session went into cooldown, old DeepSeek session is reset to avoid hammering rate-limited/expired account; +- account status visible in `/health` without auth file paths or file names; +- auth files must be stored with `0600` permissions. -Настроить cooldown: +Configure cooldown: ```bash DEEPSEEK_ACCOUNT_COOLDOWN_MS=600000 npm start @@ -299,22 +420,22 @@ DEEPSEEK_ACCOUNT_COOLDOWN_MS=600000 npm start --- -## 🔑 Идеи для консольной авторизации +## Console auth ideas -Парольный flow из PR #3 можно делать, но безопаснее не хранить пароль и не делать это дефолтом. Нормальная реализация: +Password flow from PR #3 can be done, but safer not to store password and not make it default. Normal implementation: -1. `npm run auth:console` спрашивает email/телефон и пароль через hidden prompt. -2. Пароль держится только в памяти процесса, не пишется в файлы/logs/history. -3. Скрипт повторяет Web login flow через `fetch`/CDP: получает captcha/verify challenge, отдаёт человеку ссылку/код, ждёт подтверждение. -4. После успешного login сохраняется только `deepseek-auth.json` стандартного формата. -5. Если DeepSeek просит captcha/2FA — скрипт честно говорит “открой ссылку, пройди проверку, нажми Enter”, а не пытается обходить защиту. -6. Для VPS лучше режим `auth:console --no-save-password --output deepseek-auth.json`. +1. `npm run auth:console` asks for email/phone and password via hidden prompt. +2. Password stays only in process memory, not written to files/logs/history. +3. Script replicates Web login flow via `fetch`/CDP: gets captcha/verify challenge, gives human link/code, waits for confirmation. +4. After successful login, only standard-format `deepseek-auth.json` is saved. +5. If DeepSeek asks for captcha/2FA — script honestly says "open link, pass check, press Enter", doesn't try to bypass protection. +6. For VPS better mode `auth:console --no-save-password --output deepseek-auth.json`. -Минимальный безопасный MVP: console auth только интерактивный, без env-пароля. Допустимый automation-вариант: `DEEPSEEK_EMAIL=... npm run auth:console`, но пароль всё равно вводится hidden prompt. +Minimal safe MVP: console auth only interactive, no env password. Acceptable automation variant: `DEEPSEEK_EMAIL=... npm run auth:console`, but password still entered via hidden prompt. --- -## ✅ Проверка работы +## ✅ Verify it works ```bash curl http://localhost:9655/ @@ -322,11 +443,11 @@ curl http://localhost:9655/v1/models curl http://localhost:9655/v1/model-capabilities ``` -Если всё ок, `/health` вернёт статус сервера, список поддерживаемых aliases и `config_ready: true`. +If all good, `/health` returns server status, list of supported aliases, and `config_ready: true`. --- -## 🧪 Примеры запросов +## Request examples ### Chat Completions @@ -335,7 +456,7 @@ curl -X POST http://localhost:9655/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "deepseek-chat", - "messages": [{"role": "user", "content": "Привет! Ответь одной фразой."}], + "messages": [{"role": "user", "content": "Hi! Reply with one phrase."}], "stream": false }' ``` @@ -347,18 +468,18 @@ curl -X POST http://localhost:9655/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "deepseek-reasoner", - "messages": [{"role": "user", "content": "Реши коротко: почему небо голубое?"}], + "messages": [{"role": "user", "content": "Solve briefly: why is the sky blue?"}], "stream": false }' ``` -Для reasoning-моделей API отдаёт цепочку размышления отдельно от финального ответа: +For reasoning models, API returns the reasoning chain separately from the final answer: - non-stream: `choices[0].message.reasoning_content` - stream: `choices[0].delta.reasoning_content` - usage: `usage.completion_tokens_details.reasoning_tokens` -`reasoning_tokens` — приблизительная оценка по извлечённому DeepSeek Web `THINK`-тексту, потому что web stream не отдаёт официальный token usage по reasoning отдельно. +`reasoning_tokens` — approximate estimate based on extracted DeepSeek Web `THINK` text, because web stream doesn't return official token usage for reasoning separately. ### Web search @@ -367,7 +488,7 @@ curl -X POST http://localhost:9655/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "deepseek-chat-search", - "messages": [{"role": "user", "content": "Найди свежий факт про DeepSeek и ответь кратко."}], + "messages": [{"role": "user", "content": "Find a fresh fact about DeepSeek and reply briefly."}], "stream": false }' ``` @@ -379,7 +500,7 @@ curl -N -X POST http://localhost:9655/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "deepseek-chat", - "messages": [{"role": "user", "content": "Напиши короткую шутку."}], + "messages": [{"role": "user", "content": "Write a short joke."}], "stream": true }' ``` @@ -392,12 +513,12 @@ curl -X POST http://localhost:9655/v1/messages \ -d '{ "model": "deepseek-chat", "max_tokens": 512, - "messages": [{"role": "user", "content": "Ответь ровно OK"}], + "messages": [{"role": "user", "content": "Reply exactly OK"}], "stream": false }' ``` -Для Claude Code можно указывать backend напрямую: +For Claude Code you can specify backend directly: ```bash export ANTHROPIC_BASE_URL="http://127.0.0.1:9655" @@ -413,127 +534,130 @@ curl -X POST http://localhost:9655/v1/responses \ -H "Content-Type: application/json" \ -d '{ "model": "deepseek-chat", - "input": "Ответь ровно OK", + "input": "Reply exactly OK", "stream": false }' ``` ### Tool calling -FreeDeepseekAPI принимает: +FreeDeepseekAPI accepts: - OpenAI `tools`; - Anthropic `tools`; - Responses API function tools. -Прокси просит DeepSeek вернуть строгий JSON tool call, но также умеет парсить fallback-форматы: +Proxy asks DeepSeek to return strict JSON tool call, but also parses fallback formats: - `TOOL_CALL:` -- fenced JSON +- fenced JSON with an explicit `tool_call`, `tool_calls`, or `function_call` envelope - `...` +- DeepSeek DSML (`<|DSML|tool_calls>...`) and web variant with `<||DSML|| Tool Calls>` --- -## 🧠 Модели +## Models -`GET /v1/models` возвращает только aliases, которые сейчас проверены и работают через этот proxy. +`GET /v1/models` returns only aliases that are currently verified and working through this proxy. -### Рабочие aliases +### Working aliases -| Alias | Web mode | Reasoning | Web search | Комментарий | +| Alias | Web mode | Reasoning | Web search | Notes | | --- | --- | --- | --- | --- | -| `deepseek-chat` | `Быстрый` / `default` | нет | нет | базовый chat | -| `deepseek-v3` | `Быстрый` / `default` | нет | нет | совместимый alias | -| `deepseek-default` | `Быстрый` / `default` | нет | нет | совместимый alias | -| `deepseek-reasoner` | `Быстрый` / `default` | да | нет | `thinking_enabled=true` | -| `deepseek-r1` | `Быстрый` / `default` | да | нет | R1-compatible alias | -| `deepseek-chat-search` | `Быстрый` / `default` | нет | да | web search | -| `deepseek-default-search` | `Быстрый` / `default` | нет | да | web search alias | -| `deepseek-reasoner-search` | `Быстрый` / `default` | да | да | reasoning + search | -| `deepseek-r1-search` | `Быстрый` / `default` | да | да | R1-compatible + search | -| `deepseek-expert` | `Эксперт` / `expert` | нет | нет | Expert mode | -| `deepseek-v4-pro` | `Эксперт` / `expert` | да | нет | Expert + reasoning | - -Полный маппинг: +| `deepseek-chat` | `Fast` / `default` | no | no | basic chat | +| `deepseek-v3` | `Fast` / `default` | no | no | compatibility alias | +| `deepseek-default` | `Fast` / `default` | no | no | compatibility alias | +| `deepseek-reasoner` | `Fast` / `default` | yes | no | `thinking_enabled=true` | +| `deepseek-r1` | `Fast` / `default` | yes | no | R1-compatible alias | +| `deepseek-chat-search` | `Fast` / `default` | no | yes | web search | +| `deepseek-default-search` | `Fast` / `default` | no | yes | web search alias | +| `deepseek-reasoner-search` | `Fast` / `default` | yes | yes | reasoning + search | +| `deepseek-r1-search` | `Fast` / `default` | yes | yes | R1-compatible + search | +| `deepseek-expert` | `Expert` / `expert` | no | no | Expert mode | +| `deepseek-v4-pro` | `Expert` / `expert` | yes | no | Expert + reasoning | + +Full mapping: ```bash curl http://localhost:9655/v1/model-capabilities ``` -По официальной странице DeepSeek V4 Preview `deepseek-chat` и `deepseek-reasoner` сейчас route'ятся в `deepseek-v4-flash` non-thinking/thinking. В самом `chat.deepseek.com` direct stream точное имя чекпойнта не отдаётся (`model: ""`), поэтому proxy фиксирует одновременно web-режим (`default` / `Быстрый`) и актуальную официальную маршрутизацию (`DeepSeek-V4-Flash`). +According to the official DeepSeek V4 Preview page, `deepseek-chat` and `deepseek-reasoner` are currently routed to `deepseek-v4-flash` non-thinking/thinking. In `chat.deepseek.com` direct stream the exact checkpoint name is not returned (`model: ""`), so the proxy records both the web mode (`default` / `Fast`) and the current official routing (`DeepSeek-V4-Flash`). -Текущий вывод DeepSeek Web remote config показывает такие web-режимы: +Current DeepSeek Web remote config shows these web modes: -- `default` / UI `Быстрый` — работает; поддерживает `thinking_enabled` и `search_enabled`. -- `expert` / UI `Эксперт` — работает через актуальный web-контракт (`x-client-version=2.0.0`) и поддерживает `thinking_enabled`. В `/v1/models` выдаются `deepseek-expert` без reasoning и `deepseek-v4-pro` как Expert + reasoning. -- `vision` / UI `Распознавание` — виден в remote config, но сейчас direct Web API возвращает `backend_err_by_model` (`Vision is temporarily unavailable`). Поэтому `deepseek-vision` скрыт из `/v1/models`. +- `default` / UI `Fast` — works; supports `thinking_enabled` and `search_enabled`. +- `expert` / UI `Expert` — works via the current web contract (`x-client-version=2.0.0`) and supports `thinking_enabled`. In `/v1/models`, `deepseek-expert` is served without reasoning and `deepseek-v4-pro` as Expert + reasoning. +- `vision` / UI `Recognition` — visible in remote config, but currently the direct Web API returns `backend_err_by_model` (`Vision is temporarily unavailable`). Therefore `deepseek-vision` is hidden from `/v1/models`. -Search для Expert по remote config недоступен, поэтому `deepseek-expert-search` остаётся unsupported. +Search for Expert is unavailable per remote config, so `deepseek-expert-search` remains unsupported. --- -## 🔌 Endpoints +## Endpoints -| Method | Path | Назначение | +| Method | Path | Purpose | | --- | --- | --- | -| `GET` | `/` или `/health` | статус proxy | -| `GET` | `/v1/models` | список рабочих OpenAI-compatible aliases | -| `GET` | `/v1/model-capabilities` | полный маппинг aliases, real model, capabilities | +| `GET` | `/` or `/health` | proxy status | +| `GET` | `/v1/models` | list of working OpenAI-compatible aliases | +| `GET` | `/v1/model-capabilities` | full alias mapping, real model, capabilities | | `POST` | `/v1/chat/completions` | OpenAI-compatible Chat Completions | | `POST` | `/v1/messages` | Anthropic Messages API shim | | `POST` | `/v1/responses` | OpenAI Responses API shim | -| `GET` | `/v1/sessions` | активные локальные agent sessions | -| `POST` | `/reset-session?agent=` | сбросить одну session | -| `POST` | `/reset-session?agent=all` | сбросить все sessions | +| `GET` | `/v1/sessions` | active local agent sessions | +| `POST` | `/reset-session?agent=` | reset a single session | +| `POST` | `/reset-session?agent=all` | reset all sessions | --- -## 🖥 Open WebUI +## Open WebUI -Base URL для Open WebUI в Docker: +Base URL for Open WebUI in Docker: ```text http://host.docker.internal:9655/v1 ``` -Для локального запуска без Docker: +For local launch without Docker: ```text http://localhost:9655/v1 ``` -API key можно указать любой: proxy сам ходит в DeepSeek Web через сохранённую browser-сессию. +If `PROXY_API_KEY` is not set, any API key can be used. If the key is set, +the client must send exactly that value — proxy verifies the bearer token +before granting access to models, sessions, and completions. --- -## 🔐 Обновить логин +## Update login ```bash npm run auth npm start ``` -Если DeepSeek начал отвечать `401`, `403` или просит новый PoW/session — повторите `npm run auth` и обновите сохранённую browser-сессию. +If DeepSeek starts returning `401`, `403` or asks for a new PoW/session — re-run `npm run auth` to refresh the saved browser session. -Локальные файлы авторизации не должны попадать в GitHub: +Local auth files must not be committed to GitHub: - `deepseek-auth.json` - `.chrome-profile-deepseek/` - `.env` -Они уже добавлены в `.gitignore`. +They are already in `.gitignore`. --- -## 🧪 Тесты +## 🧪 Tests -Синтаксическая проверка проекта: +Syntax check for the project: ```bash npm test ``` -Live smoke-тесты против запущенного локального proxy: +Live smoke tests against a running local proxy: ```bash BASE_URL=http://127.0.0.1:9655 MODEL=deepseek-chat npm run test:live @@ -541,16 +665,16 @@ BASE_URL=http://127.0.0.1:9655 MODEL=deepseek-chat npm run test:live --- -## 📌 Статус проекта +## Project status -FreeDeepseekAPI — экспериментальный web-chat proxy для локального использования и интеграций. Он зависит от текущего контракта DeepSeek Web Chat, поэтому при изменениях на стороне DeepSeek может потребоваться обновление auth/session logic или model mapping. +FreeDeepseekAPI is an experimental web-chat proxy for local use and integrations. It depends on the current DeepSeek Web Chat contract, so when DeepSeek makes changes, auth/session logic or model mapping may need updating. -Если что-то перестало работать: +If something stops working: -1. обновите логин через `npm run auth`; -2. проверьте `/v1/model-capabilities`; -3. повторите запрос на свежей сессии; -4. если проблема сохраняется — вероятно, DeepSeek изменил внутренний Web API. +1. refresh login via `npm run auth`; +2. check `/v1/model-capabilities`; +3. retry the request on a fresh session; +4. if the problem persists — DeepSeek likely changed the internal Web API. --- diff --git a/chrome-extension/README.md b/chrome-extension/README.md deleted file mode 100644 index 169369d..0000000 --- a/chrome-extension/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# DeepSeek → FreeDeepseekAPI (расширение) - -Добавляет аккаунт DeepSeek в локальный FreeDeepseekAPI **одним кликом**: -перехватывает заголовки реального запроса к `chat.deepseek.com/api/...` -(`token` из `Authorization`, все cookie, `hif_*`) и отправляет на -`http://localhost:9655/api/accounts/import`. - -Работает в Firefox и Chrome/Edge (Manifest V3). - -## Установка - -**Firefox** -1. Откройте `about:debugging#/runtime/this-firefox` -2. «Загрузить временное дополнение» → выберите `manifest.json` из этой папки. - (Временное дополнение: после перезапуска Firefox установить заново.) - -**Chrome / Edge** -1. Откройте `chrome://extensions` -2. Включите «Режим разработчика». -3. «Загрузить распакованное» → выберите эту папку. - -## Использование -1. Запустите FreeDeepseekAPI (порт 9655). -2. Откройте `chat.deepseek.com` и войдите в нужный аккаунт. -3. **Отправьте любое сообщение** (например `ok`) — чтобы прошёл запрос, из которого берутся креды. -4. Клик по иконке расширения → **«➕ Добавить в FreeDeepseekAPI»**. - -Для нескольких аккаунтов повторите из разных профилей/логинов браузера. - -Вспомогательные кнопки: «Собрать» (показать креды), «Копировать JSON», -«Скачать файл» (`deepseek-auth.json`) — на случай ручного импорта через дашборд. diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 481c500..7b32d94 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -1,45 +1,97 @@ -// DeepSeek → FreeDeepseekAPI — перехват заголовков реального запроса. -// token (Authorization: Bearer), cookie (все), hif (x-hif-*) берутся из -// настоящего запроса к chat.deepseek.com/api/... — как в HAR/cURL. - -const WASM_DEFAULT = 'https://fe-static.deepseek.com/chat/static/sha3_wasm_bg.7b9ca65ddd.wasm'; -const KEY = 'deepseek_capture'; - -// extraHeaders нужен Chrome для доступа к Cookie/Authorization; Firefox даёт их без него. -const opts = ['requestHeaders']; -try { - if (chrome.webRequest.OnBeforeSendHeadersOptions && - chrome.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS) { - opts.push('extraHeaders'); - } -} catch (e) { /* Firefox: опции нет — это нормально */ } - -chrome.webRequest.onBeforeSendHeaders.addListener( - (details) => { - const h = {}; - for (const x of (details.requestHeaders || [])) h[x.name.toLowerCase()] = x.value; - const auth = h['authorization'] || ''; - const token = /^bearer\s+\S/i.test(auth) ? auth.replace(/^bearer\s+/i, '').trim() : ''; - const cookie = h['cookie'] || ''; - if (token && cookie) { - const cap = { - token, - cookie, - hif_dliq: h['x-hif-dliq'] || '', - hif_leim: h['x-hif-leim'] || '', - wasmUrl: WASM_DEFAULT, - _t: Date.now(), - }; - chrome.storage.local.set({ [KEY]: cap }); +// DeepSeek Auth Exporter — Background Service Worker +// Reads cookies from Chrome, forwards content-script localStorage data. + +const STORAGE_KEY = 'deepseek_auth'; + +// Read all needed cookies from chat.deepseek.com +async function readCookies() { + const needed = ['token', 'ds_session_id', 'smidV2']; + const results = {}; + for (const name of needed) { + const cookie = await new Promise((resolve) => + chrome.cookies.get({ url: 'https://chat.deepseek.com', name }, resolve) + ); + results[name] = cookie ? cookie.value : ''; + } + + // Build cookie header string + const parts = []; + if (results.ds_session_id) parts.push(`ds_session_id=${results.ds_session_id}`); + if (results.smidV2) parts.push(`smidV2=${results.smidV2}`); + results.cookie = parts.join('; '); + + return results; +} + +// Read localStorage values via content script injection +async function readLocalStorage(tabId) { + const keys = ['hif_dliq', 'hif_leim']; + try { + const results = await new Promise((resolve, reject) => { + chrome.tabs.sendMessage( + tabId, + { action: 'readLocalStorage', keys }, + (response) => { + if (chrome.runtime.lastError) reject(chrome.runtime.lastError.message); + else resolve(response.data || {}); } - }, - { urls: ['https://chat.deepseek.com/api/*'] }, - opts -); - -chrome.runtime.onMessage.addListener((req, sender, sendResponse) => { - if (req.action === 'get') { - chrome.storage.local.get(KEY, (r) => sendResponse({ success: true, cap: r[KEY] || null })); - return true; // async - } + ); + }); + return results; + } catch (e) { + return {}; + } +} + +// Find an open DeepSeek tab +function findDeepSeekTab() { + return new Promise((resolve) => { + chrome.tabs.query( + { url: 'https://chat.deepseek.com/*' }, + (tabs) => resolve(tabs.length > 0 ? tabs[0] : null) + ); + }); +} + +async function collectAndStore(tabId) { + const cookies = await readCookies(); + let ls = {}; + if (tabId) ls = await readLocalStorage(tabId); + + const merged = { + token: cookies.token || '', + ds_session_id: cookies.ds_session_id || '', + smidV2: cookies.smidV2 || '', + cookie: cookies.cookie || '', + hif_dliq: ls.hif_dliq || '', + hif_leim: ls.hif_leim || '', + _lastUpdated: new Date().toISOString(), + }; + + await new Promise((resolve) => + chrome.storage.local.set({ [STORAGE_KEY]: merged }, resolve) + ); + return merged; +} + +// Message handler — popup requests +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'collect') { + findDeepSeekTab().then(async (tab) => { + if (!tab) { + sendResponse({ success: false, error: 'No DeepSeek tab open' }); + return; + } + const auth = await collectAndStore(tab.id); + sendResponse({ success: true, auth }); + }); + return true; // keep channel open for async + } + + if (request.action === 'export') { + chrome.storage.local.get(STORAGE_KEY, (result) => { + sendResponse({ success: true, auth: result[STORAGE_KEY] || {} }); + }); + return true; + } }); diff --git a/chrome-extension/icons/icon128.png b/chrome-extension/icons/icon128.png deleted file mode 100644 index ccce267..0000000 Binary files a/chrome-extension/icons/icon128.png and /dev/null differ diff --git a/chrome-extension/icons/icon16.png b/chrome-extension/icons/icon16.png deleted file mode 100644 index 14af773..0000000 Binary files a/chrome-extension/icons/icon16.png and /dev/null differ diff --git a/chrome-extension/icons/icon48.png b/chrome-extension/icons/icon48.png deleted file mode 100644 index dc6a5a2..0000000 Binary files a/chrome-extension/icons/icon48.png and /dev/null differ diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 5501740..8b51a78 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,30 +1,21 @@ { "manifest_version": 3, - "name": "DeepSeek → FreeDeepseekAPI", - "version": "1.4", - "description": "Добавляет аккаунт DeepSeek в локальный FreeDeepseekAPI одним кликом. Перехватывает заголовки запроса chat.deepseek.com (token + cookie + hif).", - "author": "FreeDeepseekAPI", - "browser_specific_settings": { - "gecko": { - "id": "freedeepseek-auth@forgetmeai", - "strict_min_version": "121.0", - "data_collection_permissions": { "required": ["none"] } + "name": "DeepSeek Auth Exporter", + "version": "1.0", + "description": "Extract DeepSeek Chat credentials from cookies + localStorage for use with the web API proxy", + "permissions": ["cookies", "storage", "downloads"], + "host_permissions": ["https://chat.deepseek.com/*"], + "content_scripts": [ + { + "matches": ["https://chat.deepseek.com/*"], + "js": ["content.js"], + "run_at": "document_idle" } - }, - "permissions": ["webRequest", "storage"], - "host_permissions": [ - "https://chat.deepseek.com/*", - "http://localhost:9655/*", - "http://127.0.0.1:9655/*" ], "background": { - "service_worker": "background.js", - "scripts": ["background.js"] + "service_worker": "background.js" }, "action": { - "default_popup": "popup.html", - "default_title": "DeepSeek → FreeDeepseekAPI", - "default_icon": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } - }, - "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" } + "default_popup": "popup.html" + } } diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html index 05f1be5..f5837d7 100644 --- a/chrome-extension/popup.html +++ b/chrome-extension/popup.html @@ -22,54 +22,18 @@ .json-display { background: #0d1117; border: 1px solid #333; border-radius: 4px; padding: 8px; font-size: 10px; font-family: 'Courier New', monospace; color: #7ee787; white-space: pre-wrap; word-break: break-all; max-height: 180px; overflow-y: auto; margin-bottom: 8px; } .field label { display: block; font-size: 11px; color: #999; margin-bottom: 4px; } .detail { font-size: 10px; color: #666; text-align: center; } - .pool-section { margin-top: 14px; border-top: 1px solid #333; padding-top: 10px; } - .pool-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } - .pool-header .title { font-size: 12px; color: #4fc3f7; font-weight: 600; } - .link-btn { background: none; border: 1px solid #444; color: #9ccc65; border-radius: 5px; padding: 4px 8px; font-size: 11px; cursor: pointer; } - .link-btn:hover { background: #ffffff10; } - .link-btn:disabled { opacity: 0.5; cursor: default; } - .pool-list { display: flex; flex-direction: column; gap: 4px; max-height: 220px; overflow-y: auto; } - .pool-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #0d1117; border: 1px solid #2a2a3a; border-radius: 5px; font-size: 11px; } - .pool-row .id { color: #888; font-family: 'Courier New', monospace; min-width: 40px; } - .pool-row .email { flex: 1; color: #ccc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .badge { padding: 2px 7px; border-radius: 10px; font-size: 10px; font-weight: 600; white-space: nowrap; } - .badge.ok { background: #1b5e2033; color: #66bb6a; border: 1px solid #2e7d32; } - .badge.invalid { background: #b71c1c33; color: #ef5350; border: 1px solid #c62828; } - .badge.wait { background: #e6510033; color: #ff9800; border: 1px solid #e65100; } - .badge.expired { background: #44444433; color: #999; border: 1px solid #555; } - .badge.checking { background: #1565c033; color: #4fc3f7; border: 1px solid #1565c0; } - .row-actions { display: flex; gap: 4px; } - .acc-btn { background: #ffffff0d; border: 1px solid #444; color: #ddd; border-radius: 4px; padding: 3px 7px; font-size: 11px; cursor: pointer; } - .acc-btn:hover { background: #ffffff1a; } - .acc-btn.danger:hover { background: #b71c1c44; color: #ef5350; } - .acc-btn.confirm { background: #b71c1c; color: #fff; border-color: #c62828; } - .pool-empty { color: #666; font-size: 11px; text-align: center; padding: 10px; } - .hdr { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; } - .hdr h1 { margin: 0; } - .srv-dot { width: 9px; height: 9px; border-radius: 50%; background: #666; flex: none; } - .srv-dot.online { background: #66bb6a; box-shadow: 0 0 5px #2e7d32; } - .srv-dot.offline { background: #ef5350; box-shadow: 0 0 5px #c62828; } - .btn-row button:disabled { opacity: 0.45; cursor: default; } - .pool-row .email { cursor: pointer; } - .pool-row .email:hover { color: #4fc3f7; } -
-

🔑 DeepSeek → FreeDeepseekAPI

- -
-
Откройте chat.deepseek.com, отправьте любое сообщение, затем нажмите кнопку
+

🔑 DeepSeek Auth Exporter

+
Export credentials for FreeDeepseekAPI
⏳ Loading...
- -
-
- - - + + +
@@ -79,17 +43,6 @@

🔑 DeepSeek → FreeDeepseekAPI

Open chat.deepseek.com, then click Collect
-
-
- Пул аккаунтов - - - - -
-
-
- diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js index 423ea09..4e86805 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -1,184 +1,103 @@ -// DeepSeek → FreeDeepseekAPI — Popup -function $(id) { return document.getElementById(id); } -const API_BASE = 'http://localhost:9655'; -const PROXY_URL = API_BASE + '/api/accounts/import'; - -let current = null; // перехваченный набор кредов {token,cookie,hif_*,wasmUrl} - -function setStatus(cls, text) { $('status').className = 'status ' + cls; $('status').textContent = text; } -function setSrvDot(online) { const d = $('srvDot'); if (d) { d.className = 'srv-dot ' + (online ? 'online' : 'offline'); d.title = online ? 'Сервер онлайн (:9655)' : 'Сервер недоступен (:9655)'; } } -function setCredButtons(has) { for (const id of ['btnAdd', 'btnCopy', 'btnSave']) { const b = $(id); if (b) b.disabled = !has; } } - -function render(cap) { - if (!cap || !cap.token || !cap.cookie) { - setStatus('warn', '⚠️ Откройте chat.deepseek.com и ОТПРАВЬТЕ любое сообщение, затем нажмите кнопку.'); - $('jsonPreview').textContent = '{ }'; - $('detail').textContent = 'Креды появятся после запроса к DeepSeek'; - setCredButtons(false); - return null; - } - const auth = { token: cap.token, hif_dliq: cap.hif_dliq || '', hif_leim: cap.hif_leim || '', cookie: cap.cookie, wasmUrl: cap.wasmUrl }; - // превью с маскировкой секретов - $('jsonPreview').textContent = JSON.stringify({ - token: auth.token.slice(0, 6) + '…(' + auth.token.length + ')', - cookie: auth.cookie.slice(0, 48) + '…', - hif_leim: auth.hif_leim ? ('…(' + auth.hif_leim.length + ')') : '', - }, null, 2); - setStatus('ok', '✅ Перехвачено: token + cookie' + (auth.hif_leim ? ' + hif' : '') + ' — готово'); - $('detail').textContent = cap._t ? ('Обновлено: ' + new Date(cap._t).toLocaleTimeString()) : ''; - setCredButtons(true); - return auth; -} - -function refresh() { - chrome.runtime.sendMessage({ action: 'get' }, (r) => { current = (r && r.success) ? render(r.cap) : render(null); }); -} - -// ── Панель пула (строится через DOM API, без innerHTML, чтобы исключить XSS) ── -function mkBtn(act, label, title, cls) { - const b = document.createElement('button'); - b.className = cls; b.dataset.act = act; b.title = title; b.textContent = label; - return b; -} - -function setEmpty(text) { - const pool = $('pool'); pool.textContent = ''; - const e = document.createElement('div'); e.className = 'pool-empty'; e.textContent = text; - pool.appendChild(e); -} +// DeepSeek Auth Exporter — Popup Script -function renderPool(list) { - $('poolTitle').textContent = `Пул аккаунтов (${list.length})`; - if (!list.length) { setEmpty('Нет аккаунтов'); return; } - const pool = $('pool'); pool.textContent = ''; - for (const a of list) { - const row = document.createElement('div'); - row.className = 'pool-row'; row.dataset.id = a.id; - - const idEl = document.createElement('span'); - idEl.className = 'id'; idEl.textContent = a.id; - - const emailEl = document.createElement('span'); - emailEl.className = 'email'; emailEl.textContent = a.label || a.email || '—'; emailEl.title = a.email || a.label || ''; - - const badge = document.createElement('span'); - badge.className = 'badge ' + (a.status || '').toLowerCase(); badge.textContent = a.status || '—'; +function $(id) { return document.getElementById(id); } - const actions = document.createElement('span'); - actions.className = 'row-actions'; - actions.append(mkBtn('check', '↻', 'Проверить', 'acc-btn'), mkBtn('del', '✕', 'Удалить', 'acc-btn danger')); +const WASM_URL = 'https://fe-static.deepseek.com/chat/static/sha3_wasm_bg.7b9ca65ddd.wasm'; - row.append(idEl, emailEl, badge, actions); - pool.appendChild(row); - } -} +function buildAuthJson(data) { + const cookie = []; + if (data.ds_session_id) cookie.push(`ds_session_id=${data.ds_session_id}`); + if (data.smidV2) cookie.push(`smidV2=${data.smidV2}`); -async function loadPool() { - try { - const r = await fetch(API_BASE + '/api/accounts'); - const j = await r.json(); - setSrvDot(true); - renderPool(j.accounts || []); - } catch { - setSrvDot(false); - $('poolTitle').textContent = 'Пул аккаунтов'; - setEmpty('FreeDeepseekAPI недоступен на :9655'); - } + return { + token: data.token || '', + hif_dliq: data.hif_dliq || '', + hif_leim: data.hif_leim || '', + cookie: cookie.join('; '), + wasmUrl: WASM_URL, + }; } -async function checkAccount(id) { - const row = document.querySelector(`.pool-row[data-id="${id}"]`); - const b = row && row.querySelector('.badge'); - if (b) { b.className = 'badge checking'; b.textContent = '…'; } - try { - const r = await fetch(`${API_BASE}/api/accounts/${id}/check`, { method: 'POST' }); - const j = await r.json(); - if (b) { b.className = 'badge ' + (j.status || '').toLowerCase(); b.textContent = j.status || '—'; } - if (j.email && row) { const em = row.querySelector('.email'); em.textContent = j.email; em.title = j.email; } - return j.status; - } catch { if (b) { b.className = 'badge invalid'; b.textContent = 'ERR'; } return 'ERROR'; } +function getStatus(auth, data) { + const checks = [ + { label: 'token', ok: !!auth.token }, + { label: 'cookie (ds_session_id / smidV2)', ok: auth.cookie.includes('=') }, + { label: 'hif_dliq', ok: !!auth.hif_dliq }, + { label: 'hif_leim', ok: !!auth.hif_leim }, + ]; + return { checks, allOk: checks.every((c) => c.ok) }; } -async function deleteAccount(id) { - try { await fetch(`${API_BASE}/api/accounts/${id}`, { method: 'DELETE' }); } catch { /* noop */ } - loadPool(); +function render(data) { + const auth = buildAuthJson(data); + const preview = JSON.stringify(auth, null, 2); + $('jsonPreview').textContent = preview; + + const { checks, allOk } = getStatus(auth, data); + const missing = checks.filter((c) => !c.ok).map((c) => c.label); + + if (!data._lastUpdated) { + $('status').className = 'status warn'; + $('status').textContent = '⚠️ No credentials yet. Click "Collect from Tab" while on chat.deepseek.com'; + } else if (allOk) { + $('status').className = 'status ok'; + $('status').textContent = '✅ All 4 credentials captured — ready to export'; + } else { + $('status').className = 'status warn'; + $('status').textContent = `⚠️ Missing: ${missing.join(', ')}`; + } + + $('detail').textContent = data._lastUpdated + ? `Last updated: ${data._lastUpdated}` + : 'Open chat.deepseek.com, then click Collect'; } -// делегирование кликов в панели (check / удаление с инлайн-подтверждением) -$('pool').addEventListener('click', (e) => { - const emailEl = e.target.closest('.email'); - if (emailEl && emailEl.textContent && emailEl.textContent !== '—') { - const full = emailEl.title || emailEl.textContent; - navigator.clipboard.writeText(full).then(() => { const o = emailEl.textContent; emailEl.textContent = '✓ скопировано'; setTimeout(() => { emailEl.textContent = o; }, 900); }); - return; - } - const btn = e.target.closest('.acc-btn'); if (!btn) return; - const row = btn.closest('.pool-row'); const id = row && row.dataset.id; if (!id) return; - const act = btn.dataset.act; - if (act === 'check') { checkAccount(id); return; } - if (act === 'del') { - const actions = btn.parentElement; actions.textContent = ''; - actions.append(mkBtn('yes', '✓', 'Удалить', 'acc-btn confirm'), mkBtn('no', '✗', 'Отмена', 'acc-btn')); - return; +function loadAuth() { + chrome.runtime.sendMessage({ action: 'export' }, (response) => { + if (response && response.success) render(response.auth); + else { + $('status').className = 'status err'; + $('status').textContent = '❌ Failed to read stored credentials'; } - if (act === 'yes') deleteAccount(id); - else if (act === 'no') loadPool(); -}); - -async function checkAll() { - $('btnCheckAll').disabled = true; - const ids = [...document.querySelectorAll('.pool-row')].map(r => r.dataset.id); - for (const id of ids) await checkAccount(id); - $('btnCheckAll').disabled = false; + }); } -$('btnCheckAll').addEventListener('click', checkAll); -$('btnDashboard').addEventListener('click', () => { chrome.tabs.create({ url: API_BASE + '/dashboard' }); }); -// ── Главная кнопка — добавить перехваченные креды + авто-валидация ── -$('btnAdd').addEventListener('click', async () => { - if (!current) { - refresh(); - setStatus('warn', '⏳ Кредов нет. Отправьте сообщение в DeepSeek и нажмите снова.'); - return; - } - setStatus('warn', '⏳ Отправка в FreeDeepseekAPI…'); - try { - const r = await fetch(PROXY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(current) }); - const j = await r.json(); - if (j.ok) { - const who = j.email ? ` (${j.email})` : ''; - setStatus('ok', `✅ Добавлен как ${j.id}${who} — проверяю…`); - await loadPool(); - const st = await checkAccount(j.id); - if (st === 'OK') setStatus('ok', `🟢 ${j.id}${who} — рабочий`); - else setStatus('err', `🔴 ${j.id} — статус: ${st || 'неизвестен'}`); - } else if (j.existingId) { - setStatus('warn', `⚠️ Уже добавлен как ${j.existingId}`); - loadPool(); - } else { - setStatus('err', '❌ ' + (j.error || 'Ошибка добавления')); - } - } catch (e) { - setStatus('err', '❌ FreeDeepseekAPI недоступен на localhost:9655 (запущен?)'); +// Collect button — reads cookies + localStorage from active DeepSeek tab +$('btnCollect').addEventListener('click', () => { + $('status').className = 'status warn'; + $('status').textContent = '⏳ Collecting from chat.deepseek.com...'; + chrome.runtime.sendMessage({ action: 'collect' }, (response) => { + if (response && response.success) { + render(response.auth); + } else { + $('status').className = 'status err'; + $('status').textContent = '❌ ' + (response?.error || 'Unknown error'); } + }); }); -$('btnCollect').addEventListener('click', refresh); - +// Copy JSON button $('btnCopy').addEventListener('click', () => { - if (!current) return; - navigator.clipboard.writeText(JSON.stringify(current, null, 2)).then(() => { - $('btnCopy').textContent = '✅'; setTimeout(() => { $('btnCopy').textContent = '📋 Копировать JSON'; }, 1200); - }); + const json = $('jsonPreview').textContent; + navigator.clipboard.writeText(json).then(() => { + $('btnCopy').textContent = '✅ Copied!'; + setTimeout(() => { $('btnCopy').textContent = '📋 Copy JSON'; }, 1500); + }); }); +// Download file button $('btnSave').addEventListener('click', () => { - if (!current) return; - const blob = new Blob([JSON.stringify(current, null, 2) + '\n'], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); a.href = url; a.download = 'deepseek-auth.json'; a.click(); - URL.revokeObjectURL(url); + const json = $('jsonPreview').textContent; + const blob = new Blob([json + '\n'], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'deepseek-auth.json'; + a.click(); + URL.revokeObjectURL(url); + $('btnSave').textContent = '✅ Saved!'; + setTimeout(() => { $('btnSave').textContent = '💾 Download File'; }, 1500); }); -refresh(); -loadPool(); +// Initial load +loadAuth(); diff --git a/client.js b/client.js index 773f904..ceb502c 100755 --- a/client.js +++ b/client.js @@ -15,6 +15,8 @@ const fs = require('fs'); const path = require('path'); +const os = require('os'); +const { solvePOW } = require('./lib/pow'); // Load from env or config file const CONFIG = { @@ -57,28 +59,8 @@ const BASE_HEADERS = { 'Cookie': CONFIG.cookie, 'Content-Type': 'application/json', }; -async function solvePOW(challenge) { - const resp = await fetch(CONFIG.wasmUrl); - const wasmBytes = await resp.arrayBuffer(); - const mod = await WebAssembly.instantiate(wasmBytes, { wbg: {} }); - const e = mod.instance.exports; - const encoder = new TextEncoder(); - const prefix = challenge.salt + '_' + challenge.expire_at + '_'; - const cBytes = encoder.encode(challenge.challenge); - const pBytes = encoder.encode(prefix); - const cP = e.__wbindgen_export_0(cBytes.length, 1) >>> 0; - const pP = e.__wbindgen_export_0(pBytes.length, 1) >>> 0; - new Uint8Array(e.memory.buffer, cP, cBytes.length).set(cBytes); - new Uint8Array(e.memory.buffer, pP, pBytes.length).set(pBytes); - const sp = e.__wbindgen_add_to_stack_pointer(-16); - e.wasm_solve(sp, cP, cBytes.length, pP, pBytes.length, challenge.difficulty); - const dv = new DataView(e.memory.buffer); - const code = dv.getInt32(sp, true); - const ans = dv.getFloat64(sp + 8, true); - e.__wbindgen_add_to_stack_pointer(16); - if (code === 0 || !Number.isFinite(ans) || ans <= 0) throw new Error('POW solve failed'); - return Math.floor(ans); -} +// solvePOW() is imported from lib/pow (compiled-module cache + WASM-fetch timeout), +// shared with server.js. async function askDeepSeek(prompt, onChunk) { const chalResp = await fetch('https://chat.deepseek.com/api/v0/chat/create_pow_challenge', { @@ -87,7 +69,7 @@ async function askDeepSeek(prompt, onChunk) { }); const chalData = await chalResp.json(); const challenge = chalData.data.biz_data.challenge; - const answer = await solvePOW(challenge); + const answer = await solvePOW(challenge, CONFIG.wasmUrl); const sessResp = await fetch('https://chat.deepseek.com/api/v0/chat_session/create', { method: 'POST', headers: BASE_HEADERS, body: '{}' @@ -145,23 +127,28 @@ async function askDeepSeek(prompt, onChunk) { return fullResponse; } +function readStdin() { + // fd 0 works on Windows too (unlike '/dev/stdin'); empty if no pipe. + try { return fs.readFileSync(0, 'utf8').trim(); } catch { return ''; } +} + async function main() { - const prompt = process.argv.slice(2).join(' ') || fs.readFileSync('/dev/stdin', 'utf8').trim(); + const prompt = process.argv.slice(2).join(' ') || readStdin(); if (!prompt) { console.error('Usage: node client.js "your prompt here"'); process.exit(1); } let fullText = ''; - const response = await askDeepSeek(prompt, (chunk) => { + await askDeepSeek(prompt, (chunk) => { process.stdout.write(chunk); fullText += chunk; }); process.stdout.write('\n'); - const ts = Date.now(); - fs.writeFileSync(`/tmp/deepseek_response_${ts}.txt`, fullText.trim()); - console.error(`\n[*] Saved /tmp/deepseek_response_${ts}.txt`); + const outFile = path.join(os.tmpdir(), `deepseek_response_${Date.now()}.txt`); + fs.writeFileSync(outFile, fullText.trim()); + console.error(`\n[*] Saved ${outFile}`); } main().catch(e => { diff --git a/lib/parseAuth.js b/lib/parseAuth.js index b406925..df11c00 100644 --- a/lib/parseAuth.js +++ b/lib/parseAuth.js @@ -1,15 +1,15 @@ 'use strict'; /* - Общий парсер авторизации DeepSeek из "Copy as cURL" или HAR-файла. - Используется и CLI-скриптами (scripts/auth_from_*.js), и эндпоинтом - дашборда POST /api/accounts/import. Возвращает плоский объект - { token, cookie, hif_dliq, hif_leim, wasmUrl } или { error }. + Common DeepSeek auth parser from "Copy as cURL" or HAR file. + Used by both CLI scripts (scripts/auth_from_*.js) and dashboard + endpoint POST /api/accounts/import. Returns flat object + { token, cookie, hif_dliq, hif_leim, wasmUrl } or { error }. */ const WASM_DEFAULT = 'https://fe-static.deepseek.com/chat/static/sha3_wasm_bg.7b9ca65ddd.wasm'; // -H 'name: value' | -H "name: value" | -H $'name: value' (Chrome bash ANSI-C) | --header ... -// Опциональный \$? перед кавычкой покрывает форму "Copy as cURL (bash)". +// Optional \$? before the quote covers Chrome's "Copy as cURL (bash)" form. function extractHeadersFromCurl(curl) { const headers = {}; const re = /(?:-H|--header)\s+\$?(['"])(.+?):\s?([\s\S]*?)\1(?=\s|$)/g; @@ -32,7 +32,7 @@ function fromHeaders(h) { } function parseCurl(curl) { - // снять bash line-continuations (\ + перевод строки), которые Chrome вставляет в "Copy as cURL" + // strip bash line-continuations (\ + newline) that Chrome inserts in "Copy as cURL" const s = String(curl || '').replace(/\\\r?\n/g, ' '); const r = fromHeaders(extractHeadersFromCurl(s)); const wm = s.match(/https?:\/\/[^\s'"]*sha3[^\s'"]*\.wasm/i); @@ -43,11 +43,11 @@ function parseCurl(curl) { function parseHar(harText) { let har; try { har = (typeof harText === 'object') ? harText : JSON.parse(harText); } - catch { return { error: 'Не удалось прочитать HAR (не JSON)' }; } + catch { return { error: 'Failed to parse HAR (not valid JSON)' }; } const entries = (har.log && har.log.entries) || []; const hv = (hs, n) => { const x = (hs || []).find(y => (y.name || '').toLowerCase() === n); return x ? (x.value || '') : ''; }; - // выбираем лучший запрос к deepseek с Authorization: Bearer + // pick best request to deepseek with Authorization: Bearer let best = null; for (const e of entries) { const req = e.request || {}; @@ -63,40 +63,40 @@ function parseHar(harText) { best = { score, token: auth.replace(/^Bearer\s+/i, '').trim(), cookie, hif_dliq: dliq, hif_leim: leim }; } } - if (!best) return { error: 'В HAR нет запросов к deepseek.com с заголовком Authorization: Bearer' }; + if (!best) return { error: 'No requests to deepseek.com with Authorization: Bearer header in HAR' }; let wasmUrl = ''; for (const e of entries) { const u = (e.request && e.request.url) || ''; if (/sha3.*\.wasm/i.test(u)) { wasmUrl = u; break; } } return { token: best.token, cookie: best.cookie, hif_dliq: best.hif_dliq, hif_leim: best.hif_leim, wasmUrl }; } -// Авто-определение формата ввода (HAR — JSON с log.entries; иначе cURL). +// Auto-detect input format (HAR — JSON with log.entries; otherwise cURL). function parseAuthInput(text) { const s = String(text || '').trim(); - if (!s) return { error: 'Пустой ввод' }; + if (!s) return { error: 'Empty input' }; if (s[0] === '{' || s[0] === '[') { - // готовый JSON {token,cookie,...} (например, из расширения-экспортёра) + // ready-made JSON {token,cookie,...} (e.g. from a browser extension export) try { const o = JSON.parse(s); if (o && typeof o === 'object' && o.token && o.cookie) { return { token: String(o.token), cookie: String(o.cookie), hif_dliq: o.hif_dliq || '', hif_leim: o.hif_leim || '', wasmUrl: o.wasmUrl || '' }; } - } catch { /* не JSON-объект — пробуем HAR ниже */ } + } catch { /* not a plain JSON object — try HAR below */ } const r = parseHar(s); - if (!r.error) return r; // это был HAR + if (!r.error) return r; // it was a HAR } if (/\bcurl\b|--header|(^|\s)-H\s/i.test(s)) return parseCurl(s); - // последняя попытка — вдруг HAR без явного префикса + // last attempt — maybe HAR without explicit prefix return parseHar(s); } -// Валидация + проставление wasmUrl (из ввода → из прошлого аккаунта → дефолт). +// Validation + fill wasmUrl (from input → from previous account → default). function finalizeAuth(parsed, prevWasmUrl) { - if (!parsed || parsed.error) return parsed || { error: 'Пусто' }; + if (!parsed || parsed.error) return parsed || { error: 'Empty' }; const missing = []; if (!parsed.token) missing.push('token (authorization: Bearer)'); if (!parsed.cookie) missing.push('cookie'); - if (missing.length) return { error: 'Не найдено: ' + missing.join(', ') }; + if (missing.length) return { error: 'Missing: ' + missing.join(', ') }; return { token: parsed.token, hif_dliq: parsed.hif_dliq || '', diff --git a/lib/pow.js b/lib/pow.js new file mode 100644 index 0000000..8b1c8e3 --- /dev/null +++ b/lib/pow.js @@ -0,0 +1,54 @@ +'use strict'; +// Proof-of-work solver for the DeepSeek Web API. +// +// The PoW WASM module is immutable per URL, so we compile it ONCE and cache the +// compiled WebAssembly.Module; each solve only spins up a fresh instance (clean +// linear memory). This removes a network download + recompile from every single +// completion (and every retry), and gives the WASM fetch a hard timeout so a +// stalled CDN can never hang a request forever. +// +// Shared by server.js and client.js (previously duplicated verbatim). + +const moduleCache = new Map(); // wasmUrl -> Promise + +async function loadModule(wasmUrl, { timeoutMs = 15000 } = {}) { + if (!wasmUrl) throw new Error('POW: missing wasmUrl'); + if (!moduleCache.has(wasmUrl)) { + const p = (async () => { + const resp = await fetch(wasmUrl, { signal: AbortSignal.timeout(timeoutMs) }); + if (!resp.ok) throw new Error(`POW: could not fetch WASM (HTTP ${resp.status})`); + const bytes = await resp.arrayBuffer(); + return WebAssembly.compile(bytes); + })(); + moduleCache.set(wasmUrl, p); + // Don't cache a failed download — let the next solve retry the fetch. + p.catch(() => moduleCache.delete(wasmUrl)); + } + return moduleCache.get(wasmUrl); +} + +async function solvePOW(challenge, wasmUrl, opts = {}) { + const module = await loadModule(wasmUrl, opts); + // Instantiating from a compiled Module returns the Instance directly + // (no { instance, module } wrapper, unlike the bytes form). + const instance = await WebAssembly.instantiate(module, { wbg: {} }); + const e = instance.exports; + const encoder = new TextEncoder(); + const prefix = challenge.salt + '_' + challenge.expire_at + '_'; + const cBytes = encoder.encode(challenge.challenge); + const pBytes = encoder.encode(prefix); + const cP = e.__wbindgen_export_0(cBytes.length, 1) >>> 0; + const pP = e.__wbindgen_export_0(pBytes.length, 1) >>> 0; + new Uint8Array(e.memory.buffer, cP, cBytes.length).set(cBytes); + new Uint8Array(e.memory.buffer, pP, pBytes.length).set(pBytes); + const sp = e.__wbindgen_add_to_stack_pointer(-16); + e.wasm_solve(sp, cP, cBytes.length, pP, pBytes.length, challenge.difficulty); + const dv = new DataView(e.memory.buffer); + const code = dv.getInt32(sp, true); + const ans = dv.getFloat64(sp + 8, true); + e.__wbindgen_add_to_stack_pointer(16); + if (code === 0 || !Number.isFinite(ans) || ans <= 0) throw new Error('POW failed'); + return Math.floor(ans); +} + +module.exports = { solvePOW, _moduleCache: moduleCache }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1dcd0ad --- /dev/null +++ b/package-lock.json @@ -0,0 +1,20 @@ +{ + "name": "free-deepseek-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "free-deepseek-api", + "version": "0.1.0", + "license": "MIT", + "bin": { + "free-deepseek-api": "server.js", + "free-deepseek-client": "client.js" + }, + "engines": { + "node": ">=18.0.0" + } + } + } +} diff --git a/package.json b/package.json index dfa855d..aedbae7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "doctor": "node scripts/doctor.js", "deepseek:auth": "node scripts/deepseek_chrome_auth.js", "client": "node client.js", - "test": "node --check server.js && node --check scripts/auth.js && node --check scripts/auth_import.js && node --check scripts/doctor.js && node --check scripts/deepseek_chrome_auth.js && node --check scripts/probe_deepseek_models.js && node --check client.js && node --check scripts/live_agentic_smoke_tests.mjs && node --test tests/unit.test.js", + "test": "node --check server.js && node --check lib/pow.js && node --check scripts/auth.js && node --check scripts/auth_import.js && node --check scripts/doctor.js && node --check scripts/deepseek_chrome_auth.js && node --check scripts/probe_deepseek_models.js && node --check client.js && node --check scripts/live_agentic_smoke_tests.mjs && node --test tests/unit.test.js", "test:live": "node scripts/live_agentic_smoke_tests.mjs" }, "keywords": [ diff --git a/public/dashboard.html b/public/dashboard.html index 8ce1a9c..a0bece4 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -3,7 +3,7 @@ -FreeDeepseekAPI — Дашборд +FreeDeepseekAPI — Dashboard @@ -93,7 +93,7 @@ .chip{font-family:var(--mono);font-size:var(--fs-xs);background:var(--card2);border:1px solid var(--border);border-radius:6px;padding:7px 10px;cursor:pointer;transition:.12s;display:flex;align-items:center;gap:6px;color:var(--text)} .chip:hover{border-color:var(--accent)} - /* аккаунты — прямые полосы статуса */ + /* accounts — direct status bars */ .spacer{flex:1} .acc{display:flex;align-items:center;gap:var(--s3);padding:10px 12px;background:var(--card2);border:1px solid var(--border);border-left:3px solid var(--faint);border-radius:0 var(--r) var(--r) 0;margin-bottom:6px;flex-wrap:wrap} .acc.OK{border-left-color:var(--ok)} .acc.WAIT{border-left-color:var(--warn)} @@ -122,7 +122,7 @@ .bubble .role{font-size:10px;text-transform:uppercase;letter-spacing:.06em;opacity:.65;margin-bottom:4px;font-weight:700} .bubble.streaming .body::after{content:"▍";animation:blink 1s steps(2) infinite;color:var(--accent)} @keyframes blink{50%{opacity:0}} - /* панель размышлений */ + /* reasoning panel */ .think{margin-bottom:8px;border:1px solid var(--border);border-radius:8px;background:var(--bg2);overflow:hidden} .think summary{cursor:pointer;padding:6px 10px;color:var(--muted);font-weight:600;font-size:var(--fs-xs);display:flex;align-items:center;gap:6px;user-select:none;list-style:none} .think summary::-webkit-details-marker{display:none} @@ -179,85 +179,85 @@

FreeDeepseekAPI

- проверка… + checking…
-
@@ -268,17 +268,17 @@

Модели (`; function toast(m, kind){ const t=$('#toast'); t.innerHTML=(kind==='err'?icon('i-warn'):kind==='ok'?icon('i-check'):'')+''+esc(m)+''; t.className='toast show'+(kind?' '+kind:''); clearTimeout(toast._t); toast._t=setTimeout(()=>t.className='toast',1900); } async function copyText(t){ - try{ if(navigator.clipboard && isSecureContext){ await navigator.clipboard.writeText(t); return toast('Скопировано','ok'); } throw 0; } - catch{ try{ const a=document.createElement('textarea'); a.value=t; a.style.position='fixed'; a.style.opacity='0'; document.body.appendChild(a); a.select(); document.execCommand('copy'); a.remove(); toast('Скопировано','ok'); }catch{ toast('Не удалось скопировать','err'); } } + try{ if(navigator.clipboard && isSecureContext){ await navigator.clipboard.writeText(t); return toast('Copied','ok'); } throw 0; } + catch{ try{ const a=document.createElement('textarea'); a.value=t; a.style.position='fixed'; a.style.opacity='0'; document.body.appendChild(a); a.select(); document.execCommand('copy'); a.remove(); toast('Copied','ok'); }catch{ toast('Failed to copy','err'); } } } function mdRender(text){ try{ if(window.marked&&window.DOMPurify){ const html=window.marked.parse(text,{breaks:true}); return window.DOMPurify.sanitize(html,{ADD_ATTR:['target']}); } }catch{} return null; } -// стабильный agent-id для серверной сессии DeepSeek (многоходовость) +// stable agent-id for server-side DeepSeek session (multi-turn) const AGENT_ID = (()=>{ let v=localStorage.getItem('ds_agent'); if(!v){ v='dash-'+Math.abs(Date.now()^(performance.now()*1000|0)).toString(36); localStorage.setItem('ds_agent',v); } return v; })(); const apiBase = origin+'/v1'; $('#baseUrl').textContent = apiBase; -// ── Вкладки ────────────────────────────────────────────────────────────── +// ── Tabs ────────────────────────────────────────────────────────────── const TABS=['chat','accounts','overview']; const inited={}; function showTab(name){ @@ -304,10 +304,10 @@

Модели (copyText(`from openai import OpenAI\nclient = OpenAI(base_url="${apiBase}", api_key="sk-deepseek")\nr = client.chat.completions.create(model="deepseek-chat",\n messages=[{"role":"user","content":"Привет"}])\nprint(r.choices[0].message.content)`); +$('#copyCurl').onclick=()=>copyText(`curl ${apiBase}/chat/completions \\\n -H "Authorization: Bearer sk-deepseek" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"deepseek-chat","messages":[{"role":"user","content":"Hi"}]}'`); +$('#copyPy').onclick=()=>copyText(`from openai import OpenAI\nclient = OpenAI(base_url="${apiBase}", api_key="sk-deepseek")\nr = client.chat.completions.create(model="deepseek-chat",\n messages=[{"role":"user","content":"Hi"}])\nprint(r.choices[0].message.content)`); -// ── Статус / KPI / авторизация ─────────────────────────────────────────── +// ── Status / KPI / Auth ─────────────────────────────────────────── let healthCache=null; async function loadHealth(){ try{ @@ -315,84 +315,84 @@

Модели (Авторизация не настроена. Запустите Авторизация DeepSeek.bat и войдите в свой аккаунт DeepSeek.

'; return; } - const exp=j.token_exp?('
'+esc(fmtExp(j.token_exp))+'
'):''; - box.innerHTML=`
${j.has_token?'есть ✓':'нет'}
-
${j.has_cookie?'есть ✓':'нет'}
-
${j.has_hif?'есть ✓':'опционально'}
${exp}`; - }catch{ box.innerHTML='
Не удалось получить статус авторизации
'; } + if(!j.config_ready){ box.innerHTML='
Authorization not configured. Run DeepSeek Authorization.bat and log into your DeepSeek account.
'; return; } + const exp=j.token_exp?('
'+esc(fmtExp(j.token_exp))+'
'):''; + box.innerHTML=`
${j.has_token?'present ✓':'missing'}
+
${j.has_cookie?'present ✓':'missing'}
+
${j.has_hif?'present ✓':'optional'}
${exp}`; + }catch{ box.innerHTML='
Failed to get authorization status
'; } } -// ── Аккаунты ─────────────────────────────────────────────────────────────── -const accStLabel=s=>({OK:'активен',WAIT:'лимит',INVALID:'невалиден',EXPIRED:'истёк',ERROR:'ошибка'})[s]||s; -function fmtResetAt(iso){ try{ return new Date(iso).toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); }catch{ return ''; } } +// ── Accounts ─────────────────────────────────────────────────────────────── +const accStLabel=s=>({OK:'active',WAIT:'limit',INVALID:'invalid',EXPIRED:'expired',ERROR:'error'})[s]||s; +function fmtResetAt(iso){ try{ return new Date(iso).toLocaleString('en-US',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); }catch{ return ''; } } async function loadAccountsList(){ const list=$('#accList'); try{ const j=await (await fetch(origin+'/api/accounts')).json(); - if(!j.accounts||!j.accounts.length){ list.innerHTML='
Пока нет аккаунтов. Добавьте первый через импорт ниже.
'; return; } + if(!j.accounts||!j.accounts.length){ list.innerHTML='
No accounts yet. Add first via import below.
'; return; } list.innerHTML=j.accounts.map(a=>{ - const meta=a.resetAt?('лимит до '+fmtResetAt(a.resetAt)):(a.exp?fmtExp(a.exp):''); + const meta=a.resetAt?('limit until '+fmtResetAt(a.resetAt)):(a.exp?fmtExp(a.exp):''); const email=a.email?`${esc(a.email)}`:''; const labelHtml=a.label?`${esc(a.label)}`:''; return `
${esc(a.id)}${labelHtml}${accStLabel(a.status)}${email}…${esc(a.preview||'')}${esc(meta)} - - -
`; + + + `; }).join(''); setAccUpdated(); - }catch{ list.innerHTML='
Не удалось загрузить аккаунты.
'; } + }catch{ list.innerHTML='
Failed to load accounts.
'; } } -function setAccUpdated(){ const e=$('#accUpdated'); if(e) e.textContent='обновлено '+new Date().toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}); } +function setAccUpdated(){ const e=$('#accUpdated'); if(e) e.textContent='updated '+new Date().toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'}); } $('#checkAllAcc').onclick=async()=>{ const b=$('#checkAllAcc'); b.disabled=true; try{ const j=await (await fetch(origin+'/api/accounts')).json(); for(const a of (j.accounts||[])){ try{ await fetch(origin+'/api/accounts/'+encodeURIComponent(a.id)+'/check',{method:'POST'}); }catch{} } - await loadAccountsList(); await loadHealth(); toast('Проверка завершена','ok'); - }catch{ toast('Ошибка','err'); } b.disabled=false; + await loadAccountsList(); await loadHealth(); toast('Check complete','ok'); + }catch{ toast('Error','err'); } b.disabled=false; }; $('#importBtn').onclick=async()=>{ - const text=$('#importText').value.trim(); if(!text) return toast('Вставьте cURL или HAR','err'); + const text=$('#importText').value.trim(); if(!text) return toast('Paste cURL or HAR','err'); const b=$('#importBtn'); b.disabled=true; try{ const j=await (await fetch(origin+'/api/accounts/import',{method:'POST',headers:{'Content-Type':'text/plain'},body:text})).json(); - if(j.ok){ toast('Добавлен '+j.id,'ok'); $('#importText').value=''; loadAccountsList(); loadHealth(); } else toast(j.error||'Ошибка импорта','err'); - }catch{ toast('Сеть недоступна','err'); } b.disabled=false; + if(j.ok){ toast('Added '+j.id,'ok'); $('#importText').value=''; loadAccountsList(); loadHealth(); } else toast(j.error||'Import Error','err'); + }catch{ toast('Network unavailable','err'); } b.disabled=false; }; document.addEventListener('click', async e=>{ const t=e.target.closest('button'); if(!t) return; if(t.dataset.label!==undefined){ const acc=t.closest('.acc'); if(!acc) return; const cur=acc.querySelector('.acc-label')?.textContent||''; - const inp=document.createElement('input'); inp.type='text'; inp.value=cur; inp.placeholder='имя аккаунта'; inp.style.cssText='max-width:150px;padding:3px 7px'; + const inp=document.createElement('input'); inp.type='text'; inp.value=cur; inp.placeholder='account name'; inp.style.cssText='max-width:150px;padding:3px 7px'; acc.insertBefore(inp, acc.querySelector('.spacer')); inp.focus(); inp.select(); inp.addEventListener('keydown', async ev=>{ if(ev.key==='Enter'){ ev.preventDefault(); - try{ await fetch(origin+'/api/accounts/'+encodeURIComponent(t.dataset.label)+'/label',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({label:inp.value})}); toast('Имя сохранено','ok'); }catch{ toast('Ошибка','err'); } + try{ await fetch(origin+'/api/accounts/'+encodeURIComponent(t.dataset.label)+'/label',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({label:inp.value})}); toast('Name saved','ok'); }catch{ toast('Error','err'); } loadAccountsList(); } else if(ev.key==='Escape'){ loadAccountsList(); } }); - inp.addEventListener('blur', ()=>loadAccountsList()); // убрать input, если кликнули мимо + inp.addEventListener('blur', ()=>loadAccountsList()); // remove input if clicked away return; } - if(t.dataset.check){ const acc=t.closest('.acc'); const badge=acc&&acc.querySelector('.badge'); if(badge){ badge.className='badge checking'; badge.textContent='проверка'; } t.disabled=true; + if(t.dataset.check){ const acc=t.closest('.acc'); const badge=acc&&acc.querySelector('.badge'); if(badge){ badge.className='badge checking'; badge.textContent='checking'; } t.disabled=true; try{ await fetch(origin+'/api/accounts/'+encodeURIComponent(t.dataset.check)+'/check',{method:'POST'}); }catch{} await loadAccountsList(); await loadHealth(); return; } if(t.dataset.del){ - if(t.dataset.confirm!=='1'){ t.dataset.confirm='1'; t.classList.add('danger'); t.innerHTML=icon('i-trash')+'точно?'; setTimeout(()=>{ if(t.dataset.confirm==='1'){ t.dataset.confirm=''; t.classList.remove('danger'); t.innerHTML=icon('i-trash'); } },3000); return; } + if(t.dataset.confirm!=='1'){ t.dataset.confirm='1'; t.classList.add('danger'); t.innerHTML=icon('i-trash')+'sure?'; setTimeout(()=>{ if(t.dataset.confirm==='1'){ t.dataset.confirm=''; t.classList.remove('danger'); t.innerHTML=icon('i-trash'); } },3000); return; } t.disabled=true; - try{ await fetch(origin+'/api/accounts/'+encodeURIComponent(t.dataset.del),{method:'DELETE'}); toast('Удалён','ok'); }catch{ toast('Ошибка','err'); } loadAccountsList(); loadHealth(); return; } + try{ await fetch(origin+'/api/accounts/'+encodeURIComponent(t.dataset.del),{method:'DELETE'}); toast('Deleted','ok'); }catch{ toast('Error','err'); } loadAccountsList(); loadHealth(); return; } }); setInterval(()=>{ if($('#tab-accounts').classList.contains('active')) loadAccountsList(); }, 20000); -// ── Модели ───────────────────────────────────────────────────────────────── +// ── Models ───────────────────────────────────────────────────────────────── async function loadModels(){ const list=$('#modelList'); try{ @@ -401,12 +401,12 @@

Модели (R':'')+(c.search?'S':''); - return `${esc(m.id)}${caps}`; - }).join('')||'не удалось загрузить'; - }catch{ list.innerHTML='не удалось загрузить. '; } + const caps=(c.reasoning?'R':'')+(c.search?'S':''); + return `${esc(m.id)}${caps}`; + }).join('')||'failed to load'; + }catch{ list.innerHTML='failed to load. '; } } -// #chatModel наполняется реальными моделями из /v1/models; выбор сохраняется в localStorage +// #chatModel is populated with real models from /v1/models; selection saved to localStorage async function populateChatModels(){ const sel=$('#chatModel'); if(!sel) return; try{ @@ -415,21 +415,21 @@

Модели (${esc(m.id)}`).join(''); if(saved && data.some(m=>m.id===saved)) sel.value=saved; - }catch{ /* оставляем статичный fallback-список */ } + }catch{ /* keep static fallback list */ } } document.addEventListener('click', e=>{ const c=e.target.closest('.chip[data-model]'); if(c) copyText(c.dataset.model); }); -// Модель берётся напрямую из селектора (#chatModel). -// Веб-поиск (-search) сейчас отключён: DeepSeek Web стабильно отдаёт пустой ответ. +// Model is taken directly from selector (#chatModel). +// Web search (-search) currently disabled: DeepSeek Web consistently returns empty response. -// ── Чат (история + reasoning + markdown, через /v1/chat/completions) ────────── +// ── Chat (history + reasoning + markdown, via /v1/chat/completions) ────────── let chatHistory=[], chatBusy=false, chatAbort=null; function bubbleEl(m){ const d=document.createElement('div'); d.className='bubble '+m.role+(m.err?' err':''); - d.innerHTML='
'+(m.role==='user'?'Вы':'AI')+'
'; + d.innerHTML='
'+(m.role==='user'?'You':'AI')+'
'; if(m.role==='assistant' && m.reasoning){ const det=document.createElement('details'); det.className='think'; - det.innerHTML=''+icon('i-bulb')+'Размышления
'; + det.innerHTML=''+icon('i-bulb')+'Reasoning
'; det.querySelector('.think-body').textContent=m.reasoning; d.appendChild(det); d._think=det; } @@ -440,22 +440,22 @@

Модели ('+icon('i-chat')+'Начните диалог. Ответ — стримингом с markdown; у reasoner-моделей появится панель «Размышления».'; return; } + if(!chatHistory.length){ box.innerHTML='
'+icon('i-chat')+'Start dialog. Response streams with markdown; reasoner models will show \'Reasoning\' panel.
'; return; } box.innerHTML=''; chatHistory.forEach(m=>box.appendChild(bubbleEl(m))); box.scrollTop=box.scrollHeight; } function atBottom(){ const b=$('#msgs'); return b.scrollHeight-b.scrollTop-b.clientHeight<60; } -function setBusy(b){ chatBusy=b; const btn=$('#sendChat'); btn.innerHTML=b?'Стоп':icon('i-send')+'Отправить'; btn.classList.toggle('danger',b); } +function setBusy(b){ chatBusy=b; const btn=$('#sendChat'); btn.innerHTML=b?'Stop':icon('i-send')+'Send'; btn.classList.toggle('danger',b); } function ensureThink(node, asst){ if(node._think) return node._think.querySelector('.think-body'); const det=document.createElement('details'); det.className='think'; det.open=true; - det.innerHTML=''+icon('i-bulb')+'Размышления
'; + det.innerHTML=''+icon('i-bulb')+'Reasoning
'; node.insertBefore(det, node._c); node._think=det; return det.querySelector('.think-body'); } @@ -487,9 +487,9 @@

Модели (Модели (Модели (.json). The server - picks it up on its next reload. Works with any browser logged in to chat.deepseek.com. - - Usage: - node scripts/auth_from_curl.js < curl.txt - node scripts/auth_from_curl.js path/to/curl.txt - Get-Clipboard -Raw | node scripts/auth_from_curl.js # from clipboard (PowerShell) - - How to get cURL: chat.deepseek.com → F12 → Network → send a message → - right-click the /api/v0/... request → Copy → Copy as cURL. -*/ -const fs = require('fs'); -const path = require('path'); -const { parseAuthInput, finalizeAuth, WASM_DEFAULT } = require('../lib/parseAuth'); - -const MANAGED_AUTH_DIR = path.join(__dirname, '..', 'data', 'accounts'); - -function readStdin() { - return new Promise(resolve => { - let s = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', d => s += d); - process.stdin.on('end', () => resolve(s)); - if (process.stdin.isTTY) resolve(''); - }); -} - -(async () => { - const arg = process.argv[2]; - let input = (arg && fs.existsSync(arg)) ? fs.readFileSync(arg, 'utf8') : await readStdin(); - input = String(input || '').trim(); - if (!input) { console.error('Empty: pass cURL via stdin, a file argument, or the clipboard.'); process.exit(1); } - - const parsed = finalizeAuth(parseAuthInput(input), WASM_DEFAULT); - if (parsed.error) { - console.error('Error: ' + parsed.error); - console.error('Copy exactly the chat.deepseek.com/api/... request via "Copy as cURL".'); - process.exit(2); - } - const content = { - token: parsed.token, - cookie: parsed.cookie, - wasmUrl: parsed.wasmUrl || WASM_DEFAULT, - hif_dliq: parsed.hif_dliq || '', - hif_leim: parsed.hif_leim || '', - }; - fs.mkdirSync(MANAGED_AUTH_DIR, { recursive: true }); - const file = path.join(MANAGED_AUTH_DIR, `account_${Date.now()}.json`); - fs.writeFileSync(file, JSON.stringify(content, null, 2), { mode: 0o600 }); - console.log('OK: account written to ' + file + - ' (token ' + parsed.token.length + ' chars, cookie ' + parsed.cookie.split(';').filter(Boolean).length + ' values)'); -})(); diff --git a/scripts/auth_from_har.js b/scripts/auth_from_har.js deleted file mode 100644 index a9ac706..0000000 --- a/scripts/auth_from_har.js +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -/* - Adds a DeepSeek account from a HAR file to the file-based pool by writing a new - auth file into the managed dir (data/accounts/account_.json). The server picks - it up on its next reload. HAR = DevTools → Network → "Save all as HAR". Any browser. - - Usage: - node scripts/auth_from_har.js "path/to/archive.har" - - Picks the best chat.deepseek.com/api/... request with Authorization: Bearer. -*/ -const fs = require('fs'); -const path = require('path'); -const { parseHar, finalizeAuth, WASM_DEFAULT } = require('../lib/parseAuth'); - -const MANAGED_AUTH_DIR = path.join(__dirname, '..', 'data', 'accounts'); - -const harPath = process.argv[2]; -if (!harPath || !fs.existsSync(harPath)) { - console.error('Provide a path to a .har: node scripts/auth_from_har.js "archive.har"'); - process.exit(1); -} - -const parsed = finalizeAuth(parseHar(fs.readFileSync(harPath, 'utf8')), WASM_DEFAULT); -if (parsed.error) { - console.error('Error: ' + parsed.error); - console.error('Save the HAR while logged in and after sending a message in DeepSeek.'); - process.exit(2); -} -const content = { - token: parsed.token, - cookie: parsed.cookie, - wasmUrl: parsed.wasmUrl || WASM_DEFAULT, - hif_dliq: parsed.hif_dliq || '', - hif_leim: parsed.hif_leim || '', -}; -fs.mkdirSync(MANAGED_AUTH_DIR, { recursive: true }); -const file = path.join(MANAGED_AUTH_DIR, `account_${Date.now()}.json`); -fs.writeFileSync(file, JSON.stringify(content, null, 2), { mode: 0o600 }); -console.log('OK: account written to ' + file + - ' (token ' + parsed.token.length + ' chars, cookie ' + parsed.cookie.split(';').filter(Boolean).length + ' values)'); diff --git a/scripts/auth_import.js b/scripts/auth_import.js index 96b8417..4ebd4a5 100644 --- a/scripts/auth_import.js +++ b/scripts/auth_import.js @@ -74,18 +74,18 @@ Usage: DEEPSEEK_TOKEN="" npm run auth:import -- --input ./cookies.json Options: - --input, -i Source JSON: готовый deepseek-auth.json или browser cookie export + --input, -i Source JSON: ready deepseek-auth.json or browser cookie export --output, -o Target auth path (default: ${DEFAULT_OUT}) Security: - Для cookies.json передавайте token через DEEPSEEK_TOKEN, не через CLI argument, - чтобы не светить его в shell history/process list. + For cookies.json, pass token via DEEPSEEK_TOKEN, not via CLI argument, + to avoid exposing it in shell history/process list. VPS flow: - 1) На домашнем ПК: npm run auth - 2) Скопируй deepseek-auth.json на VPS - 3) На VPS: npm run auth:import -- --input ./deepseek-auth.json - 4) Запуск: NON_INTERACTIVE=1 npm start`); + 1) On your home PC: npm run auth + 2) Copy deepseek-auth.json to VPS + 3) On VPS: npm run auth:import -- --input ./deepseek-auth.json + 4) Start: NON_INTERACTIVE=1 npm start`); } async function main(argv = process.argv.slice(2)) { const tokenArg = argValue(argv, '--token'); @@ -103,7 +103,7 @@ async function main(argv = process.argv.slice(2)) { const errors = validateAuth(auth); if (errors.length) { console.error(`[auth:import] Invalid auth import: ${errors.join(', ')}`); - console.error('[auth:import] Если импортируешь browser cookies, передай token через DEEPSEEK_TOKEN=...'); + console.error('[auth:import] If importing browser cookies, pass token via DEEPSEEK_TOKEN=...'); return 2; } secureWriteJson(outputPath, auth); diff --git a/scripts/deepseek_chrome_auth.js b/scripts/deepseek_chrome_auth.js index 91384fb..acfc98d 100755 --- a/scripts/deepseek_chrome_auth.js +++ b/scripts/deepseek_chrome_auth.js @@ -453,13 +453,13 @@ async function main() { await cdp.send('Network.enable'); console.log( - '\n[auth] Chrome открыт. Войди в DeepSeek в ЭТОМ отдельном окне.', + '\n[auth] Chrome is open. Log in to DeepSeek in THIS separate window.', ); console.log( - '[auth] После логина отправь в DeepSeek короткое сообщение, например: ok', + '[auth] After logging in, send a short message to DeepSeek, for example: ok', ); await ask( - '[auth] Когда залогинился и отправил тестовое сообщение — нажми ENTER здесь: ', + '[auth] When you have logged in and sent the test message — press ENTER here: ', ); let auth = null; diff --git a/scripts/probe_deepseek_models.js b/scripts/probe_deepseek_models.js index c4ff2a0..96678e6 100755 --- a/scripts/probe_deepseek_models.js +++ b/scripts/probe_deepseek_models.js @@ -77,7 +77,7 @@ async function probe(model_type, thinking_enabled, search_enabled) { chat_session_id: sessionId, parent_message_id: null, model_type, - prompt: 'Ответь ровно OK', + prompt: 'Reply exactly OK', ref_file_ids: [], thinking_enabled, search_enabled, diff --git a/server.js b/server.js index 43dd96d..790ee53 100755 --- a/server.js +++ b/server.js @@ -6,8 +6,8 @@ * parses LLM text responses for TOOL_CALL patterns, returns OpenAI tool_calls format. * * Per-agent sessions: each unique `user` field gets its own DeepSeek web session. - * Auto-reset: sessions reset when message chain > 50 messages or age > 2 hours. - * Listens on 0.0.0.0:9655 + * Auto-reset: sessions reset when message chain reaches 100 messages or age > 2 hours. + * Listens on 127.0.0.1:9655 by default (HOST is configurable) */ const http = require('http'); @@ -15,9 +15,48 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const readline = require('readline'); +const crypto = require('crypto'); const { spawnSync } = require('child_process'); +const { solvePOW } = require('./lib/pow'); const { parseAuthInput, finalizeAuth } = require('./lib/parseAuth'); +// Load .env file if present (zero-dependency dotenv replacement) +// Only sets variables that are not already defined in the environment +function loadEnvFile() { + const envPath = path.join(__dirname, '.env'); + try { + if (!fs.existsSync(envPath)) return; + const content = fs.readFileSync(envPath, 'utf8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx < 1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + let value = trimmed.slice(eqIdx + 1).trim(); + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + // Only set if not already in environment (env takes precedence) + if (!process.env[key]) { + process.env[key] = value; + } + } + } catch (e) { + // Silent fail — .env is optional + } +} +loadEnvFile(); + +// Per-DeepSeek-request network timeout. Plain fetch() has NO default timeout, so a +// stalled upstream would hang the inbound request (and pin the account) forever. +// Increased to 180s for large prompts (DeepSeek can be slow with complex tasks) +const DS_FETCH_TIMEOUT_MS = Number(process.env.DEEPSEEK_FETCH_TIMEOUT_MS || 180000); +function dsFetch(url, options = {}, timeoutMs = DS_FETCH_TIMEOUT_MS) { + return fetch(url, { ...options, signal: options.signal || AbortSignal.timeout(timeoutMs) }); +} + const SERVER_HOST = os.hostname(); // Dynamic hostname detection const SERVER_PUBLIC_IP = (() => { try { @@ -33,7 +72,33 @@ const SERVER_PUBLIC_IP = (() => { const FORGETMEAI_WATERMARK = 't.me/forgetmeai'; const PORT = Number(process.env.PORT || 9655); -const HOST = process.env.HOST || '0.0.0.0'; +const HOST = process.env.HOST || '127.0.0.1'; + +function loadProxyApiKey(env = process.env) { + if (env.PROXY_API_KEY) return String(env.PROXY_API_KEY); + const secretPath = String(env.PROXY_API_KEY_FILE || '').trim(); + if (!secretPath) return ''; + try { + return fs.readFileSync(secretPath, 'utf8').trim(); + } catch (error) { + // A missing optional secret is equivalent to an unset key. Container + // deployments set REQUIRE_PROXY_API_KEY=1 and fail closed in main(). + if (error.code === 'ENOENT') return ''; + throw new Error(`Could not read PROXY_API_KEY_FILE (${secretPath}): ${error.message}`); + } +} + +function requireProxyApiKey(key, required) { + if (required && !key) { + throw new Error('PROXY_API_KEY is required. Set PROXY_API_KEY or mount a secret and set PROXY_API_KEY_FILE.'); + } +} + +const PROXY_API_KEY = loadProxyApiKey(); +const PROXY_CORS_ORIGINS = new Set(String(process.env.PROXY_CORS_ORIGINS || '') + .split(',') + .map(value => normalizeOrigin(value)) + .filter(Boolean)); function formatWatermark(prefix = 'ForgetMeAI') { return `${prefix}: ${FORGETMEAI_WATERMARK}`; } function printBanner() { console.log(` @@ -43,7 +108,7 @@ function printBanner() { ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ███████ ███████ ██████ ███████ ███████ ███████ ██ ██ - FreeDeepseekAPI — API-прокси для DeepSeek Web Chat + FreeDeepseekAPI — API proxy for DeepSeek Web Chat ${formatWatermark()} `); } @@ -53,6 +118,56 @@ function prompt(question) { } function isTruthy(value) { return typeof value === 'string' && ['1','true','yes','on'].includes(value.trim().toLowerCase()); } +function isProxyAuthorized(authorization, expectedKey = PROXY_API_KEY) { + if (!expectedKey) return true; + if (typeof authorization !== 'string' || !authorization.startsWith('Bearer ')) return false; + const supplied = Buffer.from(authorization.slice('Bearer '.length), 'utf8'); + const expected = Buffer.from(String(expectedKey), 'utf8'); + return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected); +} + +function isLoopbackHost(host) { + const normalized = String(host || '').trim().toLowerCase().replace(/^\[|\]$/g, ''); + return normalized === '127.0.0.1' + || normalized === '::1' + || normalized === '::ffff:127.0.0.1' + || normalized === 'localhost'; +} + +function normalizeOrigin(origin) { + const value = String(origin || '').trim().replace(/\/+$/, ''); + if (!value) return ''; + try { + const parsed = new URL(value); + return parsed.origin === 'null' ? value : parsed.origin; + } catch (e) { + return value; + } +} + +function isBrowserOriginAllowed(origin, allowedOrigins = PROXY_CORS_ORIGINS) { + if (!origin) return true; // curl, SDKs, and other non-browser clients + const normalized = normalizeOrigin(origin); + if (allowedOrigins.has(normalized)) return true; + try { + const parsed = new URL(normalized); + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') + && isLoopbackHost(parsed.hostname); + } catch (e) { + return false; + } +} + +const CONTEXT_COMPACTED_HEADER = 'X-FreeDeepseek-Context-Compacted'; +function setCorsResponseHeaders(res) { + res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Access-Control-Expose-Headers', CONTEXT_COMPACTED_HEADER); +} +function markContextCompacted(res) { + res.setHeader(CONTEXT_COMPACTED_HEADER, 'true'); +} + // === Per-Agent Session Store === const sessions = new Map(); // keyed by agent ID (from `user` field) const MAX_HISTORY_LENGTH = 15; @@ -63,9 +178,7 @@ const SESSION_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours // === DeepSeek Web API Config — loaded from external config file === const DS_CONFIG_PATH = process.env.DEEPSEEK_AUTH_PATH || path.join(__dirname, 'deepseek-auth.json'); // Managed runtime auth dir: accounts added through the dashboard / import scripts -// are written here as account_.json and picked up on the next reload. This -// directory is always scanned in addition to the env-provided paths, so runtime -// CRUD works on top of the maintainer's file-based pool without a separate store. +// are written here as account_.json and picked up on the next reload. const MANAGED_AUTH_DIR = path.join(__dirname, 'data', 'accounts'); const DEFAULT_ACCOUNT_COOLDOWN_MS = Number(process.env.DEEPSEEK_ACCOUNT_COOLDOWN_MS || 10 * 60 * 1000); const DEFAULT_WASM = 'https://fe-static.deepseek.com/chat/static/sha3_wasm_bg.7b9ca65ddd.wasm'; @@ -73,6 +186,21 @@ let DS_CONFIG = {}; let dsHeaders = {}; const accounts = []; let accountRoundRobin = 0; +let inFlight = 0; // concurrent in-flight completions (backpressure cap) +// Overall wall-clock budget for one inbound request (caps the retry/continuation +// loops), max concurrent completions, and the empty-response retry cap. +// Increased to 600s for complex tasks (large files, multi-step operations) +const REQUEST_DEADLINE_MS = Number(process.env.DEEPSEEK_REQUEST_DEADLINE_MS || 600000); +const MAX_CONCURRENT = Number(process.env.DEEPSEEK_MAX_CONCURRENT || 24); +const configuredEmptyRetries = Number(process.env.DEEPSEEK_MAX_RETRIES); +const MAX_EMPTY_RETRIES = Number.isFinite(configuredEmptyRetries) + ? Math.max(0, Math.min(10, Math.floor(configuredEmptyRetries))) + : 2; +const MIN_UPSTREAM_PROMPT_CHARS = 16000; +const configuredPromptChars = Number(process.env.DEEPSEEK_MAX_PROMPT_CHARS); +const MAX_UPSTREAM_PROMPT_CHARS = Number.isFinite(configuredPromptChars) + ? Math.max(MIN_UPSTREAM_PROMPT_CHARS, Math.floor(configuredPromptChars)) + : 100000; function buildBaseHeaders(config = DS_CONFIG) { return { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", @@ -122,7 +250,6 @@ function discoverAuthPaths() { envPaths = [DS_CONFIG_PATH]; } // Always also include the managed runtime dir, de-duplicated against env paths - // (resolved to absolute) so the same file is never loaded twice. const seen = new Set(envPaths.map(p => path.resolve(p))); const merged = [...envPaths]; for (const p of managedAuthPaths()) { @@ -156,69 +283,6 @@ function loadDeepSeekConfig({ fatal = true } = {}) { return false; } function hasAuthConfig() { return accounts.some(a => a.config.token && a.config.cookie); } -function accountStatus(account) { - return { - id: account.id, - ready: !!(account.config.token && account.config.cookie), - cooldown: account.cooldownUntil > Date.now(), - cooldown_remaining_sec: Math.max(0, Math.ceil((account.cooldownUntil - Date.now()) / 1000)), - failures: account.failures, - last_used_at: account.lastUsedAt || null, - }; -} -function selectAccountForSession(session) { - const now = Date.now(); - if (session.accountId) { - const sticky = accounts.find(a => a.id === session.accountId); - if (sticky && sticky.config.token && sticky.config.cookie && sticky.cooldownUntil <= now) return sticky; - if (sticky && sticky.cooldownUntil > now) { - // A DeepSeek chat_session belongs to the auth account that created it. - // If that account is rate-limited/expired, do not keep hammering it; - // reset the web session and let a healthy account take over. - session.id = null; - session.parentMessageId = null; - session.createdAt = null; - session.messageCount = 0; - } - session.accountId = null; - } - const ready = accounts.filter(a => a.config.token && a.config.cookie && a.cooldownUntil <= now); - if (ready.length === 0) { - const waiting = accounts.filter(a => a.config.token && a.config.cookie).sort((a, b) => a.cooldownUntil - b.cooldownUntil)[0]; - if (waiting) { - const waitSec = Math.max(1, Math.ceil((waiting.cooldownUntil - now) / 1000)); - throw new Error(`All DeepSeek auth accounts are cooling down. Retry in ~${waitSec}s or import a fresh account with npm run auth:import.`); - } - throw new Error('No valid DeepSeek auth accounts. Run npm run auth or npm run auth:import.'); - } - const account = ready[accountRoundRobin % ready.length]; - accountRoundRobin++; - session.accountId = account.id; - return account; -} -// Parse a Retry-After header value into a cooldown duration in ms, or null if -// absent/unparseable. Supports both forms: delta-seconds (e.g. "120") and an -// HTTP-date (e.g. "Wed, 21 Oct 2025 07:28:00 GMT"). Clamped to >= 1s. -function parseRetryAfterMs(retryAfterRaw) { - if (!retryAfterRaw) return null; - const raw = String(retryAfterRaw).trim(); - if (/^\d+$/.test(raw)) return Math.max(1000, Number(raw) * 1000); - const t = Date.parse(raw); - if (!Number.isNaN(t)) return Math.max(1000, t - Date.now()); - return null; -} -function markAccountFailure(account, status, reason = '', retryAfterRaw = null) { - if (!account) return; - account.failures++; - if ([401, 403, 429].includes(Number(status))) { - // On 429, honor a valid Retry-After header (seconds or HTTP-date) when present; - // otherwise fall back to the fixed env-configured cooldown. - const retryMs = Number(status) === 429 ? parseRetryAfterMs(retryAfterRaw) : null; - const cooldownMs = retryMs != null ? retryMs : DEFAULT_ACCOUNT_COOLDOWN_MS; - account.cooldownUntil = Date.now() + cooldownMs; - console.log(`[account:${account.id}] cooldown for ${Math.round(cooldownMs / 1000)}s after HTTP ${status}${reason ? ` (${reason})` : ''}${retryMs != null ? ' (Retry-After)' : ''}`); - } -} // === Account management (dashboard / import) on top of the file-based pool === @@ -329,10 +393,74 @@ async function checkAccountLive(account) { } return { status: 'ERROR', email: '' }; } catch { - // Network error or 15s timeout — treat as unknown, do not crash the route. return { status: 'ERROR', email: '' }; } } + +function accountStatus(account) { + return { + id: account.id, + ready: !!(account.config.token && account.config.cookie), + cooldown: account.cooldownUntil > Date.now(), + cooldown_remaining_sec: Math.max(0, Math.ceil((account.cooldownUntil - Date.now()) / 1000)), + failures: account.failures, + last_used_at: account.lastUsedAt || null, + }; +} +function selectAccountForSession(session) { + const now = Date.now(); + if (session.accountId) { + const sticky = accounts.find(a => a.id === session.accountId); + if (sticky && sticky.config.token && sticky.config.cookie && sticky.cooldownUntil <= now) return sticky; + // A DeepSeek chat_session belongs to the auth account that created it. + // If that account disappeared, lost credentials, or is cooling down, + // never reuse its session id under a different account. + resetRemoteSession(session); + session.accountId = null; + } + const ready = accounts.filter(a => a.config.token && a.config.cookie && a.cooldownUntil <= now); + if (ready.length === 0) { + const waiting = accounts.filter(a => a.config.token && a.config.cookie).sort((a, b) => a.cooldownUntil - b.cooldownUntil)[0]; + if (waiting) { + const waitSec = Math.max(1, Math.ceil((waiting.cooldownUntil - now) / 1000)); + // Tagged so the request handler returns 429 + Retry-After instead of a + // generic 500 (integrator backoff keys on the status code, not the text). + const err = new Error(`All DeepSeek auth accounts are cooling down. Retry in ~${waitSec}s or import a fresh account with npm run auth:import.`); + err.status = 429; err.retryAfter = waitSec; err.type = 'rate_limit'; + throw err; + } + const noAuth = new Error('No valid DeepSeek auth accounts. Run npm run auth or npm run auth:import.'); + noAuth.status = 503; noAuth.type = 'no_auth'; + throw noAuth; + } + const account = ready[accountRoundRobin % ready.length]; + accountRoundRobin++; + session.accountId = account.id; + return account; +} +// Parse a Retry-After header value into a cooldown duration in ms, or null if +// absent/unparseable. Supports both forms: delta-seconds (e.g. "120") and an +// HTTP-date (e.g. "Wed, 21 Oct 2025 07:28:00 GMT"). Clamped to >= 1s. +function parseRetryAfterMs(retryAfterRaw) { + if (!retryAfterRaw) return null; + const raw = String(retryAfterRaw).trim(); + if (/^\d+$/.test(raw)) return Math.max(1000, Number(raw) * 1000); + const t = Date.parse(raw); + if (!Number.isNaN(t)) return Math.max(1000, t - Date.now()); + return null; +} +function markAccountFailure(account, status, reason = '', retryAfterRaw = null) { + if (!account) return; + account.failures++; + if ([401, 403, 429].includes(Number(status))) { + // On 429, honor a valid Retry-After header (seconds or HTTP-date) when present; + // otherwise fall back to the fixed env-configured cooldown. + const retryMs = Number(status) === 429 ? parseRetryAfterMs(retryAfterRaw) : null; + const cooldownMs = retryMs != null ? retryMs : DEFAULT_ACCOUNT_COOLDOWN_MS; + account.cooldownUntil = Date.now() + cooldownMs; + console.log(`[account:${account.id}] cooldown for ${Math.round(cooldownMs / 1000)}s after HTTP ${status}${reason ? ` (${reason})` : ''}${retryMs != null ? ' (Retry-After)' : ''}`); + } +} async function readDeepSeekJsonResponse(resp, label, account) { const text = await resp.text(); let json = null; @@ -346,7 +474,9 @@ async function readDeepSeekJsonResponse(resp, label, account) { if (!resp.ok) markAccountFailure(account, resp.status, label); return { json, text }; } -loadDeepSeekConfig({ fatal: false }); +if (require.main === module) { + loadDeepSeekConfig({ fatal: false }); +} function createSession() { return { @@ -356,64 +486,167 @@ function createSession() { messageCount: 0, accountId: null, history: [], + lastActivityAt: Date.now(), + }; +} + +// Session persistence — save/load sessions to disk +const SESSION_FILE = path.join(__dirname, 'sessions.json'); + +function saveSessions() { + try { + const data = {}; + for (const [agentId, session] of sessions) { + // Only save sessions with history (skip empty ones) + if (session.history && session.history.length > 0) { + data[agentId] = { + history: session.history.slice(-20), // Keep last 20 exchanges + accountId: session.accountId, + lastActivityAt: session.lastActivityAt, + }; + } + } + fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2)); + } catch (e) { + console.log(`[DS-API] Failed to save sessions: ${e.message}`); + } +} + +function loadSessions() { + // If CLEAR_SESSIONS_ON_START is set, delete sessions.json and start fresh + if (isTruthy(process.env.DEEPSEEK_CLEAR_SESSIONS_ON_START)) { + try { + if (fs.existsSync(SESSION_FILE)) { + fs.unlinkSync(SESSION_FILE); + console.log(`[DS-API] Cleared sessions on start (DEEPSEEK_CLEAR_SESSIONS_ON_START)`); + } + } catch (e) { + console.log(`[DS-API] Failed to clear sessions: ${e.message}`); + } + return; + } + + try { + if (fs.existsSync(SESSION_FILE)) { + const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); + for (const [agentId, saved] of Object.entries(data)) { + const session = createSession(); + session.history = saved.history || []; + session.accountId = saved.accountId; + session.lastActivityAt = saved.lastActivityAt || Date.now(); + sessions.set(agentId, session); + } + console.log(`[DS-API] Loaded ${Object.keys(data).length} session(s) from disk`); + } + } catch (e) { + console.log(`[DS-API] Failed to load sessions: ${e.message}`); + } +} + +function resetRemoteSession(session) { + const failed = { + failedSessionId: session.id, + failedMessageCount: session.messageCount, + accountId: session.accountId, }; + session.id = null; + session.parentMessageId = null; + session.createdAt = null; + session.messageCount = 0; + // Keep local recovery history and the sticky account assignment. A remote + // chat can be unhealthy without invalidating either of those local hints. + return failed; +} + +function prepareSessionForPrompt(session, now = Date.now()) { + if (!session || !session.id) return null; + let reason = null; + if (session.messageCount >= MAX_MESSAGE_DEPTH) reason = 'max_message_depth'; + else if (session.createdAt && now - session.createdAt > SESSION_TTL_MS) reason = 'session_ttl'; + if (!reason) return null; + return { reason, ...resetRemoteSession(session) }; } function getOrCreateAgentSession(agentId) { if (!sessions.has(agentId)) { sessions.set(agentId, createSession()); } - return sessions.get(agentId); -} - -async function solvePOW(challenge, config = DS_CONFIG) { - const resp = await fetch(config.wasmUrl); - const wasmBytes = await resp.arrayBuffer(); - const mod = await WebAssembly.instantiate(wasmBytes, { wbg: {} }); - const e = mod.instance.exports; - const encoder = new TextEncoder(); - const prefix = challenge.salt + '_' + challenge.expire_at + '_'; - const cBytes = encoder.encode(challenge.challenge); - const pBytes = encoder.encode(prefix); - const cP = e.__wbindgen_export_0(cBytes.length, 1) >>> 0; - const pP = e.__wbindgen_export_0(pBytes.length, 1) >>> 0; - new Uint8Array(e.memory.buffer, cP, cBytes.length).set(cBytes); - new Uint8Array(e.memory.buffer, pP, pBytes.length).set(pBytes); - const sp = e.__wbindgen_add_to_stack_pointer(-16); - e.wasm_solve(sp, cP, cBytes.length, pP, pBytes.length, challenge.difficulty); - const dv = new DataView(e.memory.buffer); - const code = dv.getInt32(sp, true); - const ans = dv.getFloat64(sp + 8, true); - e.__wbindgen_add_to_stack_pointer(16); - if (code === 0 || !Number.isFinite(ans) || ans <= 0) throw new Error('POW failed'); - return Math.floor(ans); + const session = sessions.get(agentId); + session.lastActivityAt = Date.now(); + return session; +} + +// Tool definition cache per agent — clients like Hermes often only send tools +// in the first request. Cache them so subsequent requests still have tools. +const agentToolCache = new Map(); + +function cacheAgentTools(agentId, tools) { + if (!tools || tools.length === 0) return; + const existing = agentToolCache.get(agentId) || []; + // Merge new tools with existing ones (by function name) + const merged = new Map(); + for (const tool of existing) { + const name = tool?.function?.name; + if (name) merged.set(name, tool); + } + for (const tool of tools) { + const name = tool?.function?.name; + if (name) merged.set(name, tool); + } + const finalTools = Array.from(merged.values()); + agentToolCache.set(agentId, finalTools); + + // Log tool names for debugging (enable with DEEPSEEK_LOG_TOOLS=1) + if (isTruthy(process.env.DEEPSEEK_LOG_TOOLS)) { + const toolNames = finalTools.map(t => t?.function?.name).filter(Boolean); + console.log(`[DS-API] ${agentId} tools cached (${toolNames.length}): ${toolNames.join(', ')}`); + } +} + +function getCachedAgentTools(agentId) { + return agentToolCache.get(agentId) || []; } +// Evict idle sessions so the Map (keyed by client IP / user id) can't grow without +// bound on a long-running process. Drops entries untouched for 2× the session TTL. +function sweepIdleSessions(maxIdleMs = SESSION_TTL_MS * 2) { + const now = Date.now(); + let removed = 0; + for (const [agentId, session] of sessions) { + if (now - (session.lastActivityAt || 0) > maxIdleMs) { sessions.delete(agentId); removed++; } + } + if (removed) console.log(`[DS-API] swept ${removed} idle session(s); ${sessions.size} remain`); + return removed; +} + +// solvePOW() lives in lib/pow (compiled-module cache + WASM-fetch timeout), +// shared with client.js. Called as solvePOW(challenge, wasmUrl). + const MODEL_CONFIGS = { - // DeepSeek Web real model_type: default / UI name: "Быстрый". + // DeepSeek Web real model_type: default / UI name: "Fast". // Public model family: DeepSeek-V3.2-Exp chat mode (fast, no visible reasoning). 'deepseek-chat': { model_type: 'default', thinking_enabled: false, search_enabled: false, - real_model: 'DeepSeek-V4-Flash non-thinking (DeepSeek Web “Быстрый” / default)', + real_model: 'DeepSeek-V4-Flash non-thinking (DeepSeek Web “Fast” / default)', capabilities: { reasoning: false, web_search: false, files: true }, supported: true, }, 'deepseek-v3': { model_type: 'default', thinking_enabled: false, search_enabled: false, - real_model: 'DeepSeek-V4-Flash non-thinking (DeepSeek Web “Быстрый” / default)', + real_model: 'DeepSeek-V4-Flash non-thinking (DeepSeek Web “Fast” / default)', capabilities: { reasoning: false, web_search: false, files: true }, supported: true, }, 'deepseek-default': { model_type: 'default', thinking_enabled: false, search_enabled: false, - real_model: 'DeepSeek-V4-Flash non-thinking (DeepSeek Web “Быстрый” / default)', + real_model: 'DeepSeek-V4-Flash non-thinking (DeepSeek Web “Fast” / default)', capabilities: { reasoning: false, web_search: false, files: true }, supported: true, }, // Same DeepSeek Web default model, but with thinking_enabled=true. UI exposes it as thinking/reasoning mode. 'deepseek-reasoner': { model_type: 'default', thinking_enabled: true, search_enabled: false, - real_model: 'DeepSeek-V4-Flash thinking mode (DeepSeek Web “Быстрый” + thinking_enabled)', + real_model: 'DeepSeek-V4-Flash thinking mode (DeepSeek Web “Fast” + thinking_enabled)', capabilities: { reasoning: true, web_search: false, files: true }, supported: true, }, @@ -425,13 +658,13 @@ const MODEL_CONFIGS = { }, 'deepseek-chat-search': { model_type: 'default', thinking_enabled: false, search_enabled: true, - real_model: 'DeepSeek-V4-Flash non-thinking (DeepSeek Web “Быстрый” / default) + web search', + real_model: 'DeepSeek-V4-Flash non-thinking (DeepSeek Web “Fast” / default) + web search', capabilities: { reasoning: false, web_search: true, files: true }, supported: true, }, 'deepseek-default-search': { model_type: 'default', thinking_enabled: false, search_enabled: true, - real_model: 'DeepSeek-V4-Flash non-thinking (DeepSeek Web “Быстрый” / default) + web search', + real_model: 'DeepSeek-V4-Flash non-thinking (DeepSeek Web “Fast” / default) + web search', capabilities: { reasoning: false, web_search: true, files: true }, supported: true, }, @@ -447,29 +680,29 @@ const MODEL_CONFIGS = { capabilities: { reasoning: true, web_search: true, files: true }, supported: true, }, - // DeepSeek Web UI name: “Эксперт”. Requires current web client headers (x-client-version=2.0.0). + // DeepSeek Web UI name: “Expert”. Requires current web client headers (x-client-version=2.0.0). 'deepseek-expert': { model_type: 'expert', thinking_enabled: false, search_enabled: false, - real_model: 'DeepSeek Web “Эксперт” (limited resources)', + real_model: 'DeepSeek Web “Expert” (limited resources)', capabilities: { reasoning: false, web_search: false, files: false }, supported: true, }, 'deepseek-v4-pro': { model_type: 'expert', thinking_enabled: true, search_enabled: false, - real_model: 'DeepSeek Web “Эксперт” + thinking mode (exposed as deepseek-v4-pro alias)', + real_model: 'DeepSeek Web “Expert” + thinking mode (exposed as deepseek-v4-pro alias)', capabilities: { reasoning: true, web_search: false, files: false }, supported: true, }, 'deepseek-expert-search': { model_type: 'expert', thinking_enabled: false, search_enabled: true, - real_model: 'DeepSeek Web “Эксперт” + search requested, but Expert has search_feature=null in remote config', + real_model: 'DeepSeek Web “Expert” + search requested, but Expert has search_feature=null in remote config', capabilities: { reasoning: false, web_search: false, files: false }, supported: false, unavailable_reason: 'Expert mode is rejected; remote config says search is not available for Expert.', }, 'deepseek-vision': { model_type: 'vision', thinking_enabled: false, search_enabled: false, - real_model: 'DeepSeek Web “Распознавание” / image understanding beta', + real_model: 'DeepSeek Web “Recognition” / image understanding beta', capabilities: { reasoning: false, web_search: false, files: true, vision: true }, supported: false, unavailable_reason: 'Current Web API returns: Vision is temporarily unavailable (backend_err_by_model).', @@ -488,6 +721,60 @@ const ALL_MODEL_CAPABILITIES = Object.fromEntries(Object.entries(MODEL_CONFIGS). unavailable_reason: cfg.unavailable_reason || null, }])); +function isAssistantOutputFragment(fragment) { + return fragment + && (fragment.type === 'RESPONSE' || fragment.type === 'SEARCH') + && typeof fragment.content === 'string'; +} + +function isReasoningFragment(fragment) { + return fragment + && (fragment.type === 'THINK' || fragment.type === 'REASONING') + && typeof fragment.content === 'string'; +} + +function isDeepSeekModelErrorEvent(event) { + return event && event.type === 'error'; +} + +function createUpstreamHttpError(status, body = '', retryAfter = null) { + const code = Number(status) || 502; + const detail = String(body || '').replace(/\s+/g, ' ').trim().substring(0, 300); + const type = code === 429 + ? 'rate_limit_error' + : ((code === 401 || code === 403) ? 'authentication_error' : 'upstream_http_error'); + const error = new Error(`DeepSeek upstream HTTP ${code}${detail ? `: ${detail}` : ''}`); + error.status = code; + error.type = type; + if (retryAfter) error.retryAfter = retryAfter; + return error; +} + +function rebuildFragmentText(fragments) { + const responseText = fragments + .filter(isAssistantOutputFragment) + .map(f => f.content) + .join(''); + const thinkText = fragments + .filter(isReasoningFragment) + .map(f => f.content) + .join(''); + return { responseText, thinkText }; +} + +function applyResponsePatchOperations(ops, appendFragments) { + if (!Array.isArray(ops)) return false; + let applied = false; + for (const op of ops) { + if (!op || typeof op !== 'object') continue; + if (op.p === 'fragments' && op.o === 'APPEND' && op.v !== undefined) { + appendFragments(op.v); + applied = true; + } + } + return applied; +} + function resolveModelConfig(model) { const requested = String(model || 'deepseek-chat').toLowerCase(); return MODEL_CONFIGS[requested] || MODEL_CONFIGS['deepseek-chat']; @@ -495,34 +782,30 @@ function resolveModelConfig(model) { function isKnownModel(model) { return Object.prototype.hasOwnProperty.call(MODEL_CONFIGS, String(model || '').toLowerCase()); } function isSupportedModel(model) { return resolveModelConfig(model).supported === true; } -async function askDeepSeekStream(prompt, agentId, model = 'deepseek-default') { +async function askDeepSeekStream(prompt, agentId, model = 'deepseek-default', freshSessionPrompt = prompt) { const modelCfg = resolveModelConfig(model); const session = getOrCreateAgentSession(agentId); + const hadRemoteSession = Boolean(session.id); const account = selectAccountForSession(session); const dsHeaders = account.headers; account.lastUsedAt = Date.now(); const agentTag = `[${agentId}/acct:${account.id}]`; - // Auto-reset on deep message chain - if (session.id && session.messageCount >= MAX_MESSAGE_DEPTH) { - console.log(`${agentTag} Session ${session.id} hit ${session.messageCount} messages. Auto-resetting.`); - session.id = null; - session.parentMessageId = null; - session.createdAt = null; - session.messageCount = 0; - // History preserved for context injection + // Normally this rollover is performed before the prompt is built, so local + // recovery history can be injected. Keep this guard for direct callers and + // concurrent requests that may have advanced the same session meanwhile. + const rollover = prepareSessionForPrompt(session); + const accountRotationReset = hadRemoteSession && !session.id; + const recoveredFreshSession = accountRotationReset || Boolean(rollover); + let effectivePrompt = recoveredFreshSession ? freshSessionPrompt : prompt; + if (accountRotationReset) { + console.log(`${agentTag} Account rotation reset the previous remote session; using recovery prompt.`); } - - // Reset expired sessions (DeepSeek web sessions last ~1-2 hours) - if (session.id && session.createdAt && (Date.now() - session.createdAt > SESSION_TTL_MS)) { - console.log(`${agentTag} Session ${session.id} expired (age: ${Math.round((Date.now() - session.createdAt) / 60000)}min). Creating new...`); - session.id = null; - session.parentMessageId = null; - session.createdAt = null; - session.messageCount = 0; + if (rollover) { + console.log(`${agentTag} Session ${rollover.failedSessionId} reset before upstream call (${rollover.reason}).`); } - const cr = await fetch('https://chat.deepseek.com/api/v0/chat/create_pow_challenge', { + const cr = await dsFetch('https://chat.deepseek.com/api/v0/chat/create_pow_challenge', { method: 'POST', headers: dsHeaders, body: JSON.stringify({ target_path: '/api/v0/chat/completion' }) }); @@ -538,10 +821,10 @@ async function askDeepSeekStream(prompt, agentId, model = 'deepseek-default') { if (!challenge) { throw new Error('DeepSeek PoW response has no data.biz_data.challenge. Auth may be expired, captcha may be required, or DeepSeek changed Web API. Run npm run doctor, then npm run auth.'); } - const answer = await solvePOW(challenge, account.config); + const answer = await solvePOW(challenge, account.config.wasmUrl); if (!session.id) { - const sr = await fetch('https://chat.deepseek.com/api/v0/chat_session/create', { + const sr = await dsFetch('https://chat.deepseek.com/api/v0/chat_session/create', { method: 'POST', headers: dsHeaders, body: '{}' }); const { json: sessionData, text: sessionText } = await readDeepSeekJsonResponse(sr, 'session create', account); @@ -564,104 +847,202 @@ async function askDeepSeekStream(prompt, agentId, model = 'deepseek-default') { salt: challenge.salt, answer: answer, signature: challenge.signature, target_path: '/api/v0/chat/completion' })).toString('base64'); - const resp = await fetch('https://chat.deepseek.com/api/v0/chat/completion', { - method: 'POST', - headers: { ...dsHeaders, 'X-DS-PoW-Response': powB64 }, - body: JSON.stringify({ - chat_session_id: session.id, - parent_message_id: session.parentMessageId, - model_type: modelCfg.model_type, - prompt: prompt, ref_file_ids: [], - thinking_enabled: modelCfg.thinking_enabled, search_enabled: modelCfg.search_enabled, - action: null, preempt: false, - }) - }); + + // Retry with exponential backoff for 502 errors (DeepSeek server overload) + const MAX_502_RETRIES = 3; + const BASE_502_DELAY_MS = 2000; + let last502Error = null; + + for (let retry502 = 0; retry502 <= MAX_502_RETRIES; retry502++) { + if (retry502 > 0) { + const delay = Math.min(BASE_502_DELAY_MS * Math.pow(2, retry502 - 1), 30000); + console.log(`${agentTag} 502 retry ${retry502}/${MAX_502_RETRIES}, waiting ${delay}ms...`); + await new Promise(r => setTimeout(r, delay)); + } + + const resp = await dsFetch('https://chat.deepseek.com/api/v0/chat/completion', { + method: 'POST', + headers: { ...dsHeaders, 'X-DS-PoW-Response': powB64 }, + body: JSON.stringify({ + chat_session_id: session.id, + parent_message_id: session.parentMessageId, + model_type: modelCfg.model_type, + prompt: effectivePrompt, ref_file_ids: [], + thinking_enabled: modelCfg.thinking_enabled, search_enabled: modelCfg.search_enabled, + action: null, preempt: false, + }) + }); - // If session expired, reset and retry once - if (resp.status !== 200) { - // Pass Retry-After so a 429 honors the server-requested cooldown (#16). - markAccountFailure(account, resp.status, 'completion', resp.headers.get('retry-after')); - const errText = await resp.text(); - console.log(`${agentTag} Session error (${resp.status}): ${errText.substring(0, 100)}`); - if (resp.status === 400 || resp.status === 404 || resp.status === 500) { - console.log(`${agentTag} Session ${session.id} expired. Creating new session...`); - session.id = null; - session.parentMessageId = null; - session.createdAt = null; - session.messageCount = 0; - - const sr2 = await fetch('https://chat.deepseek.com/api/v0/chat_session/create', { - method: 'POST', headers: dsHeaders, body: '{}' - }); - const { json: sessionData2, text: sessionText2 } = await readDeepSeekJsonResponse(sr2, 'session recreate', account); - const createdSessionId2 = sessionData2?.data?.biz_data?.chat_session?.id || sessionData2?.data?.biz_data?.id; - if (!sr2.ok || !createdSessionId2) { - throw new Error(`Could not recreate DeepSeek chat session (HTTP ${sr2.status}). Run npm run doctor, then npm run auth. First chars: ${String(sessionText2 || '').substring(0, 120)}`); + // If session expired, reset and retry once + if (resp.status !== 200) { + // Pass Retry-After so a 429 honors the server-requested cooldown (#16). + const retryAfter = resp.headers.get('retry-after'); + markAccountFailure(account, resp.status, 'completion', retryAfter); + const errText = await resp.text(); + console.log(`${agentTag} Session error (${resp.status}): ${errText.substring(0, 100)}`); + + // 502 = DeepSeek server overload — retry with backoff + if (resp.status === 502) { + last502Error = createUpstreamHttpError(resp.status, errText, retryAfter); + console.log(`${agentTag} 502 server overload, will retry if attempts remain...`); + continue; // retry loop } - session.id = createdSessionId2; - session.accountId = account.id; - session.parentMessageId = null; - session.createdAt = Date.now(); - console.log(`${agentTag} Created new session: ${session.id}`); - - const newPowB64 = Buffer.from(JSON.stringify({ - algorithm: challenge.algorithm, challenge: challenge.challenge, - salt: challenge.salt, answer: answer, - signature: challenge.signature, target_path: '/api/v0/chat/completion' - })).toString('base64'); - const resp2 = await fetch('https://chat.deepseek.com/api/v0/chat/completion', { - method: 'POST', - headers: { ...dsHeaders, 'X-DS-PoW-Response': newPowB64 }, - body: JSON.stringify({ - chat_session_id: session.id, - parent_message_id: null, - model_type: modelCfg.model_type, - prompt: prompt, ref_file_ids: [], - thinking_enabled: modelCfg.thinking_enabled, search_enabled: modelCfg.search_enabled, - action: null, preempt: false, - }) - }); - return { resp: resp2, agentId, account }; + + if (resp.status === 400 || resp.status === 404 || resp.status === 500) { + console.log(`${agentTag} Session ${session.id} expired. Creating new session...`); + resetRemoteSession(session); + + const sr2 = await dsFetch('https://chat.deepseek.com/api/v0/chat_session/create', { + method: 'POST', headers: dsHeaders, body: '{}' + }); + const { json: sessionData2, text: sessionText2 } = await readDeepSeekJsonResponse(sr2, 'session recreate', account); + const createdSessionId2 = sessionData2?.data?.biz_data?.chat_session?.id || sessionData2?.data?.biz_data?.id; + if (!sr2.ok || !createdSessionId2) { + throw new Error(`Could not recreate DeepSeek chat session (HTTP ${sr2.status}). Run npm run doctor, then npm run auth. First chars: ${String(sessionText2 || '').substring(0, 120)}`); + } + session.id = createdSessionId2; + session.accountId = account.id; + session.parentMessageId = null; + session.createdAt = Date.now(); + console.log(`${agentTag} Created new session: ${session.id}`); + + const newPowB64 = Buffer.from(JSON.stringify({ + algorithm: challenge.algorithm, challenge: challenge.challenge, + salt: challenge.salt, answer: answer, + signature: challenge.signature, target_path: '/api/v0/chat/completion' + })).toString('base64'); + const resp2 = await dsFetch('https://chat.deepseek.com/api/v0/chat/completion', { + method: 'POST', + headers: { ...dsHeaders, 'X-DS-PoW-Response': newPowB64 }, + body: JSON.stringify({ + chat_session_id: session.id, + parent_message_id: null, + model_type: modelCfg.model_type, + prompt: freshSessionPrompt, ref_file_ids: [], + thinking_enabled: modelCfg.thinking_enabled, search_enabled: modelCfg.search_enabled, + action: null, preempt: false, + }) + }); + if (!resp2.ok) { + const retryAfter2 = resp2.headers.get('retry-after'); + markAccountFailure(account, resp2.status, 'completion after session recreate', retryAfter2); + const errText2 = await resp2.text(); + throw createUpstreamHttpError(resp2.status, errText2, retryAfter2); + } + effectivePrompt = freshSessionPrompt; + return { resp: resp2, agentId, account, promptUsed: effectivePrompt, freshSessionReset: true }; + } + // The body was consumed for diagnostics, so returning this Response + // would hand a locked stream to readDeepSeekResponse. Surface a typed + // error instead and retain the real upstream status/Retry-After. + throw createUpstreamHttpError(resp.status, errText, retryAfter); } + + // Success — return the response + return { resp, agentId, account, promptUsed: effectivePrompt, freshSessionReset: recoveredFreshSession }; } - - return { resp, agentId, account }; + + // All 502 retries exhausted + throw last502Error || new Error('DeepSeek server overload: all 502 retries exhausted'); } // === Tool Calling Support === +const TOOL_SCHEMA_ANNOTATION_KEYS = new Set(['description', 'examples', '$comment', 'title']); +const TOOL_SCHEMA_MAP_KEYS = new Set(['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas']); +const TOOL_SCHEMA_ARRAY_KEYS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']); +const TOOL_SCHEMA_SINGLE_KEYS = new Set([ + 'additionalItems', 'additionalProperties', 'contains', 'contentSchema', 'else', 'if', + 'items', 'not', 'propertyNames', 'then', 'unevaluatedItems', 'unevaluatedProperties', +]); + +function compactToolSchema(value) { + if (Array.isArray(value)) return value.map(compactToolSchema); + if (!value || typeof value !== 'object') return value; + const compact = {}; + for (const [key, child] of Object.entries(value)) { + // Descriptions/examples dominate large agent tool payloads but do not + // affect argument validation. Traverse only keywords whose values are + // themselves schemas. Literal instance values under const/enum/default + // must remain byte-for-byte equivalent, even when they contain fields + // named "description" or "title". + if (TOOL_SCHEMA_ANNOTATION_KEYS.has(key)) continue; + if (TOOL_SCHEMA_MAP_KEYS.has(key) && child && typeof child === 'object' && !Array.isArray(child)) { + compact[key] = Object.fromEntries(Object.entries(child).map(([name, schema]) => [name, compactToolSchema(schema)])); + } else if (TOOL_SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(child)) { + compact[key] = child.map(compactToolSchema); + } else if (TOOL_SCHEMA_SINGLE_KEYS.has(key)) { + compact[key] = Array.isArray(child) ? child.map(compactToolSchema) : compactToolSchema(child); + } else if (key === 'dependencies' && child && typeof child === 'object' && !Array.isArray(child)) { + compact[key] = Object.fromEntries(Object.entries(child).map(([name, dependency]) => [ + name, + Array.isArray(dependency) ? dependency : compactToolSchema(dependency), + ])); + } else { + compact[key] = child; + } + } + return compact; +} + function formatToolDefinitions(tools) { if (!tools || tools.length === 0) return ''; + const MAX_TOOLS = 60; + const truncated = tools.length > MAX_TOOLS; + const effectiveTools = truncated ? tools.slice(0, MAX_TOOLS) : tools; + const rawSchemaChars = effectiveTools.reduce((total, tool) => { + try { return total + JSON.stringify(tool?.function?.parameters || {}).length; } + catch (e) { return total; } + }, 0); + const compactSchemas = rawSchemaChars > Math.floor(MAX_UPSTREAM_PROMPT_CHARS * 0.3) || effectiveTools.length > 30; let text = '\n\n--- TOOL REQUEST SYSTEM ---\n'; - text += 'You are an AI that ONLY REASONS and REQUESTS tool executions. You do NOT run any commands yourself.\n'; - text += 'When you need data from the local server, REQUEST exactly one tool call. Prefer strict JSON:\n'; - text += '{"tool_call":{"name":"","arguments":{...}}}\n\n'; - text += 'Legacy format is also accepted: TOOL_CALL: \narguments: \n\n'; - text += 'Your response will be sent to the local gateway, which executes the command and sends the output back in the next message.\n\n'; + text += 'You are an AI assistant that can use tools to help the user.\n\n'; text += 'RULES:\n'; - text += '1. You ONLY output the tool request — you never run anything yourself\n'; - text += '2. Do NOT simulate, guess, or fabricate command output — wait for the actual result\n'; - text += '3. The tool runs on ' + SERVER_HOST + ' (' + SERVER_PUBLIC_IP + '), the local server — NOT on DeepSeek\n'; - text += '4. After the tool executes, the result will be sent to you as a new user/tool message\n'; - text += '5. Never add explanation before or after the tool request when requesting a tool\n'; - text += '6. Keep arguments compact. Do not include large file contents unless the tool schema requires it.\n\n'; - text += 'Available functions:\n'; - for (const tool of tools) { + text += '1. When you need to run a command or perform an action, output a tool call.\n'; + text += '2. Output ONLY the tool call JSON — no explanation, no code fences, no markdown.\n'; + text += '3. After receiving a tool result, analyze it and respond to the user.\n\n'; + text += 'TOOL CALL FORMAT (output exactly this, nothing else):\n'; + text += '{"tool_call":{"name":"","arguments":{...}}}\n\n'; + text += 'EXAMPLES:\n'; + text += 'To list files: {"tool_call":{"name":"terminal","arguments":{"command":"ls -la"}}}\n'; + text += 'To read a file: {"tool_call":{"name":"read_file","arguments":{"path":"/path/to/file"}}}\n'; + text += 'To edit a file: {"tool_call":{"name":"edit_file","arguments":{"path":"/path/to/file","content":"new content"}}}\n\n'; + text += 'WRONG — do NOT do these:\n'; + text += '- Do NOT wrap in ```json``` code fences\n'; + text += '- Do NOT use XML or DSML format\n'; + text += '- Do NOT explain what you will do before the tool call\n'; + text += '- Do NOT output tool call and text together\n\n'; + text += 'CHOOSE THE RIGHT TOOL:\n'; + text += '- "terminal" → shell commands (ls, cat, grep, etc)\n'; + text += '- "read_file" → read file contents\n'; + text += '- "edit_file" → modify file contents\n'; + text += '- "execute_code" → run code in sandbox\n\n'; + text += `Available functions (${effectiveTools.length}${truncated ? ` of ${tools.length} — truncated to save context` : ''}):\n`; + for (const tool of effectiveTools) { if (tool.type === 'function' && tool.function) { const fn = tool.function; text += `\n## ${fn.name}\n`; - text += `${fn.description || ''}\n`; + const description = String(fn.description || '').replace(/\s+/g, ' ').trim(); + text += `${description.length > 200 ? description.substring(0, 197) + '...' : description}\n`; if (fn.parameters) { - text += `Parameters: ${JSON.stringify(fn.parameters)}\n`; + text += `Parameters: ${JSON.stringify(compactSchemas ? compactToolSchema(fn.parameters) : fn.parameters)}\n`; } } } text += '\n--- END TOOL REQUEST SYSTEM ---\n'; - text += '\nREMEMBER: Request tools only with strict JSON or TOOL_CALL legacy format. Never simulate results.'; + text += '\nREMEMBER: Output ONLY the tool call JSON. No text before or after. No code fences. No explanation.'; return text; } +const MAX_TOOL_MARKUP_CHARS = 256 * 1024; +const MAX_TOOL_ARGUMENT_CHARS = 128 * 1024; +const MAX_TOOL_JSON_CANDIDATES = 32; +const MAX_DSML_PARAMETERS = 128; +const MAX_DSML_STRUCTURAL_TAGS = MAX_DSML_PARAMETERS * 2 + 16; +const MAX_DSML_TAG_CHARS = 2048; + function extractBalancedJsonAt(text, startIndex) { + if (text[startIndex] !== '{') return null; let braceDepth = 0; let inString = false; let escape = false; @@ -681,44 +1062,385 @@ function extractBalancedJsonAt(text, startIndex) { return null; } -function coerceToolCallObject(obj) { - if (!obj || typeof obj !== 'object') return null; - const candidate = obj.tool_call || obj.tool || obj.function_call || obj; - if (!candidate || typeof candidate !== 'object') return null; - const fn = candidate.function && typeof candidate.function === 'object' ? candidate.function : candidate; - const name = fn.name || candidate.name || obj.name; - let args = fn.arguments ?? candidate.arguments ?? candidate.input ?? obj.arguments ?? obj.input ?? {}; - if (!name || typeof name !== 'string') return null; - if (typeof args === 'string') { - try { args = JSON.parse(args); } catch (e) { args = { raw: args }; } +function extractBalancedJsonObjects(text, maxObjects = MAX_TOOL_JSON_CANDIDATES) { + const objects = []; + let start = -1; + let depth = 0; + let inString = false; + let escape = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (start === -1) { + if (ch === '{') { + start = i; + depth = 1; + inString = false; + escape = false; + } + continue; + } + if (escape) { escape = false; continue; } + if (ch === '\\' && inString) { escape = true; continue; } + if (ch === '"') { inString = !inString; continue; } + if (inString) continue; + if (ch === '{') depth++; + if (ch === '}') { + depth--; + if (depth === 0) { + objects.push(text.substring(start, i + 1)); + if (objects.length >= maxObjects) return objects; + start = -1; + } + } + } + return objects; +} + +function buildToolCall(name, args = {}) { + const toolName = typeof name === 'string' ? name.trim() : ''; + if (!/^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/.test(toolName)) return null; + let parsedArgs = args; + if (typeof parsedArgs === 'string') { + if (parsedArgs.length > MAX_TOOL_ARGUMENT_CHARS) return null; + try { parsedArgs = JSON.parse(parsedArgs); } catch (e) { return null; } + } + if (parsedArgs === null || parsedArgs === undefined) parsedArgs = {}; + if (typeof parsedArgs !== 'object' || Array.isArray(parsedArgs)) return null; + let serialized; + try { serialized = JSON.stringify(parsedArgs); } catch (e) { return null; } + if (serialized.length > MAX_TOOL_ARGUMENT_CHARS) return null; + return { name: toolName, arguments: serialized }; +} + +function coerceToolCallObject(obj, { allowBare = false } = {}) { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null; + let candidate = null; + if (Object.prototype.hasOwnProperty.call(obj, 'tool_call')) { + candidate = obj.tool_call; + } else if (Object.prototype.hasOwnProperty.call(obj, 'function_call')) { + candidate = obj.function_call; + } else if (Object.prototype.hasOwnProperty.call(obj, 'tool_calls')) { + if (!Array.isArray(obj.tool_calls) || obj.tool_calls.length !== 1) return null; + candidate = obj.tool_calls[0]; + } else if (allowBare) { + candidate = obj; } - if (!args || typeof args !== 'object' || Array.isArray(args)) args = { value: args }; - return { name, arguments: JSON.stringify(args) }; + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null; + const fn = candidate.function && typeof candidate.function === 'object' + ? candidate.function + : candidate; + return buildToolCall( + fn.name ?? candidate.name, + fn.arguments ?? candidate.arguments ?? candidate.input ?? {} + ); +} + +function repairJson(json) { + let fixed = json; + // Remove trailing commas before } or ] + fixed = fixed.replace(/,\s*([}\]])/g, '$1'); + // Add missing closing braces/brackets + const opens = (fixed.match(/{/g) || []).length; + const closes = (fixed.match(/}/g) || []).length; + for (let i = 0; i < opens - closes; i++) fixed += '}'; + const opensBracket = (fixed.match(/\[/g) || []).length; + const closesBracket = (fixed.match(/]/g) || []).length; + for (let i = 0; i < opensBracket - closesBracket; i++) fixed += ']'; + // Fix single quotes to double quotes (only for property keys and string values) + fixed = fixed.replace(/'/g, '"'); + return fixed; } -function parseJsonToolCandidate(raw, label = 'json') { +function parseJsonToolCandidate(raw, label = 'json', options = {}) { if (!raw) return null; + // Try direct parse first try { const parsed = JSON.parse(raw); - const tc = coerceToolCallObject(parsed); + const tc = coerceToolCallObject(parsed, options); if (tc) { console.log(`[parseToolCall] SUCCESS ${label}: ${tc.name} (args=${tc.arguments.length} chars)`); return tc; } } catch (e) { - console.log(`[parseToolCall] ${label} JSON.parse failed: ${e.message.substring(0, 100)}`); + // Try repair + try { + const repaired = repairJson(raw); + const parsed = JSON.parse(repaired); + const tc = coerceToolCallObject(parsed, options); + if (tc) { + console.log(`[parseToolCall] SUCCESS ${label} (repaired): ${tc.name} (args=${tc.arguments.length} chars)`); + return tc; + } + } catch (e2) { + console.log(`[parseToolCall] ${label} JSON.parse failed: ${e.message.substring(0, 100)}`); + } } return null; } +function canonicalizeToolMarkupTag(rawTag) { + let token = String(rawTag || '').trim() + .replace(/|/g, '|') + .replace(/[“”"]/g, '"') + .replace(/[‘’']/g, "'"); + let closing = false; + if (token.startsWith('/')) { + closing = true; + token = token.substring(1).trim(); + } + token = token.replace(/^\|+\s*DSML\s*\|+\s*/i, ''); + if (token.startsWith('/')) { + closing = true; + token = token.substring(1).trim(); + } + token = token.replace(/^DSML(?=(?:tool[\s_-]*calls|function[\s_-]*calls|invoke|parameter)\b)/i, ''); + + if (!closing && /^name\s*=/i.test(token)) return ``; + + const semantic = token.match(/^(?:(?:[A-Za-z_][\w.-]*):)?(tool[\s_-]*calls|function[\s_-]*calls|invoke|parameter)\b([\s\S]*)$/i); + if (!semantic) return null; + const localName = semantic[1].replace(/[\s_-]/g, '').toLowerCase(); + const canonicalName = localName === 'toolcalls' || localName === 'functioncalls' + ? 'tool_calls' + : localName; + const attrs = closing ? '' : semantic[2]; + return `<${closing ? '/' : ''}${canonicalName}${attrs}>`; +} + +function normalizeToolMarkupTags(text) { + const withAsciiAngles = String(text || '').replace(/</g, '<').replace(/>/g, '>'); + return withAsciiAngles.replace(/<([^<>]{0,1024})>/g, (whole, rawTag) => { + const canonical = canonicalizeToolMarkupTag(rawTag); + return canonical || whole; + }); +} + +function decodeDsmlValue(value) { + return String(value || '') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/&/gi, '&'); +} + +function decodeDsmlParameterValue(value) { + const raw = String(value || ''); + const cdata = raw.trim().match(/^$/i); + return cdata ? cdata[1] : decodeDsmlValue(raw); +} + +function getMarkupAttribute(attrs, attribute) { + const match = String(attrs || '').match(new RegExp(`\\b${attribute}\\s*=\\s*(["'])([^"']+)\\1`, 'i')); + return match ? match[2] : null; +} + +function readDsmlTagAt(text, start) { + if (text[start] !== '<') return null; + const prefix = text.substring(start + 1, Math.min(text.length, start + 40)).trimStart(); + if (!/^\/?(?:tool_calls|invoke|parameter|direct)\b/i.test(prefix)) return null; + let quote = null; + let end = -1; + const scanEnd = Math.min(text.length, start + MAX_DSML_TAG_CHARS + 1); + for (let i = start + 1; i < scanEnd; i++) { + const ch = text[i]; + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '>') { + end = i; + break; + } + } + if (end === -1) return { invalid: true }; + + let token = text.substring(start + 1, end).trim(); + let closing = false; + if (token.startsWith('/')) { + closing = true; + token = token.substring(1).trim(); + } + let selfClosing = false; + if (!closing && token.endsWith('/')) { + selfClosing = true; + token = token.substring(0, token.length - 1).trim(); + } + const match = token.match(/^(tool_calls|invoke|parameter|direct)\b([\s\S]*)$/i); + if (!match) return null; + return { + name: match[1].toLowerCase(), + attrs: closing ? '' : match[2], + closing, + selfClosing, + start, + end: end + 1, + }; +} + +function scanDsmlStructuralTags(text) { + const tags = []; + const value = String(text || ''); + for (let i = 0; i < value.length;) { + if (value.substring(i, i + 9).toUpperCase() === '', i + 9); + if (cdataEnd === -1) return null; + i = cdataEnd + 3; + continue; + } + if (value[i] !== '<') { + i++; + continue; + } + const tag = readDsmlTagAt(value, i); + if (!tag) { + i++; + continue; + } + if (tag.invalid) return null; + tags.push(tag); + if (tags.length > MAX_DSML_STRUCTURAL_TAGS) return null; + i = tag.end; + } + return tags; +} + +function parseDsmlParameter(attrs, rawBody, args, seenNames) { + const parameterName = getMarkupAttribute(attrs, 'name'); + if (!parameterName || !/^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/.test(parameterName) || seenNames.has(parameterName)) return false; + seenNames.add(parameterName); + const stringMode = getMarkupAttribute(attrs, 'string'); + const rawValue = decodeDsmlParameterValue(rawBody); + if (rawValue.length > MAX_TOOL_ARGUMENT_CHARS) return false; + let value = rawValue; + if (stringMode && stringMode.toLowerCase() === 'false') { + try { value = JSON.parse(rawValue.trim()); } catch (e) { return false; } + } + args[parameterName] = value; + return true; +} + +function parseDsmlInvoke(name, body) { + const structuralTags = scanDsmlStructuralTags(body); + if (!structuralTags) return null; + const parameterTags = structuralTags.filter(tag => tag.name === 'parameter'); + if (structuralTags.some(tag => tag.name !== 'parameter')) return null; + + const args = {}; + let parameterCount = 0; + const seenNames = new Set(); + let cursor = 0; + for (let i = 0; i < parameterTags.length; i += 2) { + const opening = parameterTags[i]; + const closing = parameterTags[i + 1]; + if (!opening || opening.closing || opening.selfClosing || !closing || !closing.closing) return null; + if (body.substring(cursor, opening.start).trim()) return null; + parameterCount++; + if (parameterCount > MAX_DSML_PARAMETERS) return null; + if (!parseDsmlParameter(opening.attrs, body.substring(opening.end, closing.start), args, seenNames)) return null; + cursor = closing.end; + } + if (parameterCount > 0) { + if (body.substring(cursor).trim()) return null; + return buildToolCall(name, args); + } + + const decodedBody = decodeDsmlValue(body).trim(); + if (!decodedBody) return buildToolCall(name, {}); + const objects = extractBalancedJsonObjects(decodedBody, 2); + if (objects.length !== 1 || decodedBody !== objects[0]) return null; + try { return buildToolCall(name, JSON.parse(objects[0])); } + catch (e) { return null; } +} + +function extractToolCallScope(normalized) { + const tags = scanDsmlStructuralTags(normalized); + if (!tags) return null; + const wrappers = tags.filter(tag => tag.name === 'tool_calls'); + const openings = wrappers.filter(tag => !tag.closing); + const closings = wrappers.filter(tag => tag.closing); + if (openings.length > 0) { + if (openings.length !== 1 || openings[0].selfClosing || closings.length === 0) return null; + const opening = openings[0]; + const closing = closings[closings.length - 1]; + if (wrappers.some(tag => tag.closing && tag.start < opening.end) || closing.start < opening.end) return null; + if (tags.some(tag => tag.name !== 'tool_calls' && (tag.start < opening.end || tag.start >= closing.start))) return null; + return normalized.substring(opening.end, closing.start); + } + // Narrow repair: tolerate a missing opening wrapper only when a closing + // wrapper exists. A bare invoke without this sentinel is never executable. + if (closings.length > 0) { + const closing = closings[closings.length - 1]; + const invokeOpenings = tags.filter(tag => tag.name === 'invoke' && !tag.closing && tag.start < closing.start); + if (invokeOpenings.length === 1 && !invokeOpenings[0].selfClosing) { + if (tags.some(tag => tag.name !== 'tool_calls' && (tag.start < invokeOpenings[0].start || tag.start >= closing.start))) return null; + return normalized.substring(invokeOpenings[0].start, closing.start); + } + } + return null; +} + +function parseDsmlToolCall(text) { + if (String(text || '').length > MAX_TOOL_MARKUP_CHARS) return null; + const normalized = normalizeToolMarkupTags(text); + const scope = extractToolCallScope(normalized); + if (scope === null) return null; + const tags = scanDsmlStructuralTags(scope); + if (!tags || tags.length === 0) return null; + const first = tags[0]; + if (scope.substring(0, first.start).trim()) return null; + + if (first.name === 'invoke' && !first.closing && !first.selfClosing) { + const invokeTags = tags.filter(tag => tag.name === 'invoke'); + if (invokeTags.length !== 2 || invokeTags[0] !== first || invokeTags[1].closing !== true) return null; + const closing = invokeTags[1]; + if (scope.substring(closing.end).trim()) return null; + if (tags.some(tag => (tag.name === 'tool_calls' || tag.name === 'direct'))) return null; + const parsed = parseDsmlInvoke(getMarkupAttribute(first.attrs, 'name'), scope.substring(first.end, closing.start)); + if (parsed) { + console.log(`[parseToolCall] SUCCESS dsml: ${parsed.name} (args=${parsed.arguments.length} chars)`); + return parsed; + } + } + + if (first.name === 'direct' && !first.closing && !first.selfClosing) { + if (tags.some((tag, index) => index > 0 && (tag.name === 'direct' || tag.name === 'invoke' || tag.name === 'tool_calls'))) return null; + const parsed = parseDsmlInvoke(getMarkupAttribute(first.attrs, 'name'), scope.substring(first.end)); + if (parsed) { + console.log(`[parseToolCall] SUCCESS dsml-direct: ${parsed.name} (args=${parsed.arguments.length} chars)`); + return parsed; + } + } + return null; +} + +function looksLikeToolCallMarkup(text) { + return /TOOL_CALL:\s*[\w-]+|<\s*tool_call\b|[||]+\s*DSML\s*[||]+|[<<]\s*\/?\s*(?:DSML)?(?:[\w.-]+:)?(?:tool[\s_-]*calls|function[\s_-]*calls|invoke)\b|["'](?:tool_call|tool_calls|function_call)["']\s*:/i.test(String(text || '')); +} + function parseToolCall(text) { if (!text || typeof text !== 'string') return null; + if (text.length > MAX_TOOL_MARKUP_CHARS) { + console.log(`[parseToolCall] Refusing oversized tool markup candidate (${text.length} chars)`); + return null; + } + + if (/[||]+\s*DSML\s*[||]+|[<<]\s*\/?\s*(?:DSML)?(?:[\w.-]+:)?(?:tool[\s_-]*calls|function[\s_-]*calls|invoke)\b/i.test(text)) { + const dsml = parseDsmlToolCall(text); + if (dsml) return dsml; + console.log('[parseToolCall] Tool markup found but wrapper/invoke was incomplete or malformed'); + return null; + } // XML-ish wrappers used by some agent prompts. const xmlMatch = text.match(/]*>([\s\S]*?)<\/tool_call>/i); if (xmlMatch) { const inner = xmlMatch[1].trim(); - const tc = parseJsonToolCandidate(inner, 'xml'); + const tc = parseJsonToolCandidate(inner, 'xml', { allowBare: true }); if (tc) return tc; } @@ -741,8 +1463,11 @@ function parseToolCall(text) { if (rawJson) { try { const args = JSON.parse(rawJson); - console.log(`[parseToolCall] SUCCESS legacy: ${name} (args=${rawJson.length} chars)`); - return { name, arguments: JSON.stringify(args) }; + const tc = buildToolCall(name, args); + if (tc) { + console.log(`[parseToolCall] SUCCESS legacy: ${name} (args=${rawJson.length} chars)`); + return tc; + } } catch (e) { console.log(`[parseToolCall] legacy JSON.parse failed: ${e.message.substring(0,100)}`); } @@ -754,12 +1479,9 @@ function parseToolCall(text) { } } - // First balanced JSON object in the whole response. Supports: - // {"tool_call":{"name":"...","arguments":{...}}}, {"name":"...","arguments":{...}}, etc. - for (let i = 0; i < text.length; i++) { - if (text[i] !== '{') continue; - const rawJson = extractBalancedJsonAt(text, i); - if (!rawJson) continue; + // Scan each top-level balanced object once (linear time). Only explicit + // tool-call envelopes are executable; bare {name, arguments} examples are not. + for (const rawJson of extractBalancedJsonObjects(text)) { const tc = parseJsonToolCandidate(rawJson, 'inline'); if (tc) return tc; } @@ -823,7 +1545,7 @@ function buildToolCallResponse(toolCall, model = 'deepseek-default', prompt = '' }; } -function buildTextResponse(content, prompt, model = 'deepseek-default', reasoningContent = '') { +function buildTextResponse(content, prompt, model = 'deepseek-default', reasoningContent = '', finishReason = null) { const message = { role: 'assistant', content }; if (reasoningContent) message.reasoning_content = reasoningContent; return { @@ -834,7 +1556,9 @@ function buildTextResponse(content, prompt, model = 'deepseek-default', reasonin choices: [{ index: 0, message, - finish_reason: 'stop' + // Surface truncation: a 'length' finish lets length-aware clients re-request + // instead of silently treating a cut-off answer as a clean stop. + finish_reason: finishReason === 'length' ? 'length' : 'stop' }], usage: buildUsage(prompt, content, reasoningContent), watermark: FORGETMEAI_WATERMARK @@ -987,7 +1711,7 @@ function writeSse(res, event, data) { } function sendAnthropicStream(res, openaiResp) { - res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' }); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); const choice = openaiResp.choices[0]; const msg = choice.message || {}; const message = toAnthropicResponse(openaiResp); @@ -1058,7 +1782,7 @@ function toResponsesResponse(openaiResp) { } function sendResponsesStream(res, openaiResp) { - res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' }); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); const response = toResponsesResponse(openaiResp); const choice = openaiResp.choices[0]; const msg = choice.message || {}; @@ -1100,7 +1824,7 @@ function sendResponsesStream(res, openaiResp) { } function sendOpenAIStream(res, openaiResp) { - res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' }); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); const choice = openaiResp.choices[0]; const msg = choice.message || {}; const id = openaiResp.id; @@ -1185,21 +1909,150 @@ function extractScreenshotPaths(messages) { return paths; } +const PROMPT_COMPACTION_MARKER = '\n\n[Earlier context compacted by FreeDeepseekAPI]\n\n'; + +function truncatePromptMiddle(text, maxChars, headRatio = 0.35) { + const value = String(text || ''); + if (value.length <= maxChars) return value; + if (maxChars <= 0) return ''; + if (maxChars <= PROMPT_COMPACTION_MARKER.length) return value.substring(value.length - maxChars); + const payloadChars = maxChars - PROMPT_COMPACTION_MARKER.length; + const headChars = Math.max(0, Math.min(payloadChars, Math.floor(payloadChars * headRatio))); + const tailChars = payloadChars - headChars; + return value.substring(0, headChars) + PROMPT_COMPACTION_MARKER + value.substring(value.length - tailChars); +} + +function hasExplicitConversationHistory(messages) { + const turns = (messages || []).filter(msg => msg && msg.role !== 'system'); + return turns.length > 1 || turns.some(msg => msg.role === 'assistant' || msg.role === 'tool'); +} + +function buildRecoveryHistoryPrefix(history) { + if (!Array.isArray(history) || history.length === 0) return ''; + let prefix = '[Previous conversation]\n'; + for (const exchange of history) { + prefix += `User: ${String(exchange?.user || '')}\nAssistant: ${String(exchange?.assistant || '')}\n\n`; + } + return prefix + '[Continue from here]\n\n'; +} + +function buildBoundedPrompt(systemPrompt, historyPrefix, conversationPrompt, maxChars = MAX_UPSTREAM_PROMPT_CHARS) { + const system = String(systemPrompt || '').trim(); + const history = String(historyPrefix || ''); + const conversation = String(conversationPrompt || '').trim(); + const original = system ? `${system}\n\n${history}${conversation}` : `${history}${conversation}`; + const safeMax = Math.max(1, Math.floor(Number(maxChars) || MAX_UPSTREAM_PROMPT_CHARS)); + if (original.length <= safeMax) { + return { prompt: original, compacted: false, historyDropped: false, originalChars: original.length, promptChars: original.length }; + } + + // Server-side history is only a recovery hint. Drop it before truncating + // client-provided messages, which may already contain the same turns. + const historyDropped = history.length > 0; + const currentConversation = conversation; + const separatorLength = system && currentConversation ? 2 : 0; + let systemBudget = system ? Math.floor((safeMax - separatorLength) * 0.5) : 0; + let conversationBudget = Math.max(0, safeMax - separatorLength - systemBudget); + + // Give unused capacity from a short side to the other side. + if (system.length < systemBudget) { + systemBudget = system.length; + conversationBudget = Math.max(0, safeMax - separatorLength - systemBudget); + } else if (currentConversation.length < conversationBudget) { + conversationBudget = currentConversation.length; + systemBudget = Math.max(0, safeMax - separatorLength - conversationBudget); + } + + // Preserve the start of the task/system instructions and the most recent + // tool loop. The injected tool adapter lives at the end of systemPrompt. + const boundedSystem = truncatePromptMiddle(system, systemBudget, 0.35); + const boundedConversation = truncatePromptMiddle(currentConversation, conversationBudget, 0.25); + let bounded = boundedSystem && boundedConversation + ? `${boundedSystem}\n\n${boundedConversation}` + : (boundedSystem || boundedConversation); + if (bounded.length > safeMax) bounded = bounded.substring(0, safeMax); + return { + prompt: bounded, + compacted: true, + historyDropped, + originalChars: original.length, + promptChars: bounded.length, + }; +} + +function buildRetryPrompt(systemPrompt, historyPrefix, conversationPrompt, currentPrompt, maxChars) { + const retryBuild = buildBoundedPrompt(systemPrompt, historyPrefix, conversationPrompt, maxChars); + const current = String(currentPrompt || ''); + return { + ...retryBuild, + compacted: retryBuild.compacted || retryBuild.prompt.length < current.length, + originalChars: retryBuild.originalChars, + promptChars: retryBuild.prompt.length, + previousPromptChars: current.length, + }; +} + +function appendPromptInstruction(promptText, instruction, maxChars = MAX_UPSTREAM_PROMPT_CHARS) { + const suffix = `\n\n${String(instruction || '').trim()}`; + const baseBudget = Math.max(0, maxChars - suffix.length); + return truncatePromptMiddle(promptText, baseBudget, 0.35) + suffix; +} + +function isContinuationRecoverySafe(previousAccountId, continuationCall) { + const nextAccountId = continuationCall?.account?.id; + return !previousAccountId + || !nextAccountId + || nextAccountId === previousAccountId + || continuationCall?.freshSessionReset === true; +} + +function isContextTooLongError(error) { + const message = typeof error === 'string' + ? error + : `${error?.content || ''} ${error?.message || ''} ${error?.finish_reason || ''} ${error?.type || ''}`; + return /(?:content|prompt|context).{0,40}(?:too\s+long|too\s+large|length|limit|maximum)|maximum.{0,30}(?:context|token)|too\s+many\s+tokens|содержани[ея]\s+слишком\s+длин|контекст.{0,30}(?:длин|лимит)|内容.{0,12}(?:过长|太长)|上下文.{0,12}(?:过长|超出)/i.test(message); +} + +function normalizeRetryResponse(result) { + return { + content: result?.content ? sanitizeContent(result.content) : '', + reasoningContent: result?.reasoningContent ? sanitizeContent(result.reasoningContent) : '', + finishReason: result?.finishReason ?? null, + modelError: result?.modelError || null, + }; +} + +function classifyRecoveryFailure(modelError, timedOut = false) { + if (isContextTooLongError(modelError)) return { status: 400, type: 'context_length_exceeded' }; + if (timedOut) return { status: 504, type: 'request_timeout' }; + return { status: 502, type: modelError?.type || 'empty_response' }; +} + +function isTimeoutError(error) { + const name = String(error?.name || ''); + const message = String(error?.message || ''); + return name === 'TimeoutError' || name === 'AbortError' || /(?:timed?\s*out|timeout)/i.test(message); +} + function formatMessages(messages, tools) { - let systemPrompt = ''; + let rawSystemPrompt = ''; for (const msg of messages) { if (msg.role === 'system' && msg.content) { - systemPrompt += msg.content + '\n'; + rawSystemPrompt += normalizeMessageContent(msg.content) + '\n'; } } - systemPrompt += formatToolDefinitions(tools); + const toolDefs = formatToolDefinitions(tools); + // Return tool definitions separately so they can be injected AFTER compaction. + // If tools are part of the system prompt that gets truncated, tool definitions + // get lost — the model then doesn't know which tools exist. + const systemPrompt = rawSystemPrompt + toolDefs; // Build full conversation history for DeepSeek's context let conversation = ''; for (const msg of messages) { if (msg.role === 'system') continue; // already in systemPrompt if (msg.role === 'user' && msg.content) { - conversation += `User: ${msg.content}\n\n`; + conversation += `User: ${normalizeMessageContent(msg.content)}\n\n`; } else if (msg.role === 'assistant') { if (msg.tool_calls && msg.tool_calls.length > 0) { // This was a tool call response from a previous turn @@ -1207,30 +2060,33 @@ function formatMessages(messages, tools) { conversation += `Assistant: TOOL_CALL: ${tc.function.name}\narguments: ${tc.function.arguments}\n\n`; } } else if (msg.content) { - conversation += `Assistant: ${msg.content}\n\n`; + conversation += `Assistant: ${normalizeMessageContent(msg.content)}\n\n`; } } else if (msg.role === 'tool' && msg.content) { // Tool execution result — send back to DeepSeek as context - const truncated = msg.content.length > 8000 - ? msg.content.substring(0, 8000) + '\n...[truncated]' - : msg.content; - conversation += `[Tool Result]\n${truncated}\n\n`; + const toolContent = normalizeMessageContent(msg.content); + // Do not impose a second, per-result 8k limit: one large tool result + // may be the essential input. buildBoundedPrompt applies the single + // global request cap while preserving the latest conversation tail. + conversation += `[Tool Result]\n${toolContent}\n\n`; } } - // The last user message + full conversation context - return { prompt: conversation.trim(), systemPrompt: systemPrompt.trim() }; + + // Diagnostic: log prompt breakdown + const toolCount = (tools || []).length; + console.log(`[PROMPT-DIAG] system=${rawSystemPrompt.length} chars, tools=${toolDefs.length} chars (${toolCount} defs), conversation=${conversation.length} chars, total=${rawSystemPrompt.length + toolDefs.length + conversation.length} chars`); + + // Return tool definitions separately so they survive compaction. + // The caller will inject toolDefs after buildBoundedPrompt truncates the system+conversation. + return { prompt: conversation.trim(), systemPrompt: rawSystemPrompt.trim(), toolDefs: toolDefs.trim() }; } -// Account management is allowed from the local machine only. function isLocal(req) { const ip = (req.socket && req.socket.remoteAddress) || ''; return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; } -// CSRF guard for account management: reject cross-site requests. A browser always -// sends Origin (and Referer) on a cross-origin request, so reject when the source -// host does not match the server host. Non-browser clients (curl/scripts) send -// neither and are not a CSRF vector, so they pass. +// CSRF guard for account management: reject cross-site requests. function isCrossOrigin(req) { const src = req.headers.origin || req.headers.referer; if (!src) return false; @@ -1239,17 +2095,53 @@ function isCrossOrigin(req) { // === HTTP Server === const server = http.createServer(async (req, res) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + const requestOrigin = req.headers.origin; + res.setHeader('Vary', 'Origin'); + if (!isBrowserOriginAllowed(requestOrigin)) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Browser origin is not allowed', type: 'cors_error' } })); + return; + } + if (requestOrigin) res.setHeader('Access-Control-Allow-Origin', normalizeOrigin(requestOrigin)); + setCorsResponseHeaders(res); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const isPublicProbe = req.method === 'GET' && (url.pathname === '/' || url.pathname === '/health' || url.pathname === '/readyz'); + if (!isPublicProbe && !isProxyAuthorized(req.headers.authorization)) { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'WWW-Authenticate': 'Bearer', + }); + res.end(JSON.stringify({ error: { message: 'Invalid or missing proxy API key', type: 'authentication_error' } })); + return; + } // Health check if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/health')) { + const includePrivateStatus = !PROXY_API_KEY || isProxyAuthorized(req.headers.authorization); + const health = { status: 'ok', service: 'FreeDeepseekAPI', watermark: FORGETMEAI_WATERMARK }; + if (includePrivateStatus) Object.assign(health, { + models: SUPPORTED_MODEL_IDS, + unsupported_models: Object.keys(MODEL_CONFIGS).filter(id => !MODEL_CONFIGS[id].supported), + agents: sessions.size, + in_flight: inFlight, + accounts: accounts.map(accountStatus), + config_ready: hasAuthConfig(), + session_reuse: { strategy: 'sticky per x-agent-session/user', ttl_minutes: Math.round(SESSION_TTL_MS / 60000), max_messages: MAX_MESSAGE_DEPTH, reset_all: 'POST /reset-session?agent=all' }, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok', service: 'FreeDeepseekAPI', watermark: FORGETMEAI_WATERMARK, models: SUPPORTED_MODEL_IDS, unsupported_models: Object.keys(MODEL_CONFIGS).filter(id => !MODEL_CONFIGS[id].supported), agents: sessions.size, accounts: accounts.map(accountStatus), config_ready: hasAuthConfig(), session_reuse: { strategy: 'sticky per x-agent-session/user', ttl_minutes: Math.round(SESSION_TTL_MS / 60000), max_messages: MAX_MESSAGE_DEPTH, reset_all: 'POST /reset-session?agent=all' } })); + res.end(JSON.stringify(health)); + return; + } + + // Readiness probe (distinct from the liveness check above): 503 unless at least + // one account can serve right now, so an aggregator/LB won't route to a cold pool. + if (req.method === 'GET' && url.pathname === '/readyz') { + const now = Date.now(); + const ready = accounts.filter(a => a.config.token && a.config.cookie && a.cooldownUntil <= now).length; + res.writeHead(ready > 0 ? 200 : 503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ready: ready > 0, ready_accounts: ready, total_accounts: accounts.length })); return; } @@ -1377,8 +2269,6 @@ const server = http.createServer(async (req, res) => { const account = accounts.find(a => a.id === id); if (!account) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Account not found' })); return; } const { status, email } = await checkAccountLive(account); - // On success, persist a freshly discovered email both in memory and (for - // managed files) on disk, so it survives the next pool reload. if (status === 'OK' && email) { account.config.email = email; if (isManagedFile(account.file)) { @@ -1403,7 +2293,6 @@ const server = http.createServer(async (req, res) => { try { const label = String((JSON.parse(body || '{}').label) || '').slice(0, 80); account.config.label = label; - // Persist for managed files so the label survives the next pool reload. if (isManagedFile(account.file)) { try { fs.writeFileSync(account.file, JSON.stringify(account.config, null, 2), { mode: 0o600 }); } catch (e) { console.error(`[account:${id}] could not persist label: ${e.message}`); } @@ -1432,14 +2321,35 @@ const server = http.createServer(async (req, res) => { res.writeHead(404); res.end('Not found'); return; } + // Backpressure: reject rather than fan out unbounded concurrent upstream work. + if (inFlight >= MAX_CONCURRENT) { + res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '2' }); + res.end(JSON.stringify({ error: { message: `Server busy (${inFlight}/${MAX_CONCURRENT} requests in flight). Retry shortly.`, type: 'overloaded' } })); + return; + } + let body = ''; - req.on('data', chunk => body += chunk); + let bodyTooLarge = false; + const MAX_BODY_BYTES = 10 * 1024 * 1024; // chat payloads are small; cap memory before JSON.parse + req.on('data', chunk => { body += chunk; if (body.length > MAX_BODY_BYTES) { bodyTooLarge = true; req.destroy(); } }); req.on('end', async () => { + if (bodyTooLarge) { + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Request body too large', type: 'payload_too_large' } })); + return; + } + inFlight++; + let clientGone = false; + res.on('close', () => { clientGone = true; }); + const requestStartedAt = Date.now(); + const deadlineHit = () => Date.now() - requestStartedAt > REQUEST_DEADLINE_MS; + let activeSession = null; + let activeAgentId = null; try { const rawParams = JSON.parse(body || '{}'); const params = normalizeApiParams(rawParams, apiMode); const messages = params.messages || []; - const tools = params.tools || []; + let tools = params.tools || []; const stream = params.stream === true; const requestedModel = String(params.model || 'deepseek-chat').toLowerCase(); if (!isKnownModel(requestedModel)) { @@ -1460,26 +2370,109 @@ const server = http.createServer(async (req, res) => { ? String(requestedSession) : ((remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1') ? 'dev-agent' : remoteAddr); const agentTag = `[${agentId}]`; - const { prompt, systemPrompt } = formatMessages(messages, tools); + activeAgentId = agentId; + + // Cache tools from this request, or use cached tools if client sent none. + // Clients like Hermes often only send tool definitions in the first request. + if (tools.length > 0) { + cacheAgentTools(agentId, tools); + } else { + const cached = getCachedAgentTools(agentId); + if (cached.length > 0) { + console.log(`${agentTag} Using ${cached.length} cached tool definitions (client sent none)`); + tools = cached; + } + } + + // "/new" command: if the latest user message is exactly "/new" (whitespace-insensitive), + // reset this agent's DeepSeek session/history instead of forwarding anything to DeepSeek. + const lastUserMessage = [...messages].reverse().find(m => m && m.role === 'user'); + const lastUserText = lastUserMessage && typeof lastUserMessage.content === 'string' + ? lastUserMessage.content.trim() + : ''; + if (lastUserText === '/new') { + const existing = sessions.get(agentId); + const historyCount = existing ? existing.history.length : 0; + sessions.set(agentId, createSession()); + console.log(`${agentTag} /new received — session reset (history cleared: ${historyCount})`); + const confirmation = buildTextResponse('Started a new chat. Session and history have been reset.', '/new', requestedModel); + if (stream) { + if (apiMode === 'anthropic') { + sendAnthropicStream(res, confirmation); + } else if (apiMode === 'responses') { + sendResponsesStream(res, confirmation); + } else { + sendOpenAIStream(res, confirmation); + } + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + if (apiMode === 'anthropic') { + res.end(JSON.stringify(toAnthropicResponse(confirmation))); + } else if (apiMode === 'responses') { + res.end(JSON.stringify(toResponsesResponse(confirmation))); + } else { + res.end(JSON.stringify(confirmation)); + } + } + return; + } + + const { prompt, systemPrompt, toolDefs } = formatMessages(messages, tools); + // For usage accounting, count the CLIENT's original input — not the + // proxy-expanded fullPrompt (system + injected tools + history) — so + // prompt_tokens reflects what the caller actually sent. + const clientPromptText = messages.map(m => normalizeMessageContent(m.content)).join('\n'); const session = getOrCreateAgentSession(agentId); + activeSession = session; - // Build history prefix if starting fresh - let historyPrefix = ''; - if (!session.id && session.history.length > 0) { - historyPrefix = '[Previous conversation]\n'; - for (const exchange of session.history) { - historyPrefix += `User: ${exchange.user}\nAssistant: ${exchange.assistant}\n\n`; + // Roll over TTL/depth-limited sessions before deciding whether to + // inject local recovery history into the newly built prompt. + const promptRollover = prepareSessionForPrompt(session); + if (promptRollover) { + console.log(`${agentTag} Session ${promptRollover.failedSessionId} reset before prompt build (${promptRollover.reason}); recovery history preserved.`); + } + + // Keep a recovery prompt available even while the upstream session + // is healthy. If that remote chat expires mid-request, its opaque + // state disappears and the replacement must receive local history. + const recoveryHistoryPrefix = hasExplicitConversationHistory(messages) + ? '' + : buildRecoveryHistoryPrefix(session.history); + const historyPrefix = !session.id ? recoveryHistoryPrefix : ''; + + // Build prompt WITHOUT tool definitions — tools are injected AFTER + // compaction so they are never truncated or lost. + const promptBuild = buildBoundedPrompt(systemPrompt, historyPrefix, prompt); + const freshPromptBuild = buildBoundedPrompt(systemPrompt, recoveryHistoryPrefix, prompt); + let fullPrompt = promptBuild.prompt; + let promptCompacted = promptBuild.compacted; + + // Inject tool definitions AFTER compaction — they always survive. + // If tools were part of the system prompt, compaction would truncate them. + if (toolDefs) { + fullPrompt = fullPrompt + '\n' + toolDefs; + if (freshPromptBuild.prompt) { + freshPromptBuild.prompt = freshPromptBuild.prompt + '\n' + toolDefs; } - historyPrefix += '[Continue from here]\n\n'; } - const fullPrompt = systemPrompt - ? `${systemPrompt}\n\n${historyPrefix}${prompt}` - : `${historyPrefix}${prompt}`; + if (promptBuild.compacted) { + markContextCompacted(res); + console.log(`${agentTag} Compacted upstream prompt ${promptBuild.originalChars} -> ${promptBuild.promptChars} chars${promptBuild.historyDropped ? ' (recovery history dropped)' : ''}`); + console.log(`${agentTag} [PROMPT-DIAG] tools_injected=true, tool_defs_size=${toolDefs.length} chars (preserved after compaction)`); + } const startTime = Date.now(); - const { resp: dsResp } = await askDeepSeekStream(fullPrompt, agentId, requestedModel); + const initialCall = await askDeepSeekStream(fullPrompt, agentId, requestedModel, freshPromptBuild.prompt); + const dsResp = initialCall.resp; + if (initialCall.promptUsed !== fullPrompt) { + fullPrompt = initialCall.promptUsed; + if (freshPromptBuild.compacted) { + promptCompacted = true; + markContextCompacted(res); + } + } // Process streaming response from DeepSeek — returns { content, reasoningContent, messageId, finishReason } async function readDeepSeekResponse(readable) { @@ -1492,15 +2485,8 @@ const server = http.createServer(async (req, res) => { let finishReason = null; let modelError = null; - const rebuildFragmentText = () => { - const responseText = fragments - .filter(f => f && f.type === 'RESPONSE' && typeof f.content === 'string') - .map(f => f.content) - .join(''); - const thinkText = fragments - .filter(f => f && (f.type === 'THINK' || f.type === 'REASONING') && typeof f.content === 'string') - .map(f => f.content) - .join(''); + const rebuildFragmentState = () => { + const { responseText, thinkText } = rebuildFragmentText(fragments); if (responseText) fullContent = responseText; reasoningContent = thinkText; }; @@ -1510,11 +2496,12 @@ const server = http.createServer(async (req, res) => { for (const fragment of incoming) { if (fragment && typeof fragment === 'object') fragments.push({ ...fragment }); } - rebuildFragmentText(); + rebuildFragmentState(); }; + const decoder = new TextDecoder(); // one instance: preserves multi-byte (Cyrillic/emoji) split across chunks for await (const chunk of readable) { - buffer += new TextDecoder().decode(chunk, { stream: true }); + buffer += decoder.decode(chunk, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { @@ -1522,9 +2509,11 @@ const server = http.createServer(async (req, res) => { try { const d = JSON.parse(line.slice(6)); if (d.response_message_id !== undefined && !newMessageId) newMessageId = d.response_message_id; - if (d.type === 'error' || d.finish_reason || d.content) { + if (isDeepSeekModelErrorEvent(d)) { modelError = { type: d.type || 'error', content: d.content || '', finish_reason: d.finish_reason || null }; - if (d.finish_reason) finishReason = d.finish_reason; + } + if (d.finish_reason) { + finishReason = d.finish_reason; } if (d.p !== undefined) lastPath = d.p; if (d.v && typeof d.v === 'object' && d.v.response) { @@ -1545,11 +2534,14 @@ const server = http.createServer(async (req, res) => { if (lastPath === 'response/fragments' && d.v !== undefined) { appendFragments(d.v); } + if (lastPath === 'response' && d.v !== undefined) { + applyResponsePatchOperations(d.v, appendFragments); + } if (lastPath === 'response/fragments/-1/content' && d.v !== undefined && typeof d.v !== 'object') { if (fragments.length > 0) { const lastFragment = fragments[fragments.length - 1]; lastFragment.content = `${lastFragment.content || ''}${d.v}`; - rebuildFragmentText(); + rebuildFragmentState(); } } if (lastPath === 'response/content' && d.v !== undefined && typeof d.v !== 'object') { @@ -1582,65 +2574,107 @@ const server = http.createServer(async (req, res) => { const elapsed = Date.now() - startTime; console.log(`${agentTag} Got ${fullContent.length} chars (+${reasoningContent.length} reasoning chars) in ${elapsed}ms (msg#${session.messageCount})`); - if ((!fullContent || fullContent.trim().length === 0) && modelError) { - res.writeHead(502, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: modelError.content || 'DeepSeek returned an error without content', type: modelError.finish_reason || modelError.type || 'deepseek_model_error', model: requestedModel, real_model: resolveModelConfig(requestedModel).real_model } })); - return; - } - - // Empty response — retry loop with fresh sessions + // Empty/context-overflow recovery. Each retry gets a smaller prompt + // and a fresh remote session; bounded attempts prevent retry storms. let retryAttempt = 0; - const MAX_RETRIES = 10; while (!fullContent || fullContent.trim().length === 0) { + // Stop early if the client hung up or we've blown the request budget — + // no point burning more PoW solves + account quota for a dead socket. + if (clientGone) { console.log(`${agentTag} client disconnected; abandoning empty-retry loop`); return; } + if (deadlineHit()) { console.log(`${agentTag} request deadline hit; stopping empty-retry loop`); break; } + const contextTooLong = isContextTooLongError(modelError); + if (modelError && !contextTooLong) break; + if (retryAttempt >= MAX_EMPTY_RETRIES) break; retryAttempt++; - if (retryAttempt > MAX_RETRIES) { - console.log(`${agentTag} Empty after ${MAX_RETRIES} retries. Giving up.`); - res.writeHead(502, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: `DeepSeek returned empty content after ${MAX_RETRIES} retries`, - type: 'empty_response', - agent: agentId, - session_id: session.id, - message_count: session.messageCount, - history_length: session.history.length, - retry_attempts: retryAttempt - 1, - } - })); - return; + + const retryRatio = contextTooLong + ? Math.max(0.35, 0.8 - retryAttempt * 0.2) + : Math.max(0.5, 1 - retryAttempt * 0.2); + const retryBudget = Math.max(MIN_UPSTREAM_PROMPT_CHARS, Math.floor(MAX_UPSTREAM_PROMPT_CHARS * retryRatio)); + const retryBuild = buildRetryPrompt(systemPrompt, recoveryHistoryPrefix, prompt, fullPrompt, retryBudget); + const retryPrompt = retryBuild.prompt; + if (retryBuild.compacted) { + promptCompacted = true; + markContextCompacted(res); } - console.log(`${agentTag} Empty response (msg#${session.messageCount}, retry ${retryAttempt}/${MAX_RETRIES}). Resetting session...`); - session.id = null; - session.parentMessageId = null; - session.createdAt = null; - session.messageCount = 0; + const reason = contextTooLong ? 'context-too-long response' : 'empty response'; + console.log(`${agentTag} ${reason} (msg#${session.messageCount}, retry ${retryAttempt}/${MAX_EMPTY_RETRIES}, prompt=${retryPrompt.length} chars). Resetting session...`); + resetRemoteSession(session); // Brief delay before retry to let DeepSeek breathe - await new Promise(r => setTimeout(r, Math.min(1000 * retryAttempt, 5000))); - const { resp: retryResp } = await askDeepSeekStream(fullPrompt, agentId, requestedModel); + await new Promise(r => setTimeout(r, Math.min(500 * retryAttempt, 1500))); + const { resp: retryResp } = await askDeepSeekStream(retryPrompt, agentId, requestedModel); const retryResult = await readDeepSeekResponse(retryResp.body); - const retryContent = retryResult && retryResult.content ? sanitizeContent(retryResult.content) : ''; - const retryReasoning = retryResult && retryResult.reasoningContent ? sanitizeContent(retryResult.reasoningContent) : ''; - if (retryContent && retryContent.trim().length > 0) { + const retryState = normalizeRetryResponse(retryResult); + fullPrompt = retryPrompt; + modelError = retryState.modelError; + // A previous empty response may have carried finish_reason=length. + // Never leak it into a successful retry that supplied no reason. + finishReason = retryState.finishReason; + if (retryState.content && retryState.content.trim().length > 0) { console.log(`${agentTag} Retry ${retryAttempt} succeeded`); - fullContent = retryContent; - reasoningContent = retryReasoning; + fullContent = retryState.content; + reasoningContent = retryState.reasoningContent; } } + if (!fullContent || fullContent.trim().length === 0) { + const timedOut = deadlineHit(); + const failureClass = classifyRecoveryFailure(modelError, timedOut); + const failure = resetRemoteSession(session); + const errorType = failureClass.type; + const errorMessage = modelError?.content + || (timedOut + ? 'DeepSeek request deadline reached while recovering an empty response' + : `DeepSeek returned empty content after ${retryAttempt} retr${retryAttempt === 1 ? 'y' : 'ies'}`); + console.log(`${agentTag} ${errorType} after ${retryAttempt} retr${retryAttempt === 1 ? 'y' : 'ies'}. Giving up.`); + res.writeHead(failureClass.status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: errorMessage, + type: errorType, + agent: agentId, + failed_session_id: failure.failedSessionId, + message_count: failure.failedMessageCount, + history_length: session.history.length, + account: failure.accountId, + retry_attempts: retryAttempt, + upstream_prompt_chars: fullPrompt.length, + prompt_compacted: promptCompacted, + model: requestedModel, + real_model: resolveModelConfig(requestedModel).real_model, + } + })); + return; + } + // Auto-continuation: if finish_reason is 'length' or content is very long (>25000 chars), // send a continuation request to get the rest of the response let continuationRounds = 0; const MAX_CONTINUATION = 2; while ((finishReason === 'length' || fullContent.length > 25000) && continuationRounds < MAX_CONTINUATION) { + if (clientGone || deadlineHit()) break; continuationRounds++; console.log(`${agentTag} Response ${fullContent.length} chars (finish=${finishReason}). Auto-continuing (${continuationRounds}/${MAX_CONTINUATION})...`); await new Promise(r => setTimeout(r, 500)); const contBeforeId = session.accountId; - const { resp: contResp, account: contAccount } = await askDeepSeekStream('continue', agentId, requestedModel); - // If rotation moved us to a different account, its chat_session has no - // prior context — "continue" would return irrelevant text. Abort (#20). - if (contAccount && contBeforeId && contAccount.id !== contBeforeId) { + const continuationRecoveryPrompt = appendPromptInstruction( + `${freshPromptBuild.prompt}\n\n[Assistant response so far]\n${fullContent}`, + 'Continue the assistant response from exactly where it stopped. Do not restart or repeat completed sections.' + ); + const continuationCall = await askDeepSeekStream( + 'continue', + agentId, + requestedModel, + continuationRecoveryPrompt + ); + const { resp: contResp, account: contAccount } = continuationCall; + // A cross-account continuation is valid only when the call + // detected that reset and sent the full recovery prompt. If an + // unexpected rotation ever bypasses that guard, discard the new + // remote session before returning to the client (#20). + if (!isContinuationRecoverySafe(contBeforeId, continuationCall)) { console.log(`${agentTag} continuation rotated to ${contAccount.id} ≠ ${contBeforeId} — skipping (foreign session)`); + resetRemoteSession(session); break; } const contResult = await readDeepSeekResponse(contResp.body); @@ -1657,33 +2691,58 @@ const server = http.createServer(async (req, res) => { } } - let toolCall = parseToolCall(fullContent); + const allowedToolNames = new Set(tools + .filter(tool => tool?.type === 'function' && tool.function?.name) + .map(tool => tool.function.name)); + let toolCall = allowedToolNames.size > 0 ? parseToolCall(fullContent) : null; + if (toolCall && !allowedToolNames.has(toolCall.name)) { + console.log(`${agentTag} Model requested unknown tool ${toolCall.name}; passing through to client.`); + } - // Retry if TOOL_CALL was found but JSON was truncated/invalid - if (!toolCall && /TOOL_CALL:\s*\w/i.test(fullContent)) { - console.log(`${agentTag} TOOL_CALL detected but JSON invalid/truncated (${fullContent.length} chars). Retrying with stricter prompt...`); - session.id = null; - session.parentMessageId = null; - session.createdAt = null; - session.messageCount = 0; + // Retry once if legacy, XML, or DSML tool markup was truncated or + // malformed. Never pass raw DSML through as a normal assistant turn. + if (allowedToolNames.size > 0 && !toolCall && looksLikeToolCallMarkup(fullContent) && !clientGone && !deadlineHit()) { + console.log(`${agentTag} Tool-call markup detected but invalid/truncated (${fullContent.length} chars). Retrying with stricter prompt...`); + resetRemoteSession(session); await new Promise(r => setTimeout(r, 1000)); - const strictPrompt = fullPrompt + '\n\n[STRICT INSTRUCTION] Your previous response had a TOOL_CALL but the arguments were too long and got cut off. Keep the arguments SHORT — no large file contents. Just use a minimal example or reference the file by name. Output ONLY: TOOL_CALL: \narguments: '; + const strictPrompt = appendPromptInstruction( + freshPromptBuild.prompt, + '[STRICT INSTRUCTION] Your previous response contained incomplete tool-call markup. Keep arguments short and output ONLY strict JSON: {"tool_call":{"name":"","arguments":{...}}}' + ); const { resp: retryResp2 } = await askDeepSeekStream(strictPrompt, agentId, requestedModel); const retryResult2 = await readDeepSeekResponse(retryResp2.body); const retryContent2 = retryResult2 && retryResult2.content ? sanitizeContent(retryResult2.content) : ''; if (retryContent2 && retryContent2.trim()) { const retryTc = parseToolCall(retryContent2); - if (retryTc) { + if (retryTc && allowedToolNames.has(retryTc.name)) { console.log(`${agentTag} Retry with strict prompt succeeded: ${retryTc.name}`); fullContent = retryContent2; reasoningContent = retryResult2.reasoningContent ? sanitizeContent(retryResult2.reasoningContent) : ''; toolCall = retryTc; } else { - console.log(`${agentTag} Retry still has broken JSON. Sending as text.`); + console.log(`${agentTag} Retry still has broken tool markup. Returning a safe error instead of leaking it as text.`); reasoningContent = retryResult2.reasoningContent ? sanitizeContent(retryResult2.reasoningContent) : reasoningContent; } } } + + if (allowedToolNames.size > 0 && !toolCall && looksLikeToolCallMarkup(fullContent)) { + const failure = resetRemoteSession(session); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { + message: 'DeepSeek returned malformed tool-call markup after one repair attempt', + type: 'malformed_tool_call', + agent: agentId, + failed_session_id: failure.failedSessionId, + message_count: failure.failedMessageCount, + history_length: session.history.length, + account: failure.accountId, + prompt_compacted: promptCompacted, + model: requestedModel, + real_model: resolveModelConfig(requestedModel).real_model, + } })); + return; + } // Check if any tool results in the current conversation contained a screenshot path. // If so, and the response doesn't already have MEDIA:, inject it so the gateway @@ -1697,10 +2756,12 @@ const server = http.createServer(async (req, res) => { } storeHistory(agentId, prompt, fullContent, toolCall); + // Persist session to disk (async, non-blocking) + setImmediate(saveSessions); const openaiResponse = toolCall - ? buildToolCallResponse(toolCall, requestedModel, fullPrompt, reasoningContent) - : buildTextResponse(fullContent, fullPrompt, requestedModel, reasoningContent); + ? buildToolCallResponse(toolCall, requestedModel, clientPromptText, reasoningContent) + : buildTextResponse(fullContent, clientPromptText, requestedModel, reasoningContent, finishReason); if (stream) { if (apiMode === 'anthropic') { @@ -1724,8 +2785,28 @@ const server = http.createServer(async (req, res) => { } } catch (e) { console.log('[DS-API] Error:', e.message); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: e.message, type: 'server_error' } })); + if (res.headersSent || clientGone) return; // streamed/aborted: nothing to send + // Pool exhaustion / no-auth carry an explicit status so integrators see + // 429/503 (not a generic 500) and can honor Retry-After. + const timedOut = isTimeoutError(e); + const status = e.status || (timedOut ? 504 : 500); + const headers = { 'Content-Type': 'application/json' }; + if (status === 429 && e.retryAfter) headers['Retry-After'] = String(e.retryAfter); + res.writeHead(status, headers); + const failure = timedOut && activeSession ? resetRemoteSession(activeSession) : null; + res.end(JSON.stringify({ error: { + message: e.message, + type: e.type || (timedOut ? 'request_timeout' : 'server_error'), + ...(failure ? { + agent: activeAgentId, + failed_session_id: failure.failedSessionId, + message_count: failure.failedMessageCount, + history_length: activeSession.history.length, + account: failure.accountId, + } : {}), + } })); + } finally { + inFlight--; } }); }); @@ -1739,11 +2820,11 @@ async function runAuthScript() { function printStatus() { console.log(`\n${formatWatermark()}`); - console.log(`Auth: ${hasAuthConfig() ? '✅ OK' : '❌ не найден deepseek-auth.json'}`); + console.log(`Auth: ${hasAuthConfig() ? '✅ OK' : '❌ deepseek-auth.json not found'}`); console.log(`Auth source: ${process.env.DEEPSEEK_AUTH_DIR || DS_CONFIG_PATH}`); - console.log(`Аккаунты: ${accounts.length ? accounts.map(a => `${a.id}${a.cooldownUntil > Date.now() ? ' (cooldown)' : ''}`).join(', ') : 'нет'}`); - console.log(`Рабочие модели: ${SUPPORTED_MODEL_IDS.join(', ')}`); - console.log('Нерабочие/скрытые aliases: ' + Object.keys(MODEL_CONFIGS).filter(id => !MODEL_CONFIGS[id].supported).join(', ')); + console.log(`Accounts: ${accounts.length ? accounts.map(a => `${a.id}${a.cooldownUntil > Date.now() ? ' (cooldown)' : ''}`).join(', ') : 'none'}`); + console.log(`Working models: ${SUPPORTED_MODEL_IDS.join(', ')}`); + console.log('Unsupported/hidden aliases: ' + Object.keys(MODEL_CONFIGS).filter(id => !MODEL_CONFIGS[id].supported).join(', ')); console.log('Capabilities: GET /v1/model-capabilities'); } @@ -1754,14 +2835,14 @@ async function showStartupMenu() { } while (true) { printStatus(); - console.log('\n=== Меню ==='); + console.log('\n=== Menu ==='); console.log(`ForgetMeAI: ${FORGETMEAI_WATERMARK}`); - console.log('1 - Авторизоваться / обновить DeepSeek login'); - console.log('2 - Импортировать auth-файл / cookies'); - console.log('3 - Показать модели и статусы'); - console.log('4 - Запустить прокси (по умолчанию)'); - console.log('5 - Выход'); - let choice = await prompt('Ваш выбор (Enter = 4): '); + console.log('1 - Authorize / update DeepSeek login'); + console.log('2 - Import auth file / cookies'); + console.log('3 - Show models and statuses'); + console.log('4 - Start proxy (default)'); + console.log('5 - Exit'); + let choice = await prompt('Your choice (Enter = 4): '); if (!choice) choice = '4'; if (choice === '1') { await runAuthScript(); @@ -1770,10 +2851,10 @@ async function showStartupMenu() { loadDeepSeekConfig({ fatal: false }); } else if (choice === '3') { console.log(JSON.stringify(ALL_MODEL_CAPABILITIES, null, 2)); - await prompt('\nНажмите Enter, чтобы вернуться в меню...'); + await prompt('\nPress Enter to return to menu...'); } else if (choice === '4') { if (!hasAuthConfig()) { - console.log('Нужен deepseek-auth.json. Запустите пункт 1 или 2.'); + console.log('deepseek-auth.json is required. Run option 1 or 2.'); continue; } return true; @@ -1785,8 +2866,23 @@ async function showStartupMenu() { async function main() { printBanner(); + requireProxyApiKey(PROXY_API_KEY, isTruthy(process.env.REQUIRE_PROXY_API_KEY)); + if (!isLoopbackHost(HOST) && !PROXY_API_KEY) { + console.warn(`[DS-API] WARNING: HOST=${HOST} exposes the proxy without authentication. Set PROXY_API_KEY or bind to 127.0.0.1.`); + } const shouldStart = await showStartupMenu(); if (!shouldStart) process.exit(0); + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') console.error(`[DS-API] FATAL: port ${PORT} already in use. Set PORT= or stop the other instance.`); + else console.error('[DS-API] server error:', err); + process.exit(1); + }); + // Load persisted sessions from disk + loadSessions(); + // Periodically evict idle sessions (unref'd so it never keeps the process alive). + setInterval(sweepIdleSessions, 10 * 60 * 1000).unref(); + // Save sessions periodically (every 5 minutes) + setInterval(saveSessions, 5 * 60 * 1000).unref(); server.listen(PORT, HOST, () => { console.log(`[DS-API] Server on http://${HOST}:${PORT} (multi-agent sessions enabled)`); console.log(`[DS-API] ${formatWatermark()}`); @@ -1801,4 +2897,63 @@ async function main() { }); } -main().catch(err => { console.error('[DS-API] FATAL:', err); process.exit(1); }); +if (require.main === module) { + // Don't let a stray rejection/throw take the whole proxy down silently. + process.on('unhandledRejection', (reason) => console.error('[DS-API] unhandledRejection:', reason)); + process.on('uncaughtException', (err) => console.error('[DS-API] uncaughtException:', err)); + // Graceful shutdown: stop accepting, drain, then exit (force-exit after 10s). + const shutdown = (sig) => { + console.log(`[DS-API] ${sig} received — shutting down…`); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 10000).unref(); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + main().catch(err => { console.error('[DS-API] FATAL:', err); process.exit(1); }); +} + +module.exports = { + __test: { + isAssistantOutputFragment, + isReasoningFragment, + isDeepSeekModelErrorEvent, + createUpstreamHttpError, + rebuildFragmentText, + applyResponsePatchOperations, + compactToolSchema, + formatToolDefinitions, + parseToolCall, + parseDsmlToolCall, + looksLikeToolCallMarkup, + truncatePromptMiddle, + hasExplicitConversationHistory, + buildRecoveryHistoryPrefix, + buildBoundedPrompt, + buildRetryPrompt, + isContinuationRecoverySafe, + isContextTooLongError, + normalizeRetryResponse, + classifyRecoveryFailure, + isTimeoutError, + formatMessages, + createSession, + resetRemoteSession, + prepareSessionForPrompt, + sweepIdleSessions, + sessions, + accounts, + selectAccountForSession, + isProxyAuthorized, + loadProxyApiKey, + requireProxyApiKey, + isLoopbackHost, + normalizeOrigin, + isBrowserOriginAllowed, + setCorsResponseHeaders, + markContextCompacted, + CONTEXT_COMPACTED_HEADER, + sendAnthropicStream, + sendResponsesStream, + sendOpenAIStream, + }, +}; diff --git a/tests/unit.test.js b/tests/unit.test.js index 31a9656..4805aff 100644 --- a/tests/unit.test.js +++ b/tests/unit.test.js @@ -6,6 +6,7 @@ const path = require('node:path'); const { spawnSync } = require('node:child_process'); const ROOT = path.resolve(__dirname, '..'); +const serverInternals = require('../server.js').__test; function tmpdir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'fdsapi-test-')); @@ -109,3 +110,626 @@ test('chrome auth prints actionable OS instructions when Chrome is missing', () assert.match(out, /Linux/i); assert.match(out, /CHROME_PATH/i); }); + +test('chrome extension manifest only declares icon files that exist', () => { + const manifestPath = path.join(ROOT, 'chrome-extension', 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const iconPaths = []; + + function collectIconPaths(value, key = '') { + if (typeof value === 'string') { + if (key === 'icons' || key === 'default_icon') iconPaths.push(value); + return; + } + + if (!value || typeof value !== 'object') return; + + if ((key === 'icons' || key === 'default_icon') && !Array.isArray(value)) { + for (const iconPath of Object.values(value)) { + if (typeof iconPath === 'string') iconPaths.push(iconPath); + } + return; + } + + for (const [childKey, childValue] of Object.entries(value)) { + collectIconPaths(childValue, childKey); + } + } + + collectIconPaths(manifest); + + for (const iconPath of iconPaths) { + assert.equal( + fs.existsSync(path.join(path.dirname(manifestPath), iconPath)), + true, + `Missing extension icon declared in manifest: ${iconPath}`, + ); + } +}); + +test('DeepSeek stream parser treats SEARCH fragments as assistant output', () => { + const rebuilt = serverInternals.rebuildFragmentText([ + { type: 'SEARCH', content: 'The official Reuters website is ' }, + { type: 'SEARCH', content: 'https://www.reuters.com/.' }, + ]); + + assert.equal(rebuilt.responseText, 'The official Reuters website is https://www.reuters.com/.'); + assert.equal(rebuilt.thinkText, ''); +}); + +test('DeepSeek stream parser applies response-level fragment append patches', () => { + const fragments = []; + const appendFragments = (value) => { + const incoming = Array.isArray(value) ? value : [value]; + for (const fragment of incoming) fragments.push({ ...fragment }); + }; + + const applied = serverInternals.applyResponsePatchOperations([ + { p: 'fragments', o: 'APPEND', v: [{ type: 'RESPONSE', content: 'The' }] }, + { p: 'has_pending_fragment', o: 'SET', v: false }, + ], appendFragments); + + assert.equal(applied, true); + assert.deepEqual(fragments, [{ type: 'RESPONSE', content: 'The' }]); + assert.equal(serverInternals.rebuildFragmentText(fragments).responseText, 'The'); +}); + +test('DeepSeek stream parser does not treat service content chunks as model errors', () => { + assert.equal(serverInternals.isDeepSeekModelErrorEvent({ content: 'Official Reuters website URL' }), false); + assert.equal(serverInternals.isDeepSeekModelErrorEvent({ finish_reason: 'stop' }), false); + assert.equal(serverInternals.isDeepSeekModelErrorEvent({ type: 'error', content: 'backend error' }), true); +}); + +test('consumed upstream HTTP errors retain status, type, and retry hints', () => { + const limited = serverInternals.createUpstreamHttpError(429, ' Rate limited\ntry later ', '12'); + assert.equal(limited.status, 429); + assert.equal(limited.type, 'rate_limit_error'); + assert.equal(limited.retryAfter, '12'); + assert.match(limited.message, /Rate limited try later/); + + const unauthorized = serverInternals.createUpstreamHttpError(401, 'expired'); + assert.equal(unauthorized.status, 401); + assert.equal(unauthorized.type, 'authentication_error'); +}); + +test('sweepIdleSessions evicts only idle entries', () => { + serverInternals.sessions.set('stale-x', { lastActivityAt: 1 }); + serverInternals.sessions.set('fresh-x', { lastActivityAt: Date.now() }); + serverInternals.sweepIdleSessions(60 * 1000); + assert.equal(serverInternals.sessions.has('stale-x'), false); + assert.equal(serverInternals.sessions.has('fresh-x'), true); + serverInternals.sessions.delete('fresh-x'); +}); + +test('proxy API key authentication is optional and uses exact bearer tokens', () => { + assert.equal(serverInternals.isProxyAuthorized(undefined, ''), true); + assert.equal(serverInternals.isProxyAuthorized('Bearer secret', 'secret'), true); + assert.equal(serverInternals.isProxyAuthorized('Bearer wrong', 'secret'), false); + assert.equal(serverInternals.isProxyAuthorized('Basic secret', 'secret'), false); + assert.equal(serverInternals.isProxyAuthorized('Bearer secret ', 'secret'), false); +}); + +test('proxy API key can be loaded from a mounted secret and required explicitly', () => { + const dir = tmpdir(); + const secretPath = path.join(dir, 'proxy-api-key'); + fs.writeFileSync(secretPath, 'mounted-secret\n'); + + assert.equal(serverInternals.loadProxyApiKey({ PROXY_API_KEY_FILE: secretPath }), 'mounted-secret'); + assert.equal(serverInternals.loadProxyApiKey({ PROXY_API_KEY: 'env-secret', PROXY_API_KEY_FILE: secretPath }), 'env-secret'); + assert.equal(serverInternals.loadProxyApiKey({ PROXY_API_KEY_FILE: path.join(dir, 'missing') }), ''); + assert.doesNotThrow(() => serverInternals.requireProxyApiKey('mounted-secret', true)); + assert.throws( + () => serverInternals.requireProxyApiKey('', true), + /PROXY_API_KEY is required/, + ); +}); + +test('Containerfile keeps the rootless Podman runtime minimal and fail-closed', () => { + const containerfile = fs.readFileSync(path.join(ROOT, 'Containerfile'), 'utf8'); + const containerignore = fs.readFileSync(path.join(ROOT, '.containerignore'), 'utf8'); + const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8'); + const copyLines = containerfile + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.startsWith('COPY ')); + + assert.deepEqual(copyLines, [ + 'COPY --chown=1000:1000 package.json server.js ./', + 'COPY --chown=1000:1000 lib/pow.js ./lib/pow.js', + ]); + assert.doesNotMatch(containerfile, /^\s*(?:COPY|ADD)\s+\.\s/m); + assert.match(containerfile, /^USER 1000:1000$/m); + assert.match(containerfile, /HOST=0\.0\.0\.0/); + assert.match(containerfile, /NON_INTERACTIVE=1/); + assert.match(containerfile, /REQUIRE_PROXY_API_KEY=1/); + assert.match(containerfile, /PROXY_API_KEY_FILE=\/run\/secrets\/proxy-api-key/); + assert.match(containerfile, /^HEALTHCHECK /m); + assert.match(containerfile, /path:'\/health'/); + assert.match(containerfile, /^CMD \["node", "server\.js"\]$/m); + + assert.match(containerignore, /^\*$/m); + assert.doesNotMatch(containerignore, /^!.*(?:auth|secret|\.env)/mi); + assert.match(readme, /--publish 127\.0\.0\.1:9655:9655/); + assert.match(readme, /--secret free-deepseek-auth[^\n]*mode=0400/); + assert.match(readme, /--secret free-deepseek-proxy-key[^\n]*mode=0400/); + assert.match(readme, /--read-only/); + assert.match(readme, /--cap-drop=ALL/); + assert.match(readme, /--security-opt=no-new-privileges/); +}); + +test('loopback host detection covers supported local bind addresses', () => { + assert.equal(serverInternals.isLoopbackHost('127.0.0.1'), true); + assert.equal(serverInternals.isLoopbackHost('::1'), true); + assert.equal(serverInternals.isLoopbackHost('[::1]'), true); + assert.equal(serverInternals.isLoopbackHost('::ffff:127.0.0.1'), true); + assert.equal(serverInternals.isLoopbackHost('localhost'), true); + assert.equal(serverInternals.isLoopbackHost('0.0.0.0'), false); +}); + +test('browser origin guard allows local UIs and exact configured origins only', () => { + const allowed = new Set(['https://ui.example.com', 'chrome-extension://trusted-id']); + assert.equal(serverInternals.isBrowserOriginAllowed(undefined, allowed), true); + assert.equal(serverInternals.isBrowserOriginAllowed('http://localhost:3000', allowed), true); + assert.equal(serverInternals.isBrowserOriginAllowed('http://127.0.0.1:8080', allowed), true); + assert.equal(serverInternals.isBrowserOriginAllowed('http://[::1]:3000', allowed), true); + assert.equal(serverInternals.isBrowserOriginAllowed('https://ui.example.com/path', allowed), true); + assert.equal(serverInternals.isBrowserOriginAllowed('chrome-extension://trusted-id', allowed), true); + assert.equal(serverInternals.isBrowserOriginAllowed('https://evil.example', allowed), false); + assert.equal(serverInternals.isBrowserOriginAllowed('chrome-extension://other-id', allowed), false); + assert.equal(serverInternals.isBrowserOriginAllowed('null', allowed), false); +}); + +test('parseToolCall converts canonical DeepSeek DSML into an OpenAI tool call', () => { + const dsml = [ + 'I will inspect it.', + '<|DSML|tool_calls>', + '<|DSML|invoke name="execute_code">', + '<|DSML|parameter name="code" string="true">print("ok")', + '<|DSML|parameter name="timeout" string="false">30', + '<|DSML|parameter name="capture" string="false">true', + '', + '', + ].join('\n'); + + const call = serverInternals.parseToolCall(dsml); + assert.equal(call.name, 'execute_code'); + assert.deepEqual(JSON.parse(call.arguments), { + code: 'print("ok")', + timeout: 30, + capture: true, + }); +}); + +test('parseToolCall accepts the doubled-bar DSML Web variant from issue #19', () => { + const dsml = [ + '<||DSML|| Tool Calls>', + '<||DSML|| name="web_search">{"query":"DeepSeek DSML"}', + '', + ].join('\n'); + + const call = serverInternals.parseToolCall(dsml); + assert.equal(call.name, 'web_search'); + assert.deepEqual(JSON.parse(call.arguments), { query: 'DeepSeek DSML' }); +}); + +test('parseToolCall accepts zero-argument, CDATA, legacy, collapsed, and prefixed wrappers', () => { + const zeroArg = serverInternals.parseToolCall( + '<|DSML|tool_calls><|DSML|invoke name="ping">' + ); + assert.deepEqual(zeroArg, { name: 'ping', arguments: '{}' }); + + const cdata = serverInternals.parseToolCall( + '\n\n\n]]>' + ); + assert.deepEqual(JSON.parse(cdata.arguments), { content: 'line 1\n\n\n\n' }); + + const collapsed = serverInternals.parseToolCall( + '/tmp/a' + ); + assert.deepEqual(JSON.parse(collapsed.arguments), { path: '/tmp/a' }); + + const prefixed = serverInternals.parseToolCall( + '/tmp/b' + ); + assert.deepEqual(JSON.parse(prefixed.arguments), { path: '/tmp/b' }); +}); + +test('parseToolCall normalizes fullwidth delimiters and narrowly repairs a missing opening wrapper', () => { + const fullwidth = serverInternals.parseToolCall( + '<|DSML|Tool Calls><|DSML|Invoke name=“read_file”><|DSML|Parameter name=“path”>/tmp/c</|DSML|Parameter></|DSML|Invoke></|DSML|Tool Calls>' + ); + assert.deepEqual(JSON.parse(fullwidth.arguments), { path: '/tmp/c' }); + + const repaired = serverInternals.parseToolCall( + '/tmp/d' + ); + assert.deepEqual(JSON.parse(repaired.arguments), { path: '/tmp/d' }); +}); + +test('parseToolCall rejects bare invokes and bare JSON examples', () => { + const bareInvoke = '<|DSML|invoke name="execute_code"><|DSML|parameter name="code">danger()'; + assert.equal(serverInternals.parseToolCall(bareInvoke), null); + assert.equal(serverInternals.looksLikeToolCallMarkup(bareInvoke), true); + + const prose = 'For example return {"name":"execute_code","arguments":{"code":"danger()"}} when appropriate.'; + assert.equal(serverInternals.parseToolCall(prose), null); + assert.equal(serverInternals.parseToolCall('```json\n{"name":"execute_code","arguments":{"code":"danger()"}}\n```'), null); +}); + +test('parseToolCall accepts only explicit JSON envelopes with valid object arguments', () => { + const explicit = serverInternals.parseToolCall( + 'Use this: {"tool_call":{"name":"read_file","arguments":{"path":"/tmp/a"}}}' + ); + assert.deepEqual(JSON.parse(explicit.arguments), { path: '/tmp/a' }); + + const openai = serverInternals.parseToolCall(JSON.stringify({ + tool_calls: [{ + type: 'function', + function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/b' }) }, + }], + })); + assert.deepEqual(JSON.parse(openai.arguments), { path: '/tmp/b' }); + + assert.equal(serverInternals.parseToolCall('{"tool_call":{"name":"read_file","arguments":"not-json"}}'), null); + assert.equal(serverInternals.parseToolCall(JSON.stringify({ + tool_calls: [ + { function: { name: 'read_file', arguments: '{}' } }, + { function: { name: 'write_file', arguments: '{}' } }, + ], + })), null); +}); + +test('parseToolCall bounds tool markup and scans unmatched braces in linear time', () => { + const oversized = `<|DSML|tool_calls>${'x'.repeat(256 * 1024)}`; + assert.equal(serverInternals.parseToolCall(oversized), null); + + const started = Date.now(); + assert.equal(serverInternals.parseToolCall('{'.repeat(64 * 1024)), null); + assert.ok(Date.now() - started < 1000, 'unmatched JSON braces should not block the event loop'); + + const malformedTagStarted = Date.now(); + const malformedTags = ``; + assert.equal(serverInternals.parseToolCall(malformedTags), null); + assert.ok(Date.now() - malformedTagStarted < 1000, 'malformed quoted DSML tags should be rejected in bounded time'); +}); + +test('parseToolCall refuses incomplete DSML instead of executing JSON found inside it', () => { + const malformed = [ + '<|DSML|tool_calls>', + '<|DSML|invoke name="execute_code">', + '{"name":"dangerous_fallback","code":"rm -rf /"}', + '', + ].join('\n'); + + assert.equal(serverInternals.parseToolCall(malformed), null); + assert.equal(serverInternals.looksLikeToolCallMarkup(malformed), true); +}); + +test('parseToolCall rejects partially consumed DSML parameters and wrapper scope', () => { + const truncatedSecondParameter = '/tmp/atruncated'; + const trailingInvokeJunk = '/tmp/aGARBAGE'; + const truncatedSecondInvoke = '/tmp/a'; + const twoCompleteInvokes = ''; + const secondInvokeOutsideWrapper = '/tmp/a'; + const trailingWrapperJunk = 'GARBAGE'; + const unclosedCdata = ''; + const tooManyParameters = `${Array.from({ length: 129 }, (_, i) => `${i}`).join('')}`; + + for (const malformed of [ + truncatedSecondParameter, + trailingInvokeJunk, + truncatedSecondInvoke, + twoCompleteInvokes, + secondInvokeOutsideWrapper, + trailingWrapperJunk, + unclosedCdata, + tooManyParameters, + ]) { + assert.equal(serverInternals.parseToolCall(malformed), null, malformed); + assert.equal(serverInternals.looksLikeToolCallMarkup(malformed), true, malformed); + } +}); + +test('tool schema compaction drops prose annotations but preserves validation shape', () => { + const compact = serverInternals.compactToolSchema({ + type: 'object', + description: 'large top-level description', + properties: { + command: { type: 'string', description: 'large property description' }, + count: { type: 'integer', minimum: 1 }, + description: { type: 'string', description: 'annotation, not the property name' }, + title: { type: 'boolean', title: 'annotation, not the property name' }, + nested: { + anyOf: [ + { type: 'string', description: 'remove from array item one' }, + { type: 'integer', title: 'remove from array item two' }, + ], + }, + }, + required: ['command', 'description', 'title'], + }); + + assert.deepEqual(compact, { + type: 'object', + properties: { + command: { type: 'string' }, + count: { type: 'integer', minimum: 1 }, + description: { type: 'string' }, + title: { type: 'boolean' }, + nested: { anyOf: [{ type: 'string' }, { type: 'integer' }] }, + }, + required: ['command', 'description', 'title'], + }); +}); + +test('tool schema compaction preserves literal const, enum, and default values', () => { + const literals = { + type: 'object', + description: 'drop this annotation', + const: { description: 'literal field', title: 'literal title', nested: { examples: ['literal'] } }, + enum: [ + { description: 'first', value: 1 }, + { title: 'second', value: 2 }, + ], + default: { description: 'default literal', title: 'default title' }, + properties: { + choice: { + description: 'drop nested annotation', + const: { description: 'required argument value', title: 'keep me' }, + }, + }, + }; + + assert.deepEqual(serverInternals.compactToolSchema(literals), { + type: 'object', + const: literals.const, + enum: literals.enum, + default: literals.default, + properties: { + choice: { const: literals.properties.choice.const }, + }, + }); +}); + +test('buildBoundedPrompt preserves task edges and drops duplicate recovery history', () => { + const system = `SYSTEM_START\n${'s'.repeat(50000)}\nTOOL_ADAPTER_END`; + const history = `[Previous conversation]\n${'h'.repeat(10000)}\n`; + const conversation = `TASK_START\n${'c'.repeat(70000)}\nLATEST_TOOL_RESULT`; + const bounded = serverInternals.buildBoundedPrompt(system, history, conversation, 20000); + + assert.equal(bounded.compacted, true); + assert.equal(bounded.historyDropped, true); + assert.ok(bounded.prompt.length <= 20000); + assert.match(bounded.prompt, /SYSTEM_START/); + assert.match(bounded.prompt, /TOOL_ADAPTER_END/); + assert.match(bounded.prompt, /TASK_START/); + assert.match(bounded.prompt, /LATEST_TOOL_RESULT/); + assert.doesNotMatch(bounded.prompt, /Previous conversation/); +}); + +test('client-provided multi-turn history suppresses server recovery-history injection', () => { + assert.equal(serverInternals.hasExplicitConversationHistory([ + { role: 'system', content: 'rules' }, + { role: 'user', content: 'hello' }, + ]), false); + assert.equal(serverInternals.hasExplicitConversationHistory([ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + { role: 'tool', content: 'result' }, + ]), true); +}); + +test('context-too-long detector recognizes DeepSeek localized errors', () => { + assert.equal(serverInternals.isContextTooLongError({ content: 'Содержание слишком длинное. Сократите его и попробуйте снова.' }), true); + assert.equal(serverInternals.isContextTooLongError({ content: 'Maximum context length exceeded' }), true); + assert.equal(serverInternals.isContextTooLongError({ content: 'Temporary backend overload' }), false); +}); + +test('empty-response retry keeps recovery history unless a smaller global cap requires compaction', () => { + const system = 'SYSTEM'; + const history = '[Previous conversation]\nUser: old\nAssistant: answer\n\n[Continue from here]\n\n'; + const conversation = 'User: follow up'; + const initial = serverInternals.buildBoundedPrompt(system, history, conversation, 5000); + const unchangedRetry = serverInternals.buildRetryPrompt(system, history, conversation, initial.prompt, 4000); + + assert.equal(unchangedRetry.compacted, false); + assert.match(unchangedRetry.prompt, /Previous conversation/); + assert.match(unchangedRetry.prompt, /Assistant: answer/); + + const largeConversation = `TASK_START\n${'x'.repeat(9000)}\nLATEST_RESULT`; + const largeInitial = serverInternals.buildBoundedPrompt(system, history, largeConversation, 8000); + const smallerRetry = serverInternals.buildRetryPrompt(system, history, largeConversation, largeInitial.prompt, 4000); + assert.equal(smallerRetry.compacted, true); + assert.ok(smallerRetry.prompt.length <= 4000); + assert.match(smallerRetry.prompt, /TASK_START/); + assert.match(smallerRetry.prompt, /LATEST_RESULT/); +}); + +test('fresh-session retry restores local history that a healthy remote session initially omitted', () => { + const system = 'SYSTEM'; + const history = serverInternals.buildRecoveryHistoryPrefix([ + { user: 'original task', assistant: 'original answer' }, + ]); + const conversation = 'User: follow up'; + const establishedSessionPrompt = serverInternals.buildBoundedPrompt(system, '', conversation, 5000).prompt; + const freshSessionRetry = serverInternals.buildRetryPrompt( + system, + history, + conversation, + establishedSessionPrompt, + 5000, + ); + + assert.ok(freshSessionRetry.prompt.length > establishedSessionPrompt.length); + assert.match(freshSessionRetry.prompt, /Previous conversation/); + assert.match(freshSessionRetry.prompt, /original task/); + assert.match(freshSessionRetry.prompt, /original answer/); + assert.match(freshSessionRetry.prompt, /follow up/); +}); + +test('remote reset preserves local history and sticky account while returning failure diagnostics', () => { + const session = serverInternals.createSession(); + session.id = 'failed-session'; + session.parentMessageId = 'parent'; + session.createdAt = 123; + session.messageCount = 17; + session.accountId = 'account_2'; + session.history.push({ user: 'old task', assistant: 'old answer' }); + + const failure = serverInternals.resetRemoteSession(session); + assert.deepEqual(failure, { + failedSessionId: 'failed-session', + failedMessageCount: 17, + accountId: 'account_2', + }); + assert.equal(session.id, null); + assert.equal(session.parentMessageId, null); + assert.equal(session.createdAt, null); + assert.equal(session.messageCount, 0); + assert.equal(session.accountId, 'account_2'); + assert.equal(session.history.length, 1); +}); + +test('account rotation clears a foreign remote session and preserves local recovery history', (t) => { + const originalAccounts = serverInternals.accounts.splice(0); + t.after(() => { + serverInternals.accounts.splice(0, serverInternals.accounts.length, ...originalAccounts); + }); + + serverInternals.accounts.push( + { + id: 'cooling', + config: { token: 'one', cookie: 'one' }, + cooldownUntil: Date.now() + 60_000, + headers: {}, + }, + { + id: 'ready', + config: { token: 'two', cookie: 'two' }, + cooldownUntil: 0, + headers: {}, + }, + ); + const session = serverInternals.createSession(); + session.id = 'foreign-session'; + session.parentMessageId = 'foreign-parent'; + session.accountId = 'cooling'; + session.messageCount = 7; + session.history.push({ user: 'old task', assistant: 'old answer' }); + + const selected = serverInternals.selectAccountForSession(session); + assert.equal(selected.id, 'ready'); + assert.equal(session.accountId, 'ready'); + assert.equal(session.id, null); + assert.equal(session.parentMessageId, null); + assert.equal(session.messageCount, 0); + assert.equal(session.history.length, 1); +}); + +test('cross-account continuation is accepted only with a fresh recovery prompt', () => { + assert.equal(serverInternals.isContinuationRecoverySafe('one', { + account: { id: 'one' }, + freshSessionReset: false, + }), true); + assert.equal(serverInternals.isContinuationRecoverySafe('one', { + account: { id: 'two' }, + freshSessionReset: true, + }), true); + assert.equal(serverInternals.isContinuationRecoverySafe('one', { + account: { id: 'two' }, + freshSessionReset: false, + }), false); +}); + +test('TTL and depth rollover happens before prompt construction and preserves recovery state', () => { + const depthSession = serverInternals.createSession(); + depthSession.id = 'deep-session'; + depthSession.messageCount = 100; + depthSession.accountId = 'account_1'; + depthSession.history.push({ user: 'u', assistant: 'a' }); + const depthReset = serverInternals.prepareSessionForPrompt(depthSession, Date.now()); + assert.equal(depthReset.reason, 'max_message_depth'); + assert.equal(depthSession.id, null); + assert.equal(depthSession.history.length, 1); + assert.equal(depthSession.accountId, 'account_1'); + + const now = Date.now(); + const ttlSession = serverInternals.createSession(); + ttlSession.id = 'old-session'; + ttlSession.createdAt = now - (2 * 60 * 60 * 1000) - 1; + const ttlReset = serverInternals.prepareSessionForPrompt(ttlSession, now); + assert.equal(ttlReset.reason, 'session_ttl'); + assert.equal(ttlReset.failedSessionId, 'old-session'); +}); + +test('tool results use the global prompt cap instead of an unconditional 8k truncation', () => { + const toolResult = `RESULT_START\n${'z'.repeat(12000)}\nRESULT_END`; + const formatted = serverInternals.formatMessages([ + { role: 'user', content: 'inspect this' }, + { role: 'tool', content: toolResult }, + ], []); + + assert.match(formatted.prompt, /RESULT_START/); + assert.match(formatted.prompt, /RESULT_END/); + assert.ok(formatted.prompt.length > 12000); + + const bounded = serverInternals.buildBoundedPrompt(formatted.systemPrompt, '', formatted.prompt, 5000); + assert.equal(bounded.compacted, true); + assert.ok(bounded.prompt.length <= 5000); + assert.match(bounded.prompt, /RESULT_END/); +}); + +test('retry state clears a stale finish reason and failure classes use protocol-appropriate status codes', () => { + const retry = serverInternals.normalizeRetryResponse({ content: 'recovered', finishReason: null }); + assert.equal(retry.finishReason, null); + + assert.deepEqual( + serverInternals.classifyRecoveryFailure({ content: 'Maximum context length exceeded' }, false), + { status: 400, type: 'context_length_exceeded' }, + ); + assert.deepEqual( + serverInternals.classifyRecoveryFailure(null, true), + { status: 504, type: 'request_timeout' }, + ); + assert.deepEqual( + serverInternals.classifyRecoveryFailure(null, false), + { status: 502, type: 'empty_response' }, + ); + assert.equal(serverInternals.isTimeoutError({ name: 'TimeoutError', message: 'operation timed out' }), true); + assert.equal(serverInternals.isTimeoutError(new Error('ordinary upstream error')), false); +}); + +test('context-compaction header is marked and exposed to browser clients', () => { + const headers = new Map(); + const response = { setHeader: (name, value) => headers.set(name, value) }; + serverInternals.setCorsResponseHeaders(response); + serverInternals.markContextCompacted(response); + + assert.equal(headers.get('Access-Control-Expose-Headers'), serverInternals.CONTEXT_COMPACTED_HEADER); + assert.equal(headers.get(serverInternals.CONTEXT_COMPACTED_HEADER), 'true'); +}); + +test('stream helpers preserve the request-level exact CORS origin', () => { + const response = { + id: 'ds-test', + created: 1, + model: 'deepseek-chat', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; + + for (const send of [ + serverInternals.sendAnthropicStream, + serverInternals.sendResponsesStream, + serverInternals.sendOpenAIStream, + ]) { + let writeHeadHeaders = null; + const res = { + writeHead: (_status, headers) => { writeHeadHeaders = headers; }, + write: () => {}, + end: () => {}, + }; + send(res, response); + assert.equal(Object.hasOwn(writeHeadHeaders, 'Access-Control-Allow-Origin'), false); + } +});