src/server.js
Rama master · réplica de lectura del repositorio original.
/**
* 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 <key>`, `x-goog-api-key: <key>` or `?key=<key>`'
: credentialSurface === 'models'
? '`Authorization: Bearer <key>`, `x-api-key: <key>`, `x-goog-api-key: <key>` or `?key=<key>`'
: credentialSurface === 'messages' || credentialSurface === 'operator'
? '`Authorization: Bearer <key>` or `x-api-key: <key>`'
: '`Authorization: Bearer <key>`';
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:<hash>` segment
// while a bodyless GET derives `:client:<ip+ua>` — 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;
}