Compare commits

...

40 Commits

Author SHA1 Message Date
github-actions[bot]
0f5321b0ef chore: sync VERSION file with release v1.1.260 [skip ci] 2026-01-21 02:19:34 +00:00
shaw
c7d7bf47d6 fix: 更新claude账号oauth链接生成规则 2026-01-21 10:06:24 +08:00
Wesley Liddick
ebc30b6026 Merge pull request #906 from 0xRichardH/fix-bedrock-sse-stream-event [skip ci]
Fix bedrock sse stream event
2026-01-21 09:38:19 +08:00
Wesley Liddick
d5a7af2d7d Merge pull request #903 from RedwindA/main [skip ci]
feat(droid): add prompt_cache_retention and safety_identifier to fiel…
2026-01-21 09:37:19 +08:00
Richard Hao
81a3e26e27 fix: correct Bedrock SSE stream event format to match Claude API spec
- message_start: nest fields inside 'message' object with type: 'message'
- content_block_delta: add type field to data
- message_delta: add type field to data
- message_stop: remove usage field, just return type
- Extract usage from message_delta instead of message_stop
2026-01-18 11:38:38 +08:00
Richard Hao
64db4a270d fix: handle bedrock content block start/stop events 2026-01-18 10:58:11 +08:00
RedwindA
ca027ecb90 feat(droid): add prompt_cache_retention and safety_identifier to fieldsToRemove 2026-01-16 04:22:05 +08:00
github-actions[bot]
21e6944abb chore: sync VERSION file with release v1.1.259 [skip ci] 2026-01-15 03:07:53 +00:00
Wesley Liddick
4ea3d4830f Merge pull request #858 from zengqinglei/feature/gemini-retrieve-user-quota
feat: 添加 Gemini retrieveUserQuota 接口支持
2026-01-15 11:07:41 +08:00
github-actions[bot]
3000632d4e chore: sync VERSION file with release v1.1.258 [skip ci] 2026-01-15 01:25:03 +00:00
Wesley Liddick
9e3a4cf45a Merge pull request #899 from UncleJ-h/fix/remove-unused-heapdump
fix: remove unused heapdump dependency
2026-01-15 09:24:51 +08:00
UncleJ-h
eb992697b6 fix: remove unused heapdump dependency
The heapdump package was added in v1.1.257 but is not actually used anywhere in the codebase.

This causes build failures on platforms without Python (e.g., Zeabur) because heapdump requires node-gyp compilation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:43:45 +08:00
github-actions[bot]
35ab34d687 chore: sync VERSION file with release v1.1.257 [skip ci] 2026-01-14 07:41:16 +00:00
Wesley Liddick
bc4b050c69 Merge pull request #895 from wayfind/fix/memory-simple
fix(memory): reduce memory retention in request handling
2026-01-14 15:40:59 +08:00
root
189d53d793 style: fix ESLint prefer-const and formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:46:08 +00:00
root
b148537428 style: fix prettier formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:42:39 +00:00
root
9d1a451027 fix(memory): comprehensive req closure capture fixes
Additional fixes for memory leaks:
- Bedrock stream: extract _apiKeyIdBedrock, _rateLimitInfoBedrock, _requestBodyBedrock
- Non-stream requests: extract variables at block start
- Non-stream service calls: use extracted variables
- Non-stream usage recording: use extracted variables

All async callbacks now use local variables instead of req.* references,
preventing the entire request object (including large req.body with images)
from being retained by closures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:29:29 +00:00
root
ba815de08f fix(memory): extract req properties to avoid closure capturing entire request object
Problem:
- usageCallback closures referenced req.apiKey.id and req.rateLimitInfo
- This caused entire req object (including req.body with images) to be retained
- Base64 images in messages accumulated in memory (290 images = 26MB)

Solution:
- Extract needed properties before callback: _apiKeyId, _rateLimitInfo, etc.
- Closures now capture small local variables instead of entire req object
- Enables proper GC of request bodies after stream completion

Results verified via heapdump analysis:
- String memory: 144MB -> 24MB (-83%)
- Base64 images: 290 -> 0 (-100%)
- Heapdump size: 57MB -> 28MB (-51%)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:53:20 +00:00
root
b26027731e fix(memory): clear bodyString after req.write() to prevent closure capture
Additional memory optimizations:
- Set bodyString = null after req.write() in both stream and non-stream requests
- Use let instead of const for bodyString to allow nullifying
- Store non-stream originalBodyString in bodyStore to avoid closure capture
- Clean up bodyStore in finally block for non-stream requests

This prevents V8 closures (res.on handlers) from retaining large request
body strings until stream completion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:57:54 +00:00
root
f535b35a1c fix(memory): use bodyStore to avoid closure capturing request body
Problem:
- Stream response handlers (res.on) captured requestOptions in closures
- requestOptions contained originalBodyString (~800KB per request)
- These strings couldn't be GC'd until stream completed
- With concurrent requests, memory accumulated rapidly

