@wwkit/llmproxy 1.0.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwkit/llmproxy",
3
- "version": "1.0.4",
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/cft": "1.0.5",
25
- "@wwkit/shared": "1.0.5"
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
  }
@@ -4,7 +4,7 @@
4
4
  // 新增 provider 只需在 providers/<id>/ 下放对应模块文件,factory 自动发现。
5
5
  //
6
6
  // 模块文件必须 export default class(继承对应抽象基类)。
7
- // 可选导出常量:SIGN_CONFIG, ERROR_CONFIG, BENEFIT_MODELS(固定实现细节,无需用户配置)。
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
- }
@@ -3,40 +3,10 @@
3
3
  // 把上游 fetch Response 流到下游 http.ServerResponse。
4
4
  // 上游按 fetch Response 形式传入(可以是流式或非流式)。
5
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 错误响应并提前结束流。
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, clientGone } = ctx;
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: 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
- }
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 { stopBridge, probeBridge, pidsOnPort } from './ctl-impl.js'
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 spawnServer()
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 chromePath = getChromePath();
92
- if (chromePath) {
93
- try { execSync(`"${chromePath}" "${loginUrl}"`); } catch {}
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] { headers?, signal? }
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
- return await fetch(url, {
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
+ }