mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge branch 'dev' into main
This commit is contained in:
@@ -483,6 +483,7 @@ model_provider = "crs"
|
||||
model = "gpt-5"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[model_providers.crs]
|
||||
name = "crs"
|
||||
@@ -494,7 +495,7 @@ wire_api = "responses"
|
||||
|
||||
```json
|
||||
{
|
||||
"OPENAI_API_KEY": "你的后台创建的API密钥"
|
||||
"OPENAI_API_KEY": "你的后台创建的API密钥"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
18
src/app.js
18
src/app.js
@@ -537,6 +537,15 @@ class Application {
|
||||
logger.info(
|
||||
`🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes`
|
||||
)
|
||||
|
||||
// 🚨 启动限流状态自动清理服务
|
||||
// 每5分钟检查一次过期的限流状态,确保账号能及时恢复调度
|
||||
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
|
||||
const cleanupIntervalMinutes = config.system.rateLimitCleanupInterval || 5 // 默认5分钟
|
||||
rateLimitCleanupService.start(cleanupIntervalMinutes)
|
||||
logger.info(
|
||||
`🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)`
|
||||
)
|
||||
}
|
||||
|
||||
setupGracefulShutdown() {
|
||||
@@ -554,6 +563,15 @@ class Application {
|
||||
} catch (error) {
|
||||
logger.error('❌ Error cleaning up pricing service:', error)
|
||||
}
|
||||
|
||||
// 停止限流清理服务
|
||||
try {
|
||||
const rateLimitCleanupService = require('./services/rateLimitCleanupService')
|
||||
rateLimitCleanupService.stop()
|
||||
logger.info('🚨 Rate limit cleanup service stopped')
|
||||
} catch (error) {
|
||||
logger.error('❌ Error stopping rate limit cleanup service:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.disconnect()
|
||||
|
||||
@@ -1092,7 +1092,7 @@ const globalRateLimit = async (req, res, next) =>
|
||||
|
||||
// 📊 请求大小限制中间件
|
||||
const requestSizeLimit = (req, res, next) => {
|
||||
const maxSize = 10 * 1024 * 1024 // 10MB
|
||||
const maxSize = 60 * 1024 * 1024 // 60MB
|
||||
const contentLength = parseInt(req.headers['content-length'] || '0')
|
||||
|
||||
if (contentLength > maxSize) {
|
||||
|
||||
@@ -2059,7 +2059,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
groupId,
|
||||
groupIds,
|
||||
autoStopOnWarning,
|
||||
useUnifiedUserAgent
|
||||
useUnifiedUserAgent,
|
||||
useUnifiedClientId,
|
||||
unifiedClientId
|
||||
} = req.body
|
||||
|
||||
if (!name) {
|
||||
@@ -2100,7 +2102,9 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||
platform,
|
||||
priority: priority || 50, // 默认优先级为50
|
||||
autoStopOnWarning: autoStopOnWarning === true, // 默认为false
|
||||
useUnifiedUserAgent: useUnifiedUserAgent === true // 默认为false
|
||||
useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false
|
||||
useUnifiedClientId: useUnifiedClientId === true, // 默认为false
|
||||
unifiedClientId: unifiedClientId || '' // 统一的客户端标识
|
||||
})
|
||||
|
||||
// 如果是分组类型,将账户添加到分组
|
||||
@@ -2703,6 +2707,23 @@ router.post(
|
||||
}
|
||||
)
|
||||
|
||||
// 重置Claude Console账户状态(清除所有异常状态)
|
||||
router.post(
|
||||
'/claude-console-accounts/:accountId/reset-status',
|
||||
authenticateAdmin,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const result = await claudeConsoleAccountService.resetAccountStatus(accountId)
|
||||
logger.success(`✅ Admin reset status for Claude Console account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset Claude Console account status:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 手动重置所有Claude Console账户的每日使用量
|
||||
router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
@@ -5419,6 +5440,7 @@ router.get('/oem-settings', async (req, res) => {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '', // Base64编码的图标数据
|
||||
showAdminButton: true, // 是否显示管理后台按钮
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -5448,7 +5470,7 @@ router.get('/oem-settings', async (req, res) => {
|
||||
// 更新OEM设置
|
||||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { siteName, siteIcon, siteIconData } = req.body
|
||||
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
|
||||
|
||||
// 验证输入
|
||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||
@@ -5479,6 +5501,7 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||
siteName: siteName.trim(),
|
||||
siteIcon: (siteIcon || '').trim(),
|
||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||
showAdminButton: showAdminButton !== false, // 默认为true
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -6190,6 +6213,21 @@ router.put('/openai-accounts/:id/toggle', authenticateAdmin, async (req, res) =>
|
||||
}
|
||||
})
|
||||
|
||||
// 重置 OpenAI 账户状态(清除所有异常状态)
|
||||
router.post('/openai-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
|
||||
const result = await openaiAccountService.resetAccountStatus(accountId)
|
||||
|
||||
logger.success(`✅ Admin reset status for OpenAI account: ${accountId}`)
|
||||
return res.json({ success: true, data: result })
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset OpenAI account status:', error)
|
||||
return res.status(500).json({ error: 'Failed to reset status', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 OpenAI 账户调度状态
|
||||
router.put(
|
||||
'/openai-accounts/:accountId/toggle-schedulable',
|
||||
|
||||
@@ -31,8 +31,8 @@ router.post('/api/get-key-id', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 验证API Key
|
||||
const validation = await apiKeyService.validateApiKey(apiKey)
|
||||
// 验证API Key(使用不触发激活的验证方法)
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
|
||||
if (!validation.valid) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
@@ -146,6 +146,11 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
permissions: keyData.permissions || 'all',
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
activatedAt: keyData.activatedAt || null,
|
||||
usage // 使用完整的 usage 数据,而不是只有 total
|
||||
}
|
||||
} else if (apiKey) {
|
||||
@@ -158,8 +163,8 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 验证API Key(重用现有的验证逻辑)
|
||||
const validation = await apiKeyService.validateApiKey(apiKey)
|
||||
// 验证API Key(使用不触发激活的验证方法)
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
|
||||
if (!validation.valid) {
|
||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||
@@ -335,6 +340,11 @@ router.post('/api/user-stats', async (req, res) => {
|
||||
isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
activatedAt: keyData.activatedAt || null,
|
||||
permissions: fullKeyData.permissions,
|
||||
|
||||
// 使用统计(使用验证结果中的完整数据)
|
||||
|
||||
@@ -87,7 +87,8 @@ async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel =
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
// 主处理函数,供两个路由共享
|
||||
const handleResponses = async (req, res) => {
|
||||
let upstream = null
|
||||
try {
|
||||
// 从中间件获取 API Key 数据
|
||||
@@ -205,6 +206,96 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
axiosConfig
|
||||
)
|
||||
}
|
||||
|
||||
// 处理 429 限流错误
|
||||
if (upstream.status === 429) {
|
||||
logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`)
|
||||
|
||||
// 解析响应体中的限流信息
|
||||
let resetsInSeconds = null
|
||||
let errorData = null
|
||||
|
||||
try {
|
||||
// 对于429错误,无论是否是流式请求,响应都会是完整的JSON错误对象
|
||||
if (isStream && upstream.data) {
|
||||
// 流式响应需要先收集数据
|
||||
const chunks = []
|
||||
await new Promise((resolve, reject) => {
|
||||
upstream.data.on('data', (chunk) => chunks.push(chunk))
|
||||
upstream.data.on('end', resolve)
|
||||
upstream.data.on('error', reject)
|
||||
// 设置超时防止无限等待
|
||||
setTimeout(resolve, 5000)
|
||||
})
|
||||
|
||||
const fullResponse = Buffer.concat(chunks).toString()
|
||||
try {
|
||||
errorData = JSON.parse(fullResponse)
|
||||
} catch (e) {
|
||||
logger.error('Failed to parse 429 error response:', e)
|
||||
logger.debug('Raw response:', fullResponse)
|
||||
}
|
||||
} else {
|
||||
// 非流式响应直接使用data
|
||||
errorData = upstream.data
|
||||
}
|
||||
|
||||
// 提取重置时间
|
||||
if (errorData && errorData.error && errorData.error.resets_in_seconds) {
|
||||
resetsInSeconds = errorData.error.resets_in_seconds
|
||||
logger.info(
|
||||
`🕐 Codex rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)`
|
||||
)
|
||||
} else {
|
||||
logger.warn(
|
||||
'⚠️ Could not extract resets_in_seconds from 429 response, using default 60 minutes'
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('⚠️ Failed to parse rate limit error:', e)
|
||||
}
|
||||
|
||||
// 标记账户为限流状态
|
||||
await unifiedOpenAIScheduler.markAccountRateLimited(
|
||||
accountId,
|
||||
'openai',
|
||||
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
|
||||
resetsInSeconds
|
||||
)
|
||||
|
||||
// 返回错误响应给客户端
|
||||
const errorResponse = errorData || {
|
||||
error: {
|
||||
type: 'usage_limit_reached',
|
||||
message: 'The usage limit has been reached',
|
||||
resets_in_seconds: resetsInSeconds
|
||||
}
|
||||
}
|
||||
|
||||
if (isStream) {
|
||||
// 流式响应也需要设置正确的状态码
|
||||
res.status(429)
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||
res.end()
|
||||
} else {
|
||||
res.status(429).json(errorResponse)
|
||||
}
|
||||
|
||||
return
|
||||
} else if (upstream.status === 200 || upstream.status === 201) {
|
||||
// 请求成功,检查并移除限流状态
|
||||
const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId)
|
||||
if (isRateLimited) {
|
||||
logger.info(
|
||||
`✅ Removing rate limit for OpenAI account ${accountId} after successful request`
|
||||
)
|
||||
await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai')
|
||||
}
|
||||
}
|
||||
|
||||
res.status(upstream.status)
|
||||
|
||||
if (isStream) {
|
||||
@@ -239,6 +330,8 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
let usageData = null
|
||||
let actualModel = null
|
||||
let usageReported = false
|
||||
let rateLimitDetected = false
|
||||
let rateLimitResetsInSeconds = null
|
||||
|
||||
if (!isStream) {
|
||||
// 非流式响应处理
|
||||
@@ -317,6 +410,17 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
logger.debug('📊 Captured OpenAI usage data:', usageData)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有限流错误
|
||||
if (eventData.error && eventData.error.type === 'usage_limit_reached') {
|
||||
rateLimitDetected = true
|
||||
if (eventData.error.resets_in_seconds) {
|
||||
rateLimitResetsInSeconds = eventData.error.resets_in_seconds
|
||||
logger.warn(
|
||||
`🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds`
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
@@ -388,6 +492,26 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果在流式响应中检测到限流
|
||||
if (rateLimitDetected) {
|
||||
logger.warn(`🚫 Processing rate limit for OpenAI account ${accountId} from stream`)
|
||||
await unifiedOpenAIScheduler.markAccountRateLimited(
|
||||
accountId,
|
||||
'openai',
|
||||
sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null,
|
||||
rateLimitResetsInSeconds
|
||||
)
|
||||
} else if (upstream.status === 200) {
|
||||
// 流式请求成功,检查并移除限流状态
|
||||
const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId)
|
||||
if (isRateLimited) {
|
||||
logger.info(
|
||||
`✅ Removing rate limit for OpenAI account ${accountId} after successful stream`
|
||||
)
|
||||
await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai')
|
||||
}
|
||||
}
|
||||
|
||||
res.end()
|
||||
})
|
||||
|
||||
@@ -419,7 +543,11 @@ router.post('/responses', authenticateApiKey, async (req, res) => {
|
||||
res.status(status).json({ error: { message } })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 注册两个路由路径,都使用相同的处理函数
|
||||
router.post('/responses', authenticateApiKey, handleResponses)
|
||||
router.post('/v1/responses', authenticateApiKey, handleResponses)
|
||||
|
||||
// 使用情况统计端点
|
||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
|
||||
@@ -258,6 +258,126 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 验证API Key(仅用于统计查询,不触发激活)
|
||||
async validateApiKeyForStats(apiKey) {
|
||||
try {
|
||||
if (!apiKey || !apiKey.startsWith(this.prefix)) {
|
||||
return { valid: false, error: 'Invalid API key format' }
|
||||
}
|
||||
|
||||
// 计算API Key的哈希值
|
||||
const hashedKey = this._hashApiKey(apiKey)
|
||||
|
||||
// 通过哈希值直接查找API Key(性能优化)
|
||||
const keyData = await redis.findApiKeyByHash(hashedKey)
|
||||
|
||||
if (!keyData) {
|
||||
return { valid: false, error: 'API key not found' }
|
||||
}
|
||||
|
||||
// 检查是否激活
|
||||
if (keyData.isActive !== 'true') {
|
||||
return { valid: false, error: 'API key is disabled' }
|
||||
}
|
||||
|
||||
// 注意:这里不处理激活逻辑,保持 API Key 的未激活状态
|
||||
|
||||
// 检查是否过期(仅对已激活的 Key 检查)
|
||||
if (
|
||||
keyData.isActivated === 'true' &&
|
||||
keyData.expiresAt &&
|
||||
new Date() > new Date(keyData.expiresAt)
|
||||
) {
|
||||
return { valid: false, error: 'API key has expired' }
|
||||
}
|
||||
|
||||
// 如果API Key属于某个用户,检查用户是否被禁用
|
||||
if (keyData.userId) {
|
||||
try {
|
||||
const userService = require('./userService')
|
||||
const user = await userService.getUserById(keyData.userId, false)
|
||||
if (!user || !user.isActive) {
|
||||
return { valid: false, error: 'User account is disabled' }
|
||||
}
|
||||
} catch (userError) {
|
||||
// 如果用户服务出错,记录但不影响API Key验证
|
||||
logger.warn(`Failed to check user status for API key ${keyData.id}:`, userError)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当日费用
|
||||
const dailyCost = (await redis.getDailyCost(keyData.id)) || 0
|
||||
|
||||
// 获取使用统计
|
||||
const usage = await redis.getUsageStats(keyData.id)
|
||||
|
||||
// 解析限制模型数据
|
||||
let restrictedModels = []
|
||||
try {
|
||||
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []
|
||||
} catch (e) {
|
||||
restrictedModels = []
|
||||
}
|
||||
|
||||
// 解析允许的客户端
|
||||
let allowedClients = []
|
||||
try {
|
||||
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []
|
||||
} catch (e) {
|
||||
allowedClients = []
|
||||
}
|
||||
|
||||
// 解析标签
|
||||
let tags = []
|
||||
try {
|
||||
tags = keyData.tags ? JSON.parse(keyData.tags) : []
|
||||
} catch (e) {
|
||||
tags = []
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
keyData: {
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
// 添加激活相关字段
|
||||
expirationMode: keyData.expirationMode || 'fixed',
|
||||
isActivated: keyData.isActivated === 'true',
|
||||
activationDays: parseInt(keyData.activationDays || 0),
|
||||
activatedAt: keyData.activatedAt || null,
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||
geminiAccountId: keyData.geminiAccountId,
|
||||
openaiAccountId: keyData.openaiAccountId,
|
||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||
bedrockAccountId: keyData.bedrockAccountId,
|
||||
permissions: keyData.permissions || 'all',
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||
rateLimitRequests: parseInt(keyData.rateLimitRequests || 0),
|
||||
rateLimitCost: parseFloat(keyData.rateLimitCost || 0),
|
||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||
restrictedModels,
|
||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||
allowedClients,
|
||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||
dailyCost: dailyCost || 0,
|
||||
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
|
||||
tags,
|
||||
usage
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ API key validation error (stats):', error)
|
||||
return { valid: false, error: 'Internal validation error' }
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有API Keys
|
||||
async getAllApiKeys(includeDeleted = false) {
|
||||
try {
|
||||
|
||||
@@ -60,7 +60,9 @@ class ClaudeAccountService {
|
||||
schedulable = true, // 是否可被调度
|
||||
subscriptionInfo = null, // 手动设置的订阅信息
|
||||
autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度
|
||||
useUnifiedUserAgent = false // 是否使用统一Claude Code版本的User-Agent
|
||||
useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent
|
||||
useUnifiedClientId = false, // 是否使用统一的客户端标识
|
||||
unifiedClientId = '' // 统一的客户端标识
|
||||
} = options
|
||||
|
||||
const accountId = uuidv4()
|
||||
@@ -93,6 +95,8 @@ class ClaudeAccountService {
|
||||
schedulable: schedulable.toString(), // 是否可被调度
|
||||
autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度
|
||||
useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent
|
||||
useUnifiedClientId: useUnifiedClientId.toString(), // 是否使用统一的客户端标识
|
||||
unifiedClientId: unifiedClientId || '', // 统一的客户端标识
|
||||
// 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空
|
||||
subscriptionInfo: subscriptionInfo
|
||||
? JSON.stringify(subscriptionInfo)
|
||||
@@ -166,7 +170,10 @@ class ClaudeAccountService {
|
||||
createdAt: accountData.createdAt,
|
||||
expiresAt: accountData.expiresAt,
|
||||
scopes: claudeAiOauth ? claudeAiOauth.scopes : [],
|
||||
autoStopOnWarning
|
||||
autoStopOnWarning,
|
||||
useUnifiedUserAgent,
|
||||
useUnifiedClientId,
|
||||
unifiedClientId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +499,9 @@ class ClaudeAccountService {
|
||||
autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false
|
||||
// 添加统一User-Agent设置
|
||||
useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false
|
||||
// 添加统一客户端标识设置
|
||||
useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false
|
||||
unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识
|
||||
// 添加停止原因
|
||||
stoppedReason: account.stoppedReason || null
|
||||
}
|
||||
@@ -528,7 +538,9 @@ class ClaudeAccountService {
|
||||
'schedulable',
|
||||
'subscriptionInfo',
|
||||
'autoStopOnWarning',
|
||||
'useUnifiedUserAgent'
|
||||
'useUnifiedUserAgent',
|
||||
'useUnifiedClientId',
|
||||
'unifiedClientId'
|
||||
]
|
||||
const updatedData = { ...accountData }
|
||||
|
||||
@@ -1075,6 +1087,8 @@ class ClaudeAccountService {
|
||||
const updatedAccountData = { ...accountData }
|
||||
updatedAccountData.rateLimitedAt = new Date().toISOString()
|
||||
updatedAccountData.rateLimitStatus = 'limited'
|
||||
// 限流时停止调度,与 OpenAI 账号保持一致
|
||||
updatedAccountData.schedulable = false
|
||||
|
||||
// 如果提供了准确的限流重置时间戳(来自API响应头)
|
||||
if (rateLimitResetTimestamp) {
|
||||
@@ -1159,9 +1173,33 @@ class ClaudeAccountService {
|
||||
delete accountData.rateLimitedAt
|
||||
delete accountData.rateLimitStatus
|
||||
delete accountData.rateLimitEndAt // 清除限流结束时间
|
||||
// 恢复可调度状态,与 OpenAI 账号保持一致
|
||||
accountData.schedulable = true
|
||||
await redis.setClaudeAccount(accountId, accountData)
|
||||
|
||||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`)
|
||||
logger.success(
|
||||
`✅ Rate limit removed for account: ${accountData.name} (${accountId}), schedulable restored`
|
||||
)
|
||||
|
||||
// 发送 Webhook 通知限流已解除
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || 'Claude Account',
|
||||
platform: 'claude-oauth',
|
||||
status: 'recovered',
|
||||
errorCode: 'CLAUDE_OAUTH_RATE_LIMIT_CLEARED',
|
||||
reason: 'Rate limit has been cleared and account is now schedulable',
|
||||
timestamp: getISOStringWithTimezone(new Date())
|
||||
})
|
||||
logger.info(
|
||||
`📢 Webhook notification sent for Claude account ${accountData.name} rate limit cleared`
|
||||
)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send rate limit cleared webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error)
|
||||
|
||||
@@ -400,6 +400,7 @@ class ClaudeConsoleAccountService {
|
||||
rateLimitedAt: new Date().toISOString(),
|
||||
rateLimitStatus: 'limited',
|
||||
isActive: 'false', // 禁用账户
|
||||
schedulable: 'false', // 停止调度,与其他平台保持一致
|
||||
errorMessage: `Rate limited at ${new Date().toISOString()}`
|
||||
}
|
||||
|
||||
@@ -468,6 +469,7 @@ class ClaudeConsoleAccountService {
|
||||
// 没有额度限制,完全恢复
|
||||
await client.hset(accountKey, {
|
||||
isActive: 'true',
|
||||
schedulable: 'true', // 恢复调度,与其他平台保持一致
|
||||
status: 'active',
|
||||
errorMessage: ''
|
||||
})
|
||||
@@ -1131,6 +1133,66 @@ class ClaudeConsoleAccountService {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置账户所有异常状态
|
||||
async resetAccountStatus(accountId) {
|
||||
try {
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 准备要更新的字段
|
||||
const updates = {
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
schedulable: 'true',
|
||||
isActive: 'true' // 重要:必须恢复isActive状态
|
||||
}
|
||||
|
||||
// 删除所有异常状态相关的字段
|
||||
const fieldsToDelete = [
|
||||
'rateLimitedAt',
|
||||
'rateLimitStatus',
|
||||
'unauthorizedAt',
|
||||
'unauthorizedCount',
|
||||
'overloadedAt',
|
||||
'overloadStatus',
|
||||
'blockedAt',
|
||||
'quotaStoppedAt'
|
||||
]
|
||||
|
||||
// 执行更新
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for Claude Console account ${accountId}`)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || accountId,
|
||||
platform: 'claude-console',
|
||||
status: 'recovered',
|
||||
errorCode: 'STATUS_RESET',
|
||||
reason: 'Account status manually reset',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.warn('Failed to send webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true, accountId }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to reset Claude Console account status: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeConsoleAccountService()
|
||||
|
||||
@@ -126,8 +126,11 @@ class ClaudeRelayService {
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
|
||||
// 获取账户信息
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
|
||||
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
|
||||
const processedBody = this._processRequestBody(requestBody, clientHeaders)
|
||||
const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
@@ -344,7 +347,7 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 🔄 处理请求体
|
||||
_processRequestBody(body, clientHeaders = {}) {
|
||||
_processRequestBody(body, clientHeaders = {}, account = null) {
|
||||
if (!body) {
|
||||
return body
|
||||
}
|
||||
@@ -446,9 +449,31 @@ class ClaudeRelayService {
|
||||
delete processedBody.top_p
|
||||
}
|
||||
|
||||
// 处理统一的客户端标识
|
||||
if (account && account.useUnifiedClientId && account.unifiedClientId) {
|
||||
this._replaceClientId(processedBody, account.unifiedClientId)
|
||||
}
|
||||
|
||||
return processedBody
|
||||
}
|
||||
|
||||
// 🔄 替换请求中的客户端标识
|
||||
_replaceClientId(body, unifiedClientId) {
|
||||
if (!body || !body.metadata || !body.metadata.user_id || !unifiedClientId) {
|
||||
return
|
||||
}
|
||||
|
||||
const userId = body.metadata.user_id
|
||||
// user_id格式:user_{64位十六进制}_account__session_{uuid}
|
||||
// 只替换第一个下划线后到_account之前的部分(客户端标识)
|
||||
const match = userId.match(/^user_[a-f0-9]{64}(_account__session_[a-f0-9-]{36})$/)
|
||||
if (match && match[1]) {
|
||||
// 替换客户端标识部分
|
||||
body.metadata.user_id = `user_${unifiedClientId}${match[1]}`
|
||||
logger.info(`🔄 Replaced client ID with unified ID: ${body.metadata.user_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔢 验证并限制max_tokens参数
|
||||
_validateAndLimitMaxTokens(body) {
|
||||
if (!body || !body.max_tokens) {
|
||||
@@ -660,16 +685,13 @@ class ClaudeRelayService {
|
||||
|
||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
|
||||
const userAgent =
|
||||
unifiedUA ||
|
||||
clientHeaders?.['user-agent'] ||
|
||||
clientHeaders?.['User-Agent'] ||
|
||||
'claude-cli/1.0.102 (external, cli)'
|
||||
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)'
|
||||
options.headers['User-Agent'] = userAgent
|
||||
}
|
||||
|
||||
logger.info(`🔗 指纹是这个: ${options.headers['User-Agent']}`)
|
||||
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
||||
logger.info(
|
||||
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
|
||||
)
|
||||
|
||||
// 使用自定义的 betaHeader 或默认值
|
||||
const betaHeader =
|
||||
@@ -840,8 +862,11 @@ class ClaudeRelayService {
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||
|
||||
// 获取账户信息
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
|
||||
// 处理请求体(传递 clientHeaders 以判断是否需要设置 Claude Code 系统提示词)
|
||||
const processedBody = this._processRequestBody(requestBody, clientHeaders)
|
||||
const processedBody = this._processRequestBody(requestBody, clientHeaders, account)
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId)
|
||||
@@ -931,14 +956,13 @@ class ClaudeRelayService {
|
||||
|
||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||
if (!options.headers['User-Agent'] && !options.headers['user-agent']) {
|
||||
const userAgent =
|
||||
unifiedUA ||
|
||||
clientHeaders?.['user-agent'] ||
|
||||
clientHeaders?.['User-Agent'] ||
|
||||
'claude-cli/1.0.102 (external, cli)'
|
||||
const userAgent = unifiedUA || 'claude-cli/1.0.57 (external, cli)'
|
||||
options.headers['User-Agent'] = userAgent
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔗 指纹是这个: ${options.headers['User-Agent'] || options.headers['user-agent']}`
|
||||
)
|
||||
// 使用自定义的 betaHeader 或默认值
|
||||
const betaHeader =
|
||||
requestOptions?.betaHeader !== undefined ? requestOptions.betaHeader : this.betaHeader
|
||||
|
||||
@@ -814,14 +814,37 @@ function isRateLimited(account) {
|
||||
}
|
||||
|
||||
// 设置账户限流状态
|
||||
async function setAccountRateLimited(accountId, isLimited) {
|
||||
async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) {
|
||||
const updates = {
|
||||
rateLimitStatus: isLimited ? 'limited' : 'normal',
|
||||
rateLimitedAt: isLimited ? new Date().toISOString() : null
|
||||
rateLimitedAt: isLimited ? new Date().toISOString() : null,
|
||||
// 限流时停止调度,解除限流时恢复调度
|
||||
schedulable: isLimited ? 'false' : 'true'
|
||||
}
|
||||
|
||||
// 如果提供了重置时间(秒数),计算重置时间戳
|
||||
if (isLimited && resetsInSeconds !== null && resetsInSeconds > 0) {
|
||||
const resetTime = new Date(Date.now() + resetsInSeconds * 1000).toISOString()
|
||||
updates.rateLimitResetAt = resetTime
|
||||
logger.info(
|
||||
`🕐 Account ${accountId} will be reset at ${resetTime} (in ${resetsInSeconds} seconds / ${Math.ceil(resetsInSeconds / 60)} minutes)`
|
||||
)
|
||||
} else if (isLimited) {
|
||||
// 如果没有提供重置时间,使用默认的60分钟
|
||||
const defaultResetSeconds = 60 * 60 // 1小时
|
||||
const resetTime = new Date(Date.now() + defaultResetSeconds * 1000).toISOString()
|
||||
updates.rateLimitResetAt = resetTime
|
||||
logger.warn(
|
||||
`⚠️ No reset time provided for account ${accountId}, using default 60 minutes. Reset at ${resetTime}`
|
||||
)
|
||||
} else if (!isLimited) {
|
||||
updates.rateLimitResetAt = null
|
||||
}
|
||||
|
||||
await updateAccount(accountId, updates)
|
||||
logger.info(`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}`)
|
||||
logger.info(
|
||||
`Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}, schedulable: ${updates.schedulable}`
|
||||
)
|
||||
|
||||
// 如果被限流,发送 Webhook 通知
|
||||
if (isLimited) {
|
||||
@@ -834,7 +857,9 @@ async function setAccountRateLimited(accountId, isLimited) {
|
||||
platform: 'openai',
|
||||
status: 'blocked',
|
||||
errorCode: 'OPENAI_RATE_LIMITED',
|
||||
reason: 'Account rate limited (429 error). Estimated reset in 1 hour',
|
||||
reason: resetsInSeconds
|
||||
? `Account rate limited (429 error). Reset in ${Math.ceil(resetsInSeconds / 60)} minutes`
|
||||
: 'Account rate limited (429 error). Estimated reset in 1 hour',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`)
|
||||
@@ -844,6 +869,48 @@ async function setAccountRateLimited(accountId, isLimited) {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置账户所有异常状态
|
||||
async function resetAccountStatus(accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const updates = {
|
||||
// 根据是否有有效的 accessToken 来设置 status
|
||||
status: account.accessToken ? 'active' : 'created',
|
||||
// 恢复可调度状态
|
||||
schedulable: 'true',
|
||||
// 清除错误相关字段
|
||||
errorMessage: null,
|
||||
rateLimitedAt: null,
|
||||
rateLimitStatus: 'normal',
|
||||
rateLimitResetAt: null
|
||||
}
|
||||
|
||||
await updateAccount(accountId, updates)
|
||||
logger.info(`✅ Reset all error status for OpenAI account ${accountId}`)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'openai',
|
||||
status: 'recovered',
|
||||
errorCode: 'STATUS_RESET',
|
||||
reason: 'Account status manually reset',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} status reset`)
|
||||
} catch (webhookError) {
|
||||
logger.error('Failed to send status reset webhook notification:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true, message: 'Account status reset successfully' }
|
||||
}
|
||||
|
||||
// 切换账户调度状态
|
||||
async function toggleSchedulable(accountId) {
|
||||
const account = await getAccount(accountId)
|
||||
@@ -873,15 +940,26 @@ async function getAccountRateLimitInfo(accountId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
if (account.rateLimitStatus === 'limited') {
|
||||
const now = Date.now()
|
||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
||||
const remainingTime = Math.max(0, limitedAt + limitDuration - now)
|
||||
let remainingTime = 0
|
||||
|
||||
// 优先使用 rateLimitResetAt 字段(精确的重置时间)
|
||||
if (account.rateLimitResetAt) {
|
||||
const resetAt = new Date(account.rateLimitResetAt).getTime()
|
||||
remainingTime = Math.max(0, resetAt - now)
|
||||
}
|
||||
// 回退到使用 rateLimitedAt + 默认1小时
|
||||
else if (account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
const limitDuration = 60 * 60 * 1000 // 默认1小时
|
||||
remainingTime = Math.max(0, limitedAt + limitDuration - now)
|
||||
}
|
||||
|
||||
return {
|
||||
isRateLimited: remainingTime > 0,
|
||||
rateLimitedAt: account.rateLimitedAt,
|
||||
rateLimitResetAt: account.rateLimitResetAt,
|
||||
minutesRemaining: Math.ceil(remainingTime / (60 * 1000))
|
||||
}
|
||||
}
|
||||
@@ -889,6 +967,7 @@ async function getAccountRateLimitInfo(accountId) {
|
||||
return {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
rateLimitResetAt: null,
|
||||
minutesRemaining: 0
|
||||
}
|
||||
}
|
||||
@@ -926,6 +1005,7 @@ module.exports = {
|
||||
refreshAccountToken,
|
||||
isTokenExpired,
|
||||
setAccountRateLimited,
|
||||
resetAccountStatus,
|
||||
toggleSchedulable,
|
||||
getAccountRateLimitInfo,
|
||||
updateAccountUsage,
|
||||
|
||||
351
src/services/rateLimitCleanupService.js
Normal file
351
src/services/rateLimitCleanupService.js
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* 限流状态自动清理服务
|
||||
* 定期检查并清理所有类型账号的过期限流状态
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger')
|
||||
const openaiAccountService = require('./openaiAccountService')
|
||||
const claudeAccountService = require('./claudeAccountService')
|
||||
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||
const webhookService = require('./webhookService')
|
||||
|
||||
class RateLimitCleanupService {
|
||||
constructor() {
|
||||
this.cleanupInterval = null
|
||||
this.isRunning = false
|
||||
// 默认每5分钟检查一次
|
||||
this.intervalMs = 5 * 60 * 1000
|
||||
// 存储已清理的账户信息,用于发送恢复通知
|
||||
this.clearedAccounts = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动自动清理服务
|
||||
* @param {number} intervalMinutes - 检查间隔(分钟),默认5分钟
|
||||
*/
|
||||
start(intervalMinutes = 5) {
|
||||
if (this.cleanupInterval) {
|
||||
logger.warn('⚠️ Rate limit cleanup service is already running')
|
||||
return
|
||||
}
|
||||
|
||||
this.intervalMs = intervalMinutes * 60 * 1000
|
||||
|
||||
logger.info(`🧹 Starting rate limit cleanup service (interval: ${intervalMinutes} minutes)`)
|
||||
|
||||
// 立即执行一次清理
|
||||
this.performCleanup()
|
||||
|
||||
// 设置定期执行
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.performCleanup()
|
||||
}, this.intervalMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止自动清理服务
|
||||
*/
|
||||
stop() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
logger.info('🛑 Rate limit cleanup service stopped')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次清理检查
|
||||
*/
|
||||
async performCleanup() {
|
||||
if (this.isRunning) {
|
||||
logger.debug('⏭️ Cleanup already in progress, skipping this cycle')
|
||||
return
|
||||
}
|
||||
|
||||
this.isRunning = true
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
logger.debug('🔍 Starting rate limit cleanup check...')
|
||||
|
||||
const results = {
|
||||
openai: { checked: 0, cleared: 0, errors: [] },
|
||||
claude: { checked: 0, cleared: 0, errors: [] },
|
||||
claudeConsole: { checked: 0, cleared: 0, errors: [] }
|
||||
}
|
||||
|
||||
// 清理 OpenAI 账号
|
||||
await this.cleanupOpenAIAccounts(results.openai)
|
||||
|
||||
// 清理 Claude 账号
|
||||
await this.cleanupClaudeAccounts(results.claude)
|
||||
|
||||
// 清理 Claude Console 账号
|
||||
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
||||
|
||||
const totalChecked =
|
||||
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
||||
const totalCleared =
|
||||
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (totalCleared > 0) {
|
||||
logger.info(
|
||||
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
|
||||
)
|
||||
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
||||
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
||||
logger.info(
|
||||
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
||||
)
|
||||
|
||||
// 发送 webhook 恢复通知
|
||||
if (this.clearedAccounts.length > 0) {
|
||||
await this.sendRecoveryNotifications()
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`🔍 Rate limit cleanup check completed: no expired limits found (${duration}ms)`
|
||||
)
|
||||
}
|
||||
|
||||
// 清空已清理账户列表
|
||||
this.clearedAccounts = []
|
||||
|
||||
// 记录错误
|
||||
const allErrors = [
|
||||
...results.openai.errors,
|
||||
...results.claude.errors,
|
||||
...results.claudeConsole.errors
|
||||
]
|
||||
if (allErrors.length > 0) {
|
||||
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Rate limit cleanup failed:', error)
|
||||
} finally {
|
||||
this.isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 OpenAI 账号的过期限流
|
||||
*/
|
||||
async cleanupOpenAIAccounts(result) {
|
||||
try {
|
||||
const accounts = await openaiAccountService.getAllAccounts()
|
||||
|
||||
for (const account of accounts) {
|
||||
// 只检查标记为限流的账号
|
||||
if (account.rateLimitStatus === 'limited') {
|
||||
result.checked++
|
||||
|
||||
try {
|
||||
// 使用 unifiedOpenAIScheduler 的检查方法,它会自动清除过期的限流
|
||||
const isStillLimited = await unifiedOpenAIScheduler.isAccountRateLimited(account.id)
|
||||
|
||||
if (!isStillLimited) {
|
||||
result.cleared++
|
||||
logger.info(
|
||||
`🧹 Auto-cleared expired rate limit for OpenAI account: ${account.name} (${account.id})`
|
||||
)
|
||||
|
||||
// 记录已清理的账户信息
|
||||
this.clearedAccounts.push({
|
||||
platform: 'OpenAI',
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
previousStatus: 'rate_limited',
|
||||
currentStatus: 'active'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup OpenAI accounts:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 Claude 账号的过期限流
|
||||
*/
|
||||
async cleanupClaudeAccounts(result) {
|
||||
try {
|
||||
const accounts = await claudeAccountService.getAllAccounts()
|
||||
|
||||
for (const account of accounts) {
|
||||
// 只检查标记为限流的账号
|
||||
if (account.rateLimitStatus === 'limited' || account.rateLimitedAt) {
|
||||
result.checked++
|
||||
|
||||
try {
|
||||
// 使用 claudeAccountService 的检查方法,它会自动清除过期的限流
|
||||
const isStillLimited = await claudeAccountService.isAccountRateLimited(account.id)
|
||||
|
||||
if (!isStillLimited) {
|
||||
result.cleared++
|
||||
logger.info(
|
||||
`🧹 Auto-cleared expired rate limit for Claude account: ${account.name} (${account.id})`
|
||||
)
|
||||
|
||||
// 记录已清理的账户信息
|
||||
this.clearedAccounts.push({
|
||||
platform: 'Claude',
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
previousStatus: 'rate_limited',
|
||||
currentStatus: 'active'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup Claude accounts:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 Claude Console 账号的过期限流
|
||||
*/
|
||||
async cleanupClaudeConsoleAccounts(result) {
|
||||
try {
|
||||
const accounts = await claudeConsoleAccountService.getAllAccounts()
|
||||
|
||||
for (const account of accounts) {
|
||||
// 检查两种状态字段:rateLimitStatus 和 status
|
||||
const hasRateLimitStatus = account.rateLimitStatus === 'limited'
|
||||
const hasStatusRateLimited = account.status === 'rate_limited'
|
||||
|
||||
if (hasRateLimitStatus || hasStatusRateLimited) {
|
||||
result.checked++
|
||||
|
||||
try {
|
||||
// 使用 claudeConsoleAccountService 的检查方法,它会自动清除过期的限流
|
||||
const isStillLimited = await claudeConsoleAccountService.isAccountRateLimited(
|
||||
account.id
|
||||
)
|
||||
|
||||
if (!isStillLimited) {
|
||||
result.cleared++
|
||||
|
||||
// 如果 status 字段是 rate_limited,需要额外清理
|
||||
if (hasStatusRateLimited && !hasRateLimitStatus) {
|
||||
await claudeConsoleAccountService.updateAccount(account.id, {
|
||||
status: 'active'
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🧹 Auto-cleared expired rate limit for Claude Console account: ${account.name} (${account.id})`
|
||||
)
|
||||
|
||||
// 记录已清理的账户信息
|
||||
this.clearedAccounts.push({
|
||||
platform: 'Claude Console',
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
previousStatus: 'rate_limited',
|
||||
currentStatus: 'active'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup Claude Console accounts:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发一次清理(供 API 或 CLI 调用)
|
||||
*/
|
||||
async manualCleanup() {
|
||||
logger.info('🧹 Manual rate limit cleanup triggered')
|
||||
await this.performCleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送限流恢复通知
|
||||
*/
|
||||
async sendRecoveryNotifications() {
|
||||
try {
|
||||
// 按平台分组账户
|
||||
const groupedAccounts = {}
|
||||
for (const account of this.clearedAccounts) {
|
||||
if (!groupedAccounts[account.platform]) {
|
||||
groupedAccounts[account.platform] = []
|
||||
}
|
||||
groupedAccounts[account.platform].push(account)
|
||||
}
|
||||
|
||||
// 构建通知消息
|
||||
const platforms = Object.keys(groupedAccounts)
|
||||
const totalAccounts = this.clearedAccounts.length
|
||||
|
||||
let message = `🎉 共有 ${totalAccounts} 个账户的限流状态已恢复\n\n`
|
||||
|
||||
for (const platform of platforms) {
|
||||
const accounts = groupedAccounts[platform]
|
||||
message += `**${platform}** (${accounts.length} 个):\n`
|
||||
for (const account of accounts) {
|
||||
message += `• ${account.accountName} (ID: ${account.accountId})\n`
|
||||
}
|
||||
message += '\n'
|
||||
}
|
||||
|
||||
// 发送 webhook 通知
|
||||
await webhookService.sendNotification('rateLimitRecovery', {
|
||||
title: '限流恢复通知',
|
||||
message,
|
||||
totalAccounts,
|
||||
platforms: Object.keys(groupedAccounts),
|
||||
accounts: this.clearedAccounts,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
logger.info(`📢 已发送限流恢复通知,涉及 ${totalAccounts} 个账户`)
|
||||
} catch (error) {
|
||||
logger.error('❌ 发送限流恢复通知失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务状态
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
running: !!this.cleanupInterval,
|
||||
intervalMinutes: this.intervalMs / (60 * 1000),
|
||||
isProcessing: this.isRunning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const rateLimitCleanupService = new RateLimitCleanupService()
|
||||
|
||||
module.exports = rateLimitCleanupService
|
||||
@@ -303,10 +303,10 @@ class UnifiedOpenAIScheduler {
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(accountId, accountType, sessionHash = null) {
|
||||
async markAccountRateLimited(accountId, accountType, sessionHash = null, resetsInSeconds = null) {
|
||||
try {
|
||||
if (accountType === 'openai') {
|
||||
await openaiAccountService.setAccountRateLimited(accountId, true)
|
||||
await openaiAccountService.setAccountRateLimited(accountId, true, resetsInSeconds)
|
||||
}
|
||||
|
||||
// 删除会话映射
|
||||
@@ -349,12 +349,30 @@ class UnifiedOpenAIScheduler {
|
||||
return false
|
||||
}
|
||||
|
||||
if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
const now = Date.now()
|
||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
||||
if (account.rateLimitStatus === 'limited') {
|
||||
// 如果有具体的重置时间,使用它
|
||||
if (account.rateLimitResetAt) {
|
||||
const resetTime = new Date(account.rateLimitResetAt).getTime()
|
||||
const now = Date.now()
|
||||
const isStillLimited = now < resetTime
|
||||
|
||||
return now < limitedAt + limitDuration
|
||||
// 如果已经过了重置时间,自动清除限流状态
|
||||
if (!isStillLimited) {
|
||||
logger.info(`✅ Auto-clearing rate limit for account ${accountId} (reset time reached)`)
|
||||
await openaiAccountService.setAccountRateLimited(accountId, false)
|
||||
return false
|
||||
}
|
||||
|
||||
return isStillLimited
|
||||
}
|
||||
|
||||
// 如果没有具体的重置时间,使用默认的1小时
|
||||
if (account.rateLimitedAt) {
|
||||
const limitedAt = new Date(account.rateLimitedAt).getTime()
|
||||
const now = Date.now()
|
||||
const limitDuration = 60 * 60 * 1000 // 1小时
|
||||
return now < limitedAt + limitDuration
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
|
||||
@@ -375,6 +375,7 @@ class WebhookService {
|
||||
quotaWarning: '📊 配额警告',
|
||||
systemError: '❌ 系统错误',
|
||||
securityAlert: '🔒 安全警报',
|
||||
rateLimitRecovery: '🎉 限流恢复通知',
|
||||
test: '🧪 测试通知'
|
||||
}
|
||||
|
||||
@@ -390,6 +391,7 @@ class WebhookService {
|
||||
quotaWarning: 'active',
|
||||
systemError: 'critical',
|
||||
securityAlert: 'critical',
|
||||
rateLimitRecovery: 'active',
|
||||
test: 'passive'
|
||||
}
|
||||
|
||||
@@ -405,6 +407,7 @@ class WebhookService {
|
||||
quotaWarning: 'bell',
|
||||
systemError: 'alert',
|
||||
securityAlert: 'alarm',
|
||||
rateLimitRecovery: 'success',
|
||||
test: 'default'
|
||||
}
|
||||
|
||||
@@ -470,6 +473,14 @@ class WebhookService {
|
||||
lines.push(`**平台**: ${data.platform}`)
|
||||
}
|
||||
|
||||
if (data.platforms) {
|
||||
lines.push(`**涉及平台**: ${data.platforms.join(', ')}`)
|
||||
}
|
||||
|
||||
if (data.totalAccounts) {
|
||||
lines.push(`**恢复账户数**: ${data.totalAccounts}`)
|
||||
}
|
||||
|
||||
if (data.status) {
|
||||
lines.push(`**状态**: ${data.status}`)
|
||||
}
|
||||
@@ -539,6 +550,7 @@ class WebhookService {
|
||||
quotaWarning: 'yellow',
|
||||
systemError: 'red',
|
||||
securityAlert: 'red',
|
||||
rateLimitRecovery: 'green',
|
||||
test: 'blue'
|
||||
}
|
||||
|
||||
@@ -554,6 +566,7 @@ class WebhookService {
|
||||
quotaWarning: ':chart_with_downwards_trend:',
|
||||
systemError: ':x:',
|
||||
securityAlert: ':lock:',
|
||||
rateLimitRecovery: ':tada:',
|
||||
test: ':test_tube:'
|
||||
}
|
||||
|
||||
@@ -569,6 +582,7 @@ class WebhookService {
|
||||
quotaWarning: 0xffeb3b, // 黄色
|
||||
systemError: 0xf44336, // 红色
|
||||
securityAlert: 0xf44336, // 红色
|
||||
rateLimitRecovery: 0x4caf50, // 绿色
|
||||
test: 0x2196f3 // 蓝色
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ const securityLogger = winston.createLogger({
|
||||
const authDetailLogger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }),
|
||||
winston.format.printf(({ level, message, timestamp, data }) => {
|
||||
// 使用更深的深度和格式化的JSON输出
|
||||
const jsonData = data ? JSON.stringify(data, null, 2) : '{}'
|
||||
|
||||
@@ -934,6 +934,64 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Claude 统一客户端标识配置 -->
|
||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="form.useUnifiedClientId"
|
||||
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
@change="handleUnifiedClientIdChange"
|
||||
/>
|
||||
<div class="ml-3 flex-1">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
使用统一的客户端标识
|
||||
</span>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
开启后将使用固定的客户端标识,使所有请求看起来来自同一个客户端,减少特征
|
||||
</p>
|
||||
<div v-if="form.useUnifiedClientId" class="mt-3">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>客户端标识 ID</span
|
||||
>
|
||||
<button
|
||||
class="rounded-md bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
|
||||
type="button"
|
||||
@click="regenerateClientId"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code
|
||||
class="block w-full select-all break-all rounded bg-gray-100 px-3 py-2 font-mono text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-300"
|
||||
>
|
||||
<span class="text-blue-600 dark:text-blue-400">{{
|
||||
form.unifiedClientId.substring(0, 8)
|
||||
}}</span
|
||||
><span class="text-gray-500 dark:text-gray-500">{{
|
||||
form.unifiedClientId.substring(8, 56)
|
||||
}}</span
|
||||
><span class="text-blue-600 dark:text-blue-400">{{
|
||||
form.unifiedClientId.substring(56)
|
||||
}}</span>
|
||||
</code>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1 text-blue-500" />
|
||||
此ID将替换请求中的user_id客户端部分,保留session部分用于粘性会话
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 所有平台的优先级设置 -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
@@ -1553,6 +1611,64 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Claude 统一客户端标识配置(编辑模式) -->
|
||||
<div v-if="form.platform === 'claude'" class="mt-4">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="form.useUnifiedClientId"
|
||||
class="mt-1 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
@change="handleUnifiedClientIdChange"
|
||||
/>
|
||||
<div class="ml-3 flex-1">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
使用统一的客户端标识
|
||||
</span>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
开启后将使用固定的客户端标识,使所有请求看起来来自同一个客户端,减少特征
|
||||
</p>
|
||||
<div v-if="form.useUnifiedClientId" class="mt-3">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>客户端标识 ID</span
|
||||
>
|
||||
<button
|
||||
class="rounded-md bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
|
||||
type="button"
|
||||
@click="regenerateClientId"
|
||||
>
|
||||
<i class="fas fa-sync-alt mr-1" />
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code
|
||||
class="block w-full select-all break-all rounded bg-gray-100 px-3 py-2 font-mono text-xs text-gray-700 dark:bg-gray-900 dark:text-gray-300"
|
||||
>
|
||||
<span class="text-blue-600 dark:text-blue-400">{{
|
||||
form.unifiedClientId.substring(0, 8)
|
||||
}}</span
|
||||
><span class="text-gray-500 dark:text-gray-500">{{
|
||||
form.unifiedClientId.substring(8, 56)
|
||||
}}</span
|
||||
><span class="text-blue-600 dark:text-blue-400">{{
|
||||
form.unifiedClientId.substring(56)
|
||||
}}</span>
|
||||
</code>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1 text-blue-500" />
|
||||
此ID将替换请求中的user_id客户端部分,保留session部分用于粘性会话
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 所有平台的优先级设置(编辑模式) -->
|
||||
<div>
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
@@ -2155,6 +2271,8 @@ const setupTokenSessionId = ref('')
|
||||
// Claude Code 统一 User-Agent 信息
|
||||
const unifiedUserAgent = ref('')
|
||||
const clearingCache = ref(false)
|
||||
// 客户端标识编辑状态(已废弃,不再需要编辑功能)
|
||||
// const editingClientId = ref(false)
|
||||
|
||||
// 初始化代理配置
|
||||
const initProxyConfig = () => {
|
||||
@@ -2193,6 +2311,8 @@ const form = ref({
|
||||
subscriptionType: 'claude_max', // 默认为 Claude Max,兼容旧数据
|
||||
autoStopOnWarning: props.account?.autoStopOnWarning || false, // 5小时限制自动停止调度
|
||||
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
||||
useUnifiedClientId: props.account?.useUnifiedClientId || false, // 使用统一的客户端标识
|
||||
unifiedClientId: props.account?.unifiedClientId || '', // 统一的客户端标识
|
||||
groupId: '',
|
||||
groupIds: [],
|
||||
projectId: props.account?.projectId || '',
|
||||
@@ -2477,6 +2597,11 @@ const exchangeSetupTokenCode = async () => {
|
||||
|
||||
const tokenInfo = await accountsStore.exchangeClaudeSetupTokenCode(data)
|
||||
|
||||
// Setup Token模式也需要确保生成客户端ID
|
||||
if (form.value.useUnifiedClientId && !form.value.unifiedClientId) {
|
||||
form.value.unifiedClientId = generateClientId()
|
||||
}
|
||||
|
||||
// 调用相同的成功处理函数
|
||||
await handleOAuthSuccess(tokenInfo)
|
||||
} catch (error) {
|
||||
@@ -2490,6 +2615,15 @@ const exchangeSetupTokenCode = async () => {
|
||||
const handleOAuthSuccess = async (tokenInfo) => {
|
||||
loading.value = true
|
||||
try {
|
||||
// OAuth模式也需要确保生成客户端ID
|
||||
if (
|
||||
form.value.platform === 'claude' &&
|
||||
form.value.useUnifiedClientId &&
|
||||
!form.value.unifiedClientId
|
||||
) {
|
||||
form.value.unifiedClientId = generateClientId()
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
@@ -2513,6 +2647,8 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
||||
data.priority = form.value.priority || 50
|
||||
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||
// 添加订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
@@ -2697,6 +2833,11 @@ const createAccount = async () => {
|
||||
? 10 * 60 * 1000 // 10分钟
|
||||
: 365 * 24 * 60 * 60 * 1000 // 1年
|
||||
|
||||
// 手动模式也需要确保生成客户端ID
|
||||
if (form.value.useUnifiedClientId && !form.value.unifiedClientId) {
|
||||
form.value.unifiedClientId = generateClientId()
|
||||
}
|
||||
|
||||
data.claudeAiOauth = {
|
||||
accessToken: form.value.accessToken,
|
||||
refreshToken: form.value.refreshToken || '',
|
||||
@@ -2706,6 +2847,8 @@ const createAccount = async () => {
|
||||
data.priority = form.value.priority || 50
|
||||
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||
// 添加订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
@@ -2972,9 +3115,16 @@ const updateAccount = async () => {
|
||||
|
||||
// Claude 官方账号优先级和订阅类型更新
|
||||
if (props.account.platform === 'claude') {
|
||||
// 更新模式也需要确保生成客户端ID
|
||||
if (form.value.useUnifiedClientId && !form.value.unifiedClientId) {
|
||||
form.value.unifiedClientId = generateClientId()
|
||||
}
|
||||
|
||||
data.priority = form.value.priority || 50
|
||||
data.autoStopOnWarning = form.value.autoStopOnWarning || false
|
||||
data.useUnifiedUserAgent = form.value.useUnifiedUserAgent || false
|
||||
data.useUnifiedClientId = form.value.useUnifiedClientId || false
|
||||
data.unifiedClientId = form.value.unifiedClientId || ''
|
||||
// 更新订阅类型信息
|
||||
data.subscriptionInfo = {
|
||||
accountType: form.value.subscriptionType || 'claude_max',
|
||||
@@ -3408,6 +3558,8 @@ watch(
|
||||
subscriptionType: subscriptionType,
|
||||
autoStopOnWarning: newAccount.autoStopOnWarning || false,
|
||||
useUnifiedUserAgent: newAccount.useUnifiedUserAgent || false,
|
||||
useUnifiedClientId: newAccount.useUnifiedClientId || false,
|
||||
unifiedClientId: newAccount.unifiedClientId || '',
|
||||
groupId: groupId,
|
||||
groupIds: [],
|
||||
projectId: newAccount.projectId || '',
|
||||
@@ -3530,6 +3682,32 @@ const clearUnifiedCache = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成客户端标识
|
||||
const generateClientId = () => {
|
||||
// 生成64位十六进制字符串(32字节)
|
||||
const bytes = new Uint8Array(32)
|
||||
crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
// 重新生成客户端标识
|
||||
const regenerateClientId = () => {
|
||||
form.value.unifiedClientId = generateClientId()
|
||||
showToast('已生成新的客户端标识', 'success')
|
||||
}
|
||||
|
||||
// 处理统一客户端标识复选框变化
|
||||
const handleUnifiedClientIdChange = () => {
|
||||
if (form.value.useUnifiedClientId) {
|
||||
// 如果启用了统一客户端标识,自动启用统一User-Agent
|
||||
form.value.useUnifiedUserAgent = true
|
||||
// 如果没有客户端标识,自动生成一个
|
||||
if (!form.value.unifiedClientId) {
|
||||
form.value.unifiedClientId = generateClientId()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取统一 User-Agent 信息
|
||||
onMounted(() => {
|
||||
// 获取Claude Code统一User-Agent信息
|
||||
|
||||
@@ -115,7 +115,19 @@
|
||||
<span class="mt-1 flex-shrink-0 text-sm text-gray-600 dark:text-gray-400 md:text-base"
|
||||
>过期时间</span
|
||||
>
|
||||
<div v-if="statsData.expiresAt" class="text-right">
|
||||
<!-- 未激活状态 -->
|
||||
<div
|
||||
v-if="statsData.expirationMode === 'activation' && !statsData.isActivated"
|
||||
class="text-sm font-medium text-amber-600 dark:text-amber-500 md:text-base"
|
||||
>
|
||||
<i class="fas fa-pause-circle mr-1 text-xs md:text-sm" />
|
||||
未激活
|
||||
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400"
|
||||
>(首次使用后{{ statsData.activationDays || 30 }}天过期)</span
|
||||
>
|
||||
</div>
|
||||
<!-- 已设置过期时间 -->
|
||||
<div v-else-if="statsData.expiresAt" class="text-right">
|
||||
<div
|
||||
v-if="isApiKeyExpired(statsData.expiresAt)"
|
||||
class="text-sm font-medium text-red-600 md:text-base"
|
||||
@@ -137,6 +149,7 @@
|
||||
{{ formatExpireDate(statsData.expiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 永不过期 -->
|
||||
<div v-else class="text-sm font-medium text-gray-400 dark:text-gray-500 md:text-base">
|
||||
<i class="fas fa-infinity mr-1 text-xs md:text-sm" />
|
||||
永不过期
|
||||
|
||||
@@ -8,6 +8,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '',
|
||||
showAdminButton: true, // 控制管理后台按钮的显示
|
||||
updatedAt: null
|
||||
})
|
||||
|
||||
@@ -64,6 +65,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
siteName: 'Claude Relay Service',
|
||||
siteIcon: '',
|
||||
siteIconData: '',
|
||||
showAdminButton: true,
|
||||
updatedAt: null
|
||||
}
|
||||
|
||||
|
||||
@@ -272,7 +272,12 @@
|
||||
>
|
||||
<i class="fas fa-share-alt mr-1" />共享
|
||||
</span>
|
||||
<!-- 显示所有分组 -->
|
||||
</div>
|
||||
<!-- 显示所有分组 - 换行显示 -->
|
||||
<div
|
||||
v-if="account.groupInfos && account.groupInfos.length > 0"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
v-for="group in account.groupInfos"
|
||||
:key="group.id"
|
||||
@@ -424,7 +429,7 @@
|
||||
typeof account.rateLimitStatus === 'object' &&
|
||||
account.rateLimitStatus.minutesRemaining > 0
|
||||
"
|
||||
>({{ account.rateLimitStatus.minutesRemaining }}分钟)</span
|
||||
>({{ formatRateLimitTime(account.rateLimitStatus.minutesRemaining) }})</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
@@ -636,7 +641,9 @@
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
v-if="
|
||||
account.platform === 'claude' &&
|
||||
(account.platform === 'claude' ||
|
||||
account.platform === 'claude-console' ||
|
||||
account.platform === 'openai') &&
|
||||
(account.status === 'unauthorized' ||
|
||||
account.status !== 'active' ||
|
||||
account.rateLimitStatus?.isRateLimited ||
|
||||
@@ -1336,7 +1343,7 @@ const loadApiKeys = async (forceReload = false) => {
|
||||
apiKeysLoaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load API keys:', error)
|
||||
// 静默处理错误
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1353,7 +1360,7 @@ const loadAccountGroups = async (forceReload = false) => {
|
||||
groupsLoaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load account groups:', error)
|
||||
// 静默处理错误
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1426,6 +1433,38 @@ const formatRemainingTime = (minutes) => {
|
||||
return `${mins}分钟`
|
||||
}
|
||||
|
||||
// 格式化限流时间(支持显示天数)
|
||||
const formatRateLimitTime = (minutes) => {
|
||||
if (!minutes || minutes <= 0) return ''
|
||||
|
||||
// 转换为整数,避免小数
|
||||
minutes = Math.floor(minutes)
|
||||
|
||||
// 计算天数、小时和分钟
|
||||
const days = Math.floor(minutes / 1440) // 1天 = 1440分钟
|
||||
const remainingAfterDays = minutes % 1440
|
||||
const hours = Math.floor(remainingAfterDays / 60)
|
||||
const mins = remainingAfterDays % 60
|
||||
|
||||
// 根据时间长度返回不同格式
|
||||
if (days > 0) {
|
||||
// 超过1天,显示天数和小时
|
||||
if (hours > 0) {
|
||||
return `${days}天${hours}小时`
|
||||
}
|
||||
return `${days}天`
|
||||
} else if (hours > 0) {
|
||||
// 超过1小时但不到1天,显示小时和分钟
|
||||
if (mins > 0) {
|
||||
return `${hours}小时${mins}分钟`
|
||||
}
|
||||
return `${hours}小时`
|
||||
} else {
|
||||
// 不到1小时,只显示分钟
|
||||
return `${mins}分钟`
|
||||
}
|
||||
}
|
||||
|
||||
// 打开创建账户模态框
|
||||
const openCreateAccountModal = () => {
|
||||
showCreateAccountModal.value = true
|
||||
@@ -1515,7 +1554,22 @@ const resetAccountStatus = async (account) => {
|
||||
|
||||
try {
|
||||
account.isResetting = true
|
||||
const data = await apiClient.post(`/admin/claude-accounts/${account.id}/reset-status`)
|
||||
|
||||
// 根据账户平台选择不同的 API 端点
|
||||
let endpoint = ''
|
||||
if (account.platform === 'openai') {
|
||||
endpoint = `/admin/openai-accounts/${account.id}/reset-status`
|
||||
} else if (account.platform === 'claude') {
|
||||
endpoint = `/admin/claude-accounts/${account.id}/reset-status`
|
||||
} else if (account.platform === 'claude-console') {
|
||||
endpoint = `/admin/claude-console-accounts/${account.id}/reset-status`
|
||||
} else {
|
||||
showToast('不支持的账户类型', 'error')
|
||||
account.isResetting = false
|
||||
return
|
||||
}
|
||||
|
||||
const data = await apiClient.post(endpoint)
|
||||
|
||||
if (data.success) {
|
||||
showToast('账户状态已重置', 'success')
|
||||
@@ -1621,13 +1675,7 @@ const getClaudeAccountType = (account) => {
|
||||
? JSON.parse(account.subscriptionInfo)
|
||||
: account.subscriptionInfo
|
||||
|
||||
// 添加调试日志
|
||||
console.log('Account subscription info:', {
|
||||
accountName: account.name,
|
||||
subscriptionInfo: info,
|
||||
hasClaudeMax: info.hasClaudeMax,
|
||||
hasClaudePro: info.hasClaudePro
|
||||
})
|
||||
// 订阅信息已解析
|
||||
|
||||
// 根据 has_claude_max 和 has_claude_pro 判断
|
||||
if (info.hasClaudeMax === true) {
|
||||
@@ -1639,13 +1687,11 @@ const getClaudeAccountType = (account) => {
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,返回默认值
|
||||
console.error('Failed to parse subscription info:', e)
|
||||
return 'Claude'
|
||||
}
|
||||
}
|
||||
|
||||
// 没有订阅信息,保持原有显示
|
||||
console.log('No subscription info for account:', account.name)
|
||||
return 'Claude'
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div
|
||||
v-if="oemSettings.ldapEnabled || oemSettings.showAdminButton !== false"
|
||||
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
|
||||
/>
|
||||
|
||||
@@ -31,6 +32,7 @@
|
||||
</router-link>
|
||||
<!-- 管理后台按钮 -->
|
||||
<router-link
|
||||
v-if="oemSettings.showAdminButton !== false"
|
||||
class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
|
||||
to="/dashboard"
|
||||
>
|
||||
|
||||
@@ -147,6 +147,41 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 管理后台按钮显示控制 -->
|
||||
<tr class="table-row">
|
||||
<td class="w-48 whitespace-nowrap px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="mr-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600"
|
||||
>
|
||||
<i class="fas fa-eye-slash text-xs text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
管理入口
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">登录按钮显示</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input v-model="hideAdminButton" class="peer sr-only" type="checkbox" />
|
||||
<div
|
||||
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
|
||||
></div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
|
||||
hideAdminButton ? '隐藏登录按钮' : '显示登录按钮'
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
隐藏后,用户需要直接访问 /admin/login 页面登录
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<tr>
|
||||
<td class="px-6 py-6" colspan="2">
|
||||
@@ -189,7 +224,148 @@
|
||||
|
||||
<!-- 移动端卡片视图 -->
|
||||
<div class="space-y-4 sm:hidden">
|
||||
<!-- 省略移动端视图代码... -->
|
||||
<!-- 站点名称卡片 -->
|
||||
<div class="glass-card p-4">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-600 text-white shadow-md"
|
||||
>
|
||||
<i class="fas fa-tag"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">站点名称</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">自定义您的站点品牌名称</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="oemSettings.siteName"
|
||||
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
maxlength="100"
|
||||
placeholder="Claude Relay Service"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 站点图标卡片 -->
|
||||
<div class="glass-card p-4">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-pink-600 text-white shadow-md"
|
||||
>
|
||||
<i class="fas fa-image"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">站点图标</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
上传自定义图标或输入图标URL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<!-- 图标预览 -->
|
||||
<div
|
||||
v-if="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
class="inline-flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-700"
|
||||
>
|
||||
<img
|
||||
alt="图标预览"
|
||||
class="h-8 w-8"
|
||||
:src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
@error="handleIconError"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">当前图标</span>
|
||||
<button
|
||||
class="rounded-lg px-3 py-1 font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-900"
|
||||
@click="removeIcon"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<div>
|
||||
<input
|
||||
ref="iconFileInputMobile"
|
||||
accept=".ico,.png,.jpg,.jpeg,.svg"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="handleIconUpload"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-success px-4 py-2"
|
||||
@click="$refs.iconFileInputMobile.click()"
|
||||
>
|
||||
<i class="fas fa-upload mr-2" />
|
||||
上传图标
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
支持 .ico, .png, .jpg, .svg 格式,最大 350KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理后台按钮显示控制卡片 -->
|
||||
<div class="glass-card p-4">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 text-white shadow-md"
|
||||
>
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">管理入口</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">控制登录按钮在首页的显示</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input v-model="hideAdminButton" class="peer sr-only" type="checkbox" />
|
||||
<div
|
||||
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
|
||||
></div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
|
||||
hideAdminButton ? '隐藏登录按钮' : '显示登录按钮'
|
||||
}}</span>
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
隐藏后,用户需要直接访问 /admin/login 页面登录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮卡片 -->
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
class="btn btn-primary w-full px-6 py-3"
|
||||
:class="{ 'cursor-not-allowed opacity-50': saving }"
|
||||
:disabled="saving"
|
||||
@click="saveOemSettings"
|
||||
>
|
||||
<div v-if="saving" class="loading-spinner mr-2"></div>
|
||||
<i v-else class="fas fa-save mr-2" />
|
||||
{{ saving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn w-full bg-gray-100 px-6 py-3 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
:disabled="saving"
|
||||
@click="resetOemSettings"
|
||||
>
|
||||
<i class="fas fa-undo mr-2" />
|
||||
重置为默认
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="oemSettings.updatedAt"
|
||||
class="text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-clock mr-1" />
|
||||
上次更新: {{ formatDateTime(oemSettings.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -791,6 +967,16 @@ const isMounted = ref(true)
|
||||
// API请求取消控制器
|
||||
const abortController = ref(new AbortController())
|
||||
|
||||
// 计算属性:隐藏管理后台按钮(反转 showAdminButton 的值)
|
||||
const hideAdminButton = computed({
|
||||
get() {
|
||||
return !oemSettings.value.showAdminButton
|
||||
},
|
||||
set(value) {
|
||||
oemSettings.value.showAdminButton = !value
|
||||
}
|
||||
})
|
||||
|
||||
// URL 验证状态
|
||||
const urlError = ref(false)
|
||||
const urlValid = ref(false)
|
||||
@@ -1286,7 +1472,8 @@ const saveOemSettings = async () => {
|
||||
const settings = {
|
||||
siteName: oemSettings.value.siteName,
|
||||
siteIcon: oemSettings.value.siteIcon,
|
||||
siteIconData: oemSettings.value.siteIconData
|
||||
siteIconData: oemSettings.value.siteIconData,
|
||||
showAdminButton: oemSettings.value.showAdminButton
|
||||
}
|
||||
const result = await settingsStore.saveOemSettings(settings)
|
||||
if (result && result.success) {
|
||||
|
||||
@@ -434,6 +434,7 @@
|
||||
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div>
|
||||
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div>
|
||||
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div>
|
||||
<div class="whitespace-nowrap text-gray-300">preferred_auth_method = "apikey"</div>
|
||||
<div class="mt-2"></div>
|
||||
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
|
||||
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
|
||||
@@ -920,6 +921,7 @@
|
||||
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div>
|
||||
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div>
|
||||
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div>
|
||||
<div class="whitespace-nowrap text-gray-300">preferred_auth_method = "apikey"</div>
|
||||
<div class="mt-2"></div>
|
||||
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
|
||||
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
|
||||
@@ -1397,6 +1399,7 @@
|
||||
<div class="whitespace-nowrap text-gray-300">model = "gpt-5"</div>
|
||||
<div class="whitespace-nowrap text-gray-300">model_reasoning_effort = "high"</div>
|
||||
<div class="whitespace-nowrap text-gray-300">disable_response_storage = true</div>
|
||||
<div class="whitespace-nowrap text-gray-300">preferred_auth_method = "apikey"</div>
|
||||
<div class="mt-2"></div>
|
||||
<div class="whitespace-nowrap text-gray-300">[model_providers.crs]</div>
|
||||
<div class="whitespace-nowrap text-gray-300">name = "crs"</div>
|
||||
|
||||
Reference in New Issue
Block a user