Solution:
- Store request body strings in this.bodyStore Map with unique ID
- Pass only bodyStoreId in requestOptions (not the 800KB string)
- Closures capture small ID, not large string
- Clean up bodyStore on request completion (success/error/timeout)
- Extract needed values before closures to avoid capturing body object
2026-01-12 08:31:47 +00:00
github-actions[bot]
962e01b080 chore: sync VERSION file with release v1.1.256 [skip ci] 2026-01-10 08:56:29 +00:00
Wesley Liddick
fcc6ac4e22 Merge pull request #885 from junejuneli/feat/add_claude_aws_type
为 AWS Bedrock 账户添加 Bearer Token 认证支持
2026-01-10 16:56:13 +08:00
Wesley Liddick
3a03147ac9 Merge pull request #884 from moonsphere/fix_claude_auth [skip ci]
fix: claude subscription detection
2026-01-10 16:55:59 +08:00
juenjunli
94f239b56a feat: 添加 Claude 4.5 系列模型支持到 AWS Bedrock 映射表
为 AWS Bedrock 服务添加 Claude 4.5 系列模型的映射支持,包括:
- Claude 4.5 Opus (claude-opus-4-5)
- Claude 4.5 Sonnet (claude-sonnet-4-5)
- Claude 4.5 Haiku (claude-haiku-4-5)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 3.5 <noreply@anthropic.com>
2026-01-10 16:04:58 +08:00
juenjunli
b07873772c fix 2026-01-10 14:19:59 +08:00
juenjunli
549c95eb80 feat: 为 AWS Bedrock 账户添加 Bearer Token 认证支持
- 新增 credentialType 字段支持 access_key 和 bearer_token 两种认证方式
- 实现 Bedrock 账户的 testAccountConnection 方法,支持 SSE 流式测试
- 前端账户表单增加认证类型选择器,自动切换输入字段
- 前端测试模态框根据账户类型自动选择测试模型(Bearer Token 使用 Sonnet 4.5,Access Key 使用 Haiku)
- 改进测试接口错误处理,避免响应流重复关闭

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 14:13:36 +08:00
jett
b397954ea4 fix lint 2026-01-10 11:41:35 +08:00
jett
ed835d0c28 using TTL-based random suffix 2026-01-10 00:56:21 +08:00
jett
28b27e6a7b fix: claude subscription detection 2026-01-10 00:31:17 +08:00
github-actions[bot]
810fe9fe90 chore: sync VERSION file with release v1.1.255 [skip ci] 2026-01-09 07:55:15 +00:00
Wesley Liddick
141b07db78 Merge pull request #880 from DaydreamCoding/feature/fix-claude-openai-endpoint
fix: 修正Claude通过openaiClaudeRoutes访问失败问题
2026-01-09 15:55:01 +08:00
QTom
1dad810d15 refactor: 统一权限检查逻辑,使用 apiKeyService.hasPermission
将散布在各处的权限检查逻辑(permissions || 'all')统一为
apiKeyService.hasPermission() 方法调用,确保:

- 权限检查的唯一真实来源
- 避免默认值不一致导致的安全问题
- 便于后续权限模型的扩展和维护

影响文件:
- geminiHandlers.js: key-info 端点
- apiStats.js: user-stats 统计端点
- openaiClaudeRoutes.js: 权限校验辅助函数
- openaiRoutes.js: key-info 端点
2026-01-09 14:36:31 +08:00
QTom
4723328be4 fix: 添加 OpenAI 兼容路由对 Claude Console 账户的支持 2026-01-09 14:31:06 +08:00
曾庆雷
944ef096b3 fix: eslint 代码风格优化 2026-01-08 18:26:45 +08:00
github-actions[bot]
114e9facee chore: sync VERSION file with release v1.1.254 [skip ci] 2026-01-08 04:08:28 +00:00
shaw
e20ce86ad4 feat: Antigravity 账号注入 systemInstruction 和 requestType header
- 在 antigravityClient.js 的请求 header 中添加 requestType: agent
- 在 anthropicGeminiBridgeService.js 中为 antigravity 账号前置注入系统提示词
2026-01-08 12:07:50 +08:00
shaw
6caabb5444 update readme 2026-01-08 08:58:16 +08:00
shaw
b924c3c559 update readme 2026-01-08 08:33:09 +08:00
QTom
6682e0a982 fix: 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期)
防止非等待等待重置的账号刷新,导致大量错误消息通知问题
2026-01-08 00:05:47 +08:00
曾庆雷
18a493e805 feat: 添加 Gemini retrieveUserQuota 接口支持
支持 Gemini CLI 0.22.2+ 的配额查询功能
实现与现有 v1internal 接口一致的 projectId 处理逻辑
2025-12-24 22:48:27 +08:00
25 changed files with 3018 additions and 497 deletions

