@wwkit/llmproxy 1.0.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.
@@ -0,0 +1,117 @@
1
+ // core/transport.js — 通用 SSE/JSON 响应透传
2
+ //
3
+ // 把上游 fetch Response 流到下游 http.ServerResponse。
4
+ // 上游按 fetch Response 形式传入(可以是流式或非流式)。
5
+ // 处理客户端断开:res 'close' → signal abort → 上游 fetch 被 abort。
6
+ // 通用:与具体 provider 无关,codearts / anthropic / openai 共用。
7
+ //
8
+ // 异常 SSE chunk 处理:snap-access 等上游偶尔会在 SSE 流中嵌入非标准错误
9
+ // (如 `{"text":"[DONE]","error_code":"InferHub.ModelArts.81027.400","error_msg":"..."}`),
10
+ // 客户端 AI SDK 无法解析会抛 Zod 校验错。这里检测到异常 chunk 时改写为标准
11
+ // OpenAI 错误响应并提前结束流。
12
+
13
+ import { log, openaiError } from "../util.js";
14
+
15
+ /**
16
+ * 检测 chunk 是否为 snap-access 异常错误格式
17
+ * @param {string} text
18
+ * @returns {boolean}
19
+ */
20
+ function isAbnormalErrorChunk(text) {
21
+ // InferHub.ModelArts.<n>.<n> 错误码 或 把 "[DONE]" 嵌入 text 字段
22
+ return /InferHub\.ModelArts\.\d+\.\d+/.test(text) ||
23
+ /"text"\s*:\s*"\[DONE\]"/.test(text);
24
+ }
25
+
26
+ /**
27
+ * 从异常 chunk 中提取 error_code / error_msg
28
+ * @param {string} text
29
+ * @returns {{code: string, message: string}}
30
+ */
31
+ function parseAbnormalError(text) {
32
+ const codeMatch = text.match(/"error_code"\s*:\s*"([^"]+)"/);
33
+ const msgMatch = text.match(/"error_msg"\s*:\s*"([^"]+)"/);
34
+ return {
35
+ code: codeMatch?.[1] || "upstream_stream_error",
36
+ message: msgMatch?.[1] || text.slice(0, 200),
37
+ };
38
+ }
39
+
40
+ /**
41
+ * 把上游 Response 透传到下游 res(流式 SSE)
42
+ * @param {object} upstream fetch Response(status, body, headers)
43
+ * @param {object} res http.ServerResponse
44
+ * @param {object} ctx { id, clientGone } 日志前缀 + 客户端断开信号
45
+ * @returns {Promise<void>}
46
+ */
47
+ export async function streamSSE(upstream, res, ctx) {
48
+ const { id, clientGone } = ctx;
49
+ res.writeHead(200, {
50
+ "Content-Type": "text/event-stream; charset=utf-8",
51
+ "Cache-Control": "no-cache",
52
+ Connection: "keep-alive",
53
+ });
54
+ const reader = upstream.body.getReader();
55
+ let abnormalHandled = false;
56
+ try {
57
+ while (true) {
58
+ const { done: rdDone, value } = await reader.read();
59
+ if (rdDone) break;
60
+ const text = new TextDecoder().decode(value, { stream: true });
61
+ if (!abnormalHandled && isAbnormalErrorChunk(text)) {
62
+ const { code, message } = parseAbnormalError(text);
63
+ log(`[chat#${id}] 上游 SSE 流中检测到异常 chunk (${code}),改写为标准错误响应`);
64
+ const errBody = JSON.stringify({
65
+ error: { message, type: "upstream_error", code },
66
+ });
67
+ res.write(`data: ${errBody}\n\n`);
68
+ res.write("data: [DONE]\n\n");
69
+ abnormalHandled = true;
70
+ // 提前结束:不再透传后续 chunk
71
+ break;
72
+ }
73
+ res.write(value);
74
+ }
75
+ } catch (e) {
76
+ if (e.name !== "AbortError") log(`[chat#${id}] 流式传输异常: ${e.message}`);
77
+ } finally {
78
+ reader.releaseLock();
79
+ upstream.body.cancel().catch(() => {});
80
+ }
81
+ res.end();
82
+ }
83
+
84
+ /**
85
+ * 把上游 Response 透传为非流式 JSON
86
+ * @param {object} upstream fetch Response
87
+ * @param {object} res http.ServerResponse
88
+ * @returns {Promise<void>}
89
+ */
90
+ export async function streamJSON(upstream, res) {
91
+ const data = await upstream.json();
92
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
93
+ res.end(JSON.stringify(data));
94
+ }
95
+
96
+ /**
97
+ * 创建"客户端断开"信号控制器
98
+ * 监听 res 'close' 事件,客户端断开时 abort
99
+ * @param {object} res http.ServerResponse
100
+ * @returns {AbortController}
101
+ */
102
+ export function clientGoneSignal(res) {
103
+ const ctrl = new AbortController();
104
+ res.on("close", () => ctrl.abort());
105
+ return ctrl;
106
+ }
107
+
108
+ /**
109
+ * 把上游错误响应(4xx/5xx)转成 OpenAI 风格错误响应写给客户端
110
+ * @param {object} res
111
+ * @param {number} status
112
+ * @param {string} text
113
+ */
114
+ export function writeUpstreamError(res, status, text) {
115
+ const code = status === 401 ? 502 : status;
116
+ openaiError(res, `上游错误: ${text.slice(0, 500)}`, code, "upstream_error");
117
+ }
@@ -0,0 +1,55 @@
1
+ // src/ctl-impl.js — bridge 进程控制共享工具(探活/停止)
2
+ import { execSync } from 'node:child_process'
3
+ import { fetchWithTimeout } from './util.js'
4
+
5
+ export function pidsOnPort(port) {
6
+ try {
7
+ const out = execSync(`lsof -tiTCP:${port} -sTCP:LISTEN`, { encoding: 'utf8' }).trim()
8
+ return out ? out.split('\n').map(Number).filter(Boolean) : []
9
+ } catch {
10
+ return []
11
+ }
12
+ }
13
+
14
+ function cmdOf(pid) {
15
+ try {
16
+ return execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8' }).trim()
17
+ } catch {
18
+ return ''
19
+ }
20
+ }
21
+
22
+ export async function probeBridge(port, timeoutMs = 1500) {
23
+ if (!pidsOnPort(port).length) return null
24
+ try {
25
+ const res = await fetchWithTimeout(`http://127.0.0.1:${port}/health`, {}, timeoutMs)
26
+ if (!res.ok) return false
27
+ const body = await res.json().catch(() => null)
28
+ return Boolean(body && body.ok === true)
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ export async function stopBridge(port, log = console.log) {
35
+ const pids = pidsOnPort(port)
36
+ if (!pids.length) {
37
+ log(`端口 ${port} 无监听进程,无需停止`)
38
+ return false
39
+ }
40
+ let killed = false
41
+ for (const pid of pids) {
42
+ const cmd = cmdOf(pid)
43
+ if (/node\b/.test(cmd) && /server\.js/.test(cmd)) {
44
+ process.kill(pid, 'SIGTERM')
45
+ killed = true
46
+ log(`已停止 llmproxy (pid=${pid})`)
47
+ } else {
48
+ log(`端口 ${port} 被 pid=${pid}(${cmd.slice(0, 60)})占用,不是本代理,跳过`)
49
+ }
50
+ }
51
+ for (let i = 0; i < 20 && pidsOnPort(port).length; i++) {
52
+ await new Promise(r => setTimeout(r, 250))
53
+ }
54
+ return killed
55
+ }
package/src/ctl.js ADDED
@@ -0,0 +1,311 @@
1
+ // src/ctl.js — 代理生命周期控制:status / start / stop / restart / login / clear
2
+ import { spawn } from 'node:child_process'
3
+ import { openSync, truncateSync, existsSync, unlinkSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { getXdgConfigDir } from '@wwkit/shared'
7
+ import { config, getProviderIds, getProviderConfig } from './config.js'
8
+ import { stopBridge, probeBridge, pidsOnPort } from './ctl-impl.js'
9
+ import { sleep } from './util.js'
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
+ const USER_CONFIG_DIR = getXdgConfigDir('llmproxy')
13
+ const CREDS_FILE = path.join(USER_CONFIG_DIR, '.creds.json')
14
+ const LOG_FILE = path.join(USER_CONFIG_DIR, 'llmproxy.log')
15
+
16
+ // ---- status ----
17
+ function getProviderStatus(id) {
18
+ if (!existsSync(CREDS_FILE)) return '未登录'
19
+ try {
20
+ const all = JSON.parse(readFileSync(CREDS_FILE, 'utf8'))
21
+ const cred = all.providers?.[id]
22
+ if (!cred) return '未登录'
23
+ if (cred.expiresAt && cred.expiresAt < Date.now()) return '已过期'
24
+ return '已登录'
25
+ } catch {
26
+ return '未登录'
27
+ }
28
+ }
29
+
30
+ export async function status() {
31
+ const p = await probeBridge(config.port)
32
+ if (p === true) {
33
+ const pids = pidsOnPort(config.port)
34
+ const pidStr = pids.length ? ` (pid=${pids.join(',')})` : ''
35
+ console.log(`已运行: http://127.0.0.1:${config.port}${pidStr}`)
36
+ console.log('')
37
+ console.log('Providers:')
38
+ for (const id of getProviderIds()) {
39
+ const status = getProviderStatus(id)
40
+ const statusTag = status === '已登录' ? '✓' : status === '已过期' ? '⏰' : '✗'
41
+ console.log(` ${statusTag} ${id} (${status})`)
42
+ if (status === '已登录') {
43
+ console.log(` GET http://127.0.0.1:${config.port}/${id}/v1/models`)
44
+ console.log(` POST http://127.0.0.1:${config.port}/${id}/v1/chat/completions`)
45
+ }
46
+ }
47
+ } else if (p === false) {
48
+ console.error(`端口 ${config.port} 被其他程序占用(非本代理)。`)
49
+ console.error(`查看: lsof -iTCP:${config.port} -sTCP:LISTEN`)
50
+ process.exit(1)
51
+ } else {
52
+ console.log(`未运行(端口 ${config.port} 空闲)。启动: llmproxy start`)
53
+ }
54
+ }
55
+
56
+ // ---- stop ----
57
+ export async function stop() {
58
+ await stopBridge(config.port)
59
+ }
60
+
61
+ // ---- start ----
62
+ export async function start() {
63
+ const p = await probeBridge(config.port)
64
+ if (p === true) {
65
+ console.log(`llmproxy 已在运行: http://127.0.0.1:${config.port}`)
66
+ console.log('如需重启: llmproxy restart')
67
+ process.exit(0)
68
+ }
69
+ if (p === false) {
70
+ console.error(`端口 ${config.port} 被其他程序占用,启动中止。`)
71
+ console.error(`查看: lsof -iTCP:${config.port} -sTCP:LISTEN;或 config.json5 改 port`)
72
+ process.exit(1)
73
+ }
74
+
75
+ await spawnServer()
76
+ }
77
+
78
+ // ---- restart ----
79
+ export async function restart() {
80
+ const killed = await stopBridge(config.port)
81
+ if (!killed) console.log('无旧实例,直接启动')
82
+ await spawnServer()
83
+ }
84
+
85
+ // ---- login ----
86
+ export async function login(opts = {}) {
87
+ const providerId = opts.provider
88
+ if (!providerId) {
89
+ console.error('必须指定 provider: llmproxy login -p <id>')
90
+ console.error(`可用: ${getProviderIds().join(', ')}`)
91
+ process.exit(1)
92
+ }
93
+ const providerConfig = getProviderConfig(providerId)
94
+ if (!providerConfig) {
95
+ console.error(`未知 provider: ${providerId}`)
96
+ console.error(`可用: ${getProviderIds().join(', ')}`)
97
+ process.exit(1)
98
+ }
99
+
100
+ // 1. 停掉旧实例
101
+ await stopBridge(config.port)
102
+
103
+ // 2. 删凭证(仅删该 provider 的子项,保留其它 provider)
104
+ if (existsSync(CREDS_FILE)) {
105
+ try {
106
+ const all = JSON.parse(readFileSync(CREDS_FILE, 'utf8'))
107
+ if (all.providers?.[providerId]) {
108
+ delete all.providers[providerId]
109
+ if (Object.keys(all.providers).length === 0) {
110
+ unlinkSync(CREDS_FILE)
111
+ console.log('已删除 .creds.json(最后一份凭证)')
112
+ } else {
113
+ writeFileSync(CREDS_FILE, JSON.stringify(all, null, 2), { mode: 0o600 })
114
+ console.log(`已删除 .creds.json 中 ${providerId} 的凭证`)
115
+ }
116
+ } else {
117
+ console.log(`.creds.json 中没有 ${providerId} 的凭证,跳过`)
118
+ }
119
+ } catch (e) {
120
+ console.log(`.creds.json 解析失败,删除整文件: ${e.message}`)
121
+ unlinkSync(CREDS_FILE)
122
+ }
123
+ } else {
124
+ console.log('.creds.json 不存在,跳过')
125
+ }
126
+
127
+ // 3. 后台 spawn(server 启动后因凭证缺失自动触发浏览器登录)
128
+ const env = {
129
+ ...process.env,
130
+ LLMPROXY_LOGIN: providerId,
131
+ }
132
+
133
+ await spawnServer(env)
134
+
135
+ console.log(`server 已启动,凭证缺失将自动打开浏览器登录 ${providerId}`)
136
+ console.log('请在浏览器中完成登录')
137
+ }
138
+
139
+ // ---- clear ----
140
+ export async function clearProvider(providerId) {
141
+ // 确定要清理的 provider 列表
142
+ let providerIds
143
+ if (providerId) {
144
+ const allIds = getProviderIds()
145
+ if (!allIds.includes(providerId)) {
146
+ console.error(`未知 provider: ${providerId}`)
147
+ console.error(`可用: ${allIds.join(', ')}`)
148
+ process.exit(1)
149
+ }
150
+ providerIds = [providerId]
151
+ } else {
152
+ providerIds = getProviderIds()
153
+ }
154
+
155
+ if (providerIds.length === 0) {
156
+ console.log('没有配置任何 provider')
157
+ return
158
+ }
159
+
160
+ let deletedCreds = 0
161
+ let deletedProfiles = 0
162
+
163
+ for (const id of providerIds) {
164
+ // 1. 删除凭证
165
+ if (existsSync(CREDS_FILE)) {
166
+ try {
167
+ const all = JSON.parse(readFileSync(CREDS_FILE, 'utf8'))
168
+ if (all.providers?.[id]) {
169
+ delete all.providers[id]
170
+ deletedCreds++
171
+ if (Object.keys(all.providers).length === 0) {
172
+ unlinkSync(CREDS_FILE)
173
+ } else {
174
+ writeFileSync(CREDS_FILE, JSON.stringify(all, null, 2), { mode: 0o600 })
175
+ }
176
+ }
177
+ } catch {}
178
+ }
179
+
180
+ // 2. 删除 Chrome profile 目录
181
+ const profileDir = path.join(
182
+ USER_CONFIG_DIR,
183
+ "chrome-profiles",
184
+ id.replace(/\//g, "-")
185
+ )
186
+ if (existsSync(profileDir)) {
187
+ rmSync(profileDir, { recursive: true, force: true })
188
+ deletedProfiles++
189
+ console.log(`已删除 profile: ${id}`)
190
+ }
191
+ }
192
+
193
+ console.log(`已清理: ${deletedCreds} 个凭证, ${deletedProfiles} 个 profile`)
194
+ }
195
+
196
+ // ---- 内部函数 ----
197
+
198
+ async function spawnServer(env = {}) {
199
+ try { truncateSync(LOG_FILE, 0) } catch {}
200
+ const out = openSync(LOG_FILE, 'a')
201
+ const child = spawn(process.execPath, [path.join(__dirname, 'server.js')], {
202
+ stdio: ['ignore', out, out],
203
+ detached: true,
204
+ env,
205
+ })
206
+ child.unref()
207
+
208
+ for (let i = 0; i < 40; i++) {
209
+ await sleep(200)
210
+ try {
211
+ const res = await fetch(`http://127.0.0.1:${config.port}/health`)
212
+ if (res.ok) {
213
+ console.log(`llmproxy 已启动: http://127.0.0.1:${config.port}(日志: ${LOG_FILE})`)
214
+ process.exit(0)
215
+ }
216
+ } catch {}
217
+ }
218
+ console.error('启动超时或失败,请查看:', LOG_FILE)
219
+ console.error(`可能原因: 端口 ${config.port} 已被非本代理进程占上(lsof -iTCP:${config.port} -sTCP:LISTEN)`)
220
+ process.exit(1)
221
+ }
222
+
223
+ function parseProviderArg(argv) {
224
+ for (let i = 0; i < argv.length - 1; i++) {
225
+ if (argv[i] === '--provider' || argv[i] === '-p') return argv[i + 1]
226
+ }
227
+ return null
228
+ }
229
+
230
+ // 导出 parseProviderArg 供 bin/index.js 使用
231
+ export { parseProviderArg }
232
+
233
+ // 导出 set/remove 操作供 bin/index.js 使用
234
+ export { setProviderToTarget, removeProviderFromTarget } from './set.js'
235
+
236
+ // ---- provider ----
237
+ export function showProviderConfig(providerId) {
238
+ if (providerId) {
239
+ const providerConfig = getProviderConfig(providerId)
240
+ if (!providerConfig) {
241
+ console.error(`未知 provider: ${providerId}`)
242
+ console.error(`可用: ${getProviderIds().join(', ')}`)
243
+ process.exit(1)
244
+ }
245
+ console.log(`# ${providerId}`)
246
+ console.log(JSON.stringify(providerConfig, null, 2))
247
+ } else {
248
+ const ids = getProviderIds()
249
+ if (ids.length === 0) {
250
+ console.log('没有配置任何 provider')
251
+ return
252
+ }
253
+ for (const id of ids) {
254
+ console.log(`# ${id}`)
255
+ console.log(JSON.stringify(getProviderConfig(id), null, 2))
256
+ console.log('')
257
+ }
258
+ }
259
+ }
260
+
261
+ // ---- debug ----
262
+ export async function debug(providerId) {
263
+ const { execSync } = await import('node:child_process')
264
+ const ids = providerId ? [providerId] : getProviderIds()
265
+ if (ids.length === 0) {
266
+ console.log('没有配置任何 provider')
267
+ return
268
+ }
269
+
270
+ for (const id of ids) {
271
+ const providerConfig = getProviderConfig(id)
272
+ if (!providerConfig) {
273
+ console.error(`未知 provider: ${id}`)
274
+ continue
275
+ }
276
+ const models = providerConfig.models || []
277
+ if (models.length === 0) {
278
+ console.log(`${id}: 无模型配置`)
279
+ continue
280
+ }
281
+
282
+ console.log(`\n# ${id} (${models.length} models)`)
283
+ for (const model of models) {
284
+ const url = `http://127.0.0.1:${config.port}/${id}/v1/chat/completions`
285
+ const body = JSON.stringify({
286
+ model,
287
+ messages: [{ role: 'user', content: 'hi' }],
288
+ max_tokens: 1,
289
+ stream: false,
290
+ })
291
+ const t0 = Date.now()
292
+ try {
293
+ const out = execSync(
294
+ `curl -s -m 15 -X POST "${url}" -H "Content-Type: application/json" -H "Authorization: Bearer ${config.proxyApiKey}" -d '${body.replace(/'/g, "'\\''")}'`,
295
+ { encoding: 'utf8', timeout: 20000 }
296
+ )
297
+ const elapsed = Date.now() - t0
298
+ const parsed = JSON.parse(out)
299
+ if (parsed.error) {
300
+ console.log(` ✗ ${model} ${elapsed}ms ${parsed.error.message || JSON.stringify(parsed.error)}`)
301
+ } else {
302
+ console.log(` ✓ ${model} ${elapsed}ms`)
303
+ }
304
+ } catch (e) {
305
+ const elapsed = Date.now() - t0
306
+ const msg = e.stderr || e.message || 'unknown error'
307
+ console.log(` ✗ ${model} ${elapsed}ms ${msg.slice(0, 120)}`)
308
+ }
309
+ }
310
+ }
311
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // src/index.js — @wwkit/llmproxy 模块导出
2
+ export { config, getConfig, getProviderIds, getProviderConfig, reloadConfig } from './config.js'
3
+ export { log, sleep, fetchWithTimeout, readBody, json, openaiError } from './util.js'
4
+ export { boot } from './server.js'