bingn commited on
Commit
f1357b6
·
verified ·
1 Parent(s): 5676725

Upload 19 files

Browse files
Files changed (7) hide show
  1. anthropic-adapter.js +4 -2
  2. chat.js +28 -5
  3. config.js +70 -0
  4. openai-adapter.js +4 -2
  5. pool.js +58 -38
  6. server.js +11 -2
  7. stream-transform.js +24 -12
anthropic-adapter.js CHANGED
@@ -11,6 +11,7 @@ import { createContext, sendMessageStreaming, sendMultiChunkStreaming } from './
11
  import { anthropicToText, resolveModel, splitToChunks } from './message-convert.js';
12
  import { transformToAnthropicSSE, collectFullResponse, transformToAnthropicSSEWithTools, probeStream } from './stream-transform.js';
13
  import { parseToolCalls, toAnthropicToolUse } from './tool-prompt.js';
 
14
 
15
  const MAX_RETRY = 3;
16
 
@@ -61,6 +62,7 @@ export async function handleMessages(body, res, pool) {
61
  const stream = body.stream === true;
62
  const hasTools = body.tools && body.tools.length > 0;
63
  const isMultiChunk = chunks.length > 1;
 
64
 
65
  if (!text) {
66
  sendError(res, 400, 'No valid message content found');
@@ -115,7 +117,7 @@ export async function handleMessages(body, res, pool) {
115
  account = null;
116
  });
117
  }
118
- probed.on('end', () => { if (account) pool.release(account, { success: true }); });
119
  probed.on('error', (err) => {
120
  if (!account) return;
121
  const msg = (err?.message || '').toLowerCase();
@@ -164,7 +166,7 @@ export async function handleMessages(body, res, pool) {
164
  if (result.cookies) account.cookies = result.cookies;
165
 
166
  const full = await collectFullResponse(result.stream);
167
- pool.release(account, { success: true });
168
 
169
  // 检测工具调用
170
  const { hasToolCalls, toolCalls, textContent } = hasTools
 
11
  import { anthropicToText, resolveModel, splitToChunks } from './message-convert.js';
12
  import { transformToAnthropicSSE, collectFullResponse, transformToAnthropicSSEWithTools, probeStream } from './stream-transform.js';
13
  import { parseToolCalls, toAnthropicToolUse } from './tool-prompt.js';
14
+ import config from './config.js';
15
 
16
  const MAX_RETRY = 3;
17
 
 
62
  const stream = body.stream === true;
63
  const hasTools = body.tools && body.tools.length > 0;
64
  const isMultiChunk = chunks.length > 1;
65
+ const cost = config.modelCost?.[model] ?? config.defaultModelCost ?? 2;
66
 
67
  if (!text) {
68
  sendError(res, 400, 'No valid message content found');
 
117
  account = null;
118
  });
119
  }
120
+ probed.on('end', () => { if (account) pool.release(account, { success: true, cost }); });
121
  probed.on('error', (err) => {
122
  if (!account) return;
123
  const msg = (err?.message || '').toLowerCase();
 
166
  if (result.cookies) account.cookies = result.cookies;
167
 
168
  const full = await collectFullResponse(result.stream);
169
+ pool.release(account, { success: true, cost });
170
 
171
  // 检测工具调用
172
  const { hasToolCalls, toolCalls, textContent } = hasTools
chat.js CHANGED
@@ -205,11 +205,34 @@ export async function getQuota(cookies) {
205
  });
206
  if (!resp.ok) return null;
207
  const data = resp.json();
208
- // 可能返回 { answersCount, freeAnswersCount, ... }
209
- return {
210
- remaining: data.freeAnswersCount ?? data.answersCount ?? null,
211
- raw: data,
212
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  } catch {
214
  return null;
215
  }
 
205
  });
206
  if (!resp.ok) return null;
207
  const data = resp.json();
