mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Merge remote-tracking branch 'f3n9/main' into um-5
This commit is contained in:
@@ -50,7 +50,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
|
|||||||
// 提取请求参数
|
// 提取请求参数
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
model = 'gemini-2.0-flash-exp',
|
model = 'gemini-2.5-flash',
|
||||||
temperature = 0.7,
|
temperature = 0.7,
|
||||||
max_tokens = 4096,
|
max_tokens = 4096,
|
||||||
stream = false
|
stream = false
|
||||||
@@ -217,7 +217,7 @@ router.get('/models', authenticateApiKey, async (req, res) => {
|
|||||||
object: 'list',
|
object: 'list',
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
id: 'gemini-2.0-flash-exp',
|
id: 'gemini-2.5-flash',
|
||||||
object: 'model',
|
object: 'model',
|
||||||
created: Date.now() / 1000,
|
created: Date.now() / 1000,
|
||||||
owned_by: 'google'
|
owned_by: 'google'
|
||||||
@@ -311,8 +311,8 @@ async function handleLoadCodeAssist(req, res) {
|
|||||||
try {
|
try {
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 从路径参数或请求体中获取模型名
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
@@ -368,8 +368,8 @@ async function handleOnboardUser(req, res) {
|
|||||||
const { tierId, cloudaicompanionProject, metadata } = req.body
|
const { tierId, cloudaicompanionProject, metadata } = req.body
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 从路径参数或请求体中获取模型名
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||||
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
@@ -439,7 +439,9 @@ async function handleCountTokens(req, res) {
|
|||||||
try {
|
try {
|
||||||
// 处理请求体结构,支持直接 contents 或 request.contents
|
// 处理请求体结构,支持直接 contents 或 request.contents
|
||||||
const requestData = req.body.request || req.body
|
const requestData = req.body.request || req.body
|
||||||
const { contents, model = 'gemini-2.0-flash-exp' } = requestData
|
const { contents } = requestData
|
||||||
|
// 从路径参数或请求体中获取模型名
|
||||||
|
const model = requestData.model || req.params.modelName || 'gemini-2.5-flash'
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
// 验证必需参数
|
// 验证必需参数
|
||||||
@@ -487,7 +489,9 @@ async function handleCountTokens(req, res) {
|
|||||||
// 共用的 generateContent 处理函数
|
// 共用的 generateContent 处理函数
|
||||||
async function handleGenerateContent(req, res) {
|
async function handleGenerateContent(req, res) {
|
||||||
try {
|
try {
|
||||||
const { model, project, user_prompt_id, request: requestData } = req.body
|
const { project, user_prompt_id, request: requestData } = req.body
|
||||||
|
// 从路径参数或请求体中获取模型名
|
||||||
|
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
// 处理不同格式的请求
|
// 处理不同格式的请求
|
||||||
@@ -582,7 +586,7 @@ async function handleGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(response)
|
res.json(version === 'v1beta' ? response.response : response)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
// 打印详细的错误信息
|
// 打印详细的错误信息
|
||||||
@@ -610,7 +614,9 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
let abortController = null
|
let abortController = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { model, project, user_prompt_id, request: requestData } = req.body
|
const { project, user_prompt_id, request: requestData } = req.body
|
||||||
|
// 从路径参数或请求体中获取模型名
|
||||||
|
const model = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
// 处理不同格式的请求
|
// 处理不同格式的请求
|
||||||
@@ -702,8 +708,28 @@ 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')
|
||||||
|
|
||||||
|
// SSE 解析函数
|
||||||
|
const parseSSELine = (line) => {
|
||||||
|
if (!line.startsWith('data: ')) {
|
||||||
|
return { type: 'other', line, data: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonStr = line.substring(6).trim()
|
||||||
|
|
||||||
|
if (!jsonStr || jsonStr === '[DONE]') {
|
||||||
|
return { type: 'control', line, data: null, jsonStr }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr)
|
||||||
|
return { type: 'data', line, data, jsonStr }
|
||||||
|
} catch (e) {
|
||||||
|
return { type: 'invalid', line, data: null, jsonStr, error: e }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理流式响应并捕获usage数据
|
// 处理流式响应并捕获usage数据
|
||||||
let buffer = ''
|
let streamBuffer = '' // 统一的流处理缓冲区
|
||||||
let totalUsage = {
|
let totalUsage = {
|
||||||
promptTokenCount: 0,
|
promptTokenCount: 0,
|
||||||
candidatesTokenCount: 0,
|
candidatesTokenCount: 0,
|
||||||
@@ -715,32 +741,60 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
try {
|
try {
|
||||||
const chunkStr = chunk.toString()
|
const chunkStr = chunk.toString()
|
||||||
|
|
||||||
// 直接转发数据到客户端
|
if (!chunkStr.trim()) {
|
||||||
if (!res.destroyed) {
|
return
|
||||||
res.write(chunkStr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同时解析数据以捕获usage信息
|
// 使用统一缓冲区处理不完整的行
|
||||||
buffer += chunkStr
|
streamBuffer += chunkStr
|
||||||
const lines = buffer.split('\n')
|
const lines = streamBuffer.split('\n')
|
||||||
buffer = lines.pop() || ''
|
streamBuffer = lines.pop() || '' // 保留最后一个不完整的行
|
||||||
|
|
||||||
|
const processedLines = []
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ') && line.length > 6) {
|
if (!line.trim()) {
|
||||||
try {
|
continue // 跳过空行,不添加到处理队列
|
||||||
const jsonStr = line.slice(6)
|
}
|
||||||
if (jsonStr && jsonStr !== '[DONE]') {
|
|
||||||
const data = JSON.parse(jsonStr)
|
|
||||||
|
|
||||||
// 从响应中提取usage数据
|
// 解析 SSE 行
|
||||||
if (data.response?.usageMetadata) {
|
const parsed = parseSSELine(line)
|
||||||
totalUsage = data.response.usageMetadata
|
|
||||||
|
// 提取 usage 数据(适用于所有版本)
|
||||||
|
if (parsed.type === 'data' && parsed.data.response?.usageMetadata) {
|
||||||
|
totalUsage = parsed.data.response.usageMetadata
|
||||||
logger.debug('📊 Captured Gemini usage data:', totalUsage)
|
logger.debug('📊 Captured Gemini usage data:', totalUsage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据版本处理输出
|
||||||
|
if (version === 'v1beta') {
|
||||||
|
if (parsed.type === 'data') {
|
||||||
|
if (parsed.data.response) {
|
||||||
|
// 有 response 字段,只返回 response 的内容
|
||||||
|
processedLines.push(`data: ${JSON.stringify(parsed.data.response)}`)
|
||||||
|
} else {
|
||||||
|
// 没有 response 字段,返回整个数据对象
|
||||||
|
processedLines.push(`data: ${JSON.stringify(parsed.data)}`)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} else if (parsed.type === 'control') {
|
||||||
// 忽略解析错误
|
// 控制消息(如 [DONE])保持原样
|
||||||
|
processedLines.push(line)
|
||||||
}
|
}
|
||||||
|
// 跳过其他类型的行('other', 'invalid')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送数据到客户端
|
||||||
|
if (version === 'v1beta') {
|
||||||
|
for (const line of processedLines) {
|
||||||
|
if (!res.destroyed) {
|
||||||
|
res.write(`${line}\n\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// v1internal 直接转发原始数据
|
||||||
|
if (!res.destroyed) {
|
||||||
|
res.write(chunkStr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -56,16 +56,27 @@ class WebhookConfigService {
|
|||||||
|
|
||||||
// 验证平台配置
|
// 验证平台配置
|
||||||
if (config.platforms) {
|
if (config.platforms) {
|
||||||
const validPlatforms = ['wechat_work', 'dingtalk', 'feishu', 'slack', 'discord', 'custom']
|
const validPlatforms = [
|
||||||
|
'wechat_work',
|
||||||
|
'dingtalk',
|
||||||
|
'feishu',
|
||||||
|
'slack',
|
||||||
|
'discord',
|
||||||
|
'custom',
|
||||||
|
'bark'
|
||||||
|
]
|
||||||
|
|
||||||
for (const platform of config.platforms) {
|
for (const platform of config.platforms) {
|
||||||
if (!validPlatforms.includes(platform.type)) {
|
if (!validPlatforms.includes(platform.type)) {
|
||||||
throw new Error(`不支持的平台类型: ${platform.type}`)
|
throw new Error(`不支持的平台类型: ${platform.type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bark平台使用deviceKey而不是url
|
||||||
|
if (platform.type !== 'bark') {
|
||||||
if (!platform.url || !this.isValidUrl(platform.url)) {
|
if (!platform.url || !this.isValidUrl(platform.url)) {
|
||||||
throw new Error(`无效的webhook URL: ${platform.url}`)
|
throw new Error(`无效的webhook URL: ${platform.url}`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 验证平台特定的配置
|
// 验证平台特定的配置
|
||||||
this.validatePlatformConfig(platform)
|
this.validatePlatformConfig(platform)
|
||||||
@@ -108,6 +119,88 @@ class WebhookConfigService {
|
|||||||
case 'custom':
|
case 'custom':
|
||||||
// 自定义webhook,用户自行负责格式
|
// 自定义webhook,用户自行负责格式
|
||||||
break
|
break
|
||||||
|
case 'bark':
|
||||||
|
// 验证设备密钥
|
||||||
|
if (!platform.deviceKey) {
|
||||||
|
throw new Error('Bark平台必须提供设备密钥')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证设备密钥格式(通常是22-24位字符)
|
||||||
|
if (platform.deviceKey.length < 20 || platform.deviceKey.length > 30) {
|
||||||
|
logger.warn('⚠️ Bark设备密钥长度可能不正确,请检查是否完整复制')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证服务器URL(如果提供)
|
||||||
|
if (platform.serverUrl) {
|
||||||
|
if (!this.isValidUrl(platform.serverUrl)) {
|
||||||
|
throw new Error('Bark服务器URL格式无效')
|
||||||
|
}
|
||||||
|
if (!platform.serverUrl.includes('/push')) {
|
||||||
|
logger.warn('⚠️ Bark服务器URL应该以/push结尾')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证声音参数(如果提供)
|
||||||
|
if (platform.sound) {
|
||||||
|
const validSounds = [
|
||||||
|
'default',
|
||||||
|
'alarm',
|
||||||
|
'anticipate',
|
||||||
|
'bell',
|
||||||
|
'birdsong',
|
||||||
|
'bloom',
|
||||||
|
'calypso',
|
||||||
|
'chime',
|
||||||
|
'choo',
|
||||||
|
'descent',
|
||||||
|
'electronic',
|
||||||
|
'fanfare',
|
||||||
|
'glass',
|
||||||
|
'gotosleep',
|
||||||
|
'healthnotification',
|
||||||
|
'horn',
|
||||||
|
'ladder',
|
||||||
|
'mailsent',
|
||||||
|
'minuet',
|
||||||
|
'multiwayinvitation',
|
||||||
|
'newmail',
|
||||||
|
'newsflash',
|
||||||
|
'noir',
|
||||||
|
'paymentsuccess',
|
||||||
|
'shake',
|
||||||
|
'sherwoodforest',
|
||||||
|
'silence',
|
||||||
|
'spell',
|
||||||
|
'suspense',
|
||||||
|
'telegraph',
|
||||||
|
'tiptoes',
|
||||||
|
'typewriters',
|
||||||
|
'update',
|
||||||
|
'alert'
|
||||||
|
]
|
||||||
|
if (!validSounds.includes(platform.sound)) {
|
||||||
|
logger.warn(`⚠️ 未知的Bark声音: ${platform.sound}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证级别参数
|
||||||
|
if (platform.level) {
|
||||||
|
const validLevels = ['active', 'timeSensitive', 'passive', 'critical']
|
||||||
|
if (!validLevels.includes(platform.level)) {
|
||||||
|
throw new Error(`无效的Bark通知级别: ${platform.level}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证图标URL(如果提供)
|
||||||
|
if (platform.icon && !this.isValidUrl(platform.icon)) {
|
||||||
|
logger.warn('⚠️ Bark图标URL格式可能不正确')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证点击跳转URL(如果提供)
|
||||||
|
if (platform.clickUrl && !this.isValidUrl(platform.clickUrl)) {
|
||||||
|
logger.warn('⚠️ Bark点击跳转URL格式可能不正确')
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ class WebhookService {
|
|||||||
feishu: this.sendToFeishu.bind(this),
|
feishu: this.sendToFeishu.bind(this),
|
||||||
slack: this.sendToSlack.bind(this),
|
slack: this.sendToSlack.bind(this),
|
||||||
discord: this.sendToDiscord.bind(this),
|
discord: this.sendToDiscord.bind(this),
|
||||||
custom: this.sendToCustom.bind(this)
|
custom: this.sendToCustom.bind(this),
|
||||||
|
bark: this.sendToBark.bind(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +213,33 @@ class WebhookService {
|
|||||||
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
|
await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bark webhook
|
||||||
|
*/
|
||||||
|
async sendToBark(platform, type, data) {
|
||||||
|
const payload = {
|
||||||
|
device_key: platform.deviceKey,
|
||||||
|
title: this.getNotificationTitle(type),
|
||||||
|
body: this.formatMessageForBark(type, data),
|
||||||
|
level: platform.level || this.getBarkLevel(type),
|
||||||
|
sound: platform.sound || this.getBarkSound(type),
|
||||||
|
group: platform.group || 'claude-relay',
|
||||||
|
badge: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加可选参数
|
||||||
|
if (platform.icon) {
|
||||||
|
payload.icon = platform.icon
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform.clickUrl) {
|
||||||
|
payload.url = platform.clickUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = platform.serverUrl || 'https://api.day.app/push'
|
||||||
|
await this.sendHttpRequest(url, payload, platform.timeout || 10000)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送HTTP请求
|
* 发送HTTP请求
|
||||||
*/
|
*/
|
||||||
@@ -351,6 +379,81 @@ class WebhookService {
|
|||||||
return titles[type] || '📢 系统通知'
|
return titles[type] || '📢 系统通知'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Bark通知级别
|
||||||
|
*/
|
||||||
|
getBarkLevel(type) {
|
||||||
|
const levels = {
|
||||||
|
accountAnomaly: 'timeSensitive',
|
||||||
|
quotaWarning: 'active',
|
||||||
|
systemError: 'critical',
|
||||||
|
securityAlert: 'critical',
|
||||||
|
test: 'passive'
|
||||||
|
}
|
||||||
|
|
||||||
|
return levels[type] || 'active'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Bark声音
|
||||||
|
*/
|
||||||
|
getBarkSound(type) {
|
||||||
|
const sounds = {
|
||||||
|
accountAnomaly: 'alarm',
|
||||||
|
quotaWarning: 'bell',
|
||||||
|
systemError: 'alert',
|
||||||
|
securityAlert: 'alarm',
|
||||||
|
test: 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
return sounds[type] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化Bark消息
|
||||||
|
*/
|
||||||
|
formatMessageForBark(type, data) {
|
||||||
|
const lines = []
|
||||||
|
|
||||||
|
if (data.accountName) {
|
||||||
|
lines.push(`账号: ${data.accountName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.platform) {
|
||||||
|
lines.push(`平台: ${data.platform}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status) {
|
||||||
|
lines.push(`状态: ${data.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.errorCode) {
|
||||||
|
lines.push(`错误: ${data.errorCode}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.reason) {
|
||||||
|
lines.push(`原因: ${data.reason}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.message) {
|
||||||
|
lines.push(`消息: ${data.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.quota) {
|
||||||
|
lines.push(`剩余配额: ${data.quota.remaining}/${data.quota.total}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.usage) {
|
||||||
|
lines.push(`使用率: ${data.usage}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加服务标识和时间戳
|
||||||
|
lines.push(`\n服务: Claude Relay Service`)
|
||||||
|
lines.push(`时间: ${new Date().toLocaleString('zh-CN')}`)
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化通知详情
|
* 格式化通知详情
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user