llm-session-proxy 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -401,6 +401,22 @@ You can also take just the parts you need: `createProxyServer` (own the lifecycl
401
401
 
402
402
  ---
403
403
 
404
+ ## Stability
405
+
406
+ - **A single malformed request cannot take the process down.** Synchronous throws along the
407
+ request path are intercepted: an invalid `Host` header, a malformed request line, or an upstream
408
+ status line / header containing illegal characters all become ordinary 4xx / 5xx responses, and
409
+ the proxy keeps serving.
410
+ - **Uncaught errors land in the log file.** A last-resort handler writes the full stack of
411
+ `uncaughtException` and `unhandledRejection` into the file given by `--log-file` (and to stderr).
412
+ Node's default is to print to stderr and terminate immediately — leaving nothing at all in the
413
+ log file, which looks exactly like "the logs are fine, the process just vanished".
414
+ **Always pass `--log-file`**, otherwise process-level clues disappear with the terminal window.
415
+ - 20 uncaught errors within 60 seconds are treated as a persistent fault and the process exits on
416
+ purpose, rather than spinning in a broken state.
417
+
418
+ ---
419
+
404
420
  ## FAQ
405
421
 
406
422
  **The client cannot connect, and the proxy logs nothing**
package/README.md CHANGED
@@ -398,6 +398,18 @@ await proxy.stop();
398
398
 
399
399
  ---
400
400
 
401
+ ## 稳定性
402
+
403
+ - **单个畸形请求不会让进程退出。** 代理拦截了请求处理路径上所有同步抛出:非法的 `Host` 头、
404
+ 畸形请求行、上游返回含非法字符的状态行或响应头,都会被转成对应的 4xx / 5xx 响应,进程继续服务。
405
+ - **未捕获异常会写进日志文件。** 兜底处理器把 `uncaughtException` 与 `unhandledRejection`
406
+ 的完整栈写入 `--log-file` 指定的文件(同时输出到 stderr);Node 默认只打 stderr 然后直接终止进程,
407
+ 日志文件里一个字都不会有,现场看起来就是「日志一切正常,进程凭空消失」。
408
+ **建议始终带上 `--log-file`**,否则进程级问题的线索会随终端窗口一起消失。
409
+ - 60 秒内连续出现 20 次未捕获错误会判定为持续故障并主动退出,避免带着坏状态空转。
410
+
411
+ ---
412
+
401
413
  ## 常见问题
402
414
 
403
415
  **客户端报连接失败 / 代理日志里没有请求记录**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-session-proxy",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "可自定义的 LLM 本地反向代理:自动生成并透传会话 ID、注入任意请求头、重写模型别名与请求路径。零依赖,用于让不支持会话头(如 x-opencode-session)的客户端接上要求该头的上游。",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/cli.js CHANGED
@@ -345,6 +345,46 @@ export function printBanner(logger, config, proxy, url) {
345
345
  if (config.log.file) logger.info(` 日志文件 ${path.resolve(config.log.file)}`);
346
346
  }
347
347
 
348
+ /**
349
+ * 安装进程级兜底。
350
+ *
351
+ * 默认行为下,一个未捕获异常或未处理的 Promise 拒绝会直接终止进程,
352
+ * 而栈信息只打到 stderr —— 日志文件里一个字都不会有,
353
+ * 表现出来就是「日志一切正常,进程却凭空消失」,极难排查。
354
+ * 这里改成:写进日志文件 + stderr,然后**继续运行**。
355
+ * 只有短时间内反复出错(判定为持续故障,再跑下去也没意义)才主动退出。
356
+ */
357
+ export function installProcessGuards(logger) {
358
+ const recent = [];
359
+ const WINDOW_MS = 60_000;
360
+ const LIMIT = 20;
361
+
362
+ const record = (kind, error) => {
363
+ const now = Date.now();
364
+ while (recent.length && now - recent[0] > WINDOW_MS) recent.shift();
365
+ recent.push(now);
366
+
367
+ const detail = error?.stack || error?.message || String(error);
368
+ logger.error(`[${kind}] 未捕获的错误(进程继续运行): ${detail}`);
369
+ try {
370
+ // logger 的文件写入可能因磁盘/权限静默降级,stderr 是最后一道线索
371
+ process.stderr.write(`${kind}: ${detail}\n`);
372
+ } catch {
373
+ /* 连 stderr 都写不进去就只能放弃 */
374
+ }
375
+
376
+ if (recent.length > LIMIT) {
377
+ logger.error(`[${kind}] ${WINDOW_MS / 1000} 秒内已发生 ${recent.length} 次,判定为持续故障,主动退出`);
378
+ process.exit(1);
379
+ }
380
+ };
381
+
382
+ process.on('uncaughtException', (error) => record('uncaughtException', error));
383
+ process.on('unhandledRejection', (reason) =>
384
+ record('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason))),
385
+ );
386
+ }
387
+
348
388
  /**
349
389
  * CLI 主入口。返回时服务器已在后台运行(进程不会退出)。
350
390
  * 错误通过 process.exitCode 表达,避免在测试中强杀进程。
@@ -396,6 +436,7 @@ export async function runCli(argv = process.argv.slice(2)) {
396
436
  }
397
437
 
398
438
  const logger = new Logger({ ...config.log, console: config.log.level !== 'silent' });
439
+ installProcessGuards(logger);
399
440
  const proxy = createProxyServer({ config, logger });
400
441
 
401
442
  let address;
package/src/logger.js CHANGED
@@ -89,7 +89,13 @@ export class Logger {
89
89
  if (this.toConsole) {
90
90
  const color = COLORS[level] || 0;
91
91
  const out = process.stderr.isTTY ? `\u001b[${color}m${line}\u001b[0m` : line;
92
- process.stderr.write(`${out}\n`);
92
+ try {
93
+ // stderr 被关掉(管道断开)时 write 会抛 EPIPE;
94
+ // 日志器绝不能因为写日志而把代理搞挂
95
+ process.stderr.write(`${out}\n`);
96
+ } catch {
97
+ /* 放弃控制台输出,文件通道继续 */
98
+ }
93
99
  }