208
+
209
+ // 从返回数据中提取剩余额度 — 兼容多种字段名
210
+ let remaining = null;
211
+ if (typeof data === 'object' && data !== null) {
212
+ // 尝试已知字段名
213
+ for (const key of ['freeAnswersCount', 'answersCount', 'remaining', 'count', 'free']) {
214
+ if (typeof data[key] === 'number') {
215
+ remaining = data[key];
216
+ break;
217
+ }
218
+ }
219
+ // 如果还是 null,遍历找第一个数字字段
220
+ if (remaining === null) {
221
+ for (const val of Object.values(data)) {
222
+ if (typeof val === 'number' && val >= 0) {
223
+ remaining = val;
224
+ break;
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ // 首次调试:打印完整返回以便确认字段
231
+ if (remaining === null) {
232
+ console.log(`[Quota] API 返回未知格式: ${JSON.stringify(data)}`);
233
+ }
234
+
235
+ return { remaining, raw: data };
236
  } catch {
237
  return null;
238
  }
config.js CHANGED
@@ -89,6 +89,76 @@ export default {
89
  checkInterval: parseInt(env.CHECK_INTERVAL) || 300000,
90
  },
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  // ==================== 模型映射 ====================
93
  modelMapping: {
94
  // ---- OpenAI ----
 
89
  checkInterval: parseInt(env.CHECK_INTERVAL) || 300000,
90
  },
91
 
92
+ // ==================== 模型消耗量 (每次请求消耗的 requests 数) ====================
93
+ // chataibot.pro 不同模型扣费不同,用于本地扣减额度
94
+ modelCost: {
95
+ // 基础模型: 1 request
96
+ 'gpt-3.5-turbo': 1,
97
+ 'gpt-4o-mini': 1,
98
+ 'gpt-4.1-nano': 1,
99
+ 'gpt-4.1-mini': 1,
100
+ 'gemini-flash': 1,
101
+ 'gemini-3-flash': 1,
102
+ 'deepseek': 1,
103
+ 'deepseek-v3.2': 1,
104
+ 'qwen3.5': 1,
105
+ 'claude-3-haiku': 1,
106
+ 'claude-4.5-haiku': 1,
107
+
108
+ // 中端模型: 2 requests
109
+ 'gpt-4': 2,
110
+ 'gpt-4-turbo': 2,
111
+ 'gpt-4o': 2,
112
+ 'gpt-4.1': 2,
113
+ 'gemini-pro': 2,
114
+ 'gemini-3-pro': 2,
115
+ 'gemini-3.1-pro': 2,
116
+ 'claude-3-sonnet': 2,
117
+ 'qwen3.5-plus': 2,
118
+ 'qwen3-max': 2,
119
+ 'grok': 2,
120
+
121
+ // 高端模型: 4 requests
122
+ 'claude-3-sonnet-high': 4,
123
+ 'claude-4.6-sonnet': 4,
124
+ 'claude-4.6-sonnet-high': 4,
125
+ 'claude-3-opus': 4,
126
+ 'claude-4.5-opus': 4,
127
+ 'claude-4.6-opus': 4,
128
+ 'gpt-5-pro': 4,
129
+ 'gpt-5.1': 4,
130
+ 'gpt-5.2': 4,
131
+ 'gpt-5.4': 4,
132
+ 'o1': 4,
133
+ 'o1-preview': 4,
134
+ 'o3': 4,
135
+ 'o3-pro': 4,
136
+ 'o4-mini': 4,
137
+ 'perplexity-pro': 4,
138
+
139
+ // 搜索模型: 2 requests
140
+ 'gpt-4o-search-preview': 2,
141
+ 'gpt-4o-mini-search-preview': 2,
142
+ 'gemini-2-flash-search': 2,
143
+ 'gemini-3-pro-search': 2,
144
+ 'gemini-3-flash-search': 2,
145
+
146
+ // 推理/高消耗: 4 requests
147
+ 'gpt-5.1-high': 4,
148
+ 'gpt-5.2-high': 4,
149
+ 'gpt-5.4-high': 4,
150
+ 'gpt-5.4-pro': 4,
151
+ 'o1-mini': 2,
152
+ 'o3-mini': 2,
153
+ 'o3-mini-high': 4,
154
+ 'o4-mini-high': 4,
155
+ 'o4-mini-deep-research': 4,
156
+ 'qwen3-thinking-2507': 2,
157
+ 'perplexity': 2,
158
+ },
159
+ // 未知模型的默认消耗
160
+ defaultModelCost: 2,
161
+
162
  // ==================== 模型映射 ====================
163
  modelMapping: {
164
  // ---- OpenAI ----
openai-adapter.js CHANGED
@@ -11,6 +11,7 @@ import { createContext, sendMessageStreaming, sendMultiChunkStreaming } from './
11
  import { openaiToText, resolveModel, splitToChunks } from './message-convert.js';
12
  import { transformToOpenAISSE, collectFullResponse, transformToOpenAISSEWithTools, probeStream } from './stream-transform.js';
13
  import { parseToolCalls, toOpenAIToolCalls } from './tool-prompt.js';
 
14
 
15
  const MAX_RETRY = 3;
16
 
@@ -73,6 +74,7 @@ export async function handleChatCompletions(body, res, pool) {
73
  const stream = body.stream === true;
74
  const hasTools = body.tools && body.tools.length > 0;
75
  const isMultiChunk = chunks.length > 1;
 
76
 
77
  if (!text) {
78
  sendError(res, 400, 'No valid message content found');
@@ -129,7 +131,7 @@ export async function handleChatCompletions(body, res, pool) {
129
  account = null;
130
  });
131
  }
132
- probed.on('end', () => { if (account) pool.release(account, { success: true }); });
133
  probed.on('error', (err) => {
134
  if (!account) return;
135
  const msg = (err?.message || '').toLowerCase();
@@ -179,7 +181,7 @@ export async function handleChatCompletions(body, res, pool) {
179
  if (result.cookies) account.cookies = result.cookies;
180
 
181
  const full = await collectFullResponse(result.stream);
182
- pool.release(account, { success: true });
183
 
184
  // 检测工具调用
185
  const { hasToolCalls, toolCalls, textContent } = hasTools
 
11
  import { openaiToText, resolveModel, splitToChunks } from './message-convert.js';
12
  import { transformToOpenAISSE, collectFullResponse, transformToOpenAISSEWithTools, probeStream } from './stream-transform.js';
13
  import { parseToolCalls, toOpenAIToolCalls } from './tool-prompt.js';
14
+ import config from './config.js';
15
 
16
  const MAX_RETRY = 3;
17
 
 
74
  const stream = body.stream === true;
75
  const hasTools = body.tools && body.tools.length > 0;
76
  const isMultiChunk = chunks.length > 1;
77
+ const cost = config.modelCost?.[model] ?? config.defaultModelCost ?? 2;
78
 
79
  if (!text) {
80
  sendError(res, 400, 'No valid message content found');
 
131
  account = null;
132
  });
133
  }
134
+ probed.on('end', () => { if (account) pool.release(account, { success: true, cost }); });
135
  probed.on('error', (err) => {
136
  if (!account) return;
137
  const msg = (err?.message || '').toLowerCase();
 
181
  if (result.cookies) account.cookies = result.cookies;
182
 
183
  const full = await collectFullResponse(result.stream);
184
+ pool.release(account, { success: true, cost });
185
 
186
  // 检测工具调用
187
  const { hasToolCalls, toolCalls, textContent } = hasTools
pool.js CHANGED
@@ -27,12 +27,13 @@ const State = {
27
  export class AccountPool {
28
  constructor() {
29
  this.accounts = [];
30
- this._registeringCount = 0; // 当前正在注册的数量
31
- this._maxConcurrentRegister = 10; // 最大并行注册数
32
  this._timer = null;
33
- this._logs = []; // 注册日志环形缓冲
34
- this._maxLogs = 200; // 最多保留 200 条
35
- this._logId = 0; // 自增 ID,方便前端增量拉取
 
36
  }
37
 
38
  /**
@@ -125,7 +126,11 @@ export class AccountPool {
125
  acc.state = State.ACTIVE;
126
  acc.lastCheckedAt = Date.now();
127
  const quota = await getQuota(acc.cookies);
128
- if (quota) acc.remainingQuota = quota.remaining;
 
 
 
 
129
  } else {
130
  acc.state = State.NEEDS_LOGIN;
131
  }
@@ -166,9 +171,25 @@ export class AccountPool {
166
  }
167
 
168
  /**
169
- * 获取一个可用账号 (Round-Robin + 额度感知)
170
  */
171
- async acquire() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  // 优先选有额度的 (quota > 0 或 quota 未知)
173
  let candidates = this.accounts.filter(a =>
174
  a.state === State.ACTIVE && (a.remainingQuota === null || a.remainingQuota > 0)
@@ -192,7 +213,7 @@ export class AccountPool {
192
  const exhausted = this.accounts.filter(a => a.state === State.EXHAUSTED);
193
  for (const acc of exhausted) {
194
  const quota = await getQuota(acc.cookies);
195
- if (quota && quota.remaining > 0) {
196
  acc.remainingQuota = quota.remaining;
197
  acc.state = State.ACTIVE;
198
  acc.lastCheckedAt = Date.now();
@@ -213,15 +234,23 @@ export class AccountPool {
213
  throw new Error('No available account in pool');
214
  }
215
 
216
- // Round-Robin: 选最久未用的账号 (兼顾额度)
217
- // exhausted (quota=0) 的排最后,其余按 lastUsedAt 升序
 
 
218
  candidates.sort((a, b) => {
219
- // quota=0 的排最后
220
- const aExhausted = a.remainingQuota !== null && a.remainingQuota <= 0;
221
- const bExhausted = b.remainingQuota !== null && b.remainingQuota <= 0;
222
- if (aExhausted !== bExhausted) return aExhausted ? 1 : -1;
223
- // 最久未用的排前面 (Round-Robin 效果)
224
- return a.lastUsedAt - b.lastUsedAt;
 
 
 
 
 
 
225
  });
226
 
227
  const account = candidates[0];
@@ -232,8 +261,10 @@ export class AccountPool {
232
 
233
  /**
234
  * 归还账号
 
 
235
  */
236
- release(account, { success = true, quotaExhausted = false, sessionExpired = false } = {}) {
237
  if (sessionExpired) {
238
  account.state = State.NEEDS_LOGIN;
239
  account.errorCount++;
@@ -243,8 +274,14 @@ export class AccountPool {
243
  } else if (success) {
244
  account.state = State.ACTIVE;
245
  account.errorCount = 0;
246
- // 异步查询真实剩余 requests 数量 (不同模型扣费不同)
247
- this._refreshQuota(account);
 
 
 
 
 
 
248
  } else {
249
  account.errorCount++;
250
  account.state = account.errorCount >= 5 ? State.DEAD : State.ACTIVE;
@@ -442,23 +479,6 @@ export class AccountPool {
442
  }
443
  }
444
 
445
- /**
446
- * 异步刷新账号的真实剩余 requests
447
- */
448
- async _refreshQuota(account) {
449
- try {
450
- const quota = await getQuota(account.cookies);
451
- if (quota) {
452
- account.remainingQuota = quota.remaining;
453
- account.lastCheckedAt = Date.now();
454
- if (quota.remaining <= 0) {
455
- account.state = State.EXHAUSTED;
456
- this._addLog(`额度耗尽: ${account.email} (${quota.remaining} requests)`, 'warn');
457
- }
458
- }
459
- } catch {}
460
- }
461
-
462
  /**
463
  * 将 dead 账号归档 (DB: 标记 state=dead / 文件: 移到 dead/ 子目录)
464
  */
@@ -515,7 +535,7 @@ export class AccountPool {
515
  for (const acc of this.accounts.filter(a => a.state === State.ACTIVE || a.state === State.EXHAUSTED)) {
516
  if (Date.now() - acc.lastCheckedAt < interval) continue;
517
  const quota = await getQuota(acc.cookies);
518
- if (quota) {
519
  acc.remainingQuota = quota.remaining;
520
  acc.lastCheckedAt = Date.now();
521
  if (acc.state === State.EXHAUSTED && quota.remaining > 0) {
 
27
  export class AccountPool {
28
  constructor() {
29
  this.accounts = [];
30
+ this._registeringCount = 0;
31
+ this._maxConcurrentRegister = 10;
32
  this._timer = null;
33
+ this._logs = [];
34
+ this._maxLogs = 200;
35
+ this._logId = 0;
36
+ this._acquireLock = Promise.resolve(); // 互斥锁:防止并发 acquire 分配同一账号
37
  }
38
 
39
  /**
 
126
  acc.state = State.ACTIVE;
127
  acc.lastCheckedAt = Date.now();
128
  const quota = await getQuota(acc.cookies);
129
+ if (quota && typeof quota.remaining === 'number') {
130
+ acc.remainingQuota = quota.remaining;
131
+ }
132
+ // quota 未知时不赋值,保持 null — release 时用 cost 会跳过扣减
133
+ // 但 probeStream 会兜底检测 "Insufficient credits" 流错误
134
  } else {
135
  acc.state = State.NEEDS_LOGIN;
136
  }
 
171
  }
172
 
173
  /**
174
+ * 获取一个可用账号 (互斥 + Round-Robin + 额度感知)
175
  */
176
+ acquire() {
177
+ // 串行化: 每次 acquire 必须等前一个完成,防止并发分配同一账号
178
+ const prev = this._acquireLock;
179
+ let unlock;
180
+ this._acquireLock = new Promise(r => { unlock = r; });
181
+ return prev.then(() => this._doAcquire()).finally(unlock);
182
+ }
183
+
184
+ async _doAcquire() {
185
+ // 先清理卡死的 IN_USE 账号 (超过 5 分钟未释放)
186
+ const stuckTimeout = 5 * 60 * 1000;
187
+ for (const acc of this.accounts) {
188
+ if (acc.state === State.IN_USE && Date.now() - acc.lastUsedAt > stuckTimeout) {
189
+ acc.state = State.ACTIVE;
190
+ this._addLog(`回收卡死账号: ${acc.email}`, 'warn');
191
+ }
192
+ }
193
  // 优先选有额度的 (quota > 0 或 quota 未知)
194
  let candidates = this.accounts.filter(a =>
195
  a.state === State.ACTIVE && (a.remainingQuota === null || a.remainingQuota > 0)
 
213
  const exhausted = this.accounts.filter(a => a.state === State.EXHAUSTED);
214
  for (const acc of exhausted) {
215
  const quota = await getQuota(acc.cookies);
216
+ if (quota && typeof quota.remaining === 'number' && quota.remaining > 0) {
217
  acc.remainingQuota = quota.remaining;
218
  acc.state = State.ACTIVE;
219
  acc.lastCheckedAt = Date.now();
 
234
  throw new Error('No available account in pool');
235
  }
236
 
237
+ // 负载均衡: Round-Robin + 额度加权
238
+ // 1. quota=0 或 EXHAUSTED 的排最后
239
+ // 2. 额度未知 (null) 的正常参与轮询
240
+ // 3. 在 lastUsedAt 相近的账号中,优先选剩余额度多的
241
  candidates.sort((a, b) => {
242
+ const aLow = typeof a.remainingQuota === 'number' && a.remainingQuota <= 0;
243
+ const bLow = typeof b.remainingQuota === 'number' && b.remainingQuota <= 0;
244
+ if (aLow !== bLow) return aLow ? 1 : -1;
245
+
246
+ // 时间窗口分组: 同一秒内视为"同时",比较额度
247
+ const timeDiff = Math.floor(a.lastUsedAt / 1000) - Math.floor(b.lastUsedAt / 1000);
248
+ if (timeDiff !== 0) return timeDiff;
249
+
250
+ // 同一时间窗口内,额度多的优先 (null 视为中等额度 50)
251
+ const aQuota = typeof a.remainingQuota === 'number' ? a.remainingQuota : 50;
252
+ const bQuota = typeof b.remainingQuota === 'number' ? b.remainingQuota : 50;
253
+ return bQuota - aQuota; // 降序,额度多的排前面
254
  });
255
 
256
  const account = candidates[0];
 
261
 
262
  /**
263
  * 归还账号
264
+ * @param {object} opts
265
+ * @param {number} [opts.cost] - 本次请求消耗的 requests 数 (由 adapter 传入)
266
  */
267
+ release(account, { success = true, quotaExhausted = false, sessionExpired = false, cost = 0 } = {}) {
268
  if (sessionExpired) {
269
  account.state = State.NEEDS_LOGIN;
270
  account.errorCount++;
 
274
  } else if (success) {
275
  account.state = State.ACTIVE;
276
  account.errorCount = 0;
277
+ // 本地扣减额度 (仅当有已知的 remaining )
278
+ if (cost > 0 && typeof account.remainingQuota === 'number') {
279
+ account.remainingQuota = Math.max(0, account.remainingQuota - cost);
280
+ if (account.remainingQuota <= 0) {
281
+ account.state = State.EXHAUSTED;
282
+ this._addLog(`额度耗尽 (本地扣减): ${account.email} (0 remaining)`, 'warn');
283
+ }
284
+ }
285
  } else {
286
  account.errorCount++;
287
  account.state = account.errorCount >= 5 ? State.DEAD : State.ACTIVE;
 
479
  }
480
  }
481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  /**
483
  * 将 dead 账号归档 (DB: 标记 state=dead / 文件: 移到 dead/ 子目录)
484
  */
 
535
  for (const acc of this.accounts.filter(a => a.state === State.ACTIVE || a.state === State.EXHAUSTED)) {
536
  if (Date.now() - acc.lastCheckedAt < interval) continue;
537
  const quota = await getQuota(acc.cookies);
538
+ if (quota && typeof quota.remaining === 'number') {
539
  acc.remainingQuota = quota.remaining;
540
  acc.lastCheckedAt = Date.now();
541
  if (acc.state === State.EXHAUSTED && quota.remaining > 0) {
server.js CHANGED
@@ -23,10 +23,19 @@ await pool.init();
23
 
24
  // ==================== HTTP 服务器 ====================
25
 
26
- function parseBody(req) {
27
  return new Promise((resolve, reject) => {
28
  let data = '';
29
- req.on('data', chunk => data += chunk);
 
 
 
 
 
 
 
 
 
30
  req.on('end', () => {
31
  try { resolve(JSON.parse(data)); }
32
  catch { reject(new Error('Invalid JSON body')); }
 
23
 
24
  // ==================== HTTP 服务器 ====================
25
 
26
+ function parseBody(req, maxSize = 2 * 1024 * 1024) { // 默认 2MB
27
  return new Promise((resolve, reject) => {
28
  let data = '';
29
+ let size = 0;
30
+ req.on('data', chunk => {
31
+ size += chunk.length;
32
+ if (size > maxSize) {
33
+ req.destroy();
34
+ reject(new Error('Request body too large'));
35
+ return;
36
+ }
37
+ data += chunk;
38
+ });
39
  req.on('end', () => {
40
  try { resolve(JSON.parse(data)); }
41
  catch { reject(new Error('Invalid JSON body')); }
stream-transform.js CHANGED
@@ -85,34 +85,44 @@ function createRepeatDetector(minRepeatLen = 150) {
85
  * @returns {Promise<ReadableStream>}
86
  * @throws {Error} 如果流的第一个实质性对象就是 streamingError
87
  */
88
- export function probeStream(upstreamStream) {
89
  return new Promise((resolve, reject) => {
90
  let resolved = false;
91
- let rawChunks = []; // 缓存预检阶段读取的原始数据
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  const parser = createJsonStreamParser((obj) => {
94
  if (resolved) return;
95
 
96
  if (obj.type === 'streamingError') {
97
  resolved = true;
98
- upstreamStream.removeListener('data', onData);
99
- upstreamStream.removeListener('end', onEnd);
100
- upstreamStream.removeListener('error', onError);
101
  upstreamStream.resume();
102
- const err = Object.assign(
103
  new Error(obj.data || 'Streaming error'),
104
  { statusCode: 429 }
105
- );
106
- reject(err);
107
  return;
108
  }
109
 
110
- // 收到第一个正常内容就确认流可用
111
  if (obj.type === 'chunk' || obj.type === 'reasoningContent') {
112
  resolved = true;
113
- upstreamStream.removeListener('data', onData);
114
- upstreamStream.removeListener('end', onEnd);
115
- upstreamStream.removeListener('error', onError);
116
  upstreamStream.pause();
117
 
118
  const wrapped = new PassThrough();
@@ -133,6 +143,7 @@ export function probeStream(upstreamStream) {
133
  parser.flush();
134
  if (!resolved) {
135
  resolved = true;
 
136
  const wrapped = new PassThrough();
137
  for (const chunk of rawChunks) wrapped.write(chunk);
138
  wrapped.end();
@@ -142,6 +153,7 @@ export function probeStream(upstreamStream) {
142
  function onError(err) {
143
  if (resolved) return;
144
  resolved = true;
 
145
  reject(err);
146
  }
147
 
 
85
  * @returns {Promise<ReadableStream>}
86
  * @throws {Error} 如果流的第一个实质性对象就是 streamingError
87
  */
88
+ export function probeStream(upstreamStream, timeoutMs = 30000) {
89
  return new Promise((resolve, reject) => {
90
  let resolved = false;
91
+ let rawChunks = [];
92
+
93
+ function cleanup() {
94
+ clearTimeout(timer);
95
+ upstreamStream.removeListener('data', onData);
96
+ upstreamStream.removeListener('end', onEnd);
97
+ upstreamStream.removeListener('error', onError);
98
+ }
99
+
100
+ // 超时保护: 防止上游永不响应导致请求永久挂起
101
+ const timer = setTimeout(() => {
102
+ if (resolved) return;
103
+ resolved = true;
104
+ cleanup();
105
+ upstreamStream.destroy();
106
+ reject(new Error('probeStream timeout'));
107
+ }, timeoutMs);
108
 
109
  const parser = createJsonStreamParser((obj) => {
110
  if (resolved) return;
111
 
112
  if (obj.type === 'streamingError') {
113
  resolved = true;
114
+ cleanup();
 
 
115
  upstreamStream.resume();
116
+ reject(Object.assign(
117
  new Error(obj.data || 'Streaming error'),
118
  { statusCode: 429 }
119
+ ));
 
120
  return;
121
  }
122
 
 
123
  if (obj.type === 'chunk' || obj.type === 'reasoningContent') {
124
  resolved = true;
125
+ cleanup();
 
 
126
  upstreamStream.pause();
127
 
128
  const wrapped = new PassThrough();
 
143
  parser.flush();
144
  if (!resolved) {
145
  resolved = true;
146
+ cleanup();
147
  const wrapped = new PassThrough();
148
  for (const chunk of rawChunks) wrapped.write(chunk);
149
  wrapped.end();
 
153
  function onError(err) {
154
  if (resolved) return;
155
  resolved = true;
156
+ cleanup();
157
  reject(err);
158
  }
159