mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-25 11:39:54 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03dfedc3d9 | ||
|
|
866806301f | ||
|
|
816c47b51d | ||
|
|
c97bfb6478 | ||
|
|
efda870e96 | ||
|
|
1ae310f2a1 | ||
|
|
6dc85b39c9 | ||
|
|
6c4670213e | ||
|
|
d16b75293d | ||
|
|
2ac31a5706 | ||
|
|
a3a922ac09 | ||
|
|
0073d40299 | ||
|
|
d812af9159 | ||
|
|
4ed5cc631a | ||
|
|
4019b043ec | ||
|
|
9d70110139 | ||
|
|
16e2bcfedb |
@@ -377,19 +377,13 @@ async function handleMessagesRequest(req, res) {
|
|||||||
accountId &&
|
accountId &&
|
||||||
accountType === 'claude-official'
|
accountType === 'claude-official'
|
||||||
) {
|
) {
|
||||||
// 🚫 检测旧会话(污染的会话)
|
// 🆕 允许新 session ID 创建绑定(支持 Claude Code /clear 等场景)
|
||||||
if (isOldSession(req.body)) {
|
// 信任客户端的 session ID 作为新会话的标识,不再检查请求内容
|
||||||
const cfg = await claudeRelayConfigService.getConfig()
|
logger.info(
|
||||||
logger.warn(
|
`🔗 Creating new session binding: sessionId=${originalSessionIdForBinding}, ` +
|
||||||
`🚫 Old session rejected: sessionId=${originalSessionIdForBinding}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
`messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, ` +
|
||||||
)
|
`accountId=${accountId}, accountType=${accountType}`
|
||||||
return res.status(400).json({
|
)
|
||||||
error: {
|
|
||||||
type: 'session_binding_error',
|
|
||||||
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建绑定
|
// 创建绑定
|
||||||
try {
|
try {
|
||||||
@@ -944,19 +938,13 @@ async function handleMessagesRequest(req, res) {
|
|||||||
accountId &&
|
accountId &&
|
||||||
accountType === 'claude-official'
|
accountType === 'claude-official'
|
||||||
) {
|
) {
|
||||||
// 🚫 检测旧会话(污染的会话)
|
// 🆕 允许新 session ID 创建绑定(支持 Claude Code /clear 等场景)
|
||||||
if (isOldSession(req.body)) {
|
// 信任客户端的 session ID 作为新会话的标识,不再检查请求内容
|
||||||
const cfg = await claudeRelayConfigService.getConfig()
|
logger.info(
|
||||||
logger.warn(
|
`🔗 Creating new session binding (non-stream): sessionId=${originalSessionIdForBindingNonStream}, ` +
|
||||||
`🚫 Old session rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
`messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, ` +
|
||||||
)
|
`accountId=${accountId}, accountType=${accountType}`
|
||||||
return res.status(400).json({
|
)
|
||||||
error: {
|
|
||||||
type: 'session_binding_error',
|
|
||||||
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建绑定
|
// 创建绑定
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -264,8 +264,9 @@ const handleResponses = async (req, res) => {
|
|||||||
const isStream = req.body?.stream !== false // 默认为流式(兼容现有行为)
|
const isStream = req.body?.stream !== false // 默认为流式(兼容现有行为)
|
||||||
|
|
||||||
// 判断是否为 Codex CLI 的请求(基于 User-Agent)
|
// 判断是否为 Codex CLI 的请求(基于 User-Agent)
|
||||||
|
// 支持: codex_vscode, codex_cli_rs, codex_exec (非交互式/脚本模式)
|
||||||
const userAgent = req.headers['user-agent'] || ''
|
const userAgent = req.headers['user-agent'] || ''
|
||||||
const codexCliPattern = /^(codex_vscode|codex_cli_rs)\/[\d.]+/i
|
const codexCliPattern = /^(codex_vscode|codex_cli_rs|codex_exec)\/[\d.]+/i
|
||||||
const isCodexCLI = codexCliPattern.test(userAgent)
|
const isCodexCLI = codexCliPattern.test(userAgent)
|
||||||
|
|
||||||
// 如果不是 Codex CLI 请求,则进行适配
|
// 如果不是 Codex CLI 请求,则进行适配
|
||||||
|
|||||||
@@ -1295,7 +1295,7 @@ class ClaudeConsoleAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已经因额度停用(避免重复操作)
|
// 检查是否已经因额度停用(避免重复操作)
|
||||||
if (!accountData.isActive && accountData.quotaStoppedAt) {
|
if (accountData.quotaStoppedAt) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1311,9 +1311,9 @@ class ClaudeConsoleAccountService {
|
|||||||
return // 已经被其他进程处理
|
return // 已经被其他进程处理
|
||||||
}
|
}
|
||||||
|
|
||||||
// 超过额度,停用账户
|
// 超过额度,停止调度但保持账户状态正常
|
||||||
|
// 不修改 isActive 和 status,只用独立字段标记配额超限
|
||||||
const updates = {
|
const updates = {
|
||||||
isActive: false,
|
|
||||||
quotaStoppedAt: new Date().toISOString(),
|
quotaStoppedAt: new Date().toISOString(),
|
||||||
errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||||
schedulable: false, // 停止调度
|
schedulable: false, // 停止调度
|
||||||
@@ -1321,13 +1321,6 @@ class ClaudeConsoleAccountService {
|
|||||||
quotaAutoStopped: 'true'
|
quotaAutoStopped: 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有当前状态是active时才改为quota_exceeded
|
|
||||||
// 如果是rate_limited等其他状态,保持原状态不变
|
|
||||||
const currentStatus = await client.hget(accountKey, 'status')
|
|
||||||
if (currentStatus === 'active') {
|
|
||||||
updates.status = 'quota_exceeded'
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.updateAccount(accountId, updates)
|
await this.updateAccount(accountId, updates)
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -1371,15 +1364,10 @@ class ClaudeConsoleAccountService {
|
|||||||
lastResetDate: today
|
lastResetDate: today
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果账户是因为超额被停用的,恢复账户
|
// 如果账户因配额超限被停用,恢复账户
|
||||||
// 注意:状态可能是 quota_exceeded 或 rate_limited(如果429错误时也超额了)
|
// 新逻辑:不再依赖 isActive === false 和 status 判断
|
||||||
if (
|
// 只要有 quotaStoppedAt 就说明是因配额超限被停止的
|
||||||
accountData.quotaStoppedAt &&
|
if (accountData.quotaStoppedAt) {
|
||||||
accountData.isActive === false &&
|
|
||||||
(accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited')
|
|
||||||
) {
|
|
||||||
updates.isActive = true
|
|
||||||
updates.status = 'active'
|
|
||||||
updates.errorMessage = ''
|
updates.errorMessage = ''
|
||||||
updates.quotaStoppedAt = ''
|
updates.quotaStoppedAt = ''
|
||||||
|
|
||||||
@@ -1389,16 +1377,7 @@ class ClaudeConsoleAccountService {
|
|||||||
updates.quotaAutoStopped = ''
|
updates.quotaAutoStopped = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是rate_limited状态,也清除限流相关字段
|
logger.info(`✅ Restored account ${accountId} after daily quota reset`)
|
||||||
if (accountData.status === 'rate_limited') {
|
|
||||||
const client = redis.getClientSafe()
|
|
||||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
|
||||||
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitAutoStopped')
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`✅ Restored account ${accountId} after daily reset (was ${accountData.status})`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateAccount(accountId, updates)
|
await this.updateAccount(accountId, updates)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const DEFAULT_CONFIG = {
|
|||||||
claudeCodeOnlyEnabled: false,
|
claudeCodeOnlyEnabled: false,
|
||||||
globalSessionBindingEnabled: false,
|
globalSessionBindingEnabled: false,
|
||||||
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||||
sessionBindingTtlDays: 30, // 会话绑定 TTL(天),默认30天
|
sessionBindingTtlDays: 1, // 会话绑定 TTL(天),默认1天(支持 /clear 场景,避免 Redis 累积)
|
||||||
// 用户消息队列配置
|
// 用户消息队列配置
|
||||||
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
|
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
|
||||||
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
|
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const redisClient = require('../models/redis')
|
const redisClient = require('../models/redis')
|
||||||
const { v4: uuidv4 } = require('uuid')
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
const crypto = require('crypto')
|
||||||
const https = require('https')
|
const https = require('https')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const { OAuth2Client } = require('google-auth-library')
|
const { OAuth2Client } = require('google-auth-library')
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class RateLimitCleanupService {
|
|||||||
openai: { checked: 0, cleared: 0, errors: [] },
|
openai: { checked: 0, cleared: 0, errors: [] },
|
||||||
claude: { checked: 0, cleared: 0, errors: [] },
|
claude: { checked: 0, cleared: 0, errors: [] },
|
||||||
claudeConsole: { checked: 0, cleared: 0, errors: [] },
|
claudeConsole: { checked: 0, cleared: 0, errors: [] },
|
||||||
|
quotaExceeded: { checked: 0, cleared: 0, errors: [] },
|
||||||
tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
|
tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,13 +86,22 @@ class RateLimitCleanupService {
|
|||||||
// 清理 Claude Console 账号
|
// 清理 Claude Console 账号
|
||||||
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
||||||
|
|
||||||
|
// 清理 Claude Console 配额超限状态
|
||||||
|
await this.cleanupClaudeConsoleQuotaExceeded(results.quotaExceeded)
|
||||||
|
|
||||||
// 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
|
// 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
|
||||||
await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
|
await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
|
||||||
|
|
||||||
const totalChecked =
|
const totalChecked =
|
||||||
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
results.openai.checked +
|
||||||
|
results.claude.checked +
|
||||||
|
results.claudeConsole.checked +
|
||||||
|
results.quotaExceeded.checked
|
||||||
const totalCleared =
|
const totalCleared =
|
||||||
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
results.openai.cleared +
|
||||||
|
results.claude.cleared +
|
||||||
|
results.claudeConsole.cleared +
|
||||||
|
results.quotaExceeded.cleared
|
||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) {
|
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) {
|
||||||
@@ -103,6 +113,9 @@ class RateLimitCleanupService {
|
|||||||
logger.info(
|
logger.info(
|
||||||
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
` Quota Exceeded: ${results.quotaExceeded.cleared}/${results.quotaExceeded.checked}`
|
||||||
|
)
|
||||||
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
|
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
|
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
|
||||||
@@ -124,6 +137,7 @@ class RateLimitCleanupService {
|
|||||||
...results.openai.errors,
|
...results.openai.errors,
|
||||||
...results.claude.errors,
|
...results.claude.errors,
|
||||||
...results.claudeConsole.errors,
|
...results.claudeConsole.errors,
|
||||||
|
...results.quotaExceeded.errors,
|
||||||
...results.tokenRefresh.errors
|
...results.tokenRefresh.errors
|
||||||
]
|
]
|
||||||
if (allErrors.length > 0) {
|
if (allErrors.length > 0) {
|
||||||
@@ -358,6 +372,54 @@ class RateLimitCleanupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查并恢复 Claude Console 账号的配额超限状态
|
||||||
|
*/
|
||||||
|
async cleanupClaudeConsoleQuotaExceeded(result) {
|
||||||
|
try {
|
||||||
|
const accounts = await claudeConsoleAccountService.getAllAccounts()
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
// 检查是否处于配额超限状态
|
||||||
|
if (account.status === 'quota_exceeded' || account.quotaStoppedAt) {
|
||||||
|
result.checked++
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 isAccountQuotaExceeded 方法,它会自动触发恢复
|
||||||
|
const isStillExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(
|
||||||
|
account.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isStillExceeded) {
|
||||||
|
result.cleared++
|
||||||
|
logger.info(
|
||||||
|
`🧹 Auto-recovered quota exceeded for Claude Console account: ${account.name} (${account.id})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 记录已恢复的账户信息
|
||||||
|
this.clearedAccounts.push({
|
||||||
|
platform: 'Claude Console',
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
previousStatus: 'quota_exceeded',
|
||||||
|
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 quota exceeded accounts:', error)
|
||||||
|
result.errors.push({ error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主动刷新 Claude 账户 Token(防止等待重置期间 Token 过期)
|
* 主动刷新 Claude 账户 Token(防止等待重置期间 Token 过期)
|
||||||
* 仅对因限流/配额限制而等待重置的账户执行刷新:
|
* 仅对因限流/配额限制而等待重置的账户执行刷新:
|
||||||
|
|||||||
@@ -673,6 +673,23 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 主动检查配额超限状态并尝试恢复(在过滤之前执行,确保可以恢复配额超限的账户)
|
||||||
|
if (currentAccount.status === 'quota_exceeded') {
|
||||||
|
// 触发配额检查,如果已到重置时间会自动恢复账户
|
||||||
|
const isStillExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(
|
||||||
|
currentAccount.id
|
||||||
|
)
|
||||||
|
if (!isStillExceeded) {
|
||||||
|
// 重新获取账户最新状态
|
||||||
|
const refreshedAccount = await claudeConsoleAccountService.getAccount(currentAccount.id)
|
||||||
|
if (refreshedAccount) {
|
||||||
|
// 更新当前循环中的账户数据
|
||||||
|
currentAccount = refreshedAccount
|
||||||
|
logger.info(`✅ Account ${currentAccount.name} recovered from quota_exceeded status`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔍 Checking Claude Console account: ${currentAccount.name} - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}`
|
`🔍 Checking Claude Console account: ${currentAccount.name} - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 客户端定义配置
|
* 客户端定义配置
|
||||||
* 定义所有支持的客户端类型和它们的属性
|
* 定义所有支持的客户端类型和它们的属性
|
||||||
|
*
|
||||||
|
* allowedPathPrefixes: 允许访问的路径前缀白名单
|
||||||
|
* - 当启用客户端限制时,只有匹配白名单的路径才允许访问
|
||||||
|
* - 防止通过其他兼容端点(如 /v1/chat/completions)绕过客户端限制
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CLIENT_DEFINITIONS = {
|
const CLIENT_DEFINITIONS = {
|
||||||
@@ -9,7 +13,27 @@ const CLIENT_DEFINITIONS = {
|
|||||||
name: 'Claude Code',
|
name: 'Claude Code',
|
||||||
displayName: 'Claude Code CLI',
|
displayName: 'Claude Code CLI',
|
||||||
description: 'Claude Code command-line interface',
|
description: 'Claude Code command-line interface',
|
||||||
icon: '🤖'
|
icon: '🤖',
|
||||||
|
// Claude Code 仅允许访问 Claude 原生端点,禁止访问 OpenAI 兼容端点
|
||||||
|
allowedPathPrefixes: [
|
||||||
|
'/api/v1/messages',
|
||||||
|
'/api/v1/models',
|
||||||
|
'/api/v1/me',
|
||||||
|
'/api/v1/usage',
|
||||||
|
'/api/v1/key-info',
|
||||||
|
'/api/v1/organizations',
|
||||||
|
'/claude/v1/messages',
|
||||||
|
'/claude/v1/models',
|
||||||
|
'/antigravity/api/',
|
||||||
|
'/gemini-cli/api/',
|
||||||
|
'/api/event_logging',
|
||||||
|
'/v1/messages',
|
||||||
|
'/v1/models',
|
||||||
|
'/v1/me',
|
||||||
|
'/v1/usage',
|
||||||
|
'/v1/key-info',
|
||||||
|
'/v1/organizations'
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
GEMINI_CLI: {
|
GEMINI_CLI: {
|
||||||
@@ -17,7 +41,9 @@ const CLIENT_DEFINITIONS = {
|
|||||||
name: 'Gemini CLI',
|
name: 'Gemini CLI',
|
||||||
displayName: 'Gemini Command Line Tool',
|
displayName: 'Gemini Command Line Tool',
|
||||||
description: 'Google Gemini API command-line interface',
|
description: 'Google Gemini API command-line interface',
|
||||||
icon: '💎'
|
icon: '💎',
|
||||||
|
// Gemini CLI 仅允许访问 Gemini 端点
|
||||||
|
allowedPathPrefixes: ['/gemini/']
|
||||||
},
|
},
|
||||||
|
|
||||||
CODEX_CLI: {
|
CODEX_CLI: {
|
||||||
@@ -25,7 +51,9 @@ const CLIENT_DEFINITIONS = {
|
|||||||
name: 'Codex CLI',
|
name: 'Codex CLI',
|
||||||
displayName: 'Codex Command Line Tool',
|
displayName: 'Codex Command Line Tool',
|
||||||
description: 'Cursor/Codex command-line interface',
|
description: 'Cursor/Codex command-line interface',
|
||||||
icon: '🔷'
|
icon: '🔷',
|
||||||
|
// Codex CLI 仅允许访问 OpenAI Responses 和 Azure 端点
|
||||||
|
allowedPathPrefixes: ['/openai/responses', '/openai/v1/responses', '/azure/']
|
||||||
},
|
},
|
||||||
|
|
||||||
DROID_CLI: {
|
DROID_CLI: {
|
||||||
@@ -33,7 +61,9 @@ const CLIENT_DEFINITIONS = {
|
|||||||
name: 'Droid CLI',
|
name: 'Droid CLI',
|
||||||
displayName: 'Factory Droid CLI',
|
displayName: 'Factory Droid CLI',
|
||||||
description: 'Factory Droid platform command-line interface',
|
description: 'Factory Droid platform command-line interface',
|
||||||
icon: '🤖'
|
icon: '🤖',
|
||||||
|
// Droid CLI 仅允许访问 Droid 端点
|
||||||
|
allowedPathPrefixes: ['/droid/']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +90,34 @@ function isValidClientId(clientId) {
|
|||||||
return Object.values(CLIENT_IDS).includes(clientId)
|
return Object.values(CLIENT_IDS).includes(clientId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查路径是否允许指定客户端访问
|
||||||
|
* @param {string} clientId - 客户端ID
|
||||||
|
* @param {string} path - 请求路径 (originalUrl 或 path)
|
||||||
|
* @returns {boolean} 是否允许
|
||||||
|
*/
|
||||||
|
function isPathAllowedForClient(clientId, path) {
|
||||||
|
const definition = getClientDefinitionById(clientId)
|
||||||
|
if (!definition) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有定义 allowedPathPrefixes,则不限制路径(向后兼容)
|
||||||
|
if (!definition.allowedPathPrefixes || definition.allowedPathPrefixes.length === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = (path || '').toLowerCase()
|
||||||
|
return definition.allowedPathPrefixes.some((prefix) =>
|
||||||
|
normalizedPath.startsWith(prefix.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
CLIENT_DEFINITIONS,
|
CLIENT_DEFINITIONS,
|
||||||
CLIENT_IDS,
|
CLIENT_IDS,
|
||||||
getAllClientDefinitions,
|
getAllClientDefinitions,
|
||||||
getClientDefinitionById,
|
getClientDefinitionById,
|
||||||
isValidClientId
|
isValidClientId,
|
||||||
|
isPathAllowedForClient
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,25 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const { CLIENT_DEFINITIONS, getAllClientDefinitions } = require('./clientDefinitions')
|
const {
|
||||||
|
CLIENT_IDS,
|
||||||
|
getAllClientDefinitions,
|
||||||
|
getClientDefinitionById,
|
||||||
|
isPathAllowedForClient
|
||||||
|
} = require('./clientDefinitions')
|
||||||
const ClaudeCodeValidator = require('./clients/claudeCodeValidator')
|
const ClaudeCodeValidator = require('./clients/claudeCodeValidator')
|
||||||
const GeminiCliValidator = require('./clients/geminiCliValidator')
|
const GeminiCliValidator = require('./clients/geminiCliValidator')
|
||||||
const CodexCliValidator = require('./clients/codexCliValidator')
|
const CodexCliValidator = require('./clients/codexCliValidator')
|
||||||
const DroidCliValidator = require('./clients/droidCliValidator')
|
const DroidCliValidator = require('./clients/droidCliValidator')
|
||||||
|
|
||||||
|
// 客户端ID到验证器的映射表
|
||||||
|
const VALIDATOR_MAP = {
|
||||||
|
[CLIENT_IDS.CLAUDE_CODE]: ClaudeCodeValidator,
|
||||||
|
[CLIENT_IDS.GEMINI_CLI]: GeminiCliValidator,
|
||||||
|
[CLIENT_IDS.CODEX_CLI]: CodexCliValidator,
|
||||||
|
[CLIENT_IDS.DROID_CLI]: DroidCliValidator
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户端验证器类
|
* 客户端验证器类
|
||||||
*/
|
*/
|
||||||
@@ -20,19 +33,12 @@ class ClientValidator {
|
|||||||
* @returns {Object|null} 验证器实例
|
* @returns {Object|null} 验证器实例
|
||||||
*/
|
*/
|
||||||
static getValidator(clientId) {
|
static getValidator(clientId) {
|
||||||
switch (clientId) {
|
const validator = VALIDATOR_MAP[clientId]
|
||||||
case 'claude_code':
|
if (!validator) {
|
||||||
return ClaudeCodeValidator
|
logger.warn(`Unknown client ID: ${clientId}`)
|
||||||
case 'gemini_cli':
|
return null
|
||||||
return GeminiCliValidator
|
|
||||||
case 'codex_cli':
|
|
||||||
return CodexCliValidator
|
|
||||||
case 'droid_cli':
|
|
||||||
return DroidCliValidator
|
|
||||||
default:
|
|
||||||
logger.warn(`Unknown client ID: ${clientId}`)
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return validator
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +46,7 @@ class ClientValidator {
|
|||||||
* @returns {Array<string>} 客户端ID列表
|
* @returns {Array<string>} 客户端ID列表
|
||||||
*/
|
*/
|
||||||
static getSupportedClients() {
|
static getSupportedClients() {
|
||||||
return ['claude_code', 'gemini_cli', 'codex_cli', 'droid_cli']
|
return Object.keys(VALIDATOR_MAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +73,7 @@ class ClientValidator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证请求是否来自允许的客户端列表中的任一客户端
|
* 验证请求是否来自允许的客户端列表中的任一客户端
|
||||||
|
* 包含路径白名单检查,防止通过其他兼容端点绕过客户端限制
|
||||||
* @param {Array<string>} allowedClients - 允许的客户端ID列表
|
* @param {Array<string>} allowedClients - 允许的客户端ID列表
|
||||||
* @param {Object} req - Express请求对象
|
* @param {Object} req - Express请求对象
|
||||||
* @returns {Object} 验证结果对象
|
* @returns {Object} 验证结果对象
|
||||||
@@ -74,10 +81,12 @@ class ClientValidator {
|
|||||||
static validateRequest(allowedClients, req) {
|
static validateRequest(allowedClients, req) {
|
||||||
const userAgent = req.headers['user-agent'] || ''
|
const userAgent = req.headers['user-agent'] || ''
|
||||||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||||
|
const requestPath = req.originalUrl || req.path || ''
|
||||||
|
|
||||||
// 记录验证开始
|
// 记录验证开始
|
||||||
logger.api(`🔍 Starting client validation for User-Agent: "${userAgent}"`)
|
logger.api(`🔍 Starting client validation for User-Agent: "${userAgent}"`)
|
||||||
logger.api(` Allowed clients: ${allowedClients.join(', ')}`)
|
logger.api(` Allowed clients: ${allowedClients.join(', ')}`)
|
||||||
|
logger.api(` Request path: ${requestPath}`)
|
||||||
logger.api(` Request from IP: ${clientIP}`)
|
logger.api(` Request from IP: ${clientIP}`)
|
||||||
|
|
||||||
// 遍历所有允许的客户端进行验证
|
// 遍历所有允许的客户端进行验证
|
||||||
@@ -89,6 +98,12 @@ class ClientValidator {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 路径白名单检查:先检查路径是否允许该客户端访问
|
||||||
|
if (!isPathAllowedForClient(clientId, requestPath)) {
|
||||||
|
logger.debug(`Path "${requestPath}" not allowed for ${validator.getName()}, skipping`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(`Checking against ${validator.getName()}...`)
|
logger.debug(`Checking against ${validator.getName()}...`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -96,12 +111,13 @@ class ClientValidator {
|
|||||||
// 验证成功
|
// 验证成功
|
||||||
logger.api(`✅ Client validated: ${validator.getName()} (${clientId})`)
|
logger.api(`✅ Client validated: ${validator.getName()} (${clientId})`)
|
||||||
logger.api(` Matched User-Agent: "${userAgent}"`)
|
logger.api(` Matched User-Agent: "${userAgent}"`)
|
||||||
|
logger.api(` Allowed path: "${requestPath}"`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
matchedClient: clientId,
|
matchedClient: clientId,
|
||||||
clientName: validator.getName(),
|
clientName: validator.getName(),
|
||||||
clientInfo: Object.values(CLIENT_DEFINITIONS).find((def) => def.id === clientId)
|
clientInfo: getClientDefinitionById(clientId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -111,11 +127,15 @@ class ClientValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 没有匹配的客户端
|
// 没有匹配的客户端
|
||||||
logger.api(`❌ No matching client found for User-Agent: "${userAgent}"`)
|
logger.api(
|
||||||
|
`❌ No matching client found for User-Agent: "${userAgent}" and path: "${requestPath}"`
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
matchedClient: null,
|
matchedClient: null,
|
||||||
reason: 'No matching client found'
|
reason: 'No matching client found or path not allowed',
|
||||||
|
userAgent,
|
||||||
|
requestPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class CodexCliValidator {
|
|||||||
// Codex CLI 的 UA 格式:
|
// Codex CLI 的 UA 格式:
|
||||||
// - codex_vscode/0.35.0 (Windows 10.0.26100; x86_64) unknown (Cursor; 0.4.10)
|
// - codex_vscode/0.35.0 (Windows 10.0.26100; x86_64) unknown (Cursor; 0.4.10)
|
||||||
// - codex_cli_rs/0.38.0 (Ubuntu 22.4.0; x86_64) WindowsTerminal
|
// - codex_cli_rs/0.38.0 (Ubuntu 22.4.0; x86_64) WindowsTerminal
|
||||||
const codexCliPattern = /^(codex_vscode|codex_cli_rs)\/[\d.]+/i
|
// - codex_exec/0.89.0 (Mac OS 26.2.0; arm64) xterm-256color (非交互式/脚本模式)
|
||||||
|
const codexCliPattern = /^(codex_vscode|codex_cli_rs|codex_exec)\/[\d.]+/i
|
||||||
const uaMatch = userAgent.match(codexCliPattern)
|
const uaMatch = userAgent.match(codexCliPattern)
|
||||||
|
|
||||||
if (!uaMatch) {
|
if (!uaMatch) {
|
||||||
|
|||||||
2
web/admin-spa/package-lock.json
generated
2
web/admin-spa/package-lock.json
generated
@@ -3789,7 +3789,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/prettier-plugin-tailwindcss": {
|
"node_modules/prettier-plugin-tailwindcss": {
|
||||||
"version": "0.6.14",
|
"version": "0.6.14",
|
||||||
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
|
"resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
|
||||||
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
|
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -4119,12 +4119,24 @@ const getSchedulableReason = (account) => {
|
|||||||
if (account.status === 'unauthorized') {
|
if (account.status === 'unauthorized') {
|
||||||
return 'API Key无效或已过期(401错误)'
|
return 'API Key无效或已过期(401错误)'
|
||||||
}
|
}
|
||||||
|
// 检查配额超限状态
|
||||||
|
if (account.status === 'quota_exceeded') {
|
||||||
|
return '余额不足'
|
||||||
|
}
|
||||||
if (account.overloadStatus === 'overloaded') {
|
if (account.overloadStatus === 'overloaded') {
|
||||||
return '服务过载(529错误)'
|
return '服务过载(529错误)'
|
||||||
}
|
}
|
||||||
if (account.rateLimitStatus === 'limited') {
|
if (account.rateLimitStatus === 'limited') {
|
||||||
return '触发限流(429错误)'
|
return '触发限流(429错误)'
|
||||||
}
|
}
|
||||||
|
// 检查配额超限状态(quotaAutoStopped 或 quotaStoppedAt 任一存在即表示配额超限)
|
||||||
|
if (
|
||||||
|
account.quotaAutoStopped === 'true' ||
|
||||||
|
account.quotaAutoStopped === true ||
|
||||||
|
account.quotaStoppedAt
|
||||||
|
) {
|
||||||
|
return '余额不足'
|
||||||
|
}
|
||||||
if (account.status === 'blocked' && account.errorMessage) {
|
if (account.status === 'blocked' && account.errorMessage) {
|
||||||
return account.errorMessage
|
return account.errorMessage
|
||||||
}
|
}
|
||||||
@@ -4203,6 +4215,15 @@ const getSchedulableReason = (account) => {
|
|||||||
return '手动停止调度'
|
return '手动停止调度'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否是配额超限状态(用于状态显示判断)
|
||||||
|
const isQuotaExceeded = (account) => {
|
||||||
|
return (
|
||||||
|
account.quotaAutoStopped === 'true' ||
|
||||||
|
account.quotaAutoStopped === true ||
|
||||||
|
!!account.quotaStoppedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户状态文本
|
// 获取账户状态文本
|
||||||
const getAccountStatusText = (account) => {
|
const getAccountStatusText = (account) => {
|
||||||
// 检查是否被封锁
|
// 检查是否被封锁
|
||||||
@@ -4221,9 +4242,9 @@ const getAccountStatusText = (account) => {
|
|||||||
if (account.status === 'temp_error') return '临时异常'
|
if (account.status === 'temp_error') return '临时异常'
|
||||||
// 检查是否错误
|
// 检查是否错误
|
||||||
if (account.status === 'error' || !account.isActive) return '错误'
|
if (account.status === 'error' || !account.isActive) return '错误'
|
||||||
// 检查是否可调度
|
// 配额超限时显示"正常"(不显示"已暂停")
|
||||||
if (account.schedulable === false) return '已暂停'
|
if (account.schedulable === false && !isQuotaExceeded(account)) return '已暂停'
|
||||||
// 否则正常
|
// 否则正常(包括配额超限状态)
|
||||||
return '正常'
|
return '正常'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4249,7 +4270,8 @@ const getAccountStatusClass = (account) => {
|
|||||||
if (account.status === 'error' || !account.isActive) {
|
if (account.status === 'error' || !account.isActive) {
|
||||||
return 'bg-red-100 text-red-800'
|
return 'bg-red-100 text-red-800'
|
||||||
}
|
}
|
||||||
if (account.schedulable === false) {
|
// 配额超限时显示绿色(正常)
|
||||||
|
if (account.schedulable === false && !isQuotaExceeded(account)) {
|
||||||
return 'bg-gray-100 text-gray-800'
|
return 'bg-gray-100 text-gray-800'
|
||||||
}
|
}
|
||||||
return 'bg-green-100 text-green-800'
|
return 'bg-green-100 text-green-800'
|
||||||
@@ -4277,7 +4299,8 @@ const getAccountStatusDotClass = (account) => {
|
|||||||
if (account.status === 'error' || !account.isActive) {
|
if (account.status === 'error' || !account.isActive) {
|
||||||
return 'bg-red-500'
|
return 'bg-red-500'
|
||||||
}
|
}
|
||||||
if (account.schedulable === false) {
|
// 配额超限时显示绿色(正常)
|
||||||
|
if (account.schedulable === false && !isQuotaExceeded(account)) {
|
||||||
return 'bg-gray-500'
|
return 'bg-gray-500'
|
||||||
}
|
}
|
||||||
return 'bg-green-500'
|
return 'bg-green-500'
|
||||||
|
|||||||
@@ -1904,7 +1904,7 @@ const claudeConfig = ref({
|
|||||||
claudeCodeOnlyEnabled: false,
|
claudeCodeOnlyEnabled: false,
|
||||||
globalSessionBindingEnabled: false,
|
globalSessionBindingEnabled: false,
|
||||||
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||||
sessionBindingTtlDays: 30,
|
sessionBindingTtlDays: 1,
|
||||||
userMessageQueueEnabled: false, // 与后端默认值保持一致
|
userMessageQueueEnabled: false, // 与后端默认值保持一致
|
||||||
userMessageQueueDelayMs: 200,
|
userMessageQueueDelayMs: 200,
|
||||||
userMessageQueueTimeoutMs: 5000, // 与后端默认值保持一致(优化后锁持有时间短无需长等待)
|
userMessageQueueTimeoutMs: 5000, // 与后端默认值保持一致(优化后锁持有时间短无需长等待)
|
||||||
@@ -2203,7 +2203,7 @@ const loadClaudeConfig = async () => {
|
|||||||
globalSessionBindingEnabled: response.config?.globalSessionBindingEnabled ?? false,
|
globalSessionBindingEnabled: response.config?.globalSessionBindingEnabled ?? false,
|
||||||
sessionBindingErrorMessage:
|
sessionBindingErrorMessage:
|
||||||
response.config?.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。',
|
response.config?.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。',
|
||||||
sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 30,
|
sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 1,
|
||||||
userMessageQueueEnabled: response.config?.userMessageQueueEnabled ?? false, // 与后端默认值保持一致
|
userMessageQueueEnabled: response.config?.userMessageQueueEnabled ?? false, // 与后端默认值保持一致
|
||||||
userMessageQueueDelayMs: response.config?.userMessageQueueDelayMs ?? 200,
|
userMessageQueueDelayMs: response.config?.userMessageQueueDelayMs ?? 200,
|
||||||
userMessageQueueTimeoutMs: response.config?.userMessageQueueTimeoutMs ?? 5000, // 与后端默认值保持一致
|
userMessageQueueTimeoutMs: response.config?.userMessageQueueTimeoutMs ?? 5000, // 与后端默认值保持一致
|
||||||
|
|||||||
Reference in New Issue
Block a user