94
100
  this.#writeFile(line);
95
101
  }
package/src/proxy.js CHANGED
@@ -22,37 +22,74 @@ const HOP_BY_HOP = new Set([
22
22
  const LOCAL_PREFIX = '/__llm_session_proxy__';
23
23
  const ERROR_CAPTURE_BYTES = 4096;
24
24
 
25
+ /**
26
+ * Node 校验 header / statusMessage 用的是 `/[^\t\x20-\x7e\x80-\xff]/`。
27
+ * 这里复刻同一套规则:返回值合法就返回 true。
28
+ */
29
+ const INVALID_HEADER_CHAR = /[^\t\x20-\x7e\x80-\xff]/;
30
+
31
+ function isValidHeaderValue(value) {
32
+ return typeof value === 'string' && !INVALID_HEADER_CHAR.test(value);
33
+ }
34
+
35
+ /** 上游的 reason phrase 不合法时返回 null,交给 Node 用默认短语。 */
36
+ function sanitizeStatusMessage(message) {
37
+ if (typeof message !== 'string' || message === '') return null;
38
+ return INVALID_HEADER_CHAR.test(message) ? null : message;
39
+ }
40
+
41
+ /**
42
+ * 丢掉含非法字符的响应头。
43
+ *
44
+ * 这些头来自上游,Node 的**入站**解析比**出站**写入宽松,所以完全可能出现
45
+ * "能收进来、写不出去"的值;而 writeHead 抛的是同步异常,抛在事件回调里
46
+ * 就是进程级崩溃。宁可丢掉个别头,也不能让进程死。
47
+ */
48
+ function sanitizeResponseHeaders(headers, onDrop) {
49
+ const clean = {};
50
+ for (const [key, value] of Object.entries(headers)) {
51
+ if (Array.isArray(value)) {
52
+ const kept = value.filter((item) => isValidHeaderValue(item));
53
+ if (kept.length) clean[key] = kept;
54
+ else if (kept.length !== value.length) onDrop(key);
55
+ continue;
56
+ }
57
+ if (isValidHeaderValue(value)) clean[key] = value;
58
+ else onDrop(key);
59
+ }
60
+ return clean;
61
+ }
62
+
25
63
  function readBody(req, maxBytes) {
26
64
  return new Promise((resolve, reject) => {
27
65
  const chunks = [];
28
66
  let size = 0;
67
+ let settled = false;
68
+ const fail = (error) => {
69
+ if (settled) return;
70
+ settled = true;
71
+ reject(error);
72
+ };
29
73
  req.on('data', (chunk) => {
74
+ if (settled) return;
30
75
  size += chunk.length;
31
76
  if (size > maxBytes) {
32
77
  const error = new Error(`请求体超过上限 ${maxBytes} 字节`);
33
78
  error.code = 'E_TOO_LARGE';
34
- reject(error);
79
+ fail(error);
35
80
  req.destroy();
36
81
  return;
37
82
  }
38
83
  chunks.push(chunk);
39
84
  });
40
- req.on('end', () => resolve(Buffer.concat(chunks)));
41
- req.on('error', reject);
42
- });
43
- }
44
-
45
- function sendJson(res, status, payload) {
46
- if (res.headersSent) {
47
- res.end();
48
- return;
49
- }
50
- const body = Buffer.from(JSON.stringify(payload), 'utf8');
51
- res.writeHead(status, {
52
- 'content-type': 'application/json; charset=utf-8',
53
- 'content-length': String(body.length),
85
+ req.on('end', () => {
86
+ if (settled) return;
87
+ settled = true;
88
+ resolve(Buffer.concat(chunks));
89
+ });
90
+ req.on('error', fail);
91
+ req.on('aborted', () => fail(Object.assign(new Error('客户端在请求体读完前断开'), { code: 'E_ABORTED' })));
54
92
  });
55
- res.end(body);
56
93
  }
57
94
 
58
95
  function formatBytes(bytes) {
@@ -66,11 +103,16 @@ function formatBytes(bytes) {
66
103
  *
67
104
  * 每个请求的处理顺序:
68
105
  * 读体 -> 解析 JSON -> 解析会话 -> 重写路径/模型 -> 注入头与体 -> 转发 -> 回传响应
106
+ *
107
+ * 稳定性约定(这里是踩过坑的地方):
108
+ * handle() 是同步入口,任何抛出都必须被捕获。http 服务器的事件回调里
109
+ * 冒出来的同步异常没有任何人接得住,Node 会直接终止整个进程——
110
+ * 而且栈只打到 stderr,不进日志文件,表现就是「日志一切正常,进程凭空消失」。
69
111
  */
70
112
  export function createProxyServer({ config, logger }) {
71
113
  const store = new SessionStore(config.session);
72
114
  const upstream = resolveUpstream(config);
73
- const stats = { startedAt: Date.now(), requests: 0, errors: 0, bytesIn: 0, bytesOut: 0 };
115
+ const stats = { startedAt: Date.now(), requests: 0, errors: 0, handledErrors: 0, bytesIn: 0, bytesOut: 0 };
74
116
 
75
117
  const agent =
76
118
  upstream.protocol === 'https'
@@ -86,9 +128,69 @@ export function createProxyServer({ config, logger }) {
86
128
 
87
129
  const guard = (req) => req.socket?.remoteAddress;
88
130
 
131
+ /** 回写 JSON。客户端可能已经断开,写失败只能吞掉,绝不能因此抛出去。 */
132
+ function respondJson(res, status, payload) {
133
+ try {
134
+ if (res.writableEnded || res.destroyed) return;
135
+ if (res.headersSent) {
136
+ res.end();
137
+ return;
138
+ }
139
+ const body = Buffer.from(JSON.stringify(payload), 'utf8');
140
+ res.writeHead(status, {
141
+ 'content-type': 'application/json; charset=utf-8',
142
+ 'content-length': String(body.length),
143
+ });
144
+ res.end(body);
145
+ } catch (error) {
146
+ log.debug(`[client] 回写响应失败(客户端可能已断开): ${error.message}`);
147
+ try {
148
+ res.destroy();
149
+ } catch {
150
+ /* 已经没了就算了 */
151
+ }
152
+ }
153
+ }
154
+
155
+ /** 写响应头。上游的 reason phrase / 响应头可能触发 writeHead 同步抛错,这里兜住。 */
156
+ function writeResponseHead(res, status, statusMessage, headers) {
157
+ const safeMessage = sanitizeStatusMessage(statusMessage);
158
+ if (safeMessage === null && statusMessage) {
159
+ log.debug(`[res] 上游 reason phrase 含非法字符,已改用默认短语: ${JSON.stringify(statusMessage)}`);
160
+ }
161
+ try {
162
+ if (safeMessage) res.writeHead(status, safeMessage, headers);
163
+ else res.writeHead(status, headers);
164
+ return true;
165
+ } catch (error) {
166
+ stats.errors += 1;
167
+ log.warn(`[res] 写响应头失败,丢弃可疑头后重试: ${error.message}`);
168
+ const fallback = sanitizeResponseHeaders(headers, (key) => log.warn(`[res] 丢弃非法响应头: ${key}`));
169
+ try {
170
+ if (res.headersSent) {
171
+ res.end();
172
+ return true;
173
+ }
174
+ const retryMessage = sanitizeStatusMessage(statusMessage);
175
+ if (retryMessage) res.writeHead(status, retryMessage, fallback);
176
+ else res.writeHead(status, fallback);
177
+ return true;
178
+ } catch (retryError) {
179
+ stats.errors += 1;
180
+ log.error(`[res] 响应头仍无法写出,放弃本次响应: ${retryError.message}`);
181
+ try {
182
+ res.destroy();
183
+ } catch {
184
+ /* ignore */
185
+ }
186
+ return false;
187
+ }
188
+ }
189
+ }
190
+
89
191
  function handleLocal(req, res, url) {
90
192
  if (url.pathname === `${LOCAL_PREFIX}/health` || url.pathname === `${LOCAL_PREFIX}/status`) {
91
- sendJson(res, 200, {
193
+ respondJson(res, 200, {
92
194
  ok: true,
93
195
  uptimeSeconds: Math.round((Date.now() - stats.startedAt) / 1000),
94
196
  listen: `${config.listen.host}:${config.listen.port}`,
@@ -111,25 +213,64 @@ export function createProxyServer({ config, logger }) {
111
213
  createdAt: record.createdAt,
112
214
  lastUsed: record.lastUsed,
113
215
  }));
114
- sendJson(res, 200, { count: items.length, items });
216
+ respondJson(res, 200, { count: items.length, items });
115
217
  return true;
116
218
  }
117
219
  return false;
118
220
  }
119
221
 
222
+ /**
223
+ * 同步入口:把所有同步抛出挡在这里,转成 500 而不是进程退出。
224
+ */
120
225
  function handle(req, res) {
226
+ try {
227
+ dispatch(req, res);
228
+ } catch (error) {
229
+ stats.errors += 1;
230
+ stats.handledErrors += 1;
231
+ log.error(
232
+ `[request-failed] ${req.method} ${req.url} 处理请求时抛错(已拦截,进程继续): ` +
233
+ `${error?.stack || error?.message || error}`,
234
+ );
235
+ respondJson(res, 500, {
236
+ error: {
237
+ type: 'proxy_internal_error',
238
+ message: `代理处理请求时出错: ${error?.message || error}`,
239
+ },
240
+ });
241
+ }
242
+ }
243
+
244
+ function dispatch(req, res) {
121
245
  const startedAt = Date.now();
122
- const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
246
+
247
+ // 先挂错误监听:客户端可能在任何时刻断开,晚挂一步就是一个未捕获的 error 事件
248
+ res.on('error', (error) => log.debug('[client] 响应流出错(客户端可能已断开):', error.message));
249
+ req.on('error', (error) => log.debug('[client] 请求流出错:', error.message));
250
+
251
+ let url = null;
252
+ try {
253
+ url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
254
+ } catch {
255
+ // Host 头非法时 new URL 会抛 ERR_INVALID_URL,而它抛在 http 服务器的回调里
256
+ stats.errors += 1;
257
+ stats.handledErrors += 1;
258
+ log.warn(`[req] 无法解析请求目标 ${JSON.stringify(req.url)}(Host=${JSON.stringify(req.headers.host)})`);
259
+ respondJson(res, 400, {
260
+ error: {
261
+ type: 'bad_request_target',
262
+ message: `无法解析请求目标: ${req.url}(Host 头为 ${JSON.stringify(req.headers.host)})`,
263
+ },
264
+ });
265
+ return;
266
+ }
123
267
 
124
268
  if (url.pathname.startsWith(LOCAL_PREFIX)) {
125
269
  if (handleLocal(req, res, url)) return;
126
- sendJson(res, 404, { error: { message: `未知的本地端点: ${url.pathname}` } });
270
+ respondJson(res, 404, { error: { message: `未知的本地端点: ${url.pathname}` } });
127
271
  return;
128
272
  }
129
273
 
130
- res.on('error', (error) => log.debug('[client] 响应流出错(客户端可能已断开):', error.message));
131
- req.on('error', (error) => log.debug('[client] 请求流出错:', error.message));
132
-
133
274
  const headersLower = new Map();
134
275
  for (const [key, value] of Object.entries(req.headers)) {
135
276
  headersLower.set(key.toLowerCase(), Array.isArray(value) ? value.join(', ') : value);
@@ -142,214 +283,284 @@ export function createProxyServer({ config, logger }) {
142
283
  )
143
284
  : Promise.resolve({ buffer: null });
144
285
 
145
- bodyPromise.then(({ buffer, error }) => {
146
- if (error) {
147
- stats.errors += 1;
148
- log.warn(`[req] ${req.method} ${req.url} 读取请求体失败: ${error.message}`);
149
- sendJson(res, 413, { error: { type: 'request_too_large', message: error.message } });
150
- return;
151
- }
286
+ bodyPromise
287
+ .then(({ buffer, error }) => {
288
+ if (error) {
289
+ stats.errors += 1;
290
+ const tooLarge = error.code === 'E_TOO_LARGE';
291
+ log.warn(`[req] ${req.method} ${req.url} 读取请求体失败: ${error.message}`);
292
+ respondJson(res, tooLarge ? 413 : 400, {
293
+ error: {
294
+ type: tooLarge ? 'request_too_large' : 'request_body_incomplete',
295
+ message: error.message,
296
+ },
297
+ });
298
+ return;
299
+ }
152
300
 
153
- // ---- 解析请求体 ----
154
- const contentType = (headersLower.get('content-type') || '').toLowerCase();
155
- let parsedBody = null;
156
- if (buffer && buffer.length && contentType.includes('json')) {
157
- try {
158
- const candidate = JSON.parse(buffer.toString('utf8'));
159
- if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) parsedBody = candidate;
160
- } catch {
161
- parsedBody = null;
301
+ // ---- 解析请求体 ----
302
+ const contentType = (headersLower.get('content-type') || '').toLowerCase();
303
+ let parsedBody = null;
304
+ if (buffer && buffer.length && contentType.includes('json')) {
305
+ try {
306
+ const candidate = JSON.parse(buffer.toString('utf8'));
307
+ if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) parsedBody = candidate;
308
+ } catch {
309
+ parsedBody = null;
310
+ }
162
311
  }
163
- }
164
312
 
165
- // ---- 会话解析 ----
166
- const headersObject = Object.fromEntries(headersLower);
167
- const session = resolveSession({ headers: headersObject, body: parsedBody, config, store });
168
-
169
- // ---- 组装模板上下文 ----
170
- const context = createContext({
171
- session: {
172
- id: session.id,
173
- count: session.count,
174
- requestId: session.requestId,
175
- key: session.key,
176
- source: session.source,
177
- },
178
- model: typeof parsedBody?.model === 'string' ? parsedBody.model : '',
179
- path: req.url,
180
- method: req.method,
181
- header: headersObject,
182
- query: Object.fromEntries(url.searchParams),
183
- });
313
+ // ---- 会话解析 ----
314
+ const headersObject = Object.fromEntries(headersLower);
315
+ const session = resolveSession({ headers: headersObject, body: parsedBody, config, store });
316
+
317
+ // ---- 组装模板上下文 ----
318
+ const context = createContext({
319
+ session: {
320
+ id: session.id,
321
+ count: session.count,
322
+ requestId: session.requestId,
323
+ key: session.key,
324
+ source: session.source,
325
+ },
326
+ model: typeof parsedBody?.model === 'string' ? parsedBody.model : '',
327
+ path: req.url,
328
+ method: req.method,
329
+ header: headersObject,
330
+ query: Object.fromEntries(url.searchParams),
331
+ });
184
332
 
185
- // ---- 重写路径 ----
186
- let targetPath = rewritePath(req.url, config.request.pathRewrite);
187
- if (upstream.basePath) {
188
- targetPath = `${upstream.basePath.replace(/\/+$/, '')}${
189
- targetPath.startsWith('/') ? targetPath : `/${targetPath}`
190
- }`;
191
- }
333
+ // ---- 重写路径 ----
334
+ let targetPath = rewritePath(req.url, config.request.pathRewrite);
335
+ if (upstream.basePath) {
336
+ targetPath = `${upstream.basePath.replace(/\/+$/, '')}${
337
+ targetPath.startsWith('/') ? targetPath : `/${targetPath}`
338
+ }`;
339
+ }
192
340
 
193
- // ---- 重写模型名 / 注入请求体参数 ----
194
- let outBuffer = buffer;
195
- let modelNote = '';
196
- const bodyChanges = [];
197
- if (parsedBody) {
198
- const modelResult = rewriteModel(parsedBody, config.model, { logger: log });
199
- if (modelResult.changed) {
200
- modelNote = ` model=${modelResult.from}->${modelResult.to}`;
201
- context.model = modelResult.to;
341
+ // ---- 重写模型名 / 注入请求体参数 ----
342
+ let outBuffer = buffer;
343
+ let modelNote = '';
344
+ const bodyChanges = [];
345
+ if (parsedBody) {
346
+ const modelResult = rewriteModel(parsedBody, config.model, { logger: log });
347
+ if (modelResult.changed) {
348
+ modelNote = ` model=${modelResult.from}->${modelResult.to}`;
349
+ context.model = modelResult.to;
350
+ }
351
+ const bodyResult = applyBodyInject(parsedBody, config.inject, context);
352
+ if (bodyResult.changed) bodyChanges.push(...bodyResult.changes);
353
+ if (modelResult.changed || bodyResult.changed) {
354
+ // 改了 body 就必须重算长度,交给 http 模块自动处理
355
+ outBuffer = Buffer.from(JSON.stringify(parsedBody), 'utf8');
356
+ }
202
357
  }
203
- const bodyResult = applyBodyInject(parsedBody, config.inject, context);
204
- if (bodyResult.changed) bodyChanges.push(...bodyResult.changes);
205
- if (modelResult.changed || bodyResult.changed) {
206
- // 改了 body 就必须重算长度,交给 http 模块自动处理
207
- outBuffer = Buffer.from(JSON.stringify(parsedBody), 'utf8');
358
+
359
+ // ---- 组装上游请求头 ----
360
+ const outHeaders = {};
361
+ for (const [key, value] of Object.entries(req.headers)) {
362
+ const lower = key.toLowerCase();
363
+ if (HOP_BY_HOP.has(lower) || lower === 'host' || lower === 'content-length') continue;
364
+ if ((config.request.dropHeaders || []).includes(lower)) continue;
365
+ if (!config.request.forwardClientSessionHeaders && config.session.headerNames.includes(lower)) continue;
366
+ outHeaders[key] = value;
208
367
  }
209
- }
210
368
 
211
- // ---- 组装上游请求头 ----
212
- const outHeaders = {};
213
- for (const [key, value] of Object.entries(req.headers)) {
214
- const lower = key.toLowerCase();
215
- if (HOP_BY_HOP.has(lower) || lower === 'host' || lower === 'content-length') continue;
216
- if ((config.request.dropHeaders || []).includes(lower)) continue;
217
- if (!config.request.forwardClientSessionHeaders && config.session.headerNames.includes(lower)) continue;
218
- outHeaders[key] = value;
219
- }
369
+ const incomingUA = headersLower.get('user-agent');
370
+ if (shouldReplaceUserAgent(incomingUA, config)) {
371
+ if (config.userAgent) outHeaders['user-agent'] = config.userAgent;
372
+ } else if (incomingUA) {
373
+ outHeaders['user-agent'] = incomingUA;
374
+ }
220
375
 
221
- const incomingUA = headersLower.get('user-agent');
222
- if (shouldReplaceUserAgent(incomingUA, config)) {
223
- if (config.userAgent) outHeaders['user-agent'] = config.userAgent;
224
- } else if (incomingUA) {
225
- outHeaders['user-agent'] = incomingUA;
226
- }
376
+ Object.assign(outHeaders, buildInjectHeaders(config.inject, context, headersLower));
227
377
 
228
- Object.assign(outHeaders, buildInjectHeaders(config.inject, context, headersLower));
378
+ const hostValue =
379
+ config.upstream.rewriteHost === false ? req.headers.host || upstream.hostHeader : upstream.hostHeader;
380
+ outHeaders.host = hostValue;
229
381
 
230
- const hostValue =
231
- config.upstream.rewriteHost === false ? req.headers.host || upstream.hostHeader : upstream.hostHeader;
232
- outHeaders.host = hostValue;
382
+ log.info(
383
+ `[req] ${req.method} ${req.url} | session=${session.id} req=${session.requestId} source=${session.source} ` +
384
+ `sessions=${store.size} auth=${headersLower.has('authorization') ? 'present' : 'MISSING'}` +
385
+ `${modelNote}${bodyChanges.length ? ` body=${bodyChanges.join('|')}` : ''}`,
386
+ );
387
+ log.debug(`[req-headers] ${JSON.stringify(outHeaders, null, 0)}`);
233
388
 
234
- log.info(
235
- `[req] ${req.method} ${req.url} | session=${session.id} req=${session.requestId} source=${session.source} ` +
236
- `sessions=${store.size} auth=${headersLower.has('authorization') ? 'present' : 'MISSING'}` +
237
- `${modelNote}${bodyChanges.length ? ` body=${bodyChanges.join('|')}` : ''}`,
238
- );
239
- log.debug(`[req-headers] ${JSON.stringify(outHeaders, null, 0)}`);
240
-
241
- // ---- 发起上游请求 ----
242
- const transport = upstream.protocol === 'https' ? https : http;
243
- const proxyReq = transport.request(
244
- {
245
- protocol: `${upstream.protocol}:`,
246
- host: upstream.host,
247
- port: upstream.port,
248
- method: req.method,
249
- path: targetPath,
250
- headers: outHeaders,
251
- agent,
252
- },
253
- (proxyRes) => {
254
- const status = proxyRes.statusCode || 502;
255
- const resHeaders = {};
256
- for (const [key, value] of Object.entries(proxyRes.headers)) {
257
- const lower = key.toLowerCase();
258
- if (HOP_BY_HOP.has(lower)) continue;
259
- resHeaders[key] = value;
260
- }
261
- resHeaders['x-llm-session-proxy-session'] = session.id || 'disabled';
262
- if (session.requestId) resHeaders['x-llm-session-proxy-request'] = session.requestId;
263
-
264
- let captured = Buffer.alloc(0);
265
- let received = 0;
266
- const capture = (chunk) => {
267
- received += chunk.length;
268
- stats.bytesOut += chunk.length;
269
- if (captured.length < ERROR_CAPTURE_BYTES) {
270
- captured = Buffer.concat([captured, chunk.subarray(0, ERROR_CAPTURE_BYTES - captured.length)]);
271
- }
272
- };
273
-
274
- if (config.response.stream) {
275
- res.writeHead(status, proxyRes.statusMessage, resHeaders);
276
- if (res.socket) res.socket.setNoDelay(true);
277
- proxyRes.on('data', capture);
278
- proxyRes.on('error', (streamError) => {
279
- log.warn(`[res] 上游响应流中断: ${streamError.message}`);
280
- res.destroy();
281
- });
282
- proxyRes.pipe(res);
283
- res.on('close', () => {
284
- if (!res.writableEnded) {
285
- proxyRes.destroy();
286
- }
389
+ // ---- 发起上游请求 ----
390
+ const transport = upstream.protocol === 'https' ? https : http;
391
+ let proxyReq;
392
+ try {
393
+ proxyReq = transport.request(
394
+ {
395
+ protocol: `${upstream.protocol}:`,
396
+ host: upstream.host,
397
+ port: upstream.port,
398
+ method: req.method,
399
+ path: targetPath,
400
+ headers: outHeaders,
401
+ agent,
402
+ },
403
+ (proxyRes) => handleUpstreamResponse({ proxyRes, res, req, session, startedAt, targetPath }),
404
+ );
405
+ } catch (error) {
406
+ // 请求头含非法字符、目标路径未转义等情况会让 transport.request 同步抛错
407
+ stats.errors += 1;
408
+ log.error(`[proxy-error] 组装上游请求失败 ${req.method} ${req.url} -> ${targetPath}: ${error.message}`);
409
+ respondJson(res, 502, {
410
+ error: {
411
+ type: 'proxy_request_build_error',
412
+ message: `无法构造上游请求: ${error.message}`,
413
+ upstream: `${upstream.protocol}://${upstream.hostHeader}${targetPath}`,
414
+ },
415
+ });
416
+ return;
417
+ }
418
+
419
+ stats.bytesIn += outBuffer ? outBuffer.length : 0;
420
+
421
+ proxyReq.setTimeout(config.request.timeoutMs, () => {
422
+ proxyReq.destroy(new Error(`上游 ${config.request.timeoutMs}ms 未响应,已超时`));
423
+ });
424
+
425
+ proxyReq.on('error', (proxyError) => {
426
+ stats.errors += 1;
427
+ log.error(`[proxy-error] ${req.method} ${req.url} -> ${targetPath}: ${proxyError.message}`);
428
+ if (!res.headersSent) {
429
+ respondJson(res, 502, {
430
+ error: {
431
+ type: 'proxy_upstream_error',
432
+ message: `无法连接上游 ${upstream.protocol}://${upstream.hostHeader}: ${proxyError.message}`,
433
+ upstream: `${upstream.protocol}://${upstream.hostHeader}${targetPath}`,
434
+ },
287
435
  });
288
436
  } else {
289
- const chunks = [];
290
- proxyRes.on('data', (chunk) => {
291
- capture(chunk);
292
- chunks.push(chunk);
293
- });
294
- proxyRes.on('end', () => {
295
- const payload = Buffer.concat(chunks);
296
- // 读到的就是压缩后的完整字节,所以只修正长度,content-encoding 必须保留
297
- resHeaders['content-length'] = String(payload.length);
298
- res.writeHead(status, proxyRes.statusMessage, resHeaders);
299
- res.end(payload);
300
- });
301
- proxyRes.on('error', (streamError) => {
302
- log.warn(`[res] 上游响应读取失败: ${streamError.message}`);
303
- if (!res.headersSent) sendJson(res, 502, { error: { message: streamError.message } });
304
- else res.destroy();
305
- });
306
- }
307
-
308
- proxyRes.on('end', () => {
309
- stats.requests += 1;
310
- if (status >= 400) {
311
- stats.errors += 1;
312
- log.error(
313
- `[upstream-error] ${status} ${req.method} ${targetPath} | ${captured
314
- .toString('utf8')
315
- .replace(/\s+/g, ' ')
316
- .slice(0, 800)}`,
317
- );
437
+ try {
438
+ res.destroy();
439
+ } catch {
440
+ /* ignore */
318
441
  }
319
- log.info(
320
- `[res] ${status} ${req.method} ${req.url} | session=${session.id} ${formatBytes(received)} ` +
321
- `${Date.now() - startedAt}ms stream=${config.response.stream}`,
322
- );
323
- });
324
- },
325
- );
442
+ }
443
+ });
326
444
 
327
- stats.bytesIn += outBuffer ? outBuffer.length : 0;
445
+ req.on('aborted', () => proxyReq.destroy(new Error('客户端中断了请求')));
328
446
 
329
- proxyReq.setTimeout(config.request.timeoutMs, () => {
330
- proxyReq.destroy(new Error(`上游 ${config.request.timeoutMs}ms 未响应,已超时`));
447
+ try {
448
+ if (outBuffer && outBuffer.length) proxyReq.write(outBuffer);
449
+ proxyReq.end();
450
+ } catch (error) {
451
+ stats.errors += 1;
452
+ log.error(`[proxy-error] 写入上游请求失败: ${error.message}`);
453
+ proxyReq.destroy(error);
454
+ }
455
+ })
456
+ .catch((error) => {
457
+ // 兜住回调里任何没被内层 try 覆盖的抛出,避免变成 unhandledRejection
458
+ stats.errors += 1;
459
+ stats.handledErrors += 1;
460
+ log.error(`[request-failed] ${req.method} ${req.url} 处理请求时抛错(已拦截,进程继续): ${error?.stack || error}`);
461
+ respondJson(res, 500, {
462
+ error: { type: 'proxy_internal_error', message: `代理处理请求时出错: ${error?.message || error}` },
463
+ });
331
464
  });
465
+ }
332
466
 
333
- proxyReq.on('error', (proxyError) => {
334
- stats.errors += 1;
335
- log.error(`[proxy-error] ${req.method} ${req.url} -> ${targetPath}: ${proxyError.message}`);
467
+ /** 上游响应回来之后的处理,单独成函数以便整体兜错。 */
468
+ function handleUpstreamResponse({ proxyRes, res, req, session, startedAt, targetPath }) {
469
+ const status = proxyRes.statusCode || 502;
470
+ const rawHeaders = {};
471
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
472
+ const lower = key.toLowerCase();
473
+ if (HOP_BY_HOP.has(lower)) continue;
474
+ rawHeaders[key] = value;
475
+ }
476
+ rawHeaders['x-llm-session-proxy-session'] = session.id || 'disabled';
477
+ if (session.requestId) rawHeaders['x-llm-session-proxy-request'] = session.requestId;
478
+
479
+ const resHeaders = sanitizeResponseHeaders(rawHeaders, (key) =>
480
+ log.warn(`[res] 上游响应头含非法字符,已丢弃: ${key}`),
481
+ );
482
+
483
+ let captured = Buffer.alloc(0);
484
+ let received = 0;
485
+ const capture = (chunk) => {
486
+ received += chunk.length;
487
+ stats.bytesOut += chunk.length;
488
+ if (captured.length < ERROR_CAPTURE_BYTES) {
489
+ captured = Buffer.concat([captured, chunk.subarray(0, ERROR_CAPTURE_BYTES - captured.length)]);
490
+ }
491
+ };
492
+
493
+ proxyRes.on('error', (streamError) => {
494
+ log.warn(`[res] 上游响应流出错: ${streamError.message}`);
495
+ try {
496
+ if (res.writableEnded) return;
336
497
  if (!res.headersSent) {
337
- sendJson(res, 502, {
338
- error: {
339
- type: 'proxy_upstream_error',
340
- message: `无法连接上游 ${upstream.protocol}://${upstream.hostHeader}: ${proxyError.message}`,
341
- upstream: `${upstream.protocol}://${upstream.hostHeader}${targetPath}`,
342
- },
343
- });
498
+ respondJson(res, 502, { error: { type: 'proxy_upstream_stream_error', message: streamError.message } });
344
499
  } else {
345
500
  res.destroy();
346
501
  }
347
- });
502
+ } catch {
503
+ /* ignore */
504
+ }
505
+ });
348
506
 
349
- req.on('aborted', () => proxyReq.destroy(new Error('客户端中断了请求')));
507
+ if (config.response.stream) {
508
+ if (!writeResponseHead(res, status, proxyRes.statusMessage, resHeaders)) {
509
+ proxyRes.destroy();
510
+ return;
511
+ }
512
+ if (res.socket) res.socket.setNoDelay(true);
513
+ proxyRes.on('data', capture);
514
+ try {
515
+ proxyRes.pipe(res);
516
+ } catch (error) {
517
+ log.warn(`[res] 管道连接失败: ${error.message}`);
518
+ proxyRes.destroy();
519
+ res.destroy();
520
+ }
521
+ res.on('close', () => {
522
+ if (!res.writableEnded) proxyRes.destroy();
523
+ });
524
+ } else {
525
+ const chunks = [];
526
+ proxyRes.on('data', (chunk) => {
527
+ capture(chunk);
528
+ chunks.push(chunk);
529
+ });
530
+ proxyRes.on('end', () => {
531
+ try {
532
+ const payload = Buffer.concat(chunks);
533
+ // 读到的就是压缩后的完整字节,所以只修正长度,content-encoding 必须保留
534
+ resHeaders['content-length'] = String(payload.length);
535
+ if (!writeResponseHead(res, status, proxyRes.statusMessage, resHeaders)) return;
536
+ res.end(payload);
537
+ } catch (error) {
538
+ stats.errors += 1;
539
+ log.error(`[res] 回写缓冲响应失败: ${error.message}`);
540
+ try {
541
+ res.destroy();
542
+ } catch {
543
+ /* ignore */
544
+ }
545
+ }
546
+ });
547
+ }
350
548
 
351
- if (outBuffer && outBuffer.length) proxyReq.write(outBuffer);
352
- proxyReq.end();
549
+ proxyRes.on('end', () => {
550
+ stats.requests += 1;
551
+ if (status >= 400) {
552
+ stats.errors += 1;
553
+ log.error(
554
+ `[upstream-error] ${status} ${req.method} ${targetPath} | ${captured
555
+ .toString('utf8')
556
+ .replace(/\s+/g, ' ')
557
+ .slice(0, 800)}`,
558
+ );
559
+ }
560
+ log.info(
561
+ `[res] ${status} ${req.method} ${req.url} | session=${session.id} ${formatBytes(received)} ` +
562
+ `${Date.now() - startedAt}ms stream=${config.response.stream}`,
563
+ );
353
564
  });
354
565
  }
355
566
 
@@ -358,6 +569,27 @@ export function createProxyServer({ config, logger }) {
358
569
  server.headersTimeout = 70000;
359
570
  server.requestTimeout = 0;
360
571
 
572
+ // 客户端发了畸形请求(非法请求行/头)时 Node 会发 clientError。
573
+ // 没有监听者时它会用自己的默认处理,这里显式接管并保证 socket 一定被关掉。
574
+ server.on('clientError', (error, socket) => {
575
+ log.debug(`[client] 解析请求失败: ${error?.code || error?.message}`);
576
+ // 复刻 Node 默认处理的语义:头太大回 431,其余畸形请求回 400
577
+ const overflow = error?.code === 'HPE_HEADER_OVERFLOW';
578
+ const status = overflow
579
+ ? 'HTTP/1.1 431 Request Header Fields Too Large'
580
+ : 'HTTP/1.1 400 Bad Request';
581
+ try {
582
+ if (socket.destroyed || !socket.writable) return;
583
+ socket.end(`${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
584
+ } catch {
585
+ try {
586
+ socket.destroy();
587
+ } catch {
588
+ /* ignore */
589
+ }
590
+ }
591
+ });
592
+
361
593
  return {
362
594
  server,
363
595
  store,
@@ -367,9 +599,19 @@ export function createProxyServer({ config, logger }) {
367
599
  agent,
368
600
  listen(port = config.listen.port, host = config.listen.host) {
369
601
  return new Promise((resolve, reject) => {
370
- server.once('error', reject);
602
+ const onStartupError = (error) => {
603
+ server.removeListener('error', onStartupError);
604
+ reject(error);
605
+ };
606
+ server.once('error', onStartupError);
371
607
  server.listen(port, host, () => {
372
- server.removeListener('error', reject);
608
+ server.removeListener('error', onStartupError);
609
+ // 启动成功后挂常驻错误处理:监听之后再冒出来的 error 事件
610
+ // 若无人接管,一样会直接终止进程
611
+ server.on('error', (error) => {
612
+ stats.errors += 1;
613
+ log.error(`[server] 服务器错误(进程继续): ${error?.stack || error?.message || error}`);
614
+ });
373
615
  resolve(server.address());
374
616
  });
375
617
  });