| |
| |
| |
| |
|
|
| import config from '../config/config.js'; |
| import logger from '../utils/logger.js'; |
| import memoryManager, { registerMemoryPoolCleanup } from '../utils/memoryManager.js'; |
| import { DEFAULT_HEARTBEAT_INTERVAL, LONG_COOLDOWN_THRESHOLD } from '../constants/index.js'; |
| import tokenCooldownManager from '../auth/token_cooldown_manager.js'; |
| import quotaManager from '../auth/quota_manager.js'; |
| import { getGroupKey } from '../utils/modelGroups.js'; |
|
|
| |
| const HEARTBEAT_INTERVAL = config.server.heartbeatInterval || DEFAULT_HEARTBEAT_INTERVAL; |
| const SSE_HEARTBEAT = Buffer.from(': heartbeat\n\n'); |
|
|
| |
| |
| |
| |
| |
| export const createHeartbeat = (res) => { |
| const timer = setInterval(() => { |
| if (!res.writableEnded) { |
| res.write(SSE_HEARTBEAT); |
| } else { |
| clearInterval(timer); |
| } |
| }, HEARTBEAT_INTERVAL); |
|
|
| |
| res.on('close', () => clearInterval(timer)); |
| res.on('finish', () => clearInterval(timer)); |
|
|
| return timer; |
| }; |
|
|
| |
| const SSE_PREFIX = Buffer.from('data: '); |
| const SSE_SUFFIX = Buffer.from('\n\n'); |
| const SSE_DONE = Buffer.from('data: [DONE]\n\n'); |
|
|
| |
| |
| |
| |
| export const createResponseMeta = () => ({ |
| id: `chatcmpl-${Date.now()}`, |
| created: Math.floor(Date.now() / 1000) |
| }); |
|
|
| |
| |
| |
| |
| export const setStreamHeaders = (res) => { |
| res.setHeader('Content-Type', 'text/event-stream'); |
| res.setHeader('Cache-Control', 'no-cache'); |
| res.setHeader('Connection', 'keep-alive'); |
| res.setHeader('X-Accel-Buffering', 'no'); |
| |
| res.flushHeaders(); |
| }; |
|
|
| |
| const chunkPool = []; |
|
|
| |
| |
| |
| |
| export const getChunkObject = () => chunkPool.pop() || { choices: [{ index: 0, delta: {}, finish_reason: null }] }; |
|
|
| |
| |
| |
| |
| export const releaseChunkObject = (obj) => { |
| const maxSize = memoryManager.getPoolSizes().chunk; |
| if (chunkPool.length < maxSize) chunkPool.push(obj); |
| }; |
|
|
| |
| registerMemoryPoolCleanup(chunkPool, () => memoryManager.getPoolSizes().chunk); |
|
|
| |
| |
| |
| |
| export const getChunkPoolSize = () => chunkPool.length; |
|
|
| |
| |
| |
| export const clearChunkPool = () => { |
| chunkPool.length = 0; |
| }; |
|
|
| |
| |
| |
| |
| |
| export const writeStreamData = (res, data) => { |
| const json = JSON.stringify(data); |
| res.write(SSE_PREFIX); |
| res.write(json); |
| res.write(SSE_SUFFIX); |
| |
| if (typeof res.flush === 'function') { |
| res.flush(); |
| } |
| }; |
|
|
| |
| |
| |
| |
| export const endStream = (res, isWriteDone = true) => { |
| if (res.writableEnded) return; |
| if (isWriteDone) res.write(SSE_DONE); |
| res.end(); |
| }; |
|
|
| |
|
|
| function sleep(ms) { |
| return new Promise(resolve => setTimeout(resolve, ms)); |
| } |
|
|
| function parseDurationToMs(value) { |
| if (value === null || value === undefined) return null; |
| if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value)); |
| if (typeof value !== 'string') return null; |
|
|
| const s = value.trim(); |
| if (!s) return null; |
|
|
| |
| const msMatch = s.match(/^(\d+(\.\d+)?)\s*ms$/i); |
| if (msMatch) return Math.max(0, Math.floor(Number(msMatch[1]))); |
|
|
| |
| const secMatch = s.match(/^(\d+(\.\d+)?)\s*s$/i); |
| if (secMatch) return Math.max(0, Math.floor(Number(secMatch[1]) * 1000)); |
|
|
| |
| const num = Number(s); |
| if (Number.isFinite(num)) return Math.max(0, Math.floor(num)); |
| return null; |
| } |
|
|
| function tryParseJson(value) { |
| if (!value) return null; |
| if (typeof value === 'object') return value; |
| if (typeof value !== 'string') return null; |
| try { |
| return JSON.parse(value); |
| } catch { |
| |
| const first = value.indexOf('{'); |
| const last = value.lastIndexOf('}'); |
| if (first !== -1 && last !== -1 && last > first) { |
| const sliced = value.slice(first, last + 1); |
| try { |
| return JSON.parse(sliced); |
| } catch { } |
| } |
| return null; |
| } |
| } |
|
|
| function extractUpstreamErrorBody(error) { |
| |
| if (error?.isUpstreamApiError && error.rawBody) { |
| return tryParseJson(error.rawBody) || error.rawBody; |
| } |
| |
| if (error?.response?.data) { |
| return tryParseJson(error.response.data) || error.response.data; |
| } |
| |
| return tryParseJson(error?.message); |
| } |
|
|
| function getUpstreamRetryDelayMs(error) { |
| |
| const body = extractUpstreamErrorBody(error); |
| const root = (body && typeof body === 'object') ? body : null; |
| const inner = root?.error || root; |
| const details = Array.isArray(inner?.details) ? inner.details : []; |
|
|
| let bestMs = null; |
| for (const d of details) { |
| if (!d || typeof d !== 'object') continue; |
|
|
| |
| const retryDelayMs = parseDurationToMs(d.retryDelay); |
| if (retryDelayMs !== null) bestMs = bestMs === null ? retryDelayMs : Math.max(bestMs, retryDelayMs); |
|
|
| |
| const meta = d.metadata && typeof d.metadata === 'object' ? d.metadata : null; |
| const quotaResetDelayMs = parseDurationToMs(meta?.quotaResetDelay); |
| if (quotaResetDelayMs !== null) bestMs = bestMs === null ? quotaResetDelayMs : Math.max(bestMs, quotaResetDelayMs); |
|
|
| const ts = meta?.quotaResetTimeStamp; |
| if (typeof ts === 'string') { |
| const t = Date.parse(ts); |
| if (Number.isFinite(t)) { |
| const deltaMs = Math.max(0, t - Date.now()); |
| bestMs = bestMs === null ? deltaMs : Math.max(bestMs, deltaMs); |
| } |
| } |
| } |
|
|
| |
| const reason = details.find(d => d?.reason)?.reason; |
| if (reason === 'MODEL_CAPACITY_EXHAUSTED') { |
| bestMs = bestMs === null ? 1000 : Math.max(bestMs, 1000); |
| } |
|
|
| return bestMs; |
| } |
|
|
| function computeBackoffMs(attempt, explicitDelayMs) { |
| |
| const maxMs = 20_000; |
| const hasExplicit = Number.isFinite(explicitDelayMs) && explicitDelayMs !== null; |
| const baseMs = hasExplicit ? Math.max(0, Math.floor(explicitDelayMs)) : 500; |
| const exp = Math.min(maxMs, Math.floor(baseMs * Math.pow(2, Math.max(0, attempt - 1)))); |
|
|
| |
| const jitterFactor = 0.8 + Math.random() * 0.4; |
| const expJittered = Math.max(0, Math.floor(exp * jitterFactor)); |
|
|
| if (hasExplicit) { |
| |
| const buffered = Math.max(0, Math.floor(explicitDelayMs + 50)); |
| return Math.min(maxMs, Math.max(expJittered, buffered)); |
| } |
|
|
| |
| return Math.min(maxMs, Math.max(500, expJittered)); |
| } |
|
|
| |
| |
| |
| |
| |
| function getUpstreamResetTimestamp(error) { |
| const body = extractUpstreamErrorBody(error); |
| const root = (body && typeof body === 'object') ? body : null; |
| const inner = root?.error || root; |
| const details = Array.isArray(inner?.details) ? inner.details : []; |
|
|
| for (const d of details) { |
| if (!d || typeof d !== 'object') continue; |
| const meta = d.metadata && typeof d.metadata === 'object' ? d.metadata : null; |
| const ts = meta?.quotaResetTimeStamp; |
| if (typeof ts === 'string') { |
| const t = Date.parse(ts); |
| if (Number.isFinite(t)) { |
| return t; |
| } |
| } |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function isRetryableError(status, error) { |
| |
| if (status === 429) return true; |
|
|
| |
| if (status === 503) { |
| const body = extractUpstreamErrorBody(error); |
| const root = (body && typeof body === 'object') ? body : null; |
| const inner = root?.error || root; |
| const details = Array.isArray(inner?.details) ? inner.details : []; |
| |
| |
| for (const d of details) { |
| if (d?.reason === 'MODEL_CAPACITY_EXHAUSTED') { |
| return true; |
| } |
| } |
| } |
|
|
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function with429Retry(fn, maxRetries, options = {}, legacyOnAttempt = null) { |
| |
| let loggerPrefix = ''; |
| let onAttempt = null; |
| let tokenId = null; |
| let modelId = null; |
| let refreshQuota = null; |
|
|
| if (typeof options === 'string') { |
| |
| loggerPrefix = options; |
| onAttempt = legacyOnAttempt; |
| } else if (typeof options === 'object' && options !== null) { |
| loggerPrefix = options.loggerPrefix || ''; |
| onAttempt = options.onAttempt || null; |
| tokenId = options.tokenId || null; |
| modelId = options.modelId || null; |
| refreshQuota = options.refreshQuota || null; |
| } |
|
|
| const retries = Number.isFinite(maxRetries) && maxRetries > 0 ? Math.floor(maxRetries) : 0; |
| const cooldownThreshold = config.quota?.longCooldownThreshold || LONG_COOLDOWN_THRESHOLD; |
| let attempt = 0; |
|
|
| |
| while (true) { |
| try { |
| |
| if (typeof onAttempt === 'function') { |
| onAttempt(attempt); |
| } |
| return await fn(attempt); |
| } catch (error) { |
| |
| const status = Number(error.status || error.statusCode || error.response?.status); |
|
|
| if (isRetryableError(status, error)) { |
| const explicitDelayMs = getUpstreamRetryDelayMs(error); |
| const upstreamResetTimestamp = getUpstreamResetTimestamp(error); |
| const errorType = status === 503 ? '503 (容量不足)' : '429'; |
|
|
| |
| if (status === 429 && explicitDelayMs !== null && explicitDelayMs >= cooldownThreshold && tokenId && modelId) { |
| |
| if (!tokenCooldownManager.isAvailable(tokenId, modelId)) { |
| |
| throw error; |
| } |
|
|
| |
| |
| let finalResetTimestamp = upstreamResetTimestamp; |
|
|
| |
| if (!finalResetTimestamp && explicitDelayMs !== null) { |
| finalResetTimestamp = Date.now() + explicitDelayMs; |
| } |
|
|
| |
| if (!finalResetTimestamp && typeof refreshQuota === 'function') { |
| logger.info(`${loggerPrefix}上游未返回恢复时间,尝试从额度数据获取...`); |
| try { |
| await refreshQuota(); |
| const { resetTime: quotaResetTime } = quotaManager.getModelGroupResetTime(tokenId, modelId); |
| if (quotaResetTime) { |
| finalResetTimestamp = quotaResetTime; |
| } |
| } catch (e) { |
| logger.warn(`${loggerPrefix}获取额度数据失败: ${e.message}`); |
| } |
| } |
|
|
| if (finalResetTimestamp && finalResetTimestamp > Date.now()) { |
| const groupKey = getGroupKey(modelId); |
| const resetDate = new Date(finalResetTimestamp); |
| const delayMinutes = Math.round((finalResetTimestamp - Date.now()) / 1000 / 60); |
| logger.warn( |
| `${loggerPrefix}收到 ${errorType},恢复时间 ${delayMinutes} 分钟后,` + |
| `超过阈值(${Math.round(cooldownThreshold / 1000 / 60)}分钟),` + |
| `禁用 ${groupKey} 系列直到 ${resetDate.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` |
| ); |
| tokenCooldownManager.setCooldown(tokenId, modelId, finalResetTimestamp); |
| |
| throw error; |
| } |
| } |
|
|
| |
| if (attempt < retries) { |
| const nextAttempt = attempt + 1; |
| const waitMs = computeBackoffMs(nextAttempt, explicitDelayMs); |
| logger.warn( |
| `${loggerPrefix}收到 ${errorType},等待 ${waitMs}ms 后进行第 ${nextAttempt} 次重试(共 ${retries} 次)` + |
| (explicitDelayMs !== null ? `(上游提示≈${explicitDelayMs}ms)` : '') |
| ); |
| await sleep(waitMs); |
| attempt = nextAttempt; |
| continue; |
| } |
| } |
| throw error; |
| } |
| } |
| }; |
|
|