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/config.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 默认配置。
|
|
6
|
+
*
|
|
7
|
+
* 默认值刻意对齐 OpenCode Go / Zen 的要求(注入 x-opencode-* 会话头),
|
|
8
|
+
* 但每一项都可以被配置文件、环境变量或命令行参数覆盖,以适配任意上游。
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_CONFIG = {
|
|
11
|
+
listen: {
|
|
12
|
+
host: '127.0.0.1',
|
|
13
|
+
port: 9355,
|
|
14
|
+
},
|
|
15
|
+
upstream: {
|
|
16
|
+
protocol: 'https',
|
|
17
|
+
host: 'opencode.ai',
|
|
18
|
+
port: null,
|
|
19
|
+
basePath: '',
|
|
20
|
+
rewriteHost: true,
|
|
21
|
+
},
|
|
22
|
+
request: {
|
|
23
|
+
bufferBody: true,
|
|
24
|
+
maxBodyBytes: 64 * 1024 * 1024,
|
|
25
|
+
timeoutMs: 600000,
|
|
26
|
+
pathRewrite: [],
|
|
27
|
+
dropHeaders: [],
|
|
28
|
+
forwardClientSessionHeaders: true,
|
|
29
|
+
},
|
|
30
|
+
session: {
|
|
31
|
+
enabled: true,
|
|
32
|
+
headerNames: [
|
|
33
|
+
'x-opencode-session',
|
|
34
|
+
'x-session-id',
|
|
35
|
+
'x-conversation-id',
|
|
36
|
+
'x-thread-id',
|
|
37
|
+
'x-chat-id',
|
|
38
|
+
],
|
|
39
|
+
bodyFields: [
|
|
40
|
+
'session_id',
|
|
41
|
+
'sessionId',
|
|
42
|
+
'conversation_id',
|
|
43
|
+
'conversationId',
|
|
44
|
+
'thread_id',
|
|
45
|
+
'threadId',
|
|
46
|
+
'chat_id',
|
|
47
|
+
'chatId',
|
|
48
|
+
],
|
|
49
|
+
contentHash: {
|
|
50
|
+
enabled: true,
|
|
51
|
+
fields: ['system', 'system_instruction', 'instructions'],
|
|
52
|
+
includeFirstUserMessage: true,
|
|
53
|
+
includePromptField: true,
|
|
54
|
+
},
|
|
55
|
+
idPrefix: 'ses_',
|
|
56
|
+
idFormat: 'hex26',
|
|
57
|
+
requestIdFormat: 'msg_{{session.count}}',
|
|
58
|
+
maxSessions: 512,
|
|
59
|
+
ttlSeconds: 0,
|
|
60
|
+
},
|
|
61
|
+
inject: {
|
|
62
|
+
headers: {
|
|
63
|
+
'x-opencode-session': '{{session.id}}',
|
|
64
|
+
'x-opencode-request': '{{session.requestId}}',
|
|
65
|
+
'x-opencode-client': 'cli',
|
|
66
|
+
'x-opencode-project': 'global',
|
|
67
|
+
},
|
|
68
|
+
body: {},
|
|
69
|
+
removeBodyFields: [],
|
|
70
|
+
overwrite: true,
|
|
71
|
+
},
|
|
72
|
+
model: {
|
|
73
|
+
enabled: true,
|
|
74
|
+
field: 'model',
|
|
75
|
+
stripPrefixes: ['proxy-'],
|
|
76
|
+
map: {},
|
|
77
|
+
default: null,
|
|
78
|
+
},
|
|
79
|
+
userAgent: 'opencode/1.18.29 cli',
|
|
80
|
+
// keep:完全保留客户端 UA;replace:始终用上面的 UA;
|
|
81
|
+
// replace-generic:仅当客户端 UA 缺失或像个通用 HTTP 库时才替换(默认)
|
|
82
|
+
userAgentMode: 'replace-generic',
|
|
83
|
+
response: {
|
|
84
|
+
stream: true,
|
|
85
|
+
timeoutMs: 600000,
|
|
86
|
+
},
|
|
87
|
+
log: {
|
|
88
|
+
level: 'info',
|
|
89
|
+
file: null,
|
|
90
|
+
maxBytes: 5 * 1024 * 1024,
|
|
91
|
+
backups: 2,
|
|
92
|
+
requests: true,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const GENERIC_UA_RE = /^(node|undici|axios|node-fetch|python-requests|python-httpx|go-http-client|java|okhttp|curl|postmanruntime|got|superagent|request|apache-httpclient|libwww-perl|http\.client|wget)/i;
|
|
97
|
+
|
|
98
|
+
const ENV_MAP = {
|
|
99
|
+
PROXY_HOST: ['listen', 'host'],
|
|
100
|
+
PROXY_PORT: ['listen', 'port'],
|
|
101
|
+
UPSTREAM_PROTO: ['upstream', 'protocol'],
|
|
102
|
+
UPSTREAM_HOST: ['upstream', 'host'],
|
|
103
|
+
UPSTREAM_PORT: ['upstream', 'port'],
|
|
104
|
+
UPSTREAM_BASE_PATH: ['upstream', 'basePath'],
|
|
105
|
+
OPENCODE_UA: ['userAgent'],
|
|
106
|
+
USER_AGENT: ['userAgent'],
|
|
107
|
+
USER_AGENT_MODE: ['userAgentMode'],
|
|
108
|
+
LOG_FILE: ['log', 'file'],
|
|
109
|
+
LOG_LEVEL: ['log', 'level'],
|
|
110
|
+
SESSION_ID_PREFIX: ['session', 'idPrefix'],
|
|
111
|
+
SESSION_ID_FORMAT: ['session', 'idFormat'],
|
|
112
|
+
REQUEST_ID_FORMAT: ['session', 'requestIdFormat'],
|
|
113
|
+
MAX_SESSIONS: ['session', 'maxSessions'],
|
|
114
|
+
SESSION_TTL: ['session', 'ttlSeconds'],
|
|
115
|
+
MODEL_ALIAS_PREFIX: ['model', 'stripPrefixes'],
|
|
116
|
+
INJECT_HEADERS: ['inject', 'headers'],
|
|
117
|
+
PATH_REWRITE: ['request', 'pathRewrite'],
|
|
118
|
+
TIMEOUT_MS: ['request', 'timeoutMs'],
|
|
119
|
+
MAX_BODY_BYTES: ['request', 'maxBodyBytes'],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function isPlainObject(value) {
|
|
123
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 递归合并:普通对象逐层合并,数组与其他类型整体覆盖。 */
|
|
127
|
+
export function deepMerge(base, override) {
|
|
128
|
+
if (!isPlainObject(override)) return override === undefined ? base : override;
|
|
129
|
+
const out = Array.isArray(base) ? [...base] : { ...(isPlainObject(base) ? base : {}) };
|
|
130
|
+
for (const [key, value] of Object.entries(override)) {
|
|
131
|
+
if (value === undefined) continue;
|
|
132
|
+
out[key] = isPlainObject(value) && isPlainObject(out[key]) ? deepMerge(out[key], value) : value;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 宽松 JSON:允许 // 与 /* */ 注释、允许尾随逗号。方便写带说明的配置文件。 */
|
|
138
|
+
export function parseJsonLoose(text, source = '<inline>') {
|
|
139
|
+
const stripped = text
|
|
140
|
+
.replace(/^\uFEFF/, '')
|
|
141
|
+
.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n\r]*|\/\*[\s\S]*?\*\//g, (match) =>
|
|
142
|
+
match.startsWith('"') ? match : '',
|
|
143
|
+
)
|
|
144
|
+
.replace(/,(\s*[}\]])/g, '$1');
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(stripped);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new Error(`配置文件解析失败(${source}): ${error.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function loadConfigFile(filePath) {
|
|
153
|
+
const resolved = path.resolve(filePath);
|
|
154
|
+
const text = fs.readFileSync(resolved, 'utf8');
|
|
155
|
+
const raw = parseJsonLoose(text, resolved);
|
|
156
|
+
if (!isPlainObject(raw)) throw new Error(`配置文件根节点必须是对象(${resolved})`);
|
|
157
|
+
return { config: raw, path: resolved };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 把上游 URL / host:port 字符串解析成 upstream 片段。 */
|
|
161
|
+
export function parseUpstream(input) {
|
|
162
|
+
if (!input) return {};
|
|
163
|
+
const text = String(input).trim();
|
|
164
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `https://${text}`;
|
|
165
|
+
const url = new URL(withScheme);
|
|
166
|
+
const basePath = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/+$/, '') : '';
|
|
167
|
+
return {
|
|
168
|
+
protocol: url.protocol.replace(':', ''),
|
|
169
|
+
host: url.hostname,
|
|
170
|
+
port: url.port ? Number(url.port) : null,
|
|
171
|
+
basePath,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function setByPath(target, keyPath, value) {
|
|
176
|
+
let cursor = target;
|
|
177
|
+
for (let i = 0; i < keyPath.length - 1; i += 1) {
|
|
178
|
+
const key = keyPath[i];
|
|
179
|
+
if (!isPlainObject(cursor[key])) cursor[key] = {};
|
|
180
|
+
cursor = cursor[key];
|
|
181
|
+
}
|
|
182
|
+
cursor[keyPath[keyPath.length - 1]] = value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function coerce(keyPath, raw) {
|
|
186
|
+
const key = keyPath.join('.');
|
|
187
|
+
if (/^(listen\.port|upstream\.port|request\.timeoutMs|request\.maxBodyBytes|session\.maxSessions|session\.ttlSeconds|response\.timeoutMs|log\.maxBytes|log\.backups)$/.test(key)) {
|
|
188
|
+
const num = Number(raw);
|
|
189
|
+
if (!Number.isFinite(num)) throw new Error(`环境变量 ${key} 需要是数字,收到 "${raw}"`);
|
|
190
|
+
return num;
|
|
191
|
+
}
|
|
192
|
+
if (/^(inject\.headers|request\.pathRewrite)$/.test(key)) {
|
|
193
|
+
const value = JSON.parse(raw);
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
if (key === 'model.stripPrefixes') {
|
|
197
|
+
return String(raw).split(',').map((s) => s.trim()).filter(Boolean);
|
|
198
|
+
}
|
|
199
|
+
return raw;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 从环境变量读取配置片段。 */
|
|
203
|
+
export function configFromEnv(env = process.env) {
|
|
204
|
+
const out = {};
|
|
205
|
+
for (const [envName, keyPath] of Object.entries(ENV_MAP)) {
|
|
206
|
+
const raw = env[envName];
|
|
207
|
+
if (raw === undefined || raw === '') continue;
|
|
208
|
+
setByPath(out, keyPath, coerce(keyPath, raw));
|
|
209
|
+
}
|
|
210
|
+
if (env.CONFIG_FILE) out.__configFile = env.CONFIG_FILE;
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalize(config) {
|
|
215
|
+
const next = { ...config };
|
|
216
|
+
if (typeof next.upstream?.host === 'string' && /:\/\//.test(next.upstream.host)) {
|
|
217
|
+
next.upstream = deepMerge(next.upstream, parseUpstream(next.upstream.host));
|
|
218
|
+
}
|
|
219
|
+
next.listen.port = Number(next.listen.port);
|
|
220
|
+
if (next.upstream.port !== null && next.upstream.port !== undefined) {
|
|
221
|
+
next.upstream.port = Number(next.upstream.port);
|
|
222
|
+
}
|
|
223
|
+
if (next.model && Array.isArray(next.model.stripPrefixes)) {
|
|
224
|
+
next.model.stripPrefixes = next.model.stripPrefixes.filter((p) => typeof p === 'string' && p.length > 0);
|
|
225
|
+
}
|
|
226
|
+
if (next.request && Array.isArray(next.request.pathRewrite)) {
|
|
227
|
+
next.request.pathRewrite = next.request.pathRewrite
|
|
228
|
+
.map((rule) => {
|
|
229
|
+
if (!rule || typeof rule !== 'object') return null;
|
|
230
|
+
const pattern = rule.pattern ?? rule.from;
|
|
231
|
+
const replacement = rule.replacement ?? rule.to ?? '';
|
|
232
|
+
if (typeof pattern !== 'string') return null;
|
|
233
|
+
try {
|
|
234
|
+
// 提前编译,配置错误在启动时就暴露
|
|
235
|
+
// eslint-disable-next-line no-new
|
|
236
|
+
new RegExp(pattern);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
throw new Error(`pathRewrite 里的正则不合法 "${pattern}": ${error.message}`);
|
|
239
|
+
}
|
|
240
|
+
return { pattern, replacement, flags: rule.flags || undefined };
|
|
241
|
+
})
|
|
242
|
+
.filter(Boolean);
|
|
243
|
+
}
|
|
244
|
+
return next;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function validate(config) {
|
|
248
|
+
const errors = [];
|
|
249
|
+
if (!Number.isInteger(config.listen.port) || config.listen.port < 0 || config.listen.port > 65535) {
|
|
250
|
+
errors.push(`listen.port 不合法: ${config.listen.port}`);
|
|
251
|
+
}
|
|
252
|
+
if (!config.upstream?.host) errors.push('upstream.host 不能为空');
|
|
253
|
+
if (!['http', 'https'].includes(config.upstream?.protocol)) {
|
|
254
|
+
errors.push(`upstream.protocol 只支持 http / https,收到 ${config.upstream?.protocol}`);
|
|
255
|
+
}
|
|
256
|
+
if (config.session?.enabled && !['hex26', 'hex', 'uuid', 'base36', 'short'].includes(config.session.idFormat)) {
|
|
257
|
+
errors.push(`session.idFormat 不支持: ${config.session.idFormat}`);
|
|
258
|
+
}
|
|
259
|
+
if (config.inject?.headers && !isPlainObject(config.inject.headers)) {
|
|
260
|
+
errors.push('inject.headers 必须是对象(值为模板字符串)');
|
|
261
|
+
}
|
|
262
|
+
if (errors.length) throw new Error(`配置校验失败:\n - ${errors.join('\n - ')}`);
|
|
263
|
+
return config;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 组装最终配置:默认值 < 配置文件 < 环境变量 < 命令行参数。
|
|
268
|
+
* 三者都是同一套字段,优先级从低到高。
|
|
269
|
+
*/
|
|
270
|
+
export function buildConfig({ file = null, env = process.env, flags = {} } = {}) {
|
|
271
|
+
let merged = deepMerge(DEFAULT_CONFIG, {});
|
|
272
|
+
let configPath = file || env.CONFIG_FILE || null;
|
|
273
|
+
|
|
274
|
+
if (configPath) {
|
|
275
|
+
const loaded = loadConfigFile(configPath);
|
|
276
|
+
merged = deepMerge(merged, loaded.config);
|
|
277
|
+
configPath = loaded.path;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const fromEnv = configFromEnv(env);
|
|
281
|
+
delete fromEnv.__configFile;
|
|
282
|
+
merged = deepMerge(merged, fromEnv);
|
|
283
|
+
merged = deepMerge(merged, flags);
|
|
284
|
+
|
|
285
|
+
const normalized = normalize(merged);
|
|
286
|
+
validate(normalized);
|
|
287
|
+
normalized.__configPath = configPath;
|
|
288
|
+
return normalized;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 上游连接信息,供代理层使用。 */
|
|
292
|
+
export function resolveUpstream(config) {
|
|
293
|
+
const { protocol, host, port, basePath } = config.upstream;
|
|
294
|
+
const defaultPort = protocol === 'https' ? 443 : 80;
|
|
295
|
+
return {
|
|
296
|
+
protocol,
|
|
297
|
+
host,
|
|
298
|
+
port: port ?? defaultPort,
|
|
299
|
+
hostHeader: (port ?? defaultPort) === defaultPort ? host : `${host}:${port ?? defaultPort}`,
|
|
300
|
+
basePath: basePath || '',
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function shouldReplaceUserAgent(incoming, config) {
|
|
305
|
+
const mode = config.userAgentMode || 'replace-generic';
|
|
306
|
+
if (mode === 'keep') return false;
|
|
307
|
+
if (mode === 'replace') return true;
|
|
308
|
+
if (!incoming) return true;
|
|
309
|
+
return GENERIC_UA_RE.test(String(incoming).trim()) || String(incoming).toLowerCase().includes('opencode');
|
|
310
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_CONFIG,
|
|
7
|
+
buildConfig,
|
|
8
|
+
deepMerge,
|
|
9
|
+
loadConfigFile,
|
|
10
|
+
parseJsonLoose,
|
|
11
|
+
parseUpstream,
|
|
12
|
+
resolveUpstream,
|
|
13
|
+
} from './config.js';
|
|
14
|
+
import { applyBodyInject, buildInjectHeaders, rewriteModel, rewritePath } from './inject.js';
|
|
15
|
+
import { Logger } from './logger.js';
|
|
16
|
+
import { createProxyServer } from './proxy.js';
|
|
17
|
+
import {
|
|
18
|
+
SessionStore,
|
|
19
|
+
contentFingerprint,
|
|
20
|
+
findExplicitSession,
|
|
21
|
+
generateId,
|
|
22
|
+
normalizeSessionId,
|
|
23
|
+
resolveSession,
|
|
24
|
+
} from './session.js';
|
|
25
|
+
import { createContext, randomBase36, randomHex, renderDeep, renderTemplate } from './template.js';
|
|
26
|
+
|
|
27
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(moduleDir, '..', 'package.json'), 'utf8'));
|
|
29
|
+
|
|
30
|
+
export const VERSION = pkg.version;
|
|
31
|
+
export const NAME = pkg.name;
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
DEFAULT_CONFIG,
|
|
35
|
+
Logger,
|
|
36
|
+
SessionStore,
|
|
37
|
+
applyBodyInject,
|
|
38
|
+
buildConfig,
|
|
39
|
+
buildInjectHeaders,
|
|
40
|
+
contentFingerprint,
|
|
41
|
+
createContext,
|
|
42
|
+
createProxyServer,
|
|
43
|
+
deepMerge,
|
|
44
|
+
findExplicitSession,
|
|
45
|
+
generateId,
|
|
46
|
+
loadConfigFile,
|
|
47
|
+
normalizeSessionId,
|
|
48
|
+
parseJsonLoose,
|
|
49
|
+
parseUpstream,
|
|
50
|
+
randomBase36,
|
|
51
|
+
randomHex,
|
|
52
|
+
renderDeep,
|
|
53
|
+
renderTemplate,
|
|
54
|
+
resolveSession,
|
|
55
|
+
resolveUpstream,
|
|
56
|
+
rewriteModel,
|
|
57
|
+
rewritePath,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 一步启动代理(适合在脚本里内嵌使用)。
|
|
62
|
+
*
|
|
63
|
+
* const proxy = await startProxy({ configFile: './my.json' });
|
|
64
|
+
* console.log(proxy.url);
|
|
65
|
+
*/
|
|
66
|
+
export async function startProxy(options = {}) {
|
|
67
|
+
const {
|
|
68
|
+
config: providedConfig,
|
|
69
|
+
configFile = null,
|
|
70
|
+
env = process.env,
|
|
71
|
+
flags = {},
|
|
72
|
+
logger: providedLogger = null,
|
|
73
|
+
silent = false,
|
|
74
|
+
} = options;
|
|
75
|
+
|
|
76
|
+
const config = providedConfig
|
|
77
|
+
? deepMerge(DEFAULT_CONFIG, providedConfig)
|
|
78
|
+
: buildConfig({ file: configFile, env, flags });
|
|
79
|
+
|
|
80
|
+
const logger = providedLogger || new Logger({ ...config.log, console: !silent && config.log.level !== 'silent' });
|
|
81
|
+
const proxy = createProxyServer({ config, logger });
|
|
82
|
+
await proxy.listen();
|
|
83
|
+
|
|
84
|
+
const address = proxy.server.address();
|
|
85
|
+
const host = config.listen.host === '0.0.0.0' ? '127.0.0.1' : config.listen.host;
|
|
86
|
+
const url = `http://${host}:${address.port}`;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
...proxy,
|
|
90
|
+
url,
|
|
91
|
+
logger,
|
|
92
|
+
async stop() {
|
|
93
|
+
await proxy.close();
|
|
94
|
+
logger.close();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default {
|
|
100
|
+
NAME,
|
|
101
|
+
VERSION,
|
|
102
|
+
startProxy,
|
|
103
|
+
createProxyServer,
|
|
104
|
+
buildConfig,
|
|
105
|
+
SessionStore,
|
|
106
|
+
Logger,
|
|
107
|
+
};
|
package/src/inject.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { renderDeep, renderTemplate } from './template.js';
|
|
2
|
+
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function mergeInto(target, patch) {
|
|
8
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
9
|
+
if (isPlainObject(value) && isPlainObject(target[key])) mergeInto(target[key], value);
|
|
10
|
+
else target[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return target;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function deleteByPath(target, keyPath) {
|
|
16
|
+
const segments = keyPath.split('.');
|
|
17
|
+
let cursor = target;
|
|
18
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
19
|
+
cursor = cursor?.[segments[i]];
|
|
20
|
+
if (!isPlainObject(cursor) && !Array.isArray(cursor)) return;
|
|
21
|
+
}
|
|
22
|
+
if (cursor && typeof cursor === 'object') delete cursor[segments[segments.length - 1]];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 按规则重写请求路径(例如把客户端用的 /v1/ 映射到上游的 /zen/go/v1/)。 */
|
|
26
|
+
export function rewritePath(originalPath, rules = []) {
|
|
27
|
+
let result = originalPath;
|
|
28
|
+
for (const rule of rules) {
|
|
29
|
+
const re = new RegExp(rule.pattern, rule.flags);
|
|
30
|
+
result = result.replace(re, rule.replacement);
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 模型名重写:
|
|
37
|
+
* 1. map 精确映射优先(别名 -> 真实模型名)
|
|
38
|
+
* 2. stripPrefixes 逐条剥离(Trae 等客户端必须用 proxy- 前缀避开内置通道时使用)
|
|
39
|
+
* 3. default 兜底
|
|
40
|
+
* 返回 { changed, from, to },未变化时 changed=false。
|
|
41
|
+
*/
|
|
42
|
+
export function rewriteModel(body, modelConfig, { logger } = {}) {
|
|
43
|
+
if (!modelConfig?.enabled || !isPlainObject(body)) return { changed: false };
|
|
44
|
+
const field = modelConfig.field || 'model';
|
|
45
|
+
const original = body[field];
|
|
46
|
+
if (typeof original !== 'string' || !original) return { changed: false };
|
|
47
|
+
|
|
48
|
+
const map = modelConfig.map || {};
|
|
49
|
+
const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
50
|
+
|
|
51
|
+
let next = original;
|
|
52
|
+
if (hasOwn(map, original)) {
|
|
53
|
+
// 1. 原始名直接命中映射,优先级最高
|
|
54
|
+
next = map[original];
|
|
55
|
+
} else {
|
|
56
|
+
for (const prefix of modelConfig.stripPrefixes || []) {
|
|
57
|
+
if (prefix && next.startsWith(prefix)) {
|
|
58
|
+
next = next.slice(prefix.length);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 2. 剥掉前缀后再查一次 map,让客户端可以"加 proxy- 前缀 + 用短别名"同时成立
|
|
63
|
+
if (next !== original && hasOwn(map, next)) next = map[next];
|
|
64
|
+
}
|
|
65
|
+
if ((!next || next === original) && modelConfig.default) next = modelConfig.default;
|
|
66
|
+
if (!next || next === original) return { changed: false, from: original, to: original };
|
|
67
|
+
|
|
68
|
+
body[field] = next;
|
|
69
|
+
logger?.debug(`[model] ${original} -> ${next}`);
|
|
70
|
+
return { changed: true, from: original, to: next };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 按配置往请求体里补字段 / 删字段。 */
|
|
74
|
+
/**
|
|
75
|
+
* 允许用 "metadata.trace" 这样的点路径书写注入字段,展开成嵌套对象再合并,
|
|
76
|
+
* 与 removeBodyFields 的写法保持一致。
|
|
77
|
+
*/
|
|
78
|
+
function expandDottedKeys(source) {
|
|
79
|
+
const out = {};
|
|
80
|
+
for (const [key, value] of Object.entries(source)) {
|
|
81
|
+
if (!key.includes('.')) {
|
|
82
|
+
out[key] = value;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const segments = key.split('.');
|
|
86
|
+
let cursor = out;
|
|
87
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
88
|
+
if (!isPlainObject(cursor[segments[i]])) cursor[segments[i]] = {};
|
|
89
|
+
cursor = cursor[segments[i]];
|
|
90
|
+
}
|
|
91
|
+
cursor[segments[segments.length - 1]] = value;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function applyBodyInject(body, injectConfig, ctx) {
|
|
97
|
+
if (!isPlainObject(body) || !injectConfig) return { changed: false };
|
|
98
|
+
const changes = [];
|
|
99
|
+
|
|
100
|
+
if (injectConfig.body && Object.keys(injectConfig.body).length) {
|
|
101
|
+
const rendered = renderDeep(expandDottedKeys(injectConfig.body), ctx);
|
|
102
|
+
const before = JSON.stringify(body);
|
|
103
|
+
if (injectConfig.overwrite === false) {
|
|
104
|
+
const patch = {};
|
|
105
|
+
for (const [key, value] of Object.entries(rendered)) {
|
|
106
|
+
if (!(key in body)) patch[key] = value;
|
|
107
|
+
}
|
|
108
|
+
mergeInto(body, patch);
|
|
109
|
+
} else {
|
|
110
|
+
mergeInto(body, rendered);
|
|
111
|
+
}
|
|
112
|
+
if (JSON.stringify(body) !== before) changes.push(`body:${Object.keys(injectConfig.body).join(',')}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const keyPath of injectConfig.removeBodyFields || []) {
|
|
116
|
+
if (typeof keyPath === 'string' && keyPath) {
|
|
117
|
+
deleteByPath(body, keyPath);
|
|
118
|
+
changes.push(`body-${keyPath}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { changed: changes.length > 0, changes };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 生成需要注入/覆盖的请求头。
|
|
127
|
+
* inject.overwrite === false 时,已存在的请求头不会被覆盖。
|
|
128
|
+
*/
|
|
129
|
+
export function buildInjectHeaders(injectConfig, ctx, existingLower = new Map()) {
|
|
130
|
+
const out = {};
|
|
131
|
+
const headers = injectConfig?.headers || {};
|
|
132
|
+
for (const [name, template] of Object.entries(headers)) {
|
|
133
|
+
if (template === null || template === undefined) continue;
|
|
134
|
+
const lower = name.toLowerCase();
|
|
135
|
+
if (injectConfig.overwrite === false && existingLower.has(lower)) continue;
|
|
136
|
+
const value = renderTemplate(String(template), ctx);
|
|
137
|
+
if (value === '') continue;
|
|
138
|
+
out[lower] = value;
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const LEVELS = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 };
|
|
5
|
+
const COLORS = { error: 31, warn: 33, info: 36, debug: 90 };
|
|
6
|
+
|
|
7
|
+
function stringify(value) {
|
|
8
|
+
if (typeof value === 'string') return value;
|
|
9
|
+
if (value instanceof Error) return value.stack || value.message;
|
|
10
|
+
try {
|
|
11
|
+
return JSON.stringify(value);
|
|
12
|
+
} catch {
|
|
13
|
+
return String(value);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 极简日志器:同时写控制台(TTY 带色)与可选文件(按大小轮转)。
|
|
19
|
+
* 不依赖任何第三方库。
|
|
20
|
+
*
|
|
21
|
+
* 文件写入刻意采用同步 API:日志量小(每请求两三行),换来的是
|
|
22
|
+
* "进程退出/轮转时不会丢最后几行"这一确定性——排错时最需要的正是尾部日志。
|
|
23
|
+
*/
|
|
24
|
+
export class Logger {
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
const {
|
|
27
|
+
level = 'info',
|
|
28
|
+
file = null,
|
|
29
|
+
maxBytes = 5 * 1024 * 1024,
|
|
30
|
+
backups = 2,
|
|
31
|
+
console: toConsole = true,
|
|
32
|
+
} = options;
|
|
33
|
+
|
|
34
|
+
this.level = LEVELS[level] ?? LEVELS.info;
|
|
35
|
+
this.toConsole = toConsole;
|
|
36
|
+
this.maxBytes = maxBytes;
|
|
37
|
+
this.backups = Math.max(0, backups);
|
|
38
|
+
this.file = file ? path.resolve(file) : null;
|
|
39
|
+
this.written = 0;
|
|
40
|
+
|
|
41
|
+
if (this.file) {
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
44
|
+
} catch {
|
|
45
|
+
/* 目录创建失败时退化为仅控制台输出 */
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
this.written = fs.statSync(this.file).size;
|
|
49
|
+
} catch {
|
|
50
|
+
this.written = 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get filePath() {
|
|
56
|
+
return this.file;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#rotate() {
|
|
60
|
+
try {
|
|
61
|
+
for (let i = this.backups - 1; i >= 1; i -= 1) {
|
|
62
|
+
const src = `${this.file}.${i}`;
|
|
63
|
+
if (fs.existsSync(src)) fs.renameSync(src, `${this.file}.${i + 1}`);
|
|
64
|
+
}
|
|
65
|
+
if (fs.existsSync(this.file)) fs.renameSync(this.file, `${this.file}.1`);
|
|
66
|
+
} catch {
|
|
67
|
+
/* 轮转失败不影响主流程 */
|
|
68
|
+
}
|
|
69
|
+
this.written = 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#writeFile(line) {
|
|
73
|
+
if (!this.file) return;
|
|
74
|
+
const buf = Buffer.from(`${line}\n`, 'utf8');
|
|
75
|
+
if (this.maxBytes > 0 && this.written + buf.length > this.maxBytes) this.#rotate();
|
|
76
|
+
try {
|
|
77
|
+
fs.appendFileSync(this.file, buf);
|
|
78
|
+
this.written += buf.length;
|
|
79
|
+
} catch {
|
|
80
|
+
/* 写盘失败(磁盘满、权限)时静默降级,不能因为日志挂掉代理 */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#emit(level, args) {
|
|
85
|
+
if (LEVELS[level] > this.level) return;
|
|
86
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
87
|
+
const text = args.map(stringify).join(' ');
|
|
88
|
+
const line = `${ts} [${level.toUpperCase()}] ${text}`;
|
|
89
|
+
if (this.toConsole) {
|
|
90
|
+
const color = COLORS[level] || 0;
|
|
91
|
+
const out = process.stderr.isTTY ? `\u001b[${color}m${line}\u001b[0m` : line;
|
|
92
|
+
process.stderr.write(`${out}\n`);
|
|
93
|
+
}
|
|
94
|
+
this.#writeFile(line);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
error(...args) {
|
|
98
|
+
this.#emit('error', args);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
warn(...args) {
|
|
102
|
+
this.#emit('warn', args);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
info(...args) {
|
|
106
|
+
this.#emit('info', args);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
debug(...args) {
|
|
110
|
+
this.#emit('debug', args);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
child(prefix) {
|
|
114
|
+
return {
|
|
115
|
+
error: (...a) => this.error(prefix, ...a),
|
|
116
|
+
warn: (...a) => this.warn(prefix, ...a),
|
|
117
|
+
info: (...a) => this.info(prefix, ...a),
|
|
118
|
+
debug: (...a) => this.debug(prefix, ...a),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 保留接口:同步写入无需 flush,调用它只是为了语义清晰。 */
|
|
123
|
+
close() {
|
|
124
|
+
/* 无缓冲需要排空 */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { LEVELS };
|