diff --git a/.env.example b/.env.example index 2347d62..ec8842e 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,14 @@ # ── 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 +# 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 +24,13 @@ 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 + # ── Startup (deploy / CI) ── # Set either to 1/true to start the proxy immediately and skip the menu. NON_INTERACTIVE=0 diff --git a/README.md b/README.md index 00c5ee9..46ca775 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,21 @@ SKIP_ACCOUNT_MENU=1 npm start http://localhost:9655 ``` +По умолчанию proxy доступен только с этого компьютера. Для доступа из сети +явно задайте адрес и отдельный ключ proxy: + +```bash +HOST=0.0.0.0 PROXY_API_KEY='replace-with-a-long-random-value' npm start +``` + +После этого передавайте ключ как `Authorization: Bearer `. Без +`PROXY_API_KEY` non-health endpoints остаются без авторизации, поэтому не +публикуйте такой экземпляр в сеть. + +Browser-запросы разрешены с loopback-origin. Если UI открыт на другом адресе, +добавьте его точный origin через запятую, например +`PROXY_CORS_ORIGINS=https://ui.example.com,http://192.168.1.20:3000`. + --- ## 🪟 Windows запуск @@ -230,6 +245,9 @@ FreeDeepseekAPI не создаёт новый DeepSeek чат на каждый - если session id уже есть — proxy переиспользует его и продолжает chain через `parent_message_id`; - auto-reset происходит при TTL, ошибке DeepSeek session или слишком длинной цепочке сообщений; - локальная history сохраняется коротким контекстом, чтобы новая DeepSeek session могла продолжить разговор. +- длинные agent-запросы перед отправкой ограничиваются `DEEPSEEK_MAX_PROMPT_CHARS` (по умолчанию 80 000 символов): сохраняются начало задачи, свежие tool results и tool adapter; +- если клиент уже прислал multi-turn history, локальная recovery-history второй раз не добавляется; +- пустой ответ повторяется максимум `DEEPSEEK_MAX_RETRIES` раз (по умолчанию 2), причём на каждом retry контекст уменьшается. Явно задать agent/session: @@ -429,8 +447,9 @@ FreeDeepseekAPI принимает: Прокси просит DeepSeek вернуть строгий JSON tool call, но также умеет парсить fallback-форматы: - `TOOL_CALL:` -- fenced JSON +- fenced JSON with an explicit `tool_call`, `tool_calls`, or `function_call` envelope - `...` +- DeepSeek DSML (`<|DSML|tool_calls>...`) и Web-вариант с `<||DSML|| Tool Calls>` --- @@ -502,7 +521,9 @@ http://host.docker.internal:9655/v1 http://localhost:9655/v1 ``` -API key можно указать любой: proxy сам ходит в DeepSeek Web через сохранённую browser-сессию. +Если `PROXY_API_KEY` не задан, API key можно указать любой. Если ключ задан, +клиент должен передавать именно его — proxy проверяет bearer token перед +доступом к моделям, сессиям и completions. --- diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 0dd2fa3..8b51a78 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -16,12 +16,6 @@ "service_worker": "background.js" }, "action": { - "default_popup": "popup.html", - "default_icon": { - "48": "icon.png" - } - }, - "icons": { - "48": "icon.png" + "default_popup": "popup.html" } } diff --git a/docs/api-documentation.md b/docs/api-documentation.md index 3d355fd..150d34e 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -359,8 +359,9 @@ DeepSeek Web does not expose native OpenAI tool calls, so the proxy prompt-emula - strict JSON: `{"tool_call":{"name":"tool","arguments":{...}}}` - legacy format: `TOOL_CALL: tool\narguments: {...}` -- fenced JSON blocks +- fenced JSON blocks with an explicit `tool_call`, `tool_calls`, or `function_call` envelope - XML-ish `{...}` wrappers +- DeepSeek DSML (`<|DSML|tool_calls>...`) and the doubled-bar Web variant ### 3.7 List Active Sessions @@ -439,8 +440,8 @@ The proxy uses `user` from the request body. If not set, it falls back to the cl id: "uuid", // DeepSeek web session ID parentMessageId: , // Last message ID for threading createdAt: , // Session creation time - messageCount: 0-50, // Messages in this session - history: [ // Last 5 exchanges for context recovery + messageCount: 0-100, // Messages in this session + history: [ // Last 15 exchanges for context recovery { user: "...", assistant: "..." } ] } @@ -514,14 +515,15 @@ The parser traverses character by character tracking brace depth: | Condition | Action | |---|---| -| Message count >= 50 | Auto-reset DeepSeek session, keep history buffer | +| Message count >= 100 | Auto-reset DeepSeek session, keep history buffer | | Session age > 2 hours | Auto-reset (DeepSeek web session TTL) | | HTTP 400/404/500 response | Reset and retry once | -| Empty content response | Return HTTP 502 (no retry) | +| Empty content response | Compact context, reset session, retry up to `DEEPSEEK_MAX_RETRIES` (default 2) | +| Context/content too long | Pre-compact to `DEEPSEEK_MAX_PROMPT_CHARS`, then retry with a smaller budget | ### 6.2 History Buffer -When a session is reset, the proxy preserves the **last 5 exchanges** (capped at ~3000 chars). On the next request, it injects them as context: +When a session is reset, the proxy preserves the **last 15 exchanges** (capped at 10,000 chars). It injects this recovery context only when the client did not already send multi-turn history: ``` [Previous conversation] @@ -553,9 +555,10 @@ If DeepSeek's web session expires (HTTP 400/404/500): ### 7.1 Proxy Configuration (in deepseek-api-server.js) ```javascript -const MAX_HISTORY_LENGTH = 5; // Keep last 5 exchanges -const MAX_HISTORY_CHARS = 3000; // Max chars for history buffer -const MAX_MESSAGE_DEPTH = 50; // Auto-reset after 50 messages +const MAX_HISTORY_LENGTH = 15; // Keep last 15 exchanges +const MAX_HISTORY_CHARS = 10000; // Max chars for history buffer +const MAX_MESSAGE_DEPTH = 100; // Auto-reset after 100 messages +const MAX_UPSTREAM_PROMPT_CHARS = 80000; // Configurable via DEEPSEEK_MAX_PROMPT_CHARS const SESSION_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours const DS_CONFIG = { diff --git a/server.js b/server.js index 31b8740..e81c5b4 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,6 +15,7 @@ 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'); @@ -40,7 +41,12 @@ 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'; +const PROXY_API_KEY = String(process.env.PROXY_API_KEY || ''); +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(` @@ -60,6 +66,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; @@ -79,7 +135,15 @@ let inFlight = 0; // concurrent in-flight completions (backpressure cap) // loops), max concurrent completions, and the empty-response retry cap. const REQUEST_DEADLINE_MS = Number(process.env.DEEPSEEK_REQUEST_DEADLINE_MS || 120000); const MAX_CONCURRENT = Number(process.env.DEEPSEEK_MAX_CONCURRENT || 24); -const MAX_EMPTY_RETRIES = Number(process.env.DEEPSEEK_MAX_RETRIES || 4); +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)) + : 80000; 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", @@ -155,15 +219,10 @@ function selectAccountForSession(session) { 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; - } + // 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); @@ -238,6 +297,30 @@ function createSession() { }; } +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()); @@ -377,6 +460,19 @@ 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) @@ -409,31 +505,27 @@ 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 dsFetch('https://chat.deepseek.com/api/v0/chat/create_pow_challenge', { @@ -485,7 +577,7 @@ async function askDeepSeekStream(prompt, agentId, model = 'deepseek-default') { chat_session_id: session.id, parent_message_id: session.parentMessageId, model_type: modelCfg.model_type, - prompt: prompt, ref_file_ids: [], + prompt: effectivePrompt, ref_file_ids: [], thinking_enabled: modelCfg.thinking_enabled, search_enabled: modelCfg.search_enabled, action: null, preempt: false, }) @@ -494,15 +586,13 @@ async function askDeepSeekStream(prompt, agentId, model = 'deepseek-default') { // 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 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)}`); 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; + resetRemoteSession(session); const sr2 = await dsFetch('https://chat.deepseek.com/api/v0/chat_session/create', { method: 'POST', headers: dsHeaders, body: '{}' @@ -530,22 +620,75 @@ async function askDeepSeekStream(prompt, agentId, model = 'deepseek-default') { chat_session_id: session.id, parent_message_id: null, model_type: modelCfg.model_type, - prompt: prompt, ref_file_ids: [], + prompt: freshSessionPrompt, ref_file_ids: [], thinking_enabled: modelCfg.thinking_enabled, search_enabled: modelCfg.search_enabled, action: null, preempt: false, }) }); - return { resp: resp2, agentId, account }; + 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); } - return { resp, agentId, account }; + return { resp, agentId, account, promptUsed: effectivePrompt, freshSessionReset: recoveredFreshSession }; } // === 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 rawSchemaChars = tools.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.4); 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'; @@ -564,9 +707,10 @@ function formatToolDefinitions(tools) { 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 > 500 ? description.substring(0, 497) + '...' : description}\n`; if (fn.parameters) { - text += `Parameters: ${JSON.stringify(fn.parameters)}\n`; + text += `Parameters: ${JSON.stringify(compactSchemas ? compactToolSchema(fn.parameters) : fn.parameters)}\n`; } } } @@ -575,7 +719,15 @@ function formatToolDefinitions(tools) { 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; @@ -595,26 +747,84 @@ 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 (!args || typeof args !== 'object' || Array.isArray(args)) args = { value: args }; - return { name, arguments: JSON.stringify(args) }; + 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 parseJsonToolCandidate(raw, label = 'json') { +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 (!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 parseJsonToolCandidate(raw, label = 'json', options = {}) { if (!raw) return null; 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; @@ -625,14 +835,269 @@ function parseJsonToolCandidate(raw, label = 'json') { 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; } @@ -655,8 +1120,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)}`); } @@ -668,12 +1136,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; } @@ -903,7 +1368,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); @@ -974,7 +1439,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 || {}; @@ -1016,7 +1481,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; @@ -1101,11 +1566,136 @@ 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 = ''; for (const msg of messages) { if (msg.role === 'system' && msg.content) { - systemPrompt += msg.content + '\n'; + systemPrompt += normalizeMessageContent(msg.content) + '\n'; } } systemPrompt += formatToolDefinitions(tools); @@ -1115,7 +1705,7 @@ function formatMessages(messages, tools) { 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 @@ -1123,14 +1713,15 @@ 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 @@ -1139,17 +1730,43 @@ function formatMessages(messages, tools) { // === 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, 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.end(JSON.stringify(health)); return; } @@ -1252,6 +1869,8 @@ const server = http.createServer(async (req, res) => { 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); @@ -1277,6 +1896,7 @@ 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}]`; + activeAgentId = agentId; // "/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. @@ -1318,23 +1938,42 @@ const server = http.createServer(async (req, res) => { 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`; - } - historyPrefix += '[Continue from here]\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.`); } - const fullPrompt = systemPrompt - ? `${systemPrompt}\n\n${historyPrefix}${prompt}` - : `${historyPrefix}${prompt}`; + // 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 : ''; + + const promptBuild = buildBoundedPrompt(systemPrompt, historyPrefix, prompt); + const freshPromptBuild = buildBoundedPrompt(systemPrompt, recoveryHistoryPrefix, prompt); + let fullPrompt = promptBuild.prompt; + let promptCompacted = promptBuild.compacted; + if (promptBuild.compacted) { + markContextCompacted(res); + console.log(`${agentTag} Compacted upstream prompt ${promptBuild.originalChars} -> ${promptBuild.promptChars} chars${promptBuild.historyDropped ? ' (recovery history dropped)' : ''}`); + } 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) { @@ -1436,54 +2075,79 @@ 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; 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_EMPTY_RETRIES) { - console.log(`${agentTag} Empty after ${MAX_EMPTY_RETRIES} retries. Giving up.`); - res.writeHead(502, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: `DeepSeek returned empty content after ${MAX_EMPTY_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_EMPTY_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; @@ -1494,11 +2158,24 @@ const server = http.createServer(async (req, res) => { 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); @@ -1515,33 +2192,59 @@ 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}; attempting format repair.`); + toolCall = null; + } - // 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 @@ -1585,11 +2288,23 @@ const server = http.createServer(async (req, res) => { 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 status = e.status || 500; + 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); - res.end(JSON.stringify({ error: { message: e.message, type: e.type || 'server_error' } })); + 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--; } @@ -1651,6 +2366,9 @@ async function showStartupMenu() { async function main() { printBanner(); + 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) => { @@ -1694,10 +2412,41 @@ module.exports = { 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, + isLoopbackHost, + normalizeOrigin, + isBrowserOriginAllowed, + setCorsResponseHeaders, + markContextCompacted, + CONTEXT_COMPACTED_HEADER, + sendAnthropicStream, + sendResponsesStream, + sendOpenAIStream, }, }; diff --git a/tests/unit.test.js b/tests/unit.test.js index a313fb9..df6ce00 100644 --- a/tests/unit.test.js +++ b/tests/unit.test.js @@ -111,6 +111,42 @@ test('chrome auth prints actionable OS instructions when Chrome is missing', () 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 ' }, @@ -144,6 +180,18 @@ test('DeepSeek stream parser does not treat service content chunks as model erro 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() }); @@ -152,3 +200,488 @@ test('sweepIdleSessions evicts only idle entries', () => { 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('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); + } +});