@wwkit/llmproxy 1.0.3 → 1.0.5
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/package.json +3 -3
- package/src/config.json5 +34 -0
- package/src/core/factory.js +1 -15
- package/src/core/transport.js +5 -49
- package/src/ctl-impl.js +122 -1
- package/src/ctl.js +35 -4
- package/src/providers/codearts/auth.js +3 -21
- package/src/providers/codearts/client.js +88 -3
- package/src/providers/deepseek-web/auth.js +278 -0
- package/src/providers/deepseek-web/client.js +171 -0
- package/src/providers/deepseek-web/config.js +22 -0
- package/src/providers/deepseek-web/error.js +28 -0
- package/src/providers/deepseek-web/pow.js +279 -0
- package/src/providers/deepseek-web/sign.js +10 -0
- package/src/providers/deepseek-web/sse.js +202 -0
- package/src/providers/deepseek-web/tools.js +53 -0
- package/src/providers/qianwen-web/auth.js +258 -0
- package/src/providers/qianwen-web/client.js +159 -0
- package/src/providers/qianwen-web/config.js +22 -0
- package/src/providers/qianwen-web/error.js +10 -0
- package/src/providers/qianwen-web/sign.js +86 -0
- package/src/providers/qianwen-web/sse.js +157 -0
- package/src/providers/qianwen-web/tools.js +36 -0
- package/src/server.js +11 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wwkit/llmproxy",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"author": "bluesliu <langcai163@163.com>",
|
|
5
5
|
"description": "Generic OpenAI-compatible proxy for multiple LLM providers (codearts, ...). Pluggable via config.json5.",
|
|
6
6
|
"type": "module",
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"json5": "^2.2.3",
|
|
23
23
|
"undici": "^8.10.1",
|
|
24
|
-
"@wwkit/
|
|
25
|
-
"@wwkit/
|
|
24
|
+
"@wwkit/shared": "1.0.5",
|
|
25
|
+
"@wwkit/cft": "1.0.5"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"registry": "https://registry.npmjs.org/",
|
package/src/config.json5
CHANGED
|
@@ -6,6 +6,21 @@
|
|
|
6
6
|
// 客户端访问本代理时的 API Key
|
|
7
7
|
proxyApiKey: "noapikey",
|
|
8
8
|
|
|
9
|
+
// openai-tool-bridge(OpenAI tools → prompt 注入桥)
|
|
10
|
+
// 端口独立于 llmproxy;进程随 start/stop/restart 一起管理。
|
|
11
|
+
bridge: {
|
|
12
|
+
port: 1083,
|
|
13
|
+
// 上游即本 llmproxy 的 deepseek-web 入口(不带 /v1,joinUpstreamURL 会处理)
|
|
14
|
+
upstreamBaseUrl: "http://127.0.0.1:1082/deepseek-web/v1",
|
|
15
|
+
model: "deepseek-chat",
|
|
16
|
+
authMode: "passthrough",
|
|
17
|
+
timeoutSeconds: 120,
|
|
18
|
+
// 二进制路径;未指定时依次尝试 ~/.local/bin/openai-tool-bridge / openai-tool-bridge
|
|
19
|
+
binary: "",
|
|
20
|
+
// TOML 配置文件路径;留空则用默认值动态生成
|
|
21
|
+
configFile: "",
|
|
22
|
+
},
|
|
23
|
+
|
|
9
24
|
// 全局重试 + 冷却参数(所有 provider 共享)
|
|
10
25
|
retry: {
|
|
11
26
|
sessionLimitMaxWaitMs: 600000, // TM.00001041 等并发超限:最多等 10 分钟
|
|
@@ -47,5 +62,24 @@
|
|
|
47
62
|
"deepseek-v4-pro-0813",
|
|
48
63
|
],
|
|
49
64
|
},
|
|
65
|
+
"deepseek-web": {
|
|
66
|
+
auth: {
|
|
67
|
+
type: "web", // 浏览器手动登录(手机验证码),CDP 捕获凭证
|
|
68
|
+
},
|
|
69
|
+
models: [
|
|
70
|
+
"deepseek-chat",
|
|
71
|
+
"deepseek-reasoner",
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
"qianwen-web": {
|
|
75
|
+
auth: {
|
|
76
|
+
type: "web", // 浏览器手动登录(手机验证码),CDP 捕获凭证
|
|
77
|
+
},
|
|
78
|
+
models: [
|
|
79
|
+
"qwen",
|
|
80
|
+
"qwen3.8-max",
|
|
81
|
+
"qwen3.7-max",
|
|
82
|
+
],
|
|
83
|
+
},
|
|
50
84
|
},
|
|
51
85
|
}
|
package/src/core/factory.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// 新增 provider 只需在 providers/<id>/ 下放对应模块文件,factory 自动发现。
|
|
5
5
|
//
|
|
6
6
|
// 模块文件必须 export default class(继承对应抽象基类)。
|
|
7
|
-
// 可选导出常量:SIGN_CONFIG, ERROR_CONFIG
|
|
7
|
+
// 可选导出常量:SIGN_CONFIG, ERROR_CONFIG(固定实现细节,无需用户配置)。
|
|
8
8
|
|
|
9
9
|
import path from "node:path"
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
@@ -86,17 +86,3 @@ export async function buildClient(providerId, providerConfig, deps) {
|
|
|
86
86
|
basePath: signConfig.basePath,
|
|
87
87
|
})
|
|
88
88
|
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* 获取 provider 的 benefit 模型列表(从 config.js 模块读取)
|
|
92
|
-
* @param {string} providerId
|
|
93
|
-
* @returns {Promise<string[]>}
|
|
94
|
-
*/
|
|
95
|
-
export async function getBenefitModels(providerId) {
|
|
96
|
-
try {
|
|
97
|
-
const mod = await loadModule(providerModule(providerId, 'config.js'))
|
|
98
|
-
return mod.BENEFIT_MODELS || []
|
|
99
|
-
} catch {
|
|
100
|
-
return []
|
|
101
|
-
}
|
|
102
|
-
}
|
package/src/core/transport.js
CHANGED
|
@@ -3,40 +3,10 @@
|
|
|
3
3
|
// 把上游 fetch Response 流到下游 http.ServerResponse。
|
|
4
4
|
// 上游按 fetch Response 形式传入(可以是流式或非流式)。
|
|
5
5
|
// 处理客户端断开:res 'close' → signal abort → 上游 fetch 被 abort。
|
|
6
|
-
// 通用:与具体 provider
|
|
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 错误响应并提前结束流。
|
|
6
|
+
// 通用:与具体 provider 无关,所有 provider 共用。
|
|
12
7
|
|
|
13
8
|
import { log, openaiError } from "../util.js";
|
|
14
9
|
|
|
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
10
|
/**
|
|
41
11
|
* 把上游 Response 透传到下游 res(流式 SSE)
|
|
42
12
|
* @param {object} upstream fetch Response(status, body, headers)
|
|
@@ -45,31 +15,17 @@ function parseAbnormalError(text) {
|
|
|
45
15
|
* @returns {Promise<void>}
|
|
46
16
|
*/
|
|
47
17
|
export async function streamSSE(upstream, res, ctx) {
|
|
48
|
-
const { id
|
|
18
|
+
const { id } = ctx;
|
|
49
19
|
res.writeHead(200, {
|
|
50
20
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
51
21
|
"Cache-Control": "no-cache",
|
|
52
22
|
Connection: "keep-alive",
|
|
53
23
|
});
|
|
54
24
|
const reader = upstream.body.getReader();
|
|
55
|
-
let abnormalHandled = false;
|
|
56
25
|
try {
|
|
57
26
|
while (true) {
|
|
58
|
-
const { done
|
|
59
|
-
if (
|
|
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
|
-
}
|
|
27
|
+
const { done, value } = await reader.read();
|
|
28
|
+
if (done) break;
|
|
73
29
|
res.write(value);
|
|
74
30
|
}
|
|
75
31
|
} catch (e) {
|
|
@@ -114,4 +70,4 @@ export function clientGoneSignal(res) {
|
|
|
114
70
|
export function writeUpstreamError(res, status, text) {
|
|
115
71
|
const code = status === 401 ? 502 : status;
|
|
116
72
|
openaiError(res, `上游错误: ${text.slice(0, 500)}`, code, "upstream_error");
|
|
117
|
-
}
|
|
73
|
+
}
|
package/src/ctl-impl.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// src/ctl-impl.js — bridge 进程控制共享工具(探活/停止)
|
|
2
|
-
import { execSync } from 'node:child_process'
|
|
2
|
+
import { execSync, spawn } from 'node:child_process'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
3
6
|
import { fetchWithTimeout } from './util.js'
|
|
4
7
|
|
|
5
8
|
export function pidsOnPort(port) {
|
|
@@ -31,6 +34,7 @@ export async function probeBridge(port, timeoutMs = 1500) {
|
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
// ---- llmproxy 停止:只杀 node server.js ----
|
|
34
38
|
export async function stopBridge(port, log = console.log) {
|
|
35
39
|
const pids = pidsOnPort(port)
|
|
36
40
|
if (!pids.length) {
|
|
@@ -53,3 +57,120 @@ export async function stopBridge(port, log = console.log) {
|
|
|
53
57
|
}
|
|
54
58
|
return killed
|
|
55
59
|
}
|
|
60
|
+
|
|
61
|
+
// ---- openai-tool-bridge 控制 ----
|
|
62
|
+
|
|
63
|
+
// 定位 bridge 二进制
|
|
64
|
+
export function resolveBridgeBinary(configured = '') {
|
|
65
|
+
const candidates = [
|
|
66
|
+
configured.trim(),
|
|
67
|
+
path.join(process.env.HOME || '', '.local/bin/openai-tool-bridge'),
|
|
68
|
+
'openai-tool-bridge',
|
|
69
|
+
].filter(Boolean)
|
|
70
|
+
for (const c of candidates) {
|
|
71
|
+
if (fs.existsSync(c)) return c
|
|
72
|
+
}
|
|
73
|
+
return candidates[0] // 返回候选,spawn 时依赖 PATH
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 生成 bridge TOML 配置内容
|
|
77
|
+
export function bridgeToml({ port, upstreamBaseUrl, model, authMode, timeoutSeconds, debug = false }) {
|
|
78
|
+
return `[server]
|
|
79
|
+
listen = ":${port}"
|
|
80
|
+
debug = ${debug}
|
|
81
|
+
|
|
82
|
+
[upstream]
|
|
83
|
+
base_url = "${upstreamBaseUrl}"
|
|
84
|
+
model = "${model}"
|
|
85
|
+
api_key = ""
|
|
86
|
+
auth_mode = "${authMode}"
|
|
87
|
+
timeout_seconds = ${timeoutSeconds}
|
|
88
|
+
`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const BRIDGE_CONFIG_FILE = path.join(
|
|
92
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
93
|
+
'..', 'openai-tool-bridge.toml',
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
// 确保 bridge 配置就绪(不存在则写入),返回配置文件路径
|
|
97
|
+
export function ensureBridgeConfig({ port, upstreamBaseUrl, model, authMode, timeoutSeconds }) {
|
|
98
|
+
if (fs.existsSync(BRIDGE_CONFIG_FILE)) {
|
|
99
|
+
// 若内容不一致则覆写,保证配置与 config.json5 同步
|
|
100
|
+
try {
|
|
101
|
+
const cur = fs.readFileSync(BRIDGE_CONFIG_FILE, 'utf8')
|
|
102
|
+
const want = bridgeToml({ port, upstreamBaseUrl, model, authMode, timeoutSeconds })
|
|
103
|
+
if (cur !== want) fs.writeFileSync(BRIDGE_CONFIG_FILE, want)
|
|
104
|
+
} catch {}
|
|
105
|
+
} else {
|
|
106
|
+
fs.writeFileSync(BRIDGE_CONFIG_FILE, bridgeToml({ port, upstreamBaseUrl, model, authMode, timeoutSeconds }))
|
|
107
|
+
}
|
|
108
|
+
return BRIDGE_CONFIG_FILE
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 探活 bridge(/healthz)
|
|
112
|
+
export async function probeBridgeHealth(port, timeoutMs = 1500) {
|
|
113
|
+
if (!pidsOnPort(port).length) return null
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/healthz`, {}, timeoutMs)
|
|
116
|
+
if (!res.ok) return false
|
|
117
|
+
const body = await res.json().catch(() => null)
|
|
118
|
+
return Boolean(body && body.ok === true)
|
|
119
|
+
} catch {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 启动 bridge 为后台进程;返回是否成功
|
|
125
|
+
export async function spawnBridge(config, log = console.log) {
|
|
126
|
+
const cfg = config?.bridge || {}
|
|
127
|
+
const port = cfg.port || 1083
|
|
128
|
+
const bin = resolveBridgeBinary(cfg.binary)
|
|
129
|
+
const configFile = ensureBridgeConfig({
|
|
130
|
+
port,
|
|
131
|
+
upstreamBaseUrl: cfg.upstreamBaseUrl || `http://127.0.0.1:${config?.port || 1082}/deepseek-web/v1`,
|
|
132
|
+
model: cfg.model || 'deepseek-chat',
|
|
133
|
+
authMode: cfg.authMode || 'passthrough',
|
|
134
|
+
timeoutSeconds: cfg.timeoutSeconds || 120,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
if (!fs.existsSync(bin)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`未找到 openai-tool-bridge 二进制(${bin})\n请先安装: go install github.com/lfhy/openai-tool-bridge@latest 或下载 release 到 ~/.local/bin/`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const out = fs.openSync('/tmp/openai-tool-bridge.log', 'a')
|
|
144
|
+
const child = spawn(bin, ['-c', configFile], {
|
|
145
|
+
stdio: ['ignore', out, out],
|
|
146
|
+
detached: true,
|
|
147
|
+
})
|
|
148
|
+
child.unref()
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < 30; i++) {
|
|
151
|
+
await new Promise(r => setTimeout(r, 200))
|
|
152
|
+
const ok = await probeBridgeHealth(port).catch(() => null)
|
|
153
|
+
if (ok === true) {
|
|
154
|
+
log(`openai-tool-bridge 已启动: http://127.0.0.1:${port}(日志: /tmp/openai-tool-bridge.log)`)
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`openai-tool-bridge 启动超时(端口 ${port}),日志: /tmp/openai-tool-bridge.log`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 停止 bridge(按进程命令匹配,不误杀 llmproxy)
|
|
162
|
+
export async function stopBridgeProcess(log = console.log) {
|
|
163
|
+
const all = pidsOnPort(1083) // bridge 默认端口;更精确用命令名匹配
|
|
164
|
+
const targets = all.filter(pid => /openai-tool-bridge/.test(cmdOf(pid)))
|
|
165
|
+
if (!targets.length) {
|
|
166
|
+
log('openai-tool-bridge 未运行')
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
for (const pid of targets) {
|
|
170
|
+
try { process.kill(pid, 'SIGTERM'); log(`已停止 openai-tool-bridge (pid=${pid})`) } catch {}
|
|
171
|
+
}
|
|
172
|
+
for (let i = 0; i < 20 && pidsOnPort(1083).length; i++) {
|
|
173
|
+
await new Promise(r => setTimeout(r, 250))
|
|
174
|
+
}
|
|
175
|
+
return true
|
|
176
|
+
}
|
package/src/ctl.js
CHANGED
|
@@ -5,7 +5,14 @@ import path from 'node:path'
|
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import { getXdgConfigDir } from '@wwkit/shared'
|
|
7
7
|
import { config, getProviderIds, getProviderConfig } from './config.js'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
stopBridge,
|
|
10
|
+
probeBridge,
|
|
11
|
+
pidsOnPort,
|
|
12
|
+
spawnBridge,
|
|
13
|
+
stopBridgeProcess,
|
|
14
|
+
probeBridgeHealth,
|
|
15
|
+
} from './ctl-impl.js'
|
|
9
16
|
import { sleep } from './util.js'
|
|
10
17
|
|
|
11
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -34,6 +41,13 @@ export async function status() {
|
|
|
34
41
|
const pidStr = pids.length ? ` (pid=${pids.join(',')})` : ''
|
|
35
42
|
console.log(`已运行: http://127.0.0.1:${config.port}${pidStr}`)
|
|
36
43
|
console.log('')
|
|
44
|
+
// openai-tool-bridge 状态
|
|
45
|
+
const bPort = config.bridge?.port || 1083
|
|
46
|
+
const b = await probeBridgeHealth(bPort).catch(() => null)
|
|
47
|
+
console.log(b === true
|
|
48
|
+
? `tool-bridge 已运行: http://127.0.0.1:${bPort}`
|
|
49
|
+
: `tool-bridge 未运行(端口 ${bPort}): llmproxy start 将一并启动`)
|
|
50
|
+
console.log('')
|
|
37
51
|
console.log('Providers:')
|
|
38
52
|
for (const id of getProviderIds()) {
|
|
39
53
|
const status = getProviderStatus(id)
|
|
@@ -56,6 +70,7 @@ export async function status() {
|
|
|
56
70
|
// ---- stop ----
|
|
57
71
|
export async function stop() {
|
|
58
72
|
await stopBridge(config.port)
|
|
73
|
+
await stopBridgeProcess()
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
// ---- start ----
|
|
@@ -72,14 +87,29 @@ export async function start() {
|
|
|
72
87
|
process.exit(1)
|
|
73
88
|
}
|
|
74
89
|
|
|
75
|
-
await spawnServer()
|
|
90
|
+
await spawnServer({}, async () => {
|
|
91
|
+
const bPort = config.bridge?.port || 1083
|
|
92
|
+
try {
|
|
93
|
+
const bRunning = await probeBridgeHealth(bPort).catch(() => null)
|
|
94
|
+
if (bRunning !== true) await spawnBridge(config)
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(`openai-tool-bridge 启动失败: ${e.message}`)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
76
99
|
}
|
|
77
100
|
|
|
78
101
|
// ---- restart ----
|
|
79
102
|
export async function restart() {
|
|
80
103
|
const killed = await stopBridge(config.port)
|
|
81
104
|
if (!killed) console.log('无旧实例,直接启动')
|
|
82
|
-
await
|
|
105
|
+
await stopBridgeProcess()
|
|
106
|
+
await spawnServer({}, async () => {
|
|
107
|
+
try {
|
|
108
|
+
await spawnBridge(config)
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.error(`openai-tool-bridge 启动失败: ${e.message}`)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
83
113
|
}
|
|
84
114
|
|
|
85
115
|
// ---- login ----
|
|
@@ -195,7 +225,7 @@ export async function clearProvider(providerId) {
|
|
|
195
225
|
|
|
196
226
|
// ---- 内部函数 ----
|
|
197
227
|
|
|
198
|
-
async function spawnServer(env = {}) {
|
|
228
|
+
async function spawnServer(env = {}, onReady = null) {
|
|
199
229
|
try { truncateSync(LOG_FILE, 0) } catch {}
|
|
200
230
|
const out = openSync(LOG_FILE, 'a')
|
|
201
231
|
const child = spawn(process.execPath, [path.join(__dirname, 'server.js')], {
|
|
@@ -211,6 +241,7 @@ async function spawnServer(env = {}) {
|
|
|
211
241
|
const res = await fetch(`http://127.0.0.1:${config.port}/health`)
|
|
212
242
|
if (res.ok) {
|
|
213
243
|
console.log(`llmproxy 已启动: http://127.0.0.1:${config.port}(日志: ${LOG_FILE})`)
|
|
244
|
+
if (onReady) await onReady()
|
|
214
245
|
process.exit(0)
|
|
215
246
|
}
|
|
216
247
|
} catch {}
|
|
@@ -16,23 +16,11 @@ import fs from "node:fs"
|
|
|
16
16
|
import path from "node:path"
|
|
17
17
|
import process from "node:process"
|
|
18
18
|
import { getXdgConfigDir } from "@wwkit/shared"
|
|
19
|
-
import { status as cftStatus } from "@wwkit/cft"
|
|
20
19
|
import { AuthProvider } from "../../core/auth.js"
|
|
21
20
|
import { log } from "../../util.js"
|
|
22
21
|
|
|
23
22
|
const CREDS_FILE = path.join(getXdgConfigDir("llmproxy"), ".creds.json")
|
|
24
23
|
|
|
25
|
-
// 获取 Chrome 路径(优先用 @wwkit/cft 安装的 Chrome for Testing)
|
|
26
|
-
function getChromePath() {
|
|
27
|
-
try {
|
|
28
|
-
const info = cftStatus()
|
|
29
|
-
if (info?.chrome_path && fs.existsSync(info.chrome_path)) {
|
|
30
|
-
return info.chrome_path
|
|
31
|
-
}
|
|
32
|
-
} catch {}
|
|
33
|
-
return null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
24
|
const STS_HOST = "https://sts.cn-north-4.myhuaweicloud.com";
|
|
37
25
|
const TOKEN_API = "/v1/oauth2/tokens";
|
|
38
26
|
const PORTAL_HOST = "https://codearts.huaweicloud.com/portal";
|
|
@@ -88,15 +76,9 @@ export default class CodeartsAuth extends AuthProvider {
|
|
|
88
76
|
|
|
89
77
|
if (opts.manual !== false) {
|
|
90
78
|
const { execSync } = await import("node:child_process");
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
log(`[auth:${this.id}] 已打开 Chrome,请手动完成登录...`);
|
|
95
|
-
} else {
|
|
96
|
-
const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
97
|
-
try { execSync(`${openCmd} "${loginUrl}"`) } catch {}
|
|
98
|
-
log(`[auth:${this.id}] 已打开浏览器,请手动完成登录...`)
|
|
99
|
-
}
|
|
79
|
+
const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
80
|
+
try { execSync(`${openCmd} "${loginUrl}"`) } catch {}
|
|
81
|
+
log(`[auth:${this.id}] 已打开系统浏览器,请手动完成登录...`);
|
|
100
82
|
} else {
|
|
101
83
|
throw new Error(`auth:${this.id} 当前仅支持手动登录`);
|
|
102
84
|
}
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
// 封装:auth 拿凭证 + sign 签名 + fetch 发请求
|
|
4
4
|
// 上游 host / path 从 sign config 读
|
|
5
5
|
// undici Agent 关闭 keep-alive 避免上游会话池占用
|
|
6
|
+
//
|
|
7
|
+
// benefit 模型:请求自动附加 maas_type: benefit 头(消耗每日免费额度)
|
|
8
|
+
// SSE 异常检测:snap-access 偶尔在 SSE 流中嵌入非标准错误,需改写为标准 OpenAI 错误
|
|
6
9
|
|
|
7
10
|
import { Agent, fetch } from "undici";
|
|
11
|
+
import { log } from "../../util.js";
|
|
12
|
+
import { BENEFIT_MODELS } from "./config.js";
|
|
8
13
|
|
|
9
14
|
const upstreamAgent = new Agent({
|
|
10
15
|
connections: 1,
|
|
@@ -13,6 +18,24 @@ const upstreamAgent = new Agent({
|
|
|
13
18
|
pipelining: 0,
|
|
14
19
|
});
|
|
15
20
|
|
|
21
|
+
function isBenefitModel(modelId) {
|
|
22
|
+
return BENEFIT_MODELS.map(m => m.toLowerCase()).includes(String(modelId).toLowerCase())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isAbnormalErrorChunk(text) {
|
|
26
|
+
return /InferHub\.ModelArts\.\d+\.\d+/.test(text) ||
|
|
27
|
+
/"text"\s*:\s*"\[DONE\]"/.test(text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseAbnormalError(text) {
|
|
31
|
+
const codeMatch = text.match(/"error_code"\s*:\s*"([^"]+)"/);
|
|
32
|
+
const msgMatch = text.match(/"error_msg"\s*:\s*"([^"]+)"/);
|
|
33
|
+
return {
|
|
34
|
+
code: codeMatch?.[1] || "upstream_stream_error",
|
|
35
|
+
message: msgMatch?.[1] || text.slice(0, 200),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
export default class CodeartsClient {
|
|
17
40
|
constructor({ id, signProvider, authProvider, host, basePath }) {
|
|
18
41
|
this.id = id;
|
|
@@ -25,7 +48,7 @@ export default class CodeartsClient {
|
|
|
25
48
|
/**
|
|
26
49
|
* POST /chat/completions
|
|
27
50
|
* @param {object} body OpenAI 风格请求体
|
|
28
|
-
* @param {object} [opts] {
|
|
51
|
+
* @param {object} [opts] { signal? }
|
|
29
52
|
* @returns {Promise<Response>}
|
|
30
53
|
*/
|
|
31
54
|
async chatCompletions(body, opts = {}) {
|
|
@@ -37,12 +60,16 @@ export default class CodeartsClient {
|
|
|
37
60
|
const headers = {
|
|
38
61
|
"Content-Type": "application/json",
|
|
39
62
|
"Host": this.host,
|
|
40
|
-
...opts.headers,
|
|
41
63
|
};
|
|
42
64
|
if (cred.securityToken) {
|
|
43
65
|
headers["X-Security-Token"] = cred.securityToken;
|
|
44
66
|
}
|
|
45
67
|
|
|
68
|
+
// benefit 模型注入 maas_type 头
|
|
69
|
+
if (isBenefitModel(body.model)) {
|
|
70
|
+
headers["maas_type"] = "benefit";
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
const signHeaders = this.signProvider.sign({
|
|
47
74
|
method: "POST",
|
|
48
75
|
url,
|
|
@@ -53,12 +80,70 @@ export default class CodeartsClient {
|
|
|
53
80
|
});
|
|
54
81
|
Object.assign(headers, signHeaders);
|
|
55
82
|
|
|
56
|
-
|
|
83
|
+
const upstream = await fetch(url, {
|
|
57
84
|
method: "POST",
|
|
58
85
|
headers,
|
|
59
86
|
body: bodyStr,
|
|
60
87
|
signal: opts.signal || undefined,
|
|
61
88
|
dispatcher: upstreamAgent,
|
|
62
89
|
});
|
|
90
|
+
|
|
91
|
+
// 对流式响应包装异常 chunk 检测
|
|
92
|
+
if (upstream.ok && upstream.body) {
|
|
93
|
+
return wrapSSEAbnormalGuard(upstream, this.id);
|
|
94
|
+
}
|
|
95
|
+
return upstream;
|
|
63
96
|
}
|
|
64
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 包装上游 SSE Response,检测异常 chunk 并改写为标准 OpenAI 错误
|
|
101
|
+
* snap-access 偶尔在 SSE 流中嵌入 {"error_code":"InferHub.ModelArts.xxx","error_msg":"..."}
|
|
102
|
+
*/
|
|
103
|
+
function wrapSSEAbnormalGuard(upstream, providerId) {
|
|
104
|
+
const originalBody = upstream.body;
|
|
105
|
+
const reader = originalBody.getReader();
|
|
106
|
+
let abnormalHandled = false;
|
|
107
|
+
|
|
108
|
+
const stream = new ReadableStream({
|
|
109
|
+
async pull(controller) {
|
|
110
|
+
if (abnormalHandled) {
|
|
111
|
+
controller.close();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const { done, value } = await reader.read();
|
|
116
|
+
if (done) {
|
|
117
|
+
controller.close();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const text = new TextDecoder().decode(value, { stream: true });
|
|
121
|
+
if (isAbnormalErrorChunk(text)) {
|
|
122
|
+
const { code, message } = parseAbnormalError(text);
|
|
123
|
+
log(`[${providerId}] 上游 SSE 流中检测到异常 chunk (${code}),改写为标准错误响应`);
|
|
124
|
+
const errBody = JSON.stringify({
|
|
125
|
+
error: { message, type: "upstream_error", code },
|
|
126
|
+
});
|
|
127
|
+
const errChunk = new TextEncoder().encode(`data: ${errBody}\n\ndata: [DONE]\n\n`);
|
|
128
|
+
controller.enqueue(errChunk);
|
|
129
|
+
abnormalHandled = true;
|
|
130
|
+
controller.close();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
controller.enqueue(value);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
controller.error(e);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
cancel() {
|
|
139
|
+
reader.releaseLock();
|
|
140
|
+
originalBody.cancel().catch(() => {});
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return new Response(stream, {
|
|
145
|
+
status: upstream.status,
|
|
146
|
+
statusText: upstream.statusText,
|
|
147
|
+
headers: upstream.headers,
|
|
148
|
+
});
|
|
149
|
+
}
|