1058
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
# Claude Relay Service (Antigravity Edition) # Claude Relay Service
> [!CAUTION] > [!CAUTION]
> **Security Update**: v1.1.248 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.
@@ -8,117 +7,606 @@
<div align="center"> <div align="center">
This fork focuses on: [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
- Native compatibility for `claude` (Claude Code CLI) [![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
- Antigravity OAuth integration + path-based routing [![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/)
- Better stability for streaming (SSE) workloads [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
- Optional request/response dumps for debugging
**🔐 Self-hosted Claude API relay service with multi-account management**
[中文文档](README.md) • [Preview](https://demo.pincc.ai/admin-next/login) • [Telegram Channel](https://t.me/claude_relay_service)
</div>
--- ---
## Highlights ## ⭐ If You Find It Useful, Please Give It a Star!
- **Claude Code protocol compatibility**: `thoughtSignature` fallback + cache, tool_result passthrough, and message ordering fixes. > Open source is not easy, your Star is my motivation to continue updating 🚀
- **Antigravity OAuth**: account type `gemini-antigravity` with permission checks. > Join [Telegram Channel](https://t.me/claude_relay_service) for the latest updates
- **Path-based routing (Anthropic Messages API)**:
- `/api` -> Claude account pool (default)
- `/antigravity/api` -> Antigravity OAuth account pool
- `/gemini-cli/api` -> Gemini OAuth account pool
- **Stability**:
- Zombie stream watchdog (disconnect after 45s without valid data)
- Auto retry + account switching for Antigravity `429 Resource Exhausted` (streaming and non-streaming)
- **Observability**: JSONL dumps for request/response/tools/upstream (with size limit + rotation)
--- ---
## Quick Start ## ⚠️ Important Notice
### Requirements **Please read carefully before using this project:**
- Node.js 18+ (or Docker)
- Redis 6+/7+
### Docker Compose (recommended) 🚨 **Terms of Service Risk**: Using this project may violate Anthropic's terms of service. Please carefully read Anthropic's user agreement before use. All risks from using this project are borne by the user.
📖 **Disclaimer**: This project is for technical learning and research purposes only. The author is not responsible for any account bans, service interruptions, or other losses caused by using this project.
## 🤔 Is This Project Right for You?
- 🌍 **Regional Restrictions**: Can't directly access Claude Code service in your region?
- 🔒 **Privacy Concerns**: Worried about third-party mirror services logging or leaking your conversation content?
- 👥 **Cost Sharing**: Want to share Claude Code Max subscription costs with friends?
-**Stability Issues**: Third-party mirror sites often fail and are unstable, affecting efficiency?
If you have any of these concerns, this project might be suitable for you.
### Suitable Scenarios
**Cost Sharing with Friends**: 3-5 friends sharing Claude Code Max subscription, enjoying Opus freely
**Privacy Sensitive**: Don't want third-party mirrors to see your conversation content
**Technical Tinkering**: Have basic technical skills, willing to build and maintain yourself
**Stability Needs**: Need long-term stable Claude access, don't want to be restricted by mirror sites
**Regional Restrictions**: Cannot directly access Claude official service
### Unsuitable Scenarios
**Complete Beginner**: Don't understand technology at all, don't even know how to buy a server
**Occasional Use**: Use it only a few times a month, not worth the hassle
**Registration Issues**: Cannot register Claude account yourself
**Payment Issues**: No payment method to subscribe to Claude Code
**If you're just an ordinary user with low privacy requirements, just want to casually play around and quickly experience Claude, then choosing a mirror site you're familiar with would be more suitable.**
---
## 💭 Why Build Your Own?
### Potential Issues with Existing Mirror Sites
- 🕵️ **Privacy Risk**: Your conversation content is completely visible to others, forget about business secrets
- 🐌 **Performance Instability**: Slow when many people use it, often crashes during peak hours
- 💰 **Price Opacity**: Don't know the actual costs
### Benefits of Self-hosting
- 🔐 **Data Security**: All API requests only go through your own server, direct connection to Anthropic API
-**Controllable Performance**: Only a few of you using it, Max $200 package basically allows you to enjoy Opus freely
- 💰 **Cost Transparency**: Clear view of how many tokens used, specific costs calculated at official prices
- 📊 **Complete Monitoring**: Usage statistics, cost analysis, performance monitoring all available
---
## 🚀 Core Features
> 📸 **[Click to view interface preview](docs/preview.md)** - See detailed screenshots of the Web management interface
### Basic Features
-**Multi-account Management**: Add multiple Claude accounts for automatic rotation
-**Custom API Keys**: Assign independent keys to each person
-**Usage Statistics**: Detailed records of how many tokens each person used
### Advanced Features
- 🔄 **Smart Switching**: Automatically switch to next account when one has issues
- 🚀 **Performance Optimization**: Connection pooling, caching to reduce latency
- 📊 **Monitoring Dashboard**: Web interface to view all data
- 🛡️ **Security Control**: Access restrictions, rate limiting
- 🌐 **Proxy Support**: Support for HTTP/SOCKS5 proxies
---
## 📋 Deployment Requirements
### Hardware Requirements (Minimum Configuration)
- **CPU**: 1 core is sufficient
- **Memory**: 512MB (1GB recommended)
- **Storage**: 30GB available space
- **Network**: Access to Anthropic API (recommend US region servers)
- **Recommendation**: 2 cores 4GB is basically enough, choose network with good return routes to your country (to improve speed, recommend not using proxy or setting server IP for direct connection)
### Software Requirements
- **Node.js** 18 or higher
- **Redis** 6 or higher
- **Operating System**: Linux recommended
### Cost Estimation
- **Server**: Light cloud server, $5-10 per month
- **Claude Subscription**: Depends on how you share costs
- **Others**: Domain name (optional)
---
## 📦 Manual Deployment
### Step 1: Environment Setup
**Ubuntu/Debian users:**
```bash ```bash
cp .env.example .env # Install Node.js
cp config/config.example.js config/config.js curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Edit .env at least: # Install Redis
# JWT_SECRET=... (random string) sudo apt update
# ENCRYPTION_KEY=... (32-char random string) sudo apt install redis-server
sudo systemctl start redis-server
docker-compose up -d
``` ```
### Node (no Docker) **CentOS/RHEL users:**
```bash
# Install Node.js
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
sudo yum install -y nodejs
# Install Redis
sudo yum install redis
sudo systemctl start redis
```
### Step 2: Download and Configure
```bash ```bash
# Download project
git clone https://github.com/Wei-Shaw/claude-relay-service.git
cd claude-relay-service
# Install dependencies
npm install npm install
cp .env.example .env
# Copy configuration files (Important!)
cp config/config.example.js config/config.js cp config/config.example.js config/config.js
npm run setup cp .env.example .env
npm run service:start:daemon
``` ```
### Admin UI ### Step 3: Configuration File Setup
- URL: `http://<host>:3000/web` **Edit `.env` file:**
- Initial credentials: generated by `npm run setup` and saved to `data/init.json` (Docker users can also inspect container logs). ```bash
# Generate these two keys randomly, but remember them
JWT_SECRET=your-super-secret-key
ENCRYPTION_KEY=32-character-encryption-key-write-randomly
# Redis configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
```
**Edit `config/config.js` file:**
```javascript
module.exports = {
server: {
port: 3000, // Service port, can be changed
host: '0.0.0.0' // Don't change
},
redis: {
host: '127.0.0.1', // Redis address
port: 6379 // Redis port
},
// Keep other configurations as default
}
```
### Step 4: Start Service
```bash
# Initialize
npm run setup # Will randomly generate admin account password info, stored in data/init.json
# Start service
npm run service:start:daemon # Run in background (recommended)
# Check status
npm run service:status
```
--- ---
## Using with Claude Code (CLI) ## 🎮 Getting Started
### Antigravity pool (recommended) ### 1. Open Management Interface
Browser visit: `http://your-server-IP:3000/web`
Default admin account: Look in data/init.json
### 2. Add Claude Account
This step is quite important, requires OAuth authorization:
1. Click "Claude Accounts" tab
2. If you're worried about multiple accounts sharing 1 IP getting banned, you can optionally set a static proxy IP
3. Click "Add Account"
4. Click "Generate Authorization Link", will open a new page
5. Complete Claude login and authorization in the new page
6. Copy the returned Authorization Code
7. Paste to page to complete addition
**Note**: If you're in China, this step may require VPN.
### 3. Create API Key
Assign a key to each user:
1. Click "API Keys" tab
2. Click "Create New Key"
3. Give the key a name, like "Zhang San's Key"
4. Set usage limits (optional)
5. Save, note down the generated key
### 4. Start Using Claude Code and Gemini CLI
Now you can replace the official API with your own service:
**Claude Code Set Environment Variables:**
Default uses standard Claude account pool:
```bash
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"
```
**VSCode Claude Plugin Configuration:**
If using VSCode Claude plugin, configure in `~/.claude/config.json`:
```json
{
"primaryApiKey": "crs"
}
```
If the file doesn't exist, create it manually. Windows users path is `C:\Users\YourUsername\.claude\config.json`.
**Gemini CLI Set Environment Variables:**
**Method 1 (Recommended): Via Gemini Assist API**
Each account enjoys 1000 requests per day, 60 requests per minute free quota.
```bash
CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain
GOOGLE_CLOUD_ACCESS_TOKEN="API key created in the backend"
GOOGLE_GENAI_USE_GCA="true"
GEMINI_MODEL="gemini-2.5-pro"
```
> **Note**: gemini-cli console will show `Failed to fetch user info: 401 Unauthorized`, but this doesn't affect usage.
**Method 2: Via Gemini API**
Very limited free quota, easily triggers 429 errors.
```bash
GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain
GEMINI_API_KEY="API key created in the backend"
GEMINI_MODEL="gemini-2.5-pro"
```
**Use Claude Code:**
```bash ```bash
export ANTHROPIC_BASE_URL="http://<host>:3000/antigravity/api/"
export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx"
export ANTHROPIC_MODEL="claude-opus-4-5"
claude claude
``` ```
### Gemini pool **Use Gemini CLI:**
```bash ```bash
export ANTHROPIC_BASE_URL="http://<host>:3000/gemini-cli/api/" gemini
export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx"
export ANTHROPIC_MODEL="gemini-2.5-pro"
claude
```
### Standard Claude pool
```bash
export ANTHROPIC_BASE_URL="http://<host>:3000/api/"
export ANTHROPIC_AUTH_TOKEN="cr_xxxxxxxxxxxx"
claude
``` ```
--- ---
## Antigravity Quota & Models ## 🔧 Daily Maintenance
- Quota display: in Admin UI -> Accounts -> `gemini-antigravity` -> click **Test/Refresh**. ### Service Management
- Dynamic models list:
- Anthropic/Claude Code routing: `GET /antigravity/api/v1/models` (proxies Antigravity `fetchAvailableModels`) ```bash
- OpenAI-compatible routing: `GET /openai/gemini/models` (or `GET /openai/gemini/v1/models`) # Check service status
npm run service:status
# View logs
npm run service:logs
# Restart service
npm run service:restart:daemon
# Stop service
npm run service:stop
```
### Monitor Usage
- **Web Interface**: `http://your-domain:3000/web` - View usage statistics
- **Health Check**: `http://your-domain:3000/health` - Confirm service is normal
- **Log Files**: Various log files in `logs/` directory
### Upgrade Guide
When a new version is released, follow these steps to upgrade the service:
```bash
# 1. Navigate to project directory
cd claude-relay-service
# 2. Pull latest code
git pull origin main
# If you encounter package-lock.json conflicts, use the remote version
git checkout --theirs package-lock.json
git add package-lock.json
# 3. Install new dependencies (if any)
npm install
# 4. Restart service
npm run service:restart:daemon
# 5. Check service status
npm run service:status
```
**Important Notes:**
- Before upgrading, it's recommended to backup important configuration files (.env, config/config.js)
- Check the changelog to understand if there are any breaking changes
- Database structure changes will be migrated automatically if needed
### Common Issue Resolution
**Can't connect to Redis?**
```bash
# Check if Redis is running
redis-cli ping
# Should return PONG
```
**OAuth authorization failed?**
- Check if proxy settings are correct
- Ensure normal access to claude.ai
- Clear browser cache and retry
**API request failed?**
- Check if API Key is correct
- View log files for error information
- Confirm Claude account status is normal
--- ---
## Debug Dumps (optional) ## 🛠️ Advanced Usage
See `.env.example` for the full list. Common toggles: ### Reverse Proxy Deployment Guide
- `ANTHROPIC_DEBUG_REQUEST_DUMP=true` For production environments, it is recommended to use a reverse proxy for automatic HTTPS, security headers, and performance optimization. Two common solutions are provided below: **Caddy** and **Nginx Proxy Manager (NPM)**.
- `ANTHROPIC_DEBUG_RESPONSE_DUMP=true`
- `ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true`
- `ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true`
- `DUMP_MAX_FILE_SIZE_BYTES=10485760`
--- ---
## License ## Caddy Solution
This project is licensed under the [MIT License](LICENSE). Caddy is a web server that automatically manages HTTPS certificates, with simple configuration and excellent performance, ideal for deployments without Docker environments.
**1. Install Caddy**
```bash
# Ubuntu/Debian
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
# CentOS/RHEL/Fedora
sudo yum install yum-plugin-copr
sudo yum copr enable @caddy/caddy
sudo yum install caddy
```
**2. Caddy Configuration**
Edit `/etc/caddy/Caddyfile`:
```caddy
your-domain.com {
# Reverse proxy to local service
reverse_proxy 127.0.0.1:3000 {
# Support streaming responses or SSE
flush_interval -1
# Pass real IP
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Long read/write timeout configuration
transport http {
read_timeout 300s
write_timeout 300s
dial_timeout 30s
}
}
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
-Server
}
}
```
**3. Start Caddy**
```bash
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl start caddy
sudo systemctl enable caddy
sudo systemctl status caddy
```
**4. Service Configuration**
Since Caddy automatically manages HTTPS, you can restrict the service to listen locally only:
```javascript
// config/config.js
module.exports = {
server: {
port: 3000,
host: '127.0.0.1' // Listen locally only
}
}
```
**Caddy Features**
* 🔒 Automatic HTTPS with zero-configuration certificate management
* 🛡️ Secure default configuration with modern TLS suites
* ⚡ HTTP/2 and streaming support
* 🔧 Concise configuration files, easy to maintain
---
## Nginx Proxy Manager (NPM) Solution
Nginx Proxy Manager manages reverse proxies and HTTPS certificates through a graphical interface, deployed as a Docker container.
**1. Create a New Proxy Host in NPM**
Configure the Details as follows:
| Item | Setting |
| --------------------- | ------------------------ |
| Domain Names | relay.example.com |
| Scheme | http |
| Forward Hostname / IP | 192.168.0.1 (docker host IP) |
| Forward Port | 3000 |
| Block Common Exploits | ☑️ |
| Websockets Support | ❌ **Disable** |
| Cache Assets | ❌ **Disable** |
| Access List | Publicly Accessible |
> Note:
> - Ensure Claude Relay Service **listens on `0.0.0.0`, container IP, or host IP** to allow NPM internal network connections.
> - **Websockets Support and Cache Assets must be disabled**, otherwise SSE / streaming responses will fail.
**2. Custom locations**
No content needed, keep it empty.
**3. SSL Settings**
* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) or existing certificate
* ☑️ **Force SSL**
* ☑️ **HTTP/2 Support**
* ☑️ **HSTS Enabled**
* ☑️ **HSTS Subdomains**
**4. Advanced Configuration**
Add the following to Custom Nginx Configuration:
```nginx
# Pass real user IP
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Support WebSocket / SSE streaming
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
# Long connection / timeout settings (for AI chat streaming)
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 30s;
# ---- Security Settings ----
# Strict HTTPS policy (HSTS)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Block clickjacking and content sniffing
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
# Referrer / Permissions restriction policies
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Hide server information (equivalent to Caddy's `-Server`)
proxy_hide_header Server;
# ---- Performance Tuning ----
# Disable proxy caching for real-time responses (SSE / Streaming)
proxy_cache_bypass $http_upgrade;
proxy_no_cache $http_upgrade;
proxy_request_buffering off;
```
**5. Launch and Verify**
* After saving, wait for NPM to automatically request Let's Encrypt certificate (if applicable).
* Check Proxy Host status in Dashboard to ensure it shows "Online".
* Visit `https://relay.example.com`, if the green lock icon appears, HTTPS is working properly.
**NPM Features**
* 🔒 Automatic certificate application and renewal
* 🔧 Graphical interface for easy multi-service management
* ⚡ Native HTTP/2 / HTTPS support
* 🚀 Ideal for Docker container deployments
---
Both solutions are suitable for production deployment. If you use a Docker environment, **Nginx Proxy Manager is more convenient**; if you want to keep software lightweight and automated, **Caddy is a better choice**.
---
## 💡 Usage Recommendations
### Account Management
- **Regular Checks**: Check account status weekly, handle exceptions promptly
- **Reasonable Allocation**: Can assign different API keys to different people, analyze usage based on different API keys
### Security Recommendations
- **Use HTTPS**: Strongly recommend using Caddy reverse proxy (automatic HTTPS) to ensure secure data transmission
- **Regular Backups**: Back up important configurations and data
- **Monitor Logs**: Regularly check exception logs
- **Update Keys**: Regularly change JWT and encryption keys
- **Firewall Settings**: Only open necessary ports (80, 443), hide direct service ports
---
## 🆘 What to Do When You Encounter Problems?
### Self-troubleshooting
1. **Check Logs**: Log files in `logs/` directory
2. **Check Configuration**: Confirm configuration files are set correctly
3. **Test Connectivity**: Use curl to test if API is normal
4. **Restart Service**: Sometimes restarting fixes it
### Seeking Help
- **GitHub Issues**: Submit detailed error information
- **Read Documentation**: Carefully read error messages and documentation
- **Community Discussion**: See if others have encountered similar problems
---
## 📄 License
This project uses the [MIT License](LICENSE).
---
<div align="center">
**⭐ If you find it useful, please give it a Star, this is the greatest encouragement to the author!**
**🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions**
</div>

View File

@@ -1 +1 @@
1.1.253 1.1.260

20
package-lock.json generated
View File

@@ -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",

View File

@@ -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,

View File

@@ -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 }) // 错误已在服务层处理,这里仅做日志记录
} }
}) })

