mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge branch 'dev'
This commit is contained in:
1
.github/workflows/auto-release-pipeline.yml
vendored
1
.github/workflows/auto-release-pipeline.yml
vendored
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch: # 支持手动触发
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -410,10 +410,27 @@ export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
|||||||
|
|
||||||
**Gemini CLI 设置环境变量:**
|
**Gemini CLI 设置环境变量:**
|
||||||
|
|
||||||
|
**方式一(推荐):通过 Gemini Assist API 方式访问**
|
||||||
|
|
||||||
|
每账号每日享受 1000 次请求,每分钟 60 次免费限额。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
|
||||||
|
GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥"
|
||||||
|
GOOGLE_GENAI_USE_GCA="true"
|
||||||
|
GEMINI_MODEL="gemini-2.5-pro"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:gemini-cli 控制台会提示 `Failed to fetch user info: 401 Unauthorized`,但使用不受任何影响。
|
||||||
|
|
||||||
|
**方式二:通过 Gemini API 方式访问**
|
||||||
|
|
||||||
|
免费额度极少,极易触发 429 错误。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
|
||||||
|
GEMINI_API_KEY="后台创建的API密钥"
|
||||||
GEMINI_MODEL="gemini-2.5-pro"
|
GEMINI_MODEL="gemini-2.5-pro"
|
||||||
GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
|
|
||||||
GEMINI_API_KEY="后台创建的API密钥" # 使用相同的API密钥即可
|
|
||||||
```
|
```
|
||||||
**使用 Claude Code:**
|
**使用 Claude Code:**
|
||||||
|
|
||||||
|
|||||||
55
README_EN.md
55
README_EN.md
@@ -232,21 +232,68 @@ Assign a key to each user:
|
|||||||
4. Set usage limits (optional)
|
4. Set usage limits (optional)
|
||||||
5. Save, note down the generated key
|
5. Save, note down the generated key
|
||||||
|
|
||||||
### 4. Start Using Claude Code
|
### 4. Start Using Claude Code and Gemini CLI
|
||||||
|
|
||||||
Now you can replace the official API with your own service:
|
Now you can replace the official API with your own service:
|
||||||
|
|
||||||
**Set environment variables:**
|
**Claude Code Set Environment Variables:**
|
||||||
|
|
||||||
|
Default uses standard Claude account pool:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain according to actual situation
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain
|
||||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
|
export ANTHROPIC_AUTH_TOKEN="API key created in the backend"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Use claude:**
|
**VSCode Claude Plugin Configuration:**
|
||||||
|
|
||||||
|
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"primaryApiKey": "crs"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the file doesn't exist, create it manually. Windows users path is `C:\Users\YourUsername\.claude\config.json`.
|
||||||
|
|
||||||
|
**Gemini CLI Set Environment Variables:**
|
||||||
|
|
||||||
|
**Method 1 (Recommended): Via Gemini Assist API**
|
||||||
|
|
||||||
|
Each account enjoys 1000 requests per day, 60 requests per minute free quota.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain
|
||||||
|
GOOGLE_CLOUD_ACCESS_TOKEN="API key created in the backend"
|
||||||
|
GOOGLE_GENAI_USE_GCA="true"
|
||||||
|
GEMINI_MODEL="gemini-2.5-pro"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: gemini-cli console will show `Failed to fetch user info: 401 Unauthorized`, but this doesn't affect usage.
|
||||||
|
|
||||||
|
**Method 2: Via Gemini API**
|
||||||
|
|
||||||
|
Very limited free quota, easily triggers 429 errors.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain
|
||||||
|
GEMINI_API_KEY="API key created in the backend"
|
||||||
|
GEMINI_MODEL="gemini-2.5-pro"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Claude Code:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude
|
claude
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Use Gemini CLI:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Daily Maintenance
|
## 🔧 Daily Maintenance
|
||||||
|
|||||||
@@ -924,86 +924,104 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
res.setHeader('X-Accel-Buffering', 'no')
|
res.setHeader('X-Accel-Buffering', 'no')
|
||||||
|
|
||||||
// 处理流式响应并捕获usage数据
|
// 处理流式响应并捕获usage数据
|
||||||
let streamBuffer = '' // 统一的流处理缓冲区
|
// 方案 A++:透明转发 + 异步 usage 提取 + SSE 心跳机制
|
||||||
|
let streamBuffer = '' // 缓冲区用于处理不完整的行
|
||||||
let totalUsage = {
|
let totalUsage = {
|
||||||
promptTokenCount: 0,
|
promptTokenCount: 0,
|
||||||
candidatesTokenCount: 0,
|
candidatesTokenCount: 0,
|
||||||
totalTokenCount: 0
|
totalTokenCount: 0
|
||||||
}
|
}
|
||||||
const usageReported = false
|
let usageReported = false // 修复:改为 let 以便后续修改
|
||||||
|
|
||||||
|
// SSE 心跳机制:防止 Clash 等代理 120 秒超时
|
||||||
|
let heartbeatTimer = null
|
||||||
|
let lastDataTime = Date.now()
|
||||||
|
const HEARTBEAT_INTERVAL = 15000 // 15 秒
|
||||||
|
|
||||||
|
const sendHeartbeat = () => {
|
||||||
|
const timeSinceLastData = Date.now() - lastDataTime
|
||||||
|
if (timeSinceLastData >= HEARTBEAT_INTERVAL && !res.destroyed) {
|
||||||
|
res.write('\n') // 发送空行保持连接活跃
|
||||||
|
logger.info(`💓 Sent SSE keepalive (gap: ${(timeSinceLastData / 1000).toFixed(1)}s)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL)
|
||||||
|
|
||||||
streamResponse.on('data', (chunk) => {
|
streamResponse.on('data', (chunk) => {
|
||||||
try {
|
try {
|
||||||
const chunkStr = chunk.toString()
|
// 更新最后数据时间
|
||||||
|
lastDataTime = Date.now()
|
||||||
|
|
||||||
if (!chunkStr.trim()) {
|
// 1️⃣ 立即转发原始数据(零延迟,最高优先级)
|
||||||
return
|
// 对所有版本(v1beta 和 v1internal)都采用透明转发
|
||||||
|
if (!res.destroyed) {
|
||||||
|
res.write(chunk) // 直接转发 Buffer,无需转换和序列化
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用统一缓冲区处理不完整的行
|
// 2️⃣ 异步提取 usage 数据(不阻塞转发)
|
||||||
streamBuffer += chunkStr
|
// 使用 setImmediate 将解析放到下一个事件循环
|
||||||
const lines = streamBuffer.split('\n')
|
setImmediate(() => {
|
||||||
streamBuffer = lines.pop() || '' // 保留最后一个不完整的行
|
try {
|
||||||
|
const chunkStr = chunk.toString()
|
||||||
|
if (!chunkStr.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const processedLines = []
|
// 快速检查是否包含 usage 数据(避免不必要的解析)
|
||||||
|
if (!chunkStr.includes('usageMetadata')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
// 处理不完整的行
|
||||||
if (!line.trim()) {
|
streamBuffer += chunkStr
|
||||||
continue // 跳过空行,不添加到处理队列
|
const lines = streamBuffer.split('\n')
|
||||||
}
|
streamBuffer = lines.pop() || ''
|
||||||
|
|
||||||
// 解析 SSE 行
|
// 仅解析包含 usage 的行
|
||||||
const parsed = parseSSELine(line)
|
for (const line of lines) {
|
||||||
|
if (!line.trim() || !line.includes('usageMetadata')) {
|
||||||
// 提取 usage 数据(适用于所有版本)
|
continue
|
||||||
if (parsed.type === 'data' && parsed.data.response?.usageMetadata) {
|
|
||||||
totalUsage = parsed.data.response.usageMetadata
|
|
||||||
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)}`)
|
|
||||||
}
|
}
|
||||||
} else if (parsed.type === 'control') {
|
|
||||||
// 控制消息(如 [DONE])保持原样
|
|
||||||
processedLines.push(line)
|
|
||||||
}
|
|
||||||
// 跳过其他类型的行('other', 'invalid')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送数据到客户端
|
try {
|
||||||
if (version === 'v1beta') {
|
const parsed = parseSSELine(line)
|
||||||
for (const line of processedLines) {
|
if (parsed.type === 'data' && parsed.data.response?.usageMetadata) {
|
||||||
if (!res.destroyed) {
|
totalUsage = parsed.data.response.usageMetadata
|
||||||
res.write(`${line}\n\n`)
|
logger.debug('📊 Captured Gemini usage data:', totalUsage)
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
// 解析失败但不影响转发
|
||||||
|
logger.warn('⚠️ Failed to parse usage line:', parseError.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 提取失败但不影响转发
|
||||||
|
logger.warn('⚠️ Error extracting usage data:', error.message)
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
// v1internal 直接转发原始数据
|
|
||||||
if (!res.destroyed) {
|
|
||||||
res.write(chunkStr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error processing stream chunk:', error)
|
logger.error('Error processing stream chunk:', error)
|
||||||
|
// 不中断流,继续处理后续数据
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
streamResponse.on('end', async () => {
|
streamResponse.on('end', () => {
|
||||||
logger.info('Stream completed successfully')
|
logger.info('Stream completed successfully')
|
||||||
|
|
||||||
// 记录使用统计
|
// 清理心跳定时器
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer)
|
||||||
|
heartbeatTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即结束响应,不阻塞
|
||||||
|
res.end()
|
||||||
|
|
||||||
|
// 异步记录使用统计(不阻塞响应)
|
||||||
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
if (!usageReported && totalUsage.totalTokenCount > 0) {
|
||||||
try {
|
Promise.all([
|
||||||
await apiKeyService.recordUsage(
|
apiKeyService.recordUsage(
|
||||||
req.apiKey.id,
|
req.apiKey.id,
|
||||||
totalUsage.promptTokenCount || 0,
|
totalUsage.promptTokenCount || 0,
|
||||||
totalUsage.candidatesTokenCount || 0,
|
totalUsage.candidatesTokenCount || 0,
|
||||||
@@ -1011,12 +1029,8 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
0, // cacheReadTokens
|
0, // cacheReadTokens
|
||||||
model,
|
model,
|
||||||
account.id
|
account.id
|
||||||
)
|
),
|
||||||
logger.info(
|
applyRateLimitTracking(
|
||||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
|
||||||
)
|
|
||||||
|
|
||||||
await applyRateLimitTracking(
|
|
||||||
req,
|
req,
|
||||||
{
|
{
|
||||||
inputTokens: totalUsage.promptTokenCount || 0,
|
inputTokens: totalUsage.promptTokenCount || 0,
|
||||||
@@ -1027,17 +1041,30 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
model,
|
model,
|
||||||
'gemini-stream'
|
'gemini-stream'
|
||||||
)
|
)
|
||||||
} catch (error) {
|
])
|
||||||
logger.error('Failed to record Gemini usage:', error)
|
.then(() => {
|
||||||
}
|
logger.info(
|
||||||
|
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
||||||
|
)
|
||||||
|
usageReported = true
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error('Failed to record Gemini usage:', error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
res.end()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
streamResponse.on('error', (error) => {
|
streamResponse.on('error', (error) => {
|
||||||
logger.error('Stream error:', error)
|
logger.error('Stream error:', error)
|
||||||
|
|
||||||
|
// 清理心跳定时器
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer)
|
||||||
|
heartbeatTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
|
// 如果还没发送响应头,可以返回正常的错误响应
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Stream error',
|
message: error.message || 'Stream error',
|
||||||
@@ -1045,6 +1072,27 @@ async function handleStreamGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
// 如果已经开始流式传输,发送 SSE 格式的错误事件和结束标记
|
||||||
|
// 这样客户端可以正确识别流的结束,避免 "Premature close" 错误
|
||||||
|
if (!res.destroyed) {
|
||||||
|
try {
|
||||||
|
// 发送错误事件(SSE 格式)
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Stream error',
|
||||||
|
type: 'stream_error',
|
||||||
|
code: error.code
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 发送 SSE 结束标记
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Error sending error event:', writeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
candidatesTokenCount: 0,
|
candidatesTokenCount: 0,
|
||||||
totalTokenCount: 0
|
totalTokenCount: 0
|
||||||
}
|
}
|
||||||
const usageReported = false
|
let usageReported = false // 修复:改为 let 以便后续修改
|
||||||
|
|
||||||
streamResponse.on('data', (chunk) => {
|
streamResponse.on('data', (chunk) => {
|
||||||
try {
|
try {
|
||||||
@@ -512,6 +512,9 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 修复:标记 usage 已上报,避免重复上报
|
||||||
|
usageReported = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to record Gemini usage:', error)
|
logger.error('Failed to record Gemini usage:', error)
|
||||||
}
|
}
|
||||||
@@ -534,8 +537,23 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 如果已经开始发送流数据,发送错误事件
|
// 如果已经开始发送流数据,发送错误事件
|
||||||
res.write(`data: {"error": {"message": "${error.message || 'Stream error'}"}}\n\n`)
|
// 修复:使用 JSON.stringify 避免字符串插值导致的格式错误
|
||||||
res.write('data: [DONE]\n\n')
|
if (!res.destroyed) {
|
||||||
|
try {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Stream error',
|
||||||
|
type: 'stream_error',
|
||||||
|
code: error.code
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Error sending error event:', writeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -510,76 +510,102 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
res.setHeader('X-Accel-Buffering', 'no')
|
res.setHeader('X-Accel-Buffering', 'no')
|
||||||
|
|
||||||
// 处理流式响应并捕获usage数据
|
// 处理流式响应并捕获usage数据
|
||||||
let streamBuffer = '' // 统一的流处理缓冲区
|
// 方案 A++:透明转发 + 异步 usage 提取 + SSE 心跳机制
|
||||||
|
let streamBuffer = '' // 缓冲区用于处理不完整的行
|
||||||
let totalUsage = {
|
let totalUsage = {
|
||||||
promptTokenCount: 0,
|
promptTokenCount: 0,
|
||||||
candidatesTokenCount: 0,
|
candidatesTokenCount: 0,
|
||||||
totalTokenCount: 0
|
totalTokenCount: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSE 心跳机制:防止 Clash 等代理 120 秒超时
|
||||||
|
let heartbeatTimer = null
|
||||||
|
let lastDataTime = Date.now()
|
||||||
|
const HEARTBEAT_INTERVAL = 15000 // 15 秒
|
||||||
|
|
||||||
|
const sendHeartbeat = () => {
|
||||||
|
const timeSinceLastData = Date.now() - lastDataTime
|
||||||
|
if (timeSinceLastData >= HEARTBEAT_INTERVAL && !res.destroyed) {
|
||||||
|
res.write('\n') // 发送空行保持连接活跃
|
||||||
|
logger.info(`💓 Sent SSE keepalive (gap: ${(timeSinceLastData / 1000).toFixed(1)}s)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL)
|
||||||
|
|
||||||
streamResponse.on('data', (chunk) => {
|
streamResponse.on('data', (chunk) => {
|
||||||
try {
|
try {
|
||||||
const chunkStr = chunk.toString()
|
// 更新最后数据时间
|
||||||
|
lastDataTime = Date.now()
|
||||||
|
|
||||||
if (!chunkStr.trim()) {
|
// 1️⃣ 立即转发原始数据(零延迟,最高优先级)
|
||||||
return
|
if (!res.destroyed) {
|
||||||
|
res.write(chunk) // 直接转发 Buffer,无需转换和序列化
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用统一缓冲区处理不完整的行
|
// 2️⃣ 异步提取 usage 数据(不阻塞转发)
|
||||||
streamBuffer += chunkStr
|
// 使用 setImmediate 将解析放到下一个事件循环
|
||||||
const lines = streamBuffer.split('\n')
|
setImmediate(() => {
|
||||||
streamBuffer = lines.pop() || '' // 保留最后一个不完整的行
|
try {
|
||||||
|
const chunkStr = chunk.toString()
|
||||||
for (const line of lines) {
|
if (!chunkStr.trim()) {
|
||||||
if (!line.trim()) {
|
return
|
||||||
continue // 跳过空行
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 SSE 行
|
|
||||||
const parsed = parseSSELine(line)
|
|
||||||
|
|
||||||
// 记录无效的解析(用于调试)
|
|
||||||
if (parsed.type === 'invalid') {
|
|
||||||
logger.warn('Failed to parse SSE line:', {
|
|
||||||
line: parsed.line.substring(0, 100),
|
|
||||||
error: parsed.error.message
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 捕获 usage 数据
|
|
||||||
if (parsed.type === 'data' && parsed.data.response?.usageMetadata) {
|
|
||||||
totalUsage = parsed.data.response.usageMetadata
|
|
||||||
logger.debug('📊 Captured Gemini usage data:', totalUsage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换格式并发送
|
|
||||||
if (!res.destroyed) {
|
|
||||||
if (parsed.type === 'data') {
|
|
||||||
// 转换格式:移除 response 包装,直接返回标准 Gemini API 格式
|
|
||||||
if (parsed.data.response) {
|
|
||||||
res.write(`data: ${JSON.stringify(parsed.data.response)}\n\n`)
|
|
||||||
} else {
|
|
||||||
res.write(`data: ${JSON.stringify(parsed.data)}\n\n`)
|
|
||||||
}
|
|
||||||
} else if (parsed.type === 'control') {
|
|
||||||
// 保持控制消息(如 [DONE])原样
|
|
||||||
res.write(`${parsed.line}\n\n`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 快速检查是否包含 usage 数据(避免不必要的解析)
|
||||||
|
if (!chunkStr.includes('usageMetadata')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理不完整的行
|
||||||
|
streamBuffer += chunkStr
|
||||||
|
const lines = streamBuffer.split('\n')
|
||||||
|
streamBuffer = lines.pop() || ''
|
||||||
|
|
||||||
|
// 仅解析包含 usage 的行
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim() || !line.includes('usageMetadata')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseSSELine(line)
|
||||||
|
if (parsed.type === 'data' && parsed.data.response?.usageMetadata) {
|
||||||
|
totalUsage = parsed.data.response.usageMetadata
|
||||||
|
logger.debug('📊 Captured Gemini usage data:', totalUsage)
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
// 解析失败但不影响转发
|
||||||
|
logger.warn('⚠️ Failed to parse usage line:', parseError.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 提取失败但不影响转发
|
||||||
|
logger.warn('⚠️ Error extracting usage data:', error.message)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error processing stream chunk:', error)
|
logger.error('Error processing stream chunk:', error)
|
||||||
|
// 不中断流,继续处理后续数据
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
streamResponse.on('end', async () => {
|
streamResponse.on('end', () => {
|
||||||
logger.info('Stream completed successfully')
|
logger.info('Stream completed successfully')
|
||||||
|
|
||||||
// 记录使用统计
|
// 清理心跳定时器
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer)
|
||||||
|
heartbeatTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即结束响应,不阻塞
|
||||||
|
res.end()
|
||||||
|
|
||||||
|
// 异步记录使用统计(不阻塞响应)
|
||||||
if (totalUsage.totalTokenCount > 0) {
|
if (totalUsage.totalTokenCount > 0) {
|
||||||
try {
|
apiKeyService
|
||||||
await apiKeyService.recordUsage(
|
.recordUsage(
|
||||||
req.apiKey.id,
|
req.apiKey.id,
|
||||||
totalUsage.promptTokenCount || 0,
|
totalUsage.promptTokenCount || 0,
|
||||||
totalUsage.candidatesTokenCount || 0,
|
totalUsage.candidatesTokenCount || 0,
|
||||||
@@ -588,24 +614,32 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
model,
|
model,
|
||||||
account.id
|
account.id
|
||||||
)
|
)
|
||||||
logger.info(
|
.then(() => {
|
||||||
`📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}`
|
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)
|
})
|
||||||
}
|
.catch((error) => {
|
||||||
|
logger.error('Failed to record Gemini usage:', error)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Stream completed without usage data - totalTokenCount: ${totalUsage.totalTokenCount}`
|
`⚠️ Stream completed without usage data - totalTokenCount: ${totalUsage.totalTokenCount}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.end()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
streamResponse.on('error', (error) => {
|
streamResponse.on('error', (error) => {
|
||||||
logger.error('Stream error:', error)
|
logger.error('Stream error:', error)
|
||||||
|
|
||||||
|
// 清理心跳定时器
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer)
|
||||||
|
heartbeatTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
|
// 如果还没发送响应头,可以返回正常的错误响应
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: {
|
error: {
|
||||||
message: error.message || 'Stream error',
|
message: error.message || 'Stream error',
|
||||||
@@ -613,6 +647,27 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
// 如果已经开始流式传输,发送 SSE 格式的错误事件和结束标记
|
||||||
|
// 这样客户端可以正确识别流的结束,避免 "Premature close" 错误
|
||||||
|
if (!res.destroyed) {
|
||||||
|
try {
|
||||||
|
// 发送错误事件(SSE 格式)
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Stream error',
|
||||||
|
type: 'stream_error',
|
||||||
|
code: error.code
|
||||||
|
}
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 发送 SSE 结束标记
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Error sending error event:', writeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
res.end()
|
res.end()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const redisClient = require('../models/redis')
|
const redisClient = require('../models/redis')
|
||||||
const { v4: uuidv4 } = require('uuid')
|
const { v4: uuidv4 } = require('uuid')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
const https = require('https')
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const { OAuth2Client } = require('google-auth-library')
|
const { OAuth2Client } = require('google-auth-library')
|
||||||
@@ -21,6 +22,18 @@ const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.goog
|
|||||||
const OAUTH_CLIENT_SECRET = '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'
|
const OAUTH_CLIENT_SECRET = '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'
|
||||||
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
||||||
|
|
||||||
|
// 🌐 TCP Keep-Alive Agent 配置
|
||||||
|
// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题
|
||||||
|
const keepAliveAgent = new https.Agent({
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveMsecs: 30000, // 每30秒发送一次 keep-alive 探测
|
||||||
|
timeout: 120000, // 120秒连接超时
|
||||||
|
maxSockets: 100, // 最大并发连接数
|
||||||
|
maxFreeSockets: 10 // 保持的空闲连接数
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support')
|
||||||
|
|
||||||
// 加密相关常量
|
// 加密相关常量
|
||||||
const ALGORITHM = 'aes-256-cbc'
|
const ALGORITHM = 'aes-256-cbc'
|
||||||
const ENCRYPTION_SALT = 'gemini-account-salt'
|
const ENCRYPTION_SALT = 'gemini-account-salt'
|
||||||
@@ -1472,7 +1485,7 @@ async function generateContent(
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
data: request,
|
data: request,
|
||||||
timeout: 60000 // 生成内容可能需要更长时间
|
timeout: 600000 // 生成内容可能需要更长时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加代理配置
|
// 添加代理配置
|
||||||
@@ -1485,7 +1498,10 @@ async function generateContent(
|
|||||||
`🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
`🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
logger.debug('🌐 No proxy configured for Gemini generateContent')
|
// 没有代理时,使用 keepAlive agent 防止长时间请求被中断
|
||||||
|
axiosConfig.httpsAgent = keepAliveAgent
|
||||||
|
axiosConfig.httpAgent = keepAliveAgent
|
||||||
|
logger.debug('🌐 Using keepAlive agent for Gemini generateContent')
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios(axiosConfig)
|
const response = await axios(axiosConfig)
|
||||||
@@ -1548,7 +1564,7 @@ async function generateContentStream(
|
|||||||
},
|
},
|
||||||
data: request,
|
data: request,
|
||||||
responseType: 'stream',
|
responseType: 'stream',
|
||||||
timeout: 60000
|
timeout: 0 // 流式请求不设置超时限制,由 keepAlive 和 AbortSignal 控制
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加代理配置
|
// 添加代理配置
|
||||||
@@ -1561,7 +1577,10 @@ async function generateContentStream(
|
|||||||
`🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
`🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}`
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
logger.debug('🌐 No proxy configured for Gemini streamGenerateContent')
|
// 没有代理时,使用 keepAlive agent 防止长时间流式请求被中断
|
||||||
|
axiosConfig.httpsAgent = keepAliveAgent
|
||||||
|
axiosConfig.httpAgent = keepAliveAgent
|
||||||
|
logger.debug('🌐 Using keepAlive agent for Gemini streamGenerateContent')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果提供了中止信号,添加到配置中
|
// 如果提供了中止信号,添加到配置中
|
||||||
|
|||||||
Reference in New Issue
Block a user