/** * OpenAI-compatible HTTP server with multi-account management. * * POST /v1/chat/completions — chat completions * POST /v1/completions — OpenAI legacy 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 { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { validateApiKey, isAuthenticated, getAccountList, getAccountCount, addAccountByEmail, addAccountByPastedSecret, removeAccount, configureBindHost, emitNoAuthWarnings, getDroughtSummary, ensureLsForAccount, checkLockout, failedAuthAttempt, successfulAuthAttempt, } from './auth.js'; import { trustedClientIp } from './net-safety.js'; import { handleChatCompletions, normalizeOpenAIErrorBody, normalizeOpenAIErrorType } from './handlers/chat.js'; import { handleCompletions } from './handlers/completions.js'; import { handleMessages, handleCountTokens, validateMessagesRequest, validateCountTokensRequest, toAnthropicError } from './handlers/messages.js'; import { handleGemini, parseGeminiPath, geminiError } from './handlers/gemini.js'; import { handleResponses, handleGetResponse, handleDeleteResponse } from './handlers/responses.js'; import { handleModels } from './handlers/models.js'; import { createDeferredSseResponse } from './sse-commit.js'; import { createRequestBudget } from './request-budget.js'; import { resolveClineCompat, stripClineNamespace } from './handlers/cline-compat.js'; import { resolveCcCompat, stripCcNamespace } from './handlers/cc-compat.js'; import { isExperimentalEnabled, getBackendSwitch } from './runtime-config.js'; import { resolveModel, getModelInfo } from './models.js'; import { handleDashboardApi, preflightDashboardRequest, parseProxyUrl, validateProxyHost, verifyAdminRequest, validateDashboardRequestHost } from './dashboard/api.js'; import { recordRequest, updateRequest } from './dashboard/request-ring.js'; import { withStatsScope, finalizeStatsScope } from './dashboard/stats.js'; import { setAccountProxy } from './dashboard/proxy-config.js'; import { config, log } from './config.js'; import { getVersionInfo } from './version.js'; import { callerKeyFromRequest } from './caller-key.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, '..'); const VERSION_INFO = getVersionInfo(); // 10 MB is way above any realistic chat-completions payload while still // bounding worst-case memory from a malicious/broken client. export const MAX_BODY_SIZE = 10 * 1024 * 1024; // G1: Anthropic 官方要求客户端发 `anthropic-version` 请求头(如 2023-06-01)。 // 作为兼容代理我们取宽容路线:缺失不返 400,而是 warn + 回退默认版本(理由见 // SPEC-G1);未知版本也不硬失败(向前兼容),原样接受并回写。当前翻译层不因版本 // 分叉,故解析结果仅用于回写响应头 + 诊断日志。 const ANTHROPIC_DEFAULT_VERSION = '2023-06-01'; const ANTHROPIC_KNOWN_VERSIONS = new Set(['2023-06-01', '2023-01-01']); // Single-token DEVIN_CONNECT deploys (DEVIN_CONNECT_TOKEN / WINDSURF_API_KEY) // intentionally keep the account pool empty: the connect client falls back to // the env token when `acct` is null. The HTTP prechecks below must therefore // accept that deploy shape too, or every chat route 503s before the handler // that knows how to use the token is reached. function hasUsableAccount() { if (isAuthenticated()) return true; return getBackendSwitch('devinConnect') && !!(process.env.DEVIN_CONNECT_TOKEN || process.env.WINDSURF_API_KEY); } // 读取并归一 anthropic-version 请求头。返回生效版本字符串(缺失→默认)。 // Node 已把头名小写化,直接取 req.headers['anthropic-version']。 export function resolveAnthropicVersion(req) { const raw = req && req.headers ? String(req.headers['anthropic-version'] || '').trim() : ''; if (!raw) { log.warn('anthropic-version header missing; defaulting to ' + ANTHROPIC_DEFAULT_VERSION); return ANTHROPIC_DEFAULT_VERSION; } // S3-F4: the value is echoed back as a response header — a charset-clamped // version keeps unknown/future versions flowing while control bytes and // arbitrary obs-text garbage fall back to the default instead of being // mirrored into the wire (and into warn logs). if (!/^[0-9a-zA-Z._-]{1,64}$/.test(raw)) { log.warn('anthropic-version malformed: ' + headerSafeValue(raw).slice(0, 40) + ' (using default)'); return ANTHROPIC_DEFAULT_VERSION; } if (!ANTHROPIC_KNOWN_VERSIONS.has(raw)) { // 向前兼容:未知/未来版本不拒绝,原样透传。 log.warn('anthropic-version unrecognized: ' + raw.slice(0, 40) + ' (accepting as-is)'); } return raw; } // Request-controlled strings echoed into response headers (openai-model / // anthropic-model / version echoes) must be printable ASCII: Node's header // validation throws on control bytes, and on the SSE error path that throw // lands inside commit()/finishError where it used to pin the socket open // (S3-F1). Non-string values coerce the same way String() would. export function headerSafeValue(value, max = 256) { return String(value ?? '').replace(/[^\x20-\x7E]/g, '').slice(0, max); } export function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; let settled = false; const fail = (err) => { if (settled) return; settled = true; chunks.length = 0; try { req.resume?.(); } catch {} reject(err); }; req.on('data', c => { if (settled) return; size += c.length; if (size > MAX_BODY_SIZE) { fail(Object.assign(new Error('Request body too large'), { statusCode: 413, code: 'ERR_REQUEST_BODY_TOO_LARGE', })); return; } chunks.push(c); }); req.on('end', () => { if (settled) return; settled = true; resolve(Buffer.concat(chunks).toString('utf-8')); }); req.on('error', fail); }); } export function bodyTooLargePayload(style = 'openai') { if (style === 'dashboard') { return { ok: false, error: 'ERR_REQUEST_BODY_TOO_LARGE', message: 'Request body too large' }; } if (style === 'legacy') { return { error: 'Request body too large' }; } if (style === 'anthropic') { // D1: 413 maps to the dedicated request_too_large type (not // invalid_request_error), aligning with toAnthropicError(413). return { type: 'error', error: { type: 'request_too_large', message: 'Request body too large' } }; } return { error: { message: 'Request body too large', type: 'invalid_request_error' } }; } function sendBodyTooLargeIfNeeded(res, err, style = 'openai') { if (err?.statusCode !== 413 && err?.code !== 'ERR_REQUEST_BODY_TOO_LARGE') return false; json(res, 413, bodyTooLargePayload(style)); return true; } // ─── Inbound Content-Encoding refusal (Q2 T04) ───────────────────────────── // // readBody() hands the raw bytes to JSON.parse as UTF-8; it has never // decompressed anything. A compressed body therefore always ended as a // confusing "Invalid JSON" 400 — and any future attempt to "fix" that by // inflating the body would hand an attacker a gzip bomb (a few KB on the wire, // gigabytes in memory). Refuse the encoding explicitly, BEFORE the body is // read, and never decompress: the caller must resend uncompressed. // // The check keys on the header alone, so it also fires for a chunked request // with no Content-Length (nothing about the body's declared size is trusted). const SUPPORTED_CONTENT_ENCODINGS = new Set(['', 'identity']); /** The unsupported Content-Encoding value on a request, or '' when usable. */ export function unsupportedContentEncoding(req) { const raw = String(req?.headers?.['content-encoding'] ?? '').trim(); if (!raw) return ''; return SUPPORTED_CONTENT_ENCODINGS.has(raw.toLowerCase()) ? '' : raw; } export function unsupportedContentEncodingPayload(encoding, style = 'openai') { const message = `Content-Encoding "${encoding}" is not supported. Send the request body uncompressed ` + '(omit Content-Encoding, or send Content-Encoding: identity). This proxy does not decompress request bodies.'; if (style === 'dashboard') { return { ok: false, error: 'unsupported_content_encoding', message }; } if (style === 'legacy') { return { error: message, code: 'unsupported_content_encoding' }; } if (style === 'anthropic') { return { type: 'error', error: { type: 'unsupported_content_encoding', message } }; } if (style === 'gemini') { // Gemini's envelope needs a numeric code + status enum; the shared typed // string rides as `type` for clients that branch on it (unknown fields are // ignored by the Google SDKs). return { error: { code: 415, message, status: 'INVALID_ARGUMENT', type: 'unsupported_content_encoding' } }; } return { error: { message, type: 'unsupported_content_encoding' } }; } function rejectUnsupportedContentEncoding(req, res, style = 'openai') { const encoding = unsupportedContentEncoding(req); if (!encoding) return false; json(res, 415, unsupportedContentEncodingPayload(encoding, style)); return true; } function contentEncodingErrorStyle(path) { if (isAnthropicPath(path)) return 'anthropic'; if (typeof path === 'string' && (path === '/v1beta' || path.startsWith('/v1beta/') || GEMINI_GENERATE_PATH_RE.test(path))) return 'gemini'; if (typeof path === 'string' && path.startsWith('/auth/')) return 'legacy'; return 'openai'; } /** * Bind an AbortController to the HTTP response so a client hang-up mid-await * tears down the in-flight upstream call. * * Stream translators (messages/gemini/responses) already abort via * captureRes._clientDisconnected. Chat and completions already bound this at * the route. The non-stream await on messages / gemini / responses did not — * hanging up left the upstream running (the same partial-path class as * Retry-After / include_usage). Guard on !writableEnded so a normal end is a * no-op. */ export function bindClientAbort(res) { const abortController = new AbortController(); res.on('close', () => { if (!res.writableEnded) abortController.abort(); }); return abortController; } // ─── Credential carriers are SURFACE-scoped (Q2 T03) ─────────────────────── // // Every accepted carrier used to authenticate every route: a Gemini query key // (`?key=`) or `x-goog-api-key` opened /v1/chat/completions, and `x-api-key` // opened any route. That let a credential minted for one protocol be replayed // against another (and put a chat POST's key in the URL, where proxies log it). // The carriers are now matched to the surface that actually defines them: // // openai : Authorization: Bearer only (chat/completions/responses) // messages : Bearer + x-api-key (Anthropic SDK shape) // models : Bearer + x-api-key + x-goog-api-key + ?key // gemini : Bearer + x-goog-api-key + ?key (Google GenAI SDK shape) // operator : Bearer + x-api-key (/auth/* — unchanged operator flow) // any : every carrier (direct callers + /health) // // `any` is the default so a pure-function caller that does not know a route is // not silently narrowed; the router always passes the surface explicitly. const CREDENTIAL_CARRIERS = { openai: ['bearer'], messages: ['bearer', 'x-api-key'], models: ['bearer', 'x-api-key', 'x-goog-api-key', 'query'], gemini: ['bearer', 'x-goog-api-key', 'query'], operator: ['bearer', 'x-api-key'], any: ['bearer', 'x-api-key', 'x-goog-api-key', 'query'], }; // A Gemini generate call is Gemini-native ONLY at its documented prefixes: // /v1beta/models/{m}:generateContent and the /v1/models/{m}:generateContent // alias. Left-anchored so an embedded `/models/x:generateContent` tail inside // another route (e.g. /v1/messages/models/…) cannot pull the request onto the // Gemini carrier set or into handleGemini (S2-F1). const GEMINI_GENERATE_PATH_RE = /^\/v1(?:beta)?\/models\/[^:/]+:(generateContent|streamGenerateContent)$/; /** * Which carrier set applies to a request path. Operator surfaces (/auth/*) and * /health keep their historical carriers: the audit's scoping rule is about the * protocol API surfaces, and narrowing operator auth would break the documented * account-management flow. */ export function credentialSurfaceFor(path) { const p = typeof path === 'string' ? path : ''; if (p === '/v1/messages' || p.startsWith('/v1/messages/')) return 'messages'; // A generate call is Gemini-native even when it rides the /v1/models alias: // `/v1/models/{model}:generateContent` must use the Gemini carrier set, not // the looser `models` set, or x-api-key (an Anthropic carrier) would reach // handleGemini through the alias while /v1beta rejects it. Checked BEFORE the // `/v1/models/` prefix for exactly that reason. if (GEMINI_GENERATE_PATH_RE.test(p)) return 'gemini'; if (p === '/v1/models' || p.startsWith('/v1/models/')) return 'models'; if (p === '/v1beta' || p.startsWith('/v1beta/')) return 'gemini'; if (p === '/health' || p === '/auth' || p.startsWith('/auth/')) return 'operator'; return 'openai'; } export function extractToken(req, surface = 'any') { const carriers = CREDENTIAL_CARRIERS[surface] || CREDENTIAL_CARRIERS.any; const allows = (carrier) => carriers.includes(carrier); // Anthropic SDK + OAI SDK compatibility: accept either header. const authHeader = String(req.headers['authorization'] || '').trim(); // TOK-3 (audit P3): a comma used to blanket-clear the token — so a caller // sending `Authorization: Bearer my,key` (or a duplicate header Node joined // as `Bearer a, Bearer b`) was rejected AND the x-api-key fallback was // skipped, 401ing even when a correct x-api-key was present. Instead, parse // the Bearer credential and take only its FIRST comma-delimited segment: a // duplicate/injected `Bearer a, Bearer b` can't smuggle a second credential, // a stray comma no longer nukes auth, and — because we fall through when the // Bearer segment is empty — x-api-key still works. const m = allows('bearer') && authHeader.match(/^Bearer\s+(.+)$/i); if (m) { const first = m[1].split(',')[0].trim(); if (first) return first; } if (allows('x-api-key')) { // HTTP/2 keeps header padding the HTTP/1.1 parser strips; trim so the two // front ends compare the same credential (S2-F5). const xApiKey = String(req.headers['x-api-key'] || '').trim(); if (xApiKey) return xApiKey; } // Gemini-native auth: the Google GenAI SDK sends the key as `x-goog-api-key` // or a `?key=` query param, not Bearer/x-api-key. Accept both only where a // Gemini client can legitimately send them. if (allows('x-goog-api-key')) { const xGoog = String(req.headers['x-goog-api-key'] || '').trim(); if (xGoog) return xGoog; } if (allows('query')) { try { const qKey = new URL(req.url, 'http://localhost').searchParams.get('key'); if (qKey) return qKey.trim(); } catch { /* malformed url → no query key */ } } return ''; } function nativeBridgeCallerKeyForRequest(req, token, body, callerKey = '') { const key = callerKey || callerKeyFromRequest(req, token, body); const tokenAllowlist = String(process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_API_KEYS || '') .split(',') .map(s => s.trim()) .filter(Boolean); if (!tokenAllowlist.length) return key; return tokenAllowlist.includes(String(token || '').trim()) ? `${key}:api_key_allowed` : key; } 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); } // F4: inject a top-level request_id into ERROR bodies only (status >= 400), // reusing the exact value already emitted in the x-request-id/request-id // header so a client can log/correlate the same id from either place. OpenAI // and Anthropic both carry request_id in their error envelopes; success bodies // stay header-only (both APIs omit it from success payloads). Non-object bodies // pass through untouched, and an existing request_id is never clobbered. export function withRequestId(status, body, requestId) { if (status < 400) return body; if (!body || typeof body !== 'object' || Array.isArray(body)) return body; if (body.request_id != null) return body; return { ...body, request_id: requestId }; } function anthropicModelPayload(id, info) { return { type: 'model', id: info?.name || id, display_name: info?.name || id, created_at: '2026-01-01T00:00:00Z', }; } /** * Serve an OpenAI chat stream through the commit gate. * * Exported so the route wiring itself (not just the wrapper) can be tested: * `commitMs === 0` must restore the legacy raw-res shape, otherwise the gate * buffers the first frames and can rewrite a pre-content error. */ export function resolveStreamCommitMs(raw = process.env.WINDSURFAPI_STREAM_COMMIT_MS) { if (raw === undefined || raw === '') return undefined; const n = Number(raw); return Number.isFinite(n) && n >= 0 ? Math.min(n, 14_000) : undefined; } // O10: classify a failed stream with the same failure class the non-stream // error rows carry (`result.body.error.code` — the DEVIN_CONNECT // classification: RATE_LIMITED, CAPACITY, ...). Gemini's error envelope puts // the HTTP-ish numeric status in `code` and the class in `__upstream_code`, so // a string `code` wins and the generic 'error' is the fallback the non-stream // expression already uses when no class survives. function streamFailureClass(error) { if (!error || typeof error !== 'object') return 'error'; if (typeof error.code === 'string' && error.code) return error.code; if (typeof error.__upstream_code === 'string' && error.__upstream_code) return error.__upstream_code; return 'error'; } // O10: the streaming routes patch their ring row once the handler settles. A // stream that fails after the commit gate still ends as HTTP 200, so `status` // alone cannot classify it — the failure the gate observed supplies the class. function streamRingDone(ringRow, reqStartedAt, res) { return (stream) => updateRequest(ringRow.id, { durationMs: Date.now() - reqStartedAt, status: res.statusCode, ...(stream?.failure ? { class: streamFailureClass(stream.failure) } : {}), }); } export async function serveChatStream(res, result, { headers, requestId, commitMs, onDone = null, protocol = 'openai', isBootstrap = null, parseError = null, errorBody = null, mapError = null, } = {}) { // Gemini's default response is a JSON array, not SSE. It cannot be parsed by // the deferred SSE gate, so forward it immediately (the gate would hold the // entire answer until upstream completion or the bootstrap cap). if (result.mode === 'array') { if (!res.headersSent) res.writeHead(result.status || 200, headers); try { await result.handler(res); } finally { onDone?.(); } return; } if (commitMs === 0) { if (!res.headersSent) res.writeHead(result.status, headers); try { await result.handler(res); } finally { onDone?.(); } return; } const streamRes = createDeferredSseResponse(res, { status: result.status, headers, protocol, requestId, bootstrapTimeoutMs: commitMs, ...(isBootstrap ? { isBootstrap } : {}), ...(parseError ? { parseError } : {}), ...(errorBody ? { errorBody } : {}), ...(mapError ? { mapError } : {}), }); try { await result.handler(streamRes); } finally { streamRes.dispose(); // A handler that throws after committing must not leave the socket open. if (res.headersSent && !res.writableEnded) res.end(); onDone?.({ failure: streamRes.failure }); } } async function route(req, res) { const { method } = req; let path = req.url.split('?')[0]; // Tolerate a doubled `/v1/v1/` prefix some clients emit. if (path.startsWith('/v1/v1/')) path = path.slice(3); // Fallback request id for every exit that never reaches a route handler. // // Measured 2026-09-13 (L8 smoke lane): each route generated its own id and set its // own header, so the auth gate (`:412`), every 404 fallback (`:378`, `:397`, `:469`) // and the Gemini branch answered with NO request id. An operator holding only the // client-side error text could not match it to a log line. The per-route sites keep // their own ids (they override this header via writeHead) — this only guarantees the // pre-dispatch paths are never id-less. `x-request-id` is the vendor-neutral name the // other routes already emit; Anthropic's own `request-id` header still wins where set. const fallbackRequestId = 'req_' + randomUUID(); res.setHeader('x-request-id', fallbackRequestId); // Cline compatibility layer resolution (see src/handlers/cline-compat.js). // The dedicated /v1/cline/* namespace is an explicit opt-in that a partner // points Cline at, so it activates the compat shims regardless of the master // toggle; we rewrite it to the canonical /v1/* path so every existing route // match below works unchanged. On the standard path, the shims only engage // when the master experimental.clineCompat toggle is on AND the request looks // like Cline. When inactive, clineCompat.active is false and the whole // pipeline stays byte-identical for every other client. const clineCompat = resolveClineCompat({ path, headers: req.headers, masterEnabled: isExperimentalEnabled('clineCompat'), }); if (clineCompat.source === 'endpoint') path = stripClineNamespace(path); // Claude Code compatibility layer resolution (see src/handlers/cc-compat.js). // Same two-source contract as Cline: the dedicated /v1/cc/* namespace activates // the CC shims regardless of the master toggle (rewritten to canonical /v1/* so // every route below matches unchanged); on the standard path the shims engage // only when experimental.ccCompat is on AND the request looks like Claude Code. // Unlike Cline, CC defaults to the Anthropic protocol, so ccCompat is threaded // into the messages/responses handlers below, not only the chat one. When // inactive, ccCompat.active is false and the whole pipeline is byte-identical. const ccCompat = resolveCcCompat({ path, headers: req.headers, masterEnabled: isExperimentalEnabled('ccCompat'), }); if (ccCompat.source === 'endpoint') path = stripCcNamespace(path); if (method === 'OPTIONS' && !path.startsWith('/dashboard/api/')) { // Public API (/v1/*) preflight: blanket `*`, no credentials — correct for an // open, key-authenticated API. Dashboard-API preflights are deliberately // excluded so they fall through to handleDashboardApi, whose OPTIONS handler // answers through the DASHBOARD_CORS_ORIGINS allowlist (matching the actual // dashboard responses instead of a blanket `*`). #9. 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, x-goog-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, buildSource: VERSION_INFO.source, pid: process.pid, uptime: Math.round(process.uptime()), accounts: counts, }; const qs = new URL(req.url, 'http://localhost').searchParams; if (qs.get('verbose') === '1' && validateApiKey(extractToken(req, 'operator'))) { try { const { poolStats } = await import('./conversation-pool.js'); const { cacheStats } = await import('./cache.js'); const { getLsStatus } = await import('./langserver.js'); const { getSpecialAgentStatus } = await import('./special-agent.js'); const { getNativeBridgeStats } = await import('./native-bridge-stats.js'); const { getNativeBridgeConfigStatus } = await import('./cascade-native-bridge.js'); body.conversationPool = poolStats(); body.cache = cacheStats(); body.lsPool = getLsStatus(); body.specialAgent = getSpecialAgentStatus(); body.nativeBridge = getNativeBridgeStats(); body.nativeBridgeConfig = getNativeBridgeConfigStatus(); // 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/')) { const subpath = path.slice('/dashboard/api'.length); // S03/G05: run the Host/mutation/lockout/auth gate BEFORE reading the // body. Previously the whole (up to MAX_BODY_SIZE) body was buffered and // JSON-parsed before handleDashboardApi's gate ran, so an unauthenticated // caller could force 10 MiB of work per request. handleDashboardApi skips // its copy of the gate when this preflight already passed; direct callers // (unit tests, in-process handlers) still get it there. if (!preflightDashboardRequest(method, subpath, req, res)) return; let body = {}; if (method === 'POST' || method === 'PUT' || method === 'PATCH') { if (rejectUnsupportedContentEncoding(req, res, 'dashboard')) return; let raw; try { raw = await readBody(req); } catch (err) { if (sendBodyTooLargeIfNeeded(res, err, 'dashboard')) return; return json(res, 400, { ok: false, error: 'Invalid JSON' }); } // An empty body is a valid "no payload" POST (e.g. /self-update, // account status toggles) — treat it as {} instead of feeding // JSON.parse('') which throws and surfaced as a bogus "Invalid JSON" // on the dashboard's Update button (#self-update). const trimmed = String(raw ?? '').trim(); if (trimmed) { try { body = JSON.parse(trimmed); } catch { return json(res, 400, { ok: false, error: 'Invalid JSON' }); } } } 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, withRequestId(404, { error: 'Locale file not found' }, fallbackRequestId)); } } // ─── 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, withRequestId(404, { error: 'Data file not found' }, fallbackRequestId)); } } // ─── API endpoints (require API key) ──────────────────── // D1 (audit2/Server.md S02): `/auth/*` is an operator surface. In the // documented open-local mode verifyAdminRequest authenticates purely from // the loopback socket, which a DNS-rebinding page also satisfies — so a // rebinding Host could list/add/delete pool accounts. Apply the same // Host/X-Forwarded-Host boundary as `/dashboard/api/*` BEFORE the API-key // gate, the brute-force counters, or any body read: a rejected Host has no // account delta and cannot spend auth attempts. if (path === '/auth' || path.startsWith('/auth/')) { if (!validateDashboardRequestHost(req, res)) return; } const credentialSurface = credentialSurfaceFor(path); const presentedToken = extractToken(req, credentialSurface); if (!validateApiKey(presentedToken)) { // 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 = !!presentedToken; // Name only the carriers this surface actually accepts (T03). Telling a chat // caller to retry with x-api-key — which the same gate now rejects there — // would send them in a loop. const carrierHint = credentialSurface === 'gemini' ? '`Authorization: Bearer `, `x-goog-api-key: ` or `?key=`' : credentialSurface === 'models' ? '`Authorization: Bearer `, `x-api-key: `, `x-goog-api-key: ` or `?key=`' : credentialSurface === 'messages' || credentialSurface === 'operator' ? '`Authorization: Bearer ` or `x-api-key: `' : '`Authorization: Bearer `'; 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 ${carrierHint} 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).`; // Q2 T02: the Anthropic SDK parses ONLY the Anthropic error envelope and // reads the request id from the `request-id` header. The shared gate used to // answer every family with the OpenAI {error:{type:'auth_error'}} body and // no Anthropic header, so a Claude client saw an unparseable 401. Keep the // OpenAI shape byte-identical; Gemini got its own envelope below (M10). if (isAnthropicPath(path)) { res.setHeader('request-id', fallbackRequestId); return json(res, 401, withRequestId(401, { type: 'error', error: { type: 'authentication_error', message }, }, fallbackRequestId)); } // M10: Gemini-surface clients (Google GenAI SDK, generateContent on either // the /v1beta or the /v1/models alias) parse only the Google error // envelope — {error:{code,message,status:'UNAUTHENTICATED'}}. The shared // OpenAI auth_error body is as unparseable to them as it was to Anthropic // SDKs before Q2 T02. geminiError owns the vocabulary. if (credentialSurface === 'gemini') { res.setHeader('request-id', fallbackRequestId); return json(res, 401, withRequestId(401, geminiError(401, null, message).body, fallbackRequestId)); } return json(res, 401, withRequestId(401, { error: { message, type: 'auth_error' } }, fallbackRequestId)); } // Q2 T04: refuse a compressed request body before any route reads it. The // check sits after the credential wall (an unauthenticated caller still gets // the 401 it earned) and before every no-account/route guard, so the answer // does not depend on pool state. if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && rejectUnsupportedContentEncoding(req, res, contentEncodingErrorStyle(path))) return; // ─── Auth management (admin — gated by API key above) ── if (path === '/auth/status') { // Chat-key-visible summary: authenticated + counts only, NO email list. // (Grok H1: listing/adding/deleting accounts requires operator auth below.) return json(res, 200, { authenticated: isAuthenticated(), ...getAccountCount() }); } // H1 (Grok audit): account-pool management (list emails / add / delete) is an // OPERATOR action, not a chat action. A shared chat API key must NOT let any // caller run the pool. These endpoints now require dashboard-operator auth // (dashboard password, or the opt-in localhost apiKey fallback) IN ADDITION to // having passed the chat-key gate above. A bare chat key → 403. const isAccountMgmt = path === '/auth/accounts' || path.startsWith('/auth/accounts/') || (path === '/auth/login' && method === 'POST'); if (isAccountMgmt) { // H-2 (ultracode audit 2026-07-13): these admin endpoints share the same // operator credential as /dashboard/api/* but previously had NO brute-force // lockout — an attacker holding a chat key could online-guess the operator // password here unlimited times, while the same guessing on /dashboard/api/* // gets banned after 5 tries. Wire the SAME lockout (same client-IP bucket, // trustedClientIp) so the two endpoints can't be played against each other. const clientIp = trustedClientIp(req); const lock = checkLockout(clientIp); if (lock.blocked) { res.setHeader?.('Retry-After', String(Math.ceil(lock.retryAfterMs / 1000))); return json(res, 429, { error: { message: `Too many failed operator-auth attempts. IP banned for ${Math.ceil(lock.retryAfterMs / 1000)}s.`, type: 'auth_error', code: 'locked_out', } }); } if (!verifyAdminRequest(req)) { // Only count a real guess (non-empty password) toward the lockout, mirroring // dashboard/api.js — an empty header is the normal "no creds" case. if (String(req.headers['x-dashboard-password'] || '')) failedAuthAttempt(clientIp); return json(res, 403, { error: { message: 'Account-pool management (list/add/delete accounts) requires operator authentication. Send the dashboard password via the `x-dashboard-password` header (set DASHBOARD_PASSWORD, or use the dashboard UI). A chat API key alone is not sufficient.', type: 'auth_error', code: 'admin_required', } }); } // Authenticated operator — clear any failed-attempt streak for this IP. successfulAuthAttempt(clientIp); } 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 (err) { if (sendBodyTooLargeIfNeeded(res, err, 'legacy')) return; return json(res, 400, { error: 'Invalid JSON' }); } try { // ── bind proxy to account ────────────────────── async function parseAndValidateAccountProxy(proxyStr) { if (!proxyStr) return null; const parsed = parseProxyUrl(proxyStr); if (!parsed) { log.warn(`auth/login: ignoring invalid proxy format: ${String(proxyStr).slice(0, 80)}`); return null; } await validateProxyHost(parsed); return parsed; } function bindAccountProxy(accountId, parsedProxy) { if (parsedProxy) { setAccountProxy(accountId, parsedProxy); if (process.env.LS_PREWARM_ON_ACCOUNT_ADD === '1' || process.env.LS_PREWARM_PROXIES === '1') { ensureLsForAccount(accountId).then(r => { if (r && !r.ok) log.warn(`LS ensure skipped/failed: ${r.errorType || r.error}`); }).catch(e => log.warn(`LS ensure failed: ${e.message}`)); } } } // Support batch: { accounts: [{token,proxy}, ...] } if (Array.isArray(body.accounts)) { const results = []; for (const acct of body.accounts) { try { const parsedProxy = await parseAndValidateAccountProxy(acct.proxy); let result; if (acct.api_key) { result = await addAccountByPastedSecret(acct.api_key, acct.label, '', { unknownAsKey: true }); } else if (acct.token) { result = await addAccountByPastedSecret(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, parsedProxy); 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 const parsedProxy = await parseAndValidateAccountProxy(body.proxy); let account; if (body.api_key) { account = await addAccountByPastedSecret(body.api_key, body.label, '', { unknownAsKey: true }); } else if (body.token) { account = await addAccountByPastedSecret(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, parsedProxy); 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); const status = /^ERR_PROXY_/i.test(err.message || '') ? 400 : 401; return json(res, status, { error: err.message }); } } if (path === '/v1/models' && method === 'GET') { return json(res, 200, handleModels()); } if (path.startsWith('/v1/models/') && method === 'GET') { const rawId = decodeURIComponent(path.slice('/v1/models/'.length)); const modelKey = resolveModel(rawId); const info = getModelInfo(modelKey); if (!info) { return json(res, 404, { type: 'error', error: { type: 'not_found_error', message: `Model ${rawId} not found` } }); } return json(res, 200, anthropicModelPayload(rawId, info)); } if (path === '/v1/chat/completions' && method === 'POST') { if (!hasUsableAccount()) { return json(res, 503, { error: { message: 'No active accounts. POST /auth/login to add accounts.', type: 'api_error' }, }); } let body; try { body = JSON.parse(await readBody(req)); } catch (err) { if (sendBodyTooLargeIfNeeded(res, err, 'openai')) return; return json(res, 400, { error: { message: 'Invalid JSON', type: 'invalid_request_error' } }); } if (!Array.isArray(body.messages)) { return json(res, 400, { error: { message: 'messages must be an array', type: 'invalid_request_error' } }); } if (body.messages.length === 0) { return json(res, 400, { error: { message: 'messages must contain at least 1 item', type: 'invalid_request_error' } }); } const reqStartedAt = Date.now(); const token = extractToken(req, 'openai'); const callerKey = callerKeyFromRequest(req, token, body); // Thread a client-disconnect AbortSignal into the handler context so a // caller that hangs up mid-flight tears down the in-flight upstream call // and — on the DEVIN_CONNECT path — stops the failover loop from hopping // fresh pooled accounts to a dead socket. Guard on !writableEnded so the // normal end-of-response 'close' is a no-op. const abortController = bindClientAbort(res); // One absolute deadline + upstream-turn budget per request, owned by the // route. Layers (queue admission, cascade poll, connect retries) receive // the remaining time and may only shorten their own bounds. const budget = createRequestBudget({ stream: body.stream === true }); const result = await handleChatCompletions(body, { callerKey, nativeBridgeCallerKey: nativeBridgeCallerKeyForRequest(req, token, body, callerKey), signal: abortController.signal, budget, clineCompat, ccCompat, }); const processingMs = Date.now() - reqStartedAt; const requestId = 'req_' + randomUUID(); const ringRow = recordRequest({ id: requestId, model: body.model, status: result.status, stream: !!result.stream, durationMs: processingMs, class: result.body?.error?.code || (result.status < 400 ? 'ok' : 'error'), caller: callerKey, protocol: 'openai', }); const modelHeaders = { 'x-request-id': requestId, 'openai-model': headerSafeValue(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) { // Commit gate: hold the first frames until a real payload arrives. A // pre-content upstream 429/500 then becomes a real HTTP status instead of // a 200 SSE error frame. Heartbeats/role-only primes are buffered (bounded // by the wrapper) and flushed with the first real frame. const commitMs = resolveStreamCommitMs(); const streamHeaders = { 'Access-Control-Allow-Origin': '*', ...modelHeaders, ...result.headers }; await serveChatStream(res, result, { headers: streamHeaders, requestId, commitMs, onDone: streamRingDone(ringRow, reqStartedAt, 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); } // O10: normalize internal error.type to the official OpenAI vocabulary at // the egress boundary; no-op on success bodies. F4: inject the same // request_id emitted in x-request-id into the error body (status >= 400). normalizeOpenAIErrorBody(result.body, result.status); json(res, result.status, withRequestId(result.status, result.body, requestId)); } return; } if (path === '/v1/completions' && method === 'POST') { if (!hasUsableAccount()) { return json(res, 503, { error: { message: 'No active accounts. POST /auth/login to add accounts.', type: 'api_error' }, }); } let body; try { body = JSON.parse(await readBody(req)); } catch (err) { if (sendBodyTooLargeIfNeeded(res, err, 'openai')) return; return json(res, 400, { error: { message: 'Invalid JSON', type: 'invalid_request_error' } }); } if (body == null || typeof body !== 'object' || Array.isArray(body)) { return json(res, 400, { error: { message: 'Request body must be a JSON object', type: 'invalid_request_error' } }); } const reqStartedAt = Date.now(); const token = extractToken(req, 'openai'); const callerKey = callerKeyFromRequest(req, token, body); const abortController = bindClientAbort(res); const budget = createRequestBudget({ stream: false }); const result = await handleCompletions(body, { callerKey, nativeBridgeCallerKey: nativeBridgeCallerKeyForRequest(req, token, body, callerKey), signal: abortController.signal, budget, clineCompat, ccCompat, }); const processingMs = Date.now() - reqStartedAt; const requestId = 'req_' + randomUUID(); const ringRow = recordRequest({ id: requestId, model: body.model, status: result.status, stream: !!result.stream, durationMs: processingMs, class: result.body?.error?.code || (result.status < 400 ? 'ok' : 'error'), caller: callerKey, protocol: 'completions', }); const modelHeaders = { 'x-request-id': requestId, 'openai-model': headerSafeValue(body.model), 'openai-processing-ms': String(processingMs), 'openai-version': '2020-10-01', 'openai-organization': 'org-windsurf-proxy', }; 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); } normalizeOpenAIErrorBody(result.body, result.status); json(res, result.status, withRequestId(result.status, result.body, requestId)); 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 (!hasUsableAccount()) { return json(res, 503, { error: { message: 'No active accounts. POST /auth/login to add accounts.', type: 'api_error' }, }); } let body; try { body = JSON.parse(await readBody(req)); } catch (err) { if (sendBodyTooLargeIfNeeded(res, err, 'openai')) return; return json(res, 400, { error: { message: 'Invalid JSON', type: 'invalid_request_error' } }); } if (body.input == null) { return json(res, 400, { error: { message: 'input is required', type: 'invalid_request_error' } }); } const reqStartedAt = Date.now(); const token = extractToken(req, 'openai'); const callerKey = callerKeyFromRequest(req, token, body); const abortController = bindClientAbort(res); const budget = createRequestBudget({ stream: body.stream === true }); const result = await handleResponses(body, { context: { callerKey, nativeBridgeCallerKey: nativeBridgeCallerKeyForRequest(req, token, body, callerKey), ccCompat, signal: abortController.signal, budget, }, }); const processingMs = Date.now() - reqStartedAt; const requestId = 'req_' + randomUUID(); const ringRow = recordRequest({ id: requestId, model: body.model, status: result.status, stream: !!result.stream, durationMs: processingMs, class: result.body?.error?.code || (result.status < 400 ? 'ok' : 'error'), caller: callerKey, protocol: 'responses', }); const modelHeaders = { 'x-request-id': requestId, 'openai-model': headerSafeValue(body.model), 'openai-processing-ms': String(processingMs), 'openai-version': '2020-10-01', 'openai-organization': 'org-windsurf-proxy', }; if (result.stream) { const commitMs = resolveStreamCommitMs(); const streamHeaders = { 'Access-Control-Allow-Origin': '*', ...modelHeaders, ...result.headers }; await serveChatStream(res, result, { headers: streamHeaders, requestId, commitMs, protocol: 'responses', // The Responses translator emits response.created/in_progress before any // content, and a pre-content failure as response.failed. Hold only the // lifecycle frames; commit on the first content item. isBootstrap: (obj) => obj?.type === 'response.created' || obj?.type === 'response.in_progress', parseError: (obj) => (obj?.type === 'response.failed' && obj.response?.error ? obj.response.error : null), errorBody: (error, status) => ({ error: { message: error?.message || 'Upstream stream error', type: normalizeOpenAIErrorType(error?.type, status), ...(error?.code ? { code: error.code } : {}), }, }), onDone: streamRingDone(ringRow, reqStartedAt, 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); } // O10 + F4: /v1/responses is OpenAI-family — same egress normalize + // request_id injection as /v1/chat/completions. normalizeOpenAIErrorBody(result.body, result.status); json(res, result.status, withRequestId(result.status, result.body, requestId)); } return; } // GET / DELETE /v1/responses/{id} — retrieve or drop a stored response. // // Matched with an explicit prefix + a strict id shape rather than a loose // startsWith slice: `path` is never URL-decoded anywhere in this router (see the // audit note on path normalization), and keeping the id charset closed means a // crafted suffix cannot smuggle a traversal segment or a second path component // into the store lookup. Anything that does not match falls through to the 404. if ((method === 'GET' || method === 'DELETE') && path.startsWith('/v1/responses/')) { const responseId = path.slice('/v1/responses/'.length); if (/^[A-Za-z0-9_-]{1,128}$/.test(responseId)) { const token = extractToken(req, 'openai'); // Identity for a BODYLESS request. // // The original comment here claimed the ip/ua fingerprint was "exactly what a // chained POST with no `user` derives, so a client that can chain can also // retrieve". That was false, and it made both endpoints dead on arrival: any // client that CAN chain sends `user` / `prompt_cache_key` / // `safety_identifier`, so its POST callerKey carries a `:user:` segment // while a bodyless GET derives `:client:` — a different key, so the // lookup always 404'd. And a client that sends none of them derives a // matching key but fails hasPerUserScope, so it 404'd too. Measured: every // client shape 404'd, with the record provably present under the POST // identity. Not even WINDSURFAPI_SINGLE_TENANT_CACHE=1 helped — mismatched // identity is the prior problem. // // The Responses retrieval API has no request body, so the identity signal has // to ride somewhere else. It goes in a HEADER, not the query string. // // Query params were the first cut and they leak PII: `user` is, per this // repo's own note at caller-key.js:107, "often an end-user email or stable // account id (PII)" that must not be logged — and the bundled TLS front end // logs the full URL verbatim (https-proxy.js:20 prints `${method} ${url}`), // as does essentially every reverse proxy, CDN, and browser history. Putting // the exact value that module refuses to log into the URL contradicts the // policy the same repo states. // // Headers carry it without that exposure. The vocabulary mirrors the POST // body's ENTIRE scope vocabulary — extractBodyCallerSubKey also honours // conversation / metadata.conversation_id / metadata.session_id, and omitting // those left clients using them unable to retrieve their own responses (the // same "covered only some of the cases" miss this fix was made to remove). // Read through the SAME extraction path, so a caller reproduces its scope // exactly. Absent every signal, behaviour is unchanged. const h = req.headers || {}; const q = new URL(req.url, 'http://localhost').searchParams; // Header first; query kept as a documented fallback for clients that cannot // set headers, with the PII caveat spelled out in the README. // Header names use hyphens (`x-response-conversation-id`); the canonical // scope vocabulary uses underscores (`conversation_id`). The query fallback // accepts BOTH spellings: the README documents the six signals with the // header spelling and then says the same names work as query parameters, so // `?conversation-id=` was documented but 404'd while only `?conversation_id=` // worked. // // Precisely what this does and does not change. Per NAME it is a pure alias — // the underscore form wins, and either spelling of one name derives the same // identity. What it is NOT is "strictly more permissive": a spelling that was // previously inert can now FILL a scope slot, and a caller that supplies // several DIFFERENT scope vocabularies has all of them folded into one // identity, so adding a slot changes the derived key. That folding is // pre-existing — on master `?conversation=A&conversation_id=B` already // differed from `?conversation=A` alone, because both underscore spellings // were accepted — this just makes a third spelling able to reach it. The // practical rule for callers, now stated in the README, is to send ONE scope // signal and send it consistently. const pick = (name) => { const hyphen = name.replace(/_/g, '-'); return h[`x-response-${hyphen}`] || q.get(name) || q.get(hyphen) || ''; }; const ident = {}; for (const k of ['user', 'prompt_cache_key', 'safety_identifier', 'conversation']) { const v = pick(k); if (v) ident[k] = v; } const convId = pick('conversation_id'); const sessId = pick('session_id'); if (convId || sessId) { ident.metadata = { ...(convId ? { conversation_id: convId } : {}), ...(sessId ? { session_id: sessId } : {}), }; } const callerKey = callerKeyFromRequest(req, token, Object.keys(ident).length ? ident : null); const result = (method === 'GET' ? handleGetResponse : handleDeleteResponse)( responseId, { context: { callerKey } }, ); const requestId = 'req_' + randomUUID(); res.setHeader('x-request-id', requestId); res.setHeader('openai-version', '2020-10-01'); res.setHeader('openai-organization', 'org-windsurf-proxy'); normalizeOpenAIErrorBody(result.body, result.status); return json(res, result.status, withRequestId(result.status, result.body, requestId)); } } // Anthropic Messages API — Claude Code compatibility if (path === '/v1/messages/count_tokens' && method === 'POST') { if (!hasUsableAccount()) { // D2: no-account gate is a transient capacity condition, not a fatal // server bug. 503 api_error was self-contradictory (503 isn't in the // Anthropic status set; api_error implies 500). 529 overloaded_error is // the official retryable status so SDKs back off and retry. return json(res, 529, { type: 'error', error: { type: 'overloaded_error', message: 'No active accounts available, please retry' } }); } let body; try { body = JSON.parse(await readBody(req)); } catch (err) { if (sendBodyTooLargeIfNeeded(res, err, 'anthropic')) return; return json(res, 400, { type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON' } }); } // E4: model is required by the official count_tokens API. const invalid = validateCountTokensRequest(body); if (invalid) return json(res, invalid.status, invalid.body); // G1: count_tokens is also an Anthropic-family endpoint — echo the version // header for symmetry with /v1/messages (no stream here, so setHeader). const anthropicVersion = resolveAnthropicVersion(req); const result = handleCountTokens(body); res.setHeader('anthropic-version', anthropicVersion); return json(res, result.status, result.body); } if (path === '/v1/messages' && method === 'POST') { if (!hasUsableAccount()) { // D2: see count_tokens above — transient capacity → 529 overloaded_error. return json(res, 529, { type: 'error', error: { type: 'overloaded_error', message: 'No active accounts available, please retry' } }); } let body; try { body = JSON.parse(await readBody(req)); } catch (err) { if (sendBodyTooLargeIfNeeded(res, err, 'anthropic')) return; 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' } }); } // C1/C3/C4: enforce max_tokens (required, positive int) and cache_control // validity (ephemeral type, ttl ∈ {5m,1h}, ≤4 breakpoints) at the entry. const invalid = validateMessagesRequest(body); if (invalid) return json(res, invalid.status, invalid.body); const token = extractToken(req, 'messages'); const callerKey = callerKeyFromRequest(req, token, body); // G1: read the anthropic-version request header (warn+default when missing, // accept-as-is when unknown). Passed into context as a seam for future // version-gated behavior; the translation layer does not branch on it today. const anthropicVersion = resolveAnthropicVersion(req); const reqStartedAt = Date.now(); const requestId = 'req_' + randomUUID(); const abortController = bindClientAbort(res); const budget = createRequestBudget({ stream: body.stream === true }); const result = await handleMessages(body, { callerKey, nativeBridgeCallerKey: nativeBridgeCallerKeyForRequest(req, token, body, callerKey), anthropicVersion, ccCompat, signal: abortController.signal, budget, }); const processingMs = Date.now() - reqStartedAt; const ringRow = recordRequest({ id: requestId, model: body.model, status: result.status, stream: !!result.stream, durationMs: processingMs, class: result.body?.error?.code || (result.status < 400 ? 'ok' : 'error'), caller: callerKey, protocol: 'messages', }); const anthropicHeaders = { 'request-id': requestId, 'anthropic-model': headerSafeValue(body.model), // G1: echo the effective version back so clients can confirm it. 'anthropic-version': anthropicVersion, }; if (result.stream) { const commitMs = resolveStreamCommitMs(); await serveChatStream(res, result, { headers: { 'Access-Control-Allow-Origin': '*', ...anthropicHeaders, ...result.headers }, requestId, commitMs, protocol: 'messages', isBootstrap: (obj) => obj?.type === 'message_start' || obj?.type === 'ping', parseError: (obj) => (obj?.type === 'error' && obj.error ? obj.error : null), errorBody: (error, status) => toAnthropicError(status, error?.type, error?.message).body, onDone: streamRingDone(ringRow, reqStartedAt, res), }); } else { for (const [k, v] of Object.entries(anthropicHeaders)) res.setHeader(k, v); // Forward the handler's own headers. The STREAM branch above already spreads // `result.headers` into writeHead; this branch did not, so a non-stream 429 // reached the client with no `Retry-After` at all — the header the Anthropic // SDK reads to decide how long to back off. /v1/responses has always // forwarded them here (see the same spot on that route), so this was a // per-route divergence rather than a deliberate policy. // // Set BEFORE the body so a handler header cannot silently lose to the // Anthropic-vocabulary ones above — those are the route's contract and win // on collision, which is why this loop does not overwrite them. if (result.headers) { for (const [k, v] of Object.entries(result.headers)) { if (!(k in anthropicHeaders)) res.setHeader(k, v); } } // F4: inject request_id into Anthropic error bodies (status >= 400), // matching {type:'error', error:{...}, request_id}. O10 does NOT touch // Anthropic — toAnthropicError owns its own status vocabulary. json(res, result.status, withRequestId(result.status, result.body, requestId)); } return; } // ── Google Gemini API (generativelanguage) v1beta frontend ── // POST /v1beta/models/{model}:generateContent → non-stream // POST /v1beta/models/{model}:streamGenerateContent → stream // default wire format is a JSON array of GenerateContentResponse; // ?alt=sse switches to an SSE stream (preferred by newer SDKs). // The {model} segment can carry dots/dashes; the method is the suffix // after the final ':'. A v1 alias path is accepted too. if (method === 'POST' && GEMINI_GENERATE_PATH_RE.test(path)) { if (!hasUsableAccount()) { return json(res, 503, { error: { code: 503, message: 'No active accounts. POST /auth/login to add accounts.', status: 'UNAVAILABLE' } }); } const parsed = parseGeminiPath(path); if (!parsed) { return json(res, 404, { error: { code: 404, message: `${method} ${path} not found`, status: 'NOT_FOUND' } }); } let body; try { body = JSON.parse(await readBody(req)); } catch (err) { if (sendBodyTooLargeIfNeeded(res, err, 'openai')) return; return json(res, 400, { error: { code: 400, message: 'Invalid JSON', status: 'INVALID_ARGUMENT' } }); } if (!Array.isArray(body.contents) || body.contents.length === 0) { return json(res, 400, { error: { code: 400, message: 'contents must be a non-empty array', status: 'INVALID_ARGUMENT' } }); } const wantStream = parsed.method === 'streamGenerateContent'; const alt = new URL(req.url, 'http://localhost').searchParams.get('alt'); const token = extractToken(req, credentialSurfaceFor(path)); const callerKey = callerKeyFromRequest(req, token, body); const reqStartedAt = Date.now(); const geminiHeaders = { 'request-id': 'req_' + randomUUID() }; const abortController = bindClientAbort(res); const budget = createRequestBudget({ stream: wantStream }); const result = await handleGemini(parsed.model, body, { callerKey, nativeBridgeCallerKey: nativeBridgeCallerKeyForRequest(req, token, body, callerKey), signal: abortController.signal, budget, }, { stream: wantStream, alt }); const processingMs = Date.now() - reqStartedAt; const ringRow = recordRequest({ id: geminiHeaders['request-id'], model: parsed.model, status: result.status, stream: !!result.stream, durationMs: processingMs, class: result.body?.error?.code || (result.status < 400 ? 'ok' : 'error'), caller: callerKey, protocol: 'gemini', }); if (result.stream) { const commitMs = resolveStreamCommitMs(); await serveChatStream(res, result, { headers: { 'Access-Control-Allow-Origin': '*', ...geminiHeaders, ...result.headers }, requestId: geminiHeaders['request-id'], commitMs, protocol: 'gemini', isBootstrap: (obj) => Array.isArray(obj?.candidates) && obj.candidates.length > 0 && obj.candidates.every((c) => !c?.finishReason && (c?.content?.parts || []).every((p) => !p?.text && !p?.functionCall)), parseError: (obj) => (obj?.error && typeof obj.error === 'object' ? obj.error : null), errorBody: (error, status) => geminiError(status, error?.type, error?.message).body, onDone: streamRingDone(ringRow, reqStartedAt, res), }); } else { for (const [k, v] of Object.entries(geminiHeaders)) res.setHeader(k, v); // Same divergence as the Anthropic route above: the stream branch spreads // `result.headers`, this one dropped them, so a non-stream 429 carried no // `Retry-After` for the Gemini SDK to honour. Route-vocabulary headers win on // collision. if (result.headers) { for (const [k, v] of Object.entries(result.headers)) { if (!(k in geminiHeaders)) res.setHeader(k, v); } } json(res, result.status, result.body); } return; } // D4: the Anthropic surface (/v1/messages*) must get an Anthropic-shaped // error body, not the OpenAI {error:{message,type}} shape, so SDK clients // parse it correctly instead of choking on an unknown envelope. if (isAnthropicPath(path)) { return json(res, 404, { type: 'error', error: { type: 'not_found_error', message: `${method} ${path} not found` } }); } json(res, 404, { error: { message: `${method} ${path} not found`, type: 'not_found_error' } }); } // D4: paths under the Anthropic Messages surface. Fallback 404/500 handlers use // this to choose the Anthropic error envelope over the default OpenAI one. function isAnthropicPath(path) { return typeof path === 'string' && path.startsWith('/v1/messages'); } 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 { // One accounting scope per HTTP request: it joins the attempt row // (recordRequest, called at various depths of the chat handlers) with the // usage block (recordTokenUsage, called at a different depth and possibly // before the row) without relying on a shared "last row" pointer that // concurrent requests would race. The final response status is stamped on // the last attempt before the scope closes. await withStatsScope(async (scope) => { try { await route(req, res); } finally { // A throw before headers is answered by the outer catch below; only a // response that actually went out carries a status worth recording. if (res.headersSent || res.writableEnded) scope.status = res.statusCode; finalizeStatsScope(scope); } }); } catch (err) { log.error('Handler error:', err); if (!res.headersSent) { // D4: Anthropic surface gets the Anthropic error envelope; everything // else keeps the OpenAI-shaped {error:{message,type}} body. const path = String(req.url || '').split('?')[0]; if (isAnthropicPath(path)) { json(res, 500, { type: 'error', error: { type: 'api_error', message: 'Internal server error' } }); } else { 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/completions'); log.info(' POST /v1/responses'); log.info(' GET /v1/responses/{id} (retrieve stored)'); log.info(' DEL /v1/responses/{id}'); log.info(' POST /v1/messages (Anthropic)'); log.info(' POST /v1/messages/count_tokens'); log.info(' POST /v1beta/models/{model}:generateContent (Gemini)'); log.info(' POST /v1beta/models/{model}:streamGenerateContent (Gemini)'); 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; }