From 6565f1fa50d7618ee200a6bdbb3568114cbae851 Mon Sep 17 00:00:00 2001 From: y13sint Date: Sat, 6 Jun 2026 09:12:13 +0300 Subject: [PATCH 1/7] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 08a2d1d..5cba992 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# FreeQwenApi — ForgetMeAI fork +# FreeQwenApi -> **Локальный OpenAI-compatible прокси к Qwen Chat** от [t.me/forgetmeai](https://t.me/forgetmeai). +> **Локальный OpenAI-compatible прокси к Qwen Chat** ГРАЦ ЕМУ - [t.me/forgetmeai](https://t.me/forgetmeai). > Текст, модели Qwen 3.7, файлы, Open WebUI, Hermes/LiteLLM, а теперь ещё генерация изображений и видео через Qwen Chat. ![ForgetMeAI](https://img.shields.io/badge/ForgetMeAI-t.me%2Fforgetmeai-blue) From 62a9d2cfb8d7ab7fe90364140eddf896cd5a6ec8 Mon Sep 17 00:00:00 2001 From: y13sint Date: Sat, 6 Jun 2026 09:13:00 +0300 Subject: [PATCH 2/7] Update README.md --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 5cba992..0f05002 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ > **Локальный OpenAI-compatible прокси к Qwen Chat** ГРАЦ ЕМУ - [t.me/forgetmeai](https://t.me/forgetmeai). > Текст, модели Qwen 3.7, файлы, Open WebUI, Hermes/LiteLLM, а теперь ещё генерация изображений и видео через Qwen Chat. -![ForgetMeAI](https://img.shields.io/badge/ForgetMeAI-t.me%2Fforgetmeai-blue) ![API](https://img.shields.io/badge/API-OpenAI--compatible-green) ![Qwen](https://img.shields.io/badge/Qwen-Chat-purple) @@ -364,8 +363,3 @@ curl http://localhost:3264/api/videos/status - URL сгенерированных медиа могут быть временными. - Для production используйте осторожно: это инструмент для экспериментов, демо и локальных workflow. -## От ForgetMeAI - -Если fork помог — подпишитесь: [t.me/forgetmeai](https://t.me/forgetmeai) - -Там практичные AI-инструменты, локальные агенты, open-source находки и честные тесты без корпоративной лапши. From c1a6d6ab4b60784e284a5bbfa810c0dca5a597ef Mon Sep 17 00:00:00 2001 From: dansc Date: Sat, 6 Jun 2026 22:00:33 +0300 Subject: [PATCH 3/7] fix: pin one account per request so multi-account chat works createChatV2() and sendMessage() each pulled a token via round-robin getAvailableToken(), so with 2+ accounts the chat was created under one account and the message sent under another, making Qwen reply 'chat is not exist'. Resolve the token once in sendMessage and pass it into createChatV2; reset chatId on 401/429 retries so a fresh chat is created under the new account. Co-Authored-By: Claude Opus 4.8 --- src/api/chat.js | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/api/chat.js b/src/api/chat.js index 4d19743..da1f551 100644 --- a/src/api/chat.js +++ b/src/api/chat.js @@ -729,7 +729,9 @@ async function handleApiError(response, tokenObj, message, model, chatId, parent } const { hasValidTokens } = await import('./tokenManager.js'); if (hasValidTokens() && retryCount < MAX_RETRY_COUNT) { - return sendMessage(message, model, chatId, parentId, files, null, null, null, chatType, size, waitForCompletion, retryCount + 1, onChunk); + // chatId/parentId сбрасываем: при смене аккаунта старый чат + // принадлежит прежнему токену и под новым «не существует». + return sendMessage(message, model, null, null, files, null, null, null, chatType, size, waitForCompletion, retryCount + 1, onChunk); } logError('Не осталось валидных токенов или исчерпаны попытки.'); return { error: 'Все токены недействительны (401). Требуется повторная авторизация.', chatId }; @@ -753,7 +755,9 @@ async function handleApiError(response, tokenObj, message, model, chatId, parent authToken = null; const { hasValidTokens } = await import('./tokenManager.js'); if (hasValidTokens() && retryCount < MAX_RETRY_COUNT) { - return sendMessage(message, model, chatId, parentId, files, null, null, null, chatType, size, waitForCompletion, retryCount + 1, onChunk); + // chatId/parentId сбрасываем: при смене аккаунта старый чат + // принадлежит прежнему токену и под новым «не существует». + return sendMessage(message, model, null, null, files, null, null, null, chatType, size, waitForCompletion, retryCount + 1, onChunk); } return { error: `Все токены заблокированы по лимиту (${hours}ч)`, chatId }; } @@ -766,8 +770,17 @@ async function handleApiError(response, tokenObj, message, model, chatId, parent export async function sendMessage(message, model = DEFAULT_MODEL, chatId = null, parentId = null, files = null, tools = null, toolChoice = null, systemMessage = null, chatType = 't2t', size = null, waitForCompletion = true, retryCount = 0, onChunk = null) { if (!availableModels) availableModels = getAvailableModelsFromFile(); + const browserContext = getBrowserContext(); + if (!browserContext) return { error: 'Браузер не инициализирован', chatId }; + + // Резолвим аккаунт ОДИН раз: одним и тем же токеном создаём чат и + // отправляем сообщение — иначе round-robin разнесёт их по разным + // аккаунтам и Qwen вернёт «chat is not exist». + const tokenObj = await resolveAuthToken(browserContext); + if (!tokenObj) return { error: 'Ошибка авторизации: не удалось получить токен', chatId }; + if (!chatId) { - const newChatResult = await createChatV2(model, 'Новый чат', 0, chatType); + const newChatResult = await createChatV2(model, 'Новый чат', 0, chatType, tokenObj); if (newChatResult.error) return { error: 'Не удалось создать чат: ' + newChatResult.error }; chatId = newChatResult.chatId; logInfo(`Создан новый чат v2 с ID: ${chatId}`); @@ -792,12 +805,6 @@ export async function sendMessage(message, model = DEFAULT_MODEL, chatId = null, logInfo(`Тип генерации: ${chatType} (${typeLabels[chatType] || chatType})${size ? `, размер: ${size}` : ''}`); } - const browserContext = getBrowserContext(); - if (!browserContext) return { error: 'Браузер не инициализирован', chatId }; - - const tokenObj = await resolveAuthToken(browserContext); - if (!tokenObj) return { error: 'Ошибка авторизации: не удалось получить токен', chatId }; - let page = null; try { page = await pagePool.getPage(browserContext); @@ -1005,11 +1012,14 @@ export function getAuthToken() { // ─── createChatV2 ──────────────────────────────────────────────────────────── -export async function createChatV2(model = DEFAULT_MODEL, title = 'Новый чат', retryCount = 0, chatType = 't2t') { +export async function createChatV2(model = DEFAULT_MODEL, title = 'Новый чат', retryCount = 0, chatType = 't2t', tokenObj = null) { const browserContext = getBrowserContext(); if (!browserContext) return { error: 'Браузер не инициализирован' }; - const tokenObj = await getAvailableToken(); + // tokenObj может прийти от sendMessage — тогда создание чата и отправка + // идут под ОДНИМ аккаунтом (иначе round-robin создаст чат на одном + // аккаунте, а сообщение уйдёт под другим → «chat is not exist»). + if (!tokenObj) tokenObj = await getAvailableToken(); if (tokenObj?.token) { authToken = tokenObj.token; logInfo(`Используется аккаунт для создания чата: ${tokenObj.id}`); @@ -1054,7 +1064,7 @@ export async function createChatV2(model = DEFAULT_MODEL, title = 'Новый ч if (isTransient && retryCount < MAX_RETRY_COUNT) { logWarn(`Создание чата: ${result.status}, ретрай ${retryCount + 1}/${MAX_RETRY_COUNT} через ${RETRY_DELAY}мс...`); await delay(RETRY_DELAY); - return createChatV2(model, title, retryCount + 1, chatType); + return createChatV2(model, title, retryCount + 1, chatType, tokenObj); } const cleanError = isTransient From 5a99cdb17ab50f257b60e64f654aa56c76338acc Mon Sep 17 00:00:00 2001 From: dansc Date: Sat, 6 Jun 2026 22:01:55 +0300 Subject: [PATCH 4/7] fix: use configurable DEFAULT_MODEL instead of hardcoded qwen-max-latest The default model was hardcoded as 'qwen-max-latest' in config and in 22 places across routes.js; Qwen now rejects it with 'Model not found', so any request without an explicit model failed. Route all fallbacks through the existing DEFAULT_MODEL config value and update its default to a current model (qwen3.7-max). Co-Authored-By: Claude Opus 4.8 --- src/api/routes.js | 44 ++++++++++++++++++++++---------------------- src/config.js | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/api/routes.js b/src/api/routes.js index 4d01858..d3a39ba 100644 --- a/src/api/routes.js +++ b/src/api/routes.js @@ -593,7 +593,7 @@ function buildOpenAIToolResponse(result, mappedModel, toolCalls) { id: result.id || 'chatcmpl-' + Date.now(), object: 'chat.completion', created: Math.floor(Date.now() / 1000), - model: result.model || mappedModel || 'qwen-max-latest', + model: result.model || mappedModel || DEFAULT_MODEL, choices: [{ index: 0, message: { @@ -616,7 +616,7 @@ function writeToolCallsSse(res, mappedModel, result, toolCalls) { id: result.id || 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: result.model || mappedModel || 'qwen-max-latest' + model: result.model || mappedModel || DEFAULT_MODEL }; res.write('data: ' + JSON.stringify({ ...base, @@ -757,7 +757,7 @@ router.post('/chat', async (req, res) => { logInfo(`История содержит ${allMessages.length} сообщений`); } - let mappedModel = model || "qwen-max-latest"; + let mappedModel = model || DEFAULT_MODEL; if (model) { mappedModel = getMappedModel(model); if (mappedModel !== model) { @@ -789,7 +789,7 @@ router.post('/chat', async (req, res) => { id: 'chatcmpl-' + Date.now(), object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: chunk }, finish_reason: null } ] @@ -818,7 +818,7 @@ router.post('/chat', async (req, res) => { id: 'chatcmpl-' + Date.now(), object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: `Ошибка: ${result.error}` }, finish_reason: 'stop' } ] @@ -834,7 +834,7 @@ router.post('/chat', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content }, finish_reason: null } ] @@ -850,7 +850,7 @@ router.post('/chat', async (req, res) => { id: 'chatcmpl-' + Date.now(), object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: {}, finish_reason: 'stop' } ] @@ -864,7 +864,7 @@ router.post('/chat', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' } ] @@ -1117,7 +1117,7 @@ router.post('/chat/completions', async (req, res) => { logDebug('OpenWebUI meta-запрос: используем отдельный чат (без привязки к сессии)'); } - let mappedModel = model ? getMappedModel(model) : "qwen-max-latest"; + let mappedModel = model ? getMappedModel(model) : DEFAULT_MODEL; if (model && mappedModel !== model) { logInfo(`Модель "${model}" заменена на "${mappedModel}"`); } @@ -1164,7 +1164,7 @@ router.post('/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: chunk }, finish_reason: null } ] @@ -1213,7 +1213,7 @@ router.post('/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: `Ошибка: ${result.error}` }, finish_reason: null } ] @@ -1229,7 +1229,7 @@ router.post('/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content }, finish_reason: null } ] @@ -1244,7 +1244,7 @@ router.post('/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: {}, finish_reason: 'stop' } ] @@ -1258,7 +1258,7 @@ router.post('/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' } ] @@ -1296,7 +1296,7 @@ router.post('/chat/completions', async (req, res) => { id: result.id || "chatcmpl-" + Date.now(), object: "chat.completion", created: Math.floor(Date.now() / 1000), - model: result.model || mappedModel || "qwen-max-latest", + model: result.model || mappedModel || DEFAULT_MODEL, choices: result.choices || [{ index: 0, message: { @@ -1442,7 +1442,7 @@ router.post('/v1/chat/completions', async (req, res) => { logDebug('OpenWebUI meta-запрос: используем отдельный чат (без привязки к сессии)'); } - let mappedModel = model ? getMappedModel(model) : "qwen-max-latest"; + let mappedModel = model ? getMappedModel(model) : DEFAULT_MODEL; if (model && mappedModel !== model) { logInfo(`Модель "${model}" заменена на "${mappedModel}"`); } @@ -1492,7 +1492,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: 'chatcmpl-' + Date.now(), object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: chunk }, finish_reason: null } ] @@ -1536,7 +1536,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: `Ошибка: ${result.error}` }, finish_reason: 'stop' } ] @@ -1552,7 +1552,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content }, finish_reason: null } ] @@ -1567,7 +1567,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: {}, finish_reason: 'stop' } ] @@ -1581,7 +1581,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' } ] @@ -1629,7 +1629,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: result.id || "chatcmpl-" + Date.now(), object: "chat.completion", created: Math.floor(Date.now() / 1000), - model: result.model || mappedModel || "qwen-max-latest", + model: result.model || mappedModel || DEFAULT_MODEL, choices: [{ index: 0, message: { diff --git a/src/config.js b/src/config.js index 7b8e11a..8f3046e 100644 --- a/src/config.js +++ b/src/config.js @@ -48,7 +48,7 @@ export const USER_AGENT = process.env.USER_AGENT || 'Mozilla/5.0 (Windows NT 10. // ─── Сервер ────────────────────────────────────────────────────────────────── export const PORT = Number(process.env.PORT) || 3264; export const HOST = process.env.HOST || '0.0.0.0'; -export const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'qwen-max-latest'; +export const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'qwen3.7-max'; export const ALLOW_UNSCOPED_SESSION_CHAT_RESTORE = toBoolean(process.env.ALLOW_UNSCOPED_SESSION_CHAT_RESTORE); // ─── Логирование ───────────────────────────────────────────────────────────── From 83484793b2831c6a91005b00c9a8719814deecde Mon Sep 17 00:00:00 2001 From: dansc Date: Mon, 8 Jun 2026 08:08:54 +0300 Subject: [PATCH 5/7] docs: add .env.example documenting all environment variables List every supported env var (server, limits, timeouts, paths, browser, logging, image generation) with defaults and comments, and add a Configuration section in README linking to it. Variables are read from process.env directly (no .env autoloader); the file documents shell/docker-compose configuration. Co-Authored-By: Claude Opus 4.8 --- .env.example | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 13 ++++++++ 2 files changed, 99 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..98ab53d --- /dev/null +++ b/.env.example @@ -0,0 +1,86 @@ +# FreeQwenApi — справочник переменных окружения. +# +# Переменные читаются напрямую из окружения процесса (process.env). +# Автозагрузка файла .env в Node-приложении не настроена — задавайте значения +# одним из способов: +# • локально (bash): export PORT=3264 && npm start +# • локально (PowerShell): $env:PORT=3264; npm start +# • docker-compose: блок environment: в docker-compose.yml +# • docker run: docker run -e PORT=3264 ... +# +# Все переменные опциональны — ниже указаны значения по умолчанию. +# Файл можно скопировать в .env как личную шпаргалку (он в .gitignore). + +# ─── Сервер ────────────────────────────────────────────────────────────────── +PORT=3264 +HOST=0.0.0.0 +DEFAULT_MODEL=qwen3.7-max +# Разрешить восстановление чата из сессии без привязки к аккаунту (1/true/yes/on) +ALLOW_UNSCOPED_SESSION_CHAT_RESTORE=false + +# ─── Запуск / меню аккаунтов ───────────────────────────────────────────────── +# Пропустить интерактивное меню выбора аккаунта при старте (нужно для headless/CI/Docker) +SKIP_ACCOUNT_MENU=false +# Полностью неинтерактивный режим (не запрашивать ввод в консоли) +NON_INTERACTIVE=false + +# ─── Лимиты ────────────────────────────────────────────────────────────────── +# Фолбэк-длительность блокировки токена по rate-limit (часы), когда Qwen не +# прислал точное значение. Реализуется отдельным улучшением (feat/qwen-ratelimit-env). +QWEN_RATELIMIT_HOURS=24 +MAX_RETRY_COUNT=3 +MAX_HISTORY_LENGTH=100 +MAX_FILE_SIZE=10485760 +PAGE_POOL_SIZE=3 +TASK_POLL_MAX_ATTEMPTS=90 +TASK_POLL_INTERVAL=2000 + +# ─── Таймауты (мс) ─────────────────────────────────────────────────────────── +PAGE_TIMEOUT=120000 +AUTH_TIMEOUT=120000 +NAVIGATION_TIMEOUT=60000 +RETRY_DELAY=2000 +STREAMING_CHUNK_DELAY=20 + +# ─── Пути (относительно корня проекта) ─────────────────────────────────────── +SESSION_DIR=session +UPLOADS_DIR=uploads +LOGS_DIR=logs + +# ─── Браузер ───────────────────────────────────────────────────────────────── +# Путь к исполняемому файлу Chrome/Chromium. В Docker задаётся = /usr/bin/chromium. +CHROME_PATH= +VIEWPORT_WIDTH=1920 +VIEWPORT_HEIGHT=1080 +USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 + +# ─── Логирование ───────────────────────────────────────────────────────────── +LOG_LEVEL=info +LOG_MAX_SIZE=5242880 +LOG_MAX_FILES=5 + +# ─── Генерация изображений/видео (опционально) ─────────────────────────────── +# Прямой ключ DashScope для image/video API. Не обязателен: есть browser-based +# генерация через Qwen Chat. Оставьте пустым, если не используете DashScope. +DASHSCOPE_API_KEY= + +# ─── Адреса Qwen (продвинутое — обычно менять не нужно) ─────────────────────── +# Остальные URL по умолчанию выводятся из QWEN_BASE_URL. +QWEN_BASE_URL=https://chat.qwen.ai +# CHAT_API_URL= +# CREATE_CHAT_URL= +# CHAT_PAGE_URL= +# TASK_STATUS_URL= +# STS_TOKEN_API_URL= +# AUTH_SIGNIN_URL= +# OSS_SDK_URL=https://gosspublic.alicdn.com/aliyun-oss-sdk-6.20.0.min.js + +# ─── Скрипты обслуживания (опционально) ────────────────────────────────────── +# scripts/sync_models.js — синхронизация списка моделей +# QWEN_CHAT_URL= +# QWEN_MODELS_FILE= +# QWEN_MODELS_DOC= +# scripts/smoke_test.js — дымовой тест API +# QWEN_PROXY_BASE_URL=http://localhost:3264 +# QWEN_PROXY_SMOKE_MODEL= +# QWEN_PROXY_API_KEY= diff --git a/README.md b/README.md index 0f05002..156598e 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,19 @@ npm run smoke http://localhost:3264/api ``` +## Конфигурация + +Все настройки задаются переменными окружения. Полный список с дефолтами и комментариями — в [`.env.example`](.env.example): порт, модель по умолчанию, таймауты, лимиты, пути, логирование, путь к Chrome и т.д. + +Переменные читаются из окружения процесса. Задайте нужные удобным способом: + +```bash +export PORT=3264 DEFAULT_MODEL=qwen3.7-max # bash +$env:PORT=3264; npm start # PowerShell +``` + +либо через блок `environment:` в `docker-compose.yml` / флаги `-e` у `docker run`. + ## Авторизация Qwen Chat Добавить аккаунт: From 685372eb9ab0ea0dfc63a12257c750f8ad5d9194 Mon Sep 17 00:00:00 2001 From: dansc Date: Mon, 8 Jun 2026 08:11:45 +0300 Subject: [PATCH 6/7] feat: make rate-limit cooldown hours configurable via env Extract the hardcoded 24-hour rate-limit fallback into a named RATE_LIMIT_HOURS constant (config.js), sourced from QWEN_RATELIMIT_HOURS with a 24 default. Use it in chat.js (429 handling) and the markRateLimited default in tokenManager.js. Co-Authored-By: Claude Opus 4.8 --- src/api/chat.js | 7 ++++--- src/api/tokenManager.js | 4 ++-- src/config.js | 2 ++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/api/chat.js b/src/api/chat.js index da1f551..d35d724 100644 --- a/src/api/chat.js +++ b/src/api/chat.js @@ -12,7 +12,8 @@ import { CHAT_API_URL, CREATE_CHAT_URL, CHAT_PAGE_URL, TASK_STATUS_URL, PAGE_TIMEOUT, RETRY_DELAY, PAGE_POOL_SIZE, DEFAULT_MODEL, MAX_RETRY_COUNT, - TASK_POLL_MAX_ATTEMPTS, TASK_POLL_INTERVAL + TASK_POLL_MAX_ATTEMPTS, TASK_POLL_INTERVAL, + RATE_LIMIT_HOURS } from '../config.js'; const __filename = fileURLToPath(import.meta.url); @@ -738,10 +739,10 @@ async function handleApiError(response, tokenObj, message, model, chatId, parent } if (response.status === 429 || (response.errorBody && response.errorBody.includes('RateLimited'))) { - let hours = 24; + let hours = RATE_LIMIT_HOURS; try { const rateInfo = JSON.parse(response.errorBody); - hours = Number(rateInfo.num) || 24; + hours = Number(rateInfo.num) || RATE_LIMIT_HOURS; } catch { /* errorBody might not be valid JSON */ } if (tokenObj?.id === 'browser') { diff --git a/src/api/tokenManager.js b/src/api/tokenManager.js index c82fbea..e1e9591 100644 --- a/src/api/tokenManager.js +++ b/src/api/tokenManager.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { logError } from '../logger/index.js'; -import { SESSION_DIR, ACCOUNTS_DIR } from '../config.js'; +import { SESSION_DIR, ACCOUNTS_DIR, RATE_LIMIT_HOURS } from '../config.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -53,7 +53,7 @@ export function hasValidTokens() { return tokens.some(t => (!t.resetAt || new Date(t.resetAt).getTime() <= now) && !t.invalid); } -export function markRateLimited(id, hours = 24) { +export function markRateLimited(id, hours = RATE_LIMIT_HOURS) { const tokens = loadTokens(); const idx = tokens.findIndex(t => t.id === id); if (idx !== -1) { diff --git a/src/config.js b/src/config.js index 8f3046e..07bad57 100644 --- a/src/config.js +++ b/src/config.js @@ -33,6 +33,8 @@ export const MAX_HISTORY_LENGTH = Number(process.env.MAX_HISTORY_LENGTH) || 100; export const MAX_RETRY_COUNT = Number(process.env.MAX_RETRY_COUNT) || 3; export const TASK_POLL_MAX_ATTEMPTS = Number(process.env.TASK_POLL_MAX_ATTEMPTS) || 90; export const TASK_POLL_INTERVAL = Number(process.env.TASK_POLL_INTERVAL) || 2_000; +// Фолбэк-длительность блокировки токена по rate-limit (часы), когда Qwen не прислал точное значение в ответе. +export const RATE_LIMIT_HOURS = Number(process.env.QWEN_RATELIMIT_HOURS) || 24; // ─── Пути (относительно корня проекта) ─────────────────────────────────────── export const SESSION_DIR = process.env.SESSION_DIR || 'session'; From d127d4a66c874ebee8ded8b4031bcf1e9aad0f6b Mon Sep 17 00:00:00 2001 From: Sebastion Date: Tue, 9 Jun 2026 08:18:57 +0100 Subject: [PATCH 7/7] fix: sanitize chatId to prevent path traversal in chat history (CWE-22) The getHistoryFilePath() function used unsanitized chatId values directly in path.join(), allowing an attacker to read, write, or delete arbitrary .json files on the filesystem via directory traversal sequences in the chatId parameter (e.g., "../../etc/passwd"). This adds a sanitizeChatId() function that: - Rejects values containing path separators (/ or \) or traversal (..) - Whitelists only [a-zA-Z0-9_-] characters - Adds defense-in-depth resolved-path containment check All callers of getHistoryFilePath (saveHistory, loadHistory, chatExists, deleteChat) are protected since sanitization is applied at the single chokepoint function. The chatExists function now wraps in try/catch to gracefully handle invalid IDs. --- src/api/chatHistory.js | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/src/api/chatHistory.js b/src/api/chatHistory.js index a816af3..59bc9d4 100644 --- a/src/api/chatHistory.js +++ b/src/api/chatHistory.js @@ -33,8 +33,33 @@ export function createChat(chatName) { return chatId; } +/** + * Sanitize chatId to prevent path traversal (CWE-22). + * Rejects any value containing path separators, traversal sequences, + * or characters outside the allowed set [a-zA-Z0-9_-]. + * Returns null for invalid values — callers must handle null gracefully. + */ +function sanitizeChatId(chatId) { + if (typeof chatId !== 'string' || !chatId) return null; + // Reject if it contains path separators or traversal sequences + if (chatId.includes('/') || chatId.includes('\\') || chatId.includes('..')) return null; + // Whitelist: only allow alphanumeric, hyphens, and underscores + if (!/^[a-zA-Z0-9_-]+$/.test(chatId)) return null; + return chatId; +} + function getHistoryFilePath(chatId) { - return path.join(HISTORY_DIR, `${chatId}.json`); + const safeChatId = sanitizeChatId(chatId); + if (!safeChatId) { + throw new Error(`Invalid chatId: ${String(chatId).substring(0, 50)}`); + } + const filePath = path.join(HISTORY_DIR, `${safeChatId}.json`); + // Defense-in-depth: verify the resolved path is still inside HISTORY_DIR + const resolved = path.resolve(filePath); + if (!resolved.startsWith(path.resolve(HISTORY_DIR) + path.sep)) { + throw new Error(`Path traversal blocked for chatId: ${String(chatId).substring(0, 50)}`); + } + return filePath; } export function saveHistory(chatId, data) { @@ -120,10 +145,15 @@ export function loadHistory(chatId) { } export function chatExists(chatId) { - const historyFilePath = getHistoryFilePath(chatId); - const exists = fs.existsSync(historyFilePath); - logDebug(`Проверка существования чата ${chatId}: ${exists ? 'найден' : 'не найден'}`); - return exists; + try { + const historyFilePath = getHistoryFilePath(chatId); + const exists = fs.existsSync(historyFilePath); + logDebug(`Проверка существования чата ${chatId}: ${exists ? 'найден' : 'не найден'}`); + return exists; + } catch (error) { + logError(`Invalid chatId in chatExists: ${error.message}`); + return false; + } } export function renameChat(chatId, newName) { @@ -341,4 +371,4 @@ export function deleteChatsAutomatically(criteria = {}) { error: error.message }; } -} \ No newline at end of file +}