View File

@@ -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) {

View File

@@ -416,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(
@@ -470,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,
@@ -501,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(
@@ -556,7 +570,7 @@ async function handleMessagesRequest(req, res) {
apiKeyService apiKeyService
.recordUsageWithDetails( .recordUsageWithDetails(
req.apiKey.id, _apiKeyIdConsole,
usageObject, usageObject,
model, model,
usageAccountId, usageAccountId,
@@ -567,7 +581,7 @@ async function handleMessagesRequest(req, res) {
}) })
queueRateLimitUpdate( queueRateLimitUpdate(
req.rateLimitInfo, _rateLimitInfoConsole,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,
@@ -593,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) {
@@ -600,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
) )
@@ -611,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,
@@ -642,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(
@@ -696,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,
@@ -737,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 事件以追踪连接状态变化
@@ -919,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))
} }
} }
@@ -936,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转发服务
@@ -948,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') {
@@ -964,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
) )
// 构建标准响应格式 // 构建标准响应格式
@@ -996,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
) )
} }
@@ -1049,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,
@@ -1066,7 +1108,7 @@ async function handleMessagesRequest(req, res) {
) )
await queueRateLimitUpdate( await queueRateLimitUpdate(
req.rateLimitInfo, _rateLimitInfoNonStream,
{ {
inputTokens, inputTokens,
outputTokens, outputTokens,

View File

@@ -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',

View File

@@ -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 定义此路由)

View File

@@ -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`
) )
} }

View File

@@ -274,7 +274,9 @@ const handleResponses = async (req, res) => {
'text_formatting', 'text_formatting',
'truncation', 'truncation',
'text', 'text',
'service_tier' 'service_tier',
'prompt_cache_retention',
'safety_identifier'
] ]
fieldsToRemove.forEach((field) => { fieldsToRemove.forEach((field) => {
delete req.body[field] delete req.body[field]
@@ -904,7 +906,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:

View File

@@ -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) {
@@ -275,10 +283,27 @@ class AccountBalanceService {
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') {

View File

@@ -80,6 +80,15 @@ const ANTIGRAVITY_TOOL_FOLLOW_THROUGH_PROMPT =
// 工具报错时注入的 system prompt提示模型不要中断 // 工具报错时注入的 system prompt提示模型不要中断
const TOOL_ERROR_CONTINUE_PROMPT = const TOOL_ERROR_CONTINUE_PROMPT =
'Tool calls may fail (e.g., missing prerequisites). When a tool result indicates an error, do not stop: briefly explain the cause and continue with an alternative approach or the remaining steps.' 'Tool calls may fail (e.g., missing prerequisites). When a tool result indicates an error, do not stop: briefly explain the cause and continue with an alternative approach or the remaining steps.'
// Antigravity 账号前置注入的系统提示词
const ANTIGRAVITY_SYSTEM_INSTRUCTION_PREFIX = `<identity>
You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.
You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.
The USER will send you requests, which you must always prioritize addressing. Along with each USER request, we will attach additional metadata about their current state, such as what files they have open and where their cursor is.
This information may or may not be relevant to the coding task, it is up for you to decide.
</identity>
<communication_style>
- **Proactiveness**. As an agent, you are allowed to be proactive, but only in the course of completing the user's task. For example, if the user asks you to add a new component, you can edit the code, verify build and test statuses, and take any other obvious follow-up actions, such as performing additional research. However, avoid surprising the user. For example, if the user asks HOW to approach something, you should answer their question and instead of jumping into editing a file.</communication_style>`
// ============================================================================ // ============================================================================
// 辅助函数:基础工具 // 辅助函数:基础工具
@@ -1362,9 +1371,12 @@ function buildGeminiRequestFromAnthropic(
generationConfig generationConfig
} }
if (systemParts.length > 0) { // antigravity: 前置注入系统提示词
geminiRequestBody.systemInstruction = if (vendor === 'antigravity') {
vendor === 'antigravity' ? { role: 'user', parts: systemParts } : { parts: systemParts } const allParts = [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION_PREFIX }, ...systemParts]
geminiRequestBody.systemInstruction = { role: 'user', parts: allParts }
} else if (systemParts.length > 0) {
geminiRequestBody.systemInstruction = { parts: systemParts }
} }
const geminiTools = convertAnthropicToolsToGeminiTools(body.tools, { vendor }) const geminiTools = convertAnthropicToolsToGeminiTools(body.tools, { vendor })

View File

@@ -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'
} }
} }

