mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
423 lines
13 KiB
JavaScript
423 lines
13 KiB
JavaScript
/**
|
||
* OpenAI 兼容的 Claude API 路由
|
||
* 提供 OpenAI 格式的 API 接口,内部转发到 Claude
|
||
*/
|
||
|
||
const express = require('express')
|
||
const router = express.Router()
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
const logger = require('../utils/logger')
|
||
const { authenticateApiKey } = require('../middleware/auth')
|
||
const claudeRelayService = require('../services/claudeRelayService')
|
||
const openaiToClaude = require('../services/openaiToClaude')
|
||
const apiKeyService = require('../services/apiKeyService')
|
||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||
const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||
const sessionHelper = require('../utils/sessionHelper')
|
||
|
||
// 加载模型定价数据
|
||
let modelPricingData = {}
|
||
try {
|
||
const pricingPath = path.join(__dirname, '../../data/model_pricing.json')
|
||
const pricingContent = fs.readFileSync(pricingPath, 'utf8')
|
||
modelPricingData = JSON.parse(pricingContent)
|
||
logger.info('✅ Model pricing data loaded successfully')
|
||
} catch (error) {
|
||
logger.error('❌ Failed to load model pricing data:', error)
|
||
}
|
||
|
||
// 🔧 辅助函数:检查 API Key 权限
|
||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||
const permissions = apiKeyData.permissions || 'all'
|
||
return permissions === 'all' || permissions === requiredPermission
|
||
}
|
||
|
||
// 📋 OpenAI 兼容的模型列表端点
|
||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||
try {
|
||
const apiKeyData = req.apiKey
|
||
|
||
// 检查权限
|
||
if (!checkPermissions(apiKeyData, 'claude')) {
|
||
return res.status(403).json({
|
||
error: {
|
||
message: 'This API key does not have permission to access Claude',
|
||
type: 'permission_denied',
|
||
code: 'permission_denied'
|
||
}
|
||
})
|
||
}
|
||
|
||
// Claude 模型列表 - 只返回 opus-4 和 sonnet-4
|
||
let models = [
|
||
{
|
||
id: 'claude-opus-4-20250514',
|
||
object: 'model',
|
||
created: 1736726400, // 2025-01-13
|
||
owned_by: 'anthropic'
|
||
},
|
||
{
|
||
id: 'claude-sonnet-4-20250514',
|
||
object: 'model',
|
||
created: 1736726400, // 2025-01-13
|
||
owned_by: 'anthropic'
|
||
}
|
||
]
|
||
|
||
// 如果启用了模型限制,过滤模型列表
|
||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
||
}
|
||
|
||
res.json({
|
||
object: 'list',
|
||
data: models
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get OpenAI-Claude models:', error)
|
||
res.status(500).json({
|
||
error: {
|
||
message: 'Failed to retrieve models',
|
||
type: 'server_error',
|
||
code: 'internal_error'
|
||
}
|
||
})
|
||
}
|
||
return undefined
|
||
})
|
||
|
||
// 📄 OpenAI 兼容的模型详情端点
|
||
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||
try {
|
||
const apiKeyData = req.apiKey
|
||
const modelId = req.params.model
|
||
|
||
// 检查权限
|
||
if (!checkPermissions(apiKeyData, 'claude')) {
|
||
return res.status(403).json({
|
||
error: {
|
||
message: 'This API key does not have permission to access Claude',
|
||
type: 'permission_denied',
|
||
code: 'permission_denied'
|
||
}
|
||
})
|
||
}
|
||
|
||
// 检查模型限制
|
||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
||
return res.status(404).json({
|
||
error: {
|
||
message: `Model '${modelId}' not found`,
|
||
type: 'invalid_request_error',
|
||
code: 'model_not_found'
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 从 model_pricing.json 获取模型信息
|
||
const modelData = modelPricingData[modelId]
|
||
|
||
// 构建标准 OpenAI 格式的模型响应
|
||
let modelInfo
|
||
|
||
if (modelData) {
|
||
// 如果在 pricing 文件中找到了模型
|
||
modelInfo = {
|
||
id: modelId,
|
||
object: 'model',
|
||
created: 1736726400, // 2025-01-13
|
||
owned_by: 'anthropic',
|
||
permission: [],
|
||
root: modelId,
|
||
parent: null
|
||
}
|
||
} else {
|
||
// 如果没找到,返回默认信息(但仍保持正确格式)
|
||
modelInfo = {
|
||
id: modelId,
|
||
object: 'model',
|
||
created: Math.floor(Date.now() / 1000),
|
||
owned_by: 'anthropic',
|
||
permission: [],
|
||
root: modelId,
|
||
parent: null
|
||
}
|
||
}
|
||
|
||
res.json(modelInfo)
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get model details:', error)
|
||
res.status(500).json({
|
||
error: {
|
||
message: 'Failed to retrieve model details',
|
||
type: 'server_error',
|
||
code: 'internal_error'
|
||
}
|
||
})
|
||
}
|
||
return undefined
|
||
})
|
||
|
||
// 🔧 处理聊天完成请求的核心函数
|
||
async function handleChatCompletion(req, res, apiKeyData) {
|
||
const startTime = Date.now()
|
||
let abortController = null
|
||
|
||
try {
|
||
// 检查权限
|
||
if (!checkPermissions(apiKeyData, 'claude')) {
|
||
return res.status(403).json({
|
||
error: {
|
||
message: 'This API key does not have permission to access Claude',
|
||
type: 'permission_denied',
|
||
code: 'permission_denied'
|
||
}
|
||
})
|
||
}
|
||
|
||
// 记录原始请求
|
||
logger.debug('📥 Received OpenAI format request:', {
|
||
model: req.body.model,
|
||
messageCount: req.body.messages?.length,
|
||
stream: req.body.stream,
|
||
maxTokens: req.body.max_tokens
|
||
})
|
||
|
||
// 转换 OpenAI 请求为 Claude 格式
|
||
const claudeRequest = openaiToClaude.convertRequest(req.body)
|
||
|
||
// 检查模型限制
|
||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||
if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) {
|
||
return res.status(403).json({
|
||
error: {
|
||
message: `Model ${req.body.model} is not allowed for this API key`,
|
||
type: 'invalid_request_error',
|
||
code: 'model_not_allowed'
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 生成会话哈希用于sticky会话
|
||
const sessionHash = sessionHelper.generateSessionHash(claudeRequest)
|
||
|
||
// 选择可用的Claude账户
|
||
const accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||
apiKeyData,
|
||
sessionHash,
|
||
claudeRequest.model
|
||
)
|
||
const { accountId } = accountSelection
|
||
|
||
// 获取该账号存储的 Claude Code headers
|
||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||
|
||
logger.debug(`📋 Using Claude Code headers for account ${accountId}:`, {
|
||
userAgent: claudeCodeHeaders['user-agent']
|
||
})
|
||
|
||
// 处理流式请求
|
||
if (claudeRequest.stream) {
|
||
logger.info(`🌊 Processing OpenAI stream request for model: ${req.body.model}`)
|
||
|
||
// 设置 SSE 响应头
|
||
res.setHeader('Content-Type', 'text/event-stream')
|
||
res.setHeader('Cache-Control', 'no-cache')
|
||
res.setHeader('Connection', 'keep-alive')
|
||
res.setHeader('X-Accel-Buffering', 'no')
|
||
|
||
// 创建中止控制器
|
||
abortController = new AbortController()
|
||
|
||
// 处理客户端断开
|
||
req.on('close', () => {
|
||
if (abortController && !abortController.signal.aborted) {
|
||
logger.info('🔌 Client disconnected, aborting Claude request')
|
||
abortController.abort()
|
||
}
|
||
})
|
||
|
||
// 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||
claudeRequest,
|
||
apiKeyData,
|
||
res,
|
||
claudeCodeHeaders,
|
||
(usage) => {
|
||
// 记录使用统计
|
||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||
const model = usage.model || claudeRequest.model
|
||
|
||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||
apiKeyService
|
||
.recordUsageWithDetails(
|
||
apiKeyData.id,
|
||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||
model,
|
||
accountId
|
||
)
|
||
.catch((error) => {
|
||
logger.error('❌ Failed to record usage:', error)
|
||
})
|
||
}
|
||
},
|
||
// 流转换器
|
||
(() => {
|
||
// 为每个请求创建独立的会话ID
|
||
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
||
return (chunk) => openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
||
})(),
|
||
{
|
||
betaHeader:
|
||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||
}
|
||
)
|
||
} else {
|
||
// 非流式请求
|
||
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
|
||
|
||
// 发送请求到 Claude (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
||
const claudeResponse = await claudeRelayService.relayRequest(
|
||
claudeRequest,
|
||
apiKeyData,
|
||
req,
|
||
res,
|
||
claudeCodeHeaders,
|
||
{ betaHeader: 'oauth-2025-04-20' }
|
||
)
|
||
|
||
// 解析 Claude 响应
|
||
let claudeData
|
||
try {
|
||
claudeData = JSON.parse(claudeResponse.body)
|
||
} catch (error) {
|
||
logger.error('❌ Failed to parse Claude response:', error)
|
||
return res.status(502).json({
|
||
error: {
|
||
message: 'Invalid response from Claude API',
|
||
type: 'api_error',
|
||
code: 'invalid_response'
|
||
}
|
||
})
|
||
}
|
||
|
||
// 处理错误响应
|
||
if (claudeResponse.statusCode >= 400) {
|
||
return res.status(claudeResponse.statusCode).json({
|
||
error: {
|
||
message: claudeData.error?.message || 'Claude API error',
|
||
type: claudeData.error?.type || 'api_error',
|
||
code: claudeData.error?.code || 'unknown_error'
|
||
}
|
||
})
|
||
}
|
||
|
||
// 转换为 OpenAI 格式
|
||
const openaiResponse = openaiToClaude.convertResponse(claudeData, req.body.model)
|
||
|
||
// 记录使用统计
|
||
if (claudeData.usage) {
|
||
const { usage } = claudeData
|
||
// 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据
|
||
apiKeyService
|
||
.recordUsageWithDetails(
|
||
apiKeyData.id,
|
||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||
claudeRequest.model,
|
||
accountId
|
||
)
|
||
.catch((error) => {
|
||
logger.error('❌ Failed to record usage:', error)
|
||
})
|
||
}
|
||
|
||
// 返回 OpenAI 格式响应
|
||
res.json(openaiResponse)
|
||
}
|
||
|
||
const duration = Date.now() - startTime
|
||
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
|
||
} catch (error) {
|
||
logger.error('❌ OpenAI-Claude request error:', error)
|
||
|
||
const status = error.status || 500
|
||
res.status(status).json({
|
||
error: {
|
||
message: error.message || 'Internal server error',
|
||
type: 'server_error',
|
||
code: 'internal_error'
|
||
}
|
||
})
|
||
} finally {
|
||
// 清理资源
|
||
if (abortController) {
|
||
abortController = null
|
||
}
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
// 🚀 OpenAI 兼容的聊天完成端点
|
||
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||
await handleChatCompletion(req, res, req.apiKey)
|
||
})
|
||
|
||
// 🔧 OpenAI 兼容的 completions 端点(传统格式,转换为 chat 格式)
|
||
router.post('/v1/completions', authenticateApiKey, async (req, res) => {
|
||
try {
|
||
const apiKeyData = req.apiKey
|
||
|
||
// 验证必需参数
|
||
if (!req.body.prompt) {
|
||
return res.status(400).json({
|
||
error: {
|
||
message: 'Prompt is required',
|
||
type: 'invalid_request_error',
|
||
code: 'invalid_request'
|
||
}
|
||
})
|
||
}
|
||
|
||
// 将传统 completions 格式转换为 chat 格式
|
||
const originalBody = req.body
|
||
req.body = {
|
||
model: originalBody.model,
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: originalBody.prompt
|
||
}
|
||
],
|
||
max_tokens: originalBody.max_tokens,
|
||
temperature: originalBody.temperature,
|
||
top_p: originalBody.top_p,
|
||
stream: originalBody.stream,
|
||
stop: originalBody.stop,
|
||
n: originalBody.n || 1,
|
||
presence_penalty: originalBody.presence_penalty,
|
||
frequency_penalty: originalBody.frequency_penalty,
|
||
logit_bias: originalBody.logit_bias,
|
||
user: originalBody.user
|
||
}
|
||
|
||
// 使用共享的处理函数
|
||
await handleChatCompletion(req, res, apiKeyData)
|
||
} catch (error) {
|
||
logger.error('❌ OpenAI completions error:', error)
|
||
res.status(500).json({
|
||
error: {
|
||
message: 'Failed to process completion request',
|
||
type: 'server_error',
|
||
code: 'internal_error'
|
||
}
|
||
})
|
||
}
|
||
return undefined
|
||
})
|
||
|
||
module.exports = router
|