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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
// OpenCode Go / OpenCode Zen 的推荐配置。
|
|
3
|
+
// 客户端(Trae / Codex / 任意 OpenAI 兼容客户端)Base URL 填:
|
|
4
|
+
// http://127.0.0.1:9355/zen/go/v1
|
|
5
|
+
// 模型 ID 填 "proxy-" + 真实模型名,例如 proxy-glm-5.3-flash。
|
|
6
|
+
//
|
|
7
|
+
// 为什么要加 proxy- 前缀:Trae 按模型 ID 决定走不走自定义通道,
|
|
8
|
+
// 与内置预设重名(如 glm-5.3-flash)时流量会被它自己的云通道接走,
|
|
9
|
+
// 本代理会被绕过。加前缀即可强制走代理,代理会剥掉前缀再发给上游。
|
|
10
|
+
|
|
11
|
+
"listen": { "host": "127.0.0.1", "port": 9355 },
|
|
12
|
+
|
|
13
|
+
"upstream": {
|
|
14
|
+
"protocol": "https",
|
|
15
|
+
"host": "opencode.ai",
|
|
16
|
+
"port": null,
|
|
17
|
+
"basePath": ""
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
"request": {
|
|
21
|
+
"bufferBody": true,
|
|
22
|
+
"maxBodyBytes": 67108864,
|
|
23
|
+
"timeoutMs": 600000,
|
|
24
|
+
// 若客户端只能填 http://127.0.0.1:9355/v1,就打开下面这条:
|
|
25
|
+
"pathRewrite": [
|
|
26
|
+
// { "pattern": "^/v1/", "replacement": "/zen/go/v1/" }
|
|
27
|
+
],
|
|
28
|
+
"forwardClientSessionHeaders": true
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
"session": {
|
|
32
|
+
"enabled": true,
|
|
33
|
+
"headerNames": ["x-opencode-session", "x-session-id", "x-conversation-id", "x-thread-id"],
|
|
34
|
+
"bodyFields": ["session_id", "sessionId", "conversation_id", "conversationId"],
|
|
35
|
+
"contentHash": {
|
|
36
|
+
"enabled": true,
|
|
37
|
+
"fields": ["system", "system_instruction", "instructions"],
|
|
38
|
+
"includeFirstUserMessage": true
|
|
39
|
+
},
|
|
40
|
+
"idPrefix": "ses_",
|
|
41
|
+
"idFormat": "hex26",
|
|
42
|
+
"requestIdFormat": "msg_{{session.count}}",
|
|
43
|
+
"maxSessions": 512
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
"inject": {
|
|
47
|
+
"headers": {
|
|
48
|
+
"x-opencode-session": "{{session.id}}",
|
|
49
|
+
"x-opencode-request": "{{session.requestId}}",
|
|
50
|
+
"x-opencode-client": "cli",
|
|
51
|
+
"x-opencode-project": "global"
|
|
52
|
+
},
|
|
53
|
+
"overwrite": true
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
"model": {
|
|
57
|
+
"enabled": true,
|
|
58
|
+
"field": "model",
|
|
59
|
+
"stripPrefixes": ["proxy-"],
|
|
60
|
+
"map": {}
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
"userAgent": "opencode/1.18.29 cli",
|
|
64
|
+
"userAgentMode": "replace-generic",
|
|
65
|
+
|
|
66
|
+
"response": { "stream": true },
|
|
67
|
+
|
|
68
|
+
"log": { "level": "info", "file": null }
|
|
69
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "llm-session-proxy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "可自定义的 LLM 本地反向代理:自动生成并透传会话 ID、注入任意请求头、重写模型别名与请求路径。零依赖,用于让不支持会话头(如 x-opencode-session)的客户端接上要求该头的上游。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": "alaahong",
|
|
8
|
+
"homepage": "https://www.ianzhang.cn/llm-session-proxy/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+ssh://git@github.com/alaahong/llm-session-proxy.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/alaahong/llm-session-proxy/issues"
|
|
15
|
+
},
|
|
16
|
+
"main": "./src/index.js",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.js",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"llm-session-proxy": "bin/llm-session-proxy.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin/",
|
|
26
|
+
"src/",
|
|
27
|
+
"examples/",
|
|
28
|
+
"README.md",
|
|
29
|
+
"README.en.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.0.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"start": "node ./bin/llm-session-proxy.js",
|
|
37
|
+
"test": "node --test",
|
|
38
|
+
"prepack": "node ./scripts/check-pack.mjs"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"proxy",
|
|
42
|
+
"llm",
|
|
43
|
+
"reverse-proxy",
|
|
44
|
+
"session-id",
|
|
45
|
+
"header-injection",
|
|
46
|
+
"opencode",
|
|
47
|
+
"opencode-go",
|
|
48
|
+
"trae",
|
|
49
|
+
"anthropic",
|
|
50
|
+
"openai",
|
|
51
|
+
"codex",
|
|
52
|
+
"claude-code",
|
|
53
|
+
"x-opencode-session",
|
|
54
|
+
"local-proxy",
|
|
55
|
+
"sse"
|
|
56
|
+
],
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_CONFIG, buildConfig, parseUpstream } from './config.js';
|
|
5
|
+
import { NAME, VERSION } from './index.js';
|
|
6
|
+
import { Logger } from './logger.js';
|
|
7
|
+
import { createProxyServer } from './proxy.js';
|
|
8
|
+
|
|
9
|
+
export const HELP = `
|
|
10
|
+
${NAME} v${VERSION}
|
|
11
|
+
可自定义参数的 LLM 本地反向代理:自动生成/透传会话 ID、注入任意请求头、重写模型别名与请求路径。
|
|
12
|
+
|
|
13
|
+
用法
|
|
14
|
+
${NAME} [选项]
|
|
15
|
+
|
|
16
|
+
常用选项
|
|
17
|
+
-c, --config <file> 读取 JSON 配置文件(支持 // 与 /* */ 注释、尾随逗号)
|
|
18
|
+
-p, --port <number> 监听端口(默认 ${DEFAULT_CONFIG.listen.port})
|
|
19
|
+
--host <addr> 监听地址(默认 ${DEFAULT_CONFIG.listen.host})
|
|
20
|
+
-u, --upstream <url> 上游地址,如 https://opencode.ai 或 host:port
|
|
21
|
+
--base-path <path> 转发路径统一前缀,如 /zen/go/v1
|
|
22
|
+
--path-rewrite <a=>b> 路径重写(正则 => 替换),可重复
|
|
23
|
+
--inject <name=value> 追加/覆盖注入的请求头,可重复。值支持模板,如 {{session.id}}
|
|
24
|
+
--body-inject <k=v> 往请求体注入字段(支持点路径与模板),可重复
|
|
25
|
+
--model-prefix <prefix> 需要剥离的模型名前缀,可重复(默认 proxy-)
|
|
26
|
+
--model-map <a=b> 模型名精确映射,可重复(优先于前缀剥离)
|
|
27
|
+
--session-header <name> 追加"从哪个请求头读客户端会话",可重复
|
|
28
|
+
--session-field <path> 追加"从哪个请求体字段读客户端会话",可重复
|
|
29
|
+
--session-id-format <f> 会话 ID 格式:hex26 | hex | uuid | base36 | short
|
|
30
|
+
--request-id-format <t> 请求号模板,如 "msg_{{session.count}}"
|
|
31
|
+
--no-session 完全关闭会话 ID 注入
|
|
32
|
+
--no-stream 关闭流式透传(整体缓冲后返回)
|
|
33
|
+
--timeout <ms> 上游请求超时(默认 ${DEFAULT_CONFIG.request.timeoutMs})
|
|
34
|
+
--max-body <bytes> 请求体上限(默认 ${DEFAULT_CONFIG.request.maxBodyBytes})
|
|
35
|
+
--log-level <level> 日志级别:silent | error | warn | info | debug
|
|
36
|
+
--log-file <file> 额外写入日志文件(自动按大小轮转)
|
|
37
|
+
|
|
38
|
+
本地工具
|
|
39
|
+
--print-config 打印合并后的最终配置并退出
|
|
40
|
+
--init [file] 生成一份带注释的示例配置(默认 ./${NAME}.config.json)
|
|
41
|
+
-h, --help 显示帮助
|
|
42
|
+
-v, --version 显示版本
|
|
43
|
+
|
|
44
|
+
示例
|
|
45
|
+
# OpenCode Go:客户端 Base URL 填 http://127.0.0.1:9355/zen/go/v1
|
|
46
|
+
${NAME}
|
|
47
|
+
|
|
48
|
+
# 把客户端的 /v1/... 映射到上游的 /zen/go/v1/...
|
|
49
|
+
${NAME} -u https://opencode.ai --path-rewrite "^/v1/=>/zen/go/v1/"
|
|
50
|
+
|
|
51
|
+
# 任意自建上游:换地址 + 换注入头
|
|
52
|
+
${NAME} -u https://api.example.com --inject "x-api-version=2026-01-01" \\
|
|
53
|
+
--inject "x-session-id={{session.id}}" --model-prefix ""
|
|
54
|
+
|
|
55
|
+
# 生成配置文件后按需修改
|
|
56
|
+
${NAME} --init
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
const VALUE_FLAGS = new Set([
|
|
60
|
+
'-c',
|
|
61
|
+
'--config',
|
|
62
|
+
'-p',
|
|
63
|
+
'--port',
|
|
64
|
+
'--host',
|
|
65
|
+
'-u',
|
|
66
|
+
'--upstream',
|
|
67
|
+
'--base-path',
|
|
68
|
+
'--path-rewrite',
|
|
69
|
+
'--inject',
|
|
70
|
+
'--body-inject',
|
|
71
|
+
'--model-prefix',
|
|
72
|
+
'--model-map',
|
|
73
|
+
'--session-header',
|
|
74
|
+
'--session-field',
|
|
75
|
+
'--session-id-format',
|
|
76
|
+
'--request-id-format',
|
|
77
|
+
'--timeout',
|
|
78
|
+
'--max-body',
|
|
79
|
+
'--log-level',
|
|
80
|
+
'--log-file',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
function setByPath(target, keyPath, value) {
|
|
84
|
+
let cursor = target;
|
|
85
|
+
for (let i = 0; i < keyPath.length - 1; i += 1) {
|
|
86
|
+
const key = keyPath[i];
|
|
87
|
+
if (!cursor[key] || typeof cursor[key] !== 'object') cursor[key] = {};
|
|
88
|
+
cursor = cursor[key];
|
|
89
|
+
}
|
|
90
|
+
cursor[keyPath[keyPath.length - 1]] = value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function splitOnce(text, separator) {
|
|
94
|
+
const index = text.indexOf(separator);
|
|
95
|
+
if (index === -1) return [text, undefined];
|
|
96
|
+
return [text.slice(0, index), text.slice(index + separator.length)];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 解析命令行参数,返回用户显式给出的配置片段。
|
|
101
|
+
* 纯函数,不触碰进程状态,便于单元测试。
|
|
102
|
+
*/
|
|
103
|
+
export function parseArgv(argv) {
|
|
104
|
+
const flags = {};
|
|
105
|
+
const push = (keyPath, value) => {
|
|
106
|
+
const current = keyPath.reduce((acc, key) => acc?.[key], flags);
|
|
107
|
+
if (Array.isArray(current)) current.push(value);
|
|
108
|
+
else setByPath(flags, keyPath, [value]);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
let configFile = null;
|
|
112
|
+
let printConfig = false;
|
|
113
|
+
let initFile = null;
|
|
114
|
+
let initRequested = false;
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
117
|
+
const raw = argv[i];
|
|
118
|
+
if (!raw.startsWith('-')) {
|
|
119
|
+
throw new Error(`无法识别的参数: ${raw}`);
|
|
120
|
+
}
|
|
121
|
+
const [flag, inlineValue] = splitOnce(raw, '=');
|
|
122
|
+
const takeValue = () => {
|
|
123
|
+
if (inlineValue !== undefined) return inlineValue;
|
|
124
|
+
const next = argv[i + 1];
|
|
125
|
+
if (next === undefined || next.startsWith('-')) throw new Error(`参数 ${flag} 缺少取值`);
|
|
126
|
+
i += 1;
|
|
127
|
+
return next;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
switch (flag) {
|
|
131
|
+
case '-h':
|
|
132
|
+
case '--help':
|
|
133
|
+
return { help: true };
|
|
134
|
+
case '-v':
|
|
135
|
+
case '--version':
|
|
136
|
+
return { version: true };
|
|
137
|
+
case '-c':
|
|
138
|
+
case '--config':
|
|
139
|
+
configFile = takeValue();
|
|
140
|
+
break;
|
|
141
|
+
case '--print-config':
|
|
142
|
+
printConfig = true;
|
|
143
|
+
break;
|
|
144
|
+
case '--init': {
|
|
145
|
+
initRequested = true;
|
|
146
|
+
if (inlineValue !== undefined) {
|
|
147
|
+
initFile = inlineValue;
|
|
148
|
+
} else if (argv[i + 1] && !argv[i + 1].startsWith('-')) {
|
|
149
|
+
i += 1;
|
|
150
|
+
initFile = argv[i];
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case '-p':
|
|
155
|
+
case '--port':
|
|
156
|
+
setByPath(flags, ['listen', 'port'], Number(takeValue()));
|
|
157
|
+
break;
|
|
158
|
+
case '--host':
|
|
159
|
+
setByPath(flags, ['listen', 'host'], takeValue());
|
|
160
|
+
break;
|
|
161
|
+
case '-u':
|
|
162
|
+
case '--upstream':
|
|
163
|
+
setByPath(flags, ['upstream'], parseUpstream(takeValue()));
|
|
164
|
+
break;
|
|
165
|
+
case '--base-path':
|
|
166
|
+
setByPath(flags, ['upstream', 'basePath'], takeValue());
|
|
167
|
+
break;
|
|
168
|
+
case '--path-rewrite': {
|
|
169
|
+
const [pattern, replacement] = splitOnce(takeValue(), '=>');
|
|
170
|
+
if (replacement === undefined) throw new Error('--path-rewrite 需要 "正则=>替换" 形式');
|
|
171
|
+
push(['request', 'pathRewrite'], { pattern, replacement });
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case '--inject': {
|
|
175
|
+
const [name, value] = splitOnce(takeValue(), '=');
|
|
176
|
+
if (value === undefined) throw new Error('--inject 需要 name=value 形式');
|
|
177
|
+
setByPath(flags, ['inject', 'headers', name], value);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case '--body-inject': {
|
|
181
|
+
const [key, value] = splitOnce(takeValue(), '=');
|
|
182
|
+
if (value === undefined) throw new Error('--body-inject 需要 key=value 形式');
|
|
183
|
+
setByPath(flags, ['inject', 'body', key], value);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case '--model-prefix':
|
|
187
|
+
push(['model', 'stripPrefixes'], takeValue());
|
|
188
|
+
break;
|
|
189
|
+
case '--model-map': {
|
|
190
|
+
const [alias, real] = splitOnce(takeValue(), '=');
|
|
191
|
+
if (real === undefined) throw new Error('--model-map 需要 alias=real 形式');
|
|
192
|
+
setByPath(flags, ['model', 'map', alias], real);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case '--session-header':
|
|
196
|
+
push(['session', 'headerNames'], takeValue().toLowerCase());
|
|
197
|
+
break;
|
|
198
|
+
case '--session-field':
|
|
199
|
+
push(['session', 'bodyFields'], takeValue());
|
|
200
|
+
break;
|
|
201
|
+
case '--session-id-format':
|
|
202
|
+
setByPath(flags, ['session', 'idFormat'], takeValue());
|
|
203
|
+
break;
|
|
204
|
+
case '--request-id-format':
|
|
205
|
+
setByPath(flags, ['session', 'requestIdFormat'], takeValue());
|
|
206
|
+
break;
|
|
207
|
+
case '--no-session':
|
|
208
|
+
setByPath(flags, ['session', 'enabled'], false);
|
|
209
|
+
break;
|
|
210
|
+
case '--no-stream':
|
|
211
|
+
setByPath(flags, ['response', 'stream'], false);
|
|
212
|
+
break;
|
|
213
|
+
case '--timeout':
|
|
214
|
+
setByPath(flags, ['request', 'timeoutMs'], Number(takeValue()));
|
|
215
|
+
break;
|
|
216
|
+
case '--max-body':
|
|
217
|
+
setByPath(flags, ['request', 'maxBodyBytes'], Number(takeValue()));
|
|
218
|
+
break;
|
|
219
|
+
case '--log-level':
|
|
220
|
+
setByPath(flags, ['log', 'level'], takeValue());
|
|
221
|
+
break;
|
|
222
|
+
case '--log-file':
|
|
223
|
+
setByPath(flags, ['log', 'file'], takeValue());
|
|
224
|
+
break;
|
|
225
|
+
default:
|
|
226
|
+
if (VALUE_FLAGS.has(flag)) throw new Error(`参数 ${flag} 尚未实现`);
|
|
227
|
+
throw new Error(`无法识别的参数: ${flag}(用 --help 查看用法)`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { flags, configFile, printConfig, initRequested, initFile };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const SAMPLE_CONFIG = `{
|
|
235
|
+
// 说明:本文件支持 // 与 /* */ 注释以及尾随逗号。
|
|
236
|
+
// 所有字段都可以省略,省略即采用默认值。
|
|
237
|
+
|
|
238
|
+
"listen": { "host": "127.0.0.1", "port": 9355 },
|
|
239
|
+
|
|
240
|
+
// 上游。host 也可以直接写完整 URL,例如 "https://opencode.ai"
|
|
241
|
+
"upstream": {
|
|
242
|
+
"protocol": "https",
|
|
243
|
+
"host": "opencode.ai",
|
|
244
|
+
"port": null,
|
|
245
|
+
"basePath": "" // 转发时统一加的前缀,如 "/zen/go/v1"
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
"request": {
|
|
249
|
+
"bufferBody": true, // 需要改写请求体时必须为 true
|
|
250
|
+
"maxBodyBytes": 67108864,
|
|
251
|
+
"timeoutMs": 600000,
|
|
252
|
+
// 把客户端请求的 /v1/... 映射到上游的 /zen/go/v1/...
|
|
253
|
+
"pathRewrite": [
|
|
254
|
+
// { "pattern": "^/v1/", "replacement": "/zen/go/v1/" }
|
|
255
|
+
],
|
|
256
|
+
"dropHeaders": [],
|
|
257
|
+
"forwardClientSessionHeaders": true
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
"session": {
|
|
261
|
+
"enabled": true,
|
|
262
|
+
// 从哪里读取客户端自带的会话标识(优先级从高到低)
|
|
263
|
+
"headerNames": ["x-opencode-session", "x-session-id", "x-conversation-id", "x-thread-id"],
|
|
264
|
+
"bodyFields": ["session_id", "sessionId", "conversation_id", "conversationId"],
|
|
265
|
+
// 客户端没带会话标识时,用 system + 首条 user 消息的内容指纹兜底
|
|
266
|
+
"contentHash": {
|
|
267
|
+
"enabled": true,
|
|
268
|
+
"fields": ["system", "system_instruction", "instructions"],
|
|
269
|
+
"includeFirstUserMessage": true
|
|
270
|
+
},
|
|
271
|
+
"idPrefix": "ses_",
|
|
272
|
+
"idFormat": "hex26", // hex26 | hex | uuid | base36 | short
|
|
273
|
+
"requestIdFormat": "msg_{{session.count}}",
|
|
274
|
+
"maxSessions": 512,
|
|
275
|
+
"ttlSeconds": 0 // 0 表示不过期
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
"inject": {
|
|
279
|
+
// 值支持模板:{{session.id}} {{session.requestId}} {{session.count}}
|
|
280
|
+
// {{uuid}} {{random}} {{randomHex:16}} {{timestamp}} {{env.HOME}}
|
|
281
|
+
"headers": {
|
|
282
|
+
"x-opencode-session": "{{session.id}}",
|
|
283
|
+
"x-opencode-request": "{{session.requestId}}",
|
|
284
|
+
"x-opencode-client": "cli",
|
|
285
|
+
"x-opencode-project": "global"
|
|
286
|
+
},
|
|
287
|
+
"body": {}, // 追加到请求体的字段,如 { "temperature": 0.2 }
|
|
288
|
+
"removeBodyFields": [],
|
|
289
|
+
"overwrite": true // false 表示不覆盖客户端已有的同名头
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
"model": {
|
|
293
|
+
"enabled": true,
|
|
294
|
+
"field": "model",
|
|
295
|
+
"stripPrefixes": ["proxy-"], // 客户端为避开内置通道而加的前缀,这里剥掉
|
|
296
|
+
"map": {}, // 精确映射,优先于前缀剥离:{ "my-glm": "glm-5.3-flash" }
|
|
297
|
+
"default": null
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
"userAgent": "opencode/1.18.29 cli",
|
|
301
|
+
"userAgentMode": "replace-generic", // keep | replace | replace-generic
|
|
302
|
+
|
|
303
|
+
"response": { "stream": true, "timeoutMs": 600000 },
|
|
304
|
+
|
|
305
|
+
"log": { "level": "info", "file": null, "maxBytes": 5242880, "backups": 2 }
|
|
306
|
+
}
|
|
307
|
+
`;
|
|
308
|
+
|
|
309
|
+
export function printBanner(logger, config, proxy, url) {
|
|
310
|
+
const injected = Object.keys(config.inject.headers || {});
|
|
311
|
+
logger.info(`${NAME} v${VERSION} 已启动`);
|
|
312
|
+
logger.info(` 监听地址 ${url}`);
|
|
313
|
+
logger.info(` 上游 ${proxy.upstream.protocol}://${proxy.upstream.hostHeader}${proxy.upstream.basePath}`);
|
|
314
|
+
logger.info(` 注入请求头 ${injected.length ? injected.join(', ') : '(无)'}`);
|
|
315
|
+
logger.info(
|
|
316
|
+
` 模型别名 ${
|
|
317
|
+
config.model.stripPrefixes.length ? `剥离前缀 ${config.model.stripPrefixes.join(', ')}` : '未启用前缀剥离'
|
|
318
|
+
}${Object.keys(config.model.map || {}).length ? ` | 映射 ${JSON.stringify(config.model.map)}` : ''}`,
|
|
319
|
+
);
|
|
320
|
+
logger.info(
|
|
321
|
+
` 会话 ID ${
|
|
322
|
+
config.session.enabled
|
|
323
|
+
? `${config.session.idFormat},请求号 ${config.session.requestIdFormat}`
|
|
324
|
+
: '已关闭'
|
|
325
|
+
}`,
|
|
326
|
+
);
|
|
327
|
+
logger.info(
|
|
328
|
+
` 路径重写 ${
|
|
329
|
+
config.request.pathRewrite.length
|
|
330
|
+
? config.request.pathRewrite.map((rule) => `${rule.pattern} => ${rule.replacement}`).join(' | ')
|
|
331
|
+
: '(无,原样透传)'
|
|
332
|
+
}`,
|
|
333
|
+
);
|
|
334
|
+
logger.info(' ── 客户端 Base URL 怎么填 ──');
|
|
335
|
+
if (config.upstream.basePath) {
|
|
336
|
+
logger.info(` ${url} (自动补上 ${config.upstream.basePath})`);
|
|
337
|
+
logger.info(` 其他路径 ${url}/原路径`);
|
|
338
|
+
} else if (config.request.pathRewrite.length) {
|
|
339
|
+
logger.info(` ${url}/v1 (按上面的重写规则转到上游)`);
|
|
340
|
+
} else {
|
|
341
|
+
logger.info(` ${url}/zen/go/v1 或按上游路径原样拼接`);
|
|
342
|
+
}
|
|
343
|
+
logger.info(` 状态端点 ${url}/__llm_session_proxy__/status`);
|
|
344
|
+
if (config.__configPath) logger.info(` 配置文件 ${config.__configPath}`);
|
|
345
|
+
if (config.log.file) logger.info(` 日志文件 ${path.resolve(config.log.file)}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* CLI 主入口。返回时服务器已在后台运行(进程不会退出)。
|
|
350
|
+
* 错误通过 process.exitCode 表达,避免在测试中强杀进程。
|
|
351
|
+
*/
|
|
352
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
353
|
+
let parsed;
|
|
354
|
+
try {
|
|
355
|
+
parsed = parseArgv(argv);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
process.stderr.write(`${error.message}\n`);
|
|
358
|
+
process.exitCode = 2;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (parsed.help) {
|
|
363
|
+
process.stdout.write(`${HELP.trim()}\n`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (parsed.version) {
|
|
367
|
+
process.stdout.write(`${VERSION}\n`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (parsed.initRequested) {
|
|
371
|
+
const target = path.resolve(parsed.initFile || `${NAME}.config.json`);
|
|
372
|
+
if (fs.existsSync(target)) {
|
|
373
|
+
process.stderr.write(`文件已存在,未覆盖: ${target}\n`);
|
|
374
|
+
process.exitCode = 1;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
fs.writeFileSync(target, SAMPLE_CONFIG, 'utf8');
|
|
378
|
+
process.stdout.write(`已生成示例配置: ${target}\n按需修改后用 ${NAME} --config "${target}" 启动。\n`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let config;
|
|
383
|
+
try {
|
|
384
|
+
config = buildConfig({ file: parsed.configFile, flags: parsed.flags });
|
|
385
|
+
} catch (error) {
|
|
386
|
+
process.stderr.write(`${error.message}\n`);
|
|
387
|
+
process.exitCode = 2;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (parsed.printConfig) {
|
|
392
|
+
const printable = { ...config };
|
|
393
|
+
delete printable.__configPath;
|
|
394
|
+
process.stdout.write(`${JSON.stringify(printable, null, 2)}\n`);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const logger = new Logger({ ...config.log, console: config.log.level !== 'silent' });
|
|
399
|
+
const proxy = createProxyServer({ config, logger });
|
|
400
|
+
|
|
401
|
+
let address;
|
|
402
|
+
try {
|
|
403
|
+
address = await proxy.listen();
|
|
404
|
+
} catch (error) {
|
|
405
|
+
logger.error(`启动失败: ${error.message}`);
|
|
406
|
+
if (error.code === 'EADDRINUSE') {
|
|
407
|
+
logger.error(`端口 ${config.listen.port} 已被占用,换一个:${NAME} --port ${config.listen.port + 1}`);
|
|
408
|
+
}
|
|
409
|
+
process.exitCode = 1;
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const host = config.listen.host === '0.0.0.0' ? '127.0.0.1' : config.listen.host;
|
|
414
|
+
printBanner(logger, config, proxy, `http://${host}:${address.port}`);
|
|
415
|
+
|
|
416
|
+
const shutdown = async (signal) => {
|
|
417
|
+
logger.info(`收到 ${signal},正在关闭…`);
|
|
418
|
+
await proxy.close();
|
|
419
|
+
logger.info('已停止');
|
|
420
|
+
logger.close();
|
|
421
|
+
process.exit(0);
|
|
422
|
+
};
|
|
423
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
424
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
425
|
+
}
|