mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
fix: 修复分组调度功能和API Keys统计弹窗UI问题
1. 分组调度功能修复: - 统一使用 unifiedClaudeScheduler 和 unifiedGeminiScheduler - 修复 schedulable 字段数据类型不一致问题(布尔值/字符串) - 添加 _isSchedulable() 辅助方法确保兼容性 - 修复所有路由文件中的调度器调用 2. API Keys 统计弹窗UI优化: - 统一弹窗样式与系统UI风格 - 添加右上角关闭按钮 - 修复移动端宽度问题(设置为95%屏幕宽度) - 使用 Teleport 组件和项目通用样式 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRel
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const sessionHelper = require('../utils/sessionHelper');
|
const sessionHelper = require('../utils/sessionHelper');
|
||||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler');
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler');
|
||||||
|
const apiKeyService = require('../services/apiKeyService');
|
||||||
// const { OAuth2Client } = require('google-auth-library'); // OAuth2Client is not used in this file
|
// const { OAuth2Client } = require('google-auth-library'); // OAuth2Client is not used in this file
|
||||||
|
|
||||||
// 生成会话哈希
|
// 生成会话哈希
|
||||||
@@ -195,7 +196,13 @@ router.get('/models', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 选择账户获取模型列表
|
// 选择账户获取模型列表
|
||||||
const account = await geminiAccountService.selectAvailableAccount(apiKeyData.id);
|
let account = null;
|
||||||
|
try {
|
||||||
|
const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(apiKeyData, null, null);
|
||||||
|
account = await geminiAccountService.getAccount(accountSelection.accountId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to select Gemini account for models endpoint:', error);
|
||||||
|
}
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
// 返回默认模型列表
|
// 返回默认模型列表
|
||||||
@@ -470,6 +477,25 @@ async function handleGenerateContent(req, res) {
|
|||||||
req.apiKey?.id // 使用 API Key ID 作为 session ID
|
req.apiKey?.id // 使用 API Key ID 作为 session ID
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 记录使用统计
|
||||||
|
if (response?.response?.usageMetadata) {
|
||||||
|
try {
|
||||||
|
const usage = response.response.usageMetadata;
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
req.apiKey.id,
|
||||||
|
usage.promptTokenCount || 0,
|
||||||
|
usage.candidatesTokenCount || 0,
|
||||||
|
0, // cacheCreateTokens
|
||||||
|
0, // cacheReadTokens
|
||||||
|
model,
|
||||||
|
account.id
|
||||||
|
);
|
||||||
|
logger.info(`📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to record Gemini usage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(321, error.response);
|
console.log(321, error.response);
|
||||||
@@ -565,11 +591,73 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('X-Accel-Buffering', 'no');
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
|
||||||
// 直接管道转发流式响应,不进行额外处理
|
// 处理流式响应并捕获usage数据
|
||||||
streamResponse.pipe(res, { end: false });
|
let buffer = '';
|
||||||
|
let totalUsage = {
|
||||||
|
promptTokenCount: 0,
|
||||||
|
candidatesTokenCount: 0,
|
||||||
|
totalTokenCount: 0
|
||||||
|
};
|
||||||
|
let usageReported = false;
|
||||||
|
|
||||||
streamResponse.on('end', () => {
|
streamResponse.on('data', (chunk) => {
|
||||||
|
try {
|
||||||
|
const chunkStr = chunk.toString();
|
||||||
|
|
||||||
|
// 直接转发数据到客户端
|
||||||
|
if (!res.destroyed) {
|
||||||
|
res.write(chunkStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时解析数据以捕获usage信息
|
||||||
|
buffer += chunkStr;
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ') && line.length > 6) {
|
||||||
|
try {
|
||||||
|
const jsonStr = line.slice(6);
|
||||||
|
if (jsonStr && jsonStr !== '[DONE]') {
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
|
||||||
|
// 从响应中提取usage数据
|
||||||
|
if (data.response?.usageMetadata) {
|
||||||
|
totalUsage = data.response.usageMetadata;
|
||||||
|
logger.debug('📊 Captured Gemini usage data:', totalUsage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing stream chunk:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
streamResponse.on('end', async () => {
|
||||||
logger.info('Stream completed successfully');
|
logger.info('Stream completed successfully');
|
||||||
|
|
||||||
|
// 记录使用统计
|
||||||
|
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
||||||
|
try {
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
req.apiKey.id,
|
||||||
|
totalUsage.promptTokenCount || 0,
|
||||||
|
totalUsage.candidatesTokenCount || 0,
|
||||||
|
0, // cacheCreateTokens
|
||||||
|
0, // cacheReadTokens
|
||||||
|
model,
|
||||||
|
account.id
|
||||||
|
);
|
||||||
|
logger.info(`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to record Gemini usage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.end();
|
res.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const { authenticateApiKey } = require('../middleware/auth');
|
|||||||
const claudeRelayService = require('../services/claudeRelayService');
|
const claudeRelayService = require('../services/claudeRelayService');
|
||||||
const openaiToClaude = require('../services/openaiToClaude');
|
const openaiToClaude = require('../services/openaiToClaude');
|
||||||
const apiKeyService = require('../services/apiKeyService');
|
const apiKeyService = require('../services/apiKeyService');
|
||||||
const claudeAccountService = require('../services/claudeAccountService');
|
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler');
|
||||||
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService');
|
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService');
|
||||||
const sessionHelper = require('../utils/sessionHelper');
|
const sessionHelper = require('../utils/sessionHelper');
|
||||||
|
|
||||||
@@ -206,7 +206,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
const sessionHash = sessionHelper.generateSessionHash(claudeRequest);
|
const sessionHash = sessionHelper.generateSessionHash(claudeRequest);
|
||||||
|
|
||||||
// 选择可用的Claude账户
|
// 选择可用的Claude账户
|
||||||
const accountId = await claudeAccountService.selectAccountForApiKey(apiKeyData, sessionHash);
|
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(apiKeyData, sessionHash, claudeRequest.model);
|
||||||
|
const accountId = accountSelection.accountId;
|
||||||
|
|
||||||
// 获取该账号存储的 Claude Code headers
|
// 获取该账号存储的 Claude Code headers
|
||||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId);
|
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const router = express.Router();
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { authenticateApiKey } = require('../middleware/auth');
|
const { authenticateApiKey } = require('../middleware/auth');
|
||||||
const geminiAccountService = require('../services/geminiAccountService');
|
const geminiAccountService = require('../services/geminiAccountService');
|
||||||
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler');
|
||||||
const { getAvailableModels } = require('../services/geminiRelayService');
|
const { getAvailableModels } = require('../services/geminiRelayService');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@@ -167,6 +168,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let abortController = null;
|
let abortController = null;
|
||||||
let account = null; // Declare account outside try block for error handling
|
let account = null; // Declare account outside try block for error handling
|
||||||
|
let accountSelection = null; // Declare accountSelection for error handling
|
||||||
|
let sessionHash = null; // Declare sessionHash for error handling
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiKeyData = req.apiKey;
|
const apiKeyData = req.apiKey;
|
||||||
@@ -263,13 +266,16 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成会话哈希用于粘性会话
|
// 生成会话哈希用于粘性会话
|
||||||
const sessionHash = generateSessionHash(req);
|
sessionHash = generateSessionHash(req);
|
||||||
|
|
||||||
// 选择可用的 Gemini 账户
|
// 选择可用的 Gemini 账户
|
||||||
account = await geminiAccountService.selectAvailableAccount(
|
try {
|
||||||
apiKeyData.id,
|
accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(apiKeyData, sessionHash, model);
|
||||||
sessionHash
|
account = await geminiAccountService.getAccount(accountSelection.accountId);
|
||||||
);
|
} catch (error) {
|
||||||
|
logger.error('Failed to select Gemini account:', error);
|
||||||
|
account = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
@@ -339,6 +345,14 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
};
|
};
|
||||||
res.write(`data: ${JSON.stringify(initialChunk)}\n\n`);
|
res.write(`data: ${JSON.stringify(initialChunk)}\n\n`);
|
||||||
|
|
||||||
|
// 用于收集usage数据
|
||||||
|
let totalUsage = {
|
||||||
|
promptTokenCount: 0,
|
||||||
|
candidatesTokenCount: 0,
|
||||||
|
totalTokenCount: 0
|
||||||
|
};
|
||||||
|
let usageReported = false;
|
||||||
|
|
||||||
streamResponse.on('data', (chunk) => {
|
streamResponse.on('data', (chunk) => {
|
||||||
try {
|
try {
|
||||||
const chunkStr = chunk.toString();
|
const chunkStr = chunk.toString();
|
||||||
@@ -365,6 +379,12 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(jsonData);
|
const data = JSON.parse(jsonData);
|
||||||
|
|
||||||
|
// 捕获usage数据
|
||||||
|
if (data.response?.usageMetadata) {
|
||||||
|
totalUsage = data.response.usageMetadata;
|
||||||
|
logger.debug('📊 Captured Gemini usage data:', totalUsage);
|
||||||
|
}
|
||||||
|
|
||||||
// 转换为 OpenAI 流式格式
|
// 转换为 OpenAI 流式格式
|
||||||
if (data.response?.candidates && data.response.candidates.length > 0) {
|
if (data.response?.candidates && data.response.candidates.length > 0) {
|
||||||
const candidate = data.response.candidates[0];
|
const candidate = data.response.candidates[0];
|
||||||
@@ -430,8 +450,28 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
streamResponse.on('end', () => {
|
streamResponse.on('end', async () => {
|
||||||
logger.info('Stream completed successfully');
|
logger.info('Stream completed successfully');
|
||||||
|
|
||||||
|
// 记录使用统计
|
||||||
|
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
||||||
|
try {
|
||||||
|
const apiKeyService = require('../services/apiKeyService');
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyData.id,
|
||||||
|
totalUsage.promptTokenCount || 0,
|
||||||
|
totalUsage.candidatesTokenCount || 0,
|
||||||
|
0, // cacheCreateTokens
|
||||||
|
0, // cacheReadTokens
|
||||||
|
model,
|
||||||
|
account.id
|
||||||
|
);
|
||||||
|
logger.info(`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to record Gemini usage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.write('data: [DONE]\n\n');
|
res.write('data: [DONE]\n\n');
|
||||||
}
|
}
|
||||||
@@ -473,6 +513,26 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
|
|
||||||
// 转换为 OpenAI 格式并返回
|
// 转换为 OpenAI 格式并返回
|
||||||
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false);
|
const openaiResponse = convertGeminiResponseToOpenAI(response, model, false);
|
||||||
|
|
||||||
|
// 记录使用统计
|
||||||
|
if (openaiResponse.usage) {
|
||||||
|
try {
|
||||||
|
const apiKeyService = require('../services/apiKeyService');
|
||||||
|
await apiKeyService.recordUsage(
|
||||||
|
apiKeyData.id,
|
||||||
|
openaiResponse.usage.prompt_tokens || 0,
|
||||||
|
openaiResponse.usage.completion_tokens || 0,
|
||||||
|
0, // cacheCreateTokens
|
||||||
|
0, // cacheReadTokens
|
||||||
|
model,
|
||||||
|
account.id
|
||||||
|
);
|
||||||
|
logger.info(`📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to record Gemini usage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json(openaiResponse);
|
res.json(openaiResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,8 +544,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
|
|
||||||
// 处理速率限制
|
// 处理速率限制
|
||||||
if (error.status === 429) {
|
if (error.status === 429) {
|
||||||
if (req.apiKey && account) {
|
if (req.apiKey && account && accountSelection) {
|
||||||
await geminiAccountService.setAccountRateLimited(account.id, true);
|
await unifiedGeminiScheduler.markAccountRateLimited(account.id, 'gemini', sessionHash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +585,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 选择账户获取模型列表
|
// 选择账户获取模型列表
|
||||||
const account = await geminiAccountService.selectAvailableAccount(apiKeyData.id);
|
let account = null;
|
||||||
|
try {
|
||||||
|
const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey(apiKeyData, null, null);
|
||||||
|
account = await geminiAccountService.getAccount(accountSelection.accountId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to select Gemini account for models endpoint:', error);
|
||||||
|
}
|
||||||
|
|
||||||
let models = [];
|
let models = [];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const path = require('path');
|
|||||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
const claudeAccountService = require('./claudeAccountService');
|
const claudeAccountService = require('./claudeAccountService');
|
||||||
|
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler');
|
||||||
const sessionHelper = require('../utils/sessionHelper');
|
const sessionHelper = require('../utils/sessionHelper');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const config = require('../../config/config');
|
const config = require('../../config/config');
|
||||||
@@ -91,9 +92,11 @@ class ClaudeRelayService {
|
|||||||
const sessionHash = sessionHelper.generateSessionHash(requestBody);
|
const sessionHash = sessionHelper.generateSessionHash(requestBody);
|
||||||
|
|
||||||
// 选择可用的Claude账户(支持专属绑定和sticky会话)
|
// 选择可用的Claude账户(支持专属绑定和sticky会话)
|
||||||
const accountId = await claudeAccountService.selectAccountForApiKey(apiKeyData, sessionHash);
|
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(apiKeyData, sessionHash, requestBody.model);
|
||||||
|
const accountId = accountSelection.accountId;
|
||||||
|
const accountType = accountSelection.accountType;
|
||||||
|
|
||||||
logger.info(`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}${sessionHash ? `, session: ${sessionHash}` : ''}`);
|
logger.info(`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`);
|
||||||
|
|
||||||
// 获取有效的访问token
|
// 获取有效的访问token
|
||||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId);
|
const accessToken = await claudeAccountService.getValidAccessToken(accountId);
|
||||||
@@ -172,13 +175,13 @@ class ClaudeRelayService {
|
|||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
logger.warn(`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`);
|
logger.warn(`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`);
|
||||||
// 标记账号为限流状态并删除粘性会话映射,传递准确的重置时间戳
|
// 标记账号为限流状态并删除粘性会话映射,传递准确的重置时间戳
|
||||||
await claudeAccountService.markAccountRateLimited(accountId, sessionHash, rateLimitResetTimestamp);
|
await unifiedClaudeScheduler.markAccountRateLimited(accountId, accountType, sessionHash, rateLimitResetTimestamp);
|
||||||
}
|
}
|
||||||
} else if (response.statusCode === 200 || response.statusCode === 201) {
|
} else if (response.statusCode === 200 || response.statusCode === 201) {
|
||||||
// 如果请求成功,检查并移除限流状态
|
// 如果请求成功,检查并移除限流状态
|
||||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId);
|
const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited(accountId, accountType);
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
await claudeAccountService.removeAccountRateLimit(accountId);
|
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有真实的 Claude Code 请求才更新 headers
|
// 只有真实的 Claude Code 请求才更新 headers
|
||||||
@@ -621,9 +624,11 @@ class ClaudeRelayService {
|
|||||||
const sessionHash = sessionHelper.generateSessionHash(requestBody);
|
const sessionHash = sessionHelper.generateSessionHash(requestBody);
|
||||||
|
|
||||||
// 选择可用的Claude账户(支持专属绑定和sticky会话)
|
// 选择可用的Claude账户(支持专属绑定和sticky会话)
|
||||||
const accountId = await claudeAccountService.selectAccountForApiKey(apiKeyData, sessionHash);
|
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(apiKeyData, sessionHash, requestBody.model);
|
||||||
|
const accountId = accountSelection.accountId;
|
||||||
|
const accountType = accountSelection.accountType;
|
||||||
|
|
||||||
logger.info(`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}${sessionHash ? `, session: ${sessionHash}` : ''}`);
|
logger.info(`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`);
|
||||||
|
|
||||||
// 获取有效的访问token
|
// 获取有效的访问token
|
||||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId);
|
const accessToken = await claudeAccountService.getValidAccessToken(accountId);
|
||||||
@@ -638,7 +643,7 @@ class ClaudeRelayService {
|
|||||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, (usageData) => {
|
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, (usageData) => {
|
||||||
// 在usageCallback中添加accountId
|
// 在usageCallback中添加accountId
|
||||||
usageCallback({ ...usageData, accountId });
|
usageCallback({ ...usageData, accountId });
|
||||||
}, accountId, sessionHash, streamTransformer, options);
|
}, accountId, accountType, sessionHash, streamTransformer, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -646,7 +651,7 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🌊 发送流式请求到Claude API(带usage数据捕获)
|
// 🌊 发送流式请求到Claude API(带usage数据捕获)
|
||||||
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash, streamTransformer = null, requestOptions = {}) {
|
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, accountType, sessionHash, streamTransformer = null, requestOptions = {}) {
|
||||||
// 获取过滤后的客户端 headers
|
// 获取过滤后的客户端 headers
|
||||||
const filteredHeaders = this._filterClientHeaders(clientHeaders);
|
const filteredHeaders = this._filterClientHeaders(clientHeaders);
|
||||||
|
|
||||||
@@ -854,12 +859,12 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 标记账号为限流状态并删除粘性会话映射
|
// 标记账号为限流状态并删除粘性会话映射
|
||||||
await claudeAccountService.markAccountRateLimited(accountId, sessionHash, rateLimitResetTimestamp);
|
await unifiedClaudeScheduler.markAccountRateLimited(accountId, accountType, sessionHash, rateLimitResetTimestamp);
|
||||||
} else if (res.statusCode === 200) {
|
} else if (res.statusCode === 200) {
|
||||||
// 如果请求成功,检查并移除限流状态
|
// 如果请求成功,检查并移除限流状态
|
||||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId);
|
const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited(accountId, accountType);
|
||||||
if (isRateLimited) {
|
if (isRateLimited) {
|
||||||
await claudeAccountService.removeAccountRateLimit(accountId);
|
await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ class UnifiedClaudeScheduler {
|
|||||||
this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:';
|
this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||||
|
_isSchedulable(schedulable) {
|
||||||
|
// 如果是 undefined 或 null,默认为可调度
|
||||||
|
if (schedulable === undefined || schedulable === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 明确设置为 false(布尔值)或 'false'(字符串)时不可调度
|
||||||
|
return schedulable !== false && schedulable !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
// 🎯 统一调度Claude账号(官方和Console)
|
// 🎯 统一调度Claude账号(官方和Console)
|
||||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
||||||
try {
|
try {
|
||||||
@@ -152,7 +162,7 @@ class UnifiedClaudeScheduler {
|
|||||||
account.status !== 'error' &&
|
account.status !== 'error' &&
|
||||||
account.status !== 'blocked' &&
|
account.status !== 'blocked' &&
|
||||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||||
account.schedulable !== 'false') { // 检查是否可调度
|
this._isSchedulable(account.schedulable)) { // 检查是否可调度
|
||||||
|
|
||||||
// 检查是否被限流
|
// 检查是否被限流
|
||||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id);
|
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id);
|
||||||
@@ -179,7 +189,7 @@ class UnifiedClaudeScheduler {
|
|||||||
if (account.isActive === true &&
|
if (account.isActive === true &&
|
||||||
account.status === 'active' &&
|
account.status === 'active' &&
|
||||||
account.accountType === 'shared' &&
|
account.accountType === 'shared' &&
|
||||||
account.schedulable !== false) { // 检查是否可调度
|
this._isSchedulable(account.schedulable)) { // 检查是否可调度
|
||||||
|
|
||||||
// 检查模型支持(如果有请求的模型)
|
// 检查模型支持(如果有请求的模型)
|
||||||
if (requestedModel && account.supportedModels) {
|
if (requestedModel && account.supportedModels) {
|
||||||
@@ -246,7 +256,7 @@ class UnifiedClaudeScheduler {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 检查是否可调度
|
// 检查是否可调度
|
||||||
if (account.schedulable === 'false') {
|
if (!this._isSchedulable(account.schedulable)) {
|
||||||
logger.info(`🚫 Account ${accountId} is not schedulable`);
|
logger.info(`🚫 Account ${accountId} is not schedulable`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -257,7 +267,7 @@ class UnifiedClaudeScheduler {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 检查是否可调度
|
// 检查是否可调度
|
||||||
if (account.schedulable === false) {
|
if (!this._isSchedulable(account.schedulable)) {
|
||||||
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`);
|
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -444,7 +454,7 @@ class UnifiedClaudeScheduler {
|
|||||||
? account.status !== 'error' && account.status !== 'blocked'
|
? account.status !== 'error' && account.status !== 'blocked'
|
||||||
: account.status === 'active';
|
: account.status === 'active';
|
||||||
|
|
||||||
if (isActive && status && account.schedulable !== false) {
|
if (isActive && status && this._isSchedulable(account.schedulable)) {
|
||||||
// 检查模型支持(Console账户)
|
// 检查模型支持(Console账户)
|
||||||
if (accountType === 'claude-console' && requestedModel && account.supportedModels && account.supportedModels.length > 0) {
|
if (accountType === 'claude-console' && requestedModel && account.supportedModels && account.supportedModels.length > 0) {
|
||||||
if (!account.supportedModels.includes(requestedModel)) {
|
if (!account.supportedModels.includes(requestedModel)) {
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ class UnifiedGeminiScheduler {
|
|||||||
this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:';
|
this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值)
|
||||||
|
_isSchedulable(schedulable) {
|
||||||
|
// 如果是 undefined 或 null,默认为可调度
|
||||||
|
if (schedulable === undefined || schedulable === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 明确设置为 false(布尔值)或 'false'(字符串)时不可调度
|
||||||
|
return schedulable !== false && schedulable !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
// 🎯 统一调度Gemini账号
|
// 🎯 统一调度Gemini账号
|
||||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
||||||
try {
|
try {
|
||||||
@@ -128,7 +138,7 @@ class UnifiedGeminiScheduler {
|
|||||||
if (account.isActive === 'true' &&
|
if (account.isActive === 'true' &&
|
||||||
account.status !== 'error' &&
|
account.status !== 'error' &&
|
||||||
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
(account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
|
||||||
account.schedulable !== 'false') { // 检查是否可调度
|
this._isSchedulable(account.schedulable)) { // 检查是否可调度
|
||||||
|
|
||||||
// 检查token是否过期
|
// 检查token是否过期
|
||||||
const isExpired = geminiAccountService.isTokenExpired(account);
|
const isExpired = geminiAccountService.isTokenExpired(account);
|
||||||
@@ -192,7 +202,7 @@ class UnifiedGeminiScheduler {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 检查是否可调度
|
// 检查是否可调度
|
||||||
if (account.schedulable === 'false') {
|
if (!this._isSchedulable(account.schedulable)) {
|
||||||
logger.info(`🚫 Gemini account ${accountId} is not schedulable`);
|
logger.info(`🚫 Gemini account ${accountId} is not schedulable`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -347,7 +357,7 @@ class UnifiedGeminiScheduler {
|
|||||||
// 检查账户是否可用
|
// 检查账户是否可用
|
||||||
if (account.isActive === 'true' &&
|
if (account.isActive === 'true' &&
|
||||||
account.status !== 'error' &&
|
account.status !== 'error' &&
|
||||||
account.schedulable !== 'false') {
|
this._isSchedulable(account.schedulable)) {
|
||||||
|
|
||||||
// 检查token是否过期
|
// 检查token是否过期
|
||||||
const isExpired = geminiAccountService.isTokenExpired(account);
|
const isExpired = geminiAccountService.isTokenExpired(account);
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<Teleport to="body">
|
||||||
v-if="show"
|
<div
|
||||||
class="fixed inset-0 z-50 overflow-y-auto"
|
v-if="show"
|
||||||
@click.self="close"
|
class="fixed inset-0 modal z-50 flex items-center justify-center p-3 sm:p-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
<!-- 背景遮罩 -->
|
<!-- 背景遮罩 -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
class="fixed inset-0 bg-gray-900 bg-opacity-50 backdrop-blur-sm"
|
||||||
@click="close"
|
@click="close"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 模态框 -->
|
<!-- 模态框 -->
|
||||||
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
|
<div class="modal-content w-[95%] sm:w-full max-w-2xl sm:max-w-3xl p-4 sm:p-6 md:p-8 mx-auto max-h-[90vh] flex flex-col relative">
|
||||||
<!-- 标题栏 -->
|
<!-- 标题栏 -->
|
||||||
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 py-4">
|
<div class="flex items-center justify-between mb-4 sm:mb-6">
|
||||||
<h3 class="text-lg font-semibold text-white flex items-center">
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
<i class="fas fa-chart-line mr-2" />
|
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg sm:rounded-xl flex items-center justify-center">
|
||||||
使用统计详情 - {{ apiKey.name }}
|
<i class="fas fa-chart-line text-white text-sm sm:text-base" />
|
||||||
</h3>
|
</div>
|
||||||
|
<h3 class="text-lg sm:text-xl font-bold text-gray-900">
|
||||||
|
使用统计详情 - {{ apiKey.name }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-gray-400 hover:text-gray-600 transition-colors p-1"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-lg sm:text-xl" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内容区 -->
|
<!-- 内容区 -->
|
||||||
<div class="px-6 py-4 max-h-[70vh] overflow-y-auto">
|
<div class="modal-scroll-content custom-scrollbar flex-1 overflow-y-auto">
|
||||||
<!-- 总体统计卡片 -->
|
<!-- 总体统计卡片 -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
<!-- 请求统计卡片 -->
|
<!-- 请求统计卡片 -->
|
||||||
@@ -236,10 +245,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部按钮 -->
|
<!-- 底部按钮 -->
|
||||||
<div class="bg-gray-50 px-6 py-3 flex justify-end">
|
<div class="mt-4 sm:mt-6 flex justify-end gap-2 sm:gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
class="btn btn-secondary px-4 py-2 text-sm"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
关闭
|
关闭
|
||||||
@@ -247,7 +256,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -342,19 +351,5 @@ const close = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 添加过渡动画 */
|
/* 使用项目的通用样式,不需要额外定义 */
|
||||||
.transform {
|
|
||||||
animation: modalSlideIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modalSlideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user