Upload 19 files
Browse files- anthropic-adapter.js +4 -2
- chat.js +28 -5
- config.js +70 -0
- openai-adapter.js +4 -2
- pool.js +58 -38
- server.js +11 -2
- 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 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 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;
|
| 35 |
-
this._logId = 0;
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
} else {
|
| 130 |
acc.state = State.NEEDS_LOGIN;
|
| 131 |
}
|
|
@@ -166,9 +171,25 @@ export class AccountPool {
|
|
| 166 |
}
|
| 167 |
|
| 168 |
/**
|
| 169 |
-
* 获取一个可用账号 (Round-Robin + 额度感知)
|
| 170 |
*/
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
|
|
|
|
|
|
| 218 |
candidates.sort((a, b) => {
|
| 219 |
-
|
| 220 |
-
const
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
//
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 99 |
-
upstreamStream.removeListener('end', onEnd);
|
| 100 |
-
upstreamStream.removeListener('error', onError);
|
| 101 |
upstreamStream.resume();
|
| 102 |
-
|
| 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 |
-
|
| 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 |
|