View File

@@ -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 - 账户对象

View File

@@ -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'
) )
} }
} }
@@ -339,8 +343,8 @@ class BedrockRelayService {
res.write(`event: ${claudeEvent.type}\n`) res.write(`event: ${claudeEvent.type}\n`)
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`) res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
// 提取使用统计 // 提取使用统计 (usage is reported in message_delta per Claude API spec)
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) { if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
totalUsage = claudeEvent.data.usage totalUsage = claudeEvent.data.usage
} }
@@ -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',
@@ -560,8 +576,10 @@ class BedrockRelayService {
return { return {
type: 'message_start', type: 'message_start',
data: { data: {
type: 'message', type: 'message_start',
message: {
id: `msg_${Date.now()}_bedrock`, id: `msg_${Date.now()}_bedrock`,
type: 'message',
role: 'assistant', role: 'assistant',
content: [], content: [],
model: this.defaultModel, model: this.defaultModel,
@@ -571,21 +589,45 @@ class BedrockRelayService {
} }
} }
} }
}
if (bedrockChunk.type === 'content_block_start') {
return {
type: 'content_block_start',
data: {
type: 'content_block_start',
index: bedrockChunk.index || 0,
content_block: bedrockChunk.content_block || { type: 'text', text: '' }
}
}
}
if (bedrockChunk.type === 'content_block_delta') { if (bedrockChunk.type === 'content_block_delta') {
return { return {
type: 'content_block_delta', type: 'content_block_delta',
data: { data: {
type: 'content_block_delta',
index: bedrockChunk.index || 0, index: bedrockChunk.index || 0,
delta: bedrockChunk.delta || {} delta: bedrockChunk.delta || {}
} }
} }
} }
if (bedrockChunk.type === 'content_block_stop') {
return {
type: 'content_block_stop',
data: {
type: 'content_block_stop',
index: bedrockChunk.index || 0
}
}
}
if (bedrockChunk.type === 'message_delta') { if (bedrockChunk.type === 'message_delta') {
return { return {
type: 'message_delta', type: 'message_delta',
data: { data: {
type: 'message_delta',
delta: bedrockChunk.delta || {}, delta: bedrockChunk.delta || {},
usage: bedrockChunk.usage || {} usage: bedrockChunk.usage || {}
} }
@@ -596,7 +638,7 @@ class BedrockRelayService {
return { return {
type: 'message_stop', type: 'message_stop',
data: { data: {
usage: bedrockChunk.usage || {} type: 'message_stop'
} }
} }
} }

View File

@@ -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()
}) })
} }

View File

@@ -360,7 +360,10 @@ class RateLimitCleanupService {
/** /**
* 主动刷新 Claude 账户 Token防止等待重置期间 Token 过期) * 主动刷新 Claude 账户 Token防止等待重置期间 Token 过期)
* 仅对等待重置schedulable=false且 Token 即将过期的账户执行刷新 * 仅对因限流/配额限制而等待重置的账户执行刷新
* - 429 限流账户rateLimitAutoStopped=true
* - 5小时限制自动停止账户fiveHourAutoStopped=true
* 不处理错误状态账户error/temp_error
*/ */
async proactiveRefreshClaudeTokens(result) { async proactiveRefreshClaudeTokens(result) {
try { try {
@@ -381,9 +384,13 @@ class RateLimitCleanupService {
continue continue
} }
// 3. 【优化】仅处理等待重置的账户schedulable=false // 3. 【优化】仅处理因限流/配额限制而等待重置的账户
// 正常调度的账户会在请求时自动刷新,无需主动刷新 // 正常调度的账户会在请求时自动刷新,无需主动刷新
if (account.schedulable !== 'false') { // 错误状态账户的 Token 可能已失效,刷新也会失败
const isWaitingForReset =
account.rateLimitAutoStopped === 'true' || // 429 限流
account.fiveHourAutoStopped === 'true' // 5小时限制自动停止
if (!isWaitingForReset) {
continue continue
} }

View File

@@ -13,8 +13,8 @@ const OAUTH_CONFIG = {
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize', AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token', TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback', REDIRECT_URI: 'https://platform.claude.com/oauth/code/callback',
SCOPES: 'org:create_api_key user:profile user:inference', SCOPES: 'org:create_api_key user:profile user:inference user:sessions:claude_code',
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限 SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
} }
@@ -35,6 +35,7 @@ function generateState() {
/** /**
* 生成随机的 code verifierPKCE * 生成随机的 code verifierPKCE
* 符合 RFC 7636 标准32字节随机数 → base64url编码 → 43字符
* @returns {string} base64url 编码的随机字符串 * @returns {string} base64url 编码的随机字符串
*/ */
function generateCodeVerifier() { function generateCodeVerifier() {

View File

@@ -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",

View File

@@ -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
} }

View File

@@ -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'
}
} }
} }
) )

View File

@@ -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] || '未知平台')

View File

@@ -2203,7 +2203,8 @@ const supportedUsagePlatforms = [
'openai-responses', 'openai-responses',
'gemini', 'gemini',
'droid', 'droid',
'gemini-api' 'gemini-api',
'bedrock'
] ]
// 过期时间编辑弹窗状态 // 过期时间编辑弹窗状态
@@ -2547,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)