mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e6944abb | ||
|
|
4ea3d4830f | ||
|
|
3000632d4e | ||
|
|
9e3a4cf45a | ||
|
|
eb992697b6 | ||
|
|
35ab34d687 | ||
|
|
bc4b050c69 | ||
|
|
189d53d793 | ||
|
|
b148537428 | ||
|
|
9d1a451027 | ||
|
|
ba815de08f | ||
|
|
b26027731e | ||
|
|
f535b35a1c | ||
|
|
962e01b080 | ||
|
|
fcc6ac4e22 | ||
|
|
3a03147ac9 | ||
|
|
94f239b56a | ||
|
|
b07873772c | ||
|
|
549c95eb80 | ||
|
|
b397954ea4 | ||
|
|
ed835d0c28 | ||
|
|
28b27e6a7b | ||
|
|
810fe9fe90 | ||
|
|
141b07db78 | ||
|
|
1dad810d15 | ||
|
|
4723328be4 | ||
|
|
944ef096b3 | ||
|
|
114e9facee | ||
|
|
e20ce86ad4 | ||
|
|
6caabb5444 | ||
|
|
b924c3c559 | ||
|
|
6682e0a982 | ||
|
|
b9c088ce58 | ||
|
|
2ff74c21d2 | ||
|
|
8a4dadbbc0 | ||
|
|
adf2890f65 | ||
|
|
7d892a69f1 | ||
|
|
a749ddfede | ||
|
|
dbd4fb19cf | ||
|
|
39ba345a43 | ||
|
|
2693fd77b7 | ||
|
|
3cc3219a90 | ||
|
|
1b834ffcdb | ||
|
|
41999f56b4 | ||
|
|
b81c2b946f | ||
|
|
0a59a0f9d4 | ||
|
|
c4448db6ab | ||
|
|
c67d2bce9d | ||
|
|
a345812cd7 | ||
|
|
a0cbafd759 | ||
|
|
3c64038fa7 | ||
|
|
45b81bd478 | ||
|
|
fc57133230 | ||
|
|
1f06af4a56 | ||
|
|
6165fad090 | ||
|
|
d53a399d41 | ||
|
|
18a493e805 |
40
.env.example
40
.env.example
@@ -53,20 +53,38 @@ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-20
|
|||||||
# - /antigravity/api -> Antigravity OAuth
|
# - /antigravity/api -> Antigravity OAuth
|
||||||
# - /gemini-cli/api -> Gemini CLI OAuth
|
# - /gemini-cli/api -> Gemini CLI OAuth
|
||||||
|
|
||||||
# (可选)Claude Code 调试 Dump:会在项目根目录写入 jsonl 文件,便于排查 tools/schema/回包问题
|
# ============================================================================
|
||||||
# - anthropic-requests-dump.jsonl
|
# 🐛 调试 Dump 配置(可选)
|
||||||
# - anthropic-responses-dump.jsonl
|
# ============================================================================
|
||||||
# - anthropic-tools-dump.jsonl
|
# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。
|
||||||
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
|
# ⚠️ 生产环境建议关闭,避免磁盘占用。
|
||||||
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
|
|
||||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
|
|
||||||
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
|
|
||||||
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
|
|
||||||
#
|
#
|
||||||
# (可选)Antigravity 上游请求 Dump:会在项目根目录写入 jsonl 文件,便于核对最终发往上游的 payload(含 tools/schema 清洗后的结果)
|
# 📄 输出文件列表:
|
||||||
# - antigravity-upstream-requests-dump.jsonl
|
# - anthropic-requests-dump.jsonl (客户端请求)
|
||||||
|
# - anthropic-responses-dump.jsonl (返回给客户端的响应)
|
||||||
|
# - anthropic-tools-dump.jsonl (工具定义快照)
|
||||||
|
# - antigravity-upstream-requests-dump.jsonl (发往上游的请求)
|
||||||
|
# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应)
|
||||||
|
#
|
||||||
|
# 📌 开关配置:
|
||||||
|
# ANTHROPIC_DEBUG_REQUEST_DUMP=true
|
||||||
|
# ANTHROPIC_DEBUG_RESPONSE_DUMP=true
|
||||||
|
# ANTHROPIC_DEBUG_TOOLS_DUMP=true
|
||||||
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
|
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true
|
||||||
|
# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true
|
||||||
|
#
|
||||||
|
# 📏 单条记录大小上限(字节),默认 2MB:
|
||||||
|
# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152
|
||||||
|
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
|
||||||
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
|
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152
|
||||||
|
#
|
||||||
|
# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB:
|
||||||
|
# DUMP_MAX_FILE_SIZE_BYTES=10485760
|
||||||
|
#
|
||||||
|
# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务
|
||||||
|
# (仅 /antigravity/api 分流生效)
|
||||||
|
# ANTHROPIC_TOOL_ERROR_CONTINUE=true
|
||||||
|
|
||||||
|
|
||||||
# 🚫 529错误处理配置
|
# 🚫 529错误处理配置
|
||||||
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟)
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -1,9 +1,9 @@
|
|||||||
# Claude Relay Service
|
# Claude Relay Service
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **安全更新通知**:v1.1.240 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
|
> **安全更新通知**:v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。
|
||||||
>
|
>
|
||||||
> **请立即更新到 v1.1.241+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -394,29 +394,32 @@ docker-compose.yml 已包含:
|
|||||||
|
|
||||||
**Claude Code 设置环境变量:**
|
**Claude Code 设置环境变量:**
|
||||||
|
|
||||||
默认使用标准 Claude 账号池(Claude/Console/Bedrock/CCR):
|
|
||||||
|
**使用标准 Claude 账号池**
|
||||||
|
|
||||||
|
默认使用标准 Claude 账号池:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
||||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||||
```
|
```
|
||||||
|
|
||||||
如果希望 Claude Code 通过 Anthropic 协议直接使用 Gemini OAuth 账号池(路径分流,不需要在模型名里加前缀):
|
**使用 Antigravity 账户池**
|
||||||
|
|
||||||
Antigravity OAuth(支持 `claude-opus-4-5` 等 Antigravity 模型):
|
适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. 设置 Base URL 为 Antigravity 专用路径
|
||||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
||||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥(permissions 需要是 all 或 gemini)"
|
|
||||||
|
# 2. 设置 API Key(在后台创建,权限需包含 'all' 或 'gemini')
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
||||||
|
|
||||||
|
# 3. 指定模型名称(直接使用短名,无需前缀!)
|
||||||
export ANTHROPIC_MODEL="claude-opus-4-5"
|
export ANTHROPIC_MODEL="claude-opus-4-5"
|
||||||
```
|
|
||||||
|
|
||||||
Gemini CLI OAuth(使用 Gemini 模型):
|
# 4. 启动
|
||||||
|
claude
|
||||||
```bash
|
|
||||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
|
|
||||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥(permissions 需要是 all 或 gemini)"
|
|
||||||
export ANTHROPIC_MODEL="gemini-2.5-pro"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**VSCode Claude 插件配置:**
|
**VSCode Claude 插件配置:**
|
||||||
@@ -622,6 +625,7 @@ gpt-5 # Codex使用固定模型ID
|
|||||||
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
- 所有账号类型都使用相同的API密钥(在后台统一创建)
|
||||||
- 根据不同的路由前缀自动识别账号类型
|
- 根据不同的路由前缀自动识别账号类型
|
||||||
- `/claude/` - 使用Claude账号池
|
- `/claude/` - 使用Claude账号池
|
||||||
|
- `/antigravity/api/` - 使用Antigravity账号池(推荐用于Claude Code)
|
||||||
- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
|
- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用)
|
||||||
- `/gemini/` - 使用Gemini账号池
|
- `/gemini/` - 使用Gemini账号池
|
||||||
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
- `/openai/` - 使用Codex账号(只支持Openai-Response格式)
|
||||||
|
|||||||
24
README_EN.md
24
README_EN.md
@@ -1,9 +1,9 @@
|
|||||||
# Claude Relay Service
|
# Claude Relay Service
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **Security Update**: v1.1.240 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
|
> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
|
||||||
>
|
>
|
||||||
> **Please update to v1.1.241+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
> **Please update to v1.1.249+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -243,31 +243,13 @@ Now you can replace the official API with your own service:
|
|||||||
|
|
||||||
**Claude Code Set Environment Variables:**
|
**Claude Code Set Environment Variables:**
|
||||||
|
|
||||||
Default uses standard Claude account pool (Claude/Console/Bedrock/CCR):
|
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
|
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"
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want Claude Code to use Gemini OAuth accounts via the Anthropic protocol (path-based routing, no vendor prefix in `model`):
|
|
||||||
|
|
||||||
Antigravity OAuth (supports `claude-opus-4-5` and other Antigravity models):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/"
|
|
||||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
|
|
||||||
export ANTHROPIC_MODEL="claude-opus-4-5"
|
|
||||||
```
|
|
||||||
|
|
||||||
Gemini CLI OAuth (Gemini models):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/gemini-cli/api/"
|
|
||||||
export ANTHROPIC_AUTH_TOKEN="API key created in the backend (permissions must be all or gemini)"
|
|
||||||
export ANTHROPIC_MODEL="gemini-2.5-pro"
|
|
||||||
```
|
|
||||||
|
|
||||||
**VSCode Claude Plugin Configuration:**
|
**VSCode Claude Plugin Configuration:**
|
||||||
|
|
||||||
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
|
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
|
||||||
|
|||||||
21
SECURITY.md
Normal file
21
SECURITY.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Use this section to tell people about which versions of your project are
|
||||||
|
currently being supported with security updates.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 5.1.x | :white_check_mark: |
|
||||||
|
| 5.0.x | :x: |
|
||||||
|
| 4.0.x | :white_check_mark: |
|
||||||
|
| < 4.0 | :x: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Use this section to tell people how to report a vulnerability.
|
||||||
|
|
||||||
|
Tell them where to go, how often they can expect to get an update on a
|
||||||
|
reported vulnerability, what to expect if the vulnerability is accepted or
|
||||||
|
declined, etc.
|
||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"google-auth-library": "^10.1.0",
|
"google-auth-library": "^10.1.0",
|
||||||
|
"heapdump": "^0.3.15",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"https-proxy-agent": "^7.0.2",
|
"https-proxy-agent": "^7.0.2",
|
||||||
"inquirer": "^8.2.6",
|
"inquirer": "^8.2.6",
|
||||||
@@ -5398,6 +5399,19 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/heapdump": {
|
||||||
|
"version": "0.3.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.15.tgz",
|
||||||
|
"integrity": "sha512-n8aSFscI9r3gfhOcAECAtXFaQ1uy4QSke6bnaL+iymYZ/dWs9cqDqHM+rALfsHUwukUbxsdlECZ0pKmJdQ/4OA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"nan": "^2.13.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/helmet": {
|
"node_modules/helmet": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz",
|
||||||
@@ -7013,6 +7027,12 @@
|
|||||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/nan": {
|
||||||
|
"version": "2.24.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
|
||||||
|
"integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ class Application {
|
|||||||
// 🔧 基础中间件
|
// 🔧 基础中间件
|
||||||
this.app.use(
|
this.app.use(
|
||||||
express.json({
|
express.json({
|
||||||
limit: '10mb',
|
limit: '100mb',
|
||||||
verify: (req, res, buf, encoding) => {
|
verify: (req, res, buf, encoding) => {
|
||||||
// 验证JSON格式
|
// 验证JSON格式
|
||||||
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
||||||
@@ -188,7 +188,7 @@ class Application {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
this.app.use(express.urlencoded({ extended: true, limit: '100mb' }))
|
||||||
this.app.use(securityMiddleware)
|
this.app.use(securityMiddleware)
|
||||||
|
|
||||||
// 🎯 信任代理
|
// 🎯 信任代理
|
||||||
|
|||||||
@@ -862,7 +862,7 @@ async function handleKeyInfo(req, res) {
|
|||||||
res.json({
|
res.json({
|
||||||
id: keyData.id,
|
id: keyData.id,
|
||||||
name: keyData.name,
|
name: keyData.name,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions,
|
||||||
token_limit: keyData.tokenLimit,
|
token_limit: keyData.tokenLimit,
|
||||||
tokens_used: keyData.usage.total.tokens,
|
tokens_used: keyData.usage.total.tokens,
|
||||||
tokens_remaining:
|
tokens_remaining:
|
||||||
@@ -1188,6 +1188,110 @@ async function handleOnboardUser(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 retrieveUserQuota 请求
|
||||||
|
* POST /v1internal:retrieveUserQuota
|
||||||
|
*
|
||||||
|
* 功能:查询用户在各个Gemini模型上的配额使用情况
|
||||||
|
* 请求体:{ "project": "项目ID" }
|
||||||
|
* 响应:{ "buckets": [...] }
|
||||||
|
*/
|
||||||
|
async function handleRetrieveUserQuota(req, res) {
|
||||||
|
try {
|
||||||
|
// 1. 权限检查
|
||||||
|
if (!ensureGeminiPermission(req, res)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 会话哈希
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 3. 账户选择
|
||||||
|
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||||
|
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||||
|
req.apiKey,
|
||||||
|
sessionHash,
|
||||||
|
requestedModel
|
||||||
|
)
|
||||||
|
const { accountId, accountType } = schedulerResult
|
||||||
|
|
||||||
|
// 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户
|
||||||
|
if (accountType === 'gemini-api') {
|
||||||
|
logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message:
|
||||||
|
'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.',
|
||||||
|
type: 'invalid_account_type'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 获取账户
|
||||||
|
const account = await geminiAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Gemini account not found',
|
||||||
|
type: 'account_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const { accessToken, refreshToken, projectId } = account
|
||||||
|
|
||||||
|
// 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject")
|
||||||
|
const requestProject = req.body.project
|
||||||
|
|
||||||
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
|
logger.info(`RetrieveUserQuota request (${version})`, {
|
||||||
|
requestedProject: requestProject || null,
|
||||||
|
accountProject: projectId || null,
|
||||||
|
apiKeyId: req.apiKey?.id || 'unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. 解析账户的代理配置
|
||||||
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
|
// 8. 获取OAuth客户端
|
||||||
|
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||||
|
|
||||||
|
// 9. 智能处理项目ID(与其他 v1internal 接口保持一致)
|
||||||
|
const effectiveProject = projectId || requestProject || null
|
||||||
|
|
||||||
|
logger.info('📋 retrieveUserQuota项目ID处理逻辑', {
|
||||||
|
accountProjectId: projectId,
|
||||||
|
requestProject,
|
||||||
|
effectiveProject,
|
||||||
|
decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 10. 构建请求体(注入 effectiveProject)
|
||||||
|
const requestBody = { ...req.body }
|
||||||
|
if (effectiveProject) {
|
||||||
|
requestBody.project = effectiveProject
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. 调用底层服务转发请求
|
||||||
|
const response = await geminiAccountService.forwardToCodeAssist(
|
||||||
|
client,
|
||||||
|
'retrieveUserQuota',
|
||||||
|
requestBody,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json(response)
|
||||||
|
} catch (error) {
|
||||||
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
|
logger.error(`Error in retrieveUserQuota endpoint (${version})`, {
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 countTokens 请求
|
* 处理 countTokens 请求
|
||||||
*/
|
*/
|
||||||
@@ -2698,6 +2802,7 @@ module.exports = {
|
|||||||
handleSimpleEndpoint,
|
handleSimpleEndpoint,
|
||||||
handleLoadCodeAssist,
|
handleLoadCodeAssist,
|
||||||
handleOnboardUser,
|
handleOnboardUser,
|
||||||
|
handleRetrieveUserQuota,
|
||||||
handleCountTokens,
|
handleCountTokens,
|
||||||
handleGenerateContent,
|
handleGenerateContent,
|
||||||
handleStreamGenerateContent,
|
handleStreamGenerateContent,
|
||||||
|
|||||||
@@ -1434,7 +1434,6 @@ const authenticateAdmin = async (req, res, next) => {
|
|||||||
|
|
||||||
// 设置管理员信息(只包含必要信息)
|
// 设置管理员信息(只包含必要信息)
|
||||||
req.admin = {
|
req.admin = {
|
||||||
id: adminSession.adminId || 'admin',
|
|
||||||
username: adminSession.username,
|
username: adminSession.username,
|
||||||
sessionId: token,
|
sessionId: token,
|
||||||
loginTime: adminSession.loginTime
|
loginTime: adminSession.loginTime
|
||||||
@@ -1567,8 +1566,15 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const adminSession = await redis.getSession(adminToken)
|
const adminSession = await redis.getSession(adminToken)
|
||||||
if (adminSession && Object.keys(adminSession).length > 0) {
|
if (adminSession && Object.keys(adminSession).length > 0) {
|
||||||
|
// 🔒 安全修复:验证会话必须字段(与 authenticateAdmin 保持一致)
|
||||||
|
if (!adminSession.username || !adminSession.loginTime) {
|
||||||
|
logger.security(
|
||||||
|
`🔒 Corrupted admin session in authenticateUserOrAdmin from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
|
||||||
|
)
|
||||||
|
await redis.deleteSession(adminToken) // 清理无效/伪造的会话
|
||||||
|
// 不返回 401,继续尝试用户认证
|
||||||
|
} else {
|
||||||
req.admin = {
|
req.admin = {
|
||||||
id: adminSession.adminId || 'admin',
|
|
||||||
username: adminSession.username,
|
username: adminSession.username,
|
||||||
sessionId: adminToken,
|
sessionId: adminToken,
|
||||||
loginTime: adminSession.loginTime
|
loginTime: adminSession.loginTime
|
||||||
@@ -1579,6 +1585,7 @@ const authenticateUserOrAdmin = async (req, res, next) => {
|
|||||||
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`)
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug('Admin authentication failed, trying user authentication:', error.message)
|
logger.debug('Admin authentication failed, trying user authentication:', error.message)
|
||||||
}
|
}
|
||||||
@@ -2043,7 +2050,7 @@ const globalRateLimit = async (req, res, next) =>
|
|||||||
|
|
||||||
// 📊 请求大小限制中间件
|
// 📊 请求大小限制中间件
|
||||||
const requestSizeLimit = (req, res, next) => {
|
const requestSizeLimit = (req, res, next) => {
|
||||||
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '60', 10)
|
const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '100', 10)
|
||||||
const maxSize = MAX_SIZE_MB * 1024 * 1024
|
const maxSize = MAX_SIZE_MB * 1024 * 1024
|
||||||
const contentLength = parseInt(req.headers['content-length'] || '0')
|
const contentLength = parseInt(req.headers['content-length'] || '0')
|
||||||
|
|
||||||
@@ -2052,7 +2059,7 @@ const requestSizeLimit = (req, res, next) => {
|
|||||||
return res.status(413).json({
|
return res.status(413).json({
|
||||||
error: 'Payload Too Large',
|
error: 'Payload Too Large',
|
||||||
message: 'Request body size exceeds limit',
|
message: 'Request body size exceeds limit',
|
||||||
limit: '10MB'
|
limit: `${MAX_SIZE_MB}MB`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
description,
|
description,
|
||||||
region,
|
region,
|
||||||
awsCredentials,
|
awsCredentials,
|
||||||
|
bearerToken,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
priority,
|
priority,
|
||||||
accountType,
|
accountType,
|
||||||
@@ -145,9 +146,9 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证credentialType的有效性
|
// 验证credentialType的有效性
|
||||||
if (credentialType && !['default', 'access_key', 'bearer_token'].includes(credentialType)) {
|
if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +157,11 @@ router.post('/', authenticateAdmin, async (req, res) => {
|
|||||||
description: description || '',
|
description: description || '',
|
||||||
region: region || 'us-east-1',
|
region: region || 'us-east-1',
|
||||||
awsCredentials,
|
awsCredentials,
|
||||||
|
bearerToken,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
priority: priority || 50,
|
priority: priority || 50,
|
||||||
accountType: accountType || 'shared',
|
accountType: accountType || 'shared',
|
||||||
credentialType: credentialType || 'default'
|
credentialType: credentialType || 'access_key'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -206,10 +208,10 @@ router.put('/:accountId', authenticateAdmin, async (req, res) => {
|
|||||||
// 验证credentialType的有效性
|
// 验证credentialType的有效性
|
||||||
if (
|
if (
|
||||||
mappedUpdates.credentialType &&
|
mappedUpdates.credentialType &&
|
||||||
!['default', 'access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
!['access_key', 'bearer_token'].includes(mappedUpdates.credentialType)
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid credential type. Must be "default", "access_key", or "bearer_token"'
|
error: 'Invalid credential type. Must be "access_key" or "bearer_token"'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,22 +351,15 @@ router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res)
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 测试Bedrock账户连接
|
// 测试Bedrock账户连接(SSE 流式)
|
||||||
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { accountId } = req.params
|
const { accountId } = req.params
|
||||||
|
|
||||||
const result = await bedrockAccountService.testAccount(accountId)
|
await bedrockAccountService.testAccountConnection(accountId, res)
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return res.status(500).json({ error: 'Account test failed', message: result.error })
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.success(`🧪 Admin tested Bedrock account: ${accountId} - ${result.data.status}`)
|
|
||||||
return res.json({ success: true, data: result.data })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to test Bedrock account:', error)
|
logger.error('❌ Failed to test Bedrock account:', error)
|
||||||
return res.status(500).json({ error: 'Failed to test Bedrock account', message: error.message })
|
// 错误已在服务层处理,这里仅做日志记录
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const geminiApiAccountService = require('../../services/geminiApiAccountService'
|
|||||||
const openaiAccountService = require('../../services/openaiAccountService')
|
const openaiAccountService = require('../../services/openaiAccountService')
|
||||||
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
|
||||||
const droidAccountService = require('../../services/droidAccountService')
|
const droidAccountService = require('../../services/droidAccountService')
|
||||||
|
const bedrockAccountService = require('../../services/bedrockAccountService')
|
||||||
const redis = require('../../models/redis')
|
const redis = require('../../models/redis')
|
||||||
const { authenticateAdmin } = require('../../middleware/auth')
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
const logger = require('../../utils/logger')
|
const logger = require('../../utils/logger')
|
||||||
@@ -25,6 +26,7 @@ const accountTypeNames = {
|
|||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
'gemini-api': 'Gemini API',
|
'gemini-api': 'Gemini API',
|
||||||
droid: 'Droid',
|
droid: 'Droid',
|
||||||
|
bedrock: 'AWS Bedrock',
|
||||||
unknown: '未知渠道'
|
unknown: '未知渠道'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +39,8 @@ const resolveAccountByPlatform = async (accountId, platform) => {
|
|||||||
openai: openaiAccountService,
|
openai: openaiAccountService,
|
||||||
'openai-responses': openaiResponsesAccountService,
|
'openai-responses': openaiResponsesAccountService,
|
||||||
droid: droidAccountService,
|
droid: droidAccountService,
|
||||||
ccr: ccrAccountService
|
ccr: ccrAccountService,
|
||||||
|
bedrock: bedrockAccountService
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform && serviceMap[platform]) {
|
if (platform && serviceMap[platform]) {
|
||||||
@@ -161,7 +164,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
'openai-responses',
|
'openai-responses',
|
||||||
'gemini',
|
'gemini',
|
||||||
'gemini-api',
|
'gemini-api',
|
||||||
'droid'
|
'droid',
|
||||||
|
'bedrock'
|
||||||
]
|
]
|
||||||
if (!allowedPlatforms.includes(platform)) {
|
if (!allowedPlatforms.includes(platform)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -174,7 +178,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
openai: 'openai',
|
openai: 'openai',
|
||||||
'openai-responses': 'openai-responses',
|
'openai-responses': 'openai-responses',
|
||||||
'gemini-api': 'gemini-api',
|
'gemini-api': 'gemini-api',
|
||||||
droid: 'droid'
|
droid: 'droid',
|
||||||
|
bedrock: 'bedrock'
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackModelMap = {
|
const fallbackModelMap = {
|
||||||
@@ -184,7 +189,8 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
'openai-responses': 'gpt-4o-mini-2024-07-18',
|
'openai-responses': 'gpt-4o-mini-2024-07-18',
|
||||||
gemini: 'gemini-1.5-flash',
|
gemini: 'gemini-1.5-flash',
|
||||||
'gemini-api': 'gemini-2.0-flash',
|
'gemini-api': 'gemini-2.0-flash',
|
||||||
droid: 'unknown'
|
droid: 'unknown',
|
||||||
|
bedrock: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取账户信息以获取创建时间
|
// 获取账户信息以获取创建时间
|
||||||
@@ -215,6 +221,11 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
|
|||||||
case 'droid':
|
case 'droid':
|
||||||
accountData = await droidAccountService.getAccount(accountId)
|
accountData = await droidAccountService.getAccount(accountId)
|
||||||
break
|
break
|
||||||
|
case 'bedrock': {
|
||||||
|
const result = await bedrockAccountService.getAccount(accountId)
|
||||||
|
accountData = result?.success ? result.data : null
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountData && accountData.createdAt) {
|
if (accountData && accountData.createdAt) {
|
||||||
@@ -882,7 +893,7 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
|
const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query
|
||||||
|
|
||||||
const allowedGroups = ['claude', 'openai', 'gemini', 'droid']
|
const allowedGroups = ['claude', 'openai', 'gemini', 'droid', 'bedrock']
|
||||||
if (!allowedGroups.includes(group)) {
|
if (!allowedGroups.includes(group)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -894,7 +905,8 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
|||||||
claude: 'Claude账户',
|
claude: 'Claude账户',
|
||||||
openai: 'OpenAI账户',
|
openai: 'OpenAI账户',
|
||||||
gemini: 'Gemini账户',
|
gemini: 'Gemini账户',
|
||||||
droid: 'Droid账户'
|
droid: 'Droid账户',
|
||||||
|
bedrock: 'Bedrock账户'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拉取各平台账号列表
|
// 拉取各平台账号列表
|
||||||
@@ -988,6 +1000,18 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
|
|||||||
platform: 'droid'
|
platform: 'droid'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else if (group === 'bedrock') {
|
||||||
|
const result = await bedrockAccountService.getAllAccounts()
|
||||||
|
const bedrockAccounts = result?.success ? result.data : []
|
||||||
|
accounts = bedrockAccounts.map((account) => {
|
||||||
|
const id = String(account.id || '')
|
||||||
|
const shortId = id ? id.slice(0, 8) : '未知'
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: account.name || `Bedrock账号 ${shortId}`,
|
||||||
|
platform: 'bedrock'
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accounts || accounts.length === 0) {
|
if (!accounts || accounts.length === 0) {
|
||||||
|
|||||||
@@ -122,12 +122,18 @@ async function handleMessagesRequest(req, res) {
|
|||||||
try {
|
try {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
// Claude 服务权限校验,阻止未授权的 Key
|
const forcedVendor = req._anthropicVendor || null
|
||||||
if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) {
|
const requiredService =
|
||||||
|
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
|
||||||
|
|
||||||
|
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
type: 'permission_error',
|
type: 'permission_error',
|
||||||
message: '此 API Key 无权访问 Claude 服务'
|
message:
|
||||||
|
requiredService === 'gemini'
|
||||||
|
? '此 API Key 无权访问 Gemini 服务'
|
||||||
|
: '此 API Key 无权访问 Claude 服务'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -176,7 +182,6 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const forcedVendor = req._anthropicVendor || null
|
|
||||||
logger.api('📥 /v1/messages request received', {
|
logger.api('📥 /v1/messages request received', {
|
||||||
model: req.body.model || null,
|
model: req.body.model || null,
|
||||||
forcedVendor,
|
forcedVendor,
|
||||||
@@ -192,34 +197,10 @@ async function handleMessagesRequest(req, res) {
|
|||||||
|
|
||||||
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||||
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
|
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
|
||||||
const permissions = req.apiKey?.permissions || 'all'
|
|
||||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
|
||||||
return res.status(403).json({
|
|
||||||
error: {
|
|
||||||
type: 'permission_error',
|
|
||||||
message: '此 API Key 无权访问 Gemini 服务'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseModel = (req.body.model || '').trim()
|
const baseModel = (req.body.model || '').trim()
|
||||||
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
|
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude 服务权限校验,阻止未授权的 Key(默认路径保持不变)
|
|
||||||
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 服务'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否为流式请求
|
// 检查是否为流式请求
|
||||||
const isStream = req.body.stream === true
|
const isStream = req.body.stream === true
|
||||||
|
|
||||||
@@ -435,11 +416,18 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// 根据账号类型选择对应的转发服务并调用
|
// 根据账号类型选择对应的转发服务并调用
|
||||||
if (accountType === 'claude-official') {
|
if (accountType === 'claude-official') {
|
||||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||||
|
// 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象
|
||||||
|
const _apiKeyId = req.apiKey.id
|
||||||
|
const _rateLimitInfo = req.rateLimitInfo
|
||||||
|
const _requestBody = req.body // 传递后清除引用
|
||||||
|
const _apiKey = req.apiKey
|
||||||
|
const _headers = req.headers
|
||||||
|
|
||||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||||
req.body,
|
_requestBody,
|
||||||
req.apiKey,
|
_apiKey,
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headers,
|
||||||
(usageData) => {
|
(usageData) => {
|
||||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -489,13 +477,13 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude')
|
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, 'claude')
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record stream usage:', error)
|
logger.error('❌ Failed to record stream usage:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfo,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -520,11 +508,18 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
} else if (accountType === 'claude-console') {
|
} else if (accountType === 'claude-console') {
|
||||||
// Claude Console账号使用Console转发服务(需要传递accountId)
|
// Claude Console账号使用Console转发服务(需要传递accountId)
|
||||||
|
// 🧹 内存优化:提取需要的值
|
||||||
|
const _apiKeyIdConsole = req.apiKey.id
|
||||||
|
const _rateLimitInfoConsole = req.rateLimitInfo
|
||||||
|
const _requestBodyConsole = req.body
|
||||||
|
const _apiKeyConsole = req.apiKey
|
||||||
|
const _headersConsole = req.headers
|
||||||
|
|
||||||
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||||
req.body,
|
_requestBodyConsole,
|
||||||
req.apiKey,
|
_apiKeyConsole,
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersConsole,
|
||||||
(usageData) => {
|
(usageData) => {
|
||||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -575,7 +570,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsageWithDetails(
|
.recordUsageWithDetails(
|
||||||
req.apiKey.id,
|
_apiKeyIdConsole,
|
||||||
usageObject,
|
usageObject,
|
||||||
model,
|
model,
|
||||||
usageAccountId,
|
usageAccountId,
|
||||||
@@ -586,7 +581,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoConsole,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -612,6 +607,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
} else if (accountType === 'bedrock') {
|
} else if (accountType === 'bedrock') {
|
||||||
// Bedrock账号使用Bedrock转发服务
|
// Bedrock账号使用Bedrock转发服务
|
||||||
|
// 🧹 内存优化:提取需要的值
|
||||||
|
const _apiKeyIdBedrock = req.apiKey.id
|
||||||
|
const _rateLimitInfoBedrock = req.rateLimitInfo
|
||||||
|
const _requestBodyBedrock = req.body
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
|
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
|
||||||
if (!bedrockAccountResult.success) {
|
if (!bedrockAccountResult.success) {
|
||||||
@@ -619,7 +619,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await bedrockRelayService.handleStreamRequest(
|
const result = await bedrockRelayService.handleStreamRequest(
|
||||||
req.body,
|
_requestBodyBedrock,
|
||||||
bedrockAccountResult.data,
|
bedrockAccountResult.data,
|
||||||
res
|
res
|
||||||
)
|
)
|
||||||
@@ -630,13 +630,21 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const outputTokens = result.usage.output_tokens || 0
|
const outputTokens = result.usage.output_tokens || 0
|
||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId)
|
.recordUsage(
|
||||||
|
_apiKeyIdBedrock,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
result.model,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record Bedrock stream usage:', error)
|
logger.error('❌ Failed to record Bedrock stream usage:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoBedrock,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -661,11 +669,18 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
} else if (accountType === 'ccr') {
|
} else if (accountType === 'ccr') {
|
||||||
// CCR账号使用CCR转发服务(需要传递accountId)
|
// CCR账号使用CCR转发服务(需要传递accountId)
|
||||||
|
// 🧹 内存优化:提取需要的值
|
||||||
|
const _apiKeyIdCcr = req.apiKey.id
|
||||||
|
const _rateLimitInfoCcr = req.rateLimitInfo
|
||||||
|
const _requestBodyCcr = req.body
|
||||||
|
const _apiKeyCcr = req.apiKey
|
||||||
|
const _headersCcr = req.headers
|
||||||
|
|
||||||
await ccrRelayService.relayStreamRequestWithUsageCapture(
|
await ccrRelayService.relayStreamRequestWithUsageCapture(
|
||||||
req.body,
|
_requestBodyCcr,
|
||||||
req.apiKey,
|
_apiKeyCcr,
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersCcr,
|
||||||
(usageData) => {
|
(usageData) => {
|
||||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -715,13 +730,13 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
|
.recordUsageWithDetails(_apiKeyIdCcr, usageObject, model, usageAccountId, 'ccr')
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record CCR stream usage:', error)
|
logger.error('❌ Failed to record CCR stream usage:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoCcr,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -756,18 +771,26 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
}, 1000) // 1秒后检查
|
}, 1000) // 1秒后检查
|
||||||
} else {
|
} else {
|
||||||
|
// 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req
|
||||||
|
const _apiKeyIdNonStream = req.apiKey.id
|
||||||
|
const _apiKeyNameNonStream = req.apiKey.name
|
||||||
|
const _rateLimitInfoNonStream = req.rateLimitInfo
|
||||||
|
const _requestBodyNonStream = req.body
|
||||||
|
const _apiKeyNonStream = req.apiKey
|
||||||
|
const _headersNonStream = req.headers
|
||||||
|
|
||||||
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
|
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
|
||||||
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
|
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Client disconnected before non-stream request could start for key: ${req.apiKey?.name || 'unknown'}`
|
`⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}`
|
||||||
)
|
)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非流式响应 - 只使用官方真实usage数据
|
// 非流式响应 - 只使用官方真实usage数据
|
||||||
logger.info('📄 Starting non-streaming request', {
|
logger.info('📄 Starting non-streaming request', {
|
||||||
apiKeyId: req.apiKey.id,
|
apiKeyId: _apiKeyIdNonStream,
|
||||||
apiKeyName: req.apiKey.name
|
apiKeyName: _apiKeyNameNonStream
|
||||||
})
|
})
|
||||||
|
|
||||||
// 📊 监听 socket 事件以追踪连接状态变化
|
// 📊 监听 socket 事件以追踪连接状态变化
|
||||||
@@ -938,11 +961,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
? await claudeAccountService.getAccount(accountId)
|
? await claudeAccountService.getAccount(accountId)
|
||||||
: await claudeConsoleAccountService.getAccount(accountId)
|
: await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
|
||||||
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
|
if (account?.interceptWarmup === 'true' && isWarmupRequest(_requestBodyNonStream)) {
|
||||||
logger.api(
|
logger.api(
|
||||||
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
|
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
|
||||||
)
|
)
|
||||||
return res.json(buildMockWarmupResponse(req.body.model))
|
return res.json(buildMockWarmupResponse(_requestBodyNonStream.model))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,11 +978,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
if (accountType === 'claude-official') {
|
if (accountType === 'claude-official') {
|
||||||
// 官方Claude账号使用原有的转发服务
|
// 官方Claude账号使用原有的转发服务
|
||||||
response = await claudeRelayService.relayRequest(
|
response = await claudeRelayService.relayRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
req.apiKey,
|
_apiKeyNonStream,
|
||||||
req,
|
req, // clientRequest 用于断开检测,保留但服务层已优化
|
||||||
res,
|
res,
|
||||||
req.headers
|
_headersNonStream
|
||||||
)
|
)
|
||||||
} else if (accountType === 'claude-console') {
|
} else if (accountType === 'claude-console') {
|
||||||
// Claude Console账号使用Console转发服务
|
// Claude Console账号使用Console转发服务
|
||||||
@@ -967,11 +990,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
|
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
|
||||||
)
|
)
|
||||||
response = await claudeConsoleRelayService.relayRequest(
|
response = await claudeConsoleRelayService.relayRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
req.apiKey,
|
_apiKeyNonStream,
|
||||||
req,
|
req, // clientRequest 保留用于断开检测
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersNonStream,
|
||||||
accountId
|
accountId
|
||||||
)
|
)
|
||||||
} else if (accountType === 'bedrock') {
|
} else if (accountType === 'bedrock') {
|
||||||
@@ -983,9 +1006,9 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await bedrockRelayService.handleNonStreamRequest(
|
const result = await bedrockRelayService.handleNonStreamRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
bedrockAccountResult.data,
|
bedrockAccountResult.data,
|
||||||
req.headers
|
_headersNonStream
|
||||||
)
|
)
|
||||||
|
|
||||||
// 构建标准响应格式
|
// 构建标准响应格式
|
||||||
@@ -1015,11 +1038,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// CCR账号使用CCR转发服务
|
// CCR账号使用CCR转发服务
|
||||||
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
|
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
|
||||||
response = await ccrRelayService.relayRequest(
|
response = await ccrRelayService.relayRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
req.apiKey,
|
_apiKeyNonStream,
|
||||||
req,
|
req, // clientRequest 保留用于断开检测
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersNonStream,
|
||||||
accountId
|
accountId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1068,14 +1091,14 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
||||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
||||||
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
||||||
const rawModel = jsonData.model || req.body.model || 'unknown'
|
const rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown'
|
||||||
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
|
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
|
||||||
const model = usageBaseModel || rawModel
|
const model = usageBaseModel || rawModel
|
||||||
|
|
||||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||||
const { accountId: responseAccountId } = response
|
const { accountId: responseAccountId } = response
|
||||||
await apiKeyService.recordUsage(
|
await apiKeyService.recordUsage(
|
||||||
req.apiKey.id,
|
_apiKeyIdNonStream,
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cacheCreateTokens,
|
cacheCreateTokens,
|
||||||
@@ -1085,7 +1108,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await queueRateLimitUpdate(
|
await queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoNonStream,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -1250,8 +1273,7 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
//(通过 v1internal:fetchAvailableModels),避免依赖静态 modelService 列表。
|
//(通过 v1internal:fetchAvailableModels),避免依赖静态 modelService 列表。
|
||||||
const forcedVendor = req._anthropicVendor || null
|
const forcedVendor = req._anthropicVendor || null
|
||||||
if (forcedVendor === 'antigravity') {
|
if (forcedVendor === 'antigravity') {
|
||||||
const permissions = req.apiKey?.permissions || 'all'
|
if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) {
|
||||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
type: 'permission_error',
|
type: 'permission_error',
|
||||||
@@ -1444,34 +1466,25 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
|
|||||||
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
|
||||||
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
|
||||||
const forcedVendor = req._anthropicVendor || null
|
const forcedVendor = req._anthropicVendor || null
|
||||||
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
|
const requiredService =
|
||||||
const permissions = req.apiKey?.permissions || 'all'
|
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
|
||||||
if (permissions !== 'all' && permissions !== 'gemini') {
|
|
||||||
|
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
type: 'permission_error',
|
type: 'permission_error',
|
||||||
message: 'This API key does not have permission to access Gemini'
|
message:
|
||||||
|
requiredService === 'gemini'
|
||||||
|
? 'This API key does not have permission to access Gemini'
|
||||||
|
: 'This API key does not have permission to access Claude'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requiredService === 'gemini') {
|
||||||
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
|
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查权限
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔗 会话绑定验证(与 messages 端点保持一致)
|
// 🔗 会话绑定验证(与 messages 端点保持一致)
|
||||||
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
const sessionValidation = await claudeRelayConfigService.validateNewSession(
|
const sessionValidation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ router.post('/api/user-stats', async (req, res) => {
|
|||||||
restrictedModels,
|
restrictedModels,
|
||||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||||
allowedClients,
|
allowedClients,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions,
|
||||||
// 添加激活相关字段
|
// 添加激活相关字段
|
||||||
expirationMode: keyData.expirationMode || 'fixed',
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
isActivated: keyData.isActivated === 'true',
|
isActivated: keyData.isActivated === 'true',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const {
|
|||||||
handleStreamGenerateContent,
|
handleStreamGenerateContent,
|
||||||
handleLoadCodeAssist,
|
handleLoadCodeAssist,
|
||||||
handleOnboardUser,
|
handleOnboardUser,
|
||||||
|
handleRetrieveUserQuota,
|
||||||
handleCountTokens,
|
handleCountTokens,
|
||||||
handleStandardGenerateContent,
|
handleStandardGenerateContent,
|
||||||
handleStandardStreamGenerateContent,
|
handleStandardStreamGenerateContent,
|
||||||
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
|
|||||||
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// v1internal 独有路由(listExperiments)
|
// v1internal 独有路由
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +82,12 @@ router.post(
|
|||||||
handleSimpleEndpoint('listExperiments')
|
handleSimpleEndpoint('listExperiments')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /v1internal:retrieveUserQuota
|
||||||
|
* 获取用户配额信息(Gemini CLI 0.22.2+ 需要)
|
||||||
|
*/
|
||||||
|
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /v1beta/models/:modelName:listExperiments
|
* POST /v1beta/models/:modelName:listExperiments
|
||||||
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const router = express.Router()
|
|||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const { authenticateApiKey } = require('../middleware/auth')
|
const { authenticateApiKey } = require('../middleware/auth')
|
||||||
const claudeRelayService = require('../services/claudeRelayService')
|
const claudeRelayService = require('../services/claudeRelayService')
|
||||||
|
const claudeConsoleRelayService = require('../services/claudeConsoleRelayService')
|
||||||
const openaiToClaude = require('../services/openaiToClaude')
|
const openaiToClaude = require('../services/openaiToClaude')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
const unifiedClaudeScheduler = require('../services/unifiedClaudeScheduler')
|
||||||
@@ -19,8 +20,7 @@ const { getEffectiveModel } = require('../utils/modelHelper')
|
|||||||
|
|
||||||
// 🔧 辅助函数:检查 API Key 权限
|
// 🔧 辅助函数:检查 API Key 权限
|
||||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||||
const permissions = apiKeyData.permissions || 'all'
|
return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
|
||||||
return permissions === 'all' || permissions === requiredPermission
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '') {
|
||||||
@@ -235,7 +235,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
const { accountId } = accountSelection
|
const { accountId, accountType } = accountSelection
|
||||||
|
|
||||||
// 获取该账号存储的 Claude Code headers
|
// 获取该账号存储的 Claude Code headers
|
||||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||||
@@ -265,13 +265,9 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用转换后的响应流 (使用 OAuth-only beta header,添加 Claude Code 必需的 headers)
|
// 使用转换后的响应流 (根据账户类型选择转发服务)
|
||||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
// 创建 usage 回调函数
|
||||||
claudeRequest,
|
const usageCallback = (usage) => {
|
||||||
apiKeyData,
|
|
||||||
res,
|
|
||||||
claudeCodeHeaders,
|
|
||||||
(usage) => {
|
|
||||||
// 记录使用统计
|
// 记录使用统计
|
||||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||||
const model = usage.model || claudeRequest.model
|
const model = usage.model || claudeRequest.model
|
||||||
@@ -288,7 +284,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
apiKeyData.id,
|
apiKeyData.id,
|
||||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||||
model,
|
model,
|
||||||
accountId
|
accountId,
|
||||||
|
accountType
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record usage:', error)
|
logger.error('❌ Failed to record usage:', error)
|
||||||
@@ -303,27 +300,62 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
cacheReadTokens
|
cacheReadTokens
|
||||||
},
|
},
|
||||||
model,
|
model,
|
||||||
'openai-claude-stream'
|
`openai-${accountType}-stream`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
// 流转换器
|
|
||||||
(() => {
|
// 创建流转换器
|
||||||
// 为每个请求创建独立的会话ID
|
|
||||||
const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
|
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)
|
const streamTransformer = (chunk) =>
|
||||||
})(),
|
openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId)
|
||||||
|
|
||||||
|
// 根据账户类型选择转发服务
|
||||||
|
if (accountType === 'claude-console') {
|
||||||
|
// Claude Console 账户使用 Console 转发服务
|
||||||
|
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
res,
|
||||||
|
claudeCodeHeaders,
|
||||||
|
usageCallback,
|
||||||
|
accountId,
|
||||||
|
streamTransformer
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Claude Official 账户使用标准转发服务
|
||||||
|
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
res,
|
||||||
|
claudeCodeHeaders,
|
||||||
|
usageCallback,
|
||||||
|
streamTransformer,
|
||||||
{
|
{
|
||||||
betaHeader:
|
betaHeader:
|
||||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 非流式请求
|
// 非流式请求
|
||||||
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`)
|
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(
|
let claudeResponse
|
||||||
|
if (accountType === 'claude-console') {
|
||||||
|
// Claude Console 账户使用 Console 转发服务
|
||||||
|
claudeResponse = await claudeConsoleRelayService.relayRequest(
|
||||||
|
claudeRequest,
|
||||||
|
apiKeyData,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
claudeCodeHeaders,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Claude Official 账户使用标准转发服务
|
||||||
|
claudeResponse = await claudeRelayService.relayRequest(
|
||||||
claudeRequest,
|
claudeRequest,
|
||||||
apiKeyData,
|
apiKeyData,
|
||||||
req,
|
req,
|
||||||
@@ -331,6 +363,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
claudeCodeHeaders,
|
claudeCodeHeaders,
|
||||||
{ betaHeader: 'oauth-2025-04-20' }
|
{ betaHeader: 'oauth-2025-04-20' }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 解析 Claude 响应
|
// 解析 Claude 响应
|
||||||
let claudeData
|
let claudeData
|
||||||
@@ -376,7 +409,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
apiKeyData.id,
|
apiKeyData.id,
|
||||||
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
usage, // 直接传递整个 usage 对象,包含可能的 cache_creation 详细数据
|
||||||
claudeRequest.model,
|
claudeRequest.model,
|
||||||
accountId
|
accountId,
|
||||||
|
accountType
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record usage:', error)
|
logger.error('❌ Failed to record usage:', error)
|
||||||
@@ -391,7 +425,7 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
cacheReadTokens
|
cacheReadTokens
|
||||||
},
|
},
|
||||||
claudeRequest.model,
|
claudeRequest.model,
|
||||||
'openai-claude-non-stream'
|
`openai-${accountType}-non-stream`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,8 +436,19 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
|
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
logger.info('🔌 OpenAI-Claude stream ended: Client disconnected')
|
||||||
|
} else {
|
||||||
logger.error('❌ OpenAI-Claude request error:', error)
|
logger.error('❌ OpenAI-Claude request error:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
|
||||||
|
if (!res.headersSent) {
|
||||||
|
// 客户端断开使用 499 状态码 (Client Closed Request)
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
res.status(499).end()
|
||||||
|
} else {
|
||||||
const status = error.status || 500
|
const status = error.status || 500
|
||||||
res.status(status).json({
|
res.status(status).json({
|
||||||
error: {
|
error: {
|
||||||
@@ -412,6 +457,8 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
|||||||
code: 'internal_error'
|
code: 'internal_error'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// 清理资源
|
// 清理资源
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
|
|||||||
@@ -673,6 +673,12 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT
|
||||||
|
if (!res.headersSent) {
|
||||||
|
// 客户端断开使用 499 状态码 (Client Closed Request)
|
||||||
|
if (error.message === 'Client disconnected') {
|
||||||
|
res.status(499).end()
|
||||||
|
} else {
|
||||||
// 返回 OpenAI 格式的错误响应
|
// 返回 OpenAI 格式的错误响应
|
||||||
const status = error.status || 500
|
const status = error.status || 500
|
||||||
const errorResponse = {
|
const errorResponse = {
|
||||||
@@ -682,8 +688,9 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
code: 'internal_error'
|
code: 'internal_error'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(status).json(errorResponse)
|
res.status(status).json(errorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// 清理资源
|
// 清理资源
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
@@ -693,8 +700,8 @@ router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
|||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// OpenAI 兼容的模型列表端点
|
// 获取可用模型列表的共享处理器
|
||||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
async function handleGetModels(req, res) {
|
||||||
try {
|
try {
|
||||||
const apiKeyData = req.apiKey
|
const apiKeyData = req.apiKey
|
||||||
|
|
||||||
@@ -782,8 +789,13 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return undefined
|
}
|
||||||
})
|
|
||||||
|
// OpenAI 兼容的模型列表端点 (带 v1 版)
|
||||||
|
router.get('/v1/models', authenticateApiKey, handleGetModels)
|
||||||
|
|
||||||
|
// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载)
|
||||||
|
router.get('/models', authenticateApiKey, handleGetModels)
|
||||||
|
|
||||||
// OpenAI 兼容的模型详情端点
|
// OpenAI 兼容的模型详情端点
|
||||||
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||||
|
|||||||
@@ -904,7 +904,7 @@ router.get('/key-info', authenticateApiKey, async (req, res) => {
|
|||||||
id: keyData.id,
|
id: keyData.id,
|
||||||
name: keyData.name,
|
name: keyData.name,
|
||||||
description: keyData.description,
|
description: keyData.description,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions,
|
||||||
token_limit: keyData.tokenLimit,
|
token_limit: keyData.tokenLimit,
|
||||||
tokens_used: keyData.usage.total.tokens,
|
tokens_used: keyData.usage.total.tokens,
|
||||||
tokens_remaining:
|
tokens_remaining:
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) {
|
|||||||
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
|
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
|
||||||
|
|
||||||
// 检查权限
|
// 检查权限
|
||||||
const permissions = req.apiKey.permissions || 'all'
|
const { permissions } = req.apiKey
|
||||||
|
|
||||||
if (backend === 'claude') {
|
if (backend === 'claude') {
|
||||||
// Claude 后端:通过 OpenAI 兼容层
|
// Claude 后端:通过 OpenAI 兼容层
|
||||||
if (permissions !== 'all' && permissions !== 'claude') {
|
if (!apiKeyService.hasPermission(permissions, 'claude')) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'This API key does not have permission to access Claude',
|
message: 'This API key does not have permission to access Claude',
|
||||||
@@ -62,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) {
|
|||||||
await handleChatCompletion(req, res, req.apiKey)
|
await handleChatCompletion(req, res, req.apiKey)
|
||||||
} else if (backend === 'openai') {
|
} else if (backend === 'openai') {
|
||||||
// OpenAI 后端
|
// OpenAI 后端
|
||||||
if (permissions !== 'all' && permissions !== 'openai') {
|
if (!apiKeyService.hasPermission(permissions, 'openai')) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'This API key does not have permission to access OpenAI',
|
message: 'This API key does not have permission to access OpenAI',
|
||||||
|
|||||||
@@ -226,7 +226,15 @@ class AccountBalanceService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return await service.getAccount(accountId)
|
const result = await service.getAccount(accountId)
|
||||||
|
|
||||||
|
// 处理不同服务返回格式的差异
|
||||||
|
// Bedrock/CCR/Droid 等服务返回 { success, data } 格式
|
||||||
|
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
|
||||||
|
return result.success ? result.data : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllAccountsByPlatform(platform) {
|
async getAllAccountsByPlatform(platform) {
|
||||||
@@ -270,15 +278,32 @@ class AccountBalanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _getAccountBalanceForAccount(account, platform, options = {}) {
|
async _getAccountBalanceForAccount(account, platform, options = {}) {
|
||||||
const queryApi = this._parseBoolean(options.queryApi) || false
|
const queryMode = this._parseQueryMode(options.queryApi)
|
||||||
const useCache = options.useCache !== false
|
const useCache = options.useCache !== false
|
||||||
|
|
||||||
const accountId = account?.id
|
const accountId = account?.id
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
throw new Error('账户缺少 id')
|
// 如果账户缺少 id,返回空响应而不是抛出错误,避免接口报错和UI错误
|
||||||
|
this.logger.warn('账户缺少 id,返回空余额数据', { account, platform })
|
||||||
|
return this._buildResponse(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: '账户数据异常',
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
statistics: {},
|
||||||
|
lastRefreshAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
'unknown',
|
||||||
|
platform,
|
||||||
|
'local',
|
||||||
|
null,
|
||||||
|
{ scriptEnabled: false, scriptConfigured: false }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 余额脚本配置状态(用于前端控制“刷新余额”按钮)
|
// 余额脚本配置状态(用于前端控制"刷新余额"按钮)
|
||||||
let scriptConfig = null
|
let scriptConfig = null
|
||||||
let scriptConfigured = false
|
let scriptConfigured = false
|
||||||
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
|
if (typeof this.redis?.getBalanceScriptConfig === 'function') {
|
||||||
@@ -297,8 +322,14 @@ class AccountBalanceService {
|
|||||||
|
|
||||||
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
|
const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics)
|
||||||
|
|
||||||
// 非强制查询:优先读缓存
|
// 安全限制:queryApi=auto 仅用于 Antigravity(gemini + oauthProvider=antigravity)账户
|
||||||
if (!queryApi) {
|
const effectiveQueryMode =
|
||||||
|
queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity')
|
||||||
|
? 'local'
|
||||||
|
: queryMode
|
||||||
|
|
||||||
|
// local: 仅本地统计/缓存;auto: 优先缓存,无缓存则尝试远程 Provider(并缓存结果)
|
||||||
|
if (effectiveQueryMode !== 'api') {
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
const cached = await this.redis.getAccountBalance(platform, accountId)
|
const cached = await this.redis.getAccountBalance(platform, accountId)
|
||||||
if (cached && cached.status === 'success') {
|
if (cached && cached.status === 'success') {
|
||||||
@@ -321,6 +352,7 @@ class AccountBalanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (effectiveQueryMode === 'local') {
|
||||||
return this._buildResponse(
|
return this._buildResponse(
|
||||||
{
|
{
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -338,6 +370,7 @@ class AccountBalanceService {
|
|||||||
scriptMeta
|
scriptMeta
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
// 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计
|
||||||
let providerResult
|
let providerResult
|
||||||
@@ -723,6 +756,14 @@ class AccountBalanceService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_parseQueryMode(value) {
|
||||||
|
if (value === 'auto') {
|
||||||
|
return 'auto'
|
||||||
|
}
|
||||||
|
const parsed = this._parseBoolean(value)
|
||||||
|
return parsed ? 'api' : 'local'
|
||||||
|
}
|
||||||
|
|
||||||
async _mapWithConcurrency(items, limit, mapper) {
|
async _mapWithConcurrency(items, limit, mapper) {
|
||||||
const concurrency = Math.max(1, Number(limit) || 1)
|
const concurrency = Math.max(1, Number(limit) || 1)
|
||||||
const list = Array.isArray(items) ? items : []
|
const list = Array.isArray(items) ? items : []
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,8 @@ function getAntigravityHeaders(accessToken, baseUrl) {
|
|||||||
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
|
'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.11.3 windows/amd64',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept-Encoding': 'gzip'
|
'Accept-Encoding': 'gzip',
|
||||||
|
requestType: 'agent'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +305,11 @@ async function request({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isRetryable = (error) => {
|
const isRetryable = (error) => {
|
||||||
|
// 处理网络层面的连接重置或超时(常见于长请求被中间节点切断)
|
||||||
|
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const status = error?.response?.status
|
const status = error?.response?.status
|
||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
return true
|
return true
|
||||||
@@ -429,7 +435,37 @@ async function request({
|
|||||||
const status = error?.response?.status
|
const status = error?.response?.status
|
||||||
if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
|
if (status === 429 && !retriedAfterDelay && !signal?.aborted) {
|
||||||
const data = error?.response?.data
|
const data = error?.response?.data
|
||||||
const msg = typeof data === 'string' ? data : JSON.stringify(data || '')
|
|
||||||
|
// 安全地将 data 转为字符串,避免 stream 对象导致循环引用崩溃
|
||||||
|
const safeDataToString = (value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
// stream 对象存在循环引用,不能 JSON.stringify
|
||||||
|
if (typeof value === 'object' && typeof value.pipe === 'function') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(value)) {
|
||||||
|
try {
|
||||||
|
return value.toString('utf8')
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = safeDataToString(data)
|
||||||
if (
|
if (
|
||||||
msg.toLowerCase().includes('resource_exhausted') ||
|
msg.toLowerCase().includes('resource_exhausted') ||
|
||||||
msg.toLowerCase().includes('no capacity')
|
msg.toLowerCase().includes('no capacity')
|
||||||
|
|||||||
250
src/services/balanceProviders/geminiBalanceProvider.js
Normal file
250
src/services/balanceProviders/geminiBalanceProvider.js
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
const BaseBalanceProvider = require('./baseBalanceProvider')
|
||||||
|
const antigravityClient = require('../antigravityClient')
|
||||||
|
const geminiAccountService = require('../geminiAccountService')
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity'
|
||||||
|
|
||||||
|
function clamp01(value) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (value < 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (value > 1) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuotaCategory(displayName, modelId) {
|
||||||
|
const name = String(displayName || '')
|
||||||
|
const id = String(modelId || '')
|
||||||
|
|
||||||
|
if (name.includes('Gemini') && name.includes('Pro')) {
|
||||||
|
return 'Gemini Pro'
|
||||||
|
}
|
||||||
|
if (name.includes('Gemini') && name.includes('Flash')) {
|
||||||
|
return 'Gemini Flash'
|
||||||
|
}
|
||||||
|
if (name.includes('Gemini') && name.toLowerCase().includes('image')) {
|
||||||
|
return 'Gemini Image'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes('Claude') || name.includes('GPT-OSS')) {
|
||||||
|
return 'Claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.startsWith('gemini-3-pro-') || id.startsWith('gemini-2.5-pro')) {
|
||||||
|
return 'Gemini Pro'
|
||||||
|
}
|
||||||
|
if (id.startsWith('gemini-3-flash') || id.startsWith('gemini-2.5-flash')) {
|
||||||
|
return 'Gemini Flash'
|
||||||
|
}
|
||||||
|
if (id.includes('image')) {
|
||||||
|
return 'Gemini Image'
|
||||||
|
}
|
||||||
|
if (id.includes('claude') || id.includes('gpt-oss')) {
|
||||||
|
return 'Claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
return name || id || 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAntigravityQuota(modelsResponse) {
|
||||||
|
const models = modelsResponse && typeof modelsResponse === 'object' ? modelsResponse.models : null
|
||||||
|
|
||||||
|
if (!models || typeof models !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseRemainingFraction = (quotaInfo) => {
|
||||||
|
if (!quotaInfo || typeof quotaInfo !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw =
|
||||||
|
quotaInfo.remainingFraction ??
|
||||||
|
quotaInfo.remaining_fraction ??
|
||||||
|
quotaInfo.remaining ??
|
||||||
|
undefined
|
||||||
|
|
||||||
|
const num = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
|
||||||
|
if (!Number.isFinite(num)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return clamp01(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedCategories = new Set(['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image'])
|
||||||
|
const fixedOrder = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
|
||||||
|
|
||||||
|
const categoryMap = new Map()
|
||||||
|
|
||||||
|
for (const [modelId, modelDataRaw] of Object.entries(models)) {
|
||||||
|
if (!modelDataRaw || typeof modelDataRaw !== 'object') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = modelDataRaw.displayName || modelDataRaw.display_name || modelId
|
||||||
|
const quotaInfo = modelDataRaw.quotaInfo || modelDataRaw.quota_info || null
|
||||||
|
|
||||||
|
const remainingFraction = parseRemainingFraction(quotaInfo)
|
||||||
|
if (remainingFraction === null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingPercent = round2(remainingFraction * 100)
|
||||||
|
const usedPercent = round2(100 - remainingPercent)
|
||||||
|
const resetAt = quotaInfo?.resetTime || quotaInfo?.reset_time || null
|
||||||
|
|
||||||
|
const category = normalizeQuotaCategory(displayName, modelId)
|
||||||
|
if (!allowedCategories.has(category)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
category,
|
||||||
|
modelId,
|
||||||
|
displayName: String(displayName || modelId || category),
|
||||||
|
remainingPercent,
|
||||||
|
usedPercent,
|
||||||
|
resetAt: typeof resetAt === 'string' && resetAt.trim() ? resetAt : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = categoryMap.get(category)
|
||||||
|
if (!existing || entry.remainingPercent < existing.remainingPercent) {
|
||||||
|
categoryMap.set(category, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = fixedOrder.map((category) => {
|
||||||
|
const existing = categoryMap.get(category) || null
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
modelId: '',
|
||||||
|
displayName: category,
|
||||||
|
remainingPercent: null,
|
||||||
|
usedPercent: null,
|
||||||
|
resetAt: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (buckets.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const critical = buckets
|
||||||
|
.filter((item) => item.remainingPercent !== null)
|
||||||
|
.reduce((min, item) => {
|
||||||
|
if (!min) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
return (item.remainingPercent ?? 0) < (min.remainingPercent ?? 0) ? item : min
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
if (!critical) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: {
|
||||||
|
type: 'antigravity',
|
||||||
|
total: 100,
|
||||||
|
used: critical.usedPercent,
|
||||||
|
remaining: critical.remainingPercent,
|
||||||
|
percentage: critical.usedPercent,
|
||||||
|
resetAt: critical.resetAt,
|
||||||
|
buckets: buckets.map((item) => ({
|
||||||
|
category: item.category,
|
||||||
|
remaining: item.remainingPercent,
|
||||||
|
used: item.usedPercent,
|
||||||
|
percentage: item.usedPercent,
|
||||||
|
resetAt: item.resetAt
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: {
|
||||||
|
modelsCount: Object.keys(models).length,
|
||||||
|
bucketCount: buckets.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeminiBalanceProvider extends BaseBalanceProvider {
|
||||||
|
constructor() {
|
||||||
|
super('gemini')
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryBalance(account) {
|
||||||
|
const oauthProvider = account?.oauthProvider
|
||||||
|
if (oauthProvider !== OAUTH_PROVIDER_ANTIGRAVITY) {
|
||||||
|
if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) {
|
||||||
|
return this.readQuotaFromFields(account)
|
||||||
|
}
|
||||||
|
return { balance: null, currency: 'USD', queryMethod: 'local' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = String(account?.accessToken || '').trim()
|
||||||
|
const refreshToken = String(account?.refreshToken || '').trim()
|
||||||
|
const proxyConfig = account?.proxyConfig || account?.proxy || null
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Antigravity 账户缺少 accessToken')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetch = async (token) =>
|
||||||
|
await antigravityClient.fetchAvailableModels({
|
||||||
|
accessToken: token,
|
||||||
|
proxyConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = await fetch(accessToken)
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.response?.status
|
||||||
|
if ((status === 401 || status === 403) && refreshToken) {
|
||||||
|
const refreshed = await geminiAccountService.refreshAccessToken(
|
||||||
|
refreshToken,
|
||||||
|
proxyConfig,
|
||||||
|
OAUTH_PROVIDER_ANTIGRAVITY
|
||||||
|
)
|
||||||
|
const nextToken = String(refreshed?.access_token || '').trim()
|
||||||
|
if (!nextToken) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
data = await fetch(nextToken)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = buildAntigravityQuota(data)
|
||||||
|
if (!mapped) {
|
||||||
|
return {
|
||||||
|
balance: null,
|
||||||
|
currency: 'USD',
|
||||||
|
quota: null,
|
||||||
|
queryMethod: 'api',
|
||||||
|
rawData: data || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GeminiBalanceProvider
|
||||||
@@ -2,6 +2,7 @@ const ClaudeBalanceProvider = require('./claudeBalanceProvider')
|
|||||||
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
|
const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider')
|
||||||
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
|
const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider')
|
||||||
const GenericBalanceProvider = require('./genericBalanceProvider')
|
const GenericBalanceProvider = require('./genericBalanceProvider')
|
||||||
|
const GeminiBalanceProvider = require('./geminiBalanceProvider')
|
||||||
|
|
||||||
function registerAllProviders(balanceService) {
|
function registerAllProviders(balanceService) {
|
||||||
// Claude
|
// Claude
|
||||||
@@ -14,7 +15,7 @@ function registerAllProviders(balanceService) {
|
|||||||
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
|
balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai'))
|
||||||
|
|
||||||
// 其他平台(降级)
|
// 其他平台(降级)
|
||||||
balanceService.registerProvider('gemini', new GenericBalanceProvider('gemini'))
|
balanceService.registerProvider('gemini', new GeminiBalanceProvider())
|
||||||
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
|
balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api'))
|
||||||
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
|
balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock'))
|
||||||
balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))
|
balanceService.registerProvider('droid', new GenericBalanceProvider('droid'))
|
||||||
|
|||||||
@@ -2,6 +2,50 @@ const vm = require('vm')
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
const { isBalanceScriptEnabled } = require('../utils/featureFlags')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF防护:检查URL是否访问内网或敏感地址
|
||||||
|
* @param {string} url - 要检查的URL
|
||||||
|
* @returns {boolean} - true表示URL安全
|
||||||
|
*/
|
||||||
|
function isUrlSafe(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const hostname = parsed.hostname.toLowerCase()
|
||||||
|
|
||||||
|
// 禁止的协议
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁止访问localhost和私有IP
|
||||||
|
const privatePatterns = [
|
||||||
|
/^localhost$/i,
|
||||||
|
/^127\./,
|
||||||
|
/^10\./,
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
||||||
|
/^192\.168\./,
|
||||||
|
/^169\.254\./, // AWS metadata
|
||||||
|
/^0\./, // 0.0.0.0
|
||||||
|
/^::1$/,
|
||||||
|
/^fc00:/i,
|
||||||
|
/^fe80:/i,
|
||||||
|
/\.local$/i,
|
||||||
|
/\.internal$/i,
|
||||||
|
/\.localhost$/i
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of privatePatterns) {
|
||||||
|
if (pattern.test(hostname)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可配置脚本余额查询执行器
|
* 可配置脚本余额查询执行器
|
||||||
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
* - 脚本格式:({ request: {...}, extractor: function(response){...} })
|
||||||
@@ -55,6 +99,11 @@ class BalanceScriptService {
|
|||||||
throw new Error('脚本 request.url 不能为空')
|
throw new Error('脚本 request.url 不能为空')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSRF防护:验证URL安全性
|
||||||
|
if (!isUrlSafe(request.url)) {
|
||||||
|
throw new Error('脚本 request.url 不安全:禁止访问内网地址、localhost或使用非HTTP(S)协议')
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof extractor !== 'function') {
|
if (typeof extractor !== 'function') {
|
||||||
throw new Error('脚本 extractor 必须是函数')
|
throw new Error('脚本 extractor 必须是函数')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,12 +35,13 @@ class BedrockAccountService {
|
|||||||
description = '',
|
description = '',
|
||||||
region = process.env.AWS_REGION || 'us-east-1',
|
region = process.env.AWS_REGION || 'us-east-1',
|
||||||
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
|
awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken }
|
||||||
|
bearerToken = null, // AWS Bearer Token for Bedrock API Keys
|
||||||
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||||
isActive = true,
|
isActive = true,
|
||||||
accountType = 'shared', // 'dedicated' or 'shared'
|
accountType = 'shared', // 'dedicated' or 'shared'
|
||||||
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
priority = 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
credentialType = 'default' // 'default', 'access_key', 'bearer_token'
|
credentialType = 'access_key' // 'access_key', 'bearer_token'(默认为 access_key)
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -71,6 +72,11 @@ class BedrockAccountService {
|
|||||||
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
|
accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加密存储 Bearer Token
|
||||||
|
if (bearerToken) {
|
||||||
|
accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken })
|
||||||
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData))
|
||||||
|
|
||||||
@@ -106,9 +112,85 @@ class BedrockAccountService {
|
|||||||
|
|
||||||
const account = JSON.parse(accountData)
|
const account = JSON.parse(accountData)
|
||||||
|
|
||||||
// 解密AWS凭证用于内部使用
|
// 根据凭证类型解密对应的凭证
|
||||||
|
// 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底)
|
||||||
|
try {
|
||||||
|
let accessKeyDecrypted = false
|
||||||
|
let bearerTokenDecrypted = false
|
||||||
|
|
||||||
|
// 第一步:按照 credentialType 尝试解密对应的凭证
|
||||||
|
if (account.credentialType === 'access_key' && account.awsCredentials) {
|
||||||
|
// Access Key 模式:解密 AWS 凭证
|
||||||
|
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||||
|
accessKeyDecrypted = true
|
||||||
|
logger.debug(
|
||||||
|
`🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
} else if (account.credentialType === 'bearer_token' && account.bearerToken) {
|
||||||
|
// Bearer Token 模式:解密 Bearer Token
|
||||||
|
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||||
|
account.bearerToken = decrypted.token
|
||||||
|
bearerTokenDecrypted = true
|
||||||
|
logger.debug(
|
||||||
|
`🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
} else if (!account.credentialType || account.credentialType === 'default') {
|
||||||
|
// 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证
|
||||||
if (account.awsCredentials) {
|
if (account.awsCredentials) {
|
||||||
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||||
|
accessKeyDecrypted = true
|
||||||
|
}
|
||||||
|
if (account.bearerToken) {
|
||||||
|
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||||
|
account.bearerToken = decrypted.token
|
||||||
|
bearerTokenDecrypted = true
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段
|
||||||
|
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}`
|
||||||
|
)
|
||||||
|
if (account.awsCredentials) {
|
||||||
|
account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials)
|
||||||
|
accessKeyDecrypted = true
|
||||||
|
logger.warn(
|
||||||
|
`🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (account.bearerToken) {
|
||||||
|
const decrypted = this._decryptAwsCredentials(account.bearerToken)
|
||||||
|
account.bearerToken = decrypted.token
|
||||||
|
bearerTokenDecrypted = true
|
||||||
|
logger.warn(
|
||||||
|
`🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证至少解密了一种凭证
|
||||||
|
if (!accessKeyDecrypted && !bearerTokenDecrypted) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No valid credentials found in account data'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (decryptError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`,
|
||||||
|
decryptError
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Credentials decryption failed: ${decryptError.message}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`)
|
||||||
@@ -155,7 +237,11 @@ class BedrockAccountService {
|
|||||||
updatedAt: account.updatedAt,
|
updatedAt: account.updatedAt,
|
||||||
type: 'bedrock',
|
type: 'bedrock',
|
||||||
platform: 'bedrock',
|
platform: 'bedrock',
|
||||||
hasCredentials: !!account.awsCredentials
|
// 根据凭证类型判断是否有凭证
|
||||||
|
hasCredentials:
|
||||||
|
account.credentialType === 'bearer_token'
|
||||||
|
? !!account.bearerToken
|
||||||
|
: !!account.awsCredentials
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,6 +321,15 @@ class BedrockAccountService {
|
|||||||
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新 Bearer Token
|
||||||
|
if (updates.bearerToken !== undefined) {
|
||||||
|
if (updates.bearerToken) {
|
||||||
|
account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken })
|
||||||
|
} else {
|
||||||
|
delete account.bearerToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
|
// Bedrock 没有 token 刷新逻辑,不会覆盖此字段
|
||||||
if (updates.subscriptionExpiresAt !== undefined) {
|
if (updates.subscriptionExpiresAt !== undefined) {
|
||||||
@@ -345,13 +440,45 @@ class BedrockAccountService {
|
|||||||
|
|
||||||
const account = accountResult.data
|
const account = accountResult.data
|
||||||
|
|
||||||
logger.info(`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}`)
|
logger.info(
|
||||||
|
`🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
|
||||||
// 尝试获取模型列表来测试连接
|
// 验证凭证是否已解密
|
||||||
|
const hasValidCredentials =
|
||||||
|
(account.credentialType === 'access_key' && account.awsCredentials) ||
|
||||||
|
(account.credentialType === 'bearer_token' && account.bearerToken) ||
|
||||||
|
(!account.credentialType && (account.awsCredentials || account.bearerToken))
|
||||||
|
|
||||||
|
if (!hasValidCredentials) {
|
||||||
|
logger.error(
|
||||||
|
`❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No valid credentials found after decryption'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试创建 Bedrock 客户端来验证凭证格式
|
||||||
|
try {
|
||||||
|
bedrockRelayService._getBedrockClient(account.region, account)
|
||||||
|
logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`)
|
||||||
|
} catch (clientError) {
|
||||||
|
logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to create Bedrock client: ${clientError.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可用模型列表(硬编码,但至少验证了凭证格式正确)
|
||||||
const models = await bedrockRelayService.getAvailableModels(account)
|
const models = await bedrockRelayService.getAvailableModels(account)
|
||||||
|
|
||||||
if (models && models.length > 0) {
|
if (models && models.length > 0) {
|
||||||
logger.info(`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型`)
|
logger.info(
|
||||||
|
`✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}`
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -376,6 +503,135 @@ class BedrockAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🧪 测试 Bedrock 账户连接(SSE 流式返回,供前端测试页面使用)
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {Object} res - Express response 对象
|
||||||
|
* @param {string} model - 测试使用的模型
|
||||||
|
*/
|
||||||
|
async testAccountConnection(accountId, res, model = null) {
|
||||||
|
const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const accountResult = await this.getAccount(accountId)
|
||||||
|
if (!accountResult.success) {
|
||||||
|
throw new Error(accountResult.error || 'Account not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = accountResult.data
|
||||||
|
|
||||||
|
// 根据账户类型选择合适的测试模型
|
||||||
|
if (!model) {
|
||||||
|
// Access Key 模式使用 Haiku(更快更便宜)
|
||||||
|
model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 设置 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')
|
||||||
|
res.status(200)
|
||||||
|
|
||||||
|
// 发送 test_start 事件
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`)
|
||||||
|
|
||||||
|
// 构造测试请求体(Bedrock 格式)
|
||||||
|
const bedrockPayload = {
|
||||||
|
anthropic_version: 'bedrock-2023-05-31',
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content:
|
||||||
|
'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Bedrock 客户端
|
||||||
|
const region = account.region || bedrockRelayService.defaultRegion
|
||||||
|
const client = bedrockRelayService._getBedrockClient(region, account)
|
||||||
|
|
||||||
|
// 创建流式调用命令
|
||||||
|
const command = new InvokeModelWithResponseStreamCommand({
|
||||||
|
modelId: model,
|
||||||
|
body: JSON.stringify(bedrockPayload),
|
||||||
|
contentType: 'application/json',
|
||||||
|
accept: 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`)
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
const response = await client.send(command)
|
||||||
|
|
||||||
|
// 处理流式响应
|
||||||
|
// let responseText = ''
|
||||||
|
for await (const chunk of response.body) {
|
||||||
|
if (chunk.chunk) {
|
||||||
|
const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes))
|
||||||
|
|
||||||
|
// 提取文本内容
|
||||||
|
if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) {
|
||||||
|
const { text } = chunkData.delta
|
||||||
|
// responseText += text
|
||||||
|
|
||||||
|
// 发送 content 事件
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测错误
|
||||||
|
if (chunkData.type === 'error') {
|
||||||
|
throw new Error(chunkData.error?.message || 'Bedrock API error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`)
|
||||||
|
|
||||||
|
// 发送 message_stop 事件(前端兼容)
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
|
||||||
|
|
||||||
|
// 发送 test_complete 事件
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||||
|
|
||||||
|
// 结束响应
|
||||||
|
res.end()
|
||||||
|
|
||||||
|
logger.info(`✅ Test request completed for Bedrock account: ${account.name}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Test Bedrock account connection failed:`, error)
|
||||||
|
|
||||||
|
// 发送错误事件给前端
|
||||||
|
try {
|
||||||
|
// 检查响应流是否仍然可写
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.status(200)
|
||||||
|
}
|
||||||
|
const errorMsg = error.message || '测试失败'
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.error('Failed to write error to response stream:', writeError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不再重新抛出错误,避免路由层再次处理
|
||||||
|
// throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查账户订阅是否过期
|
* 检查账户订阅是否过期
|
||||||
* @param {Object} account - 账户对象
|
* @param {Object} account - 账户对象
|
||||||
|
|||||||
@@ -48,13 +48,17 @@ class BedrockRelayService {
|
|||||||
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
|
secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey,
|
||||||
sessionToken: bedrockAccount.awsCredentials.sessionToken
|
sessionToken: bedrockAccount.awsCredentials.sessionToken
|
||||||
}
|
}
|
||||||
|
} else if (bedrockAccount?.bearerToken) {
|
||||||
|
// Bearer Token 模式:AWS SDK >= 3.400.0 会自动检测环境变量
|
||||||
|
clientConfig.token = { token: bedrockAccount.bearerToken }
|
||||||
|
logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`)
|
||||||
} else {
|
} else {
|
||||||
// 检查是否有环境变量凭证
|
// 检查是否有环境变量凭证
|
||||||
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||||
clientConfig.credentials = fromEnv()
|
clientConfig.credentials = fromEnv()
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
|
'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token,或设置环境变量AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,6 +435,18 @@ class BedrockRelayService {
|
|||||||
_mapToBedrockModel(modelName) {
|
_mapToBedrockModel(modelName) {
|
||||||
// 标准Claude模型名到Bedrock模型名的映射表
|
// 标准Claude模型名到Bedrock模型名的映射表
|
||||||
const modelMapping = {
|
const modelMapping = {
|
||||||
|
// Claude 4.5 Opus
|
||||||
|
'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
|
||||||
|
// Claude 4.5 Sonnet
|
||||||
|
'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
|
||||||
|
// Claude 4.5 Haiku
|
||||||
|
'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
|
||||||
// Claude Sonnet 4
|
// Claude Sonnet 4
|
||||||
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||||
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||||
|
|||||||
@@ -21,51 +21,51 @@ const { isStreamWritable } = require('../utils/streamHelper')
|
|||||||
class ClaudeRelayService {
|
class ClaudeRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
|
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
|
||||||
|
// 🧹 内存优化:用于存储请求体字符串,避免闭包捕获
|
||||||
|
this.bodyStore = new Map()
|
||||||
|
this._bodyStoreIdCounter = 0
|
||||||
this.apiVersion = config.claude.apiVersion
|
this.apiVersion = config.claude.apiVersion
|
||||||
this.betaHeader = config.claude.betaHeader
|
this.betaHeader = config.claude.betaHeader
|
||||||
this.systemPrompt = config.claude.systemPrompt
|
this.systemPrompt = config.claude.systemPrompt
|
||||||
this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||||
|
this.toolNameSuffix = null
|
||||||
|
this.toolNameSuffixGeneratedAt = 0
|
||||||
|
this.toolNameSuffixTtlMs = 60 * 60 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header
|
// 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header
|
||||||
// 规则:
|
|
||||||
// 1. 如果客户端传递了 anthropic-beta,检查是否包含 oauth-2025-04-20
|
|
||||||
// 2. 如果没有 oauth-2025-04-20,则添加到 claude-code-20250219 后面(如果有的话),否则放在第一位
|
|
||||||
// 3. 如果客户端没传递,则根据模型判断:haiku 不需要 claude-code,其他模型需要
|
|
||||||
_getBetaHeader(modelId, clientBetaHeader) {
|
_getBetaHeader(modelId, clientBetaHeader) {
|
||||||
const OAUTH_BETA = 'oauth-2025-04-20'
|
const OAUTH_BETA = 'oauth-2025-04-20'
|
||||||
const CLAUDE_CODE_BETA = 'claude-code-20250219'
|
const CLAUDE_CODE_BETA = 'claude-code-20250219'
|
||||||
|
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'
|
||||||
|
const TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14'
|
||||||
|
|
||||||
// 如果客户端传递了 anthropic-beta
|
|
||||||
if (clientBetaHeader) {
|
|
||||||
// 检查是否已包含 oauth-2025-04-20
|
|
||||||
if (clientBetaHeader.includes(OAUTH_BETA)) {
|
|
||||||
return clientBetaHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
// 需要添加 oauth-2025-04-20
|
|
||||||
const parts = clientBetaHeader.split(',').map((p) => p.trim())
|
|
||||||
|
|
||||||
// 找到 claude-code-20250219 的位置
|
|
||||||
const claudeCodeIndex = parts.findIndex((p) => p === CLAUDE_CODE_BETA)
|
|
||||||
|
|
||||||
if (claudeCodeIndex !== -1) {
|
|
||||||
// 在 claude-code-20250219 后面插入
|
|
||||||
parts.splice(claudeCodeIndex + 1, 0, OAUTH_BETA)
|
|
||||||
} else {
|
|
||||||
// 放在第一位
|
|
||||||
parts.unshift(OAUTH_BETA)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(',')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 客户端没有传递,根据模型判断
|
|
||||||
const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku')
|
const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku')
|
||||||
if (isHaikuModel) {
|
const baseBetas = isHaikuModel
|
||||||
return 'oauth-2025-04-20,interleaved-thinking-2025-05-14'
|
? [OAUTH_BETA, INTERLEAVED_THINKING_BETA]
|
||||||
|
: [CLAUDE_CODE_BETA, OAUTH_BETA, INTERLEAVED_THINKING_BETA, TOOL_STREAMING_BETA]
|
||||||
|
|
||||||
|
const betaList = []
|
||||||
|
const seen = new Set()
|
||||||
|
const addBeta = (beta) => {
|
||||||
|
if (!beta || seen.has(beta)) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14'
|
seen.add(beta)
|
||||||
|
betaList.push(beta)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseBetas.forEach(addBeta)
|
||||||
|
|
||||||
|
if (clientBetaHeader) {
|
||||||
|
clientBetaHeader
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach(addBeta)
|
||||||
|
}
|
||||||
|
|
||||||
|
return betaList.join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildStandardRateLimitMessage(resetTime) {
|
_buildStandardRateLimitMessage(resetTime) {
|
||||||
@@ -140,6 +140,235 @@ class ClaudeRelayService {
|
|||||||
return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1)
|
return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isClaudeCodeUserAgent(clientHeaders) {
|
||||||
|
const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent']
|
||||||
|
return typeof userAgent === 'string' && /^claude-cli\/[^\s]+\s+\(/i.test(userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
_isActualClaudeCodeRequest(requestBody, clientHeaders) {
|
||||||
|
return this.isRealClaudeCodeRequest(requestBody) && this._isClaudeCodeUserAgent(clientHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
_getHeaderValueCaseInsensitive(headers, key) {
|
||||||
|
if (!headers || typeof headers !== 'object') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const lowerKey = key.toLowerCase()
|
||||||
|
for (const candidate of Object.keys(headers)) {
|
||||||
|
if (candidate.toLowerCase() === lowerKey) {
|
||||||
|
return headers[candidate]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
_isClaudeCodeCredentialError(body) {
|
||||||
|
const message = this._extractErrorMessage(body)
|
||||||
|
if (!message) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const lower = message.toLowerCase()
|
||||||
|
return (
|
||||||
|
lower.includes('only authorized for use with claude code') ||
|
||||||
|
lower.includes('cannot be used for other api requests')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_toPascalCaseToolName(name) {
|
||||||
|
const parts = name.split(/[_-]/).filter(Boolean)
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
const pascal = parts
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||||
|
.join('')
|
||||||
|
return `${pascal}_tool`
|
||||||
|
}
|
||||||
|
|
||||||
|
_getToolNameSuffix() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (!this.toolNameSuffix || now - this.toolNameSuffixGeneratedAt > this.toolNameSuffixTtlMs) {
|
||||||
|
this.toolNameSuffix = Math.random().toString(36).substring(2, 8)
|
||||||
|
this.toolNameSuffixGeneratedAt = now
|
||||||
|
}
|
||||||
|
return this.toolNameSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
_toRandomizedToolName(name) {
|
||||||
|
const suffix = this._getToolNameSuffix()
|
||||||
|
return `${name}_${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
_transformToolNamesInRequestBody(body, options = {}) {
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const useRandomized = options.useRandomizedToolNames === true
|
||||||
|
const forwardMap = new Map()
|
||||||
|
const reverseMap = new Map()
|
||||||
|
|
||||||
|
const transformName = (name) => {
|
||||||
|
if (typeof name !== 'string' || name.length === 0) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if (forwardMap.has(name)) {
|
||||||
|
return forwardMap.get(name)
|
||||||
|
}
|
||||||
|
const transformed = useRandomized
|
||||||
|
? this._toRandomizedToolName(name)
|
||||||
|
: this._toPascalCaseToolName(name)
|
||||||
|
if (transformed !== name) {
|
||||||
|
forwardMap.set(name, transformed)
|
||||||
|
reverseMap.set(transformed, name)
|
||||||
|
}
|
||||||
|
return transformed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(body.tools)) {
|
||||||
|
body.tools.forEach((tool) => {
|
||||||
|
if (tool && typeof tool.name === 'string') {
|
||||||
|
tool.name = transformName(tool.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.tool_choice && typeof body.tool_choice === 'object') {
|
||||||
|
if (typeof body.tool_choice.name === 'string') {
|
||||||
|
body.tool_choice.name = transformName(body.tool_choice.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(body.messages)) {
|
||||||
|
body.messages.forEach((message) => {
|
||||||
|
const content = message?.content
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
content.forEach((block) => {
|
||||||
|
if (block?.type === 'tool_use' && typeof block.name === 'string') {
|
||||||
|
block.name = transformName(block.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return reverseMap.size > 0 ? reverseMap : null
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreToolName(name, toolNameMap) {
|
||||||
|
if (!toolNameMap || toolNameMap.size === 0) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return toolNameMap.get(name) || name
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreToolNamesInContentBlocks(content, toolNameMap) {
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content.forEach((block) => {
|
||||||
|
if (block?.type === 'tool_use' && typeof block.name === 'string') {
|
||||||
|
block.name = this._restoreToolName(block.name, toolNameMap)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreToolNamesInResponseObject(responseBody, toolNameMap) {
|
||||||
|
if (!responseBody || typeof responseBody !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(responseBody.content)) {
|
||||||
|
this._restoreToolNamesInContentBlocks(responseBody.content, toolNameMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseBody.message && Array.isArray(responseBody.message.content)) {
|
||||||
|
this._restoreToolNamesInContentBlocks(responseBody.message.content, toolNameMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreToolNamesInResponseBody(responseBody, toolNameMap) {
|
||||||
|
if (!responseBody || !toolNameMap || toolNameMap.size === 0) {
|
||||||
|
return responseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof responseBody === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(responseBody)
|
||||||
|
this._restoreToolNamesInResponseObject(parsed, toolNameMap)
|
||||||
|
return JSON.stringify(parsed)
|
||||||
|
} catch (error) {
|
||||||
|
return responseBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof responseBody === 'object') {
|
||||||
|
this._restoreToolNamesInResponseObject(responseBody, toolNameMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreToolNamesInStreamEvent(event, toolNameMap) {
|
||||||
|
if (!event || typeof event !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.content_block && event.content_block.type === 'tool_use') {
|
||||||
|
if (typeof event.content_block.name === 'string') {
|
||||||
|
event.content_block.name = this._restoreToolName(event.content_block.name, toolNameMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.delta && event.delta.type === 'tool_use') {
|
||||||
|
if (typeof event.delta.name === 'string') {
|
||||||
|
event.delta.name = this._restoreToolName(event.delta.name, toolNameMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.message && Array.isArray(event.message.content)) {
|
||||||
|
this._restoreToolNamesInContentBlocks(event.message.content, toolNameMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(event.content)) {
|
||||||
|
this._restoreToolNamesInContentBlocks(event.content, toolNameMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_createToolNameStripperStreamTransformer(streamTransformer, toolNameMap) {
|
||||||
|
if (!toolNameMap || toolNameMap.size === 0) {
|
||||||
|
return streamTransformer
|
||||||
|
}
|
||||||
|
|
||||||
|
return (payload) => {
|
||||||
|
const transformed = streamTransformer ? streamTransformer(payload) : payload
|
||||||
|
if (!transformed || typeof transformed !== 'string') {
|
||||||
|
return transformed
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = transformed.split('\n')
|
||||||
|
const updated = lines.map((line) => {
|
||||||
|
if (!line.startsWith('data:')) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
const jsonStr = line.slice(5).trimStart()
|
||||||
|
if (!jsonStr || jsonStr === '[DONE]') {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr)
|
||||||
|
this._restoreToolNamesInStreamEvent(data, toolNameMap)
|
||||||
|
return `data: ${JSON.stringify(data)}`
|
||||||
|
} catch (error) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated.join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🚀 转发请求到Claude API
|
// 🚀 转发请求到Claude API
|
||||||
async relayRequest(
|
async relayRequest(
|
||||||
requestBody,
|
requestBody,
|
||||||
@@ -153,6 +382,7 @@ class ClaudeRelayService {
|
|||||||
let queueLockAcquired = false
|
let queueLockAcquired = false
|
||||||
let queueRequestId = null
|
let queueRequestId = null
|
||||||
let selectedAccountId = null
|
let selectedAccountId = null
|
||||||
|
let bodyStoreIdNonStream = null // 🧹 在 try 块外声明,以便 finally 清理
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 调试日志:查看API Key数据
|
// 调试日志:查看API Key数据
|
||||||
@@ -311,7 +541,12 @@ class ClaudeRelayService {
|
|||||||
// 获取有效的访问token
|
// 获取有效的访问token
|
||||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||||
|
|
||||||
|
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
|
||||||
const processedBody = this._processRequestBody(requestBody, account)
|
const processedBody = this._processRequestBody(requestBody, account)
|
||||||
|
// 🧹 内存优化:存储到 bodyStore,避免闭包捕获
|
||||||
|
const originalBodyString = JSON.stringify(processedBody)
|
||||||
|
bodyStoreIdNonStream = ++this._bodyStoreIdCounter
|
||||||
|
this.bodyStore.set(bodyStoreIdNonStream, originalBodyString)
|
||||||
|
|
||||||
// 获取代理配置
|
// 获取代理配置
|
||||||
const proxyAgent = await this._getProxyAgent(accountId)
|
const proxyAgent = await this._getProxyAgent(accountId)
|
||||||
@@ -332,16 +567,23 @@ class ClaudeRelayService {
|
|||||||
clientResponse.once('close', handleClientDisconnect)
|
clientResponse.once('close', handleClientDisconnect)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送请求到Claude API(传入回调以获取请求对象)
|
const makeRequestWithRetries = async (requestOptions) => {
|
||||||
// 🔄 403 重试机制:仅对 claude-official 类型账户(OAuth 或 Setup Token)
|
|
||||||
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0
|
||||||
let retryCount = 0
|
let retryCount = 0
|
||||||
let response
|
let response
|
||||||
let shouldRetry = false
|
let shouldRetry = false
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
// 🧹 每次重试从 bodyStore 解析新对象,避免闭包捕获
|
||||||
|
let retryRequestBody
|
||||||
|
try {
|
||||||
|
retryRequestBody = JSON.parse(this.bodyStore.get(bodyStoreIdNonStream))
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error(`❌ Failed to parse body for retry: ${parseError.message}`)
|
||||||
|
throw new Error(`Request body parse failed: ${parseError.message}`)
|
||||||
|
}
|
||||||
response = await this._makeClaudeRequest(
|
response = await this._makeClaudeRequest(
|
||||||
processedBody,
|
retryRequestBody,
|
||||||
accessToken,
|
accessToken,
|
||||||
proxyAgent,
|
proxyAgent,
|
||||||
clientHeaders,
|
clientHeaders,
|
||||||
@@ -349,10 +591,12 @@ class ClaudeRelayService {
|
|||||||
(req) => {
|
(req) => {
|
||||||
upstreamRequest = req
|
upstreamRequest = req
|
||||||
},
|
},
|
||||||
options
|
{
|
||||||
|
...requestOptions,
|
||||||
|
isRealClaudeCodeRequest
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 检查是否需要重试 403
|
|
||||||
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
|
shouldRetry = response.statusCode === 403 && retryCount < maxRetries
|
||||||
if (shouldRetry) {
|
if (shouldRetry) {
|
||||||
retryCount++
|
retryCount++
|
||||||
@@ -363,6 +607,20 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
} while (shouldRetry)
|
} while (shouldRetry)
|
||||||
|
|
||||||
|
return { response, retryCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestOptions = options
|
||||||
|
let { response, retryCount } = await makeRequestWithRetries(requestOptions)
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._isClaudeCodeCredentialError(response.body) &&
|
||||||
|
requestOptions.useRandomizedToolNames !== true
|
||||||
|
) {
|
||||||
|
requestOptions = { ...requestOptions, useRandomizedToolNames: true }
|
||||||
|
;({ response, retryCount } = await makeRequestWithRetries(requestOptions))
|
||||||
|
}
|
||||||
|
|
||||||
// 如果进行了重试,记录最终结果
|
// 如果进行了重试,记录最终结果
|
||||||
if (retryCount > 0) {
|
if (retryCount > 0) {
|
||||||
if (response.statusCode === 403) {
|
if (response.statusCode === 403) {
|
||||||
@@ -661,6 +919,10 @@ class ClaudeRelayService {
|
|||||||
)
|
)
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
|
// 🧹 清理 bodyStore
|
||||||
|
if (bodyStoreIdNonStream !== null) {
|
||||||
|
this.bodyStore.delete(bodyStoreIdNonStream)
|
||||||
|
}
|
||||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||||
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||||
try {
|
try {
|
||||||
@@ -1035,23 +1297,19 @@ class ClaudeRelayService {
|
|||||||
// 获取过滤后的客户端 headers
|
// 获取过滤后的客户端 headers
|
||||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||||
|
|
||||||
// 判断是否是真实的 Claude Code 请求
|
const isRealClaudeCode =
|
||||||
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
requestOptions.isRealClaudeCodeRequest === undefined
|
||||||
|
? this.isRealClaudeCodeRequest(body)
|
||||||
|
: requestOptions.isRealClaudeCodeRequest === true
|
||||||
|
|
||||||
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
||||||
let finalHeaders = { ...filteredHeaders }
|
let finalHeaders = { ...filteredHeaders }
|
||||||
let requestPayload = body
|
let requestPayload = body
|
||||||
|
|
||||||
if (!isRealClaudeCode) {
|
if (!isRealClaudeCode) {
|
||||||
// 获取该账号存储的 Claude Code headers
|
|
||||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||||
|
|
||||||
// 只添加客户端没有提供的 headers
|
|
||||||
Object.keys(claudeCodeHeaders).forEach((key) => {
|
Object.keys(claudeCodeHeaders).forEach((key) => {
|
||||||
const lowerKey = key.toLowerCase()
|
|
||||||
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
|
||||||
finalHeaders[key] = claudeCodeHeaders[key]
|
finalHeaders[key] = claudeCodeHeaders[key]
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,6 +1331,13 @@ class ClaudeRelayService {
|
|||||||
requestPayload = extensionResult.body
|
requestPayload = extensionResult.body
|
||||||
finalHeaders = extensionResult.headers
|
finalHeaders = extensionResult.headers
|
||||||
|
|
||||||
|
let toolNameMap = null
|
||||||
|
if (!isRealClaudeCode) {
|
||||||
|
toolNameMap = this._transformToolNamesInRequestBody(requestPayload, {
|
||||||
|
useRandomizedToolNames: requestOptions.useRandomizedToolNames === true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 序列化请求体,计算 content-length
|
// 序列化请求体,计算 content-length
|
||||||
const bodyString = JSON.stringify(requestPayload)
|
const bodyString = JSON.stringify(requestPayload)
|
||||||
const contentLength = Buffer.byteLength(bodyString, 'utf8')
|
const contentLength = Buffer.byteLength(bodyString, 'utf8')
|
||||||
@@ -1098,17 +1363,16 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||||
|
|
||||||
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
|
||||||
|
|
||||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||||
const modelId = requestPayload?.model || body?.model
|
const modelId = requestPayload?.model || body?.model
|
||||||
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
const clientBetaHeader = this._getHeaderValueCaseInsensitive(clientHeaders, 'anthropic-beta')
|
||||||
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
||||||
return {
|
return {
|
||||||
requestPayload,
|
requestPayload,
|
||||||
bodyString,
|
bodyString,
|
||||||
headers,
|
headers,
|
||||||
isRealClaudeCode
|
isRealClaudeCode,
|
||||||
|
toolNameMap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,7 +1438,8 @@ class ClaudeRelayService {
|
|||||||
return prepared.abortResponse
|
return prepared.abortResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bodyString, headers } = prepared
|
let { bodyString } = prepared
|
||||||
|
const { headers, isRealClaudeCode, toolNameMap } = prepared
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 支持自定义路径(如 count_tokens)
|
// 支持自定义路径(如 count_tokens)
|
||||||
@@ -1226,6 +1491,10 @@ class ClaudeRelayService {
|
|||||||
responseBody = responseData.toString('utf8')
|
responseBody = responseData.toString('utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isRealClaudeCode) {
|
||||||
|
responseBody = this._restoreToolNamesInResponseBody(responseBody, toolNameMap)
|
||||||
|
}
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
statusCode: res.statusCode,
|
statusCode: res.statusCode,
|
||||||
headers: res.headers,
|
headers: res.headers,
|
||||||
@@ -1284,6 +1553,8 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 写入请求体
|
// 写入请求体
|
||||||
req.write(bodyString)
|
req.write(bodyString)
|
||||||
|
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
|
||||||
|
bodyString = null
|
||||||
req.end()
|
req.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1465,7 +1736,12 @@ class ClaudeRelayService {
|
|||||||
// 获取有效的访问token
|
// 获取有效的访问token
|
||||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
const accessToken = await claudeAccountService.getValidAccessToken(accountId)
|
||||||
|
|
||||||
|
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
|
||||||
const processedBody = this._processRequestBody(requestBody, account)
|
const processedBody = this._processRequestBody(requestBody, account)
|
||||||
|
// 🧹 内存优化:存储到 bodyStore,不放入 requestOptions 避免闭包捕获
|
||||||
|
const originalBodyString = JSON.stringify(processedBody)
|
||||||
|
const bodyStoreId = ++this._bodyStoreIdCounter
|
||||||
|
this.bodyStore.set(bodyStoreId, originalBodyString)
|
||||||
|
|
||||||
// 获取代理配置
|
// 获取代理配置
|
||||||
const proxyAgent = await this._getProxyAgent(accountId)
|
const proxyAgent = await this._getProxyAgent(accountId)
|
||||||
@@ -1487,7 +1763,11 @@ class ClaudeRelayService {
|
|||||||
accountType,
|
accountType,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
streamTransformer,
|
streamTransformer,
|
||||||
options,
|
{
|
||||||
|
...options,
|
||||||
|
bodyStoreId,
|
||||||
|
isRealClaudeCodeRequest
|
||||||
|
},
|
||||||
isDedicatedOfficialAccount,
|
isDedicatedOfficialAccount,
|
||||||
// 📬 新增回调:在收到响应头时释放队列锁
|
// 📬 新增回调:在收到响应头时释放队列锁
|
||||||
async () => {
|
async () => {
|
||||||
@@ -1576,7 +1856,12 @@ class ClaudeRelayService {
|
|||||||
return prepared.abortResponse
|
return prepared.abortResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bodyString, headers } = prepared
|
let { bodyString } = prepared
|
||||||
|
const { headers, toolNameMap } = prepared
|
||||||
|
const toolNameStreamTransformer = this._createToolNameStripperStreamTransformer(
|
||||||
|
streamTransformer,
|
||||||
|
toolNameMap
|
||||||
|
)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = new URL(this.claudeApiUrl)
|
const url = new URL(this.claudeApiUrl)
|
||||||
@@ -1684,8 +1969,22 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 递归调用自身进行重试
|
// 递归调用自身进行重试
|
||||||
|
// 🧹 从 bodyStore 获取字符串用于重试
|
||||||
|
if (
|
||||||
|
!requestOptions.bodyStoreId ||
|
||||||
|
!this.bodyStore.has(requestOptions.bodyStoreId)
|
||||||
|
) {
|
||||||
|
throw new Error('529 retry requires valid bodyStoreId')
|
||||||
|
}
|
||||||
|
let retryBody
|
||||||
|
try {
|
||||||
|
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error(`❌ Failed to parse body for 529 retry: ${parseError.message}`)
|
||||||
|
throw new Error(`529 retry body parse failed: ${parseError.message}`)
|
||||||
|
}
|
||||||
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||||
body,
|
retryBody,
|
||||||
accessToken,
|
accessToken,
|
||||||
proxyAgent,
|
proxyAgent,
|
||||||
clientHeaders,
|
clientHeaders,
|
||||||
@@ -1780,11 +2079,48 @@ class ClaudeRelayService {
|
|||||||
errorData += chunk.toString()
|
errorData += chunk.toString()
|
||||||
})
|
})
|
||||||
|
|
||||||
res.on('end', () => {
|
res.on('end', async () => {
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
||||||
errorData
|
errorData
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
this._isClaudeCodeCredentialError(errorData) &&
|
||||||
|
requestOptions.useRandomizedToolNames !== true &&
|
||||||
|
requestOptions.bodyStoreId &&
|
||||||
|
this.bodyStore.has(requestOptions.bodyStoreId)
|
||||||
|
) {
|
||||||
|
let retryBody
|
||||||
|
try {
|
||||||
|
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error(`❌ Failed to parse body for 403 retry: ${parseError.message}`)
|
||||||
|
reject(new Error(`403 retry body parse failed: ${parseError.message}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||||
|
retryBody,
|
||||||
|
accessToken,
|
||||||
|
proxyAgent,
|
||||||
|
clientHeaders,
|
||||||
|
responseStream,
|
||||||
|
usageCallback,
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
sessionHash,
|
||||||
|
streamTransformer,
|
||||||
|
{ ...requestOptions, useRandomizedToolNames: true },
|
||||||
|
isDedicatedOfficialAccount,
|
||||||
|
onResponseStart,
|
||||||
|
retryCount
|
||||||
|
)
|
||||||
|
resolve(retryResult)
|
||||||
|
} catch (retryError) {
|
||||||
|
reject(retryError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (this._isOrganizationDisabledError(res.statusCode, errorData)) {
|
if (this._isOrganizationDisabledError(res.statusCode, errorData)) {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1819,7 +2155,7 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
// 如果有 streamTransformer(如测试请求),使用前端期望的格式
|
||||||
if (streamTransformer) {
|
if (toolNameStreamTransformer) {
|
||||||
responseStream.write(
|
responseStream.write(
|
||||||
`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`
|
`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`
|
||||||
)
|
)
|
||||||
@@ -1858,6 +2194,11 @@ class ClaudeRelayService {
|
|||||||
let rateLimitDetected = false // 限流检测标志
|
let rateLimitDetected = false // 限流检测标志
|
||||||
|
|
||||||
// 监听数据块,解析SSE并寻找usage信息
|
// 监听数据块,解析SSE并寻找usage信息
|
||||||
|
// 🧹 内存优化:在闭包创建前提取需要的值,避免闭包捕获 body 和 requestOptions
|
||||||
|
// body 和 requestOptions 只在闭包外使用,闭包内只引用基本类型
|
||||||
|
const requestedModel = body?.model || 'unknown'
|
||||||
|
const { isRealClaudeCodeRequest } = requestOptions
|
||||||
|
|
||||||
res.on('data', (chunk) => {
|
res.on('data', (chunk) => {
|
||||||
try {
|
try {
|
||||||
const chunkStr = chunk.toString()
|
const chunkStr = chunk.toString()
|
||||||
@@ -1873,8 +2214,8 @@ class ClaudeRelayService {
|
|||||||
if (isStreamWritable(responseStream)) {
|
if (isStreamWritable(responseStream)) {
|
||||||
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
|
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '')
|
||||||
// 如果有流转换器,应用转换
|
// 如果有流转换器,应用转换
|
||||||
if (streamTransformer) {
|
if (toolNameStreamTransformer) {
|
||||||
const transformed = streamTransformer(linesToForward)
|
const transformed = toolNameStreamTransformer(linesToForward)
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
responseStream.write(transformed)
|
responseStream.write(transformed)
|
||||||
}
|
}
|
||||||
@@ -2007,8 +2348,8 @@ class ClaudeRelayService {
|
|||||||
try {
|
try {
|
||||||
// 处理缓冲区中剩余的数据
|
// 处理缓冲区中剩余的数据
|
||||||
if (buffer.trim() && isStreamWritable(responseStream)) {
|
if (buffer.trim() && isStreamWritable(responseStream)) {
|
||||||
if (streamTransformer) {
|
if (toolNameStreamTransformer) {
|
||||||
const transformed = streamTransformer(buffer)
|
const transformed = toolNameStreamTransformer(buffer)
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
responseStream.write(transformed)
|
responseStream.write(transformed)
|
||||||
}
|
}
|
||||||
@@ -2063,7 +2404,7 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 打印原始的usage数据为JSON字符串,避免嵌套问题
|
// 打印原始的usage数据为JSON字符串,避免嵌套问题
|
||||||
logger.info(
|
logger.info(
|
||||||
`📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
|
`📊 === Stream Request Usage Summary === Model: ${requestedModel}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并
|
// 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并
|
||||||
@@ -2073,7 +2414,7 @@ class ClaudeRelayService {
|
|||||||
output_tokens: totalUsage.output_tokens,
|
output_tokens: totalUsage.output_tokens,
|
||||||
cache_creation_input_tokens: totalUsage.cache_creation_input_tokens,
|
cache_creation_input_tokens: totalUsage.cache_creation_input_tokens,
|
||||||
cache_read_input_tokens: totalUsage.cache_read_input_tokens,
|
cache_read_input_tokens: totalUsage.cache_read_input_tokens,
|
||||||
model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型
|
model: allUsageData[allUsageData.length - 1].model || requestedModel // 使用最后一个模型或请求模型
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有详细的cache_creation数据,合并它们
|
// 如果有详细的cache_creation数据,合并它们
|
||||||
@@ -2182,15 +2523,15 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
||||||
if (
|
if (clientHeaders && Object.keys(clientHeaders).length > 0 && isRealClaudeCodeRequest) {
|
||||||
clientHeaders &&
|
|
||||||
Object.keys(clientHeaders).length > 0 &&
|
|
||||||
this.isRealClaudeCodeRequest(body)
|
|
||||||
) {
|
|
||||||
await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders)
|
await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧹 清理 bodyStore
|
||||||
|
if (requestOptions.bodyStoreId) {
|
||||||
|
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||||
|
}
|
||||||
logger.debug('🌊 Claude stream response with usage capture completed')
|
logger.debug('🌊 Claude stream response with usage capture completed')
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
@@ -2247,6 +2588,10 @@ class ClaudeRelayService {
|
|||||||
)
|
)
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
|
// 🧹 清理 bodyStore
|
||||||
|
if (requestOptions.bodyStoreId) {
|
||||||
|
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||||
|
}
|
||||||
reject(error)
|
reject(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2276,6 +2621,10 @@ class ClaudeRelayService {
|
|||||||
)
|
)
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
|
// 🧹 清理 bodyStore
|
||||||
|
if (requestOptions.bodyStoreId) {
|
||||||
|
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||||
|
}
|
||||||
reject(new Error('Request timeout'))
|
reject(new Error('Request timeout'))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2289,6 +2638,8 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 写入请求体
|
// 写入请求体
|
||||||
req.write(bodyString)
|
req.write(bodyString)
|
||||||
|
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
|
||||||
|
bodyString = null
|
||||||
req.end()
|
req.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ class RateLimitCleanupService {
|
|||||||
const results = {
|
const results = {
|
||||||
openai: { checked: 0, cleared: 0, errors: [] },
|
openai: { checked: 0, cleared: 0, errors: [] },
|
||||||
claude: { checked: 0, cleared: 0, errors: [] },
|
claude: { checked: 0, cleared: 0, errors: [] },
|
||||||
claudeConsole: { checked: 0, cleared: 0, errors: [] }
|
claudeConsole: { checked: 0, cleared: 0, errors: [] },
|
||||||
|
tokenRefresh: { checked: 0, refreshed: 0, errors: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理 OpenAI 账号
|
// 清理 OpenAI 账号
|
||||||
@@ -84,21 +85,29 @@ class RateLimitCleanupService {
|
|||||||
// 清理 Claude Console 账号
|
// 清理 Claude Console 账号
|
||||||
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
await this.cleanupClaudeConsoleAccounts(results.claudeConsole)
|
||||||
|
|
||||||
|
// 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
|
||||||
|
await this.proactiveRefreshClaudeTokens(results.tokenRefresh)
|
||||||
|
|
||||||
const totalChecked =
|
const totalChecked =
|
||||||
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
results.openai.checked + results.claude.checked + results.claudeConsole.checked
|
||||||
const totalCleared =
|
const totalCleared =
|
||||||
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
results.openai.cleared + results.claude.cleared + results.claudeConsole.cleared
|
||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
if (totalCleared > 0) {
|
if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ Rate limit cleanup completed: ${totalCleared} accounts cleared out of ${totalChecked} checked (${duration}ms)`
|
`✅ Rate limit cleanup completed: ${totalCleared}/${totalChecked} accounts cleared, ${results.tokenRefresh.refreshed} tokens refreshed (${duration}ms)`
|
||||||
)
|
)
|
||||||
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`)
|
||||||
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`)
|
||||||
logger.info(
|
logger.info(
|
||||||
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}`
|
||||||
)
|
)
|
||||||
|
if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) {
|
||||||
|
logger.info(
|
||||||
|
` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 发送 webhook 恢复通知
|
// 发送 webhook 恢复通知
|
||||||
if (this.clearedAccounts.length > 0) {
|
if (this.clearedAccounts.length > 0) {
|
||||||
@@ -114,7 +123,8 @@ class RateLimitCleanupService {
|
|||||||
const allErrors = [
|
const allErrors = [
|
||||||
...results.openai.errors,
|
...results.openai.errors,
|
||||||
...results.claude.errors,
|
...results.claude.errors,
|
||||||
...results.claudeConsole.errors
|
...results.claudeConsole.errors,
|
||||||
|
...results.tokenRefresh.errors
|
||||||
]
|
]
|
||||||
if (allErrors.length > 0) {
|
if (allErrors.length > 0) {
|
||||||
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors)
|
||||||
@@ -348,6 +358,75 @@ class RateLimitCleanupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动刷新 Claude 账户 Token(防止等待重置期间 Token 过期)
|
||||||
|
* 仅对因限流/配额限制而等待重置的账户执行刷新:
|
||||||
|
* - 429 限流账户(rateLimitAutoStopped=true)
|
||||||
|
* - 5小时限制自动停止账户(fiveHourAutoStopped=true)
|
||||||
|
* 不处理错误状态账户(error/temp_error)
|
||||||
|
*/
|
||||||
|
async proactiveRefreshClaudeTokens(result) {
|
||||||
|
try {
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const accounts = await redis.getAllClaudeAccounts()
|
||||||
|
const now = Date.now()
|
||||||
|
const refreshAheadMs = 30 * 60 * 1000 // 提前30分钟刷新
|
||||||
|
const recentRefreshMs = 5 * 60 * 1000 // 5分钟内刷新过则跳过
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
// 1. 必须激活
|
||||||
|
if (account.isActive !== 'true') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 必须有 refreshToken
|
||||||
|
if (!account.refreshToken) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 【优化】仅处理因限流/配额限制而等待重置的账户
|
||||||
|
// 正常调度的账户会在请求时自动刷新,无需主动刷新
|
||||||
|
// 错误状态账户的 Token 可能已失效,刷新也会失败
|
||||||
|
const isWaitingForReset =
|
||||||
|
account.rateLimitAutoStopped === 'true' || // 429 限流
|
||||||
|
account.fiveHourAutoStopped === 'true' // 5小时限制自动停止
|
||||||
|
if (!isWaitingForReset) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 【优化】如果最近 5 分钟内已刷新,跳过(避免重复刷新)
|
||||||
|
const lastRefreshAt = account.lastRefreshAt ? new Date(account.lastRefreshAt).getTime() : 0
|
||||||
|
if (now - lastRefreshAt < recentRefreshMs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 检查 Token 是否即将过期(30分钟内)
|
||||||
|
const expiresAt = parseInt(account.expiresAt)
|
||||||
|
if (expiresAt && now < expiresAt - refreshAheadMs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 符合条件,执行刷新
|
||||||
|
result.checked++
|
||||||
|
try {
|
||||||
|
await claudeAccountService.refreshAccountToken(account.id)
|
||||||
|
result.refreshed++
|
||||||
|
logger.info(`🔄 Proactively refreshed token: ${account.name} (${account.id})`)
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
logger.warn(`⚠️ Proactive refresh failed for ${account.name}: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to proactively refresh Claude tokens:', error)
|
||||||
|
result.errors.push({ error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动触发一次清理(供 API 或 CLI 调用)
|
* 手动触发一次清理(供 API 或 CLI 调用)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const fs = require('fs/promises')
|
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const logger = require('./logger')
|
const logger = require('./logger')
|
||||||
const { getProjectRoot } = require('./projectPaths')
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||||
|
|
||||||
const REQUEST_DUMP_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP'
|
const REQUEST_DUMP_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP'
|
||||||
const REQUEST_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES'
|
const REQUEST_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES'
|
||||||
@@ -108,7 +108,7 @@ async function dumpAnthropicMessagesRequest(req, meta = {}) {
|
|||||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.appendFile(filename, line, { encoding: 'utf8' })
|
await safeRotatingAppend(filename, line)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Failed to dump Anthropic request', {
|
logger.warn('Failed to dump Anthropic request', {
|
||||||
filename,
|
filename,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const fs = require('fs/promises')
|
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const logger = require('./logger')
|
const logger = require('./logger')
|
||||||
const { getProjectRoot } = require('./projectPaths')
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||||
|
|
||||||
const RESPONSE_DUMP_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP'
|
const RESPONSE_DUMP_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP'
|
||||||
const RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES'
|
const RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES'
|
||||||
@@ -89,7 +89,7 @@ async function dumpAnthropicResponse(req, responseInfo, meta = {}) {
|
|||||||
|
|
||||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
try {
|
try {
|
||||||
await fs.appendFile(filename, line, { encoding: 'utf8' })
|
await safeRotatingAppend(filename, line)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Failed to dump Anthropic response', {
|
logger.warn('Failed to dump Anthropic response', {
|
||||||
filename,
|
filename,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const fs = require('fs/promises')
|
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const logger = require('./logger')
|
const logger = require('./logger')
|
||||||
const { getProjectRoot } = require('./projectPaths')
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||||
|
|
||||||
const UPSTREAM_REQUEST_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP'
|
const UPSTREAM_REQUEST_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP'
|
||||||
const UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES'
|
const UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES'
|
||||||
@@ -103,7 +103,7 @@ async function dumpAntigravityUpstreamRequest(requestInfo) {
|
|||||||
|
|
||||||
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
try {
|
try {
|
||||||
await fs.appendFile(filename, line, { encoding: 'utf8' })
|
await safeRotatingAppend(filename, line)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Failed to dump Antigravity upstream request', {
|
logger.warn('Failed to dump Antigravity upstream request', {
|
||||||
filename,
|
filename,
|
||||||
|
|||||||
175
src/utils/antigravityUpstreamResponseDump.js
Normal file
175
src/utils/antigravityUpstreamResponseDump.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const logger = require('./logger')
|
||||||
|
const { getProjectRoot } = require('./projectPaths')
|
||||||
|
const { safeRotatingAppend } = require('./safeRotatingAppend')
|
||||||
|
|
||||||
|
const UPSTREAM_RESPONSE_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP'
|
||||||
|
const UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP_MAX_BYTES'
|
||||||
|
const UPSTREAM_RESPONSE_DUMP_FILENAME = 'antigravity-upstream-responses-dump.jsonl'
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
const raw = process.env[UPSTREAM_RESPONSE_DUMP_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const normalized = String(raw).trim().toLowerCase()
|
||||||
|
return normalized === '1' || normalized === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxBytes() {
|
||||||
|
const raw = process.env[UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV]
|
||||||
|
if (!raw) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 2 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonStringify(payload, maxBytes) {
|
||||||
|
let json = ''
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(payload)
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'antigravity_upstream_response_dump_error',
|
||||||
|
error: 'JSON.stringify_failed',
|
||||||
|
message: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.byteLength(json, 'utf8') <= maxBytes) {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8')
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'antigravity_upstream_response_dump_truncated',
|
||||||
|
maxBytes,
|
||||||
|
originalBytes: Buffer.byteLength(json, 'utf8'),
|
||||||
|
partialJson: truncated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 Antigravity 上游 API 的响应
|
||||||
|
* @param {Object} responseInfo - 响应信息
|
||||||
|
* @param {string} responseInfo.requestId - 请求 ID
|
||||||
|
* @param {string} responseInfo.model - 模型名称
|
||||||
|
* @param {number} responseInfo.statusCode - HTTP 状态码
|
||||||
|
* @param {string} responseInfo.statusText - HTTP 状态文本
|
||||||
|
* @param {Object} responseInfo.headers - 响应头
|
||||||
|
* @param {string} responseInfo.responseType - 响应类型 (stream/non-stream/error)
|
||||||
|
* @param {Object} responseInfo.summary - 响应摘要
|
||||||
|
* @param {Object} responseInfo.error - 错误信息(如果有)
|
||||||
|
*/
|
||||||
|
async function dumpAntigravityUpstreamResponse(responseInfo) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'antigravity_upstream_response',
|
||||||
|
requestId: responseInfo?.requestId || null,
|
||||||
|
model: responseInfo?.model || null,
|
||||||
|
statusCode: responseInfo?.statusCode || null,
|
||||||
|
statusText: responseInfo?.statusText || null,
|
||||||
|
responseType: responseInfo?.responseType || null,
|
||||||
|
headers: responseInfo?.headers || null,
|
||||||
|
summary: responseInfo?.summary || null,
|
||||||
|
error: responseInfo?.error || null,
|
||||||
|
rawData: responseInfo?.rawData || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
try {
|
||||||
|
await safeRotatingAppend(filename, line)
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to dump Antigravity upstream response', {
|
||||||
|
filename,
|
||||||
|
requestId: responseInfo?.requestId || null,
|
||||||
|
error: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 SSE 流中的每个事件(用于详细调试)
|
||||||
|
*/
|
||||||
|
async function dumpAntigravityStreamEvent(eventInfo) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'antigravity_stream_event',
|
||||||
|
requestId: eventInfo?.requestId || null,
|
||||||
|
eventIndex: eventInfo?.eventIndex || null,
|
||||||
|
eventType: eventInfo?.eventType || null,
|
||||||
|
data: eventInfo?.data || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
try {
|
||||||
|
await safeRotatingAppend(filename, line)
|
||||||
|
} catch (e) {
|
||||||
|
// 静默处理,避免日志过多
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录流式响应的最终摘要
|
||||||
|
*/
|
||||||
|
async function dumpAntigravityStreamSummary(summaryInfo) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = getMaxBytes()
|
||||||
|
const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME)
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: 'antigravity_stream_summary',
|
||||||
|
requestId: summaryInfo?.requestId || null,
|
||||||
|
model: summaryInfo?.model || null,
|
||||||
|
totalEvents: summaryInfo?.totalEvents || 0,
|
||||||
|
finishReason: summaryInfo?.finishReason || null,
|
||||||
|
hasThinking: summaryInfo?.hasThinking || false,
|
||||||
|
hasToolCalls: summaryInfo?.hasToolCalls || false,
|
||||||
|
toolCallNames: summaryInfo?.toolCallNames || [],
|
||||||
|
usage: summaryInfo?.usage || null,
|
||||||
|
error: summaryInfo?.error || null,
|
||||||
|
textPreview: summaryInfo?.textPreview || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = `${safeJsonStringify(record, maxBytes)}\n`
|
||||||
|
try {
|
||||||
|
await safeRotatingAppend(filename, line)
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to dump Antigravity stream summary', {
|
||||||
|
filename,
|
||||||
|
requestId: summaryInfo?.requestId || null,
|
||||||
|
error: e?.message || String(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dumpAntigravityUpstreamResponse,
|
||||||
|
dumpAntigravityStreamEvent,
|
||||||
|
dumpAntigravityStreamSummary,
|
||||||
|
UPSTREAM_RESPONSE_DUMP_ENV,
|
||||||
|
UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV,
|
||||||
|
UPSTREAM_RESPONSE_DUMP_FILENAME
|
||||||
|
}
|
||||||
@@ -20,8 +20,9 @@ const parseBooleanEnv = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否允许执行“余额脚本”(安全开关)
|
* 是否允许执行"余额脚本"(安全开关)
|
||||||
* 默认开启,便于保持现有行为;如需禁用请显式设置 BALANCE_SCRIPT_ENABLED=false(环境变量优先)
|
* ⚠️ 安全警告:vm模块非安全沙箱,默认禁用。如需启用请显式设置 BALANCE_SCRIPT_ENABLED=true
|
||||||
|
* 仅在完全信任管理员且了解RCE风险时才启用此功能
|
||||||
*/
|
*/
|
||||||
const isBalanceScriptEnabled = () => {
|
const isBalanceScriptEnabled = () => {
|
||||||
if (
|
if (
|
||||||
@@ -36,7 +37,8 @@ const isBalanceScriptEnabled = () => {
|
|||||||
config?.features?.balanceScriptEnabled ??
|
config?.features?.balanceScriptEnabled ??
|
||||||
config?.security?.enableBalanceScript
|
config?.security?.enableBalanceScript
|
||||||
|
|
||||||
return typeof fromConfig === 'boolean' ? fromConfig : true
|
// 默认禁用,需显式启用
|
||||||
|
return typeof fromConfig === 'boolean' ? fromConfig : false
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
88
src/utils/safeRotatingAppend.js
Normal file
88
src/utils/safeRotatingAppend.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* 安全 JSONL 追加工具(带文件大小限制与自动轮转)
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* 用于所有调试 Dump 模块,避免日志文件无限增长导致 I/O 拥塞。
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* - 每次写入前检查目标文件大小
|
||||||
|
* - 超过阈值时,将现有文件重命名为 .bak(覆盖旧 .bak)
|
||||||
|
* - 然后写入新文件
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs/promises')
|
||||||
|
const logger = require('./logger')
|
||||||
|
|
||||||
|
// 默认文件大小上限:10MB
|
||||||
|
const DEFAULT_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
|
||||||
|
const MAX_FILE_SIZE_ENV = 'DUMP_MAX_FILE_SIZE_BYTES'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件大小上限(可通过环境变量覆盖)
|
||||||
|
*/
|
||||||
|
function getMaxFileSize() {
|
||||||
|
const raw = process.env[MAX_FILE_SIZE_ENV]
|
||||||
|
if (raw) {
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_MAX_FILE_SIZE_BYTES
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件大小,文件不存在时返回 0
|
||||||
|
*/
|
||||||
|
async function getFileSize(filepath) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(filepath)
|
||||||
|
return stat.size
|
||||||
|
} catch (e) {
|
||||||
|
// 文件不存在或无法读取
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全追加写入 JSONL 文件,支持自动轮转
|
||||||
|
*
|
||||||
|
* @param {string} filepath - 目标文件绝对路径
|
||||||
|
* @param {string} line - 要写入的单行(应以 \n 结尾)
|
||||||
|
* @param {Object} options - 可选配置
|
||||||
|
* @param {number} options.maxFileSize - 文件大小上限(字节),默认从环境变量或 10MB
|
||||||
|
*/
|
||||||
|
async function safeRotatingAppend(filepath, line, options = {}) {
|
||||||
|
const maxFileSize = options.maxFileSize || getMaxFileSize()
|
||||||
|
|
||||||
|
const currentSize = await getFileSize(filepath)
|
||||||
|
|
||||||
|
// 如果当前文件已达到或超过阈值,轮转
|
||||||
|
if (currentSize >= maxFileSize) {
|
||||||
|
const backupPath = `${filepath}.bak`
|
||||||
|
try {
|
||||||
|
// 先删除旧备份(如果存在)
|
||||||
|
await fs.unlink(backupPath).catch(() => {})
|
||||||
|
// 重命名当前文件为备份
|
||||||
|
await fs.rename(filepath, backupPath)
|
||||||
|
} catch (renameErr) {
|
||||||
|
// 轮转失败时记录警告日志,继续写入原文件
|
||||||
|
logger.warn('⚠️ Log rotation failed, continuing to write to original file', {
|
||||||
|
filepath,
|
||||||
|
backupPath,
|
||||||
|
error: renameErr?.message || String(renameErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加写入
|
||||||
|
await fs.appendFile(filepath, line, { encoding: 'utf8' })
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
safeRotatingAppend,
|
||||||
|
getMaxFileSize,
|
||||||
|
MAX_FILE_SIZE_ENV,
|
||||||
|
DEFAULT_MAX_FILE_SIZE_BYTES
|
||||||
|
}
|
||||||
183
src/utils/signatureCache.js
Normal file
183
src/utils/signatureCache.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Signature Cache - 签名缓存模块
|
||||||
|
*
|
||||||
|
* 用于缓存 Antigravity thinking block 的 thoughtSignature。
|
||||||
|
* Claude Code 客户端可能剥离非标准字段,导致多轮对话时签名丢失。
|
||||||
|
* 此模块按 sessionId + thinkingText 存储签名,便于后续请求恢复。
|
||||||
|
*
|
||||||
|
* 参考实现:
|
||||||
|
* - CLIProxyAPI: internal/cache/signature_cache.go
|
||||||
|
* - antigravity-claude-proxy: src/format/signature-cache.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const logger = require('./logger')
|
||||||
|
|
||||||
|
// 配置常量
|
||||||
|
const SIGNATURE_CACHE_TTL_MS = 60 * 60 * 1000 // 1 小时(同 CLIProxyAPI)
|
||||||
|
const MAX_ENTRIES_PER_SESSION = 100 // 每会话最大缓存条目
|
||||||
|
const MIN_SIGNATURE_LENGTH = 50 // 最小有效签名长度
|
||||||
|
const TEXT_HASH_LENGTH = 16 // 文本哈希长度(SHA256 前 16 位)
|
||||||
|
|
||||||
|
// 主缓存:sessionId -> Map<textHash, { signature, timestamp }>
|
||||||
|
const signatureCache = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文本内容的稳定哈希值
|
||||||
|
* @param {string} text - 待哈希的文本
|
||||||
|
* @returns {string} 16 字符的十六进制哈希
|
||||||
|
*/
|
||||||
|
function hashText(text) {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const hash = crypto.createHash('sha256').update(text).digest('hex')
|
||||||
|
return hash.slice(0, TEXT_HASH_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建会话缓存
|
||||||
|
* @param {string} sessionId - 会话 ID
|
||||||
|
* @returns {Map} 会话的签名缓存 Map
|
||||||
|
*/
|
||||||
|
function getOrCreateSessionCache(sessionId) {
|
||||||
|
if (!signatureCache.has(sessionId)) {
|
||||||
|
signatureCache.set(sessionId, new Map())
|
||||||
|
}
|
||||||
|
return signatureCache.get(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查签名是否有效
|
||||||
|
* @param {string} signature - 待检查的签名
|
||||||
|
* @returns {boolean} 签名是否有效
|
||||||
|
*/
|
||||||
|
function isValidSignature(signature) {
|
||||||
|
return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存 thinking 签名
|
||||||
|
* @param {string} sessionId - 会话 ID
|
||||||
|
* @param {string} thinkingText - thinking 内容文本
|
||||||
|
* @param {string} signature - thoughtSignature
|
||||||
|
*/
|
||||||
|
function cacheSignature(sessionId, thinkingText, signature) {
|
||||||
|
if (!sessionId || !thinkingText || !signature) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidSignature(signature)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCache = getOrCreateSessionCache(sessionId)
|
||||||
|
const textHash = hashText(thinkingText)
|
||||||
|
|
||||||
|
if (!textHash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 淘汰策略:超过限制时删除最老的 1/4 条目
|
||||||
|
if (sessionCache.size >= MAX_ENTRIES_PER_SESSION) {
|
||||||
|
const entries = Array.from(sessionCache.entries())
|
||||||
|
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
|
||||||
|
const toRemove = Math.max(1, Math.floor(entries.length / 4))
|
||||||
|
for (let i = 0; i < toRemove; i++) {
|
||||||
|
sessionCache.delete(entries[i][0])
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`[SignatureCache] Evicted ${toRemove} old entries for session ${sessionId.slice(0, 8)}...`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCache.set(textHash, {
|
||||||
|
signature,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[SignatureCache] Cached signature for session ${sessionId.slice(0, 8)}..., hash ${textHash}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的签名
|
||||||
|
* @param {string} sessionId - 会话 ID
|
||||||
|
* @param {string} thinkingText - thinking 内容文本
|
||||||
|
* @returns {string|null} 缓存的签名,未找到或过期则返回 null
|
||||||
|
*/
|
||||||
|
function getCachedSignature(sessionId, thinkingText) {
|
||||||
|
if (!sessionId || !thinkingText) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCache = signatureCache.get(sessionId)
|
||||||
|
if (!sessionCache) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const textHash = hashText(thinkingText)
|
||||||
|
if (!textHash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = sessionCache.get(textHash)
|
||||||
|
if (!entry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) {
|
||||||
|
sessionCache.delete(textHash)
|
||||||
|
logger.debug(`[SignatureCache] Entry expired for hash ${textHash}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[SignatureCache] Cache hit for session ${sessionId.slice(0, 8)}..., hash ${textHash}`
|
||||||
|
)
|
||||||
|
return entry.signature
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除会话缓存
|
||||||
|
* @param {string} sessionId - 要清除的会话 ID,为空则清除全部
|
||||||
|
*/
|
||||||
|
function clearSignatureCache(sessionId = null) {
|
||||||
|
if (sessionId) {
|
||||||
|
signatureCache.delete(sessionId)
|
||||||
|
logger.debug(`[SignatureCache] Cleared cache for session ${sessionId.slice(0, 8)}...`)
|
||||||
|
} else {
|
||||||
|
signatureCache.clear()
|
||||||
|
logger.debug('[SignatureCache] Cleared all caches')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存统计信息(调试用)
|
||||||
|
* @returns {Object} { sessionCount, totalEntries }
|
||||||
|
*/
|
||||||
|
function getCacheStats() {
|
||||||
|
let totalEntries = 0
|
||||||
|
for (const sessionCache of signatureCache.values()) {
|
||||||
|
totalEntries += sessionCache.size
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sessionCount: signatureCache.size,
|
||||||
|
totalEntries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cacheSignature,
|
||||||
|
getCachedSignature,
|
||||||
|
clearSignatureCache,
|
||||||
|
getCacheStats,
|
||||||
|
isValidSignature,
|
||||||
|
// 内部函数导出(用于测试或扩展)
|
||||||
|
hashText,
|
||||||
|
MIN_SIGNATURE_LENGTH,
|
||||||
|
MAX_ENTRIES_PER_SESSION,
|
||||||
|
SIGNATURE_CACHE_TTL_MS
|
||||||
|
}
|
||||||
15
web/admin-spa/package-lock.json
generated
15
web/admin-spa/package-lock.json
generated
@@ -1157,6 +1157,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
@@ -1351,6 +1352,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1587,6 +1589,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -3060,13 +3063,15 @@
|
|||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -3618,6 +3623,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3764,6 +3770,7 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -4028,6 +4035,7 @@
|
|||||||
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4525,6 +4533,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4915,6 +4924,7 @@
|
|||||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -5115,6 +5125,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
||||||
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.18",
|
"@vue/compiler-dom": "3.5.18",
|
||||||
"@vue/compiler-sfc": "3.5.18",
|
"@vue/compiler-sfc": "3.5.18",
|
||||||
|
|||||||
@@ -852,41 +852,194 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bedrock 特定字段 -->
|
<!-- Bedrock 特定字段 -->
|
||||||
<div v-if="form.platform === 'bedrock' && !isEdit" class="space-y-4">
|
<div v-if="form.platform === 'bedrock'" class="space-y-4">
|
||||||
|
<!-- 凭证类型选择器 -->
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>AWS 访问密钥 ID *</label
|
>凭证类型 *</label
|
||||||
|
>
|
||||||
|
<div v-if="!isEdit" class="flex gap-4">
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.credentialType"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="radio"
|
||||||
|
value="access_key"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>AWS Access Key(访问密钥)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.credentialType"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="radio"
|
||||||
|
value="bearer_token"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>Bearer Token(长期令牌)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex gap-4">
|
||||||
|
<label class="flex items-center opacity-60">
|
||||||
|
<input
|
||||||
|
v-model="form.credentialType"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
disabled
|
||||||
|
type="radio"
|
||||||
|
value="access_key"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>AWS Access Key(访问密钥)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center opacity-60">
|
||||||
|
<input
|
||||||
|
v-model="form.credentialType"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
disabled
|
||||||
|
type="radio"
|
||||||
|
value="bearer_token"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>Bearer Token(长期令牌)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
|
||||||
|
<div class="text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
<p v-if="form.credentialType === 'access_key'" class="font-medium">
|
||||||
|
使用 AWS Access Key ID 和 Secret Access Key 进行身份验证(支持临时凭证)
|
||||||
|
</p>
|
||||||
|
<p v-else class="font-medium">
|
||||||
|
使用 AWS Bedrock API Keys 生成的 Bearer Token
|
||||||
|
进行身份验证,更简单、权限范围更小
|
||||||
|
</p>
|
||||||
|
<p v-if="isEdit" class="mt-1 text-xs italic">
|
||||||
|
💡 编辑模式下凭证类型不可更改,如需切换类型请重新创建账户
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AWS Access Key 字段(仅在 access_key 模式下显示)-->
|
||||||
|
<div v-if="form.credentialType === 'access_key'">
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>AWS 访问密钥 ID {{ isEdit ? '' : '*' }}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.accessKeyId"
|
v-model="form.accessKeyId"
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
:class="{ 'border-red-500': errors.accessKeyId }"
|
:class="{ 'border-red-500': errors.accessKeyId }"
|
||||||
placeholder="请输入 AWS Access Key ID"
|
:placeholder="isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Access Key ID'"
|
||||||
required
|
:required="!isEdit"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
|
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
|
||||||
{{ errors.accessKeyId }}
|
{{ errors.accessKeyId }}
|
||||||
</p>
|
</p>
|
||||||
|
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
💡 编辑模式下,留空则保持原有 Access Key ID 不变
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>AWS 秘密访问密钥 *</label
|
>AWS 秘密访问密钥 {{ isEdit ? '' : '*' }}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.secretAccessKey"
|
v-model="form.secretAccessKey"
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
:class="{ 'border-red-500': errors.secretAccessKey }"
|
:class="{ 'border-red-500': errors.secretAccessKey }"
|
||||||
placeholder="请输入 AWS Secret Access Key"
|
:placeholder="
|
||||||
required
|
isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Secret Access Key'
|
||||||
|
"
|
||||||
|
:required="!isEdit"
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
|
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
|
||||||
{{ errors.secretAccessKey }}
|
{{ errors.secretAccessKey }}
|
||||||
</p>
|
</p>
|
||||||
|
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
💡 编辑模式下,留空则保持原有 Secret Access Key 不变
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>会话令牌 (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.sessionToken"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
:placeholder="
|
||||||
|
isEdit
|
||||||
|
? '留空则保持原有 Session Token 不变'
|
||||||
|
: '如果使用临时凭证,请输入会话令牌'
|
||||||
|
"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
仅在使用临时 AWS 凭证时需要填写
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bearer Token 字段(仅在 bearer_token 模式下显示)-->
|
||||||
|
<div v-if="form.credentialType === 'bearer_token'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>Bearer Token {{ isEdit ? '' : '*' }}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.bearerToken"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
:class="{ 'border-red-500': errors.bearerToken }"
|
||||||
|
:placeholder="
|
||||||
|
isEdit ? '留空则保持原有 Bearer Token 不变' : '请输入 AWS Bearer Token'
|
||||||
|
"
|
||||||
|
:required="!isEdit"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<p v-if="errors.bearerToken" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ errors.bearerToken }}
|
||||||
|
</p>
|
||||||
|
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
💡 编辑模式下,留空则保持原有 Bearer Token 不变
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="mt-2 rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-700 dark:bg-green-900/30"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i class="fas fa-key mt-0.5 text-green-600 dark:text-green-400" />
|
||||||
|
<div class="text-xs text-green-700 dark:text-green-300">
|
||||||
|
<p class="mb-1 font-medium">Bearer Token 说明:</p>
|
||||||
|
<ul class="list-inside list-disc space-y-1 text-xs">
|
||||||
|
<li>输入 AWS Bedrock API Keys 生成的 Bearer Token</li>
|
||||||
|
<li>Bearer Token 仅限 Bedrock 服务访问,权限范围更小</li>
|
||||||
|
<li>相比 Access Key 更简单,无需 Secret Key</li>
|
||||||
|
<li>
|
||||||
|
参考:<a
|
||||||
|
class="text-green-600 underline dark:text-green-400"
|
||||||
|
href="https://aws.amazon.com/cn/blogs/machine-learning/accelerate-ai-development-with-amazon-bedrock-api-keys/"
|
||||||
|
target="_blank"
|
||||||
|
>AWS 官方文档</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AWS 区域(两种凭证类型都需要)-->
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>AWS 区域 *</label
|
>AWS 区域 *</label
|
||||||
@@ -902,10 +1055,12 @@
|
|||||||
<p v-if="errors.region" class="mt-1 text-xs text-red-500">
|
<p v-if="errors.region" class="mt-1 text-xs text-red-500">
|
||||||
{{ errors.region }}
|
{{ errors.region }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
<div
|
||||||
|
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
|
||||||
|
>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<i class="fas fa-info-circle mt-0.5 text-blue-600" />
|
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
|
||||||
<div class="text-xs text-blue-700">
|
<div class="text-xs text-blue-700 dark:text-blue-300">
|
||||||
<p class="mb-1 font-medium">常用 AWS 区域参考:</p>
|
<p class="mb-1 font-medium">常用 AWS 区域参考:</p>
|
||||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||||
<span>• us-east-1 (美国东部)</span>
|
<span>• us-east-1 (美国东部)</span>
|
||||||
@@ -915,26 +1070,13 @@
|
|||||||
<span>• ap-northeast-1 (东京)</span>
|
<span>• ap-northeast-1 (东京)</span>
|
||||||
<span>• eu-central-1 (法兰克福)</span>
|
<span>• eu-central-1 (法兰克福)</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-blue-600">💡 请输入完整的区域代码,如 us-east-1</p>
|
<p class="mt-2 text-blue-600 dark:text-blue-400">
|
||||||
</div>
|
💡 请输入完整的区域代码,如 us-east-1
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
|
||||||
>会话令牌 (可选)</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.sessionToken"
|
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
|
||||||
placeholder="如果使用临时凭证,请输入会话令牌"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
仅在使用临时 AWS 凭证时需要填写
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
@@ -4105,10 +4247,12 @@ const form = ref({
|
|||||||
// 并发控制字段
|
// 并发控制字段
|
||||||
maxConcurrentTasks: props.account?.maxConcurrentTasks || 0,
|
maxConcurrentTasks: props.account?.maxConcurrentTasks || 0,
|
||||||
// Bedrock 特定字段
|
// Bedrock 特定字段
|
||||||
|
credentialType: props.account?.credentialType || 'access_key', // 'access_key' 或 'bearer_token'
|
||||||
accessKeyId: props.account?.accessKeyId || '',
|
accessKeyId: props.account?.accessKeyId || '',
|
||||||
secretAccessKey: props.account?.secretAccessKey || '',
|
secretAccessKey: props.account?.secretAccessKey || '',
|
||||||
region: props.account?.region || '',
|
region: props.account?.region || '',
|
||||||
sessionToken: props.account?.sessionToken || '',
|
sessionToken: props.account?.sessionToken || '',
|
||||||
|
bearerToken: props.account?.bearerToken || '', // Bearer Token 字段
|
||||||
defaultModel: props.account?.defaultModel || '',
|
defaultModel: props.account?.defaultModel || '',
|
||||||
smallFastModel: props.account?.smallFastModel || '',
|
smallFastModel: props.account?.smallFastModel || '',
|
||||||
// Azure OpenAI 特定字段
|
// Azure OpenAI 特定字段
|
||||||
@@ -4271,6 +4415,7 @@ const errors = ref({
|
|||||||
accessKeyId: '',
|
accessKeyId: '',
|
||||||
secretAccessKey: '',
|
secretAccessKey: '',
|
||||||
region: '',
|
region: '',
|
||||||
|
bearerToken: '',
|
||||||
azureEndpoint: '',
|
azureEndpoint: '',
|
||||||
deploymentName: ''
|
deploymentName: ''
|
||||||
})
|
})
|
||||||
@@ -4983,7 +5128,10 @@ const createAccount = async () => {
|
|||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
} else if (form.value.platform === 'bedrock') {
|
} else if (form.value.platform === 'bedrock') {
|
||||||
// Bedrock 验证
|
// Bedrock 验证 - 根据凭证类型进行不同验证
|
||||||
|
if (form.value.credentialType === 'access_key') {
|
||||||
|
// Access Key 模式:创建时必填,编辑时可选(留空则保持原有凭证)
|
||||||
|
if (!isEdit.value) {
|
||||||
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
|
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
|
||||||
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
|
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
|
||||||
hasError = true
|
hasError = true
|
||||||
@@ -4992,6 +5140,16 @@ const createAccount = async () => {
|
|||||||
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
|
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
|
||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else if (form.value.credentialType === 'bearer_token') {
|
||||||
|
// Bearer Token 模式:创建时必填,编辑时可选(留空则保持原有凭证)
|
||||||
|
if (!isEdit.value) {
|
||||||
|
if (!form.value.bearerToken || form.value.bearerToken.trim() === '') {
|
||||||
|
errors.value.bearerToken = '请填写 Bearer Token'
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!form.value.region || form.value.region.trim() === '') {
|
if (!form.value.region || form.value.region.trim() === '') {
|
||||||
errors.value.region = '请选择 AWS 区域'
|
errors.value.region = '请选择 AWS 区域'
|
||||||
hasError = true
|
hasError = true
|
||||||
@@ -5246,12 +5404,21 @@ const createAccount = async () => {
|
|||||||
? form.value.supportedModels
|
? form.value.supportedModels
|
||||||
: []
|
: []
|
||||||
} else if (form.value.platform === 'bedrock') {
|
} else if (form.value.platform === 'bedrock') {
|
||||||
// Bedrock 账户特定数据 - 构造 awsCredentials 对象
|
// Bedrock 账户特定数据
|
||||||
|
data.credentialType = form.value.credentialType || 'access_key'
|
||||||
|
|
||||||
|
// 根据凭证类型构造不同的凭证对象
|
||||||
|
if (form.value.credentialType === 'access_key') {
|
||||||
data.awsCredentials = {
|
data.awsCredentials = {
|
||||||
accessKeyId: form.value.accessKeyId,
|
accessKeyId: form.value.accessKeyId,
|
||||||
secretAccessKey: form.value.secretAccessKey,
|
secretAccessKey: form.value.secretAccessKey,
|
||||||
sessionToken: form.value.sessionToken || null
|
sessionToken: form.value.sessionToken || null
|
||||||
}
|
}
|
||||||
|
} else if (form.value.credentialType === 'bearer_token') {
|
||||||
|
// Bearer Token 模式:必须传递 Bearer Token
|
||||||
|
data.bearerToken = form.value.bearerToken
|
||||||
|
}
|
||||||
|
|
||||||
data.region = form.value.region
|
data.region = form.value.region
|
||||||
data.defaultModel = form.value.defaultModel || null
|
data.defaultModel = form.value.defaultModel || null
|
||||||
data.smallFastModel = form.value.smallFastModel || null
|
data.smallFastModel = form.value.smallFastModel || null
|
||||||
@@ -5579,6 +5746,13 @@ const updateAccount = async () => {
|
|||||||
|
|
||||||
// Bedrock 特定更新
|
// Bedrock 特定更新
|
||||||
if (props.account.platform === 'bedrock') {
|
if (props.account.platform === 'bedrock') {
|
||||||
|
// 更新凭证类型
|
||||||
|
if (form.value.credentialType) {
|
||||||
|
data.credentialType = form.value.credentialType
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据凭证类型更新凭证
|
||||||
|
if (form.value.credentialType === 'access_key') {
|
||||||
// 只有当有凭证变更时才构造 awsCredentials 对象
|
// 只有当有凭证变更时才构造 awsCredentials 对象
|
||||||
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
|
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
|
||||||
data.awsCredentials = {}
|
data.awsCredentials = {}
|
||||||
@@ -5592,6 +5766,13 @@ const updateAccount = async () => {
|
|||||||
data.awsCredentials.sessionToken = form.value.sessionToken || null
|
data.awsCredentials.sessionToken = form.value.sessionToken || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (form.value.credentialType === 'bearer_token') {
|
||||||
|
// Bearer Token 模式:更新 Bearer Token(编辑时可选,留空则保留原有凭证)
|
||||||
|
if (form.value.bearerToken && form.value.bearerToken.trim()) {
|
||||||
|
data.bearerToken = form.value.bearerToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (form.value.region) {
|
if (form.value.region) {
|
||||||
data.region = form.value.region
|
data.region = form.value.region
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,22 @@
|
|||||||
{{ platformLabel }}
|
{{ platformLabel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Bedrock 账号类型 -->
|
||||||
|
<div
|
||||||
|
v-if="props.account?.platform === 'bedrock'"
|
||||||
|
class="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">账号类型</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
credentialTypeBadgeClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i :class="credentialTypeIcon" />
|
||||||
|
{{ credentialTypeLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
|
||||||
@@ -209,13 +225,15 @@ const platformLabel = computed(() => {
|
|||||||
const platform = props.account.platform
|
const platform = props.account.platform
|
||||||
if (platform === 'claude') return 'Claude OAuth'
|
if (platform === 'claude') return 'Claude OAuth'
|
||||||
if (platform === 'claude-console') return 'Claude Console'
|
if (platform === 'claude-console') return 'Claude Console'
|
||||||
|
if (platform === 'bedrock') return 'AWS Bedrock'
|
||||||
return platform
|
return platform
|
||||||
})
|
})
|
||||||
|
|
||||||
const platformIcon = computed(() => {
|
const platformIcon = computed(() => {
|
||||||
if (!props.account) return 'fas fa-question'
|
if (!props.account) return 'fas fa-question'
|
||||||
const platform = props.account.platform
|
const platform = props.account.platform
|
||||||
if (platform === 'claude' || platform === 'claude-console') return 'fas fa-brain'
|
if (platform === 'claude' || platform === 'claude-console' || platform === 'bedrock')
|
||||||
|
return 'fas fa-brain'
|
||||||
return 'fas fa-robot'
|
return 'fas fa-robot'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -228,6 +246,39 @@ const platformBadgeClass = computed(() => {
|
|||||||
if (platform === 'claude-console') {
|
if (platform === 'claude-console') {
|
||||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
|
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
|
||||||
}
|
}
|
||||||
|
if (platform === 'bedrock') {
|
||||||
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
|
||||||
|
}
|
||||||
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bedrock 账号类型相关
|
||||||
|
const credentialTypeLabel = computed(() => {
|
||||||
|
if (!props.account || props.account.platform !== 'bedrock') return ''
|
||||||
|
const credentialType = props.account.credentialType
|
||||||
|
if (credentialType === 'access_key') return 'Access Key'
|
||||||
|
if (credentialType === 'bearer_token') return 'Bearer Token'
|
||||||
|
return 'Unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
const credentialTypeIcon = computed(() => {
|
||||||
|
if (!props.account || props.account.platform !== 'bedrock') return ''
|
||||||
|
const credentialType = props.account.credentialType
|
||||||
|
if (credentialType === 'access_key') return 'fas fa-key'
|
||||||
|
if (credentialType === 'bearer_token') return 'fas fa-ticket'
|
||||||
|
return 'fas fa-question'
|
||||||
|
})
|
||||||
|
|
||||||
|
const credentialTypeBadgeClass = computed(() => {
|
||||||
|
if (!props.account || props.account.platform !== 'bedrock')
|
||||||
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
const credentialType = props.account.credentialType
|
||||||
|
if (credentialType === 'access_key') {
|
||||||
|
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||||
|
}
|
||||||
|
if (credentialType === 'bearer_token') {
|
||||||
|
return 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
|
||||||
|
}
|
||||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -346,6 +397,9 @@ function getTestEndpoint() {
|
|||||||
if (platform === 'claude-console') {
|
if (platform === 'claude-console') {
|
||||||
return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`
|
return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`
|
||||||
}
|
}
|
||||||
|
if (platform === 'bedrock') {
|
||||||
|
return `${API_PREFIX}/admin/bedrock-accounts/${props.account.id}/test`
|
||||||
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +523,7 @@ function handleClose() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听show变化,重置状态
|
// 监听show变化,重置状态并设置测试模型
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
@@ -478,6 +532,21 @@ watch(
|
|||||||
responseText.value = ''
|
responseText.value = ''
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
testDuration.value = 0
|
testDuration.value = 0
|
||||||
|
|
||||||
|
// 根据平台和账号类型设置测试模型
|
||||||
|
if (props.account?.platform === 'bedrock') {
|
||||||
|
const credentialType = props.account.credentialType
|
||||||
|
if (credentialType === 'bearer_token') {
|
||||||
|
// Bearer Token 模式使用 Sonnet 4.5
|
||||||
|
testModel.value = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
|
||||||
|
} else {
|
||||||
|
// Access Key 模式使用 Haiku(更快更便宜)
|
||||||
|
testModel.value = 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 其他平台使用默认模型
|
||||||
|
testModel.value = 'claude-sonnet-4-5-20250929'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -364,7 +364,8 @@ const platformLabelMap = {
|
|||||||
'openai-responses': 'OpenAI Responses',
|
'openai-responses': 'OpenAI Responses',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
'gemini-api': 'Gemini API',
|
'gemini-api': 'Gemini API',
|
||||||
droid: 'Droid'
|
droid: 'Droid',
|
||||||
|
bedrock: 'Claude AWS Bedrock'
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformLabel = computed(() => platformLabelMap[props.account?.platform] || '未知平台')
|
const platformLabel = computed(() => platformLabelMap[props.account?.platform] || '未知平台')
|
||||||
|
|||||||
@@ -52,10 +52,51 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 配额(如适用) -->
|
<!-- 配额(如适用) -->
|
||||||
<div v-if="quotaInfo" class="space-y-1">
|
<div v-if="quotaInfo && isAntigravityQuota" class="space-y-2">
|
||||||
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||||
<span>已用: {{ formatNumber(quotaInfo.used) }}</span>
|
<span>剩余</span>
|
||||||
<span>剩余: {{ formatNumber(quotaInfo.remaining) }}</span>
|
<span>{{ formatQuotaNumber(quotaInfo.remaining) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="row in antigravityRows"
|
||||||
|
:key="row.category"
|
||||||
|
class="flex items-center gap-2 rounded-md bg-gray-50 px-2 py-1.5 dark:bg-gray-700/60"
|
||||||
|
>
|
||||||
|
<span class="h-2 w-2 shrink-0 rounded-full" :class="row.dotClass"></span>
|
||||||
|
<span
|
||||||
|
class="min-w-0 flex-1 truncate text-xs font-medium text-gray-800 dark:text-gray-100"
|
||||||
|
:title="row.category"
|
||||||
|
>
|
||||||
|
{{ row.category }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex w-[94px] flex-col gap-0.5">
|
||||||
|
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-600">
|
||||||
|
<div
|
||||||
|
class="h-1.5 rounded-full transition-all"
|
||||||
|
:class="row.barClass"
|
||||||
|
:style="{ width: `${row.remainingPercent ?? 0}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<span>{{ row.remainingText }}</span>
|
||||||
|
<span v-if="row.resetAt" class="text-gray-400 dark:text-gray-400">{{
|
||||||
|
formatResetTime(row.resetAt)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="quotaInfo" class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span>已用: {{ formatQuotaNumber(quotaInfo.used) }}</span>
|
||||||
|
<span>剩余: {{ formatQuotaNumber(quotaInfo.remaining) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
<div
|
<div
|
||||||
@@ -100,7 +141,8 @@ const props = defineProps({
|
|||||||
platform: { type: String, required: true },
|
platform: { type: String, required: true },
|
||||||
initialBalance: { type: Object, default: null },
|
initialBalance: { type: Object, default: null },
|
||||||
hideRefresh: { type: Boolean, default: false },
|
hideRefresh: { type: Boolean, default: false },
|
||||||
autoLoad: { type: Boolean, default: true }
|
autoLoad: { type: Boolean, default: true },
|
||||||
|
queryMode: { type: String, default: 'local' } // local | auto | api
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['refreshed', 'error'])
|
const emit = defineEmits(['refreshed', 'error'])
|
||||||
@@ -136,6 +178,43 @@ const quotaInfo = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isAntigravityQuota = computed(() => {
|
||||||
|
return balanceData.value?.quota?.type === 'antigravity'
|
||||||
|
})
|
||||||
|
|
||||||
|
const antigravityRows = computed(() => {
|
||||||
|
if (!isAntigravityQuota.value) return []
|
||||||
|
|
||||||
|
const buckets = balanceData.value?.quota?.buckets
|
||||||
|
const list = Array.isArray(buckets) ? buckets : []
|
||||||
|
const map = new Map(list.map((b) => [b?.category, b]))
|
||||||
|
|
||||||
|
const order = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
|
||||||
|
const styles = {
|
||||||
|
'Gemini Pro': { dotClass: 'bg-blue-500', barClass: 'bg-blue-500 dark:bg-blue-400' },
|
||||||
|
Claude: { dotClass: 'bg-purple-500', barClass: 'bg-purple-500 dark:bg-purple-400' },
|
||||||
|
'Gemini Flash': { dotClass: 'bg-cyan-500', barClass: 'bg-cyan-500 dark:bg-cyan-400' },
|
||||||
|
'Gemini Image': { dotClass: 'bg-emerald-500', barClass: 'bg-emerald-500 dark:bg-emerald-400' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return order.map((category) => {
|
||||||
|
const raw = map.get(category) || null
|
||||||
|
const remaining = raw?.remaining
|
||||||
|
const remainingPercent = Number.isFinite(Number(remaining))
|
||||||
|
? Math.max(0, Math.min(100, Number(remaining)))
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
remainingPercent,
|
||||||
|
remainingText: remainingPercent === null ? '—' : `${Math.round(remainingPercent)}%`,
|
||||||
|
resetAt: raw?.resetAt || null,
|
||||||
|
dotClass: styles[category]?.dotClass || 'bg-gray-400',
|
||||||
|
barClass: styles[category]?.barClass || 'bg-gray-400'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const quotaBarClass = computed(() => {
|
const quotaBarClass = computed(() => {
|
||||||
const percentage = quotaInfo.value?.percentage || 0
|
const percentage = quotaInfo.value?.percentage || 0
|
||||||
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
|
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
|
||||||
@@ -144,7 +223,12 @@ const quotaBarClass = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canRefresh = computed(() => {
|
const canRefresh = computed(() => {
|
||||||
// 仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
|
// antigravity 配额:允许直接触发 Provider 刷新(无需脚本)
|
||||||
|
if (props.queryMode === 'api' || props.queryMode === 'auto') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他平台:仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
|
||||||
const data = balanceData.value
|
const data = balanceData.value
|
||||||
if (!data) return false
|
if (!data) return false
|
||||||
if (data.scriptEnabled === false) return false
|
if (data.scriptEnabled === false) return false
|
||||||
@@ -159,6 +243,9 @@ const refreshTitle = computed(() => {
|
|||||||
}
|
}
|
||||||
return '请先配置余额脚本'
|
return '请先配置余额脚本'
|
||||||
}
|
}
|
||||||
|
if (isAntigravityQuota.value) {
|
||||||
|
return '刷新配额(调用 Antigravity API)'
|
||||||
|
}
|
||||||
return '刷新余额(调用脚本配置的余额 API)'
|
return '刷新余额(调用脚本配置的余额 API)'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,7 +266,10 @@ const load = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
|
const response = await apiClient.get(`/admin/accounts/${props.accountId}/balance`, {
|
||||||
params: { platform: props.platform, queryApi: false }
|
params: {
|
||||||
|
platform: props.platform,
|
||||||
|
queryApi: props.queryMode === 'api' ? true : props.queryMode === 'auto' ? 'auto' : false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if (response?.success) {
|
if (response?.success) {
|
||||||
balanceData.value = response.data
|
balanceData.value = response.data
|
||||||
@@ -231,6 +321,16 @@ const formatNumber = (num) => {
|
|||||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
return value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatQuotaNumber = (num) => {
|
||||||
|
if (num === Infinity) return '∞'
|
||||||
|
const value = Number(num)
|
||||||
|
if (!Number.isFinite(value)) return 'N/A'
|
||||||
|
if (isAntigravityQuota.value) {
|
||||||
|
return `${Math.round(value)}%`
|
||||||
|
}
|
||||||
|
return formatNumber(value)
|
||||||
|
}
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
const value = Number(amount)
|
const value = Number(amount)
|
||||||
if (!Number.isFinite(value)) return '$0.00'
|
if (!Number.isFinite(value)) return '$0.00'
|
||||||
|
|||||||
@@ -287,7 +287,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gemini OAuth流程 -->
|
<!-- Gemini OAuth流程 -->
|
||||||
<div v-else-if="platform === 'gemini'">
|
<div v-else-if="platform === 'gemini' || platform === 'gemini-antigravity'">
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-700 dark:bg-green-900/30"
|
class="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-700 dark:bg-green-900/30"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1233,13 +1233,28 @@ onMounted(async () => {
|
|||||||
form.totalCostLimit = props.apiKey.totalCostLimit || ''
|
form.totalCostLimit = props.apiKey.totalCostLimit || ''
|
||||||
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
||||||
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
|
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
|
||||||
const perms = props.apiKey.permissions
|
// 有效的权限值
|
||||||
|
const VALID_PERMS = ['claude', 'gemini', 'openai', 'droid']
|
||||||
|
let perms = props.apiKey.permissions
|
||||||
|
// 如果是字符串,尝试 JSON.parse(Redis 可能返回 "[]" 或 "[\"gemini\"]")
|
||||||
|
if (typeof perms === 'string') {
|
||||||
|
if (perms === 'all' || perms === '') {
|
||||||
|
perms = []
|
||||||
|
} else if (perms.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
perms = JSON.parse(perms)
|
||||||
|
} catch {
|
||||||
|
perms = VALID_PERMS.includes(perms) ? [perms] : []
|
||||||
|
}
|
||||||
|
} else if (VALID_PERMS.includes(perms)) {
|
||||||
|
perms = [perms]
|
||||||
|
} else {
|
||||||
|
perms = []
|
||||||
|
}
|
||||||
|
}
|
||||||
if (Array.isArray(perms)) {
|
if (Array.isArray(perms)) {
|
||||||
form.permissions = perms
|
// 过滤掉无效值(如 "[]")
|
||||||
} else if (perms === 'all' || !perms) {
|
form.permissions = perms.filter((p) => VALID_PERMS.includes(p))
|
||||||
form.permissions = []
|
|
||||||
} else if (typeof perms === 'string') {
|
|
||||||
form.permissions = [perms]
|
|
||||||
} else {
|
} else {
|
||||||
form.permissions = []
|
form.permissions = []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -797,11 +797,19 @@
|
|||||||
:account-id="account.id"
|
:account-id="account.id"
|
||||||
:initial-balance="account.balanceInfo"
|
:initial-balance="account.balanceInfo"
|
||||||
:platform="account.platform"
|
:platform="account.platform"
|
||||||
|
:query-mode="
|
||||||
|
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
|
||||||
|
? 'auto'
|
||||||
|
: 'local'
|
||||||
|
"
|
||||||
@error="(error) => handleBalanceError(account.id, error)"
|
@error="(error) => handleBalanceError(account.id, error)"
|
||||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||||
/>
|
/>
|
||||||
<div class="mt-1 text-xs">
|
<div class="mt-1 text-xs">
|
||||||
<button
|
<button
|
||||||
|
v-if="
|
||||||
|
!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')
|
||||||
|
"
|
||||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||||
@click="openBalanceScriptModal(account)"
|
@click="openBalanceScriptModal(account)"
|
||||||
>
|
>
|
||||||
@@ -1476,11 +1484,17 @@
|
|||||||
:account-id="account.id"
|
:account-id="account.id"
|
||||||
:initial-balance="account.balanceInfo"
|
:initial-balance="account.balanceInfo"
|
||||||
:platform="account.platform"
|
:platform="account.platform"
|
||||||
|
:query-mode="
|
||||||
|
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
|
||||||
|
? 'auto'
|
||||||
|
: 'local'
|
||||||
|
"
|
||||||
@error="(error) => handleBalanceError(account.id, error)"
|
@error="(error) => handleBalanceError(account.id, error)"
|
||||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||||
/>
|
/>
|
||||||
<div class="mt-1 text-xs">
|
<div class="mt-1 text-xs">
|
||||||
<button
|
<button
|
||||||
|
v-if="!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')"
|
||||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||||
@click="openBalanceScriptModal(account)"
|
@click="openBalanceScriptModal(account)"
|
||||||
>
|
>
|
||||||
@@ -2189,7 +2203,8 @@ const supportedUsagePlatforms = [
|
|||||||
'openai-responses',
|
'openai-responses',
|
||||||
'gemini',
|
'gemini',
|
||||||
'droid',
|
'droid',
|
||||||
'gemini-api'
|
'gemini-api',
|
||||||
|
'bedrock'
|
||||||
]
|
]
|
||||||
|
|
||||||
// 过期时间编辑弹窗状态
|
// 过期时间编辑弹窗状态
|
||||||
@@ -2533,7 +2548,7 @@ const closeAccountUsageModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 测试账户连通性相关函数
|
// 测试账户连通性相关函数
|
||||||
const supportedTestPlatforms = ['claude', 'claude-console']
|
const supportedTestPlatforms = ['claude', 'claude-console', 'bedrock']
|
||||||
|
|
||||||
const canTestAccount = (account) => {
|
const canTestAccount = (account) => {
|
||||||
return !!account && supportedTestPlatforms.includes(account.platform)
|
return !!account && supportedTestPlatforms.includes(account.platform)
|
||||||
|
|||||||
Reference in New Issue
Block a user