mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
fix: droid增加comm端点
This commit is contained in:
@@ -18,6 +18,7 @@ function hasDroidPermission(apiKeyData) {
|
|||||||
* 支持的 Factory.ai 端点:
|
* 支持的 Factory.ai 端点:
|
||||||
* - /droid/claude - Anthropic (Claude) Messages API
|
* - /droid/claude - Anthropic (Claude) Messages API
|
||||||
* - /droid/openai - OpenAI Responses API
|
* - /droid/openai - OpenAI Responses API
|
||||||
|
* - /droid/comm - OpenAI Chat Completions API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Claude (Anthropic) 端点 - /v1/messages
|
// Claude (Anthropic) 端点 - /v1/messages
|
||||||
@@ -60,6 +61,53 @@ router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Comm 端点 - /v1/chat/completions(OpenAI Chat Completions 格式)
|
||||||
|
router.post('/comm/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sessionId =
|
||||||
|
req.headers['session_id'] ||
|
||||||
|
req.headers['x-session-id'] ||
|
||||||
|
req.body?.session_id ||
|
||||||
|
req.body?.conversation_id ||
|
||||||
|
null
|
||||||
|
|
||||||
|
const sessionHash = sessionId
|
||||||
|
? crypto.createHash('sha256').update(String(sessionId)).digest('hex')
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!hasDroidPermission(req.apiKey)) {
|
||||||
|
logger.security(
|
||||||
|
`🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'permission_denied',
|
||||||
|
message: '此 API Key 未启用 Droid 权限'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await droidRelayService.relayRequest(
|
||||||
|
req.body,
|
||||||
|
req.apiKey,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
req.headers,
|
||||||
|
{ endpointType: 'comm', sessionHash }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.streaming) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(result.statusCode).set(result.headers).send(result.body)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Droid Comm relay error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'internal_server_error',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// OpenAI 端点 - /v1/responses
|
// OpenAI 端点 - /v1/responses
|
||||||
router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => {
|
router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class DroidAccountService {
|
|||||||
constructor() {
|
constructor() {
|
||||||
// WorkOS OAuth 配置
|
// WorkOS OAuth 配置
|
||||||
this.oauthTokenUrl = 'https://api.workos.com/user_management/authenticate'
|
this.oauthTokenUrl = 'https://api.workos.com/user_management/authenticate'
|
||||||
this.factoryApiBaseUrl = 'https://app.factory.ai/api/llm'
|
this.factoryApiBaseUrl = 'https://api.factory.ai/api/llm'
|
||||||
|
|
||||||
this.workosClientId = 'client_01HNM792M5G5G1A2THWPXKFMXB'
|
this.workosClientId = 'client_01HNM792M5G5G1A2THWPXKFMXB'
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class DroidAccountService {
|
|||||||
10 * 60 * 1000
|
10 * 60 * 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
this.supportedEndpointTypes = new Set(['anthropic', 'openai'])
|
this.supportedEndpointTypes = new Set(['anthropic', 'openai', 'comm'])
|
||||||
}
|
}
|
||||||
|
|
||||||
_sanitizeEndpointType(endpointType) {
|
_sanitizeEndpointType(endpointType) {
|
||||||
@@ -54,10 +54,14 @@ class DroidAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalized = String(endpointType).toLowerCase()
|
const normalized = String(endpointType).toLowerCase()
|
||||||
if (normalized === 'openai' || normalized === 'common') {
|
if (normalized === 'openai') {
|
||||||
return 'openai'
|
return 'openai'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized === 'comm') {
|
||||||
|
return 'comm'
|
||||||
|
}
|
||||||
|
|
||||||
if (this.supportedEndpointTypes.has(normalized)) {
|
if (this.supportedEndpointTypes.has(normalized)) {
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
@@ -544,7 +548,7 @@ class DroidAccountService {
|
|||||||
platform = 'droid',
|
platform = 'droid',
|
||||||
priority = 50, // 调度优先级 (1-100)
|
priority = 50, // 调度优先级 (1-100)
|
||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
endpointType = 'anthropic', // 默认端点类型: 'anthropic' 或 'openai'
|
endpointType = 'anthropic', // 默认端点类型: 'anthropic', 'openai' 或 'comm'
|
||||||
organizationId = '',
|
organizationId = '',
|
||||||
ownerEmail = '',
|
ownerEmail = '',
|
||||||
ownerName = '',
|
ownerName = '',
|
||||||
@@ -812,7 +816,7 @@ class DroidAccountService {
|
|||||||
status, // created, active, expired, error
|
status, // created, active, expired, error
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
schedulable: schedulable.toString(),
|
schedulable: schedulable.toString(),
|
||||||
endpointType: normalizedEndpointType, // anthropic 或 openai
|
endpointType: normalizedEndpointType, // anthropic, openai 或 comm
|
||||||
organizationId: normalizedOrganizationId || '',
|
organizationId: normalizedOrganizationId || '',
|
||||||
owner: normalizedOwnerName || normalizedOwnerEmail || '',
|
owner: normalizedOwnerName || normalizedOwnerEmail || '',
|
||||||
ownerEmail: normalizedOwnerEmail || '',
|
ownerEmail: normalizedOwnerEmail || '',
|
||||||
@@ -1475,6 +1479,11 @@ class DroidAccountService {
|
|||||||
return accountEndpoint === 'anthropic' || accountEndpoint === 'openai'
|
return accountEndpoint === 'anthropic' || accountEndpoint === 'openai'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// comm 端点可以使用任何类型的账户
|
||||||
|
if (normalizedFilter === 'comm') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return accountEndpoint === normalizedFilter
|
return accountEndpoint === normalizedFilter
|
||||||
})
|
})
|
||||||
.map((account) => ({
|
.map((account) => ({
|
||||||
@@ -1540,7 +1549,8 @@ class DroidAccountService {
|
|||||||
const normalizedType = this._sanitizeEndpointType(endpointType)
|
const normalizedType = this._sanitizeEndpointType(endpointType)
|
||||||
const baseUrls = {
|
const baseUrls = {
|
||||||
anthropic: `${this.factoryApiBaseUrl}/a${endpoint}`,
|
anthropic: `${this.factoryApiBaseUrl}/a${endpoint}`,
|
||||||
openai: `${this.factoryApiBaseUrl}/o${endpoint}`
|
openai: `${this.factoryApiBaseUrl}/o${endpoint}`,
|
||||||
|
comm: `${this.factoryApiBaseUrl}/o${endpoint}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseUrls[normalizedType] || baseUrls.openai
|
return baseUrls[normalizedType] || baseUrls.openai
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ const RUNTIME_EVENT_FMT_PAYLOAD = 'fmtPayload'
|
|||||||
|
|
||||||
class DroidRelayService {
|
class DroidRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.factoryApiBaseUrl = 'https://app.factory.ai/api/llm'
|
this.factoryApiBaseUrl = 'https://api.factory.ai/api/llm'
|
||||||
|
|
||||||
this.endpoints = {
|
this.endpoints = {
|
||||||
anthropic: '/a/v1/messages',
|
anthropic: '/a/v1/messages',
|
||||||
openai: '/o/v1/responses'
|
openai: '/o/v1/responses',
|
||||||
|
comm: '/o/v1/chat/completions'
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userAgent = 'factory-cli/0.19.12'
|
this.userAgent = 'factory-cli/0.19.12'
|
||||||
@@ -36,10 +37,14 @@ class DroidRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalized = String(endpointType).toLowerCase()
|
const normalized = String(endpointType).toLowerCase()
|
||||||
if (normalized === 'openai' || normalized === 'common') {
|
if (normalized === 'openai') {
|
||||||
return 'openai'
|
return 'openai'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized === 'comm') {
|
||||||
|
return 'comm'
|
||||||
|
}
|
||||||
|
|
||||||
if (normalized === 'anthropic') {
|
if (normalized === 'anthropic') {
|
||||||
return 'anthropic'
|
return 'anthropic'
|
||||||
}
|
}
|
||||||
@@ -559,8 +564,8 @@ class DroidRelayService {
|
|||||||
if (endpointType === 'anthropic') {
|
if (endpointType === 'anthropic') {
|
||||||
// Anthropic Messages API 格式
|
// Anthropic Messages API 格式
|
||||||
this._parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData)
|
this._parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData)
|
||||||
} else if (endpointType === 'openai') {
|
} else if (endpointType === 'openai' || endpointType === 'comm') {
|
||||||
// OpenAI Chat Completions 格式
|
// OpenAI Chat Completions 格式(openai 和 comm 共用)
|
||||||
this._parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData)
|
this._parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,8 +721,21 @@ class DroidRelayService {
|
|||||||
// 兼容传统 Chat Completions usage 字段
|
// 兼容传统 Chat Completions usage 字段
|
||||||
if (data.usage) {
|
if (data.usage) {
|
||||||
currentUsageData.input_tokens = data.usage.prompt_tokens || 0
|
currentUsageData.input_tokens = data.usage.prompt_tokens || 0
|
||||||
currentUsageData.output_tokens = data.usage.completion_tokens || 0
|
|
||||||
currentUsageData.total_tokens = data.usage.total_tokens || 0
|
currentUsageData.total_tokens = data.usage.total_tokens || 0
|
||||||
|
// completion_tokens 可能缺失(如某些模型响应),从 total_tokens - prompt_tokens 计算
|
||||||
|
if (
|
||||||
|
data.usage.completion_tokens !== undefined &&
|
||||||
|
data.usage.completion_tokens !== null
|
||||||
|
) {
|
||||||
|
currentUsageData.output_tokens = data.usage.completion_tokens
|
||||||
|
} else if (currentUsageData.total_tokens > 0 && currentUsageData.input_tokens >= 0) {
|
||||||
|
currentUsageData.output_tokens = Math.max(
|
||||||
|
0,
|
||||||
|
currentUsageData.total_tokens - currentUsageData.input_tokens
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
currentUsageData.output_tokens = 0
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug('📊 Droid OpenAI usage:', currentUsageData)
|
logger.debug('📊 Droid OpenAI usage:', currentUsageData)
|
||||||
}
|
}
|
||||||
@@ -727,8 +745,18 @@ class DroidRelayService {
|
|||||||
const { usage } = data.response
|
const { usage } = data.response
|
||||||
currentUsageData.input_tokens =
|
currentUsageData.input_tokens =
|
||||||
usage.input_tokens || usage.prompt_tokens || usage.total_tokens || 0
|
usage.input_tokens || usage.prompt_tokens || usage.total_tokens || 0
|
||||||
currentUsageData.output_tokens = usage.output_tokens || usage.completion_tokens || 0
|
|
||||||
currentUsageData.total_tokens = usage.total_tokens || 0
|
currentUsageData.total_tokens = usage.total_tokens || 0
|
||||||
|
// completion_tokens/output_tokens 可能缺失,从 total_tokens - input_tokens 计算
|
||||||
|
if (usage.output_tokens !== undefined || usage.completion_tokens !== undefined) {
|
||||||
|
currentUsageData.output_tokens = usage.output_tokens || usage.completion_tokens || 0
|
||||||
|
} else if (currentUsageData.total_tokens > 0 && currentUsageData.input_tokens >= 0) {
|
||||||
|
currentUsageData.output_tokens = Math.max(
|
||||||
|
0,
|
||||||
|
currentUsageData.total_tokens - currentUsageData.input_tokens
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
currentUsageData.output_tokens = 0
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug('📊 Droid OpenAI response usage:', currentUsageData)
|
logger.debug('📊 Droid OpenAI response usage:', currentUsageData)
|
||||||
}
|
}
|
||||||
@@ -763,7 +791,7 @@ class DroidRelayService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endpointType === 'openai') {
|
if (endpointType === 'openai' || endpointType === 'comm') {
|
||||||
if (lower.includes('data: [done]')) {
|
if (lower.includes('data: [done]')) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -817,9 +845,16 @@ class DroidRelayService {
|
|||||||
usageData.inputTokens ??
|
usageData.inputTokens ??
|
||||||
usageData.total_input_tokens
|
usageData.total_input_tokens
|
||||||
)
|
)
|
||||||
const outputTokens = toNumber(
|
const totalTokens = toNumber(usageData.total_tokens ?? usageData.totalTokens)
|
||||||
|
|
||||||
|
// 尝试从多个字段获取 output_tokens
|
||||||
|
let outputTokens = toNumber(
|
||||||
usageData.output_tokens ?? usageData.completion_tokens ?? usageData.outputTokens
|
usageData.output_tokens ?? usageData.completion_tokens ?? usageData.outputTokens
|
||||||
)
|
)
|
||||||
|
// 如果 output_tokens 为 0 但有 total_tokens,从差值计算
|
||||||
|
if (outputTokens === 0 && totalTokens > 0 && inputTokens >= 0) {
|
||||||
|
outputTokens = Math.max(0, totalTokens - inputTokens)
|
||||||
|
}
|
||||||
const cacheReadTokens = toNumber(
|
const cacheReadTokens = toNumber(
|
||||||
usageData.cache_read_input_tokens ??
|
usageData.cache_read_input_tokens ??
|
||||||
usageData.cacheReadTokens ??
|
usageData.cacheReadTokens ??
|
||||||
@@ -894,6 +929,40 @@ class DroidRelayService {
|
|||||||
return account.id || account.accountId || account.account_id || null
|
return account.id || account.accountId || account.account_id || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模型名称推断 API provider
|
||||||
|
*/
|
||||||
|
_inferProviderFromModel(model) {
|
||||||
|
if (!model || typeof model !== 'string') {
|
||||||
|
return 'baseten'
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerModel = model.toLowerCase()
|
||||||
|
|
||||||
|
// Google Gemini 模型
|
||||||
|
if (lowerModel.startsWith('gemini-') || lowerModel.includes('gemini')) {
|
||||||
|
return 'google'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anthropic Claude 模型
|
||||||
|
if (lowerModel.startsWith('claude-') || lowerModel.includes('claude')) {
|
||||||
|
return 'anthropic'
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI GPT 模型
|
||||||
|
if (lowerModel.startsWith('gpt-') || lowerModel.includes('gpt')) {
|
||||||
|
return 'azure_openai'
|
||||||
|
}
|
||||||
|
|
||||||
|
// GLM 模型使用 fireworks
|
||||||
|
if (lowerModel.startsWith('glm-') || lowerModel.includes('glm')) {
|
||||||
|
return 'fireworks'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认使用 baseten
|
||||||
|
return 'baseten'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建请求头
|
* 构建请求头
|
||||||
*/
|
*/
|
||||||
@@ -923,6 +992,12 @@ class DroidRelayService {
|
|||||||
headers['x-api-provider'] = 'azure_openai'
|
headers['x-api-provider'] = 'azure_openai'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comm 端点根据模型动态设置 provider
|
||||||
|
if (endpointType === 'comm') {
|
||||||
|
const model = requestBody?.model
|
||||||
|
headers['x-api-provider'] = this._inferProviderFromModel(model)
|
||||||
|
}
|
||||||
|
|
||||||
// 生成会话 ID(如果客户端没有提供)
|
// 生成会话 ID(如果客户端没有提供)
|
||||||
headers['x-session-id'] = clientHeaders['x-session-id'] || this._generateUUID()
|
headers['x-session-id'] = clientHeaders['x-session-id'] || this._generateUUID()
|
||||||
|
|
||||||
@@ -1034,6 +1109,36 @@ class DroidRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comm 端点:在 messages 数组前注入 system 消息
|
||||||
|
if (endpointType === 'comm') {
|
||||||
|
if (this.systemPrompt && Array.isArray(processedBody.messages)) {
|
||||||
|
const hasSystemMessage = processedBody.messages.some((m) => m && m.role === 'system')
|
||||||
|
|
||||||
|
if (hasSystemMessage) {
|
||||||
|
// 如果已有 system 消息,在第一个 system 消息的 content 前追加
|
||||||
|
const firstSystemIndex = processedBody.messages.findIndex((m) => m && m.role === 'system')
|
||||||
|
if (firstSystemIndex !== -1) {
|
||||||
|
const existingContent = processedBody.messages[firstSystemIndex].content || ''
|
||||||
|
if (
|
||||||
|
typeof existingContent === 'string' &&
|
||||||
|
!existingContent.startsWith(this.systemPrompt)
|
||||||
|
) {
|
||||||
|
processedBody.messages[firstSystemIndex] = {
|
||||||
|
...processedBody.messages[firstSystemIndex],
|
||||||
|
content: this.systemPrompt + existingContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果没有 system 消息,在 messages 数组最前面插入
|
||||||
|
processedBody.messages = [
|
||||||
|
{ role: 'system', content: this.systemPrompt },
|
||||||
|
...processedBody.messages
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理 temperature 和 top_p 参数
|
// 处理 temperature 和 top_p 参数
|
||||||
const hasValidTemperature =
|
const hasValidTemperature =
|
||||||
processedBody.temperature !== undefined && processedBody.temperature !== null
|
processedBody.temperature !== undefined && processedBody.temperature !== null
|
||||||
@@ -1080,11 +1185,17 @@ class DroidRelayService {
|
|||||||
cacheReadTokens: normalizedUsage.cache_read_input_tokens || 0
|
cacheReadTokens: normalizedUsage.cache_read_input_tokens || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const endpointLabel =
|
||||||
|
endpointType === 'anthropic'
|
||||||
|
? ' [anthropic]'
|
||||||
|
: endpointType === 'comm'
|
||||||
|
? ' [comm]'
|
||||||
|
: ' [openai]'
|
||||||
await this._applyRateLimitTracking(
|
await this._applyRateLimitTracking(
|
||||||
clientRequest?.rateLimitInfo,
|
clientRequest?.rateLimitInfo,
|
||||||
usageSummary,
|
usageSummary,
|
||||||
model,
|
model,
|
||||||
endpointType === 'anthropic' ? ' [anthropic]' : ' [openai]'
|
endpointLabel
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
|
|||||||
@@ -13,9 +13,15 @@ class DroidScheduler {
|
|||||||
return 'anthropic'
|
return 'anthropic'
|
||||||
}
|
}
|
||||||
const normalized = String(endpointType).toLowerCase()
|
const normalized = String(endpointType).toLowerCase()
|
||||||
if (normalized === 'openai' || normalized === 'common') {
|
if (normalized === 'openai') {
|
||||||
return 'openai'
|
return 'openai'
|
||||||
}
|
}
|
||||||
|
if (normalized === 'comm') {
|
||||||
|
return 'comm'
|
||||||
|
}
|
||||||
|
if (normalized === 'anthropic') {
|
||||||
|
return 'anthropic'
|
||||||
|
}
|
||||||
return 'anthropic'
|
return 'anthropic'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +63,11 @@ class DroidScheduler {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// comm 端点可以使用任何类型的账户
|
||||||
|
if (normalizedEndpoint === 'comm') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const sharedEndpoints = new Set(['anthropic', 'openai'])
|
const sharedEndpoints = new Set(['anthropic', 'openai'])
|
||||||
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
|
return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user