/** * OpenAI-compatible HTTP server with multi-account management. * * POST /v1/chat/completions — chat completions * POST /v1/responses - OpenAI Responses API * GET /v1/models — list models * POST /auth/login — add account (email+password / token / api_key) * GET /auth/accounts — list all accounts * DELETE /auth/accounts/:id — remove account * GET /auth/status — pool status summary * GET /health — health check */ import http from 'http'; import { randomUUID } from 'crypto'; import { readFileSync, existsSync } from 'fs'; import { execSync } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { validateApiKey, isAuthenticated, getAccountList, getAccountCount, addAccountByEmail, addAccountByToken, addAccountByKey, removeAccount, configureBindHost, emitNoAuthWarnings, getDroughtSummary, ensureLsForAccount, } from './auth.js'; import { handleChatCompletions } from './handlers/chat.js'; import { handleMessages } from './handlers/messages.js'; import { handleResponses } from './handlers/responses.js'; import { handleModels } from './handlers/models.js'; import { handleDashboardApi, parseProxyUrl } from './dashboard/api.js'; import { setAccountProxy } from './dashboard/proxy-config.js'; import { config, log } from './config.js'; import { VERSION } from './version.js'; import { callerKeyFromRequest } from './caller-key.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, '..'); const VERSION_INFO = (() => { let commit = '', commitMessage = '', commitDate = '', branch = 'unknown'; if (existsSync(join(REPO_ROOT, '.git'))) { try { commit = execSync('git rev-parse --short HEAD', { cwd: REPO_ROOT, timeout: 2000 }).toString().trim(); } catch {} try { commitMessage = execSync('git log -1 --pretty=format:%s', { cwd: REPO_ROOT, timeout: 2000 }).toString().trim(); } catch {} try { commitDate = execSync('git log -1 --pretty=format:%cI', { cwd: REPO_ROOT, timeout: 2000 }).toString().trim(); } catch {} try { branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT, timeout: 2000 }).toString().trim(); } catch {} } return { version: VERSION, commit, commitMessage, commitDate, branch }; })(); // 10 MB is way above any realistic chat-completions payload while still // bounding worst-case memory from a malicious/broken client. const MAX_BODY_SIZE = 10 * 1024 * 1024; function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', c => { size += c.length; if (size > MAX_BODY_SIZE) { req.destroy(); reject(Object.assign(new Error('Request body too large'), { statusCode: 413 })); return; } chunks.push(c); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); req.on('error', reject); }); } export function extractToken(req) { // Anthropic SDK + OAI SDK compatibility: accept either header. const authHeader = String(req.headers['authorization'] || '').trim(); if (authHeader && authHeader.includes(',')) return ''; const m = authHeader.match(/^Bearer\s+(.+)$/i); if (m) return m[1].trim(); const xApiKey = req.headers['x-api-key'] || ''; return xApiKey; } function json(res, status, body) { const data = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', // Per-request dynamic responses must not be cached by intermediaries. // Some upstream aggregators (e.g. sub2api, #97) priority-cache responses // when they don't see an explicit Cache-Control directive and serve // stale content for fresh requests. 'Cache-Control': 'no-store', }); res.end(data); } async function route(req, res) { const { method } = req; let path = req.url.split('?')[0]; if (method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, anthropic-version', }); return res.end(); } if (path === '/health') { const counts = getAccountCount(); const body = { status: 'ok', provider: 'WindsurfAPI bydwgx1337', version: VERSION_INFO.version, commit: VERSION_INFO.commit, commitMessage: VERSION_INFO.commitMessage, commitDate: VERSION_INFO.commitDate, branch: VERSION_INFO.branch, uptime: Math.round(process.uptime()), accounts: counts, }; const qs = new URL(req.url, 'http://localhost').searchParams; if (qs.get('verbose') === '1' && validateApiKey(extractToken(req))) { try { const { poolStats } = await import('./conversation-pool.js'); const { cacheStats } = await import('./cache.js'); const { getLsStatus } = await import('./langserver.js'); body.conversationPool = poolStats(); body.cache = cacheStats(); body.lsPool = getLsStatus(); // v2.0.57 Fix 5 — drought summary so monitoring can page on // "all accounts < 5% weekly" without screen-scraping per-account // credit dumps. body.drought = getDroughtSummary(); } catch {} } return json(res, 200, body); } // ─── Dashboard ───────────────────────────────────────── if (path === '/favicon.ico') { res.writeHead(204); return res.end(); } if (path === '/dashboard' || path === '/dashboard/') { try { // Cookie-based skin selection. `dashboard_skin=sketch` serves the // experimental hand-drawn console; anything else (or no cookie) // serves the default UI. Each UI sets/unsets the cookie via its own // settings toggle, then reloads — server picks the right file based // on the next request's cookie. Vary: Cookie keeps intermediaries // from poisoning one user's skin onto another. const cookie = String(req.headers.cookie || ''); const m = cookie.match(/(?:^|;\s*)dashboard_skin=([^;]+)/); const skin = m ? decodeURIComponent(m[1]) : ''; const file = skin === 'sketch' ? 'index-sketch.html' : 'index.html'; const html = readFileSync(join(__dirname, 'dashboard', file)); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Vary': 'Cookie', 'Cache-Control': 'no-cache', }); return res.end(html); } catch { return json(res, 500, { error: 'Dashboard not found' }); } } if (path.startsWith('/dashboard/api/')) { let body = {}; if (method === 'POST' || method === 'PUT' || method === 'PATCH') { try { body = JSON.parse(await readBody(req)); } catch {} } const subpath = path.slice('/dashboard/api'.length); return handleDashboardApi(method, subpath, body, req, res); } // ─── Dashboard i18n locale files ──────────────────────── if (path.startsWith('/dashboard/i18n/')) { try { const localeFile = path.slice('/dashboard/i18n/'.length); // Security: only allow .json files with alphanumeric/hyphen names if (!localeFile.match(/^[a-zA-Z0-9\-]+\.json$/)) { return json(res, 400, { error: 'Invalid locale file' }); } const filePath = join(__dirname, 'dashboard', 'i18n', localeFile); const content = readFileSync(filePath); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(content); } catch { return json(res, 404, { error: 'Locale file not found' }); } } // ─── Dashboard data files (contributors, etc.) ────────── // Same shape as i18n: tight regex on the basename, served as JSON. // Used by both default and sketch UIs as the single source of truth // for hand-maintained roster data so the two skins stay in sync. if (path.startsWith('/dashboard/data/')) { try { const dataFile = path.slice('/dashboard/data/'.length); if (!dataFile.match(/^[a-zA-Z0-9\-]+\.json$/)) { return json(res, 400, { error: 'Invalid data file' }); } const filePath = join(__dirname, 'dashboard', 'data', dataFile); const content = readFileSync(filePath); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(content); } catch { return json(res, 404, { error: 'Data file not found' }); } } // ─── API endpoints (require API key) ──────────────────── if (!validateApiKey(extractToken(req))) { // v2.0.61 (#110): clearer error so operators know the issue is // configuration (no API_KEY set on a public-bind instance) rather // than a bad client header. The chat client side rarely shows a // verbose error so we cram the diagnosis into the message itself. const tokenSent = !!extractToken(req); const message = tokenSent ? 'Invalid API key. Either the key is wrong, or the server has API_KEY configured to a different value than the one your client sent.' : 'Missing API key. This server runs in fail-closed mode: requests must include `Authorization: Bearer ` (or `x-api-key: `) matching the configured API_KEY env var. If you intend to run open (no auth), bind the server to localhost (HOST=127.0.0.1).'; return json(res, 401, { error: { message, type: 'auth_error' } }); } // ─── Auth management (admin — gated by API key above) ── if (path === '/auth/status') { return json(res, 200, { authenticated: isAuthenticated(), ...getAccountCount() }); } if (path === '/auth/accounts' && method === 'GET') { return json(res, 200, { accounts: getAccountList() }); } // DELETE /auth/accounts/:id if (path.startsWith('/auth/accounts/') && method === 'DELETE') { const id = path.split('/')[3]; const ok = removeAccount(id); return json(res, ok ? 200 : 404, { success: ok }); } if (path === '/auth/login' && method === 'POST') { let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'Invalid JSON' }); } try { // ── bind proxy to account ────────────────────── function bindAccountProxy(accountId, proxyStr) { if (!proxyStr) return; const parsed = parseProxyUrl(proxyStr); if (parsed) { setAccountProxy(accountId, parsed); ensureLsForAccount(accountId).catch(e => log.warn(`LS ensure failed: ${e.message}`)); } else { log.warn(`auth/login: ignoring invalid proxy format: ${String(proxyStr).slice(0, 80)}`); } } // Support batch: { accounts: [{token,proxy}, ...] } if (Array.isArray(body.accounts)) { const results = []; for (const acct of body.accounts) { try { let result; if (acct.api_key) { result = addAccountByKey(acct.api_key, acct.label); } else if (acct.token) { result = await addAccountByToken(acct.token, acct.label); } else if (acct.email && acct.password) { result = await addAccountByEmail(acct.email, acct.password); } else { results.push({ error: 'Missing credentials' }); continue; } bindAccountProxy(result.id, acct.proxy); results.push({ id: result.id, email: result.email, status: result.status }); } catch (err) { results.push({ email: acct.email, error: err.message }); } } return json(res, 200, { results, ...getAccountCount() }); } // Single account let account; if (body.api_key) { account = addAccountByKey(body.api_key, body.label); } else if (body.token) { account = await addAccountByToken(body.token, body.label); } else if (body.email && body.password) { account = await addAccountByEmail(body.email, body.password); } else { return json(res, 400, { error: 'Provide api_key, token, or email+password' }); } bindAccountProxy(account.id, body.proxy); return json(res, 200, { success: true, account: { id: account.id, email: account.email, method: account.method, status: account.status }, ...getAccountCount(), }); } catch (err) { log.error('Login failed:', err.message); return json(res, 401, { error: err.message }); } } if (path === '/v1/models' && method === 'GET') { return json(res, 200, handleModels()); } if (path === '/v1/chat/completions' && method === 'POST') { if (!isAuthenticated()) { return json(res, 503, { error: { message: 'No active accounts. POST /auth/login to add accounts.', type: 'auth_error' }, }); } let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: { message: 'Invalid JSON', type: 'invalid_request' } }); } if (!Array.isArray(body.messages)) { return json(res, 400, { error: { message: 'messages must be an array', type: 'invalid_request' } }); } if (body.messages.length === 0) { return json(res, 400, { error: { message: 'messages must contain at least 1 item', type: 'invalid_request' } }); } const reqStartedAt = Date.now(); const result = await handleChatCompletions(body, { callerKey: callerKeyFromRequest(req, extractToken(req), body) }); const processingMs = Date.now() - reqStartedAt; const modelHeaders = { 'x-request-id': 'req-' + randomUUID(), 'openai-model': body.model || '', // Actual upstream processing time — hvoy.ai and similar verifiers // treat a flat "0" as a fingerprint of a faking proxy. 'openai-processing-ms': String(processingMs), 'openai-version': '2020-10-01', // OpenAI always returns an organization header. We don't have a real // org id, but a stable synthetic one keeps the shape consistent so // the signature check doesn't pick up on the missing field. 'openai-organization': 'org-windsurf-proxy', }; if (result.stream) { res.writeHead(result.status, { 'Access-Control-Allow-Origin': '*', ...modelHeaders, ...result.headers }); await result.handler(res); } else { for (const [k, v] of Object.entries(modelHeaders)) res.setHeader(k, v); if (result.headers) { for (const [k, v] of Object.entries(result.headers)) res.setHeader(k, v); } json(res, result.status, result.body); } return; } // v2.0.71 (#121 keh4l): some clients send `/v1/response` (singular) // by mistake — this exact alias avoids a confusing 404 and routes to // the canonical handler. The plural `/v1/responses` is the spec form. if (path === '/v1/response' && method === 'POST') { path = '/v1/responses'; } if (path === '/v1/responses' && method === 'POST') { if (!isAuthenticated()) { return json(res, 503, { error: { message: 'No active accounts. POST /auth/login to add accounts.', type: 'auth_error' }, }); } let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: { message: 'Invalid JSON', type: 'invalid_request' } }); } if (body.input == null) { return json(res, 400, { error: { message: 'input is required', type: 'invalid_request' } }); } const reqStartedAt = Date.now(); const result = await handleResponses(body, { context: { callerKey: callerKeyFromRequest(req, extractToken(req), body) } }); const processingMs = Date.now() - reqStartedAt; const modelHeaders = { 'x-request-id': 'req-' + randomUUID(), 'openai-model': body.model || '', 'openai-processing-ms': String(processingMs), 'openai-version': '2020-10-01', 'openai-organization': 'org-windsurf-proxy', }; if (result.stream) { res.writeHead(result.status, { 'Access-Control-Allow-Origin': '*', ...modelHeaders, ...result.headers }); await result.handler(res); } else { for (const [k, v] of Object.entries(modelHeaders)) res.setHeader(k, v); if (result.headers) { for (const [k, v] of Object.entries(result.headers)) res.setHeader(k, v); } json(res, result.status, result.body); } return; } // Anthropic Messages API — Claude Code compatibility if (path === '/v1/messages' && method === 'POST') { if (!isAuthenticated()) { return json(res, 503, { type: 'error', error: { type: 'api_error', message: 'No active accounts' } }); } let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON' } }); } if (!Array.isArray(body.messages) || body.messages.length === 0) { return json(res, 400, { type: 'error', error: { type: 'invalid_request_error', message: 'messages must be a non-empty array' } }); } const result = await handleMessages(body, { callerKey: callerKeyFromRequest(req, extractToken(req), body) }); const anthropicHeaders = { 'request-id': 'req-' + randomUUID(), 'anthropic-model': body.model || '', }; if (result.stream) { res.writeHead(result.status, { 'Access-Control-Allow-Origin': '*', ...anthropicHeaders, ...result.headers }); await result.handler(res); } else { for (const [k, v] of Object.entries(anthropicHeaders)) res.setHeader(k, v); json(res, result.status, result.body); } return; } json(res, 404, { error: { message: `${method} ${path} not found`, type: 'not_found' } }); } export function startServer() { const activeRequests = new Set(); const bindHost = config.host || '0.0.0.0'; configureBindHost(bindHost); emitNoAuthWarnings(bindHost); const server = http.createServer(async (req, res) => { activeRequests.add(res); res.on('close', () => activeRequests.delete(res)); try { await route(req, res); } catch (err) { log.error('Handler error:', err); if (!res.headersSent) json(res, 500, { error: { message: 'Internal error', type: 'server_error' } }); } }); server.keepAliveTimeout = 65_000; server.headersTimeout = 66_000; let retryCount = 0; const maxRetries = 10; server.on('error', (err) => { if (err.code === 'EADDRINUSE') { retryCount++; if (retryCount > maxRetries) { log.error(`Port ${config.port} still in use after ${maxRetries} retries. Exiting.`); process.exit(1); } log.warn(`Port ${config.port} in use, retry ${retryCount}/${maxRetries} in 3s...`); setTimeout(() => server.listen(config.port, bindHost), 3000); } else { log.error('Server error:', err); } }); server.getActiveRequests = () => activeRequests.size; server.listen({ port: config.port, host: bindHost }, () => { log.info(`Server on http://${bindHost}:${config.port}`); log.info(' POST /v1/chat/completions'); log.info(' POST /v1/responses'); log.info(' GET /v1/models'); log.info(' POST /auth/login (add account)'); log.info(' GET /auth/accounts (list accounts)'); log.info(' DELETE /auth/accounts/:id (remove account)'); }); return server; }