Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# 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)
![API](https://img.shields.io/badge/API-OpenAI--compatible-green)
![Qwen](https://img.shields.io/badge/Qwen-Chat-purple)

Expand Down Expand Up @@ -53,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

Добавить аккаунт:
Expand Down Expand Up @@ -364,8 +376,3 @@ curl http://localhost:3264/api/videos/status
- URL сгенерированных медиа могут быть временными.
- Для production используйте осторожно: это инструмент для экспериментов, демо и локальных workflow.

## От ForgetMeAI

Если fork помог — подпишитесь: [t.me/forgetmeai](https://t.me/forgetmeai)

Там практичные AI-инструменты, локальные агенты, open-source находки и честные тесты без корпоративной лапши.
41 changes: 26 additions & 15 deletions src/api/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -729,17 +730,19 @@ 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 };
}

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') {
Expand All @@ -753,7 +756,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 };
}
Expand All @@ -766,8 +771,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}`);
Expand All @@ -792,12 +806,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);
Expand Down Expand Up @@ -1005,11 +1013,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}`);
Expand Down Expand Up @@ -1054,7 +1065,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
Expand Down
42 changes: 36 additions & 6 deletions src/api/chatHistory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -341,4 +371,4 @@ export function deleteChatsAutomatically(criteria = {}) {
error: error.message
};
}
}
}
Loading