|
|
const express = require('express') |
|
|
const claudeRelayService = require('../services/claudeRelayService') |
|
|
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService') |
|
|
const bedrockRelayService = require('../services/bedrockRelayService') |
|
|
const ccrRelayService = require('../services/ccrRelayService') |
|
|
const bedrockAccountService = require('../services/bedrockAccountService') |
|
|
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler') |
|
|
const apiKeyService = require('../services/apiKeyService') |
|
|
const { authenticateApiKey } = require('../middleware/auth') |
|
|
const logger = require('../utils/logger') |
|
|
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper') |
|
|
const sessionHelper = require('../utils/sessionHelper') |
|
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper') |
|
|
const { sanitizeUpstreamError } = require('../utils/errorSanitizer') |
|
|
const router = express.Router() |
|
|
|
|
|
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') { |
|
|
if (!rateLimitInfo) { |
|
|
return Promise.resolve({ totalTokens: 0, totalCost: 0 }) |
|
|
} |
|
|
|
|
|
const label = context ? ` (${context})` : '' |
|
|
|
|
|
return updateRateLimitCounters(rateLimitInfo, usageSummary, model) |
|
|
.then(({ totalTokens, totalCost }) => { |
|
|
if (totalTokens > 0) { |
|
|
logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) |
|
|
} |
|
|
if (typeof totalCost === 'number' && totalCost > 0) { |
|
|
logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) |
|
|
} |
|
|
return { totalTokens, totalCost } |
|
|
}) |
|
|
.catch((error) => { |
|
|
logger.error(`❌ Failed to update rate limit counters${label}:`, error) |
|
|
return { totalTokens: 0, totalCost: 0 } |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
async function handleMessagesRequest(req, res) { |
|
|
try { |
|
|
const startTime = Date.now() |
|
|
|
|
|
|
|
|
if ( |
|
|
req.apiKey.permissions && |
|
|
req.apiKey.permissions !== 'all' && |
|
|
req.apiKey.permissions !== 'claude' |
|
|
) { |
|
|
return res.status(403).json({ |
|
|
error: { |
|
|
type: 'permission_error', |
|
|
message: '此 API Key 无权访问 Claude 服务' |
|
|
} |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if (!req.body || typeof req.body !== 'object') { |
|
|
return res.status(400).json({ |
|
|
error: 'Invalid request', |
|
|
message: 'Request body must be a valid JSON object' |
|
|
}) |
|
|
} |
|
|
|
|
|
if (!req.body.messages || !Array.isArray(req.body.messages)) { |
|
|
return res.status(400).json({ |
|
|
error: 'Invalid request', |
|
|
message: 'Missing or invalid field: messages (must be an array)' |
|
|
}) |
|
|
} |
|
|
|
|
|
if (req.body.messages.length === 0) { |
|
|
return res.status(400).json({ |
|
|
error: 'Invalid request', |
|
|
message: 'Messages array cannot be empty' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if ( |
|
|
req.apiKey.enableModelRestriction && |
|
|
Array.isArray(req.apiKey.restrictedModels) && |
|
|
req.apiKey.restrictedModels.length > 0 |
|
|
) { |
|
|
const effectiveModel = getEffectiveModel(req.body.model || '') |
|
|
if (req.apiKey.restrictedModels.includes(effectiveModel)) { |
|
|
return res.status(403).json({ |
|
|
error: { |
|
|
type: 'forbidden', |
|
|
message: '暂无该模型访问权限' |
|
|
} |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const isStream = req.body.stream === true |
|
|
|
|
|
logger.api( |
|
|
`🚀 Processing ${isStream ? 'stream' : 'non-stream'} request for key: ${req.apiKey.name}` |
|
|
) |
|
|
|
|
|
if (isStream) { |
|
|
|
|
|
res.setHeader('Content-Type', 'text/event-stream') |
|
|
res.setHeader('Cache-Control', 'no-cache') |
|
|
res.setHeader('Connection', 'keep-alive') |
|
|
res.setHeader('Access-Control-Allow-Origin', '*') |
|
|
res.setHeader('X-Accel-Buffering', 'no') |
|
|
|
|
|
|
|
|
if (res.socket && typeof res.socket.setNoDelay === 'function') { |
|
|
res.socket.setNoDelay(true) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let usageDataCaptured = false |
|
|
|
|
|
|
|
|
const sessionHash = sessionHelper.generateSessionHash(req.body) |
|
|
|
|
|
|
|
|
const requestedModel = req.body.model |
|
|
let accountId |
|
|
let accountType |
|
|
try { |
|
|
const selection = await unifiedClaudeScheduler.selectAccountForApiKey( |
|
|
req.apiKey, |
|
|
sessionHash, |
|
|
requestedModel |
|
|
) |
|
|
;({ accountId, accountType } = selection) |
|
|
} catch (error) { |
|
|
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { |
|
|
const limitMessage = claudeRelayService._buildStandardRateLimitMessage( |
|
|
error.rateLimitEndAt |
|
|
) |
|
|
res.status(403) |
|
|
res.setHeader('Content-Type', 'application/json') |
|
|
res.end( |
|
|
JSON.stringify({ |
|
|
error: 'upstream_rate_limited', |
|
|
message: limitMessage |
|
|
}) |
|
|
) |
|
|
return |
|
|
} |
|
|
throw error |
|
|
} |
|
|
|
|
|
|
|
|
if (accountType === 'claude-official') { |
|
|
|
|
|
await claudeRelayService.relayStreamRequestWithUsageCapture( |
|
|
req.body, |
|
|
req.apiKey, |
|
|
res, |
|
|
req.headers, |
|
|
(usageData) => { |
|
|
|
|
|
logger.info( |
|
|
'🎯 Usage callback triggered with complete data:', |
|
|
JSON.stringify(usageData, null, 2) |
|
|
) |
|
|
|
|
|
if ( |
|
|
usageData && |
|
|
usageData.input_tokens !== undefined && |
|
|
usageData.output_tokens !== undefined |
|
|
) { |
|
|
const inputTokens = usageData.input_tokens || 0 |
|
|
const outputTokens = usageData.output_tokens || 0 |
|
|
|
|
|
let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 |
|
|
let ephemeral5mTokens = 0 |
|
|
let ephemeral1hTokens = 0 |
|
|
|
|
|
if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { |
|
|
ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 |
|
|
ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 |
|
|
|
|
|
cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens |
|
|
} |
|
|
|
|
|
const cacheReadTokens = usageData.cache_read_input_tokens || 0 |
|
|
const model = usageData.model || 'unknown' |
|
|
|
|
|
|
|
|
const { accountId: usageAccountId } = usageData |
|
|
|
|
|
|
|
|
const usageObject = { |
|
|
input_tokens: inputTokens, |
|
|
output_tokens: outputTokens, |
|
|
cache_creation_input_tokens: cacheCreateTokens, |
|
|
cache_read_input_tokens: cacheReadTokens |
|
|
} |
|
|
|
|
|
|
|
|
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { |
|
|
usageObject.cache_creation = { |
|
|
ephemeral_5m_input_tokens: ephemeral5mTokens, |
|
|
ephemeral_1h_input_tokens: ephemeral1hTokens |
|
|
} |
|
|
} |
|
|
|
|
|
apiKeyService |
|
|
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude') |
|
|
.catch((error) => { |
|
|
logger.error('❌ Failed to record stream usage:', error) |
|
|
}) |
|
|
|
|
|
queueRateLimitUpdate( |
|
|
req.rateLimitInfo, |
|
|
{ |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens |
|
|
}, |
|
|
model, |
|
|
'claude-stream' |
|
|
) |
|
|
|
|
|
usageDataCaptured = true |
|
|
logger.api( |
|
|
`📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` |
|
|
) |
|
|
} else { |
|
|
logger.warn( |
|
|
'⚠️ Usage callback triggered but data is incomplete:', |
|
|
JSON.stringify(usageData) |
|
|
) |
|
|
} |
|
|
} |
|
|
) |
|
|
} else if (accountType === 'claude-console') { |
|
|
|
|
|
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture( |
|
|
req.body, |
|
|
req.apiKey, |
|
|
res, |
|
|
req.headers, |
|
|
(usageData) => { |
|
|
|
|
|
logger.info( |
|
|
'🎯 Usage callback triggered with complete data:', |
|
|
JSON.stringify(usageData, null, 2) |
|
|
) |
|
|
|
|
|
if ( |
|
|
usageData && |
|
|
usageData.input_tokens !== undefined && |
|
|
usageData.output_tokens !== undefined |
|
|
) { |
|
|
const inputTokens = usageData.input_tokens || 0 |
|
|
const outputTokens = usageData.output_tokens || 0 |
|
|
|
|
|
let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 |
|
|
let ephemeral5mTokens = 0 |
|
|
let ephemeral1hTokens = 0 |
|
|
|
|
|
if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { |
|
|
ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 |
|
|
ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 |
|
|
|
|
|
cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens |
|
|
} |
|
|
|
|
|
const cacheReadTokens = usageData.cache_read_input_tokens || 0 |
|
|
const model = usageData.model || 'unknown' |
|
|
|
|
|
|
|
|
const usageAccountId = usageData.accountId |
|
|
|
|
|
|
|
|
const usageObject = { |
|
|
input_tokens: inputTokens, |
|
|
output_tokens: outputTokens, |
|
|
cache_creation_input_tokens: cacheCreateTokens, |
|
|
cache_read_input_tokens: cacheReadTokens |
|
|
} |
|
|
|
|
|
|
|
|
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { |
|
|
usageObject.cache_creation = { |
|
|
ephemeral_5m_input_tokens: ephemeral5mTokens, |
|
|
ephemeral_1h_input_tokens: ephemeral1hTokens |
|
|
} |
|
|
} |
|
|
|
|
|
apiKeyService |
|
|
.recordUsageWithDetails( |
|
|
req.apiKey.id, |
|
|
usageObject, |
|
|
model, |
|
|
usageAccountId, |
|
|
'claude-console' |
|
|
) |
|
|
.catch((error) => { |
|
|
logger.error('❌ Failed to record stream usage:', error) |
|
|
}) |
|
|
|
|
|
queueRateLimitUpdate( |
|
|
req.rateLimitInfo, |
|
|
{ |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens |
|
|
}, |
|
|
model, |
|
|
'claude-console-stream' |
|
|
) |
|
|
|
|
|
usageDataCaptured = true |
|
|
logger.api( |
|
|
`📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` |
|
|
) |
|
|
} else { |
|
|
logger.warn( |
|
|
'⚠️ Usage callback triggered but data is incomplete:', |
|
|
JSON.stringify(usageData) |
|
|
) |
|
|
} |
|
|
}, |
|
|
accountId |
|
|
) |
|
|
} else if (accountType === 'bedrock') { |
|
|
|
|
|
try { |
|
|
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) |
|
|
if (!bedrockAccountResult.success) { |
|
|
throw new Error('Failed to get Bedrock account details') |
|
|
} |
|
|
|
|
|
const result = await bedrockRelayService.handleStreamRequest( |
|
|
req.body, |
|
|
bedrockAccountResult.data, |
|
|
res |
|
|
) |
|
|
|
|
|
|
|
|
if (result.usage) { |
|
|
const inputTokens = result.usage.input_tokens || 0 |
|
|
const outputTokens = result.usage.output_tokens || 0 |
|
|
|
|
|
apiKeyService |
|
|
.recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId) |
|
|
.catch((error) => { |
|
|
logger.error('❌ Failed to record Bedrock stream usage:', error) |
|
|
}) |
|
|
|
|
|
queueRateLimitUpdate( |
|
|
req.rateLimitInfo, |
|
|
{ |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens: 0, |
|
|
cacheReadTokens: 0 |
|
|
}, |
|
|
result.model, |
|
|
'bedrock-stream' |
|
|
) |
|
|
|
|
|
usageDataCaptured = true |
|
|
logger.api( |
|
|
`📊 Bedrock stream usage recorded - Model: ${result.model}, Input: ${inputTokens}, Output: ${outputTokens}, Total: ${inputTokens + outputTokens} tokens` |
|
|
) |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('❌ Bedrock stream request failed:', error) |
|
|
if (!res.headersSent) { |
|
|
return res.status(500).json({ error: 'Bedrock service error', message: error.message }) |
|
|
} |
|
|
return undefined |
|
|
} |
|
|
} else if (accountType === 'ccr') { |
|
|
|
|
|
await ccrRelayService.relayStreamRequestWithUsageCapture( |
|
|
req.body, |
|
|
req.apiKey, |
|
|
res, |
|
|
req.headers, |
|
|
(usageData) => { |
|
|
|
|
|
logger.info( |
|
|
'🎯 CCR usage callback triggered with complete data:', |
|
|
JSON.stringify(usageData, null, 2) |
|
|
) |
|
|
|
|
|
if ( |
|
|
usageData && |
|
|
usageData.input_tokens !== undefined && |
|
|
usageData.output_tokens !== undefined |
|
|
) { |
|
|
const inputTokens = usageData.input_tokens || 0 |
|
|
const outputTokens = usageData.output_tokens || 0 |
|
|
|
|
|
let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 |
|
|
let ephemeral5mTokens = 0 |
|
|
let ephemeral1hTokens = 0 |
|
|
|
|
|
if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { |
|
|
ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 |
|
|
ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 |
|
|
|
|
|
cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens |
|
|
} |
|
|
|
|
|
const cacheReadTokens = usageData.cache_read_input_tokens || 0 |
|
|
const model = usageData.model || 'unknown' |
|
|
|
|
|
|
|
|
const usageAccountId = usageData.accountId |
|
|
|
|
|
|
|
|
const usageObject = { |
|
|
input_tokens: inputTokens, |
|
|
output_tokens: outputTokens, |
|
|
cache_creation_input_tokens: cacheCreateTokens, |
|
|
cache_read_input_tokens: cacheReadTokens |
|
|
} |
|
|
|
|
|
|
|
|
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { |
|
|
usageObject.cache_creation = { |
|
|
ephemeral_5m_input_tokens: ephemeral5mTokens, |
|
|
ephemeral_1h_input_tokens: ephemeral1hTokens |
|
|
} |
|
|
} |
|
|
|
|
|
apiKeyService |
|
|
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr') |
|
|
.catch((error) => { |
|
|
logger.error('❌ Failed to record CCR stream usage:', error) |
|
|
}) |
|
|
|
|
|
queueRateLimitUpdate( |
|
|
req.rateLimitInfo, |
|
|
{ |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens |
|
|
}, |
|
|
model, |
|
|
'ccr-stream' |
|
|
) |
|
|
|
|
|
usageDataCaptured = true |
|
|
logger.api( |
|
|
`📊 CCR stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` |
|
|
) |
|
|
} else { |
|
|
logger.warn( |
|
|
'⚠️ CCR usage callback triggered but data is incomplete:', |
|
|
JSON.stringify(usageData) |
|
|
) |
|
|
} |
|
|
}, |
|
|
accountId |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
if (!usageDataCaptured) { |
|
|
logger.warn( |
|
|
'⚠️ No usage data captured from SSE stream - no statistics recorded (official data only)' |
|
|
) |
|
|
} |
|
|
}, 1000) |
|
|
} else { |
|
|
|
|
|
logger.info('📄 Starting non-streaming request', { |
|
|
apiKeyId: req.apiKey.id, |
|
|
apiKeyName: req.apiKey.name |
|
|
}) |
|
|
|
|
|
|
|
|
const sessionHash = sessionHelper.generateSessionHash(req.body) |
|
|
|
|
|
|
|
|
const requestedModel = req.body.model |
|
|
let accountId |
|
|
let accountType |
|
|
try { |
|
|
const selection = await unifiedClaudeScheduler.selectAccountForApiKey( |
|
|
req.apiKey, |
|
|
sessionHash, |
|
|
requestedModel |
|
|
) |
|
|
;({ accountId, accountType } = selection) |
|
|
} catch (error) { |
|
|
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { |
|
|
const limitMessage = claudeRelayService._buildStandardRateLimitMessage( |
|
|
error.rateLimitEndAt |
|
|
) |
|
|
return res.status(403).json({ |
|
|
error: 'upstream_rate_limited', |
|
|
message: limitMessage |
|
|
}) |
|
|
} |
|
|
throw error |
|
|
} |
|
|
|
|
|
|
|
|
let response |
|
|
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`) |
|
|
logger.debug(`[DEBUG] Request URL: ${req.url}`) |
|
|
logger.debug(`[DEBUG] Request path: ${req.path}`) |
|
|
|
|
|
if (accountType === 'claude-official') { |
|
|
|
|
|
response = await claudeRelayService.relayRequest( |
|
|
req.body, |
|
|
req.apiKey, |
|
|
req, |
|
|
res, |
|
|
req.headers |
|
|
) |
|
|
} else if (accountType === 'claude-console') { |
|
|
|
|
|
logger.debug( |
|
|
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}` |
|
|
) |
|
|
response = await claudeConsoleRelayService.relayRequest( |
|
|
req.body, |
|
|
req.apiKey, |
|
|
req, |
|
|
res, |
|
|
req.headers, |
|
|
accountId |
|
|
) |
|
|
} else if (accountType === 'bedrock') { |
|
|
|
|
|
try { |
|
|
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) |
|
|
if (!bedrockAccountResult.success) { |
|
|
throw new Error('Failed to get Bedrock account details') |
|
|
} |
|
|
|
|
|
const result = await bedrockRelayService.handleNonStreamRequest( |
|
|
req.body, |
|
|
bedrockAccountResult.data, |
|
|
req.headers |
|
|
) |
|
|
|
|
|
|
|
|
response = { |
|
|
statusCode: result.success ? 200 : 500, |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify(result.success ? result.data : { error: result.error }), |
|
|
accountId |
|
|
} |
|
|
|
|
|
|
|
|
if (result.success && result.usage) { |
|
|
const responseData = JSON.parse(response.body) |
|
|
responseData.usage = result.usage |
|
|
response.body = JSON.stringify(responseData) |
|
|
} |
|
|
} catch (error) { |
|
|
logger.error('❌ Bedrock non-stream request failed:', error) |
|
|
response = { |
|
|
statusCode: 500, |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ error: 'Bedrock service error', message: error.message }), |
|
|
accountId |
|
|
} |
|
|
} |
|
|
} else if (accountType === 'ccr') { |
|
|
|
|
|
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`) |
|
|
response = await ccrRelayService.relayRequest( |
|
|
req.body, |
|
|
req.apiKey, |
|
|
req, |
|
|
res, |
|
|
req.headers, |
|
|
accountId |
|
|
) |
|
|
} |
|
|
|
|
|
logger.info('📡 Claude API response received', { |
|
|
statusCode: response.statusCode, |
|
|
headers: JSON.stringify(response.headers), |
|
|
bodyLength: response.body ? response.body.length : 0 |
|
|
}) |
|
|
|
|
|
res.status(response.statusCode) |
|
|
|
|
|
|
|
|
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length'] |
|
|
Object.keys(response.headers).forEach((key) => { |
|
|
if (!skipHeaders.includes(key.toLowerCase())) { |
|
|
res.setHeader(key, response.headers[key]) |
|
|
} |
|
|
}) |
|
|
|
|
|
let usageRecorded = false |
|
|
|
|
|
|
|
|
try { |
|
|
const jsonData = JSON.parse(response.body) |
|
|
|
|
|
logger.info('📊 Parsed Claude API response:', JSON.stringify(jsonData, null, 2)) |
|
|
|
|
|
|
|
|
if ( |
|
|
jsonData.usage && |
|
|
jsonData.usage.input_tokens !== undefined && |
|
|
jsonData.usage.output_tokens !== undefined |
|
|
) { |
|
|
const inputTokens = jsonData.usage.input_tokens || 0 |
|
|
const outputTokens = jsonData.usage.output_tokens || 0 |
|
|
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0 |
|
|
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0 |
|
|
|
|
|
const rawModel = jsonData.model || req.body.model || 'unknown' |
|
|
const { baseModel } = parseVendorPrefixedModel(rawModel) |
|
|
const model = baseModel || rawModel |
|
|
|
|
|
|
|
|
const { accountId: responseAccountId } = response |
|
|
await apiKeyService.recordUsage( |
|
|
req.apiKey.id, |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens, |
|
|
model, |
|
|
responseAccountId |
|
|
) |
|
|
|
|
|
await queueRateLimitUpdate( |
|
|
req.rateLimitInfo, |
|
|
{ |
|
|
inputTokens, |
|
|
outputTokens, |
|
|
cacheCreateTokens, |
|
|
cacheReadTokens |
|
|
}, |
|
|
model, |
|
|
'claude-non-stream' |
|
|
) |
|
|
|
|
|
usageRecorded = true |
|
|
logger.api( |
|
|
`📊 Non-stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` |
|
|
) |
|
|
} else { |
|
|
logger.warn('⚠️ No usage data found in Claude API JSON response') |
|
|
} |
|
|
|
|
|
res.json(jsonData) |
|
|
} catch (parseError) { |
|
|
logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message) |
|
|
logger.info('📄 Raw response body:', response.body) |
|
|
res.send(response.body) |
|
|
} |
|
|
|
|
|
|
|
|
if (!usageRecorded) { |
|
|
logger.warn( |
|
|
'⚠️ No usage data recorded for non-stream request - no statistics recorded (official data only)' |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
const duration = Date.now() - startTime |
|
|
logger.api(`✅ Request completed in ${duration}ms for key: ${req.apiKey.name}`) |
|
|
return undefined |
|
|
} catch (error) { |
|
|
logger.error('❌ Claude relay error:', error.message, { |
|
|
code: error.code, |
|
|
stack: error.stack |
|
|
}) |
|
|
|
|
|
|
|
|
if (!res.headersSent) { |
|
|
|
|
|
let statusCode = 500 |
|
|
let errorType = 'Relay service error' |
|
|
|
|
|
if (error.message.includes('Connection reset') || error.message.includes('socket hang up')) { |
|
|
statusCode = 502 |
|
|
errorType = 'Upstream connection error' |
|
|
} else if (error.message.includes('Connection refused')) { |
|
|
statusCode = 502 |
|
|
errorType = 'Upstream service unavailable' |
|
|
} else if (error.message.includes('timeout')) { |
|
|
statusCode = 504 |
|
|
errorType = 'Upstream timeout' |
|
|
} else if (error.message.includes('resolve') || error.message.includes('ENOTFOUND')) { |
|
|
statusCode = 502 |
|
|
errorType = 'Upstream hostname resolution failed' |
|
|
} |
|
|
|
|
|
return res.status(statusCode).json({ |
|
|
error: errorType, |
|
|
message: error.message || 'An unexpected error occurred', |
|
|
timestamp: new Date().toISOString() |
|
|
}) |
|
|
} else { |
|
|
|
|
|
if (!res.destroyed && !res.finished) { |
|
|
res.end() |
|
|
} |
|
|
return undefined |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
router.post('/v1/messages', authenticateApiKey, handleMessagesRequest) |
|
|
|
|
|
|
|
|
router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest) |
|
|
|
|
|
|
|
|
router.get('/v1/models', authenticateApiKey, async (req, res) => { |
|
|
try { |
|
|
const modelService = require('../services/modelService') |
|
|
|
|
|
|
|
|
const models = modelService.getAllModels() |
|
|
|
|
|
|
|
|
let filteredModels = models |
|
|
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) { |
|
|
filteredModels = models.filter((model) => req.apiKey.restrictedModels.includes(model.id)) |
|
|
} |
|
|
|
|
|
res.json({ |
|
|
object: 'list', |
|
|
data: filteredModels |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Models list error:', error) |
|
|
res.status(500).json({ |
|
|
error: 'Failed to get models list', |
|
|
message: error.message |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
router.get('/health', async (req, res) => { |
|
|
try { |
|
|
const healthStatus = await claudeRelayService.healthCheck() |
|
|
|
|
|
res.status(healthStatus.healthy ? 200 : 503).json({ |
|
|
status: healthStatus.healthy ? 'healthy' : 'unhealthy', |
|
|
service: 'claude-relay-service', |
|
|
version: '1.0.0', |
|
|
...healthStatus |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Health check error:', error) |
|
|
res.status(503).json({ |
|
|
status: 'unhealthy', |
|
|
service: 'claude-relay-service', |
|
|
error: error.message, |
|
|
timestamp: new Date().toISOString() |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
router.get('/v1/key-info', authenticateApiKey, async (req, res) => { |
|
|
try { |
|
|
const usage = await apiKeyService.getUsageStats(req.apiKey.id) |
|
|
|
|
|
res.json({ |
|
|
keyInfo: { |
|
|
id: req.apiKey.id, |
|
|
name: req.apiKey.name, |
|
|
tokenLimit: req.apiKey.tokenLimit, |
|
|
usage |
|
|
}, |
|
|
timestamp: new Date().toISOString() |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Key info error:', error) |
|
|
res.status(500).json({ |
|
|
error: 'Failed to get key info', |
|
|
message: error.message |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
router.get('/v1/usage', authenticateApiKey, async (req, res) => { |
|
|
try { |
|
|
const usage = await apiKeyService.getUsageStats(req.apiKey.id) |
|
|
|
|
|
res.json({ |
|
|
usage, |
|
|
limits: { |
|
|
tokens: req.apiKey.tokenLimit, |
|
|
requests: 0 |
|
|
}, |
|
|
timestamp: new Date().toISOString() |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Usage stats error:', error) |
|
|
res.status(500).json({ |
|
|
error: 'Failed to get usage stats', |
|
|
message: error.message |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
router.get('/v1/me', authenticateApiKey, async (req, res) => { |
|
|
try { |
|
|
|
|
|
res.json({ |
|
|
id: `user_${req.apiKey.id}`, |
|
|
type: 'user', |
|
|
display_name: req.apiKey.name || 'API User', |
|
|
created_at: new Date().toISOString() |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ User info error:', error) |
|
|
res.status(500).json({ |
|
|
error: 'Failed to get user info', |
|
|
message: error.message |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, res) => { |
|
|
try { |
|
|
const usage = await apiKeyService.getUsageStats(req.apiKey.id) |
|
|
|
|
|
res.json({ |
|
|
object: 'usage', |
|
|
data: [ |
|
|
{ |
|
|
type: 'credit_balance', |
|
|
credit_balance: req.apiKey.tokenLimit - (usage.totalTokens || 0) |
|
|
} |
|
|
] |
|
|
}) |
|
|
} catch (error) { |
|
|
logger.error('❌ Organization usage error:', error) |
|
|
res.status(500).json({ |
|
|
error: 'Failed to get usage info', |
|
|
message: error.message |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { |
|
|
try { |
|
|
|
|
|
if ( |
|
|
req.apiKey.permissions && |
|
|
req.apiKey.permissions !== 'all' && |
|
|
req.apiKey.permissions !== 'claude' |
|
|
) { |
|
|
return res.status(403).json({ |
|
|
error: { |
|
|
type: 'permission_error', |
|
|
message: 'This API key does not have permission to access Claude' |
|
|
} |
|
|
}) |
|
|
} |
|
|
|
|
|
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`) |
|
|
|
|
|
|
|
|
const sessionHash = sessionHelper.generateSessionHash(req.body) |
|
|
|
|
|
|
|
|
const requestedModel = req.body.model |
|
|
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( |
|
|
req.apiKey, |
|
|
sessionHash, |
|
|
requestedModel |
|
|
) |
|
|
|
|
|
let response |
|
|
if (accountType === 'claude-official') { |
|
|
|
|
|
response = await claudeRelayService.relayRequest( |
|
|
req.body, |
|
|
req.apiKey, |
|
|
req, |
|
|
res, |
|
|
req.headers, |
|
|
{ |
|
|
skipUsageRecord: true, |
|
|
customPath: '/v1/messages/count_tokens' |
|
|
} |
|
|
) |
|
|
} else if (accountType === 'claude-console') { |
|
|
|
|
|
response = await claudeConsoleRelayService.relayRequest( |
|
|
req.body, |
|
|
req.apiKey, |
|
|
req, |
|
|
res, |
|
|
req.headers, |
|
|
accountId, |
|
|
{ |
|
|
skipUsageRecord: true, |
|
|
customPath: '/v1/messages/count_tokens' |
|
|
} |
|
|
) |
|
|
} else if (accountType === 'ccr') { |
|
|
|
|
|
return res.status(501).json({ |
|
|
error: { |
|
|
type: 'not_supported', |
|
|
message: 'Token counting is not supported for CCR accounts' |
|
|
} |
|
|
}) |
|
|
} else { |
|
|
|
|
|
return res.status(501).json({ |
|
|
error: { |
|
|
type: 'not_supported', |
|
|
message: 'Token counting is not supported for Bedrock accounts' |
|
|
} |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
res.status(response.statusCode) |
|
|
|
|
|
|
|
|
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length'] |
|
|
Object.keys(response.headers).forEach((key) => { |
|
|
if (!skipHeaders.includes(key.toLowerCase())) { |
|
|
res.setHeader(key, response.headers[key]) |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
try { |
|
|
const jsonData = JSON.parse(response.body) |
|
|
|
|
|
if (response.statusCode < 200 || response.statusCode >= 300) { |
|
|
const sanitizedData = sanitizeUpstreamError(jsonData) |
|
|
res.json(sanitizedData) |
|
|
} else { |
|
|
res.json(jsonData) |
|
|
} |
|
|
} catch (parseError) { |
|
|
res.send(response.body) |
|
|
} |
|
|
|
|
|
logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`) |
|
|
} catch (error) { |
|
|
logger.error('❌ Token count error:', error) |
|
|
res.status(500).json({ |
|
|
error: { |
|
|
type: 'server_error', |
|
|
message: 'Failed to count tokens' |
|
|
} |
|
|
}) |
|
|
} |
|
|
}) |
|
|
|
|
|
module.exports = router |
|
|
module.exports.handleMessagesRequest = handleMessagesRequest |
|
|
|