llm-session-proxy 0.1.0
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/LICENSE +201 -0
- package/README.en.md +466 -0
- package/README.md +471 -0
- package/bin/llm-session-proxy.js +7 -0
- package/examples/generic-openai.json +76 -0
- package/examples/opencode-go-models.json +116 -0
- package/examples/opencode-go.json +69 -0
- package/package.json +60 -0
- package/src/cli.js +425 -0
- package/src/config.js +310 -0
- package/src/index.js +107 -0
- package/src/inject.js +141 -0
- package/src/logger.js +128 -0
- package/src/proxy.js +386 -0
- package/src/session.js +269 -0
- package/src/template.js +91 -0
package/src/proxy.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { resolveUpstream, shouldReplaceUserAgent } from './config.js';
|
|
4
|
+
import { applyBodyInject, buildInjectHeaders, rewriteModel, rewritePath } from './inject.js';
|
|
5
|
+
import { resolveSession, SessionStore } from './session.js';
|
|
6
|
+
import { createContext } from './template.js';
|
|
7
|
+
|
|
8
|
+
/** 逐跳头不能转发给上游,也不能回给客户端。 */
|
|
9
|
+
const HOP_BY_HOP = new Set([
|
|
10
|
+
'connection',
|
|
11
|
+
'keep-alive',
|
|
12
|
+
'proxy-authenticate',
|
|
13
|
+
'proxy-authorization',
|
|
14
|
+
'proxy-connection',
|
|
15
|
+
'te',
|
|
16
|
+
'trailer',
|
|
17
|
+
'transfer-encoding',
|
|
18
|
+
'upgrade',
|
|
19
|
+
'expect',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const LOCAL_PREFIX = '/__llm_session_proxy__';
|
|
23
|
+
const ERROR_CAPTURE_BYTES = 4096;
|
|
24
|
+
|
|
25
|
+
function readBody(req, maxBytes) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
let size = 0;
|
|
29
|
+
req.on('data', (chunk) => {
|
|
30
|
+
size += chunk.length;
|
|
31
|
+
if (size > maxBytes) {
|
|
32
|
+
const error = new Error(`请求体超过上限 ${maxBytes} 字节`);
|
|
33
|
+
error.code = 'E_TOO_LARGE';
|
|
34
|
+
reject(error);
|
|
35
|
+
req.destroy();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
chunks.push(chunk);
|
|
39
|
+
});
|
|
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),
|
|
54
|
+
});
|
|
55
|
+
res.end(body);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatBytes(bytes) {
|
|
59
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
60
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
61
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 创建代理服务器。
|
|
66
|
+
*
|
|
67
|
+
* 每个请求的处理顺序:
|
|
68
|
+
* 读体 -> 解析 JSON -> 解析会话 -> 重写路径/模型 -> 注入头与体 -> 转发 -> 回传响应
|
|
69
|
+
*/
|
|
70
|
+
export function createProxyServer({ config, logger }) {
|
|
71
|
+
const store = new SessionStore(config.session);
|
|
72
|
+
const upstream = resolveUpstream(config);
|
|
73
|
+
const stats = { startedAt: Date.now(), requests: 0, errors: 0, bytesIn: 0, bytesOut: 0 };
|
|
74
|
+
|
|
75
|
+
const agent =
|
|
76
|
+
upstream.protocol === 'https'
|
|
77
|
+
? new https.Agent({ keepAlive: true, maxSockets: 128, keepAliveMsecs: 30000 })
|
|
78
|
+
: new http.Agent({ keepAlive: true, maxSockets: 128, keepAliveMsecs: 30000 });
|
|
79
|
+
|
|
80
|
+
const log = {
|
|
81
|
+
error: (...a) => logger.error(...a),
|
|
82
|
+
warn: (...a) => logger.warn(...a),
|
|
83
|
+
info: (...a) => logger.info(...a),
|
|
84
|
+
debug: (...a) => logger.debug(...a),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const guard = (req) => req.socket?.remoteAddress;
|
|
88
|
+
|
|
89
|
+
function handleLocal(req, res, url) {
|
|
90
|
+
if (url.pathname === `${LOCAL_PREFIX}/health` || url.pathname === `${LOCAL_PREFIX}/status`) {
|
|
91
|
+
sendJson(res, 200, {
|
|
92
|
+
ok: true,
|
|
93
|
+
uptimeSeconds: Math.round((Date.now() - stats.startedAt) / 1000),
|
|
94
|
+
listen: `${config.listen.host}:${config.listen.port}`,
|
|
95
|
+
upstream: `${upstream.protocol}://${upstream.hostHeader}${upstream.basePath}`,
|
|
96
|
+
sessions: { active: store.size, hits: store.hits, misses: store.misses },
|
|
97
|
+
stats: { ...stats },
|
|
98
|
+
inject: {
|
|
99
|
+
headers: Object.keys(config.inject.headers || {}),
|
|
100
|
+
stripPrefixes: config.model.stripPrefixes,
|
|
101
|
+
},
|
|
102
|
+
configPath: config.__configPath || null,
|
|
103
|
+
});
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (url.pathname === `${LOCAL_PREFIX}/sessions`) {
|
|
107
|
+
const items = [...store.map.entries()].map(([key, record]) => ({
|
|
108
|
+
key,
|
|
109
|
+
id: record.id,
|
|
110
|
+
count: record.count,
|
|
111
|
+
createdAt: record.createdAt,
|
|
112
|
+
lastUsed: record.lastUsed,
|
|
113
|
+
}));
|
|
114
|
+
sendJson(res, 200, { count: items.length, items });
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handle(req, res) {
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
123
|
+
|
|
124
|
+
if (url.pathname.startsWith(LOCAL_PREFIX)) {
|
|
125
|
+
if (handleLocal(req, res, url)) return;
|
|
126
|
+
sendJson(res, 404, { error: { message: `未知的本地端点: ${url.pathname}` } });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
res.on('error', (error) => log.debug('[client] 响应流出错(客户端可能已断开):', error.message));
|
|
131
|
+
req.on('error', (error) => log.debug('[client] 请求流出错:', error.message));
|
|
132
|
+
|
|
133
|
+
const headersLower = new Map();
|
|
134
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
135
|
+
headersLower.set(key.toLowerCase(), Array.isArray(value) ? value.join(', ') : value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const bodyPromise = config.request.bufferBody
|
|
139
|
+
? readBody(req, config.request.maxBodyBytes).then(
|
|
140
|
+
(buffer) => ({ buffer }),
|
|
141
|
+
(error) => ({ error }),
|
|
142
|
+
)
|
|
143
|
+
: Promise.resolve({ buffer: null });
|
|
144
|
+
|
|
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
|
+
}
|
|
152
|
+
|
|
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;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
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
|
+
});
|
|
184
|
+
|
|
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
|
+
}
|
|
192
|
+
|
|
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;
|
|
202
|
+
}
|
|
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');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
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
|
+
}
|
|
220
|
+
|
|
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
|
+
}
|
|
227
|
+
|
|
228
|
+
Object.assign(outHeaders, buildInjectHeaders(config.inject, context, headersLower));
|
|
229
|
+
|
|
230
|
+
const hostValue =
|
|
231
|
+
config.upstream.rewriteHost === false ? req.headers.host || upstream.hostHeader : upstream.hostHeader;
|
|
232
|
+
outHeaders.host = hostValue;
|
|
233
|
+
|
|
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
|
+
}
|
|
287
|
+
});
|
|
288
|
+
} 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
|
+
);
|
|
318
|
+
}
|
|
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
|
+
);
|
|
326
|
+
|
|
327
|
+
stats.bytesIn += outBuffer ? outBuffer.length : 0;
|
|
328
|
+
|
|
329
|
+
proxyReq.setTimeout(config.request.timeoutMs, () => {
|
|
330
|
+
proxyReq.destroy(new Error(`上游 ${config.request.timeoutMs}ms 未响应,已超时`));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
proxyReq.on('error', (proxyError) => {
|
|
334
|
+
stats.errors += 1;
|
|
335
|
+
log.error(`[proxy-error] ${req.method} ${req.url} -> ${targetPath}: ${proxyError.message}`);
|
|
336
|
+
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
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
res.destroy();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
req.on('aborted', () => proxyReq.destroy(new Error('客户端中断了请求')));
|
|
350
|
+
|
|
351
|
+
if (outBuffer && outBuffer.length) proxyReq.write(outBuffer);
|
|
352
|
+
proxyReq.end();
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const server = http.createServer(handle);
|
|
357
|
+
server.keepAliveTimeout = 65000;
|
|
358
|
+
server.headersTimeout = 70000;
|
|
359
|
+
server.requestTimeout = 0;
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
server,
|
|
363
|
+
store,
|
|
364
|
+
upstream,
|
|
365
|
+
stats,
|
|
366
|
+
config,
|
|
367
|
+
agent,
|
|
368
|
+
listen(port = config.listen.port, host = config.listen.host) {
|
|
369
|
+
return new Promise((resolve, reject) => {
|
|
370
|
+
server.once('error', reject);
|
|
371
|
+
server.listen(port, host, () => {
|
|
372
|
+
server.removeListener('error', reject);
|
|
373
|
+
resolve(server.address());
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
},
|
|
377
|
+
close() {
|
|
378
|
+
agent.destroy();
|
|
379
|
+
return new Promise((resolve) => {
|
|
380
|
+
server.close(() => resolve());
|
|
381
|
+
server.closeAllConnections?.();
|
|
382
|
+
});
|
|
383
|
+
},
|
|
384
|
+
guard,
|
|
385
|
+
};
|
|
386
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { randomBase36, randomHex, renderTemplate } from './template.js';
|
|
3
|
+
|
|
4
|
+
const SAFE_ID_RE = /^[A-Za-z0-9_.:@|+-]+$/;
|
|
5
|
+
|
|
6
|
+
/** 会话 ID 必须是安全字符且长度受限,否则视为客户端未提供。 */
|
|
7
|
+
export function normalizeSessionId(value, maxLength = 128) {
|
|
8
|
+
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
|
9
|
+
const text = String(value).trim();
|
|
10
|
+
if (!text || text.length > maxLength) return null;
|
|
11
|
+
if (!SAFE_ID_RE.test(text)) return null;
|
|
12
|
+
return text;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 生成上游风格的会话 ID,如 `ses_` + 26 位十六进制。 */
|
|
16
|
+
export function generateId({ prefix = 'ses_', format = 'hex26' } = {}) {
|
|
17
|
+
switch (format) {
|
|
18
|
+
case 'uuid':
|
|
19
|
+
return `${prefix}${crypto.randomUUID()}`;
|
|
20
|
+
case 'hex':
|
|
21
|
+
case 'hex26':
|
|
22
|
+
return `${prefix}${randomHex(26)}`;
|
|
23
|
+
case 'base36':
|
|
24
|
+
return `${prefix}${randomBase36(26)}`;
|
|
25
|
+
case 'short':
|
|
26
|
+
return `${prefix}${randomHex(16)}`;
|
|
27
|
+
default:
|
|
28
|
+
return `${prefix}${randomHex(26)}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getByPath(object, path) {
|
|
33
|
+
let cursor = object;
|
|
34
|
+
for (const segment of path.split('.')) {
|
|
35
|
+
if (cursor === null || cursor === undefined || typeof cursor !== 'object') return undefined;
|
|
36
|
+
cursor = cursor[segment];
|
|
37
|
+
}
|
|
38
|
+
return cursor;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractText(content) {
|
|
42
|
+
if (typeof content === 'string') return content;
|
|
43
|
+
if (Array.isArray(content)) {
|
|
44
|
+
return content
|
|
45
|
+
.map((part) => {
|
|
46
|
+
if (typeof part === 'string') return part;
|
|
47
|
+
if (part && typeof part === 'object') return part.text ?? part.content ?? JSON.stringify(part);
|
|
48
|
+
return '';
|
|
49
|
+
})
|
|
50
|
+
.join('\n');
|
|
51
|
+
}
|
|
52
|
+
if (content && typeof content === 'object') return JSON.stringify(content);
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function firstUserMessage(body) {
|
|
57
|
+
const messages = body?.messages;
|
|
58
|
+
if (Array.isArray(messages)) {
|
|
59
|
+
for (const message of messages) {
|
|
60
|
+
if (message && typeof message === 'object' && message.role === 'user') return message;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(body?.input)) {
|
|
65
|
+
for (const item of body.input) {
|
|
66
|
+
if (item && typeof item === 'object' && (item.role === 'user' || item.type === 'message')) return item;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 从请求内容推导稳定的会话指纹:system + 首条 user 消息 + 可选 prompt。
|
|
74
|
+
* 同一对话这些内容相对稳定,因此能落回同一个 session ID,提示词缓存才有意义。
|
|
75
|
+
*/
|
|
76
|
+
export function contentFingerprint(body, options = {}) {
|
|
77
|
+
const {
|
|
78
|
+
fields = ['system', 'system_instruction', 'instructions'],
|
|
79
|
+
includeFirstUserMessage = true,
|
|
80
|
+
includePromptField = true,
|
|
81
|
+
} = options;
|
|
82
|
+
|
|
83
|
+
const anchor = [];
|
|
84
|
+
for (const field of fields) {
|
|
85
|
+
const value = getByPath(body, field);
|
|
86
|
+
if (value !== undefined && value !== null) anchor.push(`${field}=${extractText(value)}`);
|
|
87
|
+
}
|
|
88
|
+
if (includeFirstUserMessage) {
|
|
89
|
+
const user = firstUserMessage(body);
|
|
90
|
+
if (user) anchor.push(`firstUser=${extractText(user.content ?? user.text)}`);
|
|
91
|
+
}
|
|
92
|
+
if (includePromptField && typeof body?.prompt === 'string' && body.prompt) {
|
|
93
|
+
anchor.push(`prompt=${body.prompt}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const joined = anchor.filter((part) => part && part.length > 4).join('\n');
|
|
97
|
+
if (!joined.trim()) return null;
|
|
98
|
+
return `k_${crypto.createHash('sha256').update(joined, 'utf8').digest('hex')}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 探测客户端自带的真实会话标识(优先级从高到低):
|
|
103
|
+
* 1. 入站请求头
|
|
104
|
+
* 2. 请求体会话字段(含 metadata / extra_body 等嵌套位置)
|
|
105
|
+
*/
|
|
106
|
+
export function findExplicitSession(headers = {}, body = null, options = {}) {
|
|
107
|
+
const { headerNames = [], bodyFields = [] } = options;
|
|
108
|
+
|
|
109
|
+
for (const name of headerNames) {
|
|
110
|
+
const value = normalizeSessionId(headers[String(name).toLowerCase()]);
|
|
111
|
+
if (value) return { id: value, source: `header:${String(name).toLowerCase()}` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!body || typeof body !== 'object') return null;
|
|
115
|
+
for (const field of bodyFields) {
|
|
116
|
+
const value = normalizeSessionId(getByPath(body, field.replace(/^(body|metadata|extra_body)\./, (m) => m)));
|
|
117
|
+
if (value) return { id: value, source: `body:${field}` };
|
|
118
|
+
}
|
|
119
|
+
// 兼容常见嵌套位置
|
|
120
|
+
for (const container of ['metadata', 'meta', 'extra_body', 'extraBody', 'client']) {
|
|
121
|
+
const nested = body[container];
|
|
122
|
+
if (!nested || typeof nested !== 'object') continue;
|
|
123
|
+
for (const key of ['session_id', 'sessionId', 'conversation_id', 'conversationId', 'thread_id', 'threadId']) {
|
|
124
|
+
const value = normalizeSessionId(nested[key]);
|
|
125
|
+
if (value) return { id: value, source: `body:${container}.${key}` };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 会话表:显式会话与内容指纹共用一张表,超限或过期自动清理。
|
|
133
|
+
* 显式会话用 `explicit:` 前缀隔离,避免与内容指纹串号。
|
|
134
|
+
*/
|
|
135
|
+
export class SessionStore {
|
|
136
|
+
constructor({ maxSessions = 512, ttlSeconds = 0 } = {}) {
|
|
137
|
+
this.map = new Map();
|
|
138
|
+
this.maxSessions = Math.max(1, maxSessions);
|
|
139
|
+
this.ttlMs = Math.max(0, ttlSeconds) * 1000;
|
|
140
|
+
this.hits = 0;
|
|
141
|
+
this.misses = 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get size() {
|
|
145
|
+
return this.map.size;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#purgeExpired(now) {
|
|
149
|
+
if (!this.ttlMs) return;
|
|
150
|
+
for (const [key, record] of this.map) {
|
|
151
|
+
if (now - record.lastUsed > this.ttlMs) this.map.delete(key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
trim(now = Date.now()) {
|
|
156
|
+
this.#purgeExpired(now);
|
|
157
|
+
while (this.map.size > this.maxSessions) {
|
|
158
|
+
const oldest = this.map.keys().next();
|
|
159
|
+
if (oldest.done) break;
|
|
160
|
+
this.map.delete(oldest.value);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get(key, now = Date.now()) {
|
|
165
|
+
const record = this.map.get(key);
|
|
166
|
+
if (!record) return null;
|
|
167
|
+
if (this.ttlMs && now - record.lastUsed > this.ttlMs) {
|
|
168
|
+
this.map.delete(key);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return record;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
set(key, record, now = Date.now()) {
|
|
175
|
+
record.lastUsed = now;
|
|
176
|
+
this.map.set(key, record);
|
|
177
|
+
this.trim(now);
|
|
178
|
+
return record;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
clear() {
|
|
182
|
+
this.map.clear();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 决定本次请求使用哪个会话。
|
|
188
|
+
*
|
|
189
|
+
* 1. 客户端显式会话标识(header / body)—— 真实对话绑定,同一对话内恒定
|
|
190
|
+
* 2. 内容指纹(system + 首条 user 消息)—— 客户端不带会话信息时的回退
|
|
191
|
+
* 3. 都没有 —— 一次性随机 ID(只保证上游不报 400,不参与复用)
|
|
192
|
+
*
|
|
193
|
+
* 返回 { id, key, count, requestId, source }。
|
|
194
|
+
*/
|
|
195
|
+
export function resolveSession({ headers = {}, body = null, config, store }) {
|
|
196
|
+
const sessionConfig = config.session;
|
|
197
|
+
if (!sessionConfig?.enabled) {
|
|
198
|
+
return { id: null, key: null, count: 0, requestId: null, source: 'disabled' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
const explicit = findExplicitSession(headers, body, sessionConfig);
|
|
203
|
+
|
|
204
|
+
let key;
|
|
205
|
+
let record;
|
|
206
|
+
let source;
|
|
207
|
+
|
|
208
|
+
if (explicit) {
|
|
209
|
+
key = `explicit:${explicit.id}`;
|
|
210
|
+
source = explicit.source;
|
|
211
|
+
record = store.get(key, now);
|
|
212
|
+
if (record) {
|
|
213
|
+
store.hits += 1;
|
|
214
|
+
} else {
|
|
215
|
+
store.misses += 1;
|
|
216
|
+
record = { id: explicit.id, count: 0, createdAt: now, lastUsed: now };
|
|
217
|
+
store.set(key, record, now);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
const fingerprint = contentFingerprint(body, sessionConfig.contentHash || {});
|
|
221
|
+
if (fingerprint) {
|
|
222
|
+
key = fingerprint;
|
|
223
|
+
source = 'content-hash';
|
|
224
|
+
record = store.get(key, now);
|
|
225
|
+
if (record) {
|
|
226
|
+
store.hits += 1;
|
|
227
|
+
} else {
|
|
228
|
+
store.misses += 1;
|
|
229
|
+
record = {
|
|
230
|
+
id: generateId({ prefix: sessionConfig.idPrefix, format: sessionConfig.idFormat }),
|
|
231
|
+
count: 0,
|
|
232
|
+
createdAt: now,
|
|
233
|
+
lastUsed: now,
|
|
234
|
+
};
|
|
235
|
+
store.set(key, record, now);
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
// 无法归因到稳定对话:发一次性 ID,不入表,避免把会话表撑爆
|
|
239
|
+
const id = generateId({ prefix: sessionConfig.idPrefix, format: sessionConfig.idFormat });
|
|
240
|
+
return {
|
|
241
|
+
id,
|
|
242
|
+
key: null,
|
|
243
|
+
count: 1,
|
|
244
|
+
requestId: renderTemplate(sessionConfig.requestIdFormat, {
|
|
245
|
+
session: { id, count: 1 },
|
|
246
|
+
functions: {},
|
|
247
|
+
}),
|
|
248
|
+
source: 'random',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
record.count += 1;
|
|
254
|
+
record.lastUsed = now;
|
|
255
|
+
|
|
256
|
+
const requestId = renderTemplate(sessionConfig.requestIdFormat, {
|
|
257
|
+
session: { id: record.id, count: record.count },
|
|
258
|
+
functions: {},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
id: record.id,
|
|
263
|
+
key,
|
|
264
|
+
count: record.count,
|
|
265
|
+
requestId,
|
|
266
|
+
source,
|
|
267
|
+
createdAt: record.createdAt,
|
|
268
|
+
};
|
|
269
|
